import { useRef, useState, useCallback, useEffect } from 'react' import useStore from '../store/useStore' const TRACK_H = 28 const COLORS = ['#4f8eff','#ef4444','#22c55e','#f59e0b','#8b5cf6','#f97316'] function KeyframeDot({ frame, modelId, color, trackW, totalFrames }) { const { moveKeyframe, removeKeyframe } = useStore.getState() const x = (frame / totalFrames) * trackW const onPointerDown = (e) => { e.stopPropagation() const startX = e.clientX, startF = frame const move = me => { const dx = me.clientX - startX const newF = Math.max(0, Math.min(totalFrames-1, Math.round(startF + (dx/trackW)*totalFrames))) if (newF !== frame) moveKeyframe(frame, newF, modelId) } const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up) } window.addEventListener('pointermove', move) window.addEventListener('pointerup', up) } return (
{ e.stopPropagation(); removeKeyframe(frame, modelId) }} title={`Frame ${frame} — drag to move, dbl-click to delete`} style={{ position:'absolute', left: x - 5, top:'50%', transform:'translateY(-50%)', width:10, height:10, borderRadius:2, background: color, cursor:'ew-resize', zIndex:10, boxShadow:`0 0 6px ${color}88`, border:'1px solid rgba(255,255,255,0.3)', rotate:'45deg', transition:'transform 0.1s', }} /> ) } export default function Timeline() { const { models, keyframes, currentFrame, totalFrames, fps, isPlaying, setCurrentFrame, setIsPlaying, addKeyframe, selectedModelId, showTimeline, setShowTimeline, setTotalFrames, setFps, cameras=[], activeCameraId:activeCamId, } = useStore() const rulerRef = useRef() const [trackW, setTrackW] = useState(600) const [settings, setSettings] = useState(false) const measuredRef = useCallback(node => { if (!node) return const ro = new ResizeObserver(e => setTrackW(e[0].contentRect.width)) ro.observe(node) return () => ro.disconnect() }, []) const scrub = useCallback((clientX) => { const rect = rulerRef.current?.getBoundingClientRect() if (!rect) return const x = clientX - rect.left setCurrentFrame(Math.round(Math.max(0, Math.min(totalFrames-1, (x/trackW)*totalFrames)))) }, [trackW, totalFrames]) const handlePointerDown = (e) => { scrub(e.touches ? e.touches[0].clientX : e.clientX) const move = me => scrub(me.touches ? me.touches[0].clientX : me.clientX) const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up) } window.addEventListener('pointermove', move) window.addEventListener('pointerup', up) } const playheadX = (currentFrame / totalFrames) * trackW const duration = (totalFrames / fps).toFixed(1) if (!showTimeline) return (
) return (
{/* Transport bar */}
{String(currentFrame).padStart(4,'0')} /{totalFrames}
{duration}s
{selectedModelId && ( )}
{/* Settings row */} {settings && (
Frames: {[120,200,300,500].map(f => ( ))} FPS: {[24,30,60].map(f => ( ))}
)} {/* Track area */}
{/* Labels */}
LAYERS
{models.map((m,i) => (
{m.name.substring(0,10)}
))}
{/* Scrollable ruler + tracks */}
{/* Ruler */}
{ measuredRef(el); rulerRef.current = el }} style={{ position:'relative', height:20, borderBottom:'1px solid var(--border)', cursor:'crosshair', background:'var(--bg2)' }} onPointerDown={handlePointerDown} onTouchStart={e => handlePointerDown(e)} > {Array.from({ length: Math.ceil(totalFrames/10) }, (_,i) => { const f = i*10, x = (f/totalFrames)*trackW return (
{f%10===0 && {f}}
) })}
{/* Tracks */}
{models.map((m,i) => { const c = COLORS[i%COLORS.length] const mKfs = Object.entries(keyframes).filter(([,kf])=>kf[m.id]).map(([f])=>parseInt(f)) return (
{/* Track line */}
{mKfs.map(f => ( ))}
) })} {/* Playhead */}
) } const tbtn = { padding:'4px 8px', borderRadius:'var(--radius-sm)', background:'var(--bg3)', border:'1px solid var(--border)', color:'var(--text1)', fontSize:12, cursor:'pointer', flexShrink:0, transition:'all 0.12s', }