studio3d / src /components /Timeline.tsx
varunm2004's picture
Update src/components/Timeline.tsx
82c6e5a verified
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)}sclick 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;