import { useRef, useCallback, useState, useEffect } from 'react'; import { useCutsceneStore, useSelectedCutscene } from '../../store/cutsceneStore'; import { useShallow } from 'zustand/react/shallow'; import styles from './Timeline.module.css'; const LABEL_WIDTH = 140; const ROW_HEIGHT = 32; const SUBTITLE_ROW_HEIGHT = 14; const MIN_ZOOM = 20; // px per second const MAX_ZOOM = 400; const LAYER_COLORS = ['#4a7fc1', '#c17a4a', '#4ac17a', '#c14a7a', '#7a4ac1', '#c1b44a', '#4ac1c1']; function layerColor(i: number) { return LAYER_COLORS[i % LAYER_COLORS.length]; } function basename(path: string) { return path.split('/').pop() ?? path; } export default function Timeline() { const cutscene = useSelectedCutscene(); const { selectedLayerIndex, currentTimeMs, playState } = useCutsceneStore(useShallow(s => ({ selectedLayerIndex: s.selectedLayerIndex, currentTimeMs: s.currentTimeMs, playState: s.playState, }))); const { selectLayer, addLayer, removeLayer, moveLayerUp, moveLayerDown, updateLayer, setCurrentTime, setPlayState } = useCutsceneStore(useShallow(s => ({ selectLayer: s.selectLayer, addLayer: s.addLayer, removeLayer: s.removeLayer, moveLayerUp: s.moveLayerUp, moveLayerDown: s.moveLayerDown, updateLayer: s.updateLayer, setCurrentTime: s.setCurrentTime, setPlayState: s.setPlayState, }))); const [pxPerSec, setPxPerSec] = useState(60); const scrollRef = useRef(null); const isDraggingPlayhead = useRef(false); const dragState = useRef<{ type: 'move' | 'left' | 'right'; layerIndex: number; startX: number; startMs: number; endMs: number; durationMs: number; } | null>(null); const msToX = useCallback((ms: number) => (ms / 1000) * pxPerSec, [pxPerSec]); const xToMs = useCallback((x: number) => Math.max(0, Math.round((x / pxPerSec) * 1000)), [pxPerSec]); const totalMs = cutscene ? Math.max( cutscene.durationMs, cutscene.imageSegments.reduce((m, s) => Math.max(m, s.endMs), 0), 10000, ) + 2000 : 12000; const rulerWidth = Math.ceil(msToX(totalMs)); // Ruler ticks const tickStepMs = pxPerSec >= 100 ? 500 : pxPerSec >= 50 ? 1000 : 2000; const labelStepMs = pxPerSec >= 100 ? 1000 : pxPerSec >= 50 ? 2000 : 4000; const ticks: number[] = []; for (let ms = 0; ms <= totalMs; ms += tickStepMs) ticks.push(ms); // Scroll wheel zoom useEffect(() => { const el = scrollRef.current; if (!el) return; function onWheel(e: WheelEvent) { if (!e.ctrlKey && !e.metaKey) return; e.preventDefault(); setPxPerSec(prev => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, prev * (e.deltaY < 0 ? 1.15 : 0.87)))); } el.addEventListener('wheel', onWheel, { passive: false }); return () => el.removeEventListener('wheel', onWheel); }, []); // Auto-scroll playhead into view while playing useEffect(() => { if (playState !== 'playing') return; const el = scrollRef.current; if (!el) return; const x = msToX(currentTimeMs) + LABEL_WIDTH; const { scrollLeft, clientWidth } = el; if (x > scrollLeft + clientWidth - 40) { el.scrollLeft = x - clientWidth + 80; } }, [currentTimeMs, playState, msToX]); // ── Playhead drag ────────────────────────────────────────────────────────── function onRulerMouseDown(e: React.MouseEvent) { if (e.button !== 0) return; isDraggingPlayhead.current = true; const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const x = e.clientX - rect.left - LABEL_WIDTH + (scrollRef.current?.scrollLeft ?? 0); setCurrentTime(xToMs(x)); if (playState === 'playing') setPlayState('paused'); function onMove(ev: MouseEvent) { const x2 = ev.clientX - rect.left - LABEL_WIDTH + (scrollRef.current?.scrollLeft ?? 0); setCurrentTime(xToMs(x2)); } function onUp() { isDraggingPlayhead.current = false; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); } window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); } // ── Segment bar drag ─────────────────────────────────────────────────────── function onBarMouseDown(e: React.MouseEvent, layerIndex: number, type: 'move' | 'left' | 'right') { e.preventDefault(); e.stopPropagation(); selectLayer(layerIndex); const seg = cutscene!.imageSegments[layerIndex]; dragState.current = { type, layerIndex, startX: e.clientX, startMs: seg.startMs, endMs: seg.endMs, durationMs: seg.endMs - seg.startMs, }; function onMove(ev: MouseEvent) { if (!dragState.current) return; const dx = ev.clientX - dragState.current.startX; const dMs = Math.round((dx / pxPerSec) * 1000); const { type, layerIndex: li, startMs, endMs, durationMs } = dragState.current; if (type === 'move') { const newStart = Math.max(0, startMs + dMs); updateLayer(li, { startMs: newStart, endMs: newStart + durationMs }); } else if (type === 'left') { const newStart = Math.max(0, Math.min(endMs - 100, startMs + dMs)); updateLayer(li, { startMs: newStart }); } else { const newEnd = Math.max(startMs + 100, endMs + dMs); updateLayer(li, { endMs: newEnd }); } } function onUp() { dragState.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); } window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); } const playheadX = msToX(currentTimeMs) + LABEL_WIDTH; // Subtitle timing for display let subtitleBlocks: { x: number; w: number; text: string }[] = []; if (cutscene) { let elapsed = 0; for (const line of cutscene.lines) { const dur = line.durationMs > 0 ? line.durationMs : Math.max(1500, Math.round((line.text.length / 17) * 1000)); subtitleBlocks.push({ x: msToX(elapsed), w: msToX(dur), text: line.text || '…' }); elapsed += dur; } } const layerCount = cutscene?.imageSegments.length ?? 0; const totalHeight = layerCount * ROW_HEIGHT + SUBTITLE_ROW_HEIGHT + 20; return (
{/* Toolbar */}
{selectedLayerIndex !== null && cutscene && ( <> )}
Zoom:
{/* Scrollable area */}
{/* Ruler */}
{ticks.map(ms => (
{ms % labelStepMs === 0 && ( {ms / 1000}s )}
))}
{/* Layers + playhead overlay */}
{/* Playhead */}
{/* Grid lines */} {ticks.filter(ms => ms % labelStepMs === 0).map(ms => (
))} {/* Layer rows */} {cutscene?.imageSegments.map((seg, i) => { const isSelected = selectedLayerIndex === i; const color = layerColor(i); const barX = LABEL_WIDTH + msToX(seg.startMs); const barW = Math.max(4, msToX(seg.endMs) - msToX(seg.startMs)); return (
selectLayer(i)} > {/* Label */}
{basename(seg.path) || `Layer ${i + 1}`}
{/* Bar */}
onBarMouseDown(e, i, 'move')} >
onBarMouseDown(e, i, 'left')} />
onBarMouseDown(e, i, 'right')} />
); })} {/* Subtitle blocks row */} {cutscene && (
Subtitles
{subtitleBlocks.map((b, i) => (
))}
)}
); }