Skip to content

Commit 9fb06d5

Browse files
committed
feat(studio): keyframe hooks wiring — session, commits, cache, toolbar toggle
1 parent dfa9793 commit 9fb06d5

7 files changed

Lines changed: 607 additions & 19 deletions

File tree

packages/studio/src/components/TimelineToolbar.tsx

Lines changed: 193 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,214 @@ import {
44
} from "../player/components/timelineZoom";
55
import { getTimelineToggleTitle } from "../utils/timelineDiscovery";
66
import { usePlayerStore } from "../player";
7+
import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability";
78
import { Tooltip } from "./ui";
9+
import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
10+
import type { DomEditSelection } from "./editor/domEditingTypes";
11+
12+
function interpolateKeyframeProperties(
13+
keyframes: GsapPercentageKeyframe[],
14+
pct: number,
15+
): Record<string, number> {
16+
const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage);
17+
const allProps = new Set<string>();
18+
for (const kf of sorted) {
19+
for (const p of Object.keys(kf.properties)) {
20+
if (typeof kf.properties[p] === "number") allProps.add(p);
21+
}
22+
}
23+
const result: Record<string, number> = {};
24+
for (const prop of allProps) {
25+
let prev: { pct: number; val: number } | null = null;
26+
let next: { pct: number; val: number } | null = null;
27+
for (const kf of sorted) {
28+
const v = kf.properties[prop];
29+
if (typeof v !== "number") continue;
30+
if (kf.percentage <= pct) prev = { pct: kf.percentage, val: v };
31+
if (kf.percentage >= pct && !next) next = { pct: kf.percentage, val: v };
32+
}
33+
if (prev && next && prev.pct !== next.pct) {
34+
const t = (pct - prev.pct) / (next.pct - prev.pct);
35+
result[prop] = Math.round(prev.val + t * (next.val - prev.val));
36+
} else if (prev) {
37+
result[prop] = Math.round(prev.val);
38+
} else if (next) {
39+
result[prop] = Math.round(next.val);
40+
}
41+
}
42+
return result;
43+
}
44+
45+
function readRuntimeKeyframeValues(
46+
iframe: HTMLIFrameElement | null,
47+
sel: DomEditSelection,
48+
keyframes: GsapPercentageKeyframe[],
49+
): Record<string, number> {
50+
if (!iframe?.contentWindow) return {};
51+
let gsap: { getProperty?: (el: Element, prop: string) => number } | undefined;
52+
try {
53+
gsap = (iframe.contentWindow as Window & { gsap?: typeof gsap }).gsap;
54+
} catch {
55+
return {};
56+
}
57+
if (!gsap?.getProperty) return {};
58+
const selector = sel.id ? `#${sel.id}` : sel.selector;
59+
if (!selector) return {};
60+
let doc: Document | null = null;
61+
try {
62+
doc = iframe.contentDocument;
63+
} catch {
64+
return {};
65+
}
66+
const element = doc?.querySelector(selector);
67+
if (!element) return {};
68+
const allProps = new Set<string>();
69+
for (const kf of keyframes) {
70+
for (const p of Object.keys(kf.properties)) {
71+
if (typeof kf.properties[p] === "number") allProps.add(p);
72+
}
73+
}
74+
const result: Record<string, number> = {};
75+
for (const prop of allProps) {
76+
const val = Number(gsap.getProperty(element, prop));
77+
if (Number.isFinite(val)) result[prop] = Math.round(val);
78+
}
79+
return result;
80+
}
81+
82+
interface DomEditSessionSlice {
83+
domEditSelection: DomEditSelection | null;
84+
selectedGsapAnimations: GsapAnimation[];
85+
handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
86+
handleGsapAddKeyframe: (animId: string, pct: number, prop: string, val: number | string) => void;
87+
handleGsapConvertToKeyframes: (animId: string) => void;
88+
handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
89+
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
90+
}
891

992
interface TimelineToolbarProps {
1093
toggleTimelineVisibility: () => void;
94+
domEditSession?: DomEditSessionSlice;
1195
}
1296

13-
export function TimelineToolbar({ toggleTimelineVisibility }: TimelineToolbarProps) {
97+
// fallow-ignore-next-line complexity
98+
function useKeyframeToggle(session?: DomEditSessionSlice) {
99+
const currentTime = usePlayerStore((s) => s.currentTime);
100+
if (!session) return { state: "none" as const, onToggle: undefined };
101+
102+
const sel = session.domEditSelection;
103+
const anims = session.selectedGsapAnimations;
104+
const kfAnim = anims.find((a) => a.keyframes);
105+
const flatAnim = anims.find((a) => !a.keyframes);
106+
107+
let state: "active" | "inactive" | "none" = "none";
108+
if (kfAnim?.keyframes && sel) {
109+
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
110+
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
111+
const pct =
112+
elDuration > 0
113+
? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
114+
: 0;
115+
state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
116+
? "active"
117+
: "inactive";
118+
}
119+
120+
// fallow-ignore-next-line complexity
121+
const onToggle = sel
122+
? () => {
123+
const t = usePlayerStore.getState().currentTime;
124+
if (kfAnim?.keyframes) {
125+
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
126+
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
127+
const pct =
128+
elDuration > 0
129+
? Math.max(0, Math.min(100, Math.round(((t - elStart) / elDuration) * 1000) / 10))
130+
: 0;
131+
const existing = kfAnim.keyframes.keyframes.find(
132+
(k) => Math.abs(k.percentage - pct) <= 1,
133+
);
134+
if (existing) {
135+
session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
136+
} else {
137+
const runtimeValues = readRuntimeKeyframeValues(
138+
session.previewIframeRef?.current ?? null,
139+
sel,
140+
kfAnim.keyframes.keyframes,
141+
);
142+
const values =
143+
Object.keys(runtimeValues).length > 0
144+
? runtimeValues
145+
: interpolateKeyframeProperties(kfAnim.keyframes.keyframes, pct);
146+
for (const [prop, val] of Object.entries(values)) {
147+
session.handleGsapAddKeyframe(kfAnim.id, pct, prop, val);
148+
}
149+
}
150+
} else if (flatAnim) {
151+
session.handleGsapConvertToKeyframes(flatAnim.id);
152+
} else {
153+
session.handleGsapAddAnimation("to");
154+
}
155+
}
156+
: undefined;
157+
158+
return { state, onToggle };
159+
}
160+
161+
export function TimelineToolbar({
162+
toggleTimelineVisibility,
163+
domEditSession,
164+
}: TimelineToolbarProps) {
14165
const zoomMode = usePlayerStore((s) => s.zoomMode);
15166
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
16167
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
17168
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
18169
const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
170+
const { state: keyframeState, onToggle: onToggleKeyframe } = useKeyframeToggle(domEditSession);
19171

20172
return (
21173
<div className="border-b border-neutral-800/40 bg-neutral-950/96">
22174
<div className="flex items-center justify-between px-3 py-2">
23-
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
24-
Timeline
175+
<div className="flex items-center gap-3">
176+
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
177+
Timeline
178+
</div>
179+
{STUDIO_KEYFRAMES_ENABLED && onToggleKeyframe && (
180+
<Tooltip
181+
label={
182+
keyframeState === "active"
183+
? "Remove keyframe at playhead"
184+
: keyframeState === "inactive"
185+
? "Add keyframe at playhead"
186+
: "Enable keyframes"
187+
}
188+
>
189+
<button
190+
type="button"
191+
onClick={onToggleKeyframe}
192+
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
193+
keyframeState === "active"
194+
? "text-studio-accent"
195+
: keyframeState === "inactive"
196+
? "text-neutral-400 hover:text-studio-accent"
197+
: "text-neutral-600 hover:text-neutral-400"
198+
}`}
199+
>
200+
<svg width="18" height="18" viewBox="0 0 10 10" fill="currentColor">
201+
{keyframeState === "active" ? (
202+
<path d="M5 0.5L9.5 5L5 9.5L0.5 5Z" />
203+
) : (
204+
<path
205+
d="M5 1.2L8.8 5L5 8.8L1.2 5Z"
206+
fill="none"
207+
stroke="currentColor"
208+
strokeWidth="1.2"
209+
/>
210+
)}
211+
</svg>
212+
</button>
213+
</Tooltip>
214+
)}
25215
</div>
26216
<div className="flex items-center gap-1">
27217
<Tooltip label="Fit timeline to width">

packages/studio/src/hooks/useAppHotkeys.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ interface UseAppHotkeysParams {
7777
handleCopy: () => boolean;
7878
handlePaste: () => Promise<void>;
7979
handleCut: () => Promise<boolean>;
80+
onResetKeyframes: () => boolean;
81+
onDeleteSelectedKeyframes: () => void;
82+
onAfterUndoRedo?: () => void;
8083
}
8184

8285
// ── Hook ──
@@ -98,6 +101,9 @@ export function useAppHotkeys({
98101
handleCopy,
99102
handlePaste,
100103
handleCut,
104+
onResetKeyframes,
105+
onDeleteSelectedKeyframes,
106+
onAfterUndoRedo,
101107
}: UseAppHotkeysParams) {
102108
const previewHotkeyWindowRef = useRef<Window | null>(null);
103109
const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined);
@@ -144,6 +150,7 @@ export function useAppHotkeys({
144150
return;
145151
}
146152
if (result.ok && result.label) {
153+
onAfterUndoRedo?.();
147154
await syncHistoryPreviewAfterApply(result.paths);
148155
showToast(`Undid ${result.label}`, "info");
149156
}
@@ -154,6 +161,7 @@ export function useAppHotkeys({
154161
syncHistoryPreviewAfterApply,
155162
waitForPendingDomEditSaves,
156163
writeHistoryProjectFile,
164+
onAfterUndoRedo,
157165
]);
158166

159167
const handleRedo = useCallback(async () => {
@@ -167,6 +175,7 @@ export function useAppHotkeys({
167175
return;
168176
}
169177
if (result.ok && result.label) {
178+
onAfterUndoRedo?.();
170179
await syncHistoryPreviewAfterApply(result.paths);
171180
showToast(`Redid ${result.label}`, "info");
172181
}
@@ -177,6 +186,7 @@ export function useAppHotkeys({
177186
syncHistoryPreviewAfterApply,
178187
waitForPendingDomEditSaves,
179188
writeHistoryProjectFile,
189+
onAfterUndoRedo,
180190
]);
181191

182192
// ── Stable refs for the consolidated keydown handler ──
@@ -197,6 +207,10 @@ export function useAppHotkeys({
197207
handlePasteRef.current = handlePaste;
198208
const handleCutRef = useRef(handleCut);
199209
handleCutRef.current = handleCut;
210+
const onResetKeyframesRef = useRef(onResetKeyframes);
211+
onResetKeyframesRef.current = onResetKeyframes;
212+
const onDeleteSelectedKeyframesRef = useRef(onDeleteSelectedKeyframes);
213+
onDeleteSelectedKeyframesRef.current = onDeleteSelectedKeyframes;
200214

201215
// ── Consolidated keydown handler ──
202216

@@ -292,14 +306,34 @@ export function useAppHotkeys({
292306
return;
293307
}
294308

295-
// Delete / Backspace — remove selected element (timeline clip or preview selection)
309+
// Delete / Backspace — remove selected keyframes > reset keyframes > remove element
296310
if (
297311
(event.key === "Delete" || event.key === "Backspace") &&
298312
!event.metaKey &&
299313
!event.ctrlKey &&
300314
!event.altKey &&
301315
!isEditableTarget(event.target)
302316
) {
317+
// Priority: selected keyframes take precedence over clip deletion
318+
const { selectedKeyframes } = usePlayerStore.getState();
319+
if (selectedKeyframes.size > 0) {
320+
onDeleteSelectedKeyframesRef.current();
321+
usePlayerStore.getState().clearSelectedKeyframes();
322+
event.preventDefault();
323+
return;
324+
}
325+
326+
// Backspace: try resetting keyframes first; fall through to delete if none found
327+
if (event.key === "Backspace") {
328+
const { selectedElementId, keyframeCache } = usePlayerStore.getState();
329+
if (selectedElementId && keyframeCache.has(selectedElementId)) {
330+
if (onResetKeyframesRef.current()) {
331+
event.preventDefault();
332+
return;
333+
}
334+
}
335+
}
336+
303337
const { selectedElementId, elements } = usePlayerStore.getState();
304338
if (selectedElementId) {
305339
const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);

packages/studio/src/hooks/useDomEditCommits.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,37 @@ import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditO
3535
import type { EditHistoryKind } from "../utils/editHistory";
3636
import { useDomEditTextCommits } from "./useDomEditTextCommits";
3737

38+
// ── Helpers ──
39+
40+
type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
41+
42+
function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLElement): boolean {
43+
if (!iframe?.contentWindow) return false;
44+
let timelines: Record<string, TimelineLike> | undefined;
45+
try {
46+
timelines = (iframe.contentWindow as Window & { __timelines?: Record<string, TimelineLike> })
47+
.__timelines;
48+
} catch {
49+
return false;
50+
}
51+
if (!timelines) return false;
52+
const id = element.id;
53+
for (const tl of Object.values(timelines)) {
54+
if (!tl?.getChildren) continue;
55+
try {
56+
for (const child of tl.getChildren(true)) {
57+
if (!child.targets) continue;
58+
for (const t of child.targets()) {
59+
if (t === element || (id && t.id === id)) return true;
60+
}
61+
}
62+
} catch {
63+
continue;
64+
}
65+
}
66+
return false;
67+
}
68+
3869
// ── Types ──
3970

4071
interface RecordEditInput {
@@ -290,12 +321,13 @@ export function useDomEditCommits({
290321
const handleDomPathOffsetCommit = useCallback(
291322
(selection: DomEditSelection, next: { x: number; y: number }) => {
292323
applyStudioPathOffset(selection.element, next);
324+
if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return;
293325
commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
294326
label: "Move layer",
295327
coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
296328
});
297329
},
298-
[commitPositionPatchToHtml],
330+
[commitPositionPatchToHtml, previewIframeRef],
299331
);
300332

301333
const handleDomGroupPathOffsetCommit = useCallback(
@@ -307,13 +339,14 @@ export function useDomEditCommits({
307339
.join(":");
308340
for (const { selection, next } of updates) {
309341
applyStudioPathOffset(selection.element, next);
342+
if (isElementGsapTargeted(previewIframeRef.current, selection.element)) continue;
310343
commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
311344
label: `Move ${updates.length} layers`,
312345
coalesceKey: `group-path-offset:${coalesceKey}`,
313346
});
314347
}
315348
},
316-
[commitPositionPatchToHtml],
349+
[commitPositionPatchToHtml, previewIframeRef],
317350
);
318351

319352
const handleDomBoxSizeCommit = useCallback(

0 commit comments

Comments
 (0)