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);
+ }
+ }
+ }
+}