feat(studio): add timeline keyframe retiming interactions

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-27 19:52:05 +02:00
parent 4c7703ff8f
commit e36fb385bc
11 changed files with 772 additions and 77 deletions
@@ -1,3 +1,6 @@
// Boundary cases share an arrange/assert shape on purpose: each case states its
// own window, drag, and expected remap so a failure reads without cross-referencing.
// fallow-ignore-file code-duplication
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { resolveKeyframeRetime, type RetimeKeyframe } from "./keyframeRetime"; import { resolveKeyframeRetime, type RetimeKeyframe } from "./keyframeRetime";
@@ -94,12 +97,12 @@ describe("resolveKeyframeRetime — resize (past the tween boundary)", () => {
expect(r.kind).toBe("resize"); expect(r.kind).toBe("resize");
expect(r.position).toBeCloseTo(2, 5); // start unchanged expect(r.position).toBeCloseTo(2, 5); // start unchanged
expect(r.duration).toBeCloseTo(6, 5); // 8 - 2 expect(r.duration).toBeCloseTo(6, 5); // 8 - 2
// abs 2/4/8 over the new [2,8] window → 0 / 33.3 / 100. pctRemap carries each // abs 2/4/8 over the new [2,8] window → 0 / 33.333 / 100. pctRemap carries each
// existing keyframe's old→new tween-%; the commit re-keys in place (value + // existing keyframe's old→new tween-%; the commit re-keys in place (value +
// ease + _auto preserved by round-tripping the source node, not re-emitted here). // ease + _auto preserved by round-tripping the source node, not re-emitted here).
expect(r.pctRemap).toEqual([ expect(r.pctRemap).toEqual([
{ from: 0, to: 0 }, { from: 0, to: 0 },
{ from: 50, to: 33.3 }, { from: 50, to: 33.333 },
{ from: 100, to: 100 }, { from: 100, to: 100 },
]); ]);
}); });
@@ -113,10 +116,10 @@ describe("resolveKeyframeRetime — resize (past the tween boundary)", () => {
expect(r.kind).toBe("resize"); expect(r.kind).toBe("resize");
expect(r.position).toBeCloseTo(0.5, 5); expect(r.position).toBeCloseTo(0.5, 5);
expect(r.duration).toBeCloseTo(5.5, 5); // 6 - 0.5 expect(r.duration).toBeCloseTo(5.5, 5); // 6 - 0.5
// abs 0.5/4/6 over [0.5,6] → 0 / 63.6 / 100. // abs 0.5/4/6 over [0.5,6] → 0 / 63.636 / 100.
expect(r.pctRemap).toEqual([ expect(r.pctRemap).toEqual([
{ from: 0, to: 0 }, { from: 0, to: 0 },
{ from: 50, to: 63.6 }, { from: 50, to: 63.636 },
{ from: 100, to: 100 }, { from: 100, to: 100 },
]); ]);
}); });
@@ -55,7 +55,6 @@ const EPSILON_TIME = 1e-4;
const MIN_TWEEN_DURATION = 0.01; const MIN_TWEEN_DURATION = 0.01;
const round3 = (n: number) => Math.round(n * 1000) / 1000; const round3 = (n: number) => Math.round(n * 1000) / 1000;
const round1 = (n: number) => Math.round(n * 10) / 10; // 0.1% precision
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n)); const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
/** Resolve timing for a flat tween's synthesized start/end diamond. */ /** Resolve timing for a flat tween's synthesized start/end diamond. */
@@ -153,7 +152,7 @@ export function resolveKeyframeRetime(opts: {
const pctRemap: KeyframePctRemap[] = keyframes.map((kf, i) => { const pctRemap: KeyframePctRemap[] = keyframes.map((kf, i) => {
const absTime = const absTime =
i === draggedIdx ? dropAbsTime : tweenStart + (kf.percentage / 100) * tweenDuration; i === draggedIdx ? dropAbsTime : tweenStart + (kf.percentage / 100) * tweenDuration;
return { from: kf.percentage, to: round1(((absTime - newStart) / newDuration) * 100) }; return { from: kf.percentage, to: round3(((absTime - newStart) / newDuration) * 100) };
}); });
return { return {
@@ -152,13 +152,13 @@ export function useTimelineEditCallbacks({
// resizes the tween — position/duration grow so the dragged keyframe lands at // resizes the tween — position/duration grow so the dragged keyframe lands at
// the drop while every other keyframe keeps its absolute time (value+ease too). // the drop while every other keyframe keeps its absolute time (value+ease too).
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => { onMoveKeyframe: async (_elId: string, fromClipPct: number, toClipPct: number) => {
const target = resolveKeyframeTarget(fromClipPct); const target = resolveKeyframeTarget(fromClipPct);
const sel = domEditSelection; const sel = domEditSelection;
if (!target || !sel) return; if (!target || !sel) return false;
const anim = selectedGsapAnimations.find((a) => a.id === target.animId); const anim = selectedGsapAnimations.find((a) => a.id === target.animId);
const tweenStart = anim ? resolveTweenStart(anim) : null; const tweenStart = anim ? resolveTweenStart(anim) : null;
if (!anim || tweenStart === null) return; if (!anim || tweenStart === null) return false;
const tweenDuration = anim.duration ?? resolveTweenDuration(anim); const tweenDuration = anim.duration ?? resolveTweenDuration(anim);
const sourceFile = sel.sourceFile || activeCompPath || "index.html"; const sourceFile = sel.sourceFile || activeCompPath || "index.html";
const { elements, domClipChildren } = usePlayerStore.getState(); const { elements, domClipChildren } = usePlayerStore.getState();
@@ -200,7 +200,10 @@ export function useTimelineEditCallbacks({
duration: decision.duration, duration: decision.duration,
}); });
} }
} else {
return false;
} }
return true;
}, },
onChangeKeyframeEase: (_elId: string, _pct: number, ease: string) => { onChangeKeyframeEase: (_elId: string, _pct: number, ease: string) => {
for (const anim of selectedGsapAnimations) { for (const anim of selectedGsapAnimations) {
@@ -8,18 +8,32 @@ export interface KeyframeDiamondContextMenuState {
elementId: string; elementId: string;
percentage: number; percentage: number;
tweenPercentage?: number; tweenPercentage?: number;
propertyGroup?: string;
animationId?: string;
currentEase?: string; currentEase?: string;
} }
interface KeyframeDiamondContextMenuProps { interface KeyframeDiamondContextMenuProps {
state: KeyframeDiamondContextMenuState; state: KeyframeDiamondContextMenuState;
onClose: () => void; onClose: () => void;
onDelete: (elementId: string, percentage: number) => void; onDelete: (
elementId: string,
percentage: number,
propertyGroup?: string,
tweenPercentage?: number,
animationId?: string,
) => void;
onDeleteAll: (elementId: string) => void; onDeleteAll: (elementId: string) => void;
onChangeEase?: (elementId: string, percentage: number, ease: string) => void; onChangeEase?: (elementId: string, percentage: number, ease: string) => void;
onCopyProperties?: (elementId: string, percentage: number) => void; onCopyProperties?: (elementId: string, percentage: number) => void;
/** Retime the keyframe to the current playhead, preserving its value + ease. */ /** Retime the keyframe to the current playhead, preserving its value + ease. */
onMoveToPlayhead?: (elementId: string, fromPercentage: number) => void; onMoveToPlayhead?: (
elementId: string,
fromPercentage: number,
propertyGroup?: string,
tweenPercentage?: number,
animationId?: string,
) => void;
} }
export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({
@@ -51,7 +65,13 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
// Pass clip-% — resolveKeyframeTarget keys the cache lookup on clip-% // Pass clip-% — resolveKeyframeTarget keys the cache lookup on clip-%
// and returns the tween-% for the mutation. Passing tween-% here would // and returns the tween-% for the mutation. Passing tween-% here would
// miss the lookup on any tween whose window is shorter than the clip. // miss the lookup on any tween whose window is shorter than the clip.
onMoveToPlayhead(state.elementId, state.percentage); onMoveToPlayhead(
state.elementId,
state.percentage,
state.propertyGroup,
state.tweenPercentage,
state.animationId,
);
onClose(); onClose();
}} }}
> >
@@ -64,7 +84,13 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
type="button" type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left" className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
onClick={() => { onClick={() => {
onDelete(state.elementId, state.percentage); onDelete(
state.elementId,
state.percentage,
state.propertyGroup,
state.tweenPercentage,
state.animationId,
);
onClose(); onClose();
}} }}
> >
@@ -3,7 +3,7 @@
import React, { act } from "react"; import React, { act } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { TimelineClipDiamonds } from "./TimelineClipDiamonds"; import { TimelineClipDiamonds, TimelineDiamondLane } from "./TimelineClipDiamonds";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -84,12 +84,24 @@ describe("TimelineClipDiamonds", () => {
const root = createRoot(host); const root = createRoot(host);
act(() => { act(() => {
root.render( root.render(
<TimelineClipDiamonds <TimelineDiamondLane
keyframesData={{ keyframesData={{
format: "percentage", format: "percentage",
keyframes: [ keyframes: [
{ percentage: 0, properties: { x: 0 } }, {
{ percentage: 50, properties: { x: 100 } }, percentage: 0,
tweenPercentage: 0,
propertyGroup: "position",
animationId: "anim-1",
properties: { x: 0 },
},
{
percentage: 50,
tweenPercentage: 100,
propertyGroup: "position",
animationId: "anim-1",
properties: { x: 100 },
},
], ],
}} }}
clipWidthPx={5000} clipWidthPx={5000}
@@ -101,6 +113,7 @@ describe("TimelineClipDiamonds", () => {
selectedKeyframes={new Set()} selectedKeyframes={new Set()}
onClickKeyframe={onClickKeyframe} onClickKeyframe={onClickKeyframe}
onMoveKeyframe={onMoveKeyframe} onMoveKeyframe={onMoveKeyframe}
groupAware
/>, />,
); );
}); });
@@ -117,7 +130,12 @@ describe("TimelineClipDiamonds", () => {
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 })); diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 }));
}); });
expect(onClickKeyframe).toHaveBeenCalledWith(50); expect(onClickKeyframe).toHaveBeenCalledWith({
percentage: 50,
tweenPercentage: 100,
propertyGroup: "position",
animationId: "anim-1",
});
expect(onMoveKeyframe).not.toHaveBeenCalled(); expect(onMoveKeyframe).not.toHaveBeenCalled();
act(() => root.unmount()); act(() => root.unmount());
}); });
@@ -126,20 +144,39 @@ describe("TimelineClipDiamonds", () => {
// keyframe) committed the move but never selected/parked on the result — // keyframe) committed the move but never selected/parked on the result —
// the diamond it was just dragged looked exactly like one nothing happened // the diamond it was just dragged looked exactly like one nothing happened
// to. Select it at its NEW position too. // to. Select it at its NEW position too.
it("selects the keyframe at its new position after a real drag-retime", () => { it("reselects a retimed keyframe with its post-move tween percentage", () => {
const onClickKeyframe = vi.fn(); const onClickKeyframe = vi.fn();
const onMoveKeyframe = vi.fn(); const onMoveKeyframe = vi.fn().mockResolvedValue(true);
const host = document.createElement("div"); const host = document.createElement("div");
document.body.append(host); document.body.append(host);
const root = createRoot(host); const root = createRoot(host);
act(() => { act(() => {
root.render( root.render(
<TimelineClipDiamonds <TimelineDiamondLane
keyframesData={{ keyframesData={{
format: "percentage", format: "percentage",
keyframes: [ keyframes: [
{ percentage: 0, properties: { x: 0 } }, {
{ percentage: 50, properties: { x: 100 } }, percentage: 20,
tweenPercentage: 0,
propertyGroup: "position",
animationId: "anim-1",
properties: { x: 0 },
},
{
percentage: 40,
tweenPercentage: 50,
propertyGroup: "position",
animationId: "anim-1",
properties: { x: 100 },
},
{
percentage: 60,
tweenPercentage: 100,
propertyGroup: "position",
animationId: "anim-1",
properties: { x: 200 },
},
], ],
}} }}
clipWidthPx={200} clipWidthPx={200}
@@ -151,6 +188,81 @@ describe("TimelineClipDiamonds", () => {
selectedKeyframes={new Set()} selectedKeyframes={new Set()}
onClickKeyframe={onClickKeyframe} onClickKeyframe={onClickKeyframe}
onMoveKeyframe={onMoveKeyframe} onMoveKeyframe={onMoveKeyframe}
groupAware
/>,
);
});
const diamond = host.querySelector<HTMLButtonElement>('button[title="40%"]');
expect(diamond).not.toBeNull();
act(() => {
diamond!.dispatchEvent(
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 80 }),
);
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 100 }));
});
expect(onMoveKeyframe).toHaveBeenCalledWith(
{
percentage: 40,
tweenPercentage: 50,
propertyGroup: "position",
animationId: "anim-1",
},
50,
);
expect(onClickKeyframe).toHaveBeenCalledWith({
percentage: 50,
tweenPercentage: 75,
propertyGroup: "position",
animationId: "anim-1",
});
act(() => root.unmount());
});
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 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
/>, />,
); );
}); });
@@ -161,13 +273,109 @@ describe("TimelineClipDiamonds", () => {
diamond!.dispatchEvent( diamond!.dispatchEvent(
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }), pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
); );
// 4px at a 200px clip width is 2 clip-% — well past the no-op epsilon, diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 }));
// a real retime.
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 })); // 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).toHaveBeenCalledWith("clip-1", 50, 52); // The second move must identify the FROM keyframe by the pending (already-
expect(onClickKeyframe).toHaveBeenCalledWith(52); // moved) position 75%, not the stale rendered 50%; otherwise the serialized
// mutation can't locate the keyframe the first move relocated.
expect(onMoveKeyframe).toHaveBeenNthCalledWith(
2,
{
percentage: 75,
tweenPercentage: 75,
propertyGroup: "position",
animationId: "anim-1",
},
85,
);
act(() => root.unmount());
});
it.each([
["returns false", () => Promise.resolve(false)],
["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 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();
await act(async () => {
diamond!.dispatchEvent(
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
);
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 }));
await Promise.resolve();
});
act(() => {
diamond!.dispatchEvent(
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
);
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120 }));
});
expect(onMoveKeyframe).toHaveBeenNthCalledWith(
2,
{
percentage: 50,
tweenPercentage: 50,
propertyGroup: "position",
animationId: "anim-1",
},
60,
);
act(() => root.unmount()); act(() => root.unmount());
}); });
@@ -212,4 +420,88 @@ describe("TimelineClipDiamonds", () => {
expect(suppressClickRef.current).toBe(true); expect(suppressClickRef.current).toBe(true);
act(() => root.unmount()); act(() => root.unmount());
}); });
const renderSegmentLane = (lastAmbiguous: boolean) => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const kf = (percentage: number, extra: Record<string, unknown> = {}) => ({
percentage,
tweenPercentage: percentage,
propertyGroup: "position",
animationId: "anim-1",
properties: { x: percentage },
...extra,
});
act(() => {
root.render(
<TimelineDiamondLane
keyframesData={{
format: "percentage",
keyframes: [kf(0), kf(50), kf(100, { easeAmbiguous: lastAmbiguous })],
}}
clipWidthPx={200}
clipHeightPx={48}
accentColor="#4ba3d2"
isSelected
currentPercentage={0}
elementId="clip-1"
selectedKeyframes={new Set()}
onSelectSegment={vi.fn()}
groupAware
/>,
);
});
return { host, root };
};
it("hides the inline ease button on an ambiguous merged segment", () => {
// Segments 0->50 and 50->100; the 50->100 segment ends on the ambiguous
// keyframe, so its hover/ease-button area is not rendered.
const { host, root } = renderSegmentLane(true);
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1);
act(() => root.unmount());
});
it("keeps the inline ease button on unambiguous merged segments", () => {
const { host, root } = renderSegmentLane(false);
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
act(() => root.unmount());
});
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 root = createRoot(host);
const kf = (percentage: number, animationId?: string) => ({
percentage,
tweenPercentage: percentage,
propertyGroup: "position",
...(animationId ? { animationId } : {}),
properties: { x: percentage },
});
act(() => {
root.render(
<TimelineDiamondLane
keyframesData={{
format: "percentage",
keyframes: [kf(0, "anim-1"), kf(50, "anim-1"), kf(100)],
}}
clipWidthPx={200}
clipHeightPx={48}
accentColor="#4ba3d2"
isSelected
currentPercentage={0}
elementId="clip-1"
selectedKeyframes={new Set()}
onSelectSegment={vi.fn()}
groupAware
/>,
);
});
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1);
act(() => root.unmount());
});
}); });
@@ -1,22 +1,33 @@
import { memo, useRef, useState } from "react"; import { Fragment, memo, useEffect, useRef, useState } from "react";
import { BEAT_BAND_H } from "./BeatStrip"; import { BEAT_BAND_H } from "./BeatStrip";
import { import {
KEYFRAME_DRAG_THRESHOLD_PX, KEYFRAME_DRAG_THRESHOLD_PX,
previewClipPct, previewClipPct,
resolveKeyframeDrag, resolveKeyframeDrag,
} from "../../components/editor/keyframeDrag"; } from "../../components/editor/keyframeDrag";
import { MiniCurveSvg } from "../../components/editor/EaseCurveSection";
interface KeyframeEntry { import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation";
import { LANE_H } from "./timelineLayout";
import {
timelineKeyframeSelectionKey,
type TimelineKeyframeTarget,
} from "./timelineKeyframeIdentity";
interface TimelineDiamondKeyframe {
percentage: number; percentage: number;
/** Tween-relative percentage (the retime mutation keys on this, not clip %). */ /** Tween-relative percentage (the retime mutation keys on this, not clip %). */
tweenPercentage?: number; tweenPercentage?: number;
propertyGroup?: string;
animationId?: string;
properties: Record<string, number | string>; properties: Record<string, number | string>;
ease?: string; ease?: string;
/** Set when 2+ source animations collide at this percentage (a single inline
* ease button can't target one): the collapsed row hides the button here. */
easeAmbiguous?: boolean;
} }
interface KeyframeCacheEntry { interface KeyframeCacheEntry {
format: string; format: string;
keyframes: KeyframeEntry[]; keyframes: TimelineDiamondKeyframe[];
ease?: string; ease?: string;
easeEach?: string; easeEach?: string;
} }
@@ -32,7 +43,7 @@ interface TimelineClipDiamondsProps {
isSelected: boolean; isSelected: boolean;
currentPercentage: number; currentPercentage: number;
elementId: string; elementId: string;
selectedKeyframes: Set<string>; selectedKeyframes: ReadonlySet<string>;
onClickKeyframe?: (percentage: number) => void; onClickKeyframe?: (percentage: number) => void;
onShiftClickKeyframe?: (elementId: string, percentage: number) => void; onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
@@ -44,13 +55,33 @@ interface TimelineClipDiamondsProps {
elementId: string, elementId: string,
fromClipPercentage: number, fromClipPercentage: number,
toClipPercentage: number, toClipPercentage: number,
) => void; ) => Promise<boolean>;
/** Open the segment ease editor for the hovered mid-point button available on
* the inline clip row too, not just the expanded lanes. */
onSelectSegment?: (elementId: string, target: TimelineKeyframeTarget) => void;
/** Set while resolving a diamond press so the ancestor clip's onClick (which /** Set while resolving a diamond press so the ancestor clip's onClick (which
* toggles selection off when already selected) ignores the native "click" * toggles selection off when already selected) ignores the native "click"
* the browser auto-synthesizes after this button's pointerdown+pointerup. */ * the browser auto-synthesizes after this button's pointerdown+pointerup. */
suppressClickRef?: React.RefObject<boolean>; suppressClickRef?: React.RefObject<boolean>;
} }
interface TimelineDiamondLaneProps extends Omit<
TimelineClipDiamondsProps,
| "onClickKeyframe"
| "onShiftClickKeyframe"
| "onContextMenuKeyframe"
| "onMoveKeyframe"
| "onSelectSegment"
> {
groupAware?: boolean;
globalEase?: string;
onSelectSegment?: (target: TimelineKeyframeTarget) => void;
onClickKeyframe?: (target: TimelineKeyframeTarget) => void;
onShiftClickKeyframe?: (target: TimelineKeyframeTarget) => void;
onContextMenuKeyframe?: (e: React.MouseEvent, target: TimelineKeyframeTarget) => void;
onMoveKeyframe?: (target: TimelineKeyframeTarget, toClipPercentage: number) => Promise<boolean>;
}
const DIAMOND_RATIO = 0.8; const DIAMOND_RATIO = 0.8;
// Percentage tolerance for rendering keyframes near clip boundaries. Keyframes // Percentage tolerance for rendering keyframes near clip boundaries. Keyframes
// slightly outside [0, 100] (from rounding or stale cache during the async // slightly outside [0, 100] (from rounding or stale cache during the async
@@ -66,7 +97,21 @@ type DragState = {
moved: boolean; moved: boolean;
}; };
export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ function keyframeTarget(
keyframe: TimelineDiamondKeyframe,
groupAware: boolean,
): TimelineKeyframeTarget {
return groupAware
? {
percentage: keyframe.percentage,
tweenPercentage: keyframe.tweenPercentage,
propertyGroup: keyframe.propertyGroup,
animationId: keyframe.animationId,
}
: { percentage: keyframe.percentage };
}
export const TimelineDiamondLane = memo(function TimelineDiamondLane({
keyframesData, keyframesData,
clipWidthPx, clipWidthPx,
clipHeightPx, clipHeightPx,
@@ -80,14 +125,35 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
onShiftClickKeyframe, onShiftClickKeyframe,
onContextMenuKeyframe, onContextMenuKeyframe,
onMoveKeyframe, onMoveKeyframe,
onSelectSegment,
suppressClickRef, suppressClickRef,
}: TimelineClipDiamondsProps) { groupAware = false,
globalEase = "none",
}: TimelineDiamondLaneProps) {
// Hooks must run before the early return below. // Hooks must run before the early return below.
const dragRef = useRef<DragState | null>(null); const dragRef = useRef<DragState | null>(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<string, { clipPct: number; tweenPct: number }>());
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]);
// Visual-only preview of the dragged diamond's clip-% — no runtime/GSAP hold // 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 // (that optimistic hold was the #1763 flake). The atomic move-keyframe commit
// on drop re-keys the diamond from source. // on drop re-keys the diamond from source.
const [preview, setPreview] = useState<{ kfKey: string; clipPct: number } | null>(null); const [preview, setPreview] = useState<{ kfKey: string; clipPct: number } | null>(null);
// 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).
const [hoveredSegment, setHoveredSegment] = useState<number | null>(null);
// The button element can re-render (reposition/unmount) synchronously from // The button element can re-render (reposition/unmount) synchronously from
// the state updates onClickKeyframe/onMoveKeyframe trigger, before the // the state updates onClickKeyframe/onMoveKeyframe trigger, before the
// browser gets to auto-synthesize the "click" event that normally follows // browser gets to auto-synthesize the "click" event that normally follows
@@ -108,7 +174,12 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
// When the beat strip occupies the top band, shrink the diamonds and center // When the beat strip occupies the top band, shrink the diamonds and center
// them in the remaining bottom region so they don't collide with it. // them in the remaining bottom region so they don't collide with it.
const diamondSize = Math.round(clipHeightPx * (beatsActive ? 0.45 : DIAMOND_RATIO)); // One consistent keyframe-diamond size everywhere (clip bars + property lanes),
// matching the property-lane size (LANE_H · ratio). Beat-strip tracks still
// shrink to fit under the strip.
const diamondSize = beatsActive
? Math.round(clipHeightPx * 0.45)
: Math.round(LANE_H * DIAMOND_RATIO);
const half = diamondSize / 2; const half = diamondSize / 2;
const centerY = beatsActive ? BEAT_BAND_H + (clipHeightPx - BEAT_BAND_H) / 2 : clipHeightPx / 2; const centerY = beatsActive ? BEAT_BAND_H + (clipHeightPx - BEAT_BAND_H) / 2 : clipHeightPx / 2;
const sorted = keyframesData.keyframes const sorted = keyframesData.keyframes
@@ -141,32 +212,90 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
const x1 = Math.max(0, Math.min(clipWidthPx, (prev.percentage / 100) * clipWidthPx)); const x1 = Math.max(0, Math.min(clipWidthPx, (prev.percentage / 100) * clipWidthPx));
const x2 = Math.max(0, Math.min(clipWidthPx, (kf.percentage / 100) * clipWidthPx)); const x2 = Math.max(0, Math.min(clipWidthPx, (kf.percentage / 100) * clipWidthPx));
if (x2 - x1 < 1) return null; if (x2 - x1 < 1) return null;
// Group-aware target for the ease button: the segment ease is
// per-keyframe (each keyframe carries its own animationId/tweenPercentage).
// On a merged inline row the button is hidden where the segment is
// ambiguous (two source animations collide at this % with different
// eases; see easeAmbiguous) or the keyframe has no source animation id
// (runtime-scanned) so there is no tween to target.
const target = keyframeTarget(kf, true);
const ease = kf.ease ?? globalEase;
return ( return (
<div <Fragment key={`line-${i}-${prev.percentage}-${kf.percentage}`}>
key={`line-${i}-${prev.percentage}-${kf.percentage}`} <div
className="absolute" className="absolute"
style={{ data-keyframe-connector={groupAware ? "" : undefined}
left: x1, style={{
top: centerY, left: x1,
width: x2 - x1, top: centerY,
height: 2, width: x2 - x1,
transform: "translateY(-1px)", height: 2,
background: baseColor, transform: "translateY(-1px)",
opacity: baseOpacity, background: baseColor,
borderRadius: 1, opacity: baseOpacity,
}} borderRadius: 1,
/> }}
/>
{onSelectSegment && !kf.easeAmbiguous && kf.animationId !== undefined && (
<div
className="absolute"
data-keyframe-ease-segment=""
style={{
left: x1,
top: centerY,
width: x2 - x1,
height: 18,
transform: "translateY(-50%)",
pointerEvents: "auto",
}}
onMouseEnter={() => setHoveredSegment(i)}
onMouseLeave={() => setHoveredSegment((h) => (h === i ? null : h))}
>
{hoveredSegment === i && (
<button
type="button"
data-keyframe-ease-button=""
aria-label={`Edit ${ease} easing`}
title={`Edit ${ease} easing`}
className="absolute flex items-center justify-center rounded"
style={{
left: "50%",
top: "50%",
width: 16,
height: 16,
transform: "translate(-50%, -50%)",
zIndex: 3,
pointerEvents: "auto",
padding: 0,
border: "1px solid rgba(255, 255, 255, 0.14)",
background: "#171717",
cursor: "pointer",
}}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onSelectSegment(target);
}}
>
<MiniCurveSvg ease={ease} active size={12} />
</button>
)}
</div>
)}
</Fragment>
); );
})} })}
{sorted.map((kf, i) => { {sorted.map((kf, i) => {
const kfKey = `${elementId}:${kf.percentage}`; const target = keyframeTarget(kf, groupAware);
const kfKey = timelineKeyframeSelectionKey(elementId, target);
// While dragging this diamond, render it at the live preview clip-%. // While dragging this diamond, render it at the live preview clip-%.
const renderPct = preview?.kfKey === kfKey ? preview.clipPct : kf.percentage; const renderPct = preview?.kfKey === kfKey ? preview.clipPct : kf.percentage;
// Center the diamond ON its keyframe %: left = (% · width) half so the // Center the diamond ON its keyframe %: left = (% · width) half, so the
// diamond's midpoint sits exactly at the percentage. At 0% the midpoint // diamond's midpoint sits exactly on the playhead/ruler x for that time.
// is the clip's left edge (the left half overflows, which the // The 0% diamond's left half lands in the reserved left gutter (the
// overflow-visible clip shows) — NOT shifted fully inside. // content origin is inset past the label column, Figma-style) so it stays
// fully visible instead of being clipped by the sticky label column.
const leftPx = (renderPct / 100) * clipWidthPx - half; const leftPx = (renderPct / 100) * clipWidthPx - half;
const isKfSelected = selectedKeyframes.has(kfKey); const isKfSelected = selectedKeyframes.has(kfKey);
const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5; const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
@@ -181,7 +310,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
dragRef.current = { dragRef.current = {
kfKey, kfKey,
startX: e.clientX, startX: e.clientX,
fromClipPct: kf.percentage, fromClipPct: pendingRetimeRef.current.get(kfKey)?.clipPct ?? kf.percentage,
moved: false, moved: false,
}; };
} }
@@ -212,8 +341,8 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
if (!d || d.kfKey !== kfKey) { if (!d || d.kfKey !== kfKey) {
if (e.button !== 0) return; if (e.button !== 0) return;
suppressNextClick(); suppressNextClick();
if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage); if (e.shiftKey) onShiftClickKeyframe?.(target);
else onClickKeyframe?.(kf.percentage); else onClickKeyframe?.(target);
return; return;
} }
e.stopPropagation(); e.stopPropagation();
@@ -235,14 +364,54 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
// back onto ~the same position — no real retime, so treat it as the // 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/ // click it was. Otherwise a normal click with a few px of mouse/
// trackpad drift silently does nothing: no selection, no move. // trackpad drift silently does nothing: no selection, no move.
if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage); if (e.shiftKey) onShiftClickKeyframe?.(target);
else onClickKeyframe?.(kf.percentage); else onClickKeyframe?.(target);
} else if (res.kind === "move" && res.toClipPct != null) { } else if (res.kind === "move" && res.toClipPct != null) {
onMoveKeyframe?.(elementId, d.fromClipPct, res.toClipPct); 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 // 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 // new position, same as a plain click, or a drag that actually moved
// something looks identical to one that silently did nothing. // something looks identical to one that silently did nothing.
onClickKeyframe?.(res.toClipPct); onClickKeyframe?.({
...target,
percentage: res.toClipPct,
tweenPercentage: newTweenPct,
});
} }
}; };
@@ -251,6 +420,10 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
key={`${i}-${kf.percentage}`} key={`${i}-${kf.percentage}`}
type="button" type="button"
className="absolute" className="absolute"
data-keyframe-group={groupAware ? kf.propertyGroup : undefined}
data-keyframe-percentage={
groupAware ? (kf.tweenPercentage ?? kf.percentage) : undefined
}
style={{ style={{
left: leftPx, left: leftPx,
top: centerY, top: centerY,
@@ -268,10 +441,19 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} 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);
}}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onContextMenuKeyframe?.(e, elementId, kf.percentage); onContextMenuKeyframe?.(e, target);
}} }}
title={`${kf.percentage}%`} title={`${kf.percentage}%`}
> >
@@ -297,3 +479,33 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
</div> </div>
); );
}); });
export const TimelineClipDiamonds = memo(function TimelineClipDiamonds(
props: TimelineClipDiamondsProps,
) {
return (
<TimelineDiamondLane
{...props}
globalEase={props.keyframesData.ease}
onClickKeyframe={(target) => props.onClickKeyframe?.(target.percentage)}
onShiftClickKeyframe={(target) =>
props.onShiftClickKeyframe?.(props.elementId, target.percentage)
}
onContextMenuKeyframe={(e, target) =>
props.onContextMenuKeyframe?.(e, props.elementId, target.percentage)
}
onMoveKeyframe={
props.onMoveKeyframe
? (target, toClipPercentage) =>
props.onMoveKeyframe?.(props.elementId, target.percentage, toClipPercentage) ??
Promise.resolve(false)
: undefined
}
onSelectSegment={
props.onSelectSegment
? (target) => props.onSelectSegment?.(props.elementId, target)
: undefined
}
/>
);
});
@@ -78,7 +78,7 @@ export interface TimelineLaneBaseProps {
elementId: string, elementId: string,
fromClipPercentage: number, fromClipPercentage: number,
toClipPercentage: number, toClipPercentage: number,
) => void; ) => Promise<boolean>;
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void; onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
/** /**
* Right-click on EMPTY lane space (not on a clip those preventDefault * Right-click on EMPTY lane space (not on a clip those preventDefault
@@ -70,6 +70,6 @@ export interface TimelineEditCallbacks {
elementId: string, elementId: string,
fromClipPercentage: number, fromClipPercentage: number,
toClipPercentage: number, toClipPercentage: number,
) => void; ) => Promise<boolean>;
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void; onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
} }
@@ -0,0 +1,17 @@
export interface TimelineKeyframeTarget {
percentage: number;
tweenPercentage?: number;
propertyGroup?: string;
animationId?: string;
}
export function timelineKeyframeSelectionKey(
elementId: string,
target: TimelineKeyframeTarget,
): string {
if (!target.propertyGroup) return `${elementId}:${target.percentage}`;
const groupKey = target.animationId
? `${target.propertyGroup}:${target.animationId}`
: target.propertyGroup;
return `${elementId}:${groupKey}:${target.percentage}`;
}
@@ -0,0 +1,108 @@
// @vitest-environment happy-dom
import React, { act } from "react";
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 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",
tag: "div",
start: 1,
duration: 2,
track: 0,
};
const TARGET: TimelineKeyframeTarget = {
percentage: 50,
tweenPercentage: 50,
propertyGroup: "position",
animationId: "position-tween",
};
const FLAT_TWEEN_TARGET: TimelineKeyframeTarget = {
percentage: 100,
tweenPercentage: 100,
propertyGroup: "position",
animationId: "position-tween",
};
afterEach(() => {
document.body.innerHTML = "";
trackStudioSegmentEaseEdit.mockClear();
usePlayerStore.setState({ focusedEaseSegment: null });
});
describe("useTimelineKeyframeHandlers", () => {
it("tracks opening the segment ease editor when a timeline segment is selected", () => {
let onSelectSegment: ((elementId: string, target: TimelineKeyframeTarget) => void) | undefined;
function Harness() {
({ onSelectSegment } = useTimelineKeyframeHandlers({
expandedElements: [ELEMENT],
keyframeCache: new Map(),
setSelectedElementId: vi.fn(),
setKfContextMenu: vi.fn(),
toggleSelectedKeyframe: vi.fn(),
}));
return null;
}
const root = mountReactHarness(<Harness />);
act(() => onSelectSegment?.(ELEMENT.id, TARGET));
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledOnce();
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledWith({ action: "open" });
act(() => root.unmount());
});
it("focuses a flat tween segment without seeking, while keyframe clicks still seek", () => {
const onSeek = vi.fn();
const onSelectElement = vi.fn();
const setSelectedElementId = vi.fn();
let onClickKeyframe:
| ((el: TimelineElement, target: TimelineKeyframeTarget) => void)
| undefined;
let onSelectSegment: ((elementId: string, target: TimelineKeyframeTarget) => void) | undefined;
function Harness() {
({ onClickKeyframe, onSelectSegment } = useTimelineKeyframeHandlers({
expandedElements: [ELEMENT],
keyframeCache: new Map(),
onSelectElement,
onSeek,
setSelectedElementId,
setKfContextMenu: vi.fn(),
toggleSelectedKeyframe: vi.fn(),
}));
return null;
}
const root = mountReactHarness(<Harness />);
// Selecting a segment must NOT move the playhead.
act(() => onSelectSegment?.(ELEMENT.id, FLAT_TWEEN_TARGET));
expect(onSeek).not.toHaveBeenCalled();
expect(usePlayerStore.getState().focusedEaseSegment).toEqual({
animationId: "position-tween",
tweenPercentage: 100,
elementId: ELEMENT.id,
});
expect(setSelectedElementId).toHaveBeenCalledWith(ELEMENT.id);
expect(onSelectElement).toHaveBeenCalledWith(ELEMENT);
// Clicking the keyframe itself still seeks to it (start 1 + 50% of 2 = 2).
act(() => onClickKeyframe?.(ELEMENT, TARGET));
expect(onSeek).toHaveBeenCalledExactlyOnceWith(2);
act(() => root.unmount());
});
});
@@ -1,7 +1,12 @@
import { useCallback, type MouseEvent as ReactMouseEvent } from "react"; import { useCallback, type MouseEvent as ReactMouseEvent } from "react";
import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore"; import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore";
import { usePlayerStore } from "../store/playerStore"; import { usePlayerStore } from "../store/playerStore";
import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu"; import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
import {
timelineKeyframeSelectionKey,
type TimelineKeyframeTarget,
} from "./timelineKeyframeIdentity";
interface UseTimelineKeyframeHandlersInput { interface UseTimelineKeyframeHandlersInput {
expandedElements: TimelineElement[]; expandedElements: TimelineElement[];
@@ -23,42 +28,71 @@ export function useTimelineKeyframeHandlers({
toggleSelectedKeyframe, toggleSelectedKeyframe,
}: UseTimelineKeyframeHandlersInput) { }: UseTimelineKeyframeHandlersInput) {
const onClickKeyframe = useCallback( const onClickKeyframe = useCallback(
(el: TimelineElement, pct: number) => { (el: TimelineElement, target: TimelineKeyframeTarget, options?: { seek?: boolean }) => {
usePlayerStore.getState().clearSelectedKeyframes(); usePlayerStore.getState().clearSelectedKeyframes();
const elKey = el.key ?? el.id; const elKey = el.key ?? el.id;
setSelectedElementId(elKey); setSelectedElementId(elKey);
onSelectElement?.(el); onSelectElement?.(el);
toggleSelectedKeyframe(`${elKey}:${pct}`); toggleSelectedKeyframe(timelineKeyframeSelectionKey(elKey, target));
onSeek?.(el.start + (pct / 100) * el.duration); // Clicking a diamond seeks the playhead to it; selecting a segment to edit
// its ease (options.seek === false) must NOT move the playhead.
if (options?.seek !== false) {
onSeek?.(el.start + (target.percentage / 100) * el.duration);
}
const kfData = keyframeCache.get(elKey); const kfData = keyframeCache.get(elKey);
const kf = kfData?.keyframes.find((item) => Math.abs(item.percentage - pct) < 0.5); const kf = kfData?.keyframes.find(
usePlayerStore.getState().setActiveKeyframePct(kf?.tweenPercentage ?? null); (item) => Math.abs(item.percentage - target.percentage) < 0.5,
);
usePlayerStore
.getState()
.setActiveKeyframePct(target.tweenPercentage ?? kf?.tweenPercentage ?? null);
}, },
[keyframeCache, onSeek, onSelectElement, setSelectedElementId, toggleSelectedKeyframe], [keyframeCache, onSeek, onSelectElement, setSelectedElementId, toggleSelectedKeyframe],
); );
const onShiftClickKeyframe = useCallback( const onShiftClickKeyframe = useCallback(
(elId: string, pct: number) => { (elId: string, target: TimelineKeyframeTarget) => {
toggleSelectedKeyframe(`${elId}:${pct}`); toggleSelectedKeyframe(timelineKeyframeSelectionKey(elId, target));
}, },
[toggleSelectedKeyframe], [toggleSelectedKeyframe],
); );
const onSelectSegment = useCallback(
(elId: string, target: TimelineKeyframeTarget) => {
const el = expandedElements.find((item) => (item.key ?? item.id) === elId);
if (!el) return;
onClickKeyframe(el, target, { seek: false });
if (target.animationId !== undefined && target.tweenPercentage !== undefined) {
usePlayerStore.getState().setFocusedEaseSegment({
animationId: target.animationId,
tweenPercentage: target.tweenPercentage,
elementId: elId,
});
trackStudioSegmentEaseEdit({ action: "open" });
}
},
[expandedElements, onClickKeyframe],
);
const onContextMenuKeyframe = useCallback( const onContextMenuKeyframe = useCallback(
(e: ReactMouseEvent, elId: string, pct: number) => { (e: ReactMouseEvent, elId: string, target: TimelineKeyframeTarget) => {
const el = expandedElements.find((item) => (item.key ?? item.id) === elId); const el = expandedElements.find((item) => (item.key ?? item.id) === elId);
if (el) { if (el) {
setSelectedElementId(elId); setSelectedElementId(elId);
onSelectElement?.(el); onSelectElement?.(el);
} }
const kfData = keyframeCache.get(elId); const kfData = keyframeCache.get(elId);
const kf = kfData?.keyframes.find((item) => Math.abs(item.percentage - pct) < 0.2); const kf = kfData?.keyframes.find(
(item) => Math.abs(item.percentage - target.percentage) < 0.2,
);
setKfContextMenu({ setKfContextMenu({
x: e.clientX + 4, x: e.clientX + 4,
y: e.clientY + 2, y: e.clientY + 2,
elementId: elId, elementId: elId,
percentage: pct, percentage: target.percentage,
tweenPercentage: kf?.tweenPercentage, tweenPercentage: target.tweenPercentage ?? kf?.tweenPercentage,
propertyGroup: target.propertyGroup,
animationId: target.animationId,
currentEase: kf?.ease ?? kfData?.ease, currentEase: kf?.ease ?? kfData?.ease,
}); });
}, },
@@ -67,6 +101,7 @@ export function useTimelineKeyframeHandlers({
return { return {
onClickKeyframe, onClickKeyframe,
onSelectSegment,
onShiftClickKeyframe, onShiftClickKeyframe,
onContextMenuKeyframe, onContextMenuKeyframe,
}; };