Archived
feat: pdf reader
This commit is contained in:
@@ -199,7 +199,7 @@ export function useEpubReader(
|
||||
try {
|
||||
const [progressData, blob] = await Promise.all([
|
||||
getReadingProgress(bookId).catch(() => null),
|
||||
booksApi.getEpubFile(bookId),
|
||||
booksApi.getEbookFile(bookId),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useAnnotations } from "@/context/AnnotationsContext";
|
||||
import { isPdfAnchor, parsePdfAnchor } from "@/utils/pdfAnchor";
|
||||
import { normalizeHighlightColor } from "@/constants/bookmarkHighlightColors";
|
||||
|
||||
export interface PdfPageHighlight {
|
||||
rects: [number, number, number, number][];
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function usePdfHighlights(ebookId: number, currentPage: number): PdfPageHighlight[] {
|
||||
const { markers, loadBookmarks } = useAnnotations();
|
||||
|
||||
const bookMarkers = useMemo(
|
||||
() => markers.filter((m) => m.ebook_id === ebookId),
|
||||
[markers, ebookId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ebookId || Number.isNaN(ebookId)) return;
|
||||
void loadBookmarks(ebookId);
|
||||
}, [ebookId, loadBookmarks]);
|
||||
|
||||
return useMemo(() => {
|
||||
const highlights: PdfPageHighlight[] = [];
|
||||
for (const marker of bookMarkers) {
|
||||
if (!isPdfAnchor(marker.epub_cfi)) continue;
|
||||
const parsed = parsePdfAnchor(marker.epub_cfi);
|
||||
if (!parsed || parsed.page !== currentPage || !parsed.rects.length) continue;
|
||||
highlights.push({
|
||||
rects: parsed.rects,
|
||||
color: normalizeHighlightColor(marker.highlight_color),
|
||||
});
|
||||
}
|
||||
return highlights;
|
||||
}, [bookMarkers, currentPage]);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { PendingSelection } from "./useEpubSelection";
|
||||
import { rectsFromSelection, serializePdfAnchor } from "../utils/pdfAnchor";
|
||||
|
||||
export function usePdfSelection(
|
||||
pageWrapRef: React.RefObject<HTMLDivElement | null>,
|
||||
currentPage: number,
|
||||
chapterTitle: string,
|
||||
) {
|
||||
const [pendingSelection, setPendingSelection] = useState<PendingSelection | null>(null);
|
||||
const chapterTitleRef = useRef(chapterTitle);
|
||||
chapterTitleRef.current = chapterTitle;
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setPendingSelection(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const wrap = pageWrapRef.current;
|
||||
if (!wrap) return;
|
||||
|
||||
const handler = () => {
|
||||
window.setTimeout(() => {
|
||||
const sel = window.getSelection();
|
||||
const text = sel?.toString()?.trim() ?? "";
|
||||
if (!text || !sel || sel.rangeCount === 0) {
|
||||
setPendingSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const range = sel.getRangeAt(0);
|
||||
if (!wrap.contains(range.commonAncestorContainer)) {
|
||||
setPendingSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const rects = rectsFromSelection(range, wrap);
|
||||
const anchor = serializePdfAnchor(currentPage, rects.length > 0 ? rects : undefined);
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
setPendingSelection({
|
||||
epubCfi: anchor,
|
||||
locationText: text.slice(0, 2000),
|
||||
chapterIndex: currentPage - 1,
|
||||
chapterTitle: chapterTitleRef.current,
|
||||
anchorTop: rect.bottom + 8,
|
||||
anchorLeft: Math.min(rect.left, window.innerWidth - 280),
|
||||
});
|
||||
}, 10);
|
||||
};
|
||||
|
||||
wrap.addEventListener("mouseup", handler);
|
||||
return () => wrap.removeEventListener("mouseup", handler);
|
||||
}, [pageWrapRef, currentPage]);
|
||||
|
||||
return { pendingSelection, clearSelection };
|
||||
}
|
||||
Reference in New Issue
Block a user