"use client"; import { useMemo, useCallback, useRef } from 'react'; interface TranscriptFormatterProps { content: string; onTextSelect?: (text: string) => void; highlightedText?: string | null; highlightMatchIndex?: number; } interface TranscriptSegment { timestamp: string; speaker: string | null; text: string; } // Timestamp patterns for parsing const TIMESTAMP_REGEX = /^(\[?\d{1,2}:\d{2}(?::\d{2})?\]?|\(\d{1,2}:\d{2}(?::\d{2})?\)|\d{1,2}:\d{2}(?::\d{2})?\s*[-–—]|\[\d+(?:\.\d+)?s\])\s*/; // Speaker pattern - looks for "Name:" at start after timestamp const SPEAKER_REGEX = /^([A-Z][a-zA-Z\s]{1,30}):?\s+/; /** * Parse a line into timestamp, speaker, and text components */ function parseLine(line: string): TranscriptSegment | null { const trimmed = line.trim(); if (!trimmed) return null; const timestampMatch = trimmed.match(TIMESTAMP_REGEX); if (!timestampMatch) return null; const timestamp = timestampMatch[1].replace(/[-–—]\s*$/, '').replace(/[\[\]\(\)]/g, '').trim(); let remaining = trimmed.slice(timestampMatch[0].length); // Check for speaker name let speaker: string | null = null; const speakerMatch = remaining.match(SPEAKER_REGEX); if (speakerMatch) { speaker = speakerMatch[1].trim(); remaining = remaining.slice(speakerMatch[0].length); } return { timestamp, speaker, text: remaining.trim(), }; } /** * Group consecutive non-timestamp lines with the previous segment */ function parseTranscript(content: string): TranscriptSegment[] { const lines = content.split('\n'); const segments: TranscriptSegment[] = []; let currentSegment: TranscriptSegment | null = null; for (const line of lines) { const parsed = parseLine(line); if (parsed) { if (currentSegment) { segments.push(currentSegment); } currentSegment = parsed; } else if (currentSegment && line.trim()) { currentSegment.text += '\n' + line.trim(); } } if (currentSegment) { segments.push(currentSegment); } return segments; } /** * Transcript Formatter - Clean view for timestamped content */ export default function TranscriptFormatter({ content, onTextSelect, highlightedText, highlightMatchIndex = 0 }: TranscriptFormatterProps) { const containerRef = useRef(null); const globalMatchCounter = useRef(0); // Reset counter before each render globalMatchCounter.current = 0; const segments = useMemo(() => parseTranscript(content), [content]); const handleMouseUp = useCallback(() => { if (!onTextSelect) return; const selection = window.getSelection(); const text = selection?.toString().trim(); if (text && text.length > 10) { const truncatedText = text.length > 2000 ? text.slice(0, 2000) + '...' : text; onTextSelect(truncatedText); selection?.removeAllRanges(); } }, [onTextSelect]); const renderWithHighlight = useCallback((text: string): React.ReactNode => { if (!highlightedText) return text; const textLower = text.toLowerCase(); const searchLower = highlightedText.toLowerCase(); if (!textLower.includes(searchLower)) { return text; } const parts: React.ReactNode[] = []; let lastIndex = 0; let pos = 0; while (pos < textLower.length) { const index = textLower.indexOf(searchLower, pos); if (index === -1) break; if (index > lastIndex) { parts.push(text.slice(lastIndex, index)); } const isCurrent = globalMatchCounter.current === highlightMatchIndex; globalMatchCounter.current++; parts.push( {text.slice(index, index + highlightedText.length)} ); lastIndex = index + highlightedText.length; pos = index + 1; } if (lastIndex < text.length) { parts.push(text.slice(lastIndex)); } return parts.length > 0 ? <>{parts} : text; }, [highlightedText, highlightMatchIndex]); if (segments.length === 0) { return (
No transcript content detected
); } return (
{segments.map((segment, index) => (
{/* Timestamp and speaker on same line */}
{segment.timestamp} {segment.speaker && ( {segment.speaker} )}
{/* Text content */}
{renderWithHighlight(segment.text)}
))}
); }