286 lines
11 KiB
TypeScript
286 lines
11 KiB
TypeScript
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<HTMLDivElement>(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 (
|
||
<div className={styles.container}>
|
||
{/* Toolbar */}
|
||
<div className={styles.toolbar}>
|
||
<button className={styles.tbBtn} onClick={addLayer} disabled={!cutscene} title="Add layer">+ Layer</button>
|
||
{selectedLayerIndex !== null && cutscene && (
|
||
<>
|
||
<button className={styles.tbBtn} onClick={() => moveLayerUp(selectedLayerIndex!)} disabled={selectedLayerIndex! <= 0} title="Move up">↑</button>
|
||
<button className={styles.tbBtn} onClick={() => moveLayerDown(selectedLayerIndex!)} disabled={selectedLayerIndex! >= layerCount - 1} title="Move down">↓</button>
|
||
<button className={styles.tbBtn} onClick={() => removeLayer(selectedLayerIndex!)} title="Remove layer" style={{ color: '#e06c6c' }}>✕ Layer</button>
|
||
</>
|
||
)}
|
||
<div className={styles.spacer} />
|
||
<span className={styles.zoomLabel}>Zoom:</span>
|
||
<button className={styles.tbBtn} onClick={() => setPxPerSec(p => Math.max(MIN_ZOOM, p * 0.75))}>−</button>
|
||
<button className={styles.tbBtn} onClick={() => setPxPerSec(p => Math.min(MAX_ZOOM, p * 1.33))}>+</button>
|
||
<button className={styles.tbBtn} onClick={() => setPxPerSec(60)}>Reset</button>
|
||
</div>
|
||
|
||
{/* Scrollable area */}
|
||
<div className={styles.scroll} ref={scrollRef}>
|
||
{/* Ruler */}
|
||
<div
|
||
className={styles.ruler}
|
||
style={{ width: LABEL_WIDTH + rulerWidth }}
|
||
onMouseDown={onRulerMouseDown}
|
||
>
|
||
<div style={{ width: LABEL_WIDTH, flexShrink: 0 }} />
|
||
<div style={{ position: 'relative', flex: 1 }}>
|
||
{ticks.map(ms => (
|
||
<div
|
||
key={ms}
|
||
className={styles.tick}
|
||
style={{ left: msToX(ms) }}
|
||
>
|
||
{ms % labelStepMs === 0 && (
|
||
<span className={styles.tickLabel}>{ms / 1000}s</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Layers + playhead overlay */}
|
||
<div
|
||
className={styles.layersArea}
|
||
style={{ width: LABEL_WIDTH + rulerWidth, minHeight: totalHeight }}
|
||
>
|
||
{/* Playhead */}
|
||
<div className={styles.playhead} style={{ left: playheadX }} />
|
||
|
||
{/* Grid lines */}
|
||
{ticks.filter(ms => ms % labelStepMs === 0).map(ms => (
|
||
<div key={ms} className={styles.gridLine} style={{ left: LABEL_WIDTH + msToX(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 (
|
||
<div
|
||
key={i}
|
||
className={`${styles.row} ${isSelected ? styles.rowSelected : ''}`}
|
||
style={{ top: i * ROW_HEIGHT }}
|
||
onClick={() => selectLayer(i)}
|
||
>
|
||
{/* Label */}
|
||
<div className={styles.rowLabel} style={{ borderLeft: `3px solid ${color}` }}>
|
||
<span className={styles.layerName}>{basename(seg.path) || `Layer ${i + 1}`}</span>
|
||
</div>
|
||
|
||
{/* Bar */}
|
||
<div
|
||
className={styles.bar}
|
||
style={{ left: barX, width: barW, background: color + (isSelected ? 'cc' : '88') }}
|
||
onMouseDown={e => onBarMouseDown(e, i, 'move')}
|
||
>
|
||
<div className={`${styles.handle} ${styles.handleLeft}`}
|
||
onMouseDown={e => onBarMouseDown(e, i, 'left')} />
|
||
<div className={`${styles.handle} ${styles.handleRight}`}
|
||
onMouseDown={e => onBarMouseDown(e, i, 'right')} />
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* Subtitle blocks row */}
|
||
{cutscene && (
|
||
<div
|
||
className={styles.subtitleRow}
|
||
style={{ top: layerCount * ROW_HEIGHT }}
|
||
>
|
||
<div className={styles.subtitleLabel}>Subtitles</div>
|
||
{subtitleBlocks.map((b, i) => (
|
||
<div
|
||
key={i}
|
||
className={styles.subtitleBlock}
|
||
style={{ left: LABEL_WIDTH + b.x, width: Math.max(2, b.w) }}
|
||
title={b.text}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|