Spaces:
Sleeping
Sleeping
| import React, { useRef, useEffect, useCallback } from 'react'; | |
| import { useStudioStore } from '../store/useStudioStore'; | |
| import { Keyframe } from '../utils/timeline'; | |
| import './Timeline.css'; | |
| const Timeline: React.FC = () => { | |
| const { | |
| objects, selectedId, | |
| tracks, playhead, setPlayhead, | |
| timelinePlaying, setTimelinePlaying, | |
| timelineDuration, setTimelineDuration, | |
| addKeyframe, removeKeyframe, | |
| } = useStudioStore(); | |
| const rafRef = useRef<number>(0); | |
| const lastTimeRef = useRef<number>(0); | |
| const playheadRef = useRef<number>(playhead); // ← ref so tick closure stays fresh | |
| const rulerRef = useRef<HTMLDivElement>(null); | |
| // Keep ref in sync with store value | |
| useEffect(() => { playheadRef.current = playhead; }, [playhead]); | |
| // ── Playback loop ───────────────────────────────────────────── | |
| useEffect(() => { | |
| if (!timelinePlaying) { | |
| cancelAnimationFrame(rafRef.current); | |
| return; | |
| } | |
| const tick = (now: number) => { | |
| const delta = (now - lastTimeRef.current) / 1000; | |
| lastTimeRef.current = now; | |
| const next = playheadRef.current + delta; | |
| if (next >= timelineDuration) { | |
| setPlayhead(timelineDuration); | |
| setTimelinePlaying(false); | |
| return; | |
| } | |
| setPlayhead(next); | |
| rafRef.current = requestAnimationFrame(tick); | |
| }; | |
| lastTimeRef.current = performance.now(); | |
| rafRef.current = requestAnimationFrame(tick); | |
| return () => cancelAnimationFrame(rafRef.current); | |
| }, [timelinePlaying, timelineDuration, setPlayhead, setTimelinePlaying]); | |
| // ── Add keyframe at playhead for selected object ────────────── | |
| const handleAddKeyframe = useCallback(() => { | |
| if (!selectedId) return; | |
| const obj = objects.find(o => o.id === selectedId); | |
| if (!obj) return; | |
| const kf: Keyframe = { | |
| id: Math.random().toString(36).slice(2), | |
| time: parseFloat(playheadRef.current.toFixed(3)), | |
| position: [...obj.position] as [number, number, number], | |
| rotation: [...obj.rotation] as [number, number, number], | |
| scale: [...obj.scale] as [number, number, number], | |
| easing: 'ease-in-out', | |
| }; | |
| addKeyframe(selectedId, kf); | |
| }, [selectedId, objects, addKeyframe]); | |
| // ── Keyboard shortcut: I = insert keyframe ──────────────────── | |
| useEffect(() => { | |
| const handler = (e: KeyboardEvent) => { | |
| if (e.key === 'i' || e.key === 'I') handleAddKeyframe(); | |
| }; | |
| window.addEventListener('keydown', handler); | |
| return () => window.removeEventListener('keydown', handler); | |
| }, [handleAddKeyframe]); | |
| // ── Click ruler to seek ─────────────────────────────────────── | |
| const handleRulerClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => { | |
| const rect = rulerRef.current?.getBoundingClientRect(); | |
| if (!rect) return; | |
| const t = ((e.clientX - rect.left) / rect.width) * timelineDuration; | |
| setPlayhead(Math.max(0, Math.min(timelineDuration, t))); | |
| }, [timelineDuration, setPlayhead]); | |
| const fmt = (t: number) => { | |
| const s = Math.floor(t); | |
| const f = Math.floor((t % 1) * 30); | |
| return `${String(s).padStart(2, '0')}:${String(f).padStart(2, '0')}`; | |
| }; | |
| const playheadPct = (playhead / timelineDuration) * 100; | |
| return ( | |
| <div className="timeline"> | |
| {/* ── Controls bar ── */} | |
| <div className="tl-controls"> | |
| <button className="tl-btn" title="Rewind" | |
| onClick={() => { setPlayhead(0); setTimelinePlaying(false); }}>⏮</button> | |
| <button | |
| className={`tl-btn tl-play ${timelinePlaying ? 'playing' : ''}`} | |
| onClick={() => setTimelinePlaying(!timelinePlaying)} | |
| > | |
| {timelinePlaying ? '⏸' : '▶'} | |
| </button> | |
| <button className="tl-btn" title="Go to end" | |
| onClick={() => { setPlayhead(timelineDuration); setTimelinePlaying(false); }}>⏭</button> | |
| <div className="tl-time">{fmt(playhead)} / {fmt(timelineDuration)}</div> | |
| <button | |
| className={`tl-btn tl-kf-btn ${!selectedId ? 'disabled' : ''}`} | |
| onClick={handleAddKeyframe} | |
| disabled={!selectedId} | |
| title="Add keyframe at playhead (I)" | |
| > | |
| ◆ ADD KEY | |
| </button> | |
| <div className="tl-duration"> | |
| <label>Dur</label> | |
| <input | |
| type="number" min={1} max={120} step={1} | |
| value={timelineDuration} | |
| onChange={(e) => setTimelineDuration(parseInt(e.target.value) || 5)} | |
| /> | |
| <span>s</span> | |
| </div> | |
| </div> | |
| {/* ── Track area ── */} | |
| <div className="tl-body"> | |
| {/* Object labels column */} | |
| <div className="tl-labels"> | |
| {objects.length === 0 && ( | |
| <div className="tl-empty">Load a model first</div> | |
| )} | |
| {objects.map(obj => { | |
| const kfCount = tracks.find(t => t.objectId === obj.id)?.keyframes.length ?? 0; | |
| return ( | |
| <div key={obj.id} | |
| className={`tl-label ${selectedId === obj.id ? 'active' : ''}`}> | |
| <span className="tl-label-dot">◈</span> | |
| <span className="tl-label-name">{obj.name}</span> | |
| {kfCount > 0 && <span className="tl-kf-count">{kfCount}K</span>} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| {/* Ruler + tracks column */} | |
| <div className="tl-tracks-wrap"> | |
| {/* Ruler */} | |
| <div className="tl-ruler" ref={rulerRef} onClick={handleRulerClick}> | |
| {Array.from({ length: timelineDuration + 1 }, (_, i) => ( | |
| <div key={i} className="tl-tick" | |
| style={{ left: `${(i / timelineDuration) * 100}%` }}> | |
| <span>{i}s</span> | |
| </div> | |
| ))} | |
| <div className="tl-playhead" style={{ left: `${playheadPct}%` }} /> | |
| </div> | |
| {/* Keyframe tracks */} | |
| <div className="tl-tracks"> | |
| {objects.map(obj => { | |
| const track = tracks.find(t => t.objectId === obj.id); | |
| const sorted = track | |
| ? [...track.keyframes].sort((a, b) => a.time - b.time) | |
| : []; | |
| return ( | |
| <div key={obj.id} | |
| className={`tl-track ${selectedId === obj.id ? 'active' : ''}`}> | |
| {/* Connection lines */} | |
| {sorted.map((kf, i) => { | |
| if (i === sorted.length - 1) return null; | |
| const x1 = (kf.time / timelineDuration) * 100; | |
| const x2 = (sorted[i+1].time / timelineDuration) * 100; | |
| return ( | |
| <div key={kf.id + '-line'} className="tl-kf-line" | |
| style={{ left: `${x1}%`, width: `${x2 - x1}%` }} /> | |
| ); | |
| })} | |
| {/* Keyframe diamonds */} | |
| {sorted.map(kf => ( | |
| <div key={kf.id} | |
| className="tl-kf-diamond" | |
| style={{ left: `${(kf.time / timelineDuration) * 100}%` }} | |
| title={`${kf.time.toFixed(2)}s • click to delete`} | |
| onClick={(e) => { e.stopPropagation(); removeKeyframe(obj.id, kf.id); }} | |
| >◆</div> | |
| ))} | |
| {/* Playhead ghost line */} | |
| <div className="tl-track-playhead" | |
| style={{ left: `${playheadPct}%` }} /> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default Timeline; |