Skip to content

Commit 2c3ed8f

Browse files
committed
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.
1 parent b3ce4dd commit 2c3ed8f

2 files changed

Lines changed: 106 additions & 7 deletions

File tree

packages/studio/src/player/components/TimelineClipDiamonds.test.tsx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,53 @@ describe("TimelineClipDiamonds", () => {
379379
act(() => root.unmount());
380380
});
381381

382+
it("cancels an in-flight retime on Escape without committing or selecting", () => {
383+
const onClickKeyframe = vi.fn();
384+
const onMoveKeyframe = vi.fn().mockResolvedValue(true);
385+
const host = document.createElement("div");
386+
document.body.append(host);
387+
const root = createRoot(host);
388+
act(() => {
389+
root.render(
390+
<TimelineDiamondLane
391+
keyframesData={{
392+
format: "percentage",
393+
keyframes: [
394+
{ percentage: 20, tweenPercentage: 0, properties: { x: 0 } },
395+
{ percentage: 40, tweenPercentage: 50, properties: { x: 100 } },
396+
{ percentage: 60, tweenPercentage: 100, properties: { x: 200 } },
397+
],
398+
}}
399+
clipWidthPx={200}
400+
clipHeightPx={48}
401+
accentColor="#4ba3d2"
402+
isSelected
403+
currentPercentage={0}
404+
elementId="clip-1"
405+
selectedKeyframes={new Set()}
406+
onClickKeyframe={onClickKeyframe}
407+
onMoveKeyframe={onMoveKeyframe}
408+
/>,
409+
);
410+
});
411+
const diamond = host.querySelector<HTMLButtonElement>('button[title="40%"]');
412+
expect(diamond).not.toBeNull();
413+
414+
act(() => {
415+
diamond!.dispatchEvent(
416+
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 80 }),
417+
);
418+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
419+
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 100 }));
420+
});
421+
422+
// Escape ends the gesture: no retime is written, and the release is not
423+
// reinterpreted as a click that would park the playhead on the keyframe.
424+
expect(onMoveKeyframe).not.toHaveBeenCalled();
425+
expect(onClickKeyframe).not.toHaveBeenCalled();
426+
act(() => root.unmount());
427+
});
428+
382429
// Regression: onClickKeyframe's state updates can re-render the diamond
383430
// button out from under the gesture before the browser auto-synthesizes the
384431
// "click" event that follows a button's pointerdown+pointerup. That orphaned

packages/studio/src/player/components/TimelineClipDiamonds.tsx

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,13 @@ type DragState = {
9595
startX: number;
9696
fromClipPct: number;
9797
moved: boolean;
98+
/** Latest pointer x, flushed to the preview once per frame. */
99+
lastX: number;
100+
/** Index in the sorted row, needed by the neighbour clamp off the render path. */
101+
index: number;
102+
/** Escape was pressed: the drag is dead, and the pointerup that follows is
103+
* swallowed rather than falling through to the click branch. */
104+
cancelled?: boolean;
98105
};
99106

100107
function keyframeTarget(
@@ -150,6 +157,31 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
150157
// (that optimistic hold was the #1763 flake). The atomic move-keyframe commit
151158
// on drop re-keys the diamond from source.
152159
const [preview, setPreview] = useState<{ kfKey: string; clipPct: number } | null>(null);
160+
// One preview render per frame: a 120Hz trackpad fires pointermove far faster
161+
// than the lane can repaint, and every diamond in the row re-evaluates its
162+
// memo on each of those renders.
163+
const previewFrameRef = useRef<number | null>(null);
164+
const cancelPreviewFrame = () => {
165+
if (previewFrameRef.current === null) return;
166+
cancelAnimationFrame(previewFrameRef.current);
167+
previewFrameRef.current = null;
168+
};
169+
// Escape backs out of an in-flight retime, the way clip and element drags
170+
// already do. Nothing was written yet (the commit happens on pointerup), so
171+
// dropping the preview is the whole undo.
172+
useEffect(() => {
173+
const onKeyDown = (event: KeyboardEvent) => {
174+
if (event.key !== "Escape" || !dragRef.current || dragRef.current.cancelled) return;
175+
dragRef.current.cancelled = true;
176+
cancelPreviewFrame();
177+
setPreview(null);
178+
};
179+
document.addEventListener("keydown", onKeyDown);
180+
return () => {
181+
document.removeEventListener("keydown", onKeyDown);
182+
cancelPreviewFrame();
183+
};
184+
}, []);
153185
// Index of the segment whose mid-point ease button is revealed on hover, like
154186
// Figma. Null = no segment hovered → no button shown (resting state is just
155187
// the connector line + diamonds).
@@ -310,33 +342,47 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
310342
dragRef.current = {
311343
kfKey,
312344
startX: e.clientX,
345+
lastX: e.clientX,
346+
index: i,
313347
fromClipPct: pendingRetimeRef.current.get(kfKey)?.clipPct ?? kf.percentage,
314348
moved: false,
315349
};
316350
}
317351
};
318352
const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
319353
const d = dragRef.current;
320-
if (!d || d.kfKey !== kfKey) return;
354+
if (!d || d.kfKey !== kfKey || d.cancelled) return;
355+
d.lastX = e.clientX;
321356
if (!d.moved && Math.abs(e.clientX - d.startX) >= KEYFRAME_DRAG_THRESHOLD_PX) {
322357
d.moved = true;
323358
}
324-
if (d.moved) {
359+
if (!d.moved || previewFrameRef.current !== null) return;
360+
previewFrameRef.current = requestAnimationFrame(() => {
361+
previewFrameRef.current = null;
362+
const live = dragRef.current;
363+
if (!live || live.kfKey !== kfKey || live.cancelled) return;
325364
setPreview({
326365
kfKey,
327366
clipPct: previewClipPct({
328-
pointerDownX: d.startX,
329-
pointerMoveX: e.clientX,
367+
pointerDownX: live.startX,
368+
pointerMoveX: live.lastX,
330369
clipWidthPx,
331-
draggedClipPct: d.fromClipPct,
332-
draggedIndex: i,
370+
draggedClipPct: live.fromClipPct,
371+
draggedIndex: live.index,
333372
sortedClipPcts,
334373
}),
335374
});
336-
}
375+
});
337376
};
338377
const onPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
339378
const d = dragRef.current;
379+
if (d?.kfKey === kfKey && d.cancelled) {
380+
// Escape already ended this drag; the release is not a click.
381+
dragRef.current = null;
382+
e.currentTarget.releasePointerCapture?.(e.pointerId);
383+
suppressNextClick();
384+
return;
385+
}
340386
// No drag armed (canDrag false / non-primary press) → treat as a click.
341387
if (!d || d.kfKey !== kfKey) {
342388
if (e.button !== 0) return;
@@ -347,9 +393,14 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
347393
}
348394
e.stopPropagation();
349395
dragRef.current = null;
396+
cancelPreviewFrame();
350397
setPreview(null);
351398
e.currentTarget.releasePointerCapture?.(e.pointerId);
352399
suppressNextClick();
400+
// Single-diamond retime by design: a multi-select drag would have to
401+
// move every selected keyframe as one mutation, which the script ops
402+
// do not express yet. Selecting several and dragging one moves only
403+
// the dragged one.
353404
const res = resolveKeyframeDrag({
354405
pointerDownX: d.startX,
355406
pointerUpX: e.clientX,
@@ -447,6 +498,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
447498
// stays stuck at the last previewed position.
448499
if (dragRef.current?.kfKey !== kfKey) return;
449500
dragRef.current = null;
501+
cancelPreviewFrame();
450502
setPreview(null);
451503
e.currentTarget.releasePointerCapture?.(e.pointerId);
452504
}}

0 commit comments

Comments
 (0)