Skip to content

Commit 0392a98

Browse files
committed
fix(studio): address #1610 review — scope dblclick to pan-surface, kind-aware geometry guard, gate createMode, screen-space drag threshold
1 parent 6953ebb commit 0392a98

1 file changed

Lines changed: 64 additions & 19 deletions

File tree

packages/studio/src/components/editor/MotionPathOverlay.tsx

Lines changed: 64 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ type DragState = {
4545
};
4646

4747
const NODE_PX = 6; // node radius in screen pixels (kept constant across zoom)
48+
// Click-vs-drag cutoff in SCREEN pixels. Below this the pointer-up is a click
49+
// (select the keyframe); at or above it the gesture commits a move. Screen-space
50+
// (not composition px) so it behaves identically at any zoom.
51+
const DRAG_THRESHOLD_PX = 3;
4852

4953
/** The element's layout-home center in composition coordinates. GSAP x/y (and
5054
* motionPath coords) are offsets from this point, so the overlay adds it to
@@ -118,11 +122,21 @@ function useMotionPathData(
118122
): {
119123
rect: Rect | null;
120124
geometry: MotionPathGeometry | null;
125+
geometryResolved: boolean;
121126
visibleInPreview: boolean;
122127
home: { x: number; y: number } | null;
123128
} {
124129
const [rect, setRect] = useState<Rect | null>(null);
125130
const [geometry, setGeometry] = useState<MotionPathGeometry | null>(null);
131+
// Which selector the current `geometry` was read for. Compared against the live
132+
// `selector` DURING render so the "resolved" flag is correct on the very first
133+
// render after a selection change — before the polling effect runs. Until the
134+
// first read for the new selector lands, `geometry === null` means "unknown",
135+
// not "no path", so the create-mode hint must stay hidden (otherwise it flashes
136+
// over an element that already has a path during the new-selection → first-poll
137+
// gap). A ref (not state) avoids an extra render and a stale-flag window.
138+
const resolvedForRef = useRef<string | null>(null);
139+
const geometryResolved = resolvedForRef.current === selector;
126140
// Whether the target element is actually painted on screen — the path hides when
127141
// it isn't (e.g. covered by a later scene), matching the selection overlay.
128142
const [visibleInPreview, setVisibleInPreview] = useState(true);
@@ -203,14 +217,21 @@ function useMotionPathData(
203217
const recompute = () => {
204218
const read = readRuntimeKeyframes(iframeRef.current, selector);
205219
const next = buildMotionPathGeometry(read);
206-
setGeometry((prev) => (prev?.points === next?.points ? prev : next));
220+
// Compare the discriminator too, not just coords: a degenerate transition
221+
// (linear ⇄ arc with identical points) must re-render so the geometry kind
222+
// — which drives node refs, add/remove affordances and commit routing —
223+
// stays in sync. Points alone would keep the stale `kind`.
224+
setGeometry((prev) =>
225+
prev?.points === next?.points && prev?.kind === next?.kind ? prev : next,
226+
);
227+
resolvedForRef.current = selector;
207228
};
208229
recompute();
209230
const id = window.setInterval(recompute, 250);
210231
return () => window.clearInterval(id);
211232
}, [selector, iframeRef]);
212233

213-
return { rect, geometry, visibleInPreview, home };
234+
return { rect, geometry, geometryResolved, visibleInPreview, home };
214235
}
215236

216237
/**
@@ -236,7 +257,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
236257
handleGsapRemoveKeyframe,
237258
handleGsapDeleteAllForElement,
238259
} = useDomEditContext();
239-
const { rect, geometry, visibleInPreview, home } = useMotionPathData(
260+
const { rect, geometry, geometryResolved, visibleInPreview, home } = useMotionPathData(
240261
iframeRef,
241262
selectorFor(selection),
242263
);
@@ -252,35 +273,49 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
252273
const dragRef = useRef<DragState | null>(null);
253274

254275
// Create mode: a selected element with no positional motion. A double-click on
255-
// the canvas authors a new motionPath from the element to that point.
256-
const createMode = !geometry && Boolean(selection?.element) && !isPlaying;
276+
// the canvas authors a new motionPath from the element to that point. Gated on
277+
// `geometryResolved` so a fresh selection's hint never flashes before the first
278+
// runtime read confirms the element truly has no path (see useMotionPathData).
279+
const createMode = geometryResolved && !geometry && Boolean(selection?.element) && !isPlaying;
280+
const createSelector = createMode ? selectorFor(selection) : null;
281+
const compW = compositionSize?.width ?? null;
257282
// fallow-ignore-next-line complexity
258283
useEffect(() => {
259-
if (!createMode || !selection?.element || !compositionSize) return;
260-
const targetSelector = selectorFor(selection);
261-
if (!targetSelector) return;
284+
if (!createSelector || !compW) return;
285+
// Scope the create-mode listener to the preview pan-surface, not `window`: the
286+
// surface is the only region a destination double-click is meaningful in, so
287+
// this keeps the handler off the side panels and avoids leaking a global
288+
// listener. Deps are primitives (selector string + composition dims) and the
289+
// memoized `commitMutation` (stable via DomEditProvider's ref-backed
290+
// useCallback), so the effect re-runs only on a genuine create-context change
291+
// — never on an unrelated parent re-render, so no duplicate handlers.
292+
const iframe = iframeRef.current;
293+
const surface =
294+
(iframe?.ownerDocument?.querySelector("[data-preview-pan-surface]") as HTMLElement | null) ??
295+
null;
296+
const target: HTMLElement | Window = surface ?? window;
262297
// fallow-ignore-next-line complexity
263298
const onDbl = (e: MouseEvent) => {
264-
const iframe = iframeRef.current;
265-
if (!iframe || !hasMotionPathPlugin(iframe)) return;
266-
const r = iframe.getBoundingClientRect();
299+
const frame = iframeRef.current;
300+
if (!frame || !hasMotionPathPlugin(frame)) return;
301+
const r = frame.getBoundingClientRect();
267302
if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) {
268303
return;
269304
}
270305
// Resolve the element LIVE from the current iframe document — the selected
271306
// node may be detached after a soft-reload, which would skew home.
272-
const live = iframe.contentDocument?.querySelector(targetSelector);
273-
if (!isPreviewHtmlElement(live, iframe)) return;
274-
const sc = r.width / compositionSize.width;
307+
const live = frame.contentDocument?.querySelector(createSelector);
308+
if (!isPreviewHtmlElement(live, frame)) return;
309+
const sc = r.width / compW;
275310
const elHome = elementHome(live);
276311
const px = Math.round((e.clientX - r.left) / sc - elHome.x);
277312
const py = Math.round((e.clientY - r.top) / sc - elHome.y);
278313
const t = Math.round(usePlayerStore.getState().currentTime * 100) / 100;
279-
void commitCreatePath(targetSelector, t, px, py, commitMutation);
314+
void commitCreatePath(createSelector, t, px, py, commitMutation);
280315
};
281-
window.addEventListener("dblclick", onDbl);
282-
return () => window.removeEventListener("dblclick", onDbl);
283-
}, [createMode, selection, compositionSize, iframeRef, commitMutation]);
316+
target.addEventListener("dblclick", onDbl as EventListener);
317+
return () => target.removeEventListener("dblclick", onDbl as EventListener);
318+
}, [createSelector, compW, iframeRef, commitMutation]);
284319

285320
if (!rect || rect.width <= 0 || !compositionSize || compositionSize.width <= 0) return null;
286321
// Hide the whole overlay (path + create hint) when the element isn't painted —
@@ -406,7 +441,13 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
406441
const screenDy = e.clientY - d.startY;
407442
const x = Math.round(d.initX + screenDx / d.scale);
408443
const y = Math.round(d.initY + screenDy / d.scale);
409-
if (x === Math.round(d.initX) && y === Math.round(d.initY)) {
444+
// Click-vs-drag is decided in SCREEN space, not composition px: the old guard
445+
// compared rounded comp-px, which at high zoom (scale ≫ 1) swallowed real
446+
// multi-px screen drags whose sub-comp-px delta rounds to 0 → the node would
447+
// never move. A screen-distance threshold registers any genuine pointer drag
448+
// at any zoom; below it the gesture is a click (select + park the playhead).
449+
const movedScreenPx = Math.hypot(screenDx, screenDy);
450+
if (movedScreenPx < DRAG_THRESHOLD_PX) {
410451
// No drag → treat as a click: select this keyframe and park the playhead on
411452
// it. Selecting it makes the next drag MODIFY this keyframe (honored via
412453
// activeKeyframePct) instead of creating a new one.
@@ -417,6 +458,10 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
417458
}
418459
return; // no commit
419460
}
461+
// A real drag that still rounds to the same integer comp-px (sub-px move at
462+
// high zoom) would commit an identical value — a no-op undo entry. Skip the
463+
// commit, but don't treat it as a click either (the user did drag).
464+
if (x === Math.round(d.initX) && y === Math.round(d.initY)) return;
420465
void commitNode(d.ref, x, y, animId, commitMutation);
421466
// Park the playhead on the edited keyframe's time so the element previews AT
422467
// that keyframe. Without it, a playhead sitting before the tween renders the

0 commit comments

Comments
 (0)