From 47159e85af14d40d613dfd5838a448968a466dfd Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 2 May 2026 00:53:16 -0700 Subject: [PATCH] fix(studio): drag hovered canvas targets immediately --- packages/studio/src/App.tsx | 13 +-- .../src/components/editor/DomEditOverlay.tsx | 92 +++++++++++++++---- 2 files changed, 81 insertions(+), 24 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 2ba0e2bf8c..b9568b0406 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -2339,14 +2339,14 @@ export function StudioApp() { (e: React.PointerEvent, options?: { preferClipAncestor?: boolean }) => { if (captionEditMode) { updateDomEditHoverSelection(null); - return; + return null; } - updateDomEditHoverSelection( - resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, { - preferClipAncestor: options?.preferClipAncestor ?? false, - }), - ); + const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, { + preferClipAncestor: options?.preferClipAncestor ?? false, + }); + updateDomEditHoverSelection(nextSelection); + return nextSelection; }, [captionEditMode, resolveDomSelectionFromPreviewPoint, updateDomEditHoverSelection], ); @@ -3153,6 +3153,7 @@ export function StudioApp() { onCanvasMouseDown={handlePreviewCanvasMouseDown} onCanvasPointerMove={handlePreviewCanvasPointerMove} onCanvasPointerLeave={handlePreviewCanvasPointerLeave} + onSelectionChange={applyDomSelection} onBlockedMove={handleBlockedDomMove} onPathOffsetCommit={handleDomPathOffsetCommit} onBoxSizeCommit={handleDomBoxSizeCommit} diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index 51b2e10f51..47ae487510 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -46,8 +46,9 @@ interface DomEditOverlayProps { onCanvasPointerMove: ( event: React.PointerEvent, options?: { preferClipAncestor?: boolean }, - ) => void; + ) => DomEditSelection | null; onCanvasPointerLeave: () => void; + onSelectionChange: (selection: DomEditSelection, options?: { revealPanel?: boolean }) => void; onBlockedMove: (selection: DomEditSelection) => void; onPathOffsetCommit: ( selection: DomEditSelection, @@ -276,6 +277,7 @@ export function resolveDomEditRotationGesture(input: { interface GestureState { kind: GestureKind; mode: "path-offset" | "box-size" | "rotation"; + selection: DomEditSelection; startX: number; startY: number; centerX: number; @@ -316,6 +318,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ onCanvasMouseDown, onCanvasPointerMove, onCanvasPointerLeave, + onSelectionChange, onBlockedMove, onPathOffsetCommit, onBoxSizeCommit, @@ -328,6 +331,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ const gestureRef = useRef(null); const blockedMoveRef = useRef(null); const suppressNextBoxClickRef = useRef(false); + const suppressNextOverlayMouseDownRef = useRef(false); const rafPausedRef = useRef(false); const resolvedElementRef = useRef<{ key: string; element: HTMLElement } | null>(null); const resolvedHoverElementRef = useRef<{ key: string; element: HTMLElement } | null>(null); @@ -352,6 +356,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({ onCanvasPointerMoveRef.current = onCanvasPointerMove; const onCanvasPointerLeaveRef = useRef(onCanvasPointerLeave); onCanvasPointerLeaveRef.current = onCanvasPointerLeave; + const onSelectionChangeRef = useRef(onSelectionChange); + onSelectionChangeRef.current = onSelectionChange; useMountEffect(() => { let frame = 0; @@ -457,19 +463,24 @@ export const DomEditOverlay = memo(function DomEditOverlay({ }`; }, [selection]); - const startGesture = (kind: GestureKind, e: React.PointerEvent) => { - const sel = selectionRef.current; - const rect = overlayRectRef.current; + const startGesture = ( + kind: GestureKind, + e: React.PointerEvent, + options?: { selection?: DomEditSelection; rect?: OverlayRect | null }, + ) => { + const sel = options?.selection ?? selectionRef.current; + const rect = options?.rect ?? overlayRectRef.current; const box = boxRef.current; const overlayEl = overlayRef.current; - if (!sel || !rect || !box) return; + if (!sel || !rect) return false; + if (kind !== "drag" && !box) return false; const mode: GestureState["mode"] = kind === "rotate" ? "rotation" : kind === "drag" ? "path-offset" : "box-size"; - if (kind === "drag" && !sel.capabilities.canApplyManualOffset) return; - if (kind === "resize" && !sel.capabilities.canApplyManualSize) return; - if (kind === "rotate" && !sel.capabilities.canApplyManualRotation) return; + if (kind === "drag" && !sel.capabilities.canApplyManualOffset) return false; + if (kind === "resize" && !sel.capabilities.canApplyManualSize) return false; + if (kind === "rotate" && !sel.capabilities.canApplyManualRotation) return false; if (kind === "resize" && (!Number.isFinite(rect.width) || !Number.isFinite(rect.height))) { - return; + return false; } const offset = readStudioPathOffset(sel.element); const size = readStudioBoxSize(sel.element); @@ -483,13 +494,14 @@ export const DomEditOverlay = memo(function DomEditOverlay({ e.preventDefault(); e.stopPropagation(); - (e.target as HTMLElement).setPointerCapture(e.pointerId); + e.currentTarget.setPointerCapture(e.pointerId); rafPausedRef.current = true; gestureRef.current = { kind, mode, + selection: sel, startX: e.clientX, startY: e.clientY, centerX, @@ -510,11 +522,12 @@ export const DomEditOverlay = memo(function DomEditOverlay({ editScaleY: rect.editScaleY, manualEditDragToken, }; + return true; }; const onPointerMove = (e: React.PointerEvent) => { const g = gestureRef.current; - const sel = selectionRef.current; + const sel = g?.selection ?? selectionRef.current; const box = boxRef.current; const blockedMove = blockedMoveRef.current; if (!blockedMove && !g) { @@ -532,7 +545,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ return; } - if (!g || !sel || !box) return; + if (!g || !sel) return; const dx = e.clientX - g.startX; const dy = e.clientY - g.startY; @@ -555,8 +568,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({ if (g.kind === "drag") { const nextBoxLeft = g.originLeft + dx; const nextBoxTop = g.originTop + dy; - box.style.left = `${nextBoxLeft}px`; - box.style.top = `${nextBoxTop}px`; + if (box) { + box.style.left = `${nextBoxLeft}px`; + box.style.top = `${nextBoxTop}px`; + } applyStudioPathOffsetDraft( sel.element, resolveDomEditPathOffsetGesture({ @@ -569,6 +584,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ }), ); } else { + if (!box) return; const nextSize = resolveDomEditResizeGesture({ originWidth: g.originWidth, originHeight: g.originHeight, @@ -588,7 +604,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ const onPointerUp = (e: React.PointerEvent) => { const g = gestureRef.current; - const sel = selectionRef.current; + const sel = g?.selection ?? selectionRef.current; const box = boxRef.current; blockedMoveRef.current = null; if (!g || !sel) { @@ -684,11 +700,51 @@ export const DomEditOverlay = memo(function DomEditOverlay({ // box stops propagation for drag gestures, but clicks on the transparent overlay // area outside the box pass through to the iframe pick logic. const handleOverlayMouseDown = (event: React.MouseEvent) => { + if (suppressNextOverlayMouseDownRef.current) { + suppressNextOverlayMouseDownRef.current = false; + event.preventDefault(); + event.stopPropagation(); + return; + } const target = event.target as HTMLElement | null; if (target?.closest('[data-dom-edit-selection-box="true"]')) return; onCanvasMouseDown(event, { preferClipAncestor: false }); }; + const handleOverlayPointerDown = (event: React.PointerEvent) => { + if (!allowCanvasMovement || event.button !== 0) return; + const target = event.target as HTMLElement | null; + if (target?.closest('[data-dom-edit-selection-box="true"]')) return; + + const hoverCandidate = onCanvasPointerMoveRef.current(event, { + preferClipAncestor: false, + }); + const candidate = hoverCandidate ?? hoverSelectionRef.current; + if (!candidate?.capabilities.canApplyManualOffset) return; + + const overlayEl = overlayRef.current; + const iframe = iframeRef.current; + const candidateRect = + overlayEl && iframe ? toOverlayRect(overlayEl, iframe, candidate.element) : null; + if (!candidateRect) return; + + suppressNextOverlayMouseDownRef.current = true; + selectionRef.current = candidate; + overlayRectRef.current = candidateRect; + hoverRectRef.current = null; + setOverlayRect(candidateRect); + setHoverRect(null); + const didStartGesture = startGesture("drag", event, { + selection: candidate, + rect: candidateRect, + }); + if (!didStartGesture) { + suppressNextOverlayMouseDownRef.current = false; + return; + } + onSelectionChangeRef.current(candidate); + }; + // Click on the selection box itself → re-pick the element under the pointer. // This lets you click a child element even when a parent is selected, because // the click coordinates are forwarded to the iframe's element picker. @@ -704,7 +760,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ const clearPointerState = () => { const g = gestureRef.current; - const sel = selectionRef.current; + const sel = g?.selection ?? selectionRef.current; if (g?.mode === "path-offset" && sel) { restoreStudioPathOffset(sel.element, g.initialPathOffset); endStudioManualEditGesture(sel.element, g.manualEditDragToken); @@ -724,12 +780,12 @@ export const DomEditOverlay = memo(function DomEditOverlay({ return (
focusDomEditOverlayElement(event.currentTarget)} + onPointerDown={handleOverlayPointerDown} onMouseDown={handleOverlayMouseDown} onPointerMove={onPointerMove} onPointerLeave={() => onCanvasPointerLeaveRef.current()} @@ -740,7 +796,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({