From 3fa67383c458f6d807455780c465d7c21fa218c0 Mon Sep 17 00:00:00 2001 From: dillonj Date: Wed, 15 Apr 2026 20:57:43 -0600 Subject: [PATCH] feature 10,11 --- FEATURES.md | 4 +- frontend/src/components/TranscriptEditor.tsx | 125 ++++++++++++++++++- frontend/src/components/WaveformTimeline.tsx | 33 +++++ frontend/src/hooks/useKeyboardShortcuts.ts | 31 +++-- frontend/src/store/editorStore.ts | 10 ++ 5 files changed, 191 insertions(+), 12 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 6879e59..b657104 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -28,9 +28,9 @@ Features are grouped by priority. Check off items as they are implemented. ## 🟡 Medium Priority — Widely expected features -- [ ] [#010] **Transcript search (Ctrl+F)** — find words/phrases in the transcript and highlight matches. Pure frontend. Critical for long-form content. Jump between matches with Enter. +- [x] [#010] **Transcript search (Ctrl+F)** — find words/phrases in the transcript and highlight matches. Pure frontend. Critical for long-form content. Jump between matches with Enter. -- [ ] [#011] **Mark In / Out + delete (I / O keys)** — keyboard shortcuts to mark a time range on the timeline, then delete it. Faster than click-dragging words. Store the in/out points in state, `Delete` removes them. +- [x] [#011] **Mark In / Out + delete (I / O keys)** — keyboard shortcuts to mark a time range on the timeline, then delete it. Faster than click-dragging words. Store the in/out points in state, `Delete` removes them. - [ ] [#012] **Low-confidence word highlighting** — WhisperX already returns `confidence` per word. Words below a threshold (e.g. < 0.6) should be visually underlined or tinted so the user knows where to double-check. diff --git a/frontend/src/components/TranscriptEditor.tsx b/frontend/src/components/TranscriptEditor.tsx index ae38c04..243f879 100644 --- a/frontend/src/components/TranscriptEditor.tsx +++ b/frontend/src/components/TranscriptEditor.tsx @@ -1,7 +1,7 @@ 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'; +import { Scissors, VolumeX, SlidersHorizontal, Gauge, RotateCcw, Search, ChevronUp, ChevronDown, X } from 'lucide-react'; interface TranscriptEditorProps { cutMode: boolean; @@ -45,8 +45,72 @@ export default function TranscriptEditor({ const virtuosoRef = useRef(null); const zoneDragStart = useRef(null); const [zoneDragRange, setZoneDragRange] = useState<{ start: number; end: number } | null>(null); + const [searchOpen, setSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [activeMatchIdx, setActiveMatchIdx] = useState(0); + const searchInputRef = useRef(null); const selectedSet = useMemo(() => new Set(selectedWordIndices), [selectedWordIndices]); + const matchIndices = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + if (!q) return [] as number[]; + const matches: number[] = []; + for (let i = 0; i < words.length; i++) { + if (words[i].word.toLowerCase().includes(q)) matches.push(i); + } + return matches; + }, [searchQuery, words]); + const matchSet = useMemo(() => new Set(matchIndices), [matchIndices]); + const safeActiveMatchIdx = matchIndices.length === 0 + ? 0 + : Math.min(activeMatchIdx, matchIndices.length - 1); + + const jumpToMatch = useCallback((idx: number) => { + if (matchIndices.length === 0) return; + const nextIdx = ((idx % matchIndices.length) + matchIndices.length) % matchIndices.length; + setActiveMatchIdx(nextIdx); + const wordIndex = matchIndices[nextIdx]; + const el = document.getElementById(`word-${wordIndex}`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); + } + }, [matchIndices]); + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null; + const isInInput = !!target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT'); + + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f') { + e.preventDefault(); + setSearchOpen(true); + requestAnimationFrame(() => searchInputRef.current?.focus()); + return; + } + + if (!searchOpen) return; + + if (e.key === 'Escape') { + e.preventDefault(); + setSearchOpen(false); + return; + } + + if (e.key === 'Enter' && !isInInput) { + e.preventDefault(); + jumpToMatch(safeActiveMatchIdx + (e.shiftKey ? -1 : 1)); + return; + } + + if (e.key === 'Enter' && isInInput && target === searchInputRef.current) { + e.preventDefault(); + jumpToMatch(safeActiveMatchIdx + (e.shiftKey ? -1 : 1)); + } + }; + + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [jumpToMatch, searchOpen, safeActiveMatchIdx]); const [activeWordIndex, setActiveWordIndex] = useState(-1); @@ -252,6 +316,8 @@ export default function TranscriptEditor({ const muteRange = getMuteRangeForWord(globalIndex); const gainRange = getGainRangeForWord(globalIndex); const speedRange = getSpeedRangeForWord(globalIndex); + const isSearchMatch = matchSet.has(globalIndex); + const isActiveSearchMatch = matchIndices.length > 0 && matchIndices[safeActiveMatchIdx] === globalIndex; return ( ); }, - [segments, selectedSet, activeWordIndex, hoveredWordIndex, handleWordMouseDown, handleWordMouseEnter, setHoveredWordIndex, getCutRangeForWord, getMuteRangeForWord, getGainRangeForWord, getSpeedRangeForWord, removeCutRange, removeMuteRange, removeGainRange, removeSpeedRange, zoneDragRange, cutMode, muteMode, gainMode, speedMode], + [segments, selectedSet, matchSet, matchIndices, safeActiveMatchIdx, activeWordIndex, hoveredWordIndex, handleWordMouseDown, handleWordMouseEnter, setHoveredWordIndex, getCutRangeForWord, getMuteRangeForWord, getGainRangeForWord, getSpeedRangeForWord, removeCutRange, removeMuteRange, removeGainRange, removeSpeedRange, zoneDragRange, cutMode, muteMode, gainMode, speedMode], ); return (
-
+
+
+ + {searchOpen && ( +
+ { + setSearchQuery(e.target.value); + setActiveMatchIdx(0); + }} + placeholder="Search transcript" + className="w-40 bg-transparent text-xs text-editor-text focus:outline-none" + /> + + {matchIndices.length === 0 ? '0/0' : `${safeActiveMatchIdx + 1}/${matchIndices.length}`} + + + + +
+ )} +
{selectedWordIndices.length > 0 && (