From 0ab3f05b8f0755130b18515e190c19ecac9ff85e Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 18 Jul 2026 21:03:29 +0200 Subject: [PATCH] perf(studio): stabilize virtualized keyframe retiming --- .../components/TimelineClipDiamonds.test.tsx | 267 ++++++++++++- .../components/TimelineClipDiamonds.tsx | 187 +++------ .../useTimelineKeyframeHandlers.test.tsx | 9 +- .../components/useTimelineKeyframeHandlers.ts | 358 +++++++++++++++++- 4 files changed, 662 insertions(+), 159 deletions(-) diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx index d8ed941846..507ac156a7 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx @@ -3,22 +3,44 @@ import React, { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { TimelineElement } from "../store/playerStore"; +import { usePlayerStore } from "../store/playerStore"; import { TimelineClipDiamonds, TimelineDiamondLane } from "./TimelineClipDiamonds"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; afterEach(() => { document.body.innerHTML = ""; + usePlayerStore.setState({ elements: [], timelineSessionEpoch: 0 }); }); +const RETIME_ELEMENT: TimelineElement = { + id: "clip-1", + label: "Clip", + tag: "div", + start: 0, + duration: 10, + track: 0, +}; + function pointerEvent(type: string, init: PointerEventInit): Event { - if (typeof PointerEvent === "function") return new PointerEvent(type, init); - return new MouseEvent(type, init); + const event = + typeof PointerEvent === "function" ? new PointerEvent(type, init) : new MouseEvent(type, init); + if (!("pointerId" in event)) { + Object.defineProperty(event, "pointerId", { value: init.pointerId ?? 0 }); + } + return event; } -function renderDiamonds(onClickKeyframe = vi.fn()) { +function createTimelineHost() { const host = document.createElement("div"); + host.setAttribute("data-timeline-scroll-viewport", ""); document.body.append(host); + return host; +} + +function renderDiamonds(onClickKeyframe = vi.fn()) { + const host = createTimelineHost(); const root = createRoot(host); act(() => { root.render( @@ -44,6 +66,59 @@ function renderDiamonds(onClickKeyframe = vi.fn()) { return { host, root, onClickKeyframe }; } +function renderRetimeLane(onMoveKeyframe = vi.fn().mockResolvedValue(true), strict = false) { + usePlayerStore.setState({ elements: [RETIME_ELEMENT] }); + const host = createTimelineHost(); + const root = createRoot(host); + const onClickKeyframe = vi.fn(); + const lane = ( + + ); + act(() => { + root.render(strict ? {lane} : lane); + }); + const diamond = host.querySelector('button[title="50%"]'); + expect(diamond).not.toBeNull(); + return { diamond: diamond!, host, onClickKeyframe, onMoveKeyframe, root }; +} + describe("TimelineClipDiamonds", () => { it("keeps dense keyframe hit regions and visuals from overlapping", () => { const host = document.createElement("div"); @@ -81,6 +156,23 @@ describe("TimelineClipDiamonds", () => { act(() => root.unmount()); }); + it("publishes retime previews after StrictMode effect replay", () => { + const { diamond, host, root } = renderRetimeLane(undefined, true); + const initialLeft = diamond.style.left; + act(() => { + diamond.dispatchEvent( + pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }), + ); + window.dispatchEvent( + pointerEvent("pointermove", { bubbles: true, clientX: 120, pointerId: 7 }), + ); + }); + expect(host.querySelector('button[title="50%"]')?.style.left).not.toBe( + initialLeft, + ); + act(() => root.unmount()); + }); + it("treats primary pointerup without drag as a keyframe click", () => { const { host, root, onClickKeyframe } = renderDiamonds(); const diamond = host.querySelector('button[title="50%"]'); @@ -115,8 +207,7 @@ describe("TimelineClipDiamonds", () => { it("treats a drag-armed press that resolves to a no-op move as a click", () => { const onClickKeyframe = vi.fn(); const onMoveKeyframe = vi.fn(); - const host = document.createElement("div"); - document.body.append(host); + const host = createTimelineHost(); const root = createRoot(host); act(() => { root.render( @@ -183,8 +274,7 @@ describe("TimelineClipDiamonds", () => { it("reselects a retimed keyframe with its post-move tween percentage", () => { const onClickKeyframe = vi.fn(); const onMoveKeyframe = vi.fn().mockResolvedValue(true); - const host = document.createElement("div"); - document.body.append(host); + const host = createTimelineHost(); const root = createRoot(host); act(() => { root.render( @@ -258,8 +348,7 @@ describe("TimelineClipDiamonds", () => { it("composes a rapid second retime from the pending position", () => { const onMoveKeyframe = vi.fn().mockResolvedValue(true); - const host = document.createElement("div"); - document.body.append(host); + const host = createTimelineHost(); const root = createRoot(host); act(() => { root.render( @@ -332,6 +421,19 @@ describe("TimelineClipDiamonds", () => { }, 85, ); + + act(() => { + usePlayerStore.setState({ timelineSessionEpoch: 1 }); + diamond!.dispatchEvent( + pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }), + ); + diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120 })); + }); + expect(onMoveKeyframe).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ percentage: 50 }), + 60, + ); act(() => root.unmount()); }); @@ -340,8 +442,7 @@ describe("TimelineClipDiamonds", () => { ["rejects", () => Promise.reject(new Error("retime failed"))], ])("clears a failed pending retime when the callback %s", async (_label, settle) => { const onMoveKeyframe = vi.fn().mockImplementationOnce(settle).mockResolvedValue(true); - const host = document.createElement("div"); - document.body.append(host); + const host = createTimelineHost(); const root = createRoot(host); act(() => { root.render( @@ -415,6 +516,141 @@ describe("TimelineClipDiamonds", () => { act(() => root.unmount()); }); + it("commits once from the stable viewport after the source lane unmounts", () => { + const { diamond, onMoveKeyframe, root } = renderRetimeLane(); + + act(() => { + diamond.dispatchEvent( + pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }), + ); + root.unmount(); + window.dispatchEvent( + pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 7 }), + ); + window.dispatchEvent( + pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 140, pointerId: 7 }), + ); + }); + + expect(onMoveKeyframe).toHaveBeenCalledExactlyOnceWith( + { + percentage: 50, + tweenPercentage: 50, + propertyGroup: "position", + animationId: "anim-1", + }, + 60, + ); + }); + + it("includes horizontal viewport scrolling in the retime destination", () => { + const { diamond, host, onMoveKeyframe, root } = renderRetimeLane(); + + act(() => { + diamond.dispatchEvent( + pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }), + ); + host.scrollLeft = 20; + window.dispatchEvent( + pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }), + ); + }); + + expect(onMoveKeyframe).toHaveBeenCalledExactlyOnceWith(expect.any(Object), 60); + act(() => root.unmount()); + }); + + it("ignores another pointer and lets the owning pointer finish", () => { + const { diamond, onMoveKeyframe, root } = renderRetimeLane(); + + act(() => { + diamond.dispatchEvent( + pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }), + ); + window.dispatchEvent( + pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 140, pointerId: 8 }), + ); + window.dispatchEvent( + pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 7 }), + ); + }); + + expect(onMoveKeyframe).toHaveBeenCalledOnce(); + act(() => root.unmount()); + }); + + it("cancels without mutation on pointer cancel or Escape and allows the next retime", () => { + const { diamond, onMoveKeyframe, root } = renderRetimeLane(); + + act(() => { + diamond.dispatchEvent( + pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }), + ); + window.dispatchEvent( + pointerEvent("pointercancel", { + bubbles: true, + button: 0, + clientX: 120, + pointerId: 7, + }), + ); + window.dispatchEvent( + pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 7 }), + ); + + diamond.dispatchEvent( + pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 9 }), + ); + window.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" })); + window.dispatchEvent( + pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 9 }), + ); + + diamond.dispatchEvent( + pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 11 }), + ); + window.dispatchEvent( + pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 11 }), + ); + }); + + expect(onMoveKeyframe).toHaveBeenCalledOnce(); + act(() => root.unmount()); + }); + + it("cancels on project switch or source removal without poisoning the next gesture", () => { + const { diamond, onMoveKeyframe, root } = renderRetimeLane(); + + act(() => { + diamond.dispatchEvent( + pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }), + ); + usePlayerStore.setState({ timelineSessionEpoch: 1 }); + window.dispatchEvent( + pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 7 }), + ); + + diamond.dispatchEvent( + pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 9 }), + ); + usePlayerStore.setState({ elements: [] }); + window.dispatchEvent( + pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 9 }), + ); + + usePlayerStore.setState({ elements: [RETIME_ELEMENT] }); + diamond.dispatchEvent( + pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 11 }), + ); + window.dispatchEvent( + pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 11 }), + ); + }); + + expect(onMoveKeyframe).toHaveBeenCalledOnce(); + 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 @@ -424,8 +660,7 @@ describe("TimelineClipDiamonds", () => { // own clip. suppressClickRef lets that ancestor ignore the stray click. it("arms suppressClickRef synchronously on a keyframe click", () => { const suppressClickRef = { current: false }; - const host = document.createElement("div"); - document.body.append(host); + const host = createTimelineHost(); const root = createRoot(host); act(() => { root.render( @@ -458,8 +693,7 @@ describe("TimelineClipDiamonds", () => { }); const renderSegmentLane = (lastAmbiguous: boolean) => { - const host = document.createElement("div"); - document.body.append(host); + const host = createTimelineHost(); const root = createRoot(host); const kf = (percentage: number, extra: Record = {}) => ({ percentage, @@ -532,8 +766,7 @@ describe("TimelineClipDiamonds", () => { it("hides the inline ease button on a segment with no source animation id", () => { // A runtime-scanned keyframe has no animationId, so there is no tween to // target; the segment ending on it must not render a (dead) ease button. - const host = document.createElement("div"); - document.body.append(host); + const host = createTimelineHost(); const root = createRoot(host); const kf = (percentage: number, animationId?: string) => ({ percentage, diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx index 8362439f03..3e0f312df2 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -1,18 +1,17 @@ import { Fragment, memo, useEffect, useRef, useState } from "react"; import { BEAT_BAND_H } from "./BeatStrip"; -import { - KEYFRAME_DRAG_THRESHOLD_PX, - previewClipPct, - resolveKeyframeDrag, -} from "../../components/editor/keyframeDrag"; import { MiniCurveSvg } from "../../components/editor/EaseCurveSection"; -import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation"; import { LANE_H } from "./timelineLayout"; import { timelineKeyframeSelectionKey, type TimelineKeyframeTarget, } from "./timelineKeyframeIdentity"; import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth"; +import { + beginTimelineKeyframeRetime, + type TimelineKeyframeRetimeHandle, +} from "./useTimelineKeyframeHandlers"; + export interface TimelineDiamondKeyframe { percentage: number; /** Tween-relative percentage (the retime mutation keys on this, not clip %). */ @@ -90,13 +89,6 @@ const DIAMOND_RATIO = 0.8; const KF_MIN_PCT = -5; const KF_MAX_PCT = 105; -type DragState = { - kfKey: string; - startX: number; - fromClipPct: number; - moved: boolean; -}; - function keyframeTarget( keyframe: TimelineDiamondKeyframe, groupAware: boolean, @@ -132,21 +124,14 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({ globalEase = "none", }: TimelineDiamondLaneProps) { // Hooks must run before the early return below. - const dragRef = useRef(null); - // Pending retime destination (clip + tween %) per keyframe key, so a rapid - // second drag composes from where the first move left the keyframe (whose - // cache entry has not rebuilt yet) instead of the stale rendered value. - const pendingRetimeRef = useRef(new Map()); + const mountedRef = useRef(true); + const retimeHandleRef = useRef(null); useEffect(() => { - // Clear a pending entry once the authoritative cache reflects a keyframe at - // ~its destination. Match by tolerance, not equality: cache writers round - // clip %s, so an exact check would leak an entry after every successful retime. - for (const [key, pending] of pendingRetimeRef.current) { - if (keyframesData.keyframes.some((k) => Math.abs(k.percentage - pending.clipPct) < 0.2)) { - pendingRetimeRef.current.delete(key); - } - } - }, [keyframesData.keyframes]); + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); // 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. @@ -322,113 +307,42 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({ if (e.button !== 0) return; e.stopPropagation(); if (canDrag) { - e.currentTarget.setPointerCapture?.(e.pointerId); - dragRef.current = { - kfKey, - startX: e.clientX, - fromClipPct: pendingRetimeRef.current.get(kfKey)?.clipPct ?? kf.percentage, - moved: false, - }; - } - }; - const onPointerMove = (e: React.PointerEvent) => { - const d = dragRef.current; - if (!d || d.kfKey !== kfKey) return; - if (!d.moved && Math.abs(e.clientX - d.startX) >= KEYFRAME_DRAG_THRESHOLD_PX) { - d.moved = true; - } - if (d.moved) { - setPreview({ - kfKey, - clipPct: previewClipPct({ - pointerDownX: d.startX, - pointerMoveX: e.clientX, - clipWidthPx, - draggedClipPct: d.fromClipPct, - draggedIndex: i, - sortedClipPcts, - }), + retimeHandleRef.current = beginTimelineKeyframeRetime({ + event: e, + elementId, + keyframeKey: kfKey, + target, + keyframes: keyframesData.keyframes, + clipWidthPx, + draggedIndex: i, + sortedClipPercentages: sortedClipPcts, + onPreview: (clipPercentage) => { + if (!mountedRef.current) return; + setPreview(clipPercentage === null ? null : { kfKey, clipPct: clipPercentage }); + }, + 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; - // 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 stable viewport coordinator owns an armed retime. This local + // path remains only for non-draggable diamonds. + if (canDrag) { + retimeHandleRef.current?.commit(e); + retimeHandleRef.current = null; + e.stopPropagation(); return; } - e.stopPropagation(); - dragRef.current = null; - setPreview(null); - e.currentTarget.releasePointerCapture?.(e.pointerId); + if (e.button !== 0) return; suppressNextClick(); - const res = resolveKeyframeDrag({ - pointerDownX: d.startX, - pointerUpX: e.clientX, - clipWidthPx, - draggedClipPct: d.fromClipPct, - draggedIndex: i, - sortedClipPcts, - }); - 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 = pendingRetimeRef.current.get(kfKey); - const fromTarget = pendingBefore - ? { - ...target, - percentage: pendingBefore.clipPct, - tweenPercentage: pendingBefore.tweenPct, - } - : target; - const pending = { clipPct: res.toClipPct, tweenPct: newTweenPct }; - pendingRetimeRef.current.set(kfKey, pending); - const clearPending = () => { - if (pendingRetimeRef.current.get(kfKey) === pending) { - pendingRetimeRef.current.delete(kfKey); - } - }; - void onMoveKeyframe?.(fromTarget, res.toClipPct).then((committed) => { - if (!committed) clearPending(); - }, clearPending); - // 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. - onClickKeyframe?.({ - ...target, - percentage: res.toClipPct, - tweenPercentage: newTweenPct, - }); - } + if (e.shiftKey) onShiftClickKeyframe?.(target); + else onClickKeyframe?.(target); }; return ( @@ -459,17 +373,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; - 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/useTimelineKeyframeHandlers.test.tsx b/packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx index 4dcee4bad5..f1060933a8 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,12 +44,15 @@ const COLLIDING_TARGET: TimelineKeyframeTarget = { afterEach(() => { document.body.innerHTML = ""; - trackStudioSegmentEaseEdit.mockClear(); + vi.restoreAllMocks(); usePlayerStore.setState({ focusedEaseSegment: null }); }); describe("useTimelineKeyframeHandlers", () => { it("tracks opening the segment ease editor when a timeline segment is selected", () => { + const trackStudioSegmentEaseEdit = vi + .spyOn(telemetry, "trackStudioSegmentEaseEdit") + .mockImplementation(() => {}); let onSelectSegment: ((elementId: string, target: TimelineKeyframeTarget) => void) | undefined; function Harness() { diff --git a/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts b/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts index 657aac930a..bb54a10563 100644 --- a/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts +++ b/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts @@ -1,13 +1,369 @@ -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 { TimelineElement, KeyframeCacheEntry } from "../store/playerStore"; import { usePlayerStore } from "../store/playerStore"; import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu"; +import { + applyTimelineAutoScrollStep, + resolveTimelineAutoScrollLoopAction, +} from "./timelineEditing"; import { timelineKeyframeSelectionKey, type TimelineKeyframeTarget, } from "./timelineKeyframeIdentity"; +interface TimelineRetimeKeyframe { + percentage: number; + tweenPercentage?: number; + animationId?: string; +} + +interface TimelineKeyframeRetimeInput { + event: ReactPointerEvent; + elementId: string; + keyframeKey: string; + target: TimelineKeyframeTarget; + keyframes: readonly TimelineRetimeKeyframe[]; + clipWidthPx: number; + draggedIndex: number; + sortedClipPercentages: readonly number[]; + onPreview: (clipPercentage: number | null) => void; + onMove: (target: TimelineKeyframeTarget, toClipPercentage: number) => Promise; + onSelect: (target: TimelineKeyframeTarget, additive: boolean) => void; + suppressNextClick: () => void; +} + +interface PendingTimelineKeyframeRetime { + elementId: string; + clipPercentage: number; + tweenPercentage: number; + sessionEpoch: 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; +} + +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() }; + keyframeRetimeCoordinators.set(owner, coordinator); + return coordinator; +} + +function stablePointerId(pointerId: number): number | null { + return Number.isFinite(pointerId) ? pointerId : null; +} + +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"; + actor.onPreview(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) { + if ( + pending.elementId === input.elementId && + input.keyframes.some( + (keyframe) => 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 = () => { + actor.onPreview( + 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 || + !applyTimelineAutoScrollStep(viewport, actor.lastClientX, actor.lastClientY) + ) { + 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 finishCommit = (event: TimelineRetimePointerEvent) => { + if (actor.phase !== "active" || !matchesPointer(event)) return; + if (actor.sessionEpoch !== usePlayerStore.getState().timelineSessionEpoch) { + cancel(actor); + return; + } + actor.phase = "committing"; + actor.lastClientX = event.clientX; + actor.lastClientY = event.clientY; + teardown(); + releaseCapture(); + actor.onPreview(null); + if (coordinator.actor === actor) { + coordinator.actor = null; + } + actor.suppressNextClick(); + + const result = resolveKeyframeDrag({ + pointerDownX: actor.pointerDownX, + pointerUpX: pointerXWithScroll(), + clipWidthPx: actor.clipWidthPx, + draggedClipPct: actor.fromClipPercentage, + draggedIndex: actor.draggedIndex, + sortedClipPcts: actor.sortedClipPercentages, + }); + if (result.kind === "click" || result.kind === "noop") { + actor.onSelect(actor.target, event.shiftKey); + } else if (result.kind === "move" && result.toClipPct !== undefined) { + 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 mappedTweenPercentage = clipToTweenPercentage(animationKeyframes, result.toClipPct); + const newTweenPercentage = tweenPercentages.length + ? Math.max( + Math.min(...tweenPercentages), + Math.min(Math.max(...tweenPercentages), mappedTweenPercentage), + ) + : mappedTweenPercentage; + 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: result.toClipPct, + tweenPercentage: newTweenPercentage, + sessionEpoch: actor.sessionEpoch, + }; + coordinator.pending.set(actor.keyframeKey, nextPending); + const clearPending = () => { + if (coordinator.pending.get(actor.keyframeKey) === nextPending) { + coordinator.pending.delete(actor.keyframeKey); + } + }; + void actor.onMove(fromTarget, result.toClipPct).then((committed) => { + if (!committed) clearPending(); + }, clearPending); + actor.onSelect( + { + ...actor.target, + percentage: result.toClipPct, + tweenPercentage: newTweenPercentage, + }, + false, + ); + } + 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 || + (actor.sourceWasPresent && !sourceStillPresent) + ) { + coordinator.pending.clear(); + 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;