Working
This commit is contained in:
parent
f585819881
commit
96f9dae3ea
4 changed files with 203 additions and 264 deletions
|
|
@ -19,9 +19,8 @@ const PDFViewer = dynamic(
|
||||||
|
|
||||||
export default function PDFViewerPage() {
|
export default function PDFViewerPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { getDocument } = usePDF();
|
const { setCurrentDocument, currDocName } = usePDF();
|
||||||
const { setText, stop } = useTTS();
|
const { setText, stop } = useTTS();
|
||||||
const [document, setDocument] = useState<{ name: string; data: Blob } | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [zoomLevel, setZoomLevel] = useState<number>(100);
|
const [zoomLevel, setZoomLevel] = useState<number>(100);
|
||||||
|
|
@ -29,12 +28,11 @@ export default function PDFViewerPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadDocument() {
|
async function loadDocument() {
|
||||||
try {
|
try {
|
||||||
const doc = await getDocument(id as string);
|
if (!id) {
|
||||||
if (!doc) {
|
|
||||||
setError('Document not found');
|
setError('Document not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDocument(doc);
|
setCurrentDocument(id as string);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading document:', err);
|
console.error('Error loading document:', err);
|
||||||
setError('Failed to load document');
|
setError('Failed to load document');
|
||||||
|
|
@ -44,7 +42,7 @@ export default function PDFViewerPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
loadDocument();
|
loadDocument();
|
||||||
}, [id, getDocument]);
|
}, [id, setCurrentDocument]);
|
||||||
|
|
||||||
const handleZoomIn = () => setZoomLevel(prev => Math.min(prev + 10, 200));
|
const handleZoomIn = () => setZoomLevel(prev => Math.min(prev + 10, 200));
|
||||||
const handleZoomOut = () => setZoomLevel(prev => Math.max(prev - 10, 50));
|
const handleZoomOut = () => setZoomLevel(prev => Math.max(prev - 10, 50));
|
||||||
|
|
@ -107,7 +105,7 @@ export default function PDFViewerPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mr-2 text-md font-semibold text-foreground">
|
<h1 className="mr-2 text-md font-semibold text-foreground">
|
||||||
{isLoading ? 'Loading...' : document?.name}
|
{isLoading ? 'Loading...' : currDocName}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -116,7 +114,7 @@ export default function PDFViewerPage() {
|
||||||
<PDFSkeleton />
|
<PDFSkeleton />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<PDFViewer pdfData={document?.data} zoomLevel={zoomLevel} />
|
<PDFViewer zoomLevel={zoomLevel} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -13,19 +13,31 @@ import type { TextItem } from 'pdfjs-dist/types/src/display/api';
|
||||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||||
|
|
||||||
interface PDFViewerProps {
|
interface PDFViewerProps {
|
||||||
pdfData: Blob | undefined;
|
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
|
export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
const [numPages, setNumPages] = useState<number>();
|
|
||||||
const [containerWidth, setContainerWidth] = useState<number>(0);
|
const [containerWidth, setContainerWidth] = useState<number>(0);
|
||||||
const { setText, currentSentence, stopAndPlayFromIndex, isProcessing, isPlaying, currentIndex, sentences, stop } = useTTS();
|
|
||||||
const [pdfText, setPdfText] = useState('');
|
|
||||||
const [pdfDataUrl, setPdfDataUrl] = useState<string>();
|
|
||||||
const [loadingError, setLoadingError] = useState<string>();
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const { extractTextFromPDF, highlightPattern, clearHighlights, handleTextClick } = usePDF();
|
|
||||||
|
// TTS context
|
||||||
|
const {
|
||||||
|
currentSentence,
|
||||||
|
stopAndPlayFromIndex,
|
||||||
|
isProcessing
|
||||||
|
} = useTTS();
|
||||||
|
|
||||||
|
// PDF context
|
||||||
|
const {
|
||||||
|
highlightPattern,
|
||||||
|
clearHighlights,
|
||||||
|
handleTextClick,
|
||||||
|
onDocumentLoadSuccess,
|
||||||
|
currDocURL,
|
||||||
|
currDocPages,
|
||||||
|
currDocText,
|
||||||
|
currDocPage,
|
||||||
|
} = usePDF();
|
||||||
|
|
||||||
// Add static styles once during component initialization
|
// Add static styles once during component initialization
|
||||||
const styleElement = document.createElement('style');
|
const styleElement = document.createElement('style');
|
||||||
|
|
@ -47,58 +59,6 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
|
||||||
};
|
};
|
||||||
}, [styleElement]);
|
}, [styleElement]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
/*
|
|
||||||
* Converts PDF blob to a data URL for display.
|
|
||||||
* Cleans up by clearing the data URL when component unmounts.
|
|
||||||
*
|
|
||||||
* Dependencies:
|
|
||||||
* - pdfData: Re-run when the PDF blob changes to convert it to a new data URL
|
|
||||||
*/
|
|
||||||
if (!pdfData) return;
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
setPdfDataUrl(reader.result as string);
|
|
||||||
};
|
|
||||||
reader.onerror = () => {
|
|
||||||
console.error('Error reading file:', reader.error);
|
|
||||||
setLoadingError('Failed to load PDF');
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(pdfData);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
setPdfDataUrl(undefined);
|
|
||||||
};
|
|
||||||
}, [pdfData]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
/*
|
|
||||||
* Extracts text content from the PDF once it's loaded.
|
|
||||||
* Sets the extracted text for both display and text-to-speech.
|
|
||||||
*
|
|
||||||
* Dependencies:
|
|
||||||
* - pdfDataUrl: Re-run when the data URL is ready
|
|
||||||
* - extractTextFromPDF: Function from context that could change
|
|
||||||
* - setText: Function from context that could change
|
|
||||||
* - pdfData: Source PDF blob that's being processed
|
|
||||||
*/
|
|
||||||
if (!pdfDataUrl || !pdfData) return;
|
|
||||||
|
|
||||||
const loadPdfText = async () => {
|
|
||||||
try {
|
|
||||||
const text = await extractTextFromPDF(pdfData);
|
|
||||||
setPdfText(text);
|
|
||||||
setText(text);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading PDF text:', error);
|
|
||||||
setLoadingError('Failed to extract PDF text');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadPdfText();
|
|
||||||
}, [pdfDataUrl, extractTextFromPDF, setText, pdfData]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
/*
|
/*
|
||||||
* Sets up click event listeners for text selection in the PDF.
|
* Sets up click event listeners for text selection in the PDF.
|
||||||
|
|
@ -111,10 +71,11 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
|
||||||
*/
|
*/
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
if (!currDocText) return;
|
||||||
|
|
||||||
const handleClick = (event: MouseEvent) => handleTextClick(
|
const handleClick = (event: MouseEvent) => handleTextClick(
|
||||||
event,
|
event,
|
||||||
pdfText,
|
currDocText,
|
||||||
containerRef as RefObject<HTMLDivElement>,
|
containerRef as RefObject<HTMLDivElement>,
|
||||||
stopAndPlayFromIndex,
|
stopAndPlayFromIndex,
|
||||||
isProcessing
|
isProcessing
|
||||||
|
|
@ -123,7 +84,7 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
|
||||||
return () => {
|
return () => {
|
||||||
container.removeEventListener('click', handleClick);
|
container.removeEventListener('click', handleClick);
|
||||||
};
|
};
|
||||||
}, [pdfText, handleTextClick, stopAndPlayFromIndex, isProcessing]);
|
}, [currDocText, handleTextClick, stopAndPlayFromIndex, isProcessing]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
/*
|
/*
|
||||||
|
|
@ -136,25 +97,27 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
|
||||||
* - highlightPattern: Function from context that could change
|
* - highlightPattern: Function from context that could change
|
||||||
* - clearHighlights: Function from context that could change
|
* - clearHighlights: Function from context that could change
|
||||||
*/
|
*/
|
||||||
|
if (!currDocText) return;
|
||||||
|
|
||||||
const highlightTimeout = setTimeout(() => {
|
const highlightTimeout = setTimeout(() => {
|
||||||
if (containerRef.current) {
|
if (containerRef.current) {
|
||||||
highlightPattern(pdfText, currentSentence || '', containerRef as RefObject<HTMLDivElement>);
|
highlightPattern(currDocText, currentSentence || '', containerRef as RefObject<HTMLDivElement>);
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 200);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(highlightTimeout);
|
clearTimeout(highlightTimeout);
|
||||||
clearHighlights();
|
clearHighlights();
|
||||||
};
|
};
|
||||||
}, [pdfText, currentSentence, highlightPattern, clearHighlights]);
|
}, [currDocText, currentSentence, highlightPattern, clearHighlights]);
|
||||||
|
|
||||||
// Add scale calculation function
|
// Add scale calculation function
|
||||||
const calculateScale = (pageWidth: number = 595) => { // 595 is default PDF width in points
|
const calculateScale = useCallback((pageWidth = 595): number => {
|
||||||
const margin = 24; // 24px padding on each side
|
const margin = 24; // 24px padding on each side
|
||||||
const targetWidth = containerWidth - margin;
|
const targetWidth = containerWidth - margin;
|
||||||
const baseScale = targetWidth / pageWidth;
|
const baseScale = targetWidth / pageWidth;
|
||||||
return baseScale * (zoomLevel / 100);
|
return baseScale * (zoomLevel / 100);
|
||||||
};
|
}, [containerWidth, zoomLevel]);
|
||||||
|
|
||||||
// Add resize observer effect
|
// Add resize observer effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -171,121 +134,22 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function onDocumentLoadSuccess({ numPages }: { numPages: number }): void {
|
|
||||||
setNumPages(numPages);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const handlePageChange = useCallback(async (pageNumber: number) => {
|
|
||||||
if (pageNumber < 1 || pageNumber > (numPages || 1)) return;
|
|
||||||
|
|
||||||
// Stop current playback and reset states
|
|
||||||
if (isPlaying) {
|
|
||||||
stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentPage(pageNumber);
|
|
||||||
|
|
||||||
// Extract text from the new page
|
|
||||||
if (pdfData) {
|
|
||||||
try {
|
|
||||||
const pdf = await pdfjs.getDocument(pdfDataUrl!).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
|
|
||||||
);
|
|
||||||
|
|
||||||
// Process text items to maintain reading order
|
|
||||||
const lines: TextItem[][] = [];
|
|
||||||
let currentLine: TextItem[] = [];
|
|
||||||
let currentY: number | null = null;
|
|
||||||
const tolerance = 2;
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Build page text
|
|
||||||
let fullText = '';
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
fullText += lineText + ' ';
|
|
||||||
}
|
|
||||||
|
|
||||||
setText(fullText.trim()); // Update TTS with current page text
|
|
||||||
|
|
||||||
// // Wait for text processing before resuming playback
|
|
||||||
// await new Promise(resolve => setTimeout(resolve, 300));
|
|
||||||
|
|
||||||
// if (wasPlaying) {
|
|
||||||
// stopAndPlayFromIndex(0);
|
|
||||||
// }
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error extracting page text:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [isPlaying, pdfData, pdfDataUrl, numPages, setText, stop]);
|
|
||||||
|
|
||||||
// Auto-advance to next page when TTS reaches the end
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPlaying && currentIndex >= sentences.length - 1 && currentPage < (numPages || 1)) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
handlePageChange(currentPage + 1);
|
|
||||||
}, 500); // Longer delay to ensure states are settled
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [isPlaying, currentIndex, sentences.length, currentPage, numPages, handlePageChange]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="flex flex-col items-center overflow-auto max-h-[calc(100vh-100px)] w-full px-6">
|
<div ref={containerRef} className="flex flex-col items-center overflow-auto max-h-[calc(100vh-100px)] w-full px-6">
|
||||||
{loadingError ? (
|
|
||||||
<div className="text-red-500 mb-4">{loadingError}</div>
|
|
||||||
) : null}
|
|
||||||
<Document
|
<Document
|
||||||
loading={<PDFSkeleton />}
|
loading={<PDFSkeleton />}
|
||||||
noData={<PDFSkeleton />}
|
noData={<PDFSkeleton />}
|
||||||
file={pdfDataUrl}
|
file={currDocURL}
|
||||||
onLoadSuccess={(pdf) => {
|
onLoadSuccess={(pdf) => {
|
||||||
onDocumentLoadSuccess(pdf);
|
onDocumentLoadSuccess(pdf);
|
||||||
handlePageChange(1); // Load first page text
|
//handlePageChange(1); // Load first page text
|
||||||
}}
|
}}
|
||||||
className="flex flex-col items-center m-0"
|
className="flex flex-col items-center m-0"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Page
|
<Page
|
||||||
pageNumber={currentPage}
|
pageNumber={currDocPage}
|
||||||
renderAnnotationLayer={true}
|
renderAnnotationLayer={true}
|
||||||
renderTextLayer={true}
|
renderTextLayer={true}
|
||||||
className="shadow-lg"
|
className="shadow-lg"
|
||||||
|
|
@ -295,9 +159,9 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
|
||||||
</div>
|
</div>
|
||||||
</Document>
|
</Document>
|
||||||
<TTSPlayer
|
<TTSPlayer
|
||||||
currentPage={currentPage}
|
currentPage={currDocPage}
|
||||||
numPages={numPages}
|
numPages={currDocPages}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={() => {}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,21 @@ import nlp from 'compromise';
|
||||||
// Add the correct type import
|
// Add the correct type import
|
||||||
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
|
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
|
|
||||||
// Set worker from public directory
|
// Set worker from public directory
|
||||||
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.mjs';
|
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.mjs';
|
||||||
|
|
||||||
|
// Convert PDF data to PDF data URL
|
||||||
|
const 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);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
interface PDFContextType {
|
interface PDFContextType {
|
||||||
documents: PDFDocument[];
|
documents: PDFDocument[];
|
||||||
addDocument: (file: File) => Promise<string>;
|
addDocument: (file: File) => Promise<string>;
|
||||||
|
|
@ -29,7 +40,7 @@ interface PDFContextType {
|
||||||
removeDocument: (id: string) => Promise<void>;
|
removeDocument: (id: string) => Promise<void>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
extractTextFromPDF: (pdfData: Blob) => Promise<string>;
|
extractTextFromPDF: (pdfURL: string, currDocPage: number) => Promise<string>;
|
||||||
highlightPattern: (text: string, pattern: string, containerRef: React.RefObject<HTMLDivElement>) => void;
|
highlightPattern: (text: string, pattern: string, containerRef: React.RefObject<HTMLDivElement>) => void;
|
||||||
clearHighlights: () => void;
|
clearHighlights: () => void;
|
||||||
handleTextClick: (
|
handleTextClick: (
|
||||||
|
|
@ -39,16 +50,36 @@ interface PDFContextType {
|
||||||
stopAndPlayFromIndex: (index: number) => void,
|
stopAndPlayFromIndex: (index: number) => void,
|
||||||
isProcessing: boolean
|
isProcessing: boolean
|
||||||
) => void;
|
) => void;
|
||||||
|
onDocumentLoadSuccess: ({ numPages }: { numPages: number }) => void;
|
||||||
|
convertPDFDataToURL: (pdfData: Blob) => Promise<string>;
|
||||||
|
setCurrentDocument: (id: string) => Promise<void>;
|
||||||
|
currDocURL: string | undefined;
|
||||||
|
currDocName: string | undefined;
|
||||||
|
currDocPages: number | undefined;
|
||||||
|
currDocPage: number;
|
||||||
|
currDocText: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PDFContext = createContext<PDFContextType | undefined>(undefined);
|
const PDFContext = createContext<PDFContextType | undefined>(undefined);
|
||||||
|
|
||||||
export function PDFProvider({ children }: { children: ReactNode }) {
|
export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
const { isDBReady } = useConfig();
|
|
||||||
const [documents, setDocuments] = useState<PDFDocument[]>([]);
|
const [documents, setDocuments] = useState<PDFDocument[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { isDBReady } = useConfig();
|
||||||
|
const {
|
||||||
|
setText: setTTSText,
|
||||||
|
currDocPage,
|
||||||
|
currDocPages,
|
||||||
|
setCurrDocPages,
|
||||||
|
} = useTTS();
|
||||||
|
|
||||||
|
// Current document state
|
||||||
|
const [currDocURL, setCurrDocURL] = useState<string>();
|
||||||
|
const [currDocName, setCurrDocName] = useState<string>();
|
||||||
|
const [currDocText, setCurrDocText] = useState<string>();
|
||||||
|
|
||||||
// Load documents from IndexedDB once DB is ready
|
// Load documents from IndexedDB once DB is ready
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadDocuments = async () => {
|
const loadDocuments = async () => {
|
||||||
|
|
@ -117,29 +148,26 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
function onDocumentLoadSuccess({ numPages }: { numPages: number }): void {
|
||||||
|
console.log('Document loaded:', numPages);
|
||||||
|
setCurrDocPages(numPages);
|
||||||
|
}
|
||||||
|
|
||||||
// Extract text from a PDF file
|
// Extract text from a PDF file
|
||||||
const extractTextFromPDF = useCallback(async (pdfData: Blob): Promise<string> => {
|
const extractTextFromPDF = useCallback(async (pdfURL: string, currDocPage: number): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
const reader = new FileReader();
|
const base64Data = pdfURL.split(',')[1];
|
||||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
const binaryData = atob(base64Data);
|
||||||
reader.onload = () => resolve(reader.result as string);
|
const bytes = new Uint8Array(binaryData.length);
|
||||||
reader.onerror = () => reject(reader.error);
|
for (let i = 0; i < binaryData.length; i++) {
|
||||||
reader.readAsDataURL(pdfData);
|
bytes[i] = binaryData.charCodeAt(i);
|
||||||
});
|
}
|
||||||
|
|
||||||
const base64Data = dataUrl.split(',')[1];
|
const loadingTask = pdfjs.getDocument({ data: bytes });
|
||||||
const binaryData = atob(base64Data);
|
const pdf = await loadingTask.promise;
|
||||||
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 });
|
// Get only the specified page
|
||||||
const pdf = await loadingTask.promise;
|
const page = await pdf.getPage(currDocPage);
|
||||||
let fullText = '';
|
|
||||||
|
|
||||||
for (let i = 1; i <= pdf.numPages; i++) {
|
|
||||||
const page = await pdf.getPage(i);
|
|
||||||
const textContent = await page.getTextContent();
|
const textContent = await page.getTextContent();
|
||||||
|
|
||||||
// Filter out non-text items and assert proper type
|
// Filter out non-text items and assert proper type
|
||||||
|
|
@ -197,15 +225,49 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
pageText += lineText + ' ';
|
pageText += lineText + ' ';
|
||||||
}
|
}
|
||||||
|
|
||||||
fullText += pageText + '\n';
|
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 fullText.replace(/\s+/g, ' ').trim();
|
// Load curr doc text
|
||||||
|
const loadCurrDocText = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
if (!currDocURL) return;
|
||||||
|
const text = await extractTextFromPDF(currDocURL, currDocPage);
|
||||||
|
setCurrDocText(text);
|
||||||
|
setTTSText(text);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error extracting text from PDF:', error);
|
console.error('Error loading PDF text:', error);
|
||||||
throw new Error('Failed to extract text from PDF');
|
setError('Failed to extract PDF text');
|
||||||
}
|
}
|
||||||
}, []);
|
}, [currDocURL, currDocPage, extractTextFromPDF, setTTSText]);
|
||||||
|
|
||||||
|
// Update the current document text when the page changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (currDocURL) {
|
||||||
|
loadCurrDocText();
|
||||||
|
}
|
||||||
|
}, [currDocPage, currDocURL, loadCurrDocText]);
|
||||||
|
|
||||||
|
// Set curr document
|
||||||
|
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const doc = await getDocument(id);
|
||||||
|
if (doc) {
|
||||||
|
const url = await convertPDFDataToURL(doc.data);
|
||||||
|
setCurrDocName(doc.name);
|
||||||
|
setCurrDocURL(url);
|
||||||
|
//await loadCurrDocText();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get document URL:', error);
|
||||||
|
setError('Failed to retrieve the document. Please try again.');
|
||||||
|
}
|
||||||
|
}, [getDocument, convertPDFDataToURL, loadCurrDocText]);
|
||||||
|
|
||||||
// Clear all highlights in the PDF viewer
|
// Clear all highlights in the PDF viewer
|
||||||
const clearHighlights = useCallback(() => {
|
const clearHighlights = useCallback(() => {
|
||||||
|
|
@ -395,6 +457,14 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
highlightPattern,
|
highlightPattern,
|
||||||
clearHighlights,
|
clearHighlights,
|
||||||
handleTextClick,
|
handleTextClick,
|
||||||
|
onDocumentLoadSuccess,
|
||||||
|
convertPDFDataToURL,
|
||||||
|
setCurrentDocument,
|
||||||
|
currDocURL,
|
||||||
|
currDocName,
|
||||||
|
currDocPages,
|
||||||
|
currDocPage,
|
||||||
|
currDocText,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
documents,
|
documents,
|
||||||
|
|
@ -407,6 +477,14 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
highlightPattern,
|
highlightPattern,
|
||||||
clearHighlights,
|
clearHighlights,
|
||||||
handleTextClick,
|
handleTextClick,
|
||||||
|
onDocumentLoadSuccess,
|
||||||
|
convertPDFDataToURL,
|
||||||
|
setCurrentDocument,
|
||||||
|
currDocURL,
|
||||||
|
currDocName,
|
||||||
|
currDocPages,
|
||||||
|
currDocPage,
|
||||||
|
currDocText,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,9 @@ interface TTSContextType {
|
||||||
availableVoices: string[];
|
availableVoices: string[];
|
||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
setCurrentIndex: (index: number) => void;
|
setCurrentIndex: (index: number) => void;
|
||||||
|
currDocPage: number;
|
||||||
|
currDocPages: number | undefined;
|
||||||
|
setCurrDocPages: (pages: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TTSContext = createContext<TTSContextType | undefined>(undefined);
|
const TTSContext = createContext<TTSContextType | undefined>(undefined);
|
||||||
|
|
@ -67,7 +70,6 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [sentences, setSentences] = useState<string[]>([]);
|
const [sentences, setSentences] = useState<string[]>([]);
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const [audioContext, setAudioContext] = useState<AudioContextType>();
|
const [audioContext, setAudioContext] = useState<AudioContextType>();
|
||||||
const currentRequestRef = useRef<AbortController | null>(null);
|
|
||||||
const [activeHowl, setActiveHowl] = useState<Howl | null>(null);
|
const [activeHowl, setActiveHowl] = useState<Howl | null>(null);
|
||||||
const [audioQueue] = useState<AudioBuffer[]>([]);
|
const [audioQueue] = useState<AudioBuffer[]>([]);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
@ -75,31 +77,33 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [voice, setVoice] = useState('alloy');
|
const [voice, setVoice] = useState('alloy');
|
||||||
const [availableVoices, setAvailableVoices] = useState<string[]>([]);
|
const [availableVoices, setAvailableVoices] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const [currDocPage, setCurrDocPage] = useState<number>(1);
|
||||||
|
const [currDocPages, setCurrDocPages] = useState<number>();
|
||||||
|
const [nextPageLoading, setNextPageLoading] = useState(false);
|
||||||
|
|
||||||
// Audio cache using LRUCache with a maximum size of 50 entries
|
// Audio cache using LRUCache with a maximum size of 50 entries
|
||||||
const audioCacheRef = useRef(new LRUCache<string, AudioBuffer>({ max: 50 }));
|
const audioCacheRef = useRef(new LRUCache<string, AudioBuffer>({ max: 50 }));
|
||||||
|
|
||||||
const setText = useCallback((text: string) => {
|
const setText = useCallback((text: string) => {
|
||||||
setCurrentText(text);
|
setCurrentText(text);
|
||||||
|
console.log('Setting page text:', text);
|
||||||
const newSentences = splitIntoSentences(text);
|
const newSentences = splitIntoSentences(text);
|
||||||
setSentences(newSentences);
|
setSentences(newSentences);
|
||||||
setCurrentIndex(0);
|
//setCurrentIndex(0);
|
||||||
setIsPlaying(false);
|
//setIsPlaying(false);
|
||||||
|
|
||||||
// Clear audio cache
|
// Clear audio cache
|
||||||
audioCacheRef.current.clear();
|
audioCacheRef.current.clear();
|
||||||
|
|
||||||
|
setNextPageLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const abortAudio = useCallback(() => {
|
const abortAudio = useCallback(() => {
|
||||||
if (currentRequestRef.current) {
|
|
||||||
currentRequestRef.current.abort();
|
|
||||||
currentRequestRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeHowl) {
|
if (activeHowl) {
|
||||||
activeHowl.stop();
|
activeHowl.stop();
|
||||||
setActiveHowl(null);
|
setActiveHowl(null);
|
||||||
}
|
}
|
||||||
}, [currentRequestRef, activeHowl]);
|
}, [activeHowl]);
|
||||||
|
|
||||||
const togglePlay = useCallback(() => {
|
const togglePlay = useCallback(() => {
|
||||||
setIsPlaying((prev) => {
|
setIsPlaying((prev) => {
|
||||||
|
|
@ -231,10 +235,17 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
if (nextIndex < sentences.length) {
|
if (nextIndex < sentences.length) {
|
||||||
console.log('Auto-advancing to next sentence:', sentences[nextIndex]);
|
console.log('Auto-advancing to next sentence:', sentences[nextIndex]);
|
||||||
return nextIndex;
|
return nextIndex;
|
||||||
|
} else if (currDocPage < currDocPages!) {
|
||||||
|
console.log('Auto-advancing to next page:', currDocPage + 1);
|
||||||
|
|
||||||
|
setNextPageLoading(true);
|
||||||
|
setCurrDocPage(currDocPage + 1);
|
||||||
|
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
return prev;
|
return prev;
|
||||||
});
|
});
|
||||||
}, [sentences]);
|
}, [sentences, currDocPage, currDocPages]);
|
||||||
|
|
||||||
//new function to return audio buffer with caching
|
//new function to return audio buffer with caching
|
||||||
const getAudio = useCallback(async (sentence: string): Promise<AudioBuffer | undefined> => {
|
const getAudio = useCallback(async (sentence: string): Promise<AudioBuffer | undefined> => {
|
||||||
|
|
@ -273,23 +284,10 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
// Only set processing state if not preloading
|
// Only set processing state if not preloading
|
||||||
if (!preload) setIsProcessing(true);
|
if (!preload) setIsProcessing(true);
|
||||||
|
|
||||||
try {
|
const cleanedSentence = preprocessSentenceForAudio(sentence);
|
||||||
const cleanedSentence = preprocessSentenceForAudio(sentence);
|
const audioBuffer = await getAudio(cleanedSentence);
|
||||||
currentRequestRef.current = new AbortController();
|
|
||||||
|
|
||||||
const audioBuffer = await getAudio(cleanedSentence);
|
return audioBufferToURL(audioBuffer!);
|
||||||
if (!currentRequestRef.current) throw new Error('Request was cancelled');
|
|
||||||
|
|
||||||
|
|
||||||
return audioBufferToURL(audioBuffer!);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && error.name === 'AbortError') {
|
|
||||||
console.log('Request was cancelled');
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
currentRequestRef.current = null;
|
|
||||||
}
|
|
||||||
}, [isProcessing, audioContext, getAudio]);
|
}, [isProcessing, audioContext, getAudio]);
|
||||||
|
|
||||||
const playSentenceWithHowl = useCallback(async (sentence: string) => {
|
const playSentenceWithHowl = useCallback(async (sentence: string) => {
|
||||||
|
|
@ -299,9 +297,6 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
throw new Error('No audio URL generated');
|
throw new Error('No audio URL generated');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set processing to false before creating the Howl instance
|
|
||||||
setIsProcessing(false);
|
|
||||||
|
|
||||||
const howl = new Howl({
|
const howl = new Howl({
|
||||||
src: [audioUrl],
|
src: [audioUrl],
|
||||||
format: ['wav'],
|
format: ['wav'],
|
||||||
|
|
@ -335,28 +330,28 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
|
||||||
setActiveHowl(howl);
|
setActiveHowl(howl);
|
||||||
howl.play();
|
howl.play();
|
||||||
|
setIsProcessing(false);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error playing TTS:', error);
|
console.error('Error playing TTS:', error);
|
||||||
setActiveHowl(null);
|
setActiveHowl(null);
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
setIsPlaying(false); // Stop playback on error
|
//setIsPlaying(false); // Stop playback on error
|
||||||
|
|
||||||
if (error instanceof Error &&
|
if (error instanceof Error &&
|
||||||
(error.message.includes('Unable to decode audio data') ||
|
(error.message.includes('Unable to decode audio data') ||
|
||||||
error.message.includes('EncodingError'))) {
|
error.message.includes('EncodingError'))) {
|
||||||
console.log('Skipping problematic sentence:', sentence.substring(0, 50) + '...');
|
console.log('Skipping problematic sentence:', sentence);
|
||||||
advance();
|
advance(); // Skip problematic sentence
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isPlaying, processSentence, advance]);
|
}, [isPlaying, processSentence, advance]);
|
||||||
|
|
||||||
const preloadNextAudio = useCallback(async () => {
|
const preloadNextAudio = useCallback(() => {
|
||||||
try {
|
try {
|
||||||
// Only preload next sentence to reduce API load
|
|
||||||
if (sentences[currentIndex + 1] && !audioCacheRef.current.has(sentences[currentIndex + 1])) {
|
if (sentences[currentIndex + 1] && !audioCacheRef.current.has(sentences[currentIndex + 1])) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200)); // Add small delay
|
//await new Promise((resolve) => setTimeout(resolve, 200)); // Add small delay
|
||||||
await processSentence(sentences[currentIndex + 1], true); // True indicates preloading
|
processSentence(sentences[currentIndex + 1], true); // True indicates preloading
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error preloading next sentence:', error);
|
console.error('Error preloading next sentence:', error);
|
||||||
|
|
@ -364,22 +359,21 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
}, [currentIndex, sentences, audioCacheRef, processSentence]);
|
}, [currentIndex, sentences, audioCacheRef, processSentence]);
|
||||||
|
|
||||||
const playAudio = useCallback(async () => {
|
const playAudio = useCallback(async () => {
|
||||||
//if (isPlaying && sentences[currentIndex] && !isProcessing) {
|
|
||||||
await playSentenceWithHowl(sentences[currentIndex]);
|
await playSentenceWithHowl(sentences[currentIndex]);
|
||||||
//}
|
|
||||||
}, [sentences, currentIndex, playSentenceWithHowl]);
|
}, [sentences, currentIndex, playSentenceWithHowl]);
|
||||||
|
|
||||||
// main driver useEffect
|
// main driver useEffect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sentences[currentIndex]) {
|
if (!isPlaying) return; // Don't proceed if stopped
|
||||||
return; // Don't proceed if no valid sentence
|
if (isProcessing) return; // Don't proceed if processing audio
|
||||||
}
|
if (!sentences[currentIndex]) return; // Don't proceed if no sentence to play
|
||||||
|
if (nextPageLoading) return; // Don't proceed if loading next page
|
||||||
|
if (activeHowl) return; // Don't proceed if audio is already playing
|
||||||
|
|
||||||
if (isPlaying && !isProcessing && !activeHowl) {
|
// Play the current sentence and preload the next one if available
|
||||||
playAudio();
|
playAudio();
|
||||||
if (isPlaying) { // Only preload if still playing
|
if (sentences[currentIndex + 1]) {
|
||||||
preloadNextAudio().catch(console.error);
|
preloadNextAudio();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -390,6 +384,8 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
isProcessing,
|
isProcessing,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
sentences,
|
sentences,
|
||||||
|
activeHowl,
|
||||||
|
nextPageLoading,
|
||||||
playAudio,
|
playAudio,
|
||||||
preloadNextAudio,
|
preloadNextAudio,
|
||||||
abortAudio
|
abortAudio
|
||||||
|
|
@ -477,6 +473,9 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
setVoiceAndRestart,
|
setVoiceAndRestart,
|
||||||
availableVoices,
|
availableVoices,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
|
currDocPage,
|
||||||
|
currDocPages,
|
||||||
|
setCurrDocPages,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (configIsLoading) {
|
if (configIsLoading) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue