import { useCallback, useRef, useEffect, useMemo, useState } from 'react'; import { useEditorStore } from '../store/editorStore'; import { Virtuoso } from 'react-virtuoso'; import { Trash2, RotateCcw } from 'lucide-react'; export default function TranscriptEditor() { const words = useEditorStore((s) => s.words); const segments = useEditorStore((s) => s.segments); const deletedRanges = useEditorStore((s) => s.deletedRanges); const cutRanges = useEditorStore((s) => s.cutRanges); const muteRanges = useEditorStore((s) => s.muteRanges); 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 restoreRange = useEditorStore((s) => s.restoreRange); const removeCutRange = useEditorStore((s) => s.removeCutRange); const removeMuteRange = useEditorStore((s) => s.removeMuteRange); const addCutRange = useEditorStore((s) => s.addCutRange); const getWordAtTime = useEditorStore((s) => s.getWordAtTime); const selectionStart = useRef(null); const wasDragging = useRef(false); const virtuosoRef = useRef(null); const deletedSet = useMemo(() => { const s = new Set(); for (const range of deletedRanges) { for (const idx of range.wordIndices) s.add(idx); } return s; }, [deletedRanges]); 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; } 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], ); const handleWordMouseEnter = useCallback( (index: number) => { setHoveredWordIndex(index); 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(() => { selectionStart.current = null; }, []); const handleClickOutside = useCallback( (e: React.MouseEvent) => { if (wasDragging.current) { wasDragging.current = false; return; } if ((e.target as HTMLElement).dataset.wordIndex === undefined) { setSelectedWordIndices([]); } }, [setSelectedWordIndices], ); const getRangeForWord = useCallback( (wordIndex: number) => deletedRanges.find((r) => r.wordIndices.includes(wordIndex)), [deletedRanges], ); 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 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 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 isDeleted = deletedSet.has(globalIndex); const isSelected = selectedSet.has(globalIndex); const isActive = globalIndex === activeWordIndex; const isHovered = globalIndex === hoveredWordIndex; const deletedRange = isDeleted ? getRangeForWord(globalIndex) : null; const cutRange = getCutRangeForWord(globalIndex); const muteRange = getMuteRangeForWord(globalIndex); return ( handleWordMouseDown(globalIndex, e)} onMouseEnter={() => handleWordMouseEnter(globalIndex)} onMouseLeave={() => setHoveredWordIndex(null)} className={` relative px-[2px] py-[1px] rounded cursor-pointer transition-colors ${isDeleted ? 'line-through text-editor-text-muted/40 bg-editor-word-deleted' : ''} ${cutRange ? 'bg-red-500/20 text-red-100' : ''} ${muteRange ? 'bg-blue-500/20 text-blue-100' : ''} ${isSelected && !isDeleted && !cutRange && !muteRange ? 'bg-editor-word-selected text-white' : ''} ${isActive && !isDeleted && !isSelected && !cutRange && !muteRange ? 'bg-editor-accent/20 text-editor-accent' : ''} ${isHovered && !isDeleted && !isSelected && !isActive && !cutRange && !muteRange ? 'bg-editor-word-hover' : ''} `} > {word.word}{' '} {isDeleted && isHovered && deletedRange && ( )} {(cutRange || muteRange) && isHovered && ( )} ); })}

); }, [segments, deletedSet, selectedSet, activeWordIndex, hoveredWordIndex, handleWordMouseDown, handleWordMouseEnter, setHoveredWordIndex, getRangeForWord, getCutRangeForWord, getMuteRangeForWord, restoreRange, removeCutRange, removeMuteRange], ); return (
{words.length} words · {deletedRanges.length} cuts · {cutRanges.length} cut ranges · {muteRanges.length} mute ranges {selectedWordIndices.length > 0 && ( )}
); }