) => {
if (e.button !== 0) return;
e.stopPropagation();
- if (canDrag) {
- e.currentTarget.setPointerCapture?.(e.pointerId);
- dragRef.current = {
- kfKey,
- startX: e.clientX,
- lastX: e.clientX,
- index: siblingIndex,
- fromClipPct: pendingRetimes.get(kfKey)?.clipPct ?? kf.percentage,
- moved: false,
- };
- }
- };
- const onPointerMove = (e: React.PointerEvent
) => {
- const d = dragRef.current;
- 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 || 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: live.startX,
- pointerMoveX: live.lastX,
- clipWidthPx,
- draggedClipPct: live.fromClipPct,
- draggedIndex: live.index,
- sortedClipPcts: siblingClipPcts,
- }),
- });
+ if (!canDrag) return;
+ retimeHandleRef.current = beginTimelineKeyframeRetime({
+ event: e,
+ elementId,
+ keyframeKey: kfKey,
+ target,
+ keyframes: keyframesData.keyframes,
+ clipWidthPx,
+ // Clamp against this keyframe's own tween, not the whole merged row:
+ // a merged row interleaves several animations, and two colliding at
+ // one percentage would otherwise pin each other's diamonds in place.
+ draggedIndex: siblingIndex,
+ sortedClipPercentages: siblingClipPcts,
+ keyframeKeyOf: (keyframe) =>
+ timelineKeyframeSelectionKey(elementId, keyframeTarget(keyframe)),
+ onMove: (fromTarget, toClipPercentage) =>
+ onMoveKeyframe?.(fromTarget, toClipPercentage) ?? Promise.resolve(false),
+ onSelect: (nextTarget, additive) => {
+ if (additive) onShiftClickKeyframe?.(nextTarget);
+ else onClickKeyframe?.(nextTarget);
+ },
+ suppressNextClick,
});
};
const onPointerUp = (e: React.PointerEvent) => {
- 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;
- suppressNextClick();
- if (e.shiftKey) onShiftClickKeyframe?.(target);
- else onClickKeyframe?.(target);
+ // The viewport coordinator owns an armed retime; this local path is
+ // only for diamonds that cannot be dragged.
+ if (canDrag) {
+ retimeHandleRef.current?.commit(e);
+ retimeHandleRef.current = null;
+ e.stopPropagation();
return;
}
- e.stopPropagation();
- dragRef.current = null;
- cancelPreviewFrame();
- setPreview(null);
- e.currentTarget.releasePointerCapture?.(e.pointerId);
+ if (e.button !== 0) return;
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,
- clipWidthPx,
- draggedClipPct: d.fromClipPct,
- draggedIndex: siblingIndex,
- sortedClipPcts: siblingClipPcts,
- });
- if (res.kind === "click" || res.kind === "noop") {
- // "noop" is a press with enough pointer jitter to arm a drag (canDrag
- // is on for every diamond once the clip is selected) that resolved
- // back onto ~the same position — no real retime, so treat it as the
- // click it was. Otherwise a normal click with a few px of mouse/
- // trackpad drift silently does nothing: no selection, no move.
- if (e.shiftKey) onShiftClickKeyframe?.(target);
- else onClickKeyframe?.(target);
- } else if (res.kind === "move" && res.toClipPct != null) {
- const animKfs =
- target.animationId === undefined
- ? keyframesData.keyframes
- : keyframesData.keyframes.filter((k) => k.animationId === target.animationId);
- // Clamp to the mapped tween range: clipToTweenPercentage extrapolates
- // linearly, so a boundary drag past the range would otherwise reselect
- // an out-of-range tween % (e.g. 150%) even though the mutation clamps
- // the moved endpoint back to the boundary.
- const tweenPcts = animKfs
- .map((k) => k.tweenPercentage)
- .filter((v): v is number => typeof v === "number");
- const clampTween = (v: number) =>
- tweenPcts.length
- ? Math.max(Math.min(...tweenPcts), Math.min(Math.max(...tweenPcts), v))
- : v;
- const newTweenPct = clampTween(clipToTweenPercentage(animKfs, res.toClipPct));
- // For a rapid second retime the diamond still renders the stale cache
- // position, so identify the FROM keyframe by the pending (already-moved)
- // position; the mutation locates the source keyframe by this identity.
- const pendingBefore = pendingRetimes.get(kfKey);
- const fromTarget = pendingBefore
- ? {
- ...target,
- percentage: pendingBefore.clipPct,
- tweenPercentage: pendingBefore.tweenPct,
- }
- : target;
- const pending = { clipPct: res.toClipPct, tweenPct: newTweenPct };
- pendingRetimes.set(kfKey, pending);
- latestRetimeRef.current = pending;
- const clearPending = () => {
- if (pendingRetimes.get(kfKey) === pending) {
- pendingRetimes.delete(kfKey);
- }
- };
- // A rejected drop (the destination time is already occupied) snaps
- // the diamond back to its source position, so the pending entry AND
- // the selection have to revert with it — parking on the ghost drop
- // position strands the playhead + selection on a keyframe that does
- // not exist there.
- const revertRetime = () => {
- // Only the newest gesture owns the selection. A rejected first drag
- // whose commit settles after a second one started would otherwise
- // park the selection back on ITS source keyframe, undoing a retime
- // the user has already made and moving the playhead with it.
- const isLatest = latestRetimeRef.current === pending;
- clearPending();
- if (isLatest) onClickKeyframe?.(fromTarget);
- };
- void onMoveKeyframe?.(fromTarget, res.toClipPct).then((committed) => {
- if (!committed) revertRetime();
- }, revertRetime);
- // 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
- // something looks identical to one that silently did nothing. Done
- // optimistically so the gesture stays responsive; revertRetime puts
- // it back if the move is rejected.
- onClickKeyframe?.({
- ...target,
- percentage: res.toClipPct,
- tweenPercentage: newTweenPct,
- });
- }
+ if (e.shiftKey) onShiftClickKeyframe?.(target);
+ else onClickKeyframe?.(target);
};
return (
@@ -476,18 +333,16 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
overflow: "visible",
}}
onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
+ onPointerMove={canDrag ? (e) => retimeHandleRef.current?.update(e) : undefined}
onPointerUp={onPointerUp}
- onPointerCancel={(e) => {
- // Browser/OS cancellation (or lost capture) ends the drag without a
- // pointerup, so clear the armed drag and preview or a ghost diamond
- // stays stuck at the last previewed position.
- if (dragRef.current?.kfKey !== kfKey) return;
- dragRef.current = null;
- cancelPreviewFrame();
- setPreview(null);
- e.currentTarget.releasePointerCapture?.(e.pointerId);
- }}
+ onPointerCancel={
+ canDrag
+ ? (e) => {
+ retimeHandleRef.current?.cancel(e);
+ retimeHandleRef.current = null;
+ }
+ : undefined
+ }
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
diff --git a/packages/studio/src/player/components/timelineDiamondTypes.ts b/packages/studio/src/player/components/timelineDiamondTypes.ts
index ff1f617637..f4a4378308 100644
--- a/packages/studio/src/player/components/timelineDiamondTypes.ts
+++ b/packages/studio/src/player/components/timelineDiamondTypes.ts
@@ -93,20 +93,6 @@ export const DIAMOND_RATIO = 0.8;
export const KF_MIN_PCT = -5;
export const KF_MAX_PCT = 105;
-export type DragState = {
- kfKey: string;
- 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;
-};
-
/**
* The full identity of a diamond, used by every callback and by the selection
* key. Collapsed clip rows and expanded property lanes read the same cache, so
@@ -115,7 +101,11 @@ export type DragState = {
* the other, and would strip the animation id the retime/delete mutations use to
* pick between two animations that collide at one percentage.
*/
-export function keyframeTarget(keyframe: TimelineDiamondKeyframe): TimelineKeyframeTarget {
+export function keyframeTarget(
+ // The identity fields only, so callers holding a narrower keyframe row (the
+ // retime coordinator's) build the same key instead of re-listing the shape.
+ keyframe: Omit,
+): TimelineKeyframeTarget {
return {
percentage: keyframe.percentage,
tweenPercentage: keyframe.tweenPercentage,
diff --git a/packages/studio/src/player/components/timelineTestViewport.ts b/packages/studio/src/player/components/timelineTestViewport.ts
new file mode 100644
index 0000000000..a7815d31a4
--- /dev/null
+++ b/packages/studio/src/player/components/timelineTestViewport.ts
@@ -0,0 +1,13 @@
+/** Configure the shared viewport geometry used by timeline gesture hook tests. */
+export function configureTimelineTestViewport(scroll: HTMLElement, scrollHeight: number): void {
+ scroll.getBoundingClientRect = () =>
+ ({ left: 0, top: 0, right: 800, bottom: 240, width: 800, height: 240 }) as DOMRect;
+ Object.defineProperties(scroll, {
+ scrollLeft: { configurable: true, writable: true, value: 0 },
+ scrollTop: { configurable: true, writable: true, value: 0 },
+ scrollWidth: { configurable: true, value: 10_000 },
+ scrollHeight: { configurable: true, value: scrollHeight },
+ clientWidth: { configurable: true, value: 800 },
+ clientHeight: { configurable: true, value: 240 },
+ });
+}
diff --git a/packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx b/packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx
index f70425303b..7931718807 100644
--- a/packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx
+++ b/packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx
@@ -5,14 +5,12 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
import type { TimelineElement } from "../store/playerStore";
import { usePlayerStore } from "../store/playerStore";
+import * as telemetry from "../../telemetry/events";
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
-const trackStudioSegmentEaseEdit = vi.hoisted(() => vi.fn());
-vi.mock("../../telemetry/events", () => ({ trackStudioSegmentEaseEdit }));
-
const ELEMENT: TimelineElement = {
id: "clip-1",
label: "Hero card",
@@ -46,7 +44,7 @@ const COLLIDING_TARGET: TimelineKeyframeTarget = {
afterEach(() => {
document.body.innerHTML = "";
- trackStudioSegmentEaseEdit.mockClear();
+ vi.restoreAllMocks();
usePlayerStore.setState({ focusedEaseSegment: null });
});
@@ -78,6 +76,9 @@ function mountHandlers(options: Partial {
it("tracks opening the segment ease editor when a timeline segment is selected", () => {
+ const trackStudioSegmentEaseEdit = vi
+ .spyOn(telemetry, "trackStudioSegmentEaseEdit")
+ .mockImplementation(() => {});
const { root, handlers } = mountHandlers();
act(() => handlers.onSelectSegment?.(ELEMENT.id, TARGET));
diff --git a/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts b/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts
index 657aac930a..7632bad221 100644
--- a/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts
+++ b/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts
@@ -1,13 +1,475 @@
-import { useCallback, type MouseEvent as ReactMouseEvent } from "react";
+import {
+ useCallback,
+ type MouseEvent as ReactMouseEvent,
+ type PointerEvent as ReactPointerEvent,
+} from "react";
+import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation";
+import {
+ KEYFRAME_DRAG_THRESHOLD_PX,
+ previewClipPct,
+ resolveKeyframeDrag,
+} from "../../components/editor/keyframeDrag";
import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
+import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore";
import { usePlayerStore } from "../store/playerStore";
import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
+import {
+ applyTimelineHorizontalAutoScrollStep,
+ resolveTimelineAutoScrollLoopAction,
+} from "./timelineEditing";
import {
timelineKeyframeSelectionKey,
type TimelineKeyframeTarget,
} from "./timelineKeyframeIdentity";
+interface TimelineRetimeKeyframe {
+ percentage: number;
+ tweenPercentage?: number;
+ propertyGroup?: string;
+ animationId?: string;
+ collidingAnimationTargets?: AnimationKeyframeTarget[];
+}
+
+interface TimelineKeyframeRetimeInput {
+ event: ReactPointerEvent;
+ elementId: string;
+ keyframeKey: string;
+ target: TimelineKeyframeTarget;
+ keyframes: readonly TimelineRetimeKeyframe[];
+ clipWidthPx: number;
+ draggedIndex: number;
+ sortedClipPercentages: readonly number[];
+ onMove: (target: TimelineKeyframeTarget, toClipPercentage: number) => Promise;
+ onSelect: (target: TimelineKeyframeTarget, additive: boolean) => void;
+ suppressNextClick: () => void;
+ /**
+ * Selection key of a cache keyframe, used to retire a pending entry once the
+ * authoritative cache reflects THAT keyframe at its destination. Without it a
+ * bare "some keyframe is near that %" test retires the entry whenever an
+ * unrelated sibling happens to sit there, which is easy to hit on an evenly
+ * spaced row.
+ */
+ keyframeKeyOf: (keyframe: TimelineRetimeKeyframe) => string;
+}
+
+interface PendingTimelineKeyframeRetime {
+ elementId: string;
+ clipPercentage: number;
+ tweenPercentage: number;
+ destinationKeyframeKey: string;
+ sessionEpoch: number;
+}
+
+interface TimelineKeyframeRetimePreview {
+ keyframeKey: string;
+ clipPercentage: number;
+}
+
+interface TimelineKeyframeRetimeActor extends TimelineKeyframeRetimeInput {
+ phase: "active" | "committing" | "cancelled" | "complete";
+ pointerId: number | null;
+ pointerDownX: number;
+ lastClientX: number;
+ lastClientY: number;
+ originScrollLeft: number;
+ fromClipPercentage: number;
+ moved: boolean;
+ sessionEpoch: number;
+ sourceWasPresent: boolean;
+ scrollRaf: number;
+ unsubscribeStore: (() => void) | null;
+ teardownListeners: (() => void) | null;
+}
+
+interface TimelineKeyframeRetimeCoordinator {
+ actor: TimelineKeyframeRetimeActor | null;
+ pending: Map;
+ preview: TimelineKeyframeRetimePreview | null;
+ previewListeners: Set<(preview: TimelineKeyframeRetimePreview | null) => void>;
+ /**
+ * The most recent retime dispatched through this viewport, whichever diamond
+ * it came from. Selection is viewport-wide, so "is my revert still relevant"
+ * is a viewport-wide question, not a per-keyframe one.
+ */
+ latest: PendingTimelineKeyframeRetime | null;
+}
+
+type TimelineRetimePointerEvent = Pick<
+ PointerEvent,
+ "clientX" | "clientY" | "pointerId" | "shiftKey"
+>;
+
+const keyframeRetimeCoordinators = new WeakMap();
+
+function getRetimeOwner(target: HTMLElement): EventTarget {
+ return target.closest("[data-timeline-scroll-viewport]") ?? target.ownerDocument;
+}
+
+function getRetimeCoordinator(owner: EventTarget): TimelineKeyframeRetimeCoordinator {
+ const existing = keyframeRetimeCoordinators.get(owner);
+ if (existing) return existing;
+ const coordinator: TimelineKeyframeRetimeCoordinator = {
+ actor: null,
+ pending: new Map(),
+ preview: null,
+ previewListeners: new Set(),
+ latest: null,
+ };
+ keyframeRetimeCoordinators.set(owner, coordinator);
+ return coordinator;
+}
+
+function stablePointerId(pointerId: number): number | null {
+ return Number.isFinite(pointerId) ? pointerId : null;
+}
+
+/**
+ * The retime destinations already dispatched from `source`'s viewport but not
+ * yet reflected in the keyframe cache. A renderer clamping a drag against its
+ * neighbours has to compose these in, or a second drag can cross a neighbour
+ * that already moved past it.
+ */
+export function readPendingTimelineKeyframeRetimes(
+ source: HTMLElement | null | undefined,
+): ReadonlyMap {
+ if (!source) return EMPTY_PENDING_RETIMES;
+ return keyframeRetimeCoordinators.get(getRetimeOwner(source))?.pending ?? EMPTY_PENDING_RETIMES;
+}
+
+const EMPTY_PENDING_RETIMES: ReadonlyMap<
+ string,
+ { clipPercentage: number; tweenPercentage: number }
+> = new Map();
+
+function publishRetimePreview(
+ coordinator: TimelineKeyframeRetimeCoordinator,
+ preview: TimelineKeyframeRetimePreview | null,
+): void {
+ coordinator.preview = preview;
+ for (const listener of coordinator.previewListeners) listener(preview);
+}
+
+export function subscribeTimelineKeyframeRetimePreview(
+ source: HTMLElement,
+ listener: (preview: TimelineKeyframeRetimePreview | null) => void,
+): () => void {
+ const coordinator = getRetimeCoordinator(getRetimeOwner(source));
+ coordinator.previewListeners.add(listener);
+ listener(coordinator.preview);
+ return () => coordinator.previewListeners.delete(listener);
+}
+
+function resolveRetimeTweenPercentage(
+ actor: TimelineKeyframeRetimeActor,
+ toClipPercentage: number,
+): number {
+ const animationKeyframes =
+ actor.target.animationId === undefined
+ ? actor.keyframes
+ : actor.keyframes.filter((keyframe) => keyframe.animationId === actor.target.animationId);
+ const tweenPercentages = animationKeyframes
+ .map((keyframe) => keyframe.tweenPercentage)
+ .filter((value): value is number => typeof value === "number");
+ const mapped = clipToTweenPercentage(animationKeyframes, toClipPercentage);
+ if (tweenPercentages.length === 0) return mapped;
+ return Math.max(Math.min(...tweenPercentages), Math.min(Math.max(...tweenPercentages), mapped));
+}
+
+export interface TimelineKeyframeRetimeHandle {
+ update: (event: ReactPointerEvent) => void;
+ commit: (event: ReactPointerEvent) => void;
+ cancel: (event: ReactPointerEvent) => void;
+}
+
+/**
+ * Starts a keyframe retime on the stable timeline viewport. The row/button is
+ * only an entry point: window listeners own the gesture through virtualization.
+ */
+export function beginTimelineKeyframeRetime(
+ input: TimelineKeyframeRetimeInput,
+): TimelineKeyframeRetimeHandle {
+ const source = input.event.currentTarget;
+ const owner = getRetimeOwner(source);
+ const viewport = owner instanceof HTMLElement ? owner : null;
+ const coordinator = getRetimeCoordinator(owner);
+ const sessionEpoch = usePlayerStore.getState().timelineSessionEpoch;
+
+ const cancel = (actor: TimelineKeyframeRetimeActor) => {
+ if (actor.phase !== "active") return;
+ actor.phase = "cancelled";
+ publishRetimePreview(coordinator, null);
+ if (actor.scrollRaf) cancelAnimationFrame(actor.scrollRaf);
+ actor.unsubscribeStore?.();
+ actor.teardownListeners?.();
+ if (viewport && actor.pointerId !== null) {
+ try {
+ viewport.releasePointerCapture(actor.pointerId);
+ } catch {
+ // Window listeners remain the native fallback when capture is unavailable.
+ }
+ }
+ if (coordinator.actor === actor) {
+ coordinator.actor = null;
+ }
+ actor.phase = "complete";
+ };
+
+ if (coordinator.actor) cancel(coordinator.actor);
+
+ for (const [key, pending] of coordinator.pending) {
+ if (pending.sessionEpoch !== sessionEpoch) coordinator.pending.delete(key);
+ }
+
+ for (const [key, pending] of coordinator.pending) {
+ // Tolerance, not equality: cache writers round clip %s, so an exact check
+ // would leak an entry after every successful retime.
+ if (
+ pending.elementId === input.elementId &&
+ input.keyframes.some(
+ (keyframe) =>
+ input.keyframeKeyOf(keyframe) === pending.destinationKeyframeKey &&
+ Math.abs(keyframe.percentage - pending.clipPercentage) < 0.2,
+ )
+ ) {
+ coordinator.pending.delete(key);
+ }
+ }
+
+ const pending = coordinator.pending.get(input.keyframeKey);
+ const actor: TimelineKeyframeRetimeActor = {
+ ...input,
+ phase: "active",
+ pointerId: stablePointerId(input.event.pointerId),
+ pointerDownX: input.event.clientX,
+ lastClientX: input.event.clientX,
+ lastClientY: input.event.clientY,
+ originScrollLeft: viewport?.scrollLeft ?? 0,
+ fromClipPercentage: pending?.clipPercentage ?? input.target.percentage,
+ moved: false,
+ sessionEpoch,
+ sourceWasPresent: usePlayerStore
+ .getState()
+ .elements.some((element) => (element.key ?? element.id) === input.elementId),
+ scrollRaf: 0,
+ unsubscribeStore: null,
+ teardownListeners: null,
+ };
+ coordinator.actor = actor;
+
+ const matchesPointer = (event: TimelineRetimePointerEvent) =>
+ actor.pointerId === null || event.pointerId === actor.pointerId;
+ const pointerXWithScroll = () =>
+ actor.lastClientX + (viewport?.scrollLeft ?? 0) - actor.originScrollLeft;
+ const publishPreview = () => {
+ publishRetimePreview(coordinator, {
+ keyframeKey: actor.keyframeKey,
+ clipPercentage: previewClipPct({
+ pointerDownX: actor.pointerDownX,
+ pointerMoveX: pointerXWithScroll(),
+ clipWidthPx: actor.clipWidthPx,
+ draggedClipPct: actor.fromClipPercentage,
+ draggedIndex: actor.draggedIndex,
+ sortedClipPcts: actor.sortedClipPercentages,
+ }),
+ });
+ };
+ const stopAutoScroll = () => {
+ if (actor.scrollRaf) cancelAnimationFrame(actor.scrollRaf);
+ actor.scrollRaf = 0;
+ };
+ const stepAutoScroll = () => {
+ actor.scrollRaf = 0;
+ if (
+ actor.phase !== "active" ||
+ !viewport ||
+ !applyTimelineHorizontalAutoScrollStep(viewport, actor.lastClientX)
+ ) {
+ return;
+ }
+ publishPreview();
+ actor.scrollRaf = requestAnimationFrame(stepAutoScroll);
+ };
+ const syncAutoScroll = () => {
+ if (!viewport || !actor.moved) return;
+ const action = resolveTimelineAutoScrollLoopAction(
+ viewport,
+ actor.lastClientX,
+ actor.lastClientY,
+ actor.scrollRaf !== 0,
+ );
+ if (action === "stop") stopAutoScroll();
+ else if (action === "start") actor.scrollRaf = requestAnimationFrame(stepAutoScroll);
+ };
+ const teardown = () => {
+ stopAutoScroll();
+ actor.unsubscribeStore?.();
+ actor.unsubscribeStore = null;
+ actor.teardownListeners?.();
+ actor.teardownListeners = null;
+ };
+ const releaseCapture = () => {
+ if (!viewport || actor.pointerId === null) return;
+ try {
+ viewport.releasePointerCapture(actor.pointerId);
+ } catch {
+ // Capture may already have been released by the browser.
+ }
+ };
+
+ const claimActorForCommit = (event: TimelineRetimePointerEvent): boolean => {
+ if (actor.phase !== "active" || !matchesPointer(event)) return false;
+ if (actor.sessionEpoch !== usePlayerStore.getState().timelineSessionEpoch) {
+ cancel(actor);
+ return false;
+ }
+ actor.phase = "committing";
+ actor.lastClientX = event.clientX;
+ actor.lastClientY = event.clientY;
+ teardown();
+ releaseCapture();
+ publishRetimePreview(coordinator, null);
+ if (coordinator.actor === actor) {
+ coordinator.actor = null;
+ }
+ actor.suppressNextClick();
+ return true;
+ };
+
+ const commitMove = (toClipPercentage: number) => {
+ const newTweenPercentage = resolveRetimeTweenPercentage(actor, toClipPercentage);
+ const pendingBefore = coordinator.pending.get(actor.keyframeKey);
+ const fromTarget = pendingBefore
+ ? {
+ ...actor.target,
+ percentage: pendingBefore.clipPercentage,
+ tweenPercentage: pendingBefore.tweenPercentage,
+ }
+ : actor.target;
+ const nextPending = {
+ elementId: actor.elementId,
+ clipPercentage: toClipPercentage,
+ tweenPercentage: newTweenPercentage,
+ destinationKeyframeKey: timelineKeyframeSelectionKey(actor.elementId, {
+ ...actor.target,
+ percentage: toClipPercentage,
+ tweenPercentage: newTweenPercentage,
+ }),
+ sessionEpoch: actor.sessionEpoch,
+ };
+ coordinator.pending.set(actor.keyframeKey, nextPending);
+ coordinator.latest = nextPending;
+ const clearPending = () => {
+ if (coordinator.pending.get(actor.keyframeKey) === nextPending) {
+ coordinator.pending.delete(actor.keyframeKey);
+ }
+ };
+ // A rejected drop (the destination time is already occupied) snaps the
+ // diamond back to its source position, so the pending entry AND the
+ // selection have to revert with it: parking on the ghost drop position
+ // strands the playhead + selection on a keyframe that is not there.
+ const revertRetime = () => {
+ // Only the newest gesture owns the selection. A rejected first drag
+ // whose commit settles after a second one started would otherwise park
+ // the selection back on ITS source keyframe, undoing a retime the user
+ // has already made and moving the playhead with it.
+ const isLatest = coordinator.latest === nextPending;
+ clearPending();
+ if (isLatest) actor.onSelect(fromTarget, false);
+ };
+ void actor.onMove(fromTarget, toClipPercentage).then((committed) => {
+ if (!committed) revertRetime();
+ }, revertRetime);
+ actor.onSelect(
+ {
+ ...actor.target,
+ percentage: toClipPercentage,
+ tweenPercentage: newTweenPercentage,
+ },
+ false,
+ );
+ };
+
+ const finishCommit = (event: TimelineRetimePointerEvent) => {
+ if (!claimActorForCommit(event)) return;
+ const result = resolveKeyframeDrag({
+ pointerDownX: actor.pointerDownX,
+ pointerUpX: pointerXWithScroll(),
+ clipWidthPx: actor.clipWidthPx,
+ draggedClipPct: actor.fromClipPercentage,
+ draggedIndex: actor.draggedIndex,
+ sortedClipPcts: actor.sortedClipPercentages,
+ });
+ if (result.kind === "move" && result.toClipPct !== undefined) {
+ commitMove(result.toClipPct);
+ } else {
+ actor.onSelect(actor.target, event.shiftKey);
+ }
+ actor.phase = "complete";
+ };
+ const onPointerMove = (event: TimelineRetimePointerEvent) => {
+ if (actor.phase !== "active" || !matchesPointer(event)) return;
+ actor.lastClientX = event.clientX;
+ actor.lastClientY = event.clientY;
+ if (
+ !actor.moved &&
+ Math.abs(pointerXWithScroll() - actor.pointerDownX) >= KEYFRAME_DRAG_THRESHOLD_PX
+ ) {
+ actor.moved = true;
+ }
+ if (actor.moved) publishPreview();
+ syncAutoScroll();
+ };
+ const onPointerUp = (event: TimelineRetimePointerEvent) => finishCommit(event);
+ const onPointerCancel = (event: TimelineRetimePointerEvent) => {
+ if (actor.phase === "active" && matchesPointer(event)) cancel(actor);
+ };
+ const onLostPointerCapture = (event: PointerEvent) => {
+ if (actor.phase === "active" && matchesPointer(event)) cancel(actor);
+ };
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") cancel(actor);
+ };
+
+ window.addEventListener("pointermove", onPointerMove, true);
+ window.addEventListener("pointerup", onPointerUp, true);
+ window.addEventListener("pointercancel", onPointerCancel, true);
+ window.addEventListener("keydown", onKeyDown);
+ viewport?.addEventListener("lostpointercapture", onLostPointerCapture);
+ actor.teardownListeners = () => {
+ window.removeEventListener("pointermove", onPointerMove, true);
+ window.removeEventListener("pointerup", onPointerUp, true);
+ window.removeEventListener("pointercancel", onPointerCancel, true);
+ window.removeEventListener("keydown", onKeyDown);
+ viewport?.removeEventListener("lostpointercapture", onLostPointerCapture);
+ };
+ actor.unsubscribeStore = usePlayerStore.subscribe((state) => {
+ const sourceStillPresent = state.elements.some(
+ (element) => (element.key ?? element.id) === actor.elementId,
+ );
+ if (state.timelineSessionEpoch !== actor.sessionEpoch) {
+ coordinator.pending.clear();
+ coordinator.latest = null;
+ cancel(actor);
+ } else if (actor.sourceWasPresent && !sourceStillPresent) {
+ for (const [key, pending] of coordinator.pending) {
+ if (pending.elementId === actor.elementId) coordinator.pending.delete(key);
+ }
+ if (coordinator.latest?.elementId === actor.elementId) coordinator.latest = null;
+ cancel(actor);
+ }
+ });
+
+ if (viewport && actor.pointerId !== null) {
+ try {
+ viewport.setPointerCapture(actor.pointerId);
+ } catch {
+ // Window listeners are the native fallback when capture is unavailable.
+ }
+ }
+ return { update: onPointerMove, commit: onPointerUp, cancel: onPointerCancel };
+}
+
interface UseTimelineKeyframeHandlersInput {
expandedElements: TimelineElement[];
keyframeCache: Map;