@@ -45,6 +45,10 @@ type DragState = {
4545} ;
4646
4747const 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