From d025227518e760494107c6124c1d52036e34c1d4 Mon Sep 17 00:00:00 2001 From: Richard Roberson Date: Wed, 29 Jan 2025 22:04:11 -0700 Subject: [PATCH] More refactors --- src/components/DocumentList.tsx | 4 +- src/components/PDFUploader.tsx | 4 +- src/contexts/ConfigContext.tsx | 2 +- src/contexts/PDFContext.tsx | 40 ++-- src/contexts/TTSContext.tsx | 4 +- src/hooks/pdf/usePDFDocuments.ts | 16 +- src/hooks/pdf/usePDFHighlighting.ts | 150 --------------- src/hooks/pdf/usePDFTextClick.ts | 81 -------- src/hooks/pdf/usePDFTextProcessing.ts | 97 ---------- src/hooks/pdf/usePDFURLConversion.ts | 22 --- src/{services => utils}/audio.ts | 0 src/{services => utils}/indexedDB.ts | 0 src/{services => utils}/nlp.ts | 0 src/utils/pdf.ts | 266 ++++++++++++++++++++++++++ 14 files changed, 300 insertions(+), 386 deletions(-) delete mode 100644 src/hooks/pdf/usePDFHighlighting.ts delete mode 100644 src/hooks/pdf/usePDFTextClick.ts delete mode 100644 src/hooks/pdf/usePDFTextProcessing.ts delete mode 100644 src/hooks/pdf/usePDFURLConversion.ts rename src/{services => utils}/audio.ts (100%) rename src/{services => utils}/indexedDB.ts (100%) rename src/{services => utils}/nlp.ts (100%) create mode 100644 src/utils/pdf.ts diff --git a/src/components/DocumentList.tsx b/src/components/DocumentList.tsx index 4206ac5..06a3ecf 100644 --- a/src/components/DocumentList.tsx +++ b/src/components/DocumentList.tsx @@ -5,7 +5,7 @@ import { Transition, TransitionChild, DialogPanel, DialogTitle } from '@headless import { Fragment, useState } from 'react'; export function DocumentList() { - const { documents, removeDocument, isLoading } = usePDF(); + const { documents, removeDocument, isDocsLoading } = usePDF(); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [documentToDelete, setDocumentToDelete] = useState<{ id: string; name: string } | null>(null); @@ -21,7 +21,7 @@ export function DocumentList() { } }; - if (isLoading) { + if (isDocsLoading) { return (
Loading documents... diff --git a/src/components/PDFUploader.tsx b/src/components/PDFUploader.tsx index eabf24a..fb270c2 100644 --- a/src/components/PDFUploader.tsx +++ b/src/components/PDFUploader.tsx @@ -2,15 +2,15 @@ import { useState, useCallback } from 'react'; import { useDropzone } from 'react-dropzone'; +import { usePDF } from '@/contexts/PDFContext'; import { UploadIcon } from '@/components/icons/Icons'; -import { usePDFDocuments } from '@/hooks/pdf/usePDFDocuments'; interface PDFUploaderProps { className?: string; } export function PDFUploader({ className = '' }: PDFUploaderProps) { - const { addDocument } = usePDFDocuments(); + const { addDocument } = usePDF(); const [isUploading, setIsUploading] = useState(false); const [error, setError] = useState(null); diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index 66041c2..ddc6156 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -1,7 +1,7 @@ 'use client'; import { createContext, useContext, useEffect, useState } from 'react'; -import { getItem, indexedDBService, setItem } from '@/services/indexedDB'; +import { getItem, indexedDBService, setItem } from '@/utils/indexedDB'; export type ViewType = 'single' | 'dual' | 'scroll'; interface ConfigContextType { diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx index 72633de..70c71e5 100644 --- a/src/contexts/PDFContext.tsx +++ b/src/contexts/PDFContext.tsx @@ -23,23 +23,26 @@ import { useMemo, } from 'react'; -import { indexedDBService, type PDFDocument } from '@/services/indexedDB'; -import { useConfig } from '@/contexts/ConfigContext'; +import { indexedDBService, type PDFDocument } from '@/utils/indexedDB'; import { useTTS } from '@/contexts/TTSContext'; +import { + extractTextFromPDF, + convertPDFDataToURL, + highlightPattern, + clearHighlights, + handleTextClick, +} from '@/utils/pdf'; import { usePDFDocuments } from '@/hooks/pdf/usePDFDocuments'; -import { usePDFHighlighting } from '@/hooks/pdf/usePDFHighlighting'; -import { usePDFTextClick } from '@/hooks/pdf/usePDFTextClick'; -import { usePDFTextProcessing } from '@/hooks/pdf/usePDFTextProcessing'; -import { usePDFURLConversion } from '@/hooks/pdf/usePDFURLConversion'; /** * Interface defining all available methods and properties in the PDF context */ interface PDFContextType { - // Documents management + // PDF document store management + addDocument: (file: File) => Promise; documents: PDFDocument[]; removeDocument: (id: string) => Promise; - isLoading: boolean; + isDocsLoading: boolean; // Current document state currDocURL: string | undefined; @@ -80,12 +83,8 @@ export function PDFProvider({ children }: { children: ReactNode }) { setCurrDocPages, } = useTTS(); - // Initialize hooks - const { documents, isLoading, removeDocument } = usePDFDocuments(); - const { extractTextFromPDF } = usePDFTextProcessing(); - const { highlightPattern, clearHighlights } = usePDFHighlighting(); - const { handleTextClick } = usePDFTextClick(); - const { convertPDFDataToURL } = usePDFURLConversion(); + // PDF Documents hook + const { addDocument, documents, removeDocument, isLoading: isDocsLoading } = usePDFDocuments(); // Current document state const [currDocURL, setCurrDocURL] = useState(); @@ -114,7 +113,7 @@ export function PDFProvider({ children }: { children: ReactNode }) { } catch (error) { console.error('Error loading PDF text:', error); } - }, [currDocURL, currDocPage, extractTextFromPDF, setTTSText]); + }, [currDocURL, currDocPage, setTTSText]); /** * Updates the current document text when the page changes @@ -141,7 +140,7 @@ export function PDFProvider({ children }: { children: ReactNode }) { } catch (error) { console.error('Failed to get document URL:', error); } - }, [convertPDFDataToURL]); + }, []); /** * Clears the current document state @@ -157,9 +156,10 @@ export function PDFProvider({ children }: { children: ReactNode }) { // Context value memoization const contextValue = useMemo( () => ({ + addDocument, documents, removeDocument, - isLoading, + isDocsLoading, onDocumentLoadSuccess, setCurrentDocument, currDocURL, @@ -173,9 +173,10 @@ export function PDFProvider({ children }: { children: ReactNode }) { handleTextClick, }), [ + addDocument, documents, removeDocument, - isLoading, + isDocsLoading, onDocumentLoadSuccess, setCurrentDocument, currDocURL, @@ -184,9 +185,6 @@ export function PDFProvider({ children }: { children: ReactNode }) { currDocPage, currDocText, clearCurrDoc, - highlightPattern, - clearHighlights, - handleTextClick, ] ); diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 3cade11..c48ff0c 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -27,8 +27,8 @@ import OpenAI from 'openai'; import { Howl } from 'howler'; import { useConfig } from '@/contexts/ConfigContext'; -import { splitIntoSentences, preprocessSentenceForAudio } from '@/services/nlp'; -import { audioBufferToURL } from '@/services/audio'; +import { splitIntoSentences, preprocessSentenceForAudio } from '@/utils/nlp'; +import { audioBufferToURL } from '@/utils/audio'; import { useAudioCache } from '@/hooks/audio/useAudioCache'; import { useVoiceManagement } from '@/hooks/audio/useVoiceManagement'; import { useMediaSession } from '@/hooks/audio/useMediaSession'; diff --git a/src/hooks/pdf/usePDFDocuments.ts b/src/hooks/pdf/usePDFDocuments.ts index dae450f..5c75773 100644 --- a/src/hooks/pdf/usePDFDocuments.ts +++ b/src/hooks/pdf/usePDFDocuments.ts @@ -1,6 +1,6 @@ import { useState, useCallback, useEffect } from 'react'; import { v4 as uuidv4 } from 'uuid'; -import { indexedDBService, type PDFDocument } from '@/services/indexedDB'; +import { indexedDBService, type PDFDocument } from '@/utils/indexedDB'; import { useConfig } from '@/contexts/ConfigContext'; export function usePDFDocuments() { @@ -11,10 +11,8 @@ export function usePDFDocuments() { /** * Load documents from IndexedDB when the database is ready */ - useEffect(() => { - const loadDocuments = async () => { - if (!isDBReady) return; - + const loadDocuments = useCallback(async () => { + if (isDBReady) { try { const docs = await indexedDBService.getAllDocuments(); setDocuments(docs); @@ -23,11 +21,13 @@ export function usePDFDocuments() { } finally { setIsLoading(false); } - }; - - loadDocuments(); + } }, [isDBReady]); + useEffect(() => { + loadDocuments(); + }, [loadDocuments]); + /** * Adds a new document to IndexedDB * diff --git a/src/hooks/pdf/usePDFHighlighting.ts b/src/hooks/pdf/usePDFHighlighting.ts deleted file mode 100644 index 5333a2c..0000000 --- a/src/hooks/pdf/usePDFHighlighting.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { useCallback } from 'react'; -import stringSimilarity from 'string-similarity'; - -interface TextMatch { - elements: HTMLElement[]; - rating: number; - text: string; - lengthDiff: number; -} - -export function usePDFHighlighting() { - /** - * Removes all text highlights from the PDF viewer - */ - const clearHighlights = useCallback(() => { - const textNodes = document.querySelectorAll('.react-pdf__Page__textContent span'); - textNodes.forEach((node) => { - const element = node as HTMLElement; - element.style.backgroundColor = ''; - element.style.opacity = '1'; - }); - }, []); - - /** - * Finds the best matching text segment using string similarity - * - * @param {Array} elements - Array of elements and their text content - * @param {string} targetText - The text to match against - * @param {number} maxCombinedLength - Maximum length of combined text to consider - */ - const findBestTextMatch = useCallback(( - elements: Array<{ element: HTMLElement; text: string }>, - targetText: string, - maxCombinedLength: number - ): TextMatch => { - let bestMatch = { - elements: [] as HTMLElement[], - rating: 0, - text: '', - lengthDiff: Infinity, - }; - - for (let i = 0; i < elements.length; i++) { - let combinedText = ''; - const currentElements = []; - for (let j = i; j < Math.min(i + 10, elements.length); j++) { - const node = elements[j]; - const newText = combinedText ? `${combinedText} ${node.text}` : node.text; - if (newText.length > maxCombinedLength) break; - - combinedText = newText; - currentElements.push(node.element); - - const similarity = stringSimilarity.compareTwoStrings(targetText, combinedText); - const lengthDiff = Math.abs(combinedText.length - targetText.length); - const lengthPenalty = lengthDiff / targetText.length; - const adjustedRating = similarity * (1 - lengthPenalty * 0.5); - - if (adjustedRating > bestMatch.rating) { - bestMatch = { - elements: [...currentElements], - rating: adjustedRating, - text: combinedText, - lengthDiff, - }; - } - } - } - - return bestMatch; - }, []); - - /** - * Highlights matching text in the PDF viewer - * Uses a sliding context window for improved accuracy - * - * @param {string} text - The document text - * @param {string} pattern - The pattern to highlight - * @param {RefObject} containerRef - Reference to the container element - */ - const highlightPattern = useCallback(( - text: string, - pattern: string, - containerRef: React.RefObject - ) => { - clearHighlights(); - - if (!pattern?.trim()) return; - - const cleanPattern = pattern.trim().replace(/\s+/g, ' '); - const container = containerRef.current; - if (!container) return; - - const textNodes = container.querySelectorAll('.react-pdf__Page__textContent span'); - const allText = Array.from(textNodes).map((node) => ({ - element: node as HTMLElement, - text: (node.textContent || '').trim(), - })).filter((node) => node.text.length > 0); - - // Calculate the visible area of the container - const containerRect = container.getBoundingClientRect(); - const visibleTop = container.scrollTop; - const visibleBottom = visibleTop + containerRect.height; - - // Find nodes within the visible area and a buffer zone - const bufferSize = containerRect.height; // One screen height buffer - const visibleNodes = allText.filter(({ element }) => { - const rect = element.getBoundingClientRect(); - const elementTop = rect.top - containerRect.top + container.scrollTop; - return elementTop >= (visibleTop - bufferSize) && elementTop <= (visibleBottom + bufferSize); - }); - - // Search for the best match within the visible area first - let bestMatch = findBestTextMatch(visibleNodes, cleanPattern, cleanPattern.length * 2); - - // If no good match found in visible area, search the entire document - if (bestMatch.rating < 0.3) { - bestMatch = findBestTextMatch(allText, cleanPattern, cleanPattern.length * 2); - } - - const similarityThreshold = bestMatch.lengthDiff < cleanPattern.length * 0.3 ? 0.3 : 0.5; - - if (bestMatch.rating >= similarityThreshold) { - bestMatch.elements.forEach((element) => { - element.style.backgroundColor = 'grey'; - element.style.opacity = '0.4'; - }); - - if (bestMatch.elements.length > 0) { - const element = bestMatch.elements[0]; - const elementRect = element.getBoundingClientRect(); - const elementTop = elementRect.top - containerRect.top + container.scrollTop; - - // Only scroll if the element is outside the visible area - if (elementTop < visibleTop || elementTop > visibleBottom) { - container.scrollTo({ - top: elementTop - containerRect.height / 3, // Position the highlight in the top third - behavior: 'smooth', - }); - } - } - } - }, [clearHighlights, findBestTextMatch]); - - return { - highlightPattern, - clearHighlights, - findBestTextMatch, - }; -} diff --git a/src/hooks/pdf/usePDFTextClick.ts b/src/hooks/pdf/usePDFTextClick.ts deleted file mode 100644 index 9f1a9e2..0000000 --- a/src/hooks/pdf/usePDFTextClick.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { useCallback } from 'react'; -import nlp from 'compromise'; -import stringSimilarity from 'string-similarity'; -import { usePDFHighlighting } from './usePDFHighlighting'; - -export function usePDFTextClick() { - const { highlightPattern, findBestTextMatch } = usePDFHighlighting(); - - /** - * Handles text click events in the PDF viewer - * Integrates with TTS for synchronized playback - * - * @param {MouseEvent} event - The click event - * @param {string} pdfText - The text content of the page - * @param {RefObject} containerRef - Reference to the container element - * @param {Function} stopAndPlayFromIndex - Function to control TTS playback - * @param {boolean} isProcessing - Whether TTS is currently processing - */ - const handleTextClick = useCallback(( - event: MouseEvent, - pdfText: string, - containerRef: React.RefObject, - stopAndPlayFromIndex: (index: number) => void, - isProcessing: boolean - ) => { - if (isProcessing) return; - - const target = event.target as HTMLElement; - if (!target.matches('.react-pdf__Page__textContent span')) return; - - const parentElement = target.closest('.react-pdf__Page__textContent'); - if (!parentElement) return; - - const spans = Array.from(parentElement.querySelectorAll('span')); - const clickedIndex = spans.indexOf(target); - const contextWindow = 3; - const startIndex = Math.max(0, clickedIndex - contextWindow); - const endIndex = Math.min(spans.length - 1, clickedIndex + contextWindow); - const contextText = spans - .slice(startIndex, endIndex + 1) - .map((span) => span.textContent) - .join(' ') - .trim(); - - if (!contextText?.trim()) return; - - const cleanContext = contextText.trim().replace(/\s+/g, ' '); - const allText = Array.from(parentElement.querySelectorAll('span')).map((node) => ({ - element: node as HTMLElement, - text: (node.textContent || '').trim(), - })).filter((node) => node.text.length > 0); - - const bestMatch = findBestTextMatch(allText, cleanContext, cleanContext.length * 2); - const similarityThreshold = bestMatch.lengthDiff < cleanContext.length * 0.3 ? 0.3 : 0.5; - - if (bestMatch.rating >= similarityThreshold) { - const matchText = bestMatch.text; - const sentences = nlp(pdfText).sentences().out('array') as string[]; - let bestSentenceMatch = { sentence: '', rating: 0 }; - - for (const sentence of sentences) { - const rating = stringSimilarity.compareTwoStrings(matchText, sentence); - if (rating > bestSentenceMatch.rating) { - bestSentenceMatch = { sentence, rating }; - } - } - - if (bestSentenceMatch.rating >= 0.5) { - const sentenceIndex = sentences.findIndex((sentence) => sentence === bestSentenceMatch.sentence); - if (sentenceIndex !== -1) { - stopAndPlayFromIndex(sentenceIndex); - highlightPattern(pdfText, bestSentenceMatch.sentence, containerRef); - } - } - } - }, [highlightPattern, findBestTextMatch]); - - return { - handleTextClick, - }; -} diff --git a/src/hooks/pdf/usePDFTextProcessing.ts b/src/hooks/pdf/usePDFTextProcessing.ts deleted file mode 100644 index 0a855d7..0000000 --- a/src/hooks/pdf/usePDFTextProcessing.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { useCallback } from 'react'; -import { pdfjs } from 'react-pdf'; -import type { TextItem } from 'pdfjs-dist/types/src/display/api'; - -// Set worker from public directory -pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.mjs'; - -export function usePDFTextProcessing() { - /** - * Extracts text content from a specific page of the PDF - * - * @param {string} pdfURL - The URL of the PDF - * @param {number} pageNumber - The page number to extract - * @returns {Promise} The extracted text - */ - const extractTextFromPDF = useCallback(async (pdfURL: string, pageNumber: number): Promise => { - try { - const base64Data = pdfURL.split(',')[1]; - const binaryData = atob(base64Data); - const bytes = new Uint8Array(binaryData.length); - for (let i = 0; i < binaryData.length; i++) { - bytes[i] = binaryData.charCodeAt(i); - } - - const loadingTask = pdfjs.getDocument({ data: bytes }); - const pdf = await loadingTask.promise; - - // Get only the specified page - const page = await pdf.getPage(pageNumber); - const textContent = await page.getTextContent(); - - // Filter out non-text items and assert proper type - const textItems = textContent.items.filter((item): item is TextItem => - 'str' in item && 'transform' in item - ); - - // Group text items into lines based on their vertical position - const tolerance = 2; - const lines: TextItem[][] = []; - let currentLine: TextItem[] = []; - let currentY: number | null = null; - - textItems.forEach((item) => { - const y = item.transform[5]; - if (currentY === null) { - currentY = y; - currentLine.push(item); - } else if (Math.abs(y - currentY) < tolerance) { - currentLine.push(item); - } else { - lines.push(currentLine); - currentLine = [item]; - currentY = y; - } - }); - lines.push(currentLine); - - // Process each line to build text - let pageText = ''; - for (const line of lines) { - // Sort items horizontally within the line - line.sort((a, b) => a.transform[4] - b.transform[4]); - - let lineText = ''; - let prevItem: TextItem | null = null; - - for (const item of line) { - if (!prevItem) { - lineText = item.str; - } else { - const prevEndX = prevItem.transform[4] + (prevItem.width ?? 0); - const currentStartX = item.transform[4]; - const space = currentStartX - prevEndX; - - // Add space if gap is significant, otherwise concatenate directly - if (space > ((item.width ?? 0) * 0.3)) { - lineText += ' ' + item.str; - } else { - lineText += item.str; - } - } - prevItem = item; - } - pageText += lineText + ' '; - } - - return pageText.replace(/\s+/g, ' ').trim(); - } catch (error) { - console.error('Error extracting text from PDF:', error); - throw new Error('Failed to extract text from PDF'); - } - }, []); - - return { - extractTextFromPDF, - }; -} diff --git a/src/hooks/pdf/usePDFURLConversion.ts b/src/hooks/pdf/usePDFURLConversion.ts deleted file mode 100644 index 544038b..0000000 --- a/src/hooks/pdf/usePDFURLConversion.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useCallback } from 'react'; - -export function usePDFURLConversion() { - /** - * Converts PDF binary data to a data URL for display - * - * @param {Blob} pdfData - The PDF binary data - * @returns {Promise} A data URL representing the PDF - */ - const convertPDFDataToURL = useCallback((pdfData: Blob): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.onerror = () => reject(reader.error); - reader.readAsDataURL(pdfData); - }); - }, []); - - return { - convertPDFDataToURL, - }; -} diff --git a/src/services/audio.ts b/src/utils/audio.ts similarity index 100% rename from src/services/audio.ts rename to src/utils/audio.ts diff --git a/src/services/indexedDB.ts b/src/utils/indexedDB.ts similarity index 100% rename from src/services/indexedDB.ts rename to src/utils/indexedDB.ts diff --git a/src/services/nlp.ts b/src/utils/nlp.ts similarity index 100% rename from src/services/nlp.ts rename to src/utils/nlp.ts diff --git a/src/utils/pdf.ts b/src/utils/pdf.ts new file mode 100644 index 0000000..00cc7ab --- /dev/null +++ b/src/utils/pdf.ts @@ -0,0 +1,266 @@ +import { pdfjs } from 'react-pdf'; +import nlp from 'compromise'; +import stringSimilarity from 'string-similarity'; +import type { TextItem } from 'pdfjs-dist/types/src/display/api'; + +// Set worker from public directory +pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.mjs'; + +interface TextMatch { + elements: HTMLElement[]; + rating: number; + text: string; + lengthDiff: number; +} + +// URL Conversion functions +export function convertPDFDataToURL(pdfData: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(pdfData); + }); +} + +// Text Processing functions +export async function extractTextFromPDF(pdfURL: string, pageNumber: number): Promise { + try { + const base64Data = pdfURL.split(',')[1]; + const binaryData = atob(base64Data); + const bytes = new Uint8Array(binaryData.length); + for (let i = 0; i < binaryData.length; i++) { + bytes[i] = binaryData.charCodeAt(i); + } + + const loadingTask = pdfjs.getDocument({ data: bytes }); + const pdf = await loadingTask.promise; + const page = await pdf.getPage(pageNumber); + const textContent = await page.getTextContent(); + + const textItems = textContent.items.filter((item): item is TextItem => + 'str' in item && 'transform' in item + ); + + const tolerance = 2; + const lines: TextItem[][] = []; + let currentLine: TextItem[] = []; + let currentY: number | null = null; + + textItems.forEach((item) => { + const y = item.transform[5]; + if (currentY === null) { + currentY = y; + currentLine.push(item); + } else if (Math.abs(y - currentY) < tolerance) { + currentLine.push(item); + } else { + lines.push(currentLine); + currentLine = [item]; + currentY = y; + } + }); + lines.push(currentLine); + + let pageText = ''; + for (const line of lines) { + line.sort((a, b) => a.transform[4] - b.transform[4]); + let lineText = ''; + let prevItem: TextItem | null = null; + + for (const item of line) { + if (!prevItem) { + lineText = item.str; + } else { + const prevEndX = prevItem.transform[4] + (prevItem.width ?? 0); + const currentStartX = item.transform[4]; + const space = currentStartX - prevEndX; + + if (space > ((item.width ?? 0) * 0.3)) { + lineText += ' ' + item.str; + } else { + lineText += item.str; + } + } + prevItem = item; + } + pageText += lineText + ' '; + } + + return pageText.replace(/\s+/g, ' ').trim(); + } catch (error) { + console.error('Error extracting text from PDF:', error); + throw new Error('Failed to extract text from PDF'); + } +} + +// Highlighting functions +export function clearHighlights() { + const textNodes = document.querySelectorAll('.react-pdf__Page__textContent span'); + textNodes.forEach((node) => { + const element = node as HTMLElement; + element.style.backgroundColor = ''; + element.style.opacity = '1'; + }); +} + +export function findBestTextMatch( + elements: Array<{ element: HTMLElement; text: string }>, + targetText: string, + maxCombinedLength: number +): TextMatch { + let bestMatch = { + elements: [] as HTMLElement[], + rating: 0, + text: '', + lengthDiff: Infinity, + }; + + for (let i = 0; i < elements.length; i++) { + let combinedText = ''; + const currentElements = []; + for (let j = i; j < Math.min(i + 10, elements.length); j++) { + const node = elements[j]; + const newText = combinedText ? `${combinedText} ${node.text}` : node.text; + if (newText.length > maxCombinedLength) break; + + combinedText = newText; + currentElements.push(node.element); + + const similarity = stringSimilarity.compareTwoStrings(targetText, combinedText); + const lengthDiff = Math.abs(combinedText.length - targetText.length); + const lengthPenalty = lengthDiff / targetText.length; + const adjustedRating = similarity * (1 - lengthPenalty * 0.5); + + if (adjustedRating > bestMatch.rating) { + bestMatch = { + elements: [...currentElements], + rating: adjustedRating, + text: combinedText, + lengthDiff, + }; + } + } + } + + return bestMatch; +} + +export function highlightPattern( + text: string, + pattern: string, + containerRef: React.RefObject +) { + clearHighlights(); + + if (!pattern?.trim()) return; + + const cleanPattern = pattern.trim().replace(/\s+/g, ' '); + const container = containerRef.current; + if (!container) return; + + const textNodes = container.querySelectorAll('.react-pdf__Page__textContent span'); + const allText = Array.from(textNodes).map((node) => ({ + element: node as HTMLElement, + text: (node.textContent || '').trim(), + })).filter((node) => node.text.length > 0); + + const containerRect = container.getBoundingClientRect(); + const visibleTop = container.scrollTop; + const visibleBottom = visibleTop + containerRect.height; + const bufferSize = containerRect.height; + + const visibleNodes = allText.filter(({ element }) => { + const rect = element.getBoundingClientRect(); + const elementTop = rect.top - containerRect.top + container.scrollTop; + return elementTop >= (visibleTop - bufferSize) && elementTop <= (visibleBottom + bufferSize); + }); + + let bestMatch = findBestTextMatch(visibleNodes, cleanPattern, cleanPattern.length * 2); + + if (bestMatch.rating < 0.3) { + bestMatch = findBestTextMatch(allText, cleanPattern, cleanPattern.length * 2); + } + + const similarityThreshold = bestMatch.lengthDiff < cleanPattern.length * 0.3 ? 0.3 : 0.5; + + if (bestMatch.rating >= similarityThreshold) { + bestMatch.elements.forEach((element) => { + element.style.backgroundColor = 'grey'; + element.style.opacity = '0.4'; + }); + + if (bestMatch.elements.length > 0) { + const element = bestMatch.elements[0]; + const elementRect = element.getBoundingClientRect(); + const elementTop = elementRect.top - containerRect.top + container.scrollTop; + + if (elementTop < visibleTop || elementTop > visibleBottom) { + container.scrollTo({ + top: elementTop - containerRect.height / 3, + behavior: 'smooth', + }); + } + } + } +} + +// Text Click Handler +export function handleTextClick( + event: MouseEvent, + pdfText: string, + containerRef: React.RefObject, + stopAndPlayFromIndex: (index: number) => void, + isProcessing: boolean +) { + if (isProcessing) return; + + const target = event.target as HTMLElement; + if (!target.matches('.react-pdf__Page__textContent span')) return; + + const parentElement = target.closest('.react-pdf__Page__textContent'); + if (!parentElement) return; + + const spans = Array.from(parentElement.querySelectorAll('span')); + const clickedIndex = spans.indexOf(target); + const contextWindow = 3; + const startIndex = Math.max(0, clickedIndex - contextWindow); + const endIndex = Math.min(spans.length - 1, clickedIndex + contextWindow); + const contextText = spans + .slice(startIndex, endIndex + 1) + .map((span) => span.textContent) + .join(' ') + .trim(); + + if (!contextText?.trim()) return; + + const cleanContext = contextText.trim().replace(/\s+/g, ' '); + const allText = Array.from(parentElement.querySelectorAll('span')).map((node) => ({ + element: node as HTMLElement, + text: (node.textContent || '').trim(), + })).filter((node) => node.text.length > 0); + + const bestMatch = findBestTextMatch(allText, cleanContext, cleanContext.length * 2); + const similarityThreshold = bestMatch.lengthDiff < cleanContext.length * 0.3 ? 0.3 : 0.5; + + if (bestMatch.rating >= similarityThreshold) { + const matchText = bestMatch.text; + const sentences = nlp(pdfText).sentences().out('array') as string[]; + let bestSentenceMatch = { sentence: '', rating: 0 }; + + for (const sentence of sentences) { + const rating = stringSimilarity.compareTwoStrings(matchText, sentence); + if (rating > bestSentenceMatch.rating) { + bestSentenceMatch = { sentence, rating }; + } + } + + if (bestSentenceMatch.rating >= 0.5) { + const sentenceIndex = sentences.findIndex((sentence) => sentence === bestSentenceMatch.sentence); + if (sentenceIndex !== -1) { + stopAndPlayFromIndex(sentenceIndex); + highlightPattern(pdfText, bestSentenceMatch.sentence, containerRef); + } + } + } +}