varunm2004 commited on
Commit
56e8946
ยท
verified ยท
1 Parent(s): 1cb6ef5

Create Timeline.tsx

Browse files
Files changed (1) hide show
  1. src/components/Timeline.tsx +210 -0
src/components/Timeline.tsx ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useRef, useEffect, useCallback } from 'react';
2
+ import { useStudioStore } from '../store/useStudioStore';
3
+ import { Keyframe } from '../utils/timeline';
4
+ import './Timeline.css';
5
+
6
+ const Timeline: React.FC = () => {
7
+ const {
8
+ objects, selectedId,
9
+ tracks, playhead, setPlayhead,
10
+ timelinePlaying, setTimelinePlaying,
11
+ timelineDuration, setTimelineDuration,
12
+ addKeyframe, removeKeyframe,
13
+ } = useStudioStore();
14
+
15
+ const rafRef = useRef<number>(0);
16
+ const lastTimeRef = useRef<number>(0);
17
+ const rulerRef = useRef<HTMLDivElement>(null);
18
+
19
+ // โ”€โ”€ Playback loop โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
20
+ useEffect(() => {
21
+ if (!timelinePlaying) {
22
+ cancelAnimationFrame(rafRef.current);
23
+ return;
24
+ }
25
+ const tick = (now: number) => {
26
+ const delta = (now - lastTimeRef.current) / 1000;
27
+ lastTimeRef.current = now;
28
+ setPlayhead((prev: number) => {
29
+ const next = prev + delta;
30
+ if (next >= timelineDuration) {
31
+ setTimelinePlaying(false);
32
+ return timelineDuration;
33
+ }
34
+ return next;
35
+ });
36
+ rafRef.current = requestAnimationFrame(tick);
37
+ };
38
+ lastTimeRef.current = performance.now();
39
+ rafRef.current = requestAnimationFrame(tick);
40
+ return () => cancelAnimationFrame(rafRef.current);
41
+ }, [timelinePlaying, timelineDuration, setPlayhead, setTimelinePlaying]);
42
+
43
+ // โ”€โ”€ Add keyframe at playhead for selected object โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
44
+ const handleAddKeyframe = useCallback(() => {
45
+ if (!selectedId) return;
46
+ const obj = objects.find(o => o.id === selectedId);
47
+ if (!obj) return;
48
+ const kf: Keyframe = {
49
+ id: Math.random().toString(36).slice(2),
50
+ time: parseFloat(playhead.toFixed(3)),
51
+ position: [...obj.position] as [number,number,number],
52
+ rotation: [...obj.rotation] as [number,number,number],
53
+ scale: [...obj.scale] as [number,number,number],
54
+ easing: 'ease-in-out',
55
+ };
56
+ addKeyframe(selectedId, kf);
57
+ }, [selectedId, objects, playhead, addKeyframe]);
58
+
59
+ // โ”€โ”€ Keyboard shortcut: I = insert keyframe โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
60
+ useEffect(() => {
61
+ const handler = (e: KeyboardEvent) => {
62
+ if (e.key === 'i' || e.key === 'I') handleAddKeyframe();
63
+ };
64
+ window.addEventListener('keydown', handler);
65
+ return () => window.removeEventListener('keydown', handler);
66
+ }, [handleAddKeyframe]);
67
+
68
+ // โ”€โ”€ Click ruler to seek โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
69
+ const handleRulerClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
70
+ const rect = rulerRef.current?.getBoundingClientRect();
71
+ if (!rect) return;
72
+ const t = ((e.clientX - rect.left) / rect.width) * timelineDuration;
73
+ setPlayhead(Math.max(0, Math.min(timelineDuration, t)));
74
+ }, [timelineDuration, setPlayhead]);
75
+
76
+ const fmt = (t: number) => {
77
+ const s = Math.floor(t);
78
+ const f = Math.floor((t % 1) * 30);
79
+ return `${String(s).padStart(2,'0')}:${String(f).padStart(2,'0')}`;
80
+ };
81
+
82
+ const playheadPct = (playhead / timelineDuration) * 100;
83
+
84
+ return (
85
+ <div className="timeline">
86
+
87
+ {/* โ”€โ”€ Controls bar โ”€โ”€ */}
88
+ <div className="tl-controls">
89
+ <button className="tl-btn" title="Rewind"
90
+ onClick={() => { setPlayhead(0); setTimelinePlaying(false); }}>โฎ</button>
91
+
92
+ <button
93
+ className={`tl-btn tl-play ${timelinePlaying ? 'playing' : ''}`}
94
+ onClick={() => setTimelinePlaying(!timelinePlaying)}
95
+ >
96
+ {timelinePlaying ? 'โธ' : 'โ–ถ'}
97
+ </button>
98
+
99
+ <button className="tl-btn" title="Go to end"
100
+ onClick={() => { setPlayhead(timelineDuration); setTimelinePlaying(false); }}>โญ</button>
101
+
102
+ <div className="tl-time">{fmt(playhead)} / {fmt(timelineDuration)}</div>
103
+
104
+ <button
105
+ className={`tl-btn tl-kf-btn ${!selectedId ? 'disabled' : ''}`}
106
+ onClick={handleAddKeyframe}
107
+ disabled={!selectedId}
108
+ title="Add keyframe at playhead (I)"
109
+ >
110
+ โ—† ADD KEY
111
+ </button>
112
+
113
+ <div className="tl-duration">
114
+ <label>Dur</label>
115
+ <input
116
+ type="number" min={1} max={120} step={1}
117
+ value={timelineDuration}
118
+ onChange={(e) => setTimelineDuration(parseInt(e.target.value) || 5)}
119
+ />
120
+ <span>s</span>
121
+ </div>
122
+ </div>
123
+
124
+ {/* โ”€โ”€ Track area โ”€โ”€ */}
125
+ <div className="tl-body">
126
+
127
+ {/* Object labels column */}
128
+ <div className="tl-labels">
129
+ {objects.length === 0 && (
130
+ <div className="tl-empty">Load a model first</div>
131
+ )}
132
+ {objects.map(obj => {
133
+ const kfCount = tracks.find(t => t.objectId === obj.id)?.keyframes.length ?? 0;
134
+ return (
135
+ <div key={obj.id}
136
+ className={`tl-label ${selectedId === obj.id ? 'active' : ''}`}>
137
+ <span className="tl-label-dot">โ—ˆ</span>
138
+ <span className="tl-label-name">{obj.name}</span>
139
+ {kfCount > 0 && <span className="tl-kf-count">{kfCount}K</span>}
140
+ </div>
141
+ );
142
+ })}
143
+ </div>
144
+
145
+ {/* Ruler + tracks column */}
146
+ <div className="tl-tracks-wrap">
147
+
148
+ {/* Ruler */}
149
+ <div className="tl-ruler" ref={rulerRef} onClick={handleRulerClick}>
150
+ {Array.from({ length: timelineDuration + 1 }, (_, i) => (
151
+ <div key={i} className="tl-tick"
152
+ style={{ left: `${(i / timelineDuration) * 100}%` }}>
153
+ <span>{i}s</span>
154
+ </div>
155
+ ))}
156
+ {/* Playhead needle */}
157
+ <div className="tl-playhead" style={{ left: `${playheadPct}%` }} />
158
+ </div>
159
+
160
+ {/* Keyframe tracks */}
161
+ <div className="tl-tracks">
162
+ {objects.map(obj => {
163
+ const track = tracks.find(t => t.objectId === obj.id);
164
+ const sorted = track
165
+ ? [...track.keyframes].sort((a, b) => a.time - b.time)
166
+ : [];
167
+ return (
168
+ <div key={obj.id}
169
+ className={`tl-track ${selectedId === obj.id ? 'active' : ''}`}>
170
+
171
+ {/* Connection lines between consecutive keyframes */}
172
+ {sorted.map((kf, i) => {
173
+ if (i === sorted.length - 1) return null;
174
+ const x1 = (kf.time / timelineDuration) * 100;
175
+ const x2 = (sorted[i+1].time / timelineDuration) * 100;
176
+ return (
177
+ <div key={kf.id + '-line'} className="tl-kf-line"
178
+ style={{ left: `${x1}%`, width: `${x2 - x1}%` }} />
179
+ );
180
+ })}
181
+
182
+ {/* Keyframe diamonds */}
183
+ {sorted.map(kf => (
184
+ <div
185
+ key={kf.id}
186
+ className="tl-kf-diamond"
187
+ style={{ left: `${(kf.time / timelineDuration) * 100}%` }}
188
+ title={`${kf.time.toFixed(2)}s โ€ข click to delete`}
189
+ onClick={(e) => {
190
+ e.stopPropagation();
191
+ removeKeyframe(obj.id, kf.id);
192
+ }}
193
+ >โ—†</div>
194
+ ))}
195
+
196
+ {/* Playhead ghost line on each track */}
197
+ <div className="tl-track-playhead"
198
+ style={{ left: `${playheadPct}%` }} />
199
+ </div>
200
+ );
201
+ })}
202
+ </div>
203
+
204
+ </div>
205
+ </div>
206
+ </div>
207
+ );
208
+ };
209
+
210
+ export default Timeline;