From 629ea7e82f4b03a3ab0364e7fc4a1064c3e2c5db Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 15 Jul 2026 00:55:59 -0400 Subject: [PATCH] fix(studio): compose a rapid second retime from the pending position A second diamond retime issued before the first commit's async cache rebuild resolved its delta from the original pointer-down clip %, so it landed on a stale/no-op target and was silently discarded. Track the last-resolved clip % per keyframe key and use it as the next drag origin; clear it once the cache reflects that percentage. Preserves U1's post-move reselect; no optimistic hold. --- .../components/TimelineClipDiamonds.test.tsx | 76 +++++++++++++++++++ .../components/TimelineClipDiamonds.tsx | 12 ++- 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx index e745ff912e..c5d53175e7 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx @@ -220,6 +220,82 @@ describe("TimelineClipDiamonds", () => { act(() => root.unmount()); }); + it("composes a rapid second retime from the pending position", () => { + const onMoveKeyframe = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const diamond = host.querySelector('button[title="50%"]'); + expect(diamond).not.toBeNull(); + + act(() => { + diamond!.dispatchEvent( + pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }), + ); + diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 })); + + // The cache still exposes 50%, but this second +10% drag starts at the + // pending 75% destination and must therefore land at 85%, not 60%. + diamond!.dispatchEvent( + pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 150 }), + ); + diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 170 })); + }); + + expect(onMoveKeyframe).toHaveBeenNthCalledWith( + 2, + { + percentage: 50, + tweenPercentage: 50, + propertyGroup: "position", + animationId: "anim-1", + }, + 85, + ); + act(() => root.unmount()); + }); + // Regression: onClickKeyframe's state updates can re-render the diamond // button out from under the gesture before the browser auto-synthesizes the // "click" event that follows a button's pointerdown+pointerup. That orphaned diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx index 9ab08d6797..7c810a1039 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -1,4 +1,4 @@ -import { Fragment, memo, useRef, useState } from "react"; +import { Fragment, memo, useEffect, useRef, useState } from "react"; import { BEAT_BAND_H } from "./BeatStrip"; import { KEYFRAME_DRAG_THRESHOLD_PX, @@ -130,6 +130,13 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({ }: TimelineDiamondLaneProps) { // Hooks must run before the early return below. const dragRef = useRef(null); + const pendingClipPctsRef = useRef(new Map()); + useEffect(() => { + const cachedClipPcts = new Set(keyframesData.keyframes.map((keyframe) => keyframe.percentage)); + for (const [kfKey, clipPct] of pendingClipPctsRef.current) { + if (cachedClipPcts.has(clipPct)) pendingClipPctsRef.current.delete(kfKey); + } + }, [keyframesData.keyframes]); // Visual-only preview of the dragged diamond's clip-% — no runtime/GSAP hold // (that optimistic hold was the #1763 flake). The atomic move-keyframe commit // on drop re-keys the diamond from source. @@ -291,7 +298,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({ dragRef.current = { kfKey, startX: e.clientX, - fromClipPct: kf.percentage, + fromClipPct: pendingClipPctsRef.current.get(kfKey) ?? kf.percentage, moved: false, }; } @@ -348,6 +355,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({ if (e.shiftKey) onShiftClickKeyframe?.(target); else onClickKeyframe?.(target); } else if (res.kind === "move" && res.toClipPct != null) { + pendingClipPctsRef.current.set(kfKey, res.toClipPct); onMoveKeyframe?.(target, res.toClipPct); // A retime still targeted this exact diamond — park/select it at its // new position, same as a plain click, or a drag that actually moved