fix(studio): let Escape cancel a keyframe retime and throttle its preview

Escape now ends an in-flight diamond drag the way it already ends clip
and element drags: the armed gesture is marked cancelled, the preview is
dropped, and the pointerup that follows is swallowed instead of falling
through to the click branch.

The preview also flushes once per animation frame instead of once per
pointermove, so a high-rate trackpad no longer re-renders every diamond
in the row several times a frame. Single-diamond retime stays the
documented scope; multi-select drag needs a batched mutation the script
ops do not express yet.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-27 19:52:05 +02:00
parent e36fb385bc
commit 706f537f33
2 changed files with 106 additions and 7 deletions
@@ -379,6 +379,53 @@ describe("TimelineClipDiamonds", () => {
act(() => root.unmount());
});
it("cancels an in-flight retime on Escape without committing or selecting", () => {
const onClickKeyframe = vi.fn();
const onMoveKeyframe = vi.fn().mockResolvedValue(true);
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<TimelineDiamondLane
keyframesData={{
format: "percentage",
keyframes: [
{ percentage: 20, tweenPercentage: 0, properties: { x: 0 } },
{ percentage: 40, tweenPercentage: 50, properties: { x: 100 } },
{ percentage: 60, tweenPercentage: 100, properties: { x: 200 } },
],
}}
clipWidthPx={200}
clipHeightPx={48}
accentColor="#4ba3d2"
isSelected
currentPercentage={0}
elementId="clip-1"
selectedKeyframes={new Set()}
onClickKeyframe={onClickKeyframe}
onMoveKeyframe={onMoveKeyframe}
/>,
);
});
const diamond = host.querySelector<HTMLButtonElement>('button[title="40%"]');
expect(diamond).not.toBeNull();
act(() => {
diamond!.dispatchEvent(
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 80 }),
);
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 100 }));
});
// Escape ends the gesture: no retime is written, and the release is not
// reinterpreted as a click that would park the playhead on the keyframe.
expect(onMoveKeyframe).not.toHaveBeenCalled();
expect(onClickKeyframe).not.toHaveBeenCalled();
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
@@ -95,6 +95,13 @@ type DragState = {
startX: number;
fromClipPct: number;
moved: boolean;
/** Latest pointer x, flushed to the preview once per frame. */
lastX: number;
/** Index in the sorted row, needed by the neighbour clamp off the render path. */
index: number;
/** Escape was pressed: the drag is dead, and the pointerup that follows is
* swallowed rather than falling through to the click branch. */
cancelled?: boolean;
};
function keyframeTarget(
@@ -150,6 +157,31 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
// (that optimistic hold was the #1763 flake). The atomic move-keyframe commit
// on drop re-keys the diamond from source.
const [preview, setPreview] = useState<{ kfKey: string; clipPct: number } | null>(null);
// One preview render per frame: a 120Hz trackpad fires pointermove far faster
// than the lane can repaint, and every diamond in the row re-evaluates its
// memo on each of those renders.
const previewFrameRef = useRef<number | null>(null);
const cancelPreviewFrame = () => {
if (previewFrameRef.current === null) return;
cancelAnimationFrame(previewFrameRef.current);
previewFrameRef.current = null;
};
// Escape backs out of an in-flight retime, the way clip and element drags
// already do. Nothing was written yet (the commit happens on pointerup), so
// dropping the preview is the whole undo.
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape" || !dragRef.current || dragRef.current.cancelled) return;
dragRef.current.cancelled = true;
cancelPreviewFrame();
setPreview(null);
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
cancelPreviewFrame();
};
}, []);
// Index of the segment whose mid-point ease button is revealed on hover, like
// Figma. Null = no segment hovered → no button shown (resting state is just
// the connector line + diamonds).
@@ -310,6 +342,8 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
dragRef.current = {
kfKey,
startX: e.clientX,
lastX: e.clientX,
index: i,
fromClipPct: pendingRetimeRef.current.get(kfKey)?.clipPct ?? kf.percentage,
moved: false,
};
@@ -317,26 +351,38 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
};
const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
const d = dragRef.current;
if (!d || d.kfKey !== kfKey) return;
if (!d || d.kfKey !== kfKey || d.cancelled) return;
d.lastX = e.clientX;
if (!d.moved && Math.abs(e.clientX - d.startX) >= KEYFRAME_DRAG_THRESHOLD_PX) {
d.moved = true;
}
if (d.moved) {
if (!d.moved || previewFrameRef.current !== null) return;
previewFrameRef.current = requestAnimationFrame(() => {
previewFrameRef.current = null;
const live = dragRef.current;
if (!live || live.kfKey !== kfKey || live.cancelled) return;
setPreview({
kfKey,
clipPct: previewClipPct({
pointerDownX: d.startX,
pointerMoveX: e.clientX,
pointerDownX: live.startX,
pointerMoveX: live.lastX,
clipWidthPx,
draggedClipPct: d.fromClipPct,
draggedIndex: i,
draggedClipPct: live.fromClipPct,
draggedIndex: live.index,
sortedClipPcts,
}),
});
}
});
};
const onPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
const d = dragRef.current;
if (d?.kfKey === kfKey && d.cancelled) {
// Escape already ended this drag; the release is not a click.
dragRef.current = null;
e.currentTarget.releasePointerCapture?.(e.pointerId);
suppressNextClick();
return;
}
// No drag armed (canDrag false / non-primary press) → treat as a click.
if (!d || d.kfKey !== kfKey) {
if (e.button !== 0) return;
@@ -347,9 +393,14 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
}
e.stopPropagation();
dragRef.current = null;
cancelPreviewFrame();
setPreview(null);
e.currentTarget.releasePointerCapture?.(e.pointerId);
suppressNextClick();
// Single-diamond retime by design: a multi-select drag would have to
// move every selected keyframe as one mutation, which the script ops
// do not express yet. Selecting several and dragging one moves only
// the dragged one.
const res = resolveKeyframeDrag({
pointerDownX: d.startX,
pointerUpX: e.clientX,
@@ -447,6 +498,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
// stays stuck at the last previewed position.
if (dragRef.current?.kfKey !== kfKey) return;
dragRef.current = null;
cancelPreviewFrame();
setPreview(null);
e.currentTarget.releasePointerCapture?.(e.pointerId);
}}