Archived
253 lines
7.4 KiB
TypeScript
253 lines
7.4 KiB
TypeScript
/**
|
|
* 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<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 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 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,
|
|
};
|
|
}
|