From 0474d741e5dc9a62da05e61f9dd76f9f5cf1c76a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 3 Jun 2026 01:26:52 -0400 Subject: [PATCH] =?UTF-8?q?feat(studio):=20keyframe=20hooks=20wiring=20?= =?UTF-8?q?=E2=80=94=20session,=20commits,=20cache,=20toolbar=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../studio/src/components/TimelineToolbar.tsx | 196 +++++++++++++++++- packages/studio/src/hooks/useAppHotkeys.ts | 36 +++- .../studio/src/hooks/useDomEditCommits.ts | 37 +++- .../studio/src/hooks/useDomEditSession.ts | 120 ++++++++++- .../studio/src/hooks/useGsapScriptCommits.ts | 140 ++++++++++++- .../studio/src/hooks/useGsapTweenCache.ts | 55 ++++- .../studio/src/player/store/playerStore.ts | 42 ++++ 7 files changed, 607 insertions(+), 19 deletions(-) diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index 7bf86c193..d280d0733 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -4,24 +4,214 @@ import { } from "../player/components/timelineZoom"; import { getTimelineToggleTitle } from "../utils/timelineDiscovery"; import { usePlayerStore } from "../player"; +import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability"; import { Tooltip } from "./ui"; +import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "./editor/domEditingTypes"; + +function interpolateKeyframeProperties( + keyframes: GsapPercentageKeyframe[], + pct: number, +): Record { + const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage); + const allProps = new Set(); + for (const kf of sorted) { + for (const p of Object.keys(kf.properties)) { + if (typeof kf.properties[p] === "number") allProps.add(p); + } + } + const result: Record = {}; + for (const prop of allProps) { + let prev: { pct: number; val: number } | null = null; + let next: { pct: number; val: number } | null = null; + for (const kf of sorted) { + const v = kf.properties[prop]; + if (typeof v !== "number") continue; + if (kf.percentage <= pct) prev = { pct: kf.percentage, val: v }; + if (kf.percentage >= pct && !next) next = { pct: kf.percentage, val: v }; + } + if (prev && next && prev.pct !== next.pct) { + const t = (pct - prev.pct) / (next.pct - prev.pct); + result[prop] = Math.round(prev.val + t * (next.val - prev.val)); + } else if (prev) { + result[prop] = Math.round(prev.val); + } else if (next) { + result[prop] = Math.round(next.val); + } + } + return result; +} + +function readRuntimeKeyframeValues( + iframe: HTMLIFrameElement | null, + sel: DomEditSelection, + keyframes: GsapPercentageKeyframe[], +): Record { + if (!iframe?.contentWindow) return {}; + let gsap: { getProperty?: (el: Element, prop: string) => number } | undefined; + try { + gsap = (iframe.contentWindow as Window & { gsap?: typeof gsap }).gsap; + } catch { + return {}; + } + if (!gsap?.getProperty) return {}; + const selector = sel.id ? `#${sel.id}` : sel.selector; + if (!selector) return {}; + let doc: Document | null = null; + try { + doc = iframe.contentDocument; + } catch { + return {}; + } + const element = doc?.querySelector(selector); + if (!element) return {}; + const allProps = new Set(); + for (const kf of keyframes) { + for (const p of Object.keys(kf.properties)) { + if (typeof kf.properties[p] === "number") allProps.add(p); + } + } + const result: Record = {}; + for (const prop of allProps) { + const val = Number(gsap.getProperty(element, prop)); + if (Number.isFinite(val)) result[prop] = Math.round(val); + } + return result; +} + +interface DomEditSessionSlice { + domEditSelection: DomEditSelection | null; + selectedGsapAnimations: GsapAnimation[]; + handleGsapRemoveKeyframe: (animId: string, pct: number) => void; + handleGsapAddKeyframe: (animId: string, pct: number, prop: string, val: number | string) => void; + handleGsapConvertToKeyframes: (animId: string) => void; + handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void; + previewIframeRef?: React.RefObject; +} interface TimelineToolbarProps { toggleTimelineVisibility: () => void; + domEditSession?: DomEditSessionSlice; } -export function TimelineToolbar({ toggleTimelineVisibility }: TimelineToolbarProps) { +// fallow-ignore-next-line complexity +function useKeyframeToggle(session?: DomEditSessionSlice) { + const currentTime = usePlayerStore((s) => s.currentTime); + if (!session) return { state: "none" as const, onToggle: undefined }; + + const sel = session.domEditSelection; + const anims = session.selectedGsapAnimations; + const kfAnim = anims.find((a) => a.keyframes); + const flatAnim = anims.find((a) => !a.keyframes); + + let state: "active" | "inactive" | "none" = "none"; + if (kfAnim?.keyframes && sel) { + const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0; + const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1; + const pct = + elDuration > 0 + ? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10)) + : 0; + state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1) + ? "active" + : "inactive"; + } + + // fallow-ignore-next-line complexity + const onToggle = sel + ? () => { + const t = usePlayerStore.getState().currentTime; + if (kfAnim?.keyframes) { + const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0; + const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1; + const pct = + elDuration > 0 + ? Math.max(0, Math.min(100, Math.round(((t - elStart) / elDuration) * 1000) / 10)) + : 0; + const existing = kfAnim.keyframes.keyframes.find( + (k) => Math.abs(k.percentage - pct) <= 1, + ); + if (existing) { + session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage); + } else { + const runtimeValues = readRuntimeKeyframeValues( + session.previewIframeRef?.current ?? null, + sel, + kfAnim.keyframes.keyframes, + ); + const values = + Object.keys(runtimeValues).length > 0 + ? runtimeValues + : interpolateKeyframeProperties(kfAnim.keyframes.keyframes, pct); + for (const [prop, val] of Object.entries(values)) { + session.handleGsapAddKeyframe(kfAnim.id, pct, prop, val); + } + } + } else if (flatAnim) { + session.handleGsapConvertToKeyframes(flatAnim.id); + } else { + session.handleGsapAddAnimation("to"); + } + } + : undefined; + + return { state, onToggle }; +} + +export function TimelineToolbar({ + toggleTimelineVisibility, + domEditSession, +}: TimelineToolbarProps) { const zoomMode = usePlayerStore((s) => s.zoomMode); const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent); const setZoomMode = usePlayerStore((s) => s.setZoomMode); const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent); const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent); + const { state: keyframeState, onToggle: onToggleKeyframe } = useKeyframeToggle(domEditSession); return (
-
- Timeline +
+
+ Timeline +
+ {STUDIO_KEYFRAMES_ENABLED && onToggleKeyframe && ( + + + + )}
diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 4308b4bcd..61d65af9f 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -77,6 +77,9 @@ interface UseAppHotkeysParams { handleCopy: () => boolean; handlePaste: () => Promise; handleCut: () => Promise; + onResetKeyframes: () => boolean; + onDeleteSelectedKeyframes: () => void; + onAfterUndoRedo?: () => void; } // ── Hook ── @@ -98,6 +101,9 @@ export function useAppHotkeys({ handleCopy, handlePaste, handleCut, + onResetKeyframes, + onDeleteSelectedKeyframes, + onAfterUndoRedo, }: UseAppHotkeysParams) { const previewHotkeyWindowRef = useRef(null); const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined); @@ -144,6 +150,7 @@ export function useAppHotkeys({ return; } if (result.ok && result.label) { + onAfterUndoRedo?.(); await syncHistoryPreviewAfterApply(result.paths); showToast(`Undid ${result.label}`, "info"); } @@ -154,6 +161,7 @@ export function useAppHotkeys({ syncHistoryPreviewAfterApply, waitForPendingDomEditSaves, writeHistoryProjectFile, + onAfterUndoRedo, ]); const handleRedo = useCallback(async () => { @@ -167,6 +175,7 @@ export function useAppHotkeys({ return; } if (result.ok && result.label) { + onAfterUndoRedo?.(); await syncHistoryPreviewAfterApply(result.paths); showToast(`Redid ${result.label}`, "info"); } @@ -177,6 +186,7 @@ export function useAppHotkeys({ syncHistoryPreviewAfterApply, waitForPendingDomEditSaves, writeHistoryProjectFile, + onAfterUndoRedo, ]); // ── Stable refs for the consolidated keydown handler ── @@ -197,6 +207,10 @@ export function useAppHotkeys({ handlePasteRef.current = handlePaste; const handleCutRef = useRef(handleCut); handleCutRef.current = handleCut; + const onResetKeyframesRef = useRef(onResetKeyframes); + onResetKeyframesRef.current = onResetKeyframes; + const onDeleteSelectedKeyframesRef = useRef(onDeleteSelectedKeyframes); + onDeleteSelectedKeyframesRef.current = onDeleteSelectedKeyframes; // ── Consolidated keydown handler ── @@ -292,7 +306,7 @@ export function useAppHotkeys({ return; } - // Delete / Backspace — remove selected element (timeline clip or preview selection) + // Delete / Backspace — remove selected keyframes > reset keyframes > remove element if ( (event.key === "Delete" || event.key === "Backspace") && !event.metaKey && @@ -300,6 +314,26 @@ export function useAppHotkeys({ !event.altKey && !isEditableTarget(event.target) ) { + // Priority: selected keyframes take precedence over clip deletion + const { selectedKeyframes } = usePlayerStore.getState(); + if (selectedKeyframes.size > 0) { + onDeleteSelectedKeyframesRef.current(); + usePlayerStore.getState().clearSelectedKeyframes(); + event.preventDefault(); + return; + } + + // Backspace: try resetting keyframes first; fall through to delete if none found + if (event.key === "Backspace") { + const { selectedElementId, keyframeCache } = usePlayerStore.getState(); + if (selectedElementId && keyframeCache.has(selectedElementId)) { + if (onResetKeyframesRef.current()) { + event.preventDefault(); + return; + } + } + } + const { selectedElementId, elements } = usePlayerStore.getState(); if (selectedElementId) { const element = elements.find((el) => (el.key ?? el.id) === selectedElementId); diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index e733a4cdd..658740151 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -35,6 +35,37 @@ import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditO import type { EditHistoryKind } from "../utils/editHistory"; import { useDomEditTextCommits } from "./useDomEditTextCommits"; +// ── Helpers ── + +type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> }; + +function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLElement): boolean { + if (!iframe?.contentWindow) return false; + let timelines: Record | undefined; + try { + timelines = (iframe.contentWindow as Window & { __timelines?: Record }) + .__timelines; + } catch { + return false; + } + if (!timelines) return false; + const id = element.id; + for (const tl of Object.values(timelines)) { + if (!tl?.getChildren) continue; + try { + for (const child of tl.getChildren(true)) { + if (!child.targets) continue; + for (const t of child.targets()) { + if (t === element || (id && t.id === id)) return true; + } + } + } catch { + continue; + } + } + return false; +} + // ── Types ── interface RecordEditInput { @@ -290,12 +321,13 @@ export function useDomEditCommits({ const handleDomPathOffsetCommit = useCallback( (selection: DomEditSelection, next: { x: number; y: number }) => { applyStudioPathOffset(selection.element, next); + if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return; commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), { label: "Move layer", coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`, }); }, - [commitPositionPatchToHtml], + [commitPositionPatchToHtml, previewIframeRef], ); const handleDomGroupPathOffsetCommit = useCallback( @@ -307,13 +339,14 @@ export function useDomEditCommits({ .join(":"); for (const { selection, next } of updates) { applyStudioPathOffset(selection.element, next); + if (isElementGsapTargeted(previewIframeRef.current, selection.element)) continue; commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), { label: `Move ${updates.length} layers`, coalesceKey: `group-path-offset:${coalesceKey}`, }); } }, - [commitPositionPatchToHtml], + [commitPositionPatchToHtml, previewIframeRef], ); const handleDomBoxSizeCommit = useCallback( diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 5942d8f30..209be746a 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -16,7 +16,14 @@ import { useDomSelection } from "./useDomSelection"; import { usePreviewInteraction } from "./usePreviewInteraction"; import { useDomEditCommits } from "./useDomEditCommits"; import { useGsapScriptCommits } from "./useGsapScriptCommits"; -import { useGsapAnimationsForElement, useGsapCacheVersion } from "./useGsapTweenCache"; +import { + useGsapAnimationsForElement, + useGsapCacheVersion, + usePopulateKeyframeCacheForFile, + fetchParsedAnimations, + getAnimationsForElement, +} from "./useGsapTweenCache"; +import { tryGsapDragIntercept } from "./gsapRuntimeBridge"; // ── Types ── @@ -198,13 +205,21 @@ export function useDomEditSession({ const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion(); + const gsapSourceFile = domEditSelection?.sourceFile || activeCompPath || "index.html"; + + usePopulateKeyframeCacheForFile( + STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null, + gsapSourceFile, + gsapCacheVersion, + ); + const { animations: selectedGsapAnimations, multipleTimelines: gsapMultipleTimelines, unsupportedTimelinePattern: gsapUnsupportedTimelinePattern, } = useGsapAnimationsForElement( STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null, - domEditSelection?.sourceFile || activeCompPath || "index.html", + gsapSourceFile, domEditSelection ? { id: domEditSelection.id ?? null, selector: domEditSelection.selector ?? null } : null, @@ -212,6 +227,7 @@ export function useDomEditSession({ ); const { + commitMutation: gsapCommitMutation, updateGsapProperty, updateGsapMeta, deleteGsapAnimation, @@ -221,6 +237,10 @@ export function useDomEditSession({ updateGsapFromProperty, addGsapFromProperty, removeGsapFromProperty, + addKeyframe, + removeKeyframe, + convertToKeyframes, + removeAllKeyframes, } = useGsapScriptCommits({ projectIdRef, activeCompPath, @@ -270,6 +290,42 @@ export function useDomEditSession({ buildDomSelectionFromTarget, }); + // Wrap the CSS-based path offset commit with GSAP-awareness: when the + // selected element has GSAP animations controlling x/y, read the actual + // interpolated position from the iframe runtime and commit via the GSAP + // script mutation path instead of the CSS translate offset. + const handleGsapAwarePathOffsetCommit = useCallback( + async (selection: DomEditSelection, next: { x: number; y: number }) => { + if (gsapCommitMutation) { + const handled = await tryGsapDragIntercept( + selection, + next, + selectedGsapAnimations, + previewIframeRef.current, + gsapCommitMutation, + async () => { + const pid = projectId; + if (!pid) return []; + const parsed = await fetchParsedAnimations(pid, gsapSourceFile); + if (!parsed) return []; + const target = { id: selection.id ?? null, selector: selection.selector ?? null }; + return getAnimationsForElement(parsed.animations, target); + }, + ); + if (handled) return; + } + handleDomPathOffsetCommit(selection, next); + }, + [ + handleDomPathOffsetCommit, + selectedGsapAnimations, + gsapCommitMutation, + previewIframeRef, + projectId, + gsapSourceFile, + ], + ); + const handleGsapUpdateProperty = useCallback( (animId: string, prop: string, value: number | string) => { if (!domEditSelection) return; @@ -298,8 +354,11 @@ export function useDomEditSession({ (method: "to" | "from" | "set" | "fromTo") => { if (!domEditSelection) return; addGsapAnimation(domEditSelection, method, currentTime); + if (domEditSelection.element.hasAttribute("data-hf-studio-path-offset")) { + handleDomManualEditsReset(domEditSelection); + } }, - [domEditSelection, addGsapAnimation, currentTime], + [domEditSelection, addGsapAnimation, currentTime, handleDomManualEditsReset], ); const handleGsapAddProperty = useCallback( @@ -342,6 +401,52 @@ export function useDomEditSession({ [domEditSelection, removeGsapFromProperty], ); + const handleGsapAddKeyframe = useCallback( + (animId: string, percentage: number, property: string, value: number | string) => { + if (!domEditSelection) return; + addKeyframe(domEditSelection, animId, percentage, property, value); + }, + [domEditSelection, addKeyframe], + ); + + const handleGsapRemoveKeyframe = useCallback( + (animId: string, percentage: number) => { + if (!domEditSelection) return; + removeKeyframe(domEditSelection, animId, percentage); + }, + [domEditSelection, removeKeyframe], + ); + + const handleGsapConvertToKeyframes = useCallback( + (animId: string) => { + if (!domEditSelection) return; + convertToKeyframes(domEditSelection, animId); + }, + [domEditSelection, convertToKeyframes], + ); + + const handleGsapRemoveAllKeyframes = useCallback( + (animId: string) => { + if (!domEditSelection) return; + removeAllKeyframes(domEditSelection, animId); + }, + [domEditSelection, removeAllKeyframes], + ); + + /** + * Reset keyframes for the currently selected element. + * Finds the animation with keyframes from the resolved GSAP animations + * and sends a remove-all-keyframes mutation. Returns true if keyframes + * were found and the mutation was dispatched. + */ + const handleResetSelectedElementKeyframes = useCallback((): boolean => { + if (!domEditSelection) return false; + const withKeyframes = selectedGsapAnimations.find((a) => a.keyframes); + if (!withKeyframes) return false; + removeAllKeyframes(domEditSelection, withKeyframes.id); + return true; + }, [domEditSelection, selectedGsapAnimations, removeAllKeyframes]); + // Sync selection from preview document on load / refresh // eslint-disable-next-line no-restricted-syntax useEffect(() => { @@ -445,7 +550,7 @@ export function useDomEditSession({ handleDomStyleCommit, handleDomAttributeCommit, handleDomHtmlAttributeCommit, - handleDomPathOffsetCommit, + handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit, handleDomGroupPathOffsetCommit, handleDomBoxSizeCommit, handleDomRotationCommit, @@ -482,5 +587,12 @@ export function useDomEditSession({ handleGsapUpdateFromProperty, handleGsapAddFromProperty, handleGsapRemoveFromProperty, + handleGsapAddKeyframe, + handleGsapRemoveKeyframe, + handleGsapConvertToKeyframes, + handleGsapRemoveAllKeyframes, + handleResetSelectedElementKeyframes, + invalidateGsapCache: bumpGsapCache, + previewIframeRef, }; } diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index a45333388..75484e307 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -3,6 +3,8 @@ import type { ParsedGsap } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import type { EditHistoryKind } from "../utils/editHistory"; import { applySoftReload } from "../utils/gsapSoftReload"; +import { executeOptimistic } from "../utils/optimisticUpdate"; +import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore"; const PROPERTY_DEFAULTS: Record = { opacity: 1, @@ -70,6 +72,27 @@ async function mutateGsapScript( } } +function buildCacheKey(sourceFile: string, elementId: string): string { + return `${sourceFile}#${elementId}`; +} + +function readKeyframeSnapshot( + sourceFile: string, + elementId: string | null | undefined, +): KeyframeCacheEntry | undefined { + if (!elementId) return undefined; + return usePlayerStore.getState().keyframeCache.get(buildCacheKey(sourceFile, elementId)); +} + +function writeKeyframeCache( + sourceFile: string, + elementId: string | null | undefined, + data: KeyframeCacheEntry | undefined, +): void { + if (!elementId) return; + usePlayerStore.getState().setKeyframeCache(buildCacheKey(sourceFile, elementId), data); +} + interface GsapScriptCommitsParams { projectIdRef: React.MutableRefObject; activeCompPath: string | null; @@ -113,7 +136,13 @@ export function useGsapScriptCommits({ async ( selection: DomEditSelection, mutation: Record, - options: { label: string; coalesceKey?: string; softReload?: boolean }, + options: { + label: string; + coalesceKey?: string; + softReload?: boolean; + skipReload?: boolean; + beforeReload?: () => void; + }, ) => { const pid = projectIdRef.current; if (!pid) return; @@ -135,6 +164,10 @@ export function useGsapScriptCommits({ onCacheInvalidate(); + if (options.skipReload) return; + + options.beforeReload?.(); + if (options.softReload && result.scriptText) { if (!applySoftReload(previewIframeRef.current, result.scriptText)) { reloadPreview(); @@ -225,7 +258,7 @@ export function useGsapScriptCommits({ async ( selection: DomEditSelection, method: "to" | "from" | "set" | "fromTo", - currentTime?: number, + _currentTime?: number, ) => { const { selector, autoId } = ensureElementAddressable(selection); @@ -253,12 +286,15 @@ export function useGsapScriptCommits({ if (!data.changed) return; } - const start = currentTime ?? (Number.parseFloat(selection.dataAttributes.start ?? "0") || 0); + const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0; + const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1; + const position = Math.round(elStart * 1000) / 1000; + const duration = Math.round(elDuration * 1000) / 1000; const toDefaults: Record> = { from: { opacity: 0 }, - to: { opacity: 1 }, + to: { x: 0, y: 0, opacity: 1 }, set: { opacity: 1 }, - fromTo: { opacity: 1 }, + fromTo: { x: 0, y: 0, opacity: 1 }, }; await commitMutation( @@ -267,8 +303,8 @@ export function useGsapScriptCommits({ type: "add", targetSelector: selector, method, - position: start, - duration: method === "set" ? undefined : 0.5, + position, + duration: method === "set" ? undefined : duration, ease: method === "set" ? undefined : "power2.out", properties: toDefaults[method] ?? { opacity: 1 }, fromProperties: method === "fromTo" ? { opacity: 0 } : undefined, @@ -353,7 +389,93 @@ export function useGsapScriptCommits({ [commitMutation], ); + const addKeyframe = useCallback( + ( + selection: DomEditSelection, + animationId: string, + percentage: number, + property: string, + value: number | string, + ) => { + const sf = selection.sourceFile || activeCompPath || "index.html"; + const elementId = selection.id; + void executeOptimistic({ + apply: () => { + const prev = readKeyframeSnapshot(sf, elementId); + if (prev) { + const newKeyframes = [ + ...prev.keyframes, + { percentage, properties: { [property]: value } }, + ].sort((a, b) => a.percentage - b.percentage); + writeKeyframeCache(sf, elementId, { ...prev, keyframes: newKeyframes }); + } + return prev; + }, + persist: () => + commitMutation( + selection, + { type: "add-keyframe", animationId, percentage, properties: { [property]: value } }, + { label: `Add keyframe at ${percentage}%`, softReload: true }, + ), + rollback: (prev) => { + writeKeyframeCache(sf, elementId, prev); + }, + }); + }, + [commitMutation, activeCompPath], + ); + + const removeKeyframe = useCallback( + (selection: DomEditSelection, animationId: string, percentage: number) => { + const sf = selection.sourceFile || activeCompPath || "index.html"; + const elementId = selection.id; + void executeOptimistic({ + apply: () => { + const prev = readKeyframeSnapshot(sf, elementId); + if (prev) { + const newKeyframes = prev.keyframes.filter((kf) => kf.percentage !== percentage); + writeKeyframeCache(sf, elementId, { ...prev, keyframes: newKeyframes }); + } + return prev; + }, + persist: () => + commitMutation( + selection, + { type: "remove-keyframe", animationId, percentage }, + { label: `Remove keyframe at ${percentage}%`, softReload: true }, + ), + rollback: (prev) => { + writeKeyframeCache(sf, elementId, prev); + }, + }); + }, + [commitMutation, activeCompPath], + ); + + const convertToKeyframes = useCallback( + (selection: DomEditSelection, animationId: string) => { + void commitMutation( + selection, + { type: "convert-to-keyframes", animationId }, + { label: "Convert to keyframes" }, + ); + }, + [commitMutation], + ); + + const removeAllKeyframes = useCallback( + (selection: DomEditSelection, animationId: string) => { + void commitMutation( + selection, + { type: "remove-all-keyframes", animationId }, + { label: "Remove all keyframes", softReload: true }, + ); + }, + [commitMutation], + ); + return { + commitMutation, updateGsapProperty, updateGsapMeta, deleteGsapAnimation, @@ -363,5 +485,9 @@ export function useGsapScriptCommits({ updateGsapFromProperty, addGsapFromProperty, removeGsapFromProperty, + addKeyframe, + removeKeyframe, + convertToKeyframes, + removeAllKeyframes, }; } diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index ecca0b3ba..70fafa9a4 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -1,5 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser"; +import { usePlayerStore } from "../player/store/playerStore"; + +function extractIdFromSelector(selector: string): string | null { + const match = selector.match(/^#([\w-]+)/); + return match ? match[1] : null; +} /** The selected element's identity for matching tweens to it. */ export interface GsapElementTarget { @@ -28,7 +34,7 @@ export function getAnimationsForElement( ); } -async function fetchParsedAnimations( +export async function fetchParsedAnimations( projectId: string, sourceFile: string, ): Promise { @@ -98,6 +104,16 @@ export function useGsapAnimationsForElement( [allAnimations, targetId, targetSelector], ); + // Populate keyframe cache for the selected element. + // Key format must match timeline element keys: "sourceFile#domId". + const elementId = target?.id ?? null; + useEffect(() => { + if (!elementId) return; + const { setKeyframeCache } = usePlayerStore.getState(); + const withKeyframes = animations.find((a) => a.keyframes); + setKeyframeCache(`${sourceFile}#${elementId}`, withKeyframes?.keyframes ?? undefined); + }, [elementId, sourceFile, animations]); + return { animations, multipleTimelines, unsupportedTimelinePattern }; } @@ -106,3 +122,38 @@ export function useGsapCacheVersion() { const bump = useCallback(() => setVersion((v) => v + 1), []); return { version, bump }; } + +/** + * Fetch GSAP animations for a file and populate the keyframe cache for all + * elements. Called from the Timeline component so diamonds show without + * requiring a selection. + */ +export function usePopulateKeyframeCacheForFile( + projectId: string | null, + sourceFile: string, + version: number, +): void { + const lastFetchKeyRef = useRef(""); + + useEffect(() => { + const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}`; + if (fetchKey === lastFetchKeyRef.current) return; + lastFetchKeyRef.current = fetchKey; + if (!projectId) return; + + let cancelled = false; + fetchParsedAnimations(projectId, sourceFile).then((parsed) => { + if (cancelled || !parsed) return; + const { setKeyframeCache } = usePlayerStore.getState(); + for (const anim of parsed.animations) { + if (!anim.keyframes) continue; + const id = extractIdFromSelector(anim.targetSelector); + if (id) setKeyframeCache(`${sourceFile}#${id}`, anim.keyframes); + } + }); + + return () => { + cancelled = true; + }; + }, [projectId, sourceFile, version]); +} diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index fdbf18925..d67aad396 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -1,6 +1,18 @@ import { create } from "zustand"; import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences"; +/** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */ +export interface KeyframeCacheEntry { + format: string; + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }>; + ease?: string; + easeEach?: string; +} + export interface TimelineElement { id: string; label?: string; @@ -51,6 +63,15 @@ interface PlayerState { /** Work-area out-point (seconds). When set, loop ends here and E jumps here. */ outPoint: number | null; + /** Set of selected keyframe keys in format `${elementId}:${percentage}`. */ + selectedKeyframes: Set; + toggleSelectedKeyframe: (key: string) => void; + clearSelectedKeyframes: () => void; + + /** Keyframe data per element id, populated from parsed GSAP animations. */ + keyframeCache: Map; + setKeyframeCache: (elementId: string, data: KeyframeCacheEntry | undefined) => void; + setIsPlaying: (playing: boolean) => void; setCurrentTime: (time: number) => void; setDuration: (duration: number) => void; @@ -107,6 +128,25 @@ export const usePlayerStore = create((set) => ({ inPoint: null, outPoint: null, + selectedKeyframes: new Set(), + toggleSelectedKeyframe: (key) => + set((s) => { + const next = new Set(s.selectedKeyframes); + if (next.has(key)) next.delete(key); + else next.add(key); + return { selectedKeyframes: next }; + }), + clearSelectedKeyframes: () => set({ selectedKeyframes: new Set() }), + + keyframeCache: new Map(), + setKeyframeCache: (elementId, data) => + set((s) => { + const next = new Map(s.keyframeCache); + if (data) next.set(elementId, data); + else next.delete(elementId); + return { keyframeCache: next }; + }), + requestedSeekTime: null, requestSeek: (time) => set({ requestedSeekTime: time }), clearSeekRequest: () => set({ requestedSeekTime: null }), @@ -169,5 +209,7 @@ export const usePlayerStore = create((set) => ({ selectedElementId: null, inPoint: null, outPoint: null, + selectedKeyframes: new Set(), + keyframeCache: new Map(), }), }));