feat: pdf reader

This commit is contained in:
2026-06-03 22:46:38 -05:00
parent d9c66e68a6
commit f091386974
29 changed files with 29469 additions and 2593 deletions
+258
View File
@@ -0,0 +1,258 @@
/**
* usePdfReader — load PDF blob, page navigation, and persist reading progress.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { booksApi } from "../api/books";
import { getReadingProgress, 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;
pageWrapRef: React.RefObject<HTMLDivElement | 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<PdfDocumentProxy | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [pageCount, setPageCount] = useState(pageCountHint);
const [scale, setScale] = useState(1.25);
const [progress, setProgress] = useState<ReadingProgress | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isBookmarkPeekActive, setIsBookmarkPeekActive] = useState(false);
const [readingAnchor, setReadingAnchor] = useState<PdfReadingAnchor | null>(null);
const pageWrapRef = useRef<HTMLDivElement | null>(null);
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const isBookmarkPeekRef = useRef(false);
const readingAnchorRef = useRef<PdfReadingAnchor | null>(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 Promise.all([
getReadingProgress(bookId).catch(() => null),
booksApi.getEbookFile(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,
pageWrapRef,
goToPage,
goToNextPage,
goToPrevPage,
beginBookmarkPeek,
resumeReadingAnchor,
goToAnchor,
};
}