import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { StoreApi } from "zustand"; import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth"; /** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */ export interface KeyframeCacheEntry { format: string; keyframes: Array<{ percentage: number; /** Original tween-relative percentage (server mutations need this, not the clip-relative `percentage`). */ tweenPercentage?: number; /** Which property group the source tween belongs to (position, scale, rotation, visual, etc.). */ propertyGroup?: string; /** Source tween id — lets the inline clip-row ease button target a specific segment. */ animationId?: string; properties: Record; ease?: string; /** Source animation/keyframe targets that collide at this clip percentage. */ collidingAnimationTargets?: AnimationKeyframeTarget[]; }>; ease?: string; easeEach?: string; } export interface FocusedEaseSegment { animationId: string; collidingAnimationTargets?: AnimationKeyframeTarget[]; tweenPercentage: number; elementId: string; projectId: string | null; sessionEpoch: number; nonce: number; } type FocusedEaseSegmentTarget = Omit; interface TimelineSessionIdentity { timelineProjectId: string | null; timelineSessionEpoch: number; } export function isFocusedEaseRequestCurrent( request: FocusedEaseSegment, state: TimelineSessionIdentity & { selectedElementId: string | null }, ): boolean { return ( request.projectId === state.timelineProjectId && request.sessionEpoch === state.timelineSessionEpoch && request.elementId === state.selectedElementId ); } export interface KeyframeSlice { /** Selected collapsed (`element:pct`) or expanded (`element:group:animation:clipPct`) diamonds. */ selectedKeyframes: Set; toggleSelectedKeyframe: (key: string) => void; clearSelectedKeyframes: () => void; /** Clips whose keyframe property lanes are expanded in the timeline. */ expandedClipIds: Set; toggleClipExpanded: (id: string) => void; setClipExpanded: (id: string, expanded: boolean) => void; /** Union-expand clips (keyframed clips are expanded by default on load). */ expandClips: (ids: readonly string[]) => void; /** * Project/session/element-scoped request. Its nonce is monotonic across store * resets so a stale consumer can never collide with a later request. */ focusedEaseSegment: FocusedEaseSegment | null; focusedEaseRequestNonce: number; setFocusedEaseSegment: (target: FocusedEaseSegmentTarget) => void; clearFocusedEaseSegment: (nonce: number) => void; /** Keyframe data per element id, populated from parsed GSAP animations. */ keyframeCache: Map; /** Unmerged source tweens per element; expanded property lanes read this, never keyframeCache. */ gsapAnimations: Map; setGsapAnimations: (elementId: string, animations: GsapAnimation[] | undefined) => void; setKeyframeCache: (elementId: string, data: KeyframeCacheEntry | undefined) => void; } export function createKeyframeSlice( set: StoreApi["setState"], getTimelineSessionIdentity: () => TimelineSessionIdentity, ): KeyframeSlice { return { selectedKeyframes: new Set(), toggleSelectedKeyframe: (key) => set((state) => { const next = new Set(state.selectedKeyframes); if (next.has(key)) next.delete(key); else next.add(key); return { selectedKeyframes: next }; }), clearSelectedKeyframes: () => set({ selectedKeyframes: new Set() }), expandedClipIds: new Set(), toggleClipExpanded: (id) => set((state) => { const next = new Set(state.expandedClipIds); if (next.has(id)) next.delete(id); else next.add(id); return { expandedClipIds: next }; }), setClipExpanded: (id, expanded) => set((state) => { if (state.expandedClipIds.has(id) === expanded) return state; const next = new Set(state.expandedClipIds); if (expanded) next.add(id); else next.delete(id); return { expandedClipIds: next }; }), expandClips: (ids) => set((state) => { if (ids.every((id) => state.expandedClipIds.has(id))) return state; const next = new Set(state.expandedClipIds); for (const id of ids) next.add(id); return { expandedClipIds: next }; }), focusedEaseSegment: null, focusedEaseRequestNonce: 0, setFocusedEaseSegment: (target) => set((state) => { const nonce = state.focusedEaseRequestNonce + 1; const { timelineProjectId, timelineSessionEpoch } = getTimelineSessionIdentity(); return { focusedEaseRequestNonce: nonce, focusedEaseSegment: { ...target, projectId: timelineProjectId, sessionEpoch: timelineSessionEpoch, nonce, }, }; }), clearFocusedEaseSegment: (nonce) => set((state) => state.focusedEaseSegment?.nonce === nonce ? { focusedEaseSegment: null } : state, ), keyframeCache: new Map(), setKeyframeCache: (elementId, data) => set((state) => { // A write that changes nothing must not emit a new Map: the cache has // several hot writers (per-element effect, file populate, post-commit // updater, delete) and every no-op re-rendered every subscriber. if ( data ? state.keyframeCache.get(elementId) === data : !state.keyframeCache.has(elementId) ) return state; const next = new Map(state.keyframeCache); if (data) next.set(elementId, data); else next.delete(elementId); return { keyframeCache: next }; }), gsapAnimations: new Map(), setGsapAnimations: (elementId, animations) => set((state) => { if ( animations ? state.gsapAnimations.get(elementId) === animations : !state.gsapAnimations.has(elementId) ) return state; const next = new Map(state.gsapAnimations); if (animations) next.set(elementId, animations); else next.delete(elementId); return { gsapAnimations: next }; }), }; }