Skip to content

Commit b3ce4dd

Browse files
committed
feat(studio): add timeline keyframe retiming interactions
1 parent 41b0a68 commit b3ce4dd

11 files changed

Lines changed: 772 additions & 77 deletions

packages/studio/src/components/editor/keyframeRetime.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
// Boundary cases share an arrange/assert shape on purpose: each case states its
2+
// own window, drag, and expected remap so a failure reads without cross-referencing.
3+
// fallow-ignore-file code-duplication
14
import { describe, expect, it } from "vitest";
25
import { resolveKeyframeRetime, type RetimeKeyframe } from "./keyframeRetime";
36

@@ -94,12 +97,12 @@ describe("resolveKeyframeRetime — resize (past the tween boundary)", () => {
9497
expect(r.kind).toBe("resize");
9598
expect(r.position).toBeCloseTo(2, 5); // start unchanged
9699
expect(r.duration).toBeCloseTo(6, 5); // 8 - 2
97-
// abs 2/4/8 over the new [2,8] window → 0 / 33.3 / 100. pctRemap carries each
100+
// abs 2/4/8 over the new [2,8] window → 0 / 33.333 / 100. pctRemap carries each
98101
// existing keyframe's old→new tween-%; the commit re-keys in place (value +
99102
// ease + _auto preserved by round-tripping the source node, not re-emitted here).
100103
expect(r.pctRemap).toEqual([
101104
{ from: 0, to: 0 },
102-
{ from: 50, to: 33.3 },
105+
{ from: 50, to: 33.333 },
103106
{ from: 100, to: 100 },
104107
]);
105108
});
@@ -113,10 +116,10 @@ describe("resolveKeyframeRetime — resize (past the tween boundary)", () => {
113116
expect(r.kind).toBe("resize");
114117
expect(r.position).toBeCloseTo(0.5, 5);
115118
expect(r.duration).toBeCloseTo(5.5, 5); // 6 - 0.5
116-
// abs 0.5/4/6 over [0.5,6] → 0 / 63.6 / 100.
119+
// abs 0.5/4/6 over [0.5,6] → 0 / 63.636 / 100.
117120
expect(r.pctRemap).toEqual([
118121
{ from: 0, to: 0 },
119-
{ from: 50, to: 63.6 },
122+
{ from: 50, to: 63.636 },
120123
{ from: 100, to: 100 },
121124
]);
122125
});

packages/studio/src/components/editor/keyframeRetime.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ const EPSILON_TIME = 1e-4;
5555
const MIN_TWEEN_DURATION = 0.01;
5656

5757
const round3 = (n: number) => Math.round(n * 1000) / 1000;
58-
const round1 = (n: number) => Math.round(n * 10) / 10; // 0.1% precision
5958
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
6059

6160
/** Resolve timing for a flat tween's synthesized start/end diamond. */
@@ -153,7 +152,7 @@ export function resolveKeyframeRetime(opts: {
153152
const pctRemap: KeyframePctRemap[] = keyframes.map((kf, i) => {
154153
const absTime =
155154
i === draggedIdx ? dropAbsTime : tweenStart + (kf.percentage / 100) * tweenDuration;
156-
return { from: kf.percentage, to: round1(((absTime - newStart) / newDuration) * 100) };
155+
return { from: kf.percentage, to: round3(((absTime - newStart) / newDuration) * 100) };
157156
});
158157

159158
return {

packages/studio/src/components/nle/useTimelineEditCallbacks.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,13 +152,13 @@ export function useTimelineEditCallbacks({
152152
// resizes the tween — position/duration grow so the dragged keyframe lands at
153153
// the drop while every other keyframe keeps its absolute time (value+ease too).
154154
// fallow-ignore-next-line complexity
155-
onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => {
155+
onMoveKeyframe: async (_elId: string, fromClipPct: number, toClipPct: number) => {
156156
const target = resolveKeyframeTarget(fromClipPct);
157157
const sel = domEditSelection;
158-
if (!target || !sel) return;
158+
if (!target || !sel) return false;
159159
const anim = selectedGsapAnimations.find((a) => a.id === target.animId);
160160
const tweenStart = anim ? resolveTweenStart(anim) : null;
161-
if (!anim || tweenStart === null) return;
161+
if (!anim || tweenStart === null) return false;
162162
const tweenDuration = anim.duration ?? resolveTweenDuration(anim);
163163
const sourceFile = sel.sourceFile || activeCompPath || "index.html";
164164
const { elements, domClipChildren } = usePlayerStore.getState();
@@ -200,7 +200,10 @@ export function useTimelineEditCallbacks({
200200
duration: decision.duration,
201201
});
202202
}
203+
} else {
204+
return false;
203205
}
206+
return true;
204207
},
205208
onChangeKeyframeEase: (_elId: string, _pct: number, ease: string) => {
206209
for (const anim of selectedGsapAnimations) {

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

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,32 @@ export interface KeyframeDiamondContextMenuState {
88
elementId: string;
99
percentage: number;
1010
tweenPercentage?: number;
11+
propertyGroup?: string;
12+
animationId?: string;
1113
currentEase?: string;
1214
}
1315

1416
interface KeyframeDiamondContextMenuProps {
1517
state: KeyframeDiamondContextMenuState;
1618
onClose: () => void;
17-
onDelete: (elementId: string, percentage: number) => void;
19+
onDelete: (
20+
elementId: string,
21+
percentage: number,
22+
propertyGroup?: string,
23+
tweenPercentage?: number,
24+
animationId?: string,
25+
) => void;
1826
onDeleteAll: (elementId: string) => void;
1927
onChangeEase?: (elementId: string, percentage: number, ease: string) => void;
2028
onCopyProperties?: (elementId: string, percentage: number) => void;
2129
/** Retime the keyframe to the current playhead, preserving its value + ease. */
22-
onMoveToPlayhead?: (elementId: string, fromPercentage: number) => void;
30+
onMoveToPlayhead?: (
31+
elementId: string,
32+
fromPercentage: number,
33+
propertyGroup?: string,
34+
tweenPercentage?: number,
35+
animationId?: string,
36+
) => void;
2337
}
2438

2539
export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({
@@ -51,7 +65,13 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
5165
// Pass clip-% — resolveKeyframeTarget keys the cache lookup on clip-%
5266
// and returns the tween-% for the mutation. Passing tween-% here would
5367
// miss the lookup on any tween whose window is shorter than the clip.
54-
onMoveToPlayhead(state.elementId, state.percentage);
68+
onMoveToPlayhead(
69+
state.elementId,
70+
state.percentage,
71+
state.propertyGroup,
72+
state.tweenPercentage,
73+
state.animationId,
74+
);
5575
onClose();
5676
}}
5777
>
@@ -64,7 +84,13 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
6484
type="button"
6585
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"
6686
onClick={() => {
67-
onDelete(state.elementId, state.percentage);
87+
onDelete(
88+
state.elementId,
89+
state.percentage,
90+
state.propertyGroup,
91+
state.tweenPercentage,
92+
state.animationId,
93+
);
6894
onClose();
6995
}}
7096
>

0 commit comments

Comments
 (0)