/** * usePdfReader — load PDF blob, page navigation, and persist reading progress. */ import { useCallback, useEffect, useRef, useState } from "react"; import { loadEbookWithProgress } from "../api/loadEbookWithProgress"; import { updateReadingProgress } from "../api/reader"; import type { ReadingProgress } from "../types/reader"; import { parsePdfAnchor } from "../utils/pdfAnchor"; import { pdfjs, type PdfDocumentProxy } from "../utils/pdfjsSetup"; export interface PdfReadingAnchor { page: number; percentage: number; } export interface UsePdfReaderReturn { pdfDocument: PdfDocumentProxy | null; currentPage: number; pageCount: number; scale: number; setScale: (scale: number) => void; chapterTitle: string; progress: ReadingProgress | null; isLoading: boolean; error: string | null; isBookmarkPeekActive: boolean; readingAnchor: PdfReadingAnchor | null; goToPage: (page: number) => void; goToNextPage: () => void; goToPrevPage: () => void; beginBookmarkPeek: (targetAnchor: string) => void; resumeReadingAnchor: () => void; goToAnchor: (anchor: string) => void; } function pageToPercentage(page: number, pageCount: number): number { if (pageCount <= 0) return 0; return Math.round((page / pageCount) * 100); } export function usePdfReader( bookId: number, pageCountHint = 0, initialAnchor?: string, ): UsePdfReaderReturn { const [pdfDocument, setPdfDocument] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [pageCount, setPageCount] = useState(pageCountHint); const [scale, setScale] = useState(1.25); const [progress, setProgress] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [isBookmarkPeekActive, setIsBookmarkPeekActive] = useState(false); const [readingAnchor, setReadingAnchor] = useState(null); const debounceTimer = useRef | null>(null); const isBookmarkPeekRef = useRef(false); const readingAnchorRef = useRef(null); const pageCountRef = useRef(pageCountHint); const clearPeekState = useCallback(() => { isBookmarkPeekRef.current = false; readingAnchorRef.current = null; setIsBookmarkPeekActive(false); setReadingAnchor(null); }, []); const setPeekState = useCallback((anchor: PdfReadingAnchor) => { readingAnchorRef.current = anchor; isBookmarkPeekRef.current = true; setReadingAnchor(anchor); setIsBookmarkPeekActive(true); }, []); const flushProgress = useCallback( async (page: number, total: number) => { if (isBookmarkPeekRef.current) return; const percentage = pageToPercentage(page, total); try { const updated = await updateReadingProgress(bookId, { current_chapter: page, percentage, current_position: percentage, }); setProgress(updated); } catch { /* best-effort */ } }, [bookId], ); const scheduleProgress = useCallback( (page: number, total: number) => { if (isBookmarkPeekRef.current) return; if (debounceTimer.current) clearTimeout(debounceTimer.current); debounceTimer.current = setTimeout(() => { void flushProgress(page, total); }, 800); }, [flushProgress], ); const goToPage = useCallback( (page: number) => { const total = pageCountRef.current || pageCount; const clamped = Math.max(1, Math.min(page, total || page)); setCurrentPage(clamped); if (total > 0) scheduleProgress(clamped, total); }, [pageCount, scheduleProgress], ); const goToAnchor = useCallback( (anchor: string) => { const parsed = parsePdfAnchor(anchor); if (!parsed) return; goToPage(parsed.page); }, [goToPage], ); const captureAnchorFromCurrent = useCallback((): PdfReadingAnchor | null => { const total = pageCountRef.current || pageCount; if (total <= 0) return null; return { page: currentPage, percentage: pageToPercentage(currentPage, total), }; }, [currentPage, pageCount]); const beginBookmarkPeek = useCallback( (targetAnchor: string) => { const parsed = parsePdfAnchor(targetAnchor); if (!parsed) return; if (!isBookmarkPeekRef.current) { let anchor = captureAnchorFromCurrent(); if (!anchor && progress) { const page = progress.current_chapter || 1; const pct = progress.percentage ?? progress.current_position ?? 0; anchor = { page, percentage: pct > 0 ? Math.round(pct) : pageToPercentage(page, pageCountRef.current) }; } if (anchor) setPeekState(anchor); } goToPage(parsed.page); }, [captureAnchorFromCurrent, goToPage, progress, setPeekState], ); const resumeReadingAnchor = useCallback(() => { const anchor = readingAnchorRef.current; clearPeekState(); if (!anchor) return; goToPage(anchor.page); const total = pageCountRef.current || pageCount; void flushProgress(anchor.page, total); }, [clearPeekState, flushProgress, goToPage, pageCount]); const goToNextPage = useCallback(() => { goToPage(currentPage + 1); }, [currentPage, goToPage]); const goToPrevPage = useCallback(() => { goToPage(currentPage - 1); }, [currentPage, goToPage]); useEffect(() => { let cancelled = false; clearPeekState(); async function loadPdf() { setIsLoading(true); setError(null); try { const { progressData, blob } = await loadEbookWithProgress(bookId); if (cancelled) return; const loadingTask = pdfjs.getDocument({ data: await blob.arrayBuffer() }); const doc = await loadingTask.promise; if (cancelled) return; const numPages = doc.numPages; pageCountRef.current = numPages; setPdfDocument(doc); setPageCount(numPages); const peekParsed = initialAnchor && parsePdfAnchor(initialAnchor) ? parsePdfAnchor(initialAnchor) : null; const savedPage = progressData?.current_chapter || 0; const startPage = peekParsed?.page ?? (savedPage > 0 ? savedPage : 1); if (progressData) setProgress(progressData); if (peekParsed && savedPage > 0) { setPeekState({ page: savedPage, percentage: pageToPercentage(savedPage, numPages), }); } setCurrentPage(Math.min(startPage, numPages)); } catch (err: unknown) { if (!cancelled) { const message = err instanceof Error ? err.message : "Failed to load PDF"; setError(message.includes("password") ? "PDF_PASSWORD" : message); } } finally { if (!cancelled) setIsLoading(false); } } void loadPdf(); return () => { cancelled = true; if (debounceTimer.current) clearTimeout(debounceTimer.current); clearPeekState(); }; }, [bookId, initialAnchor, clearPeekState, setPeekState]); useEffect(() => { pageCountRef.current = pageCount; }, [pageCount]); const chapterTitle = pageCount > 0 ? `Page ${currentPage} / ${pageCount}` : ""; return { pdfDocument, currentPage, pageCount, scale, setScale, chapterTitle, progress, isLoading, error, isBookmarkPeekActive, readingAnchor, goToPage, goToNextPage, goToPrevPage, beginBookmarkPeek, resumeReadingAnchor, goToAnchor, }; }