More refactors
This commit is contained in:
parent
f800c12fb7
commit
d025227518
14 changed files with 300 additions and 386 deletions
|
|
@ -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 (
|
||||
<div className="w-full text-center text-muted">
|
||||
Loading documents...
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
documents: PDFDocument[];
|
||||
removeDocument: (id: string) => Promise<void>;
|
||||
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<string>();
|
||||
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>
|
||||
) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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<HTMLDivElement>,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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<string>} The extracted text
|
||||
*/
|
||||
const extractTextFromPDF = useCallback(async (pdfURL: string, pageNumber: number): Promise<string> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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<string>} A data URL representing the PDF
|
||||
*/
|
||||
const convertPDFDataToURL = useCallback((pdfData: Blob): Promise<string> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
266
src/utils/pdf.ts
Normal file
266
src/utils/pdf.ts
Normal file
|
|
@ -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<string> {
|
||||
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<string> {
|
||||
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<HTMLDivElement>
|
||||
) {
|
||||
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<HTMLDivElement>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue