mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user