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) => (
))}
{/* 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',
}