import { useCallback, useRef, useEffect, useMemo, useState } from 'react'; import { useEditorStore } from '../store/editorStore'; import { Virtuoso } from 'react-virtuoso'; import { Scissors, VolumeX, SlidersHorizontal, Gauge, RotateCcw } from 'lucide-react'; interface TranscriptEditorProps { cutMode: boolean; muteMode: boolean; gainMode: boolean; gainModeDb: number; speedMode: boolean; speedModeValue: number; } export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainModeDb, speedMode, speedModeValue, }: TranscriptEditorProps) { const words = useEditorStore((s) => s.words); const segments = useEditorStore((s) => s.segments); const cutRanges = useEditorStore((s) => s.cutRanges); const muteRanges = useEditorStore((s) => s.muteRanges); const gainRanges = useEditorStore((s) => s.gainRanges); const speedRanges = useEditorStore((s) => s.speedRanges); const selectedWordIndices = useEditorStore((s) => s.selectedWordIndices); const hoveredWordIndex = useEditorStore((s) => s.hoveredWordIndex); const setSelectedWordIndices = useEditorStore((s) => s.setSelectedWordIndices); const setHoveredWordIndex = useEditorStore((s) => s.setHoveredWordIndex); const removeCutRange = useEditorStore((s) => s.removeCutRange); const removeMuteRange = useEditorStore((s) => s.removeMuteRange); const removeGainRange = useEditorStore((s) => s.removeGainRange); const removeSpeedRange = useEditorStore((s) => s.removeSpeedRange); const addCutRange = useEditorStore((s) => s.addCutRange); const addMuteRange = useEditorStore((s) => s.addMuteRange); const addGainRange = useEditorStore((s) => s.addGainRange); const addSpeedRange = useEditorStore((s) => s.addSpeedRange); const getWordAtTime = useEditorStore((s) => s.getWordAtTime); const selectionStart = useRef(null); const wasDragging = useRef(false); const virtuosoRef = useRef(null); const zoneDragStart = useRef(null); const [zoneDragRange, setZoneDragRange] = useState<{ start: number; end: number } | null>(null); const selectedSet = useMemo(() => new Set(selectedWordIndices), [selectedWordIndices]); const [activeWordIndex, setActiveWordIndex] = useState(-1); useEffect(() => { if (words.length === 0) return; const interval = setInterval(() => { const video = document.querySelector('video') as HTMLVideoElement | null; if (!video) return; const idx = getWordAtTime(video.currentTime); setActiveWordIndex((prev) => (prev === idx ? prev : idx)); }, 250); return () => clearInterval(interval); }, [words, getWordAtTime]); // Auto-scroll to active segment via Virtuoso useEffect(() => { if (activeWordIndex < 0 || segments.length === 0) return; const segIdx = segments.findIndex((seg) => { const start = seg.globalStartIndex ?? 0; return activeWordIndex >= start && activeWordIndex < start + seg.words.length; }); if (segIdx >= 0 && virtuosoRef.current) { virtuosoRef.current.scrollIntoView({ index: segIdx, behavior: 'smooth', align: 'center' }); } }, [activeWordIndex, segments]); const handleWordMouseDown = useCallback( (index: number, e: React.MouseEvent) => { e.preventDefault(); // Ctrl+click → seek video to this word's start time if (e.ctrlKey) { const word = words[index]; if (word) { const video = document.querySelector('video') as HTMLVideoElement | null; if (video) video.currentTime = word.start; } return; } if (cutMode || muteMode || gainMode || speedMode) { zoneDragStart.current = index; setZoneDragRange({ start: index, end: index }); selectionStart.current = null; return; } wasDragging.current = false; if (e.shiftKey && selectedWordIndices.length > 0) { const first = selectedWordIndices[0]; const start = Math.min(first, index); const end = Math.max(first, index); const indices = []; for (let i = start; i <= end; i++) indices.push(i); setSelectedWordIndices(indices); } else { selectionStart.current = index; setSelectedWordIndices([index]); } }, [words, selectedWordIndices, setSelectedWordIndices, cutMode, muteMode, gainMode, speedMode], ); const handleWordMouseEnter = useCallback( (index: number) => { setHoveredWordIndex(index); if (zoneDragStart.current !== null) { setZoneDragRange({ start: Math.min(zoneDragStart.current, index), end: Math.max(zoneDragStart.current, index), }); return; } if (selectionStart.current !== null) { wasDragging.current = true; const start = Math.min(selectionStart.current, index); const end = Math.max(selectionStart.current, index); const indices = []; for (let i = start; i <= end; i++) indices.push(i); setSelectedWordIndices(indices); } }, [setHoveredWordIndex, setSelectedWordIndices], ); const handleMouseUp = useCallback(() => { if (zoneDragStart.current !== null && zoneDragRange) { const startWord = words[zoneDragRange.start]; const endWord = words[zoneDragRange.end]; if (startWord && endWord) { if (cutMode) addCutRange(startWord.start, endWord.end); if (muteMode) addMuteRange(startWord.start, endWord.end); if (gainMode) addGainRange(startWord.start, endWord.end, gainModeDb); if (speedMode) addSpeedRange(startWord.start, endWord.end, speedModeValue); } } zoneDragStart.current = null; setZoneDragRange(null); selectionStart.current = null; }, [zoneDragRange, words, cutMode, muteMode, gainMode, gainModeDb, speedMode, speedModeValue, addCutRange, addMuteRange, addGainRange, addSpeedRange]); const handleClickOutside = useCallback( (e: React.MouseEvent) => { if (wasDragging.current) { wasDragging.current = false; return; } if ((e.target as HTMLElement).dataset.wordIndex === undefined) { setSelectedWordIndices([]); } }, [setSelectedWordIndices], ); const cutSelectedWords = useCallback(() => { if (selectedWordIndices.length === 0) return; const sorted = [...selectedWordIndices].sort((a, b) => a - b); const startTime = words[sorted[0]].start; const endTime = words[sorted[sorted.length - 1]].end; addCutRange(startTime, endTime); }, [selectedWordIndices, words, addCutRange]); const muteSelectedWords = useCallback(() => { if (selectedWordIndices.length === 0) return; const sorted = [...selectedWordIndices].sort((a, b) => a - b); const startTime = words[sorted[0]].start; const endTime = words[sorted[sorted.length - 1]].end; addMuteRange(startTime, endTime); }, [selectedWordIndices, words, addMuteRange]); const gainSelectedWords = useCallback(() => { if (selectedWordIndices.length === 0) return; const sorted = [...selectedWordIndices].sort((a, b) => a - b); const startTime = words[sorted[0]].start; const endTime = words[sorted[sorted.length - 1]].end; addGainRange(startTime, endTime, gainModeDb); }, [selectedWordIndices, words, addGainRange, gainModeDb]); const speedSelectedWords = useCallback(() => { if (selectedWordIndices.length === 0) return; const sorted = [...selectedWordIndices].sort((a, b) => a - b); const startTime = words[sorted[0]].start; const endTime = words[sorted[sorted.length - 1]].end; addSpeedRange(startTime, endTime, speedModeValue); }, [selectedWordIndices, words, addSpeedRange, speedModeValue]); const getCutRangeForWord = useCallback( (wordIndex: number) => { const word = words[wordIndex]; if (!word) return null; return cutRanges.find((r) => word.start >= r.start && word.end <= r.end); }, [words, cutRanges], ); const getMuteRangeForWord = useCallback( (wordIndex: number) => { const word = words[wordIndex]; if (!word) return null; return muteRanges.find((r) => word.start >= r.start && word.end <= r.end); }, [words, muteRanges], ); const getGainRangeForWord = useCallback( (wordIndex: number) => { const word = words[wordIndex]; if (!word) return null; return gainRanges.find((r) => word.start >= r.start && word.end <= r.end); }, [words, gainRanges], ); const getSpeedRangeForWord = useCallback( (wordIndex: number) => { const word = words[wordIndex]; if (!word) return null; return speedRanges.find((r) => word.start >= r.start && word.end <= r.end); }, [words, speedRanges], ); const renderSegment = useCallback( (index: number) => { const segment = segments[index]; if (!segment) return null; return (
{segment.speaker && (
{segment.speaker}
)}

{segment.words.map((word, localIndex) => { const globalIndex = (segment.globalStartIndex ?? 0) + localIndex; const isSelected = selectedSet.has(globalIndex); const isActive = globalIndex === activeWordIndex; const isHovered = globalIndex === hoveredWordIndex; const isZoneDragSelected = zoneDragRange ? globalIndex >= zoneDragRange.start && globalIndex <= zoneDragRange.end : false; const cutRange = getCutRangeForWord(globalIndex); const muteRange = getMuteRangeForWord(globalIndex); const gainRange = getGainRangeForWord(globalIndex); const speedRange = getSpeedRangeForWord(globalIndex); return ( handleWordMouseDown(globalIndex, e)} onMouseEnter={() => handleWordMouseEnter(globalIndex)} onMouseLeave={() => setHoveredWordIndex(null)} className={` relative px-[2px] py-[1px] rounded cursor-pointer transition-colors ${cutRange ? 'bg-red-500/20 text-red-100' : ''} ${muteRange ? 'bg-blue-500/20 text-blue-100' : ''} ${gainRange ? 'bg-amber-500/20 text-amber-100' : ''} ${speedRange ? 'bg-emerald-500/20 text-emerald-100' : ''} ${isZoneDragSelected && cutMode ? 'bg-red-500/30 ring-1 ring-red-400/60' : ''} ${isZoneDragSelected && muteMode ? 'bg-blue-500/30 ring-1 ring-blue-400/60' : ''} ${isZoneDragSelected && gainMode ? 'bg-amber-500/30 ring-1 ring-amber-400/60' : ''} ${isZoneDragSelected && speedMode ? 'bg-emerald-500/30 ring-1 ring-emerald-400/60' : ''} ${isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-selected text-white' : ''} ${isActive && !isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/20 text-editor-accent' : ''} ${isHovered && !isSelected && !isActive && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-hover' : ''} `} > {word.word}{' '} {(cutRange || muteRange || gainRange || speedRange) && isHovered && ( )} ); })}

); }, [segments, selectedSet, activeWordIndex, hoveredWordIndex, handleWordMouseDown, handleWordMouseEnter, setHoveredWordIndex, getCutRangeForWord, getMuteRangeForWord, getGainRangeForWord, getSpeedRangeForWord, removeCutRange, removeMuteRange, removeGainRange, removeSpeedRange, zoneDragRange, cutMode, muteMode, gainMode, speedMode], ); return (
{selectedWordIndices.length > 0 && (
)}
); }