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.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-16 11:25:46 -04:00
parent 4e1a537a89
commit 629ea7e82f
2 changed files with 86 additions and 2 deletions
@@ -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(
<TimelineDiamondLane
keyframesData={{
format: "percentage",
keyframes: [
{
percentage: 0,
tweenPercentage: 0,
propertyGroup: "position",
animationId: "anim-1",
properties: { x: 0 },
},
{
percentage: 50,
tweenPercentage: 50,
propertyGroup: "position",
animationId: "anim-1",
properties: { x: 100 },
},
{
percentage: 100,
tweenPercentage: 100,
propertyGroup: "position",
animationId: "anim-1",
properties: { x: 200 },
},
],
}}
clipWidthPx={200}
clipHeightPx={48}
accentColor="#4ba3d2"
isSelected
currentPercentage={0}
elementId="clip-1"
selectedKeyframes={new Set()}
onMoveKeyframe={onMoveKeyframe}
groupAware
/>,
);
});
const diamond = host.querySelector<HTMLButtonElement>('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
@@ -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<DragState | null>(null);
const pendingClipPctsRef = useRef(new Map<string, number>());
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