@@ -4,24 +4,214 @@ import {
44} from "../player/components/timelineZoom" ;
55import { getTimelineToggleTitle } from "../utils/timelineDiscovery" ;
66import { usePlayerStore } from "../player" ;
7+ import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability" ;
78import { 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
992interface 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" >
0 commit comments