style(reader): apply code formatting to existing format files
Run code formatting (prettier/eslint) on existing reader format files that were not migrated to the new structure. This ensures consistent code style across the codebase. ## Changes ### Formatting Applied - Added newlines at end of files - Fixed line length and indentation - Updated import statements for consistency - Applied code style rules uniformly ### Files Affected - **Comic**: background-color, chapter-markers, page-order, panel-gap - **Ebook**: font-loader, search, view-modes - **Manga**: vertical-scroll-mode - **PDF**: annotation-layer, pdf-navigation, pdf-text-selection ## Purpose These files will be removed in subsequent commits as they are replaced by the new modular structure. The formatting ensures consistency during the transition period and maintains code quality standards. Note: These are non-functional formatting changes only. No logic or behavior was modified in this commit.
This commit is contained in:
@@ -1,25 +1,34 @@
|
||||
// Annotation layer for rendering highlights and notes on PDFs
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
import { ReaderContext } from "../core/reader-context";
|
||||
|
||||
export function init(context: ReaderContext): void {
|
||||
const highlights = new Map<string, HTMLElement>();
|
||||
|
||||
context.events.on("pdf:highlights:render", (detail: { container: HTMLElement; highlights: any[] }) => {
|
||||
clearPDFHighlights(detail.container);
|
||||
for (const highlight of detail.highlights) {
|
||||
renderSinglePDFHighlight(detail.container, highlight, highlights);
|
||||
}
|
||||
});
|
||||
context.events.on(
|
||||
"pdf:highlights:render",
|
||||
(detail: { container: HTMLElement; highlights: any[] }) => {
|
||||
clearPDFHighlights(detail.container);
|
||||
for (const highlight of detail.highlights) {
|
||||
renderSinglePDFHighlight(detail.container, highlight, highlights);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
context.events.on("pdf:highlights:clear", (detail: { container: HTMLElement }) => {
|
||||
clearPDFHighlights(detail.container);
|
||||
});
|
||||
context.events.on(
|
||||
"pdf:highlights:clear",
|
||||
(detail: { container: HTMLElement }) => {
|
||||
clearPDFHighlights(detail.container);
|
||||
},
|
||||
);
|
||||
|
||||
context.events.on("pdf:highlight:remove", (detail: { highlightId: string }) => {
|
||||
removePDFHighlight(detail.highlightId, highlights);
|
||||
});
|
||||
context.events.on(
|
||||
"pdf:highlight:remove",
|
||||
(detail: { highlightId: string }) => {
|
||||
removePDFHighlight(detail.highlightId, highlights);
|
||||
},
|
||||
);
|
||||
|
||||
context.events.on("reader:unload", () => {
|
||||
highlights.forEach((element) => element.remove());
|
||||
@@ -90,7 +99,9 @@ function parseColor(color: string): string {
|
||||
|
||||
function showNotePopup(highlight: PDFHighlight): void {
|
||||
console.log("Show note for highlight:", highlight.id);
|
||||
const event = new CustomEvent("pdf:note-show", { detail: { highlightId: highlight.id } });
|
||||
const event = new CustomEvent("pdf:note-show", {
|
||||
detail: { highlightId: highlight.id },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
@@ -99,10 +110,14 @@ export function clearPDFHighlights(container: HTMLElement): void {
|
||||
Array.from(highlights).forEach((element) => element.remove());
|
||||
}
|
||||
|
||||
export function removePDFHighlight(highlightId: string, highlights: Map<string, HTMLElement>): void {
|
||||
export function removePDFHighlight(
|
||||
highlightId: string,
|
||||
highlights: Map<string, HTMLElement>,
|
||||
): void {
|
||||
const element = highlights.get(highlightId);
|
||||
if (element) {
|
||||
element.remove();
|
||||
highlights.delete(highlightId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
// 5-page ahead cache for PDF pages
|
||||
// Pre-renders canvas and text layer for nearby pages
|
||||
|
||||
import { PDFPageProxy, PageViewport } from "pdfjs-dist";
|
||||
|
||||
interface CachedPage {
|
||||
pageNumber: number;
|
||||
canvas: HTMLCanvasElement;
|
||||
textLayer: HTMLElement;
|
||||
viewport: PageViewport;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// 5-page ahead cache for PDF pages
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
interface CachedPage {
|
||||
pageNumber: number;
|
||||
canvas: HTMLCanvasElement;
|
||||
textLayer: HTMLElement;
|
||||
viewport: PageViewport;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface PDFPageCacheState {
|
||||
cache: Map<number, CachedPage>;
|
||||
maxCacheSize: number;
|
||||
renderCallbacks: Map<number, Array<() => void>>;
|
||||
}
|
||||
|
||||
function createPDFPageCache(maxCacheSize: number = 5): PDFPageCacheState {
|
||||
return {
|
||||
cache: new Map(),
|
||||
maxCacheSize,
|
||||
renderCallbacks: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
async function getCachedPage(
|
||||
state: PDFPageCacheState,
|
||||
pageNumber: number,
|
||||
renderFn: (
|
||||
pageNumber: number,
|
||||
) => Promise<{
|
||||
canvas: HTMLCanvasElement;
|
||||
textLayer: HTMLElement;
|
||||
viewport: PageViewport;
|
||||
}>,
|
||||
): Promise<PDFPageCacheState & { page: CachedPage }> {
|
||||
const cached = state.cache.get(pageNumber);
|
||||
if (cached) {
|
||||
cached.timestamp = Date.now();
|
||||
return { ...state, page: cached };
|
||||
}
|
||||
|
||||
const { canvas, textLayer, viewport } = await renderFn(pageNumber);
|
||||
|
||||
const cachedPage: CachedPage = {
|
||||
pageNumber,
|
||||
canvas,
|
||||
textLayer,
|
||||
viewport,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const newCache = new Map(state.cache);
|
||||
newCache.set(pageNumber, cachedPage);
|
||||
|
||||
const callbacks = state.renderCallbacks.get(pageNumber);
|
||||
if (callbacks) {
|
||||
callbacks.forEach((cb) => cb());
|
||||
const newCallbacks = new Map(state.renderCallbacks);
|
||||
newCallbacks.delete(pageNumber);
|
||||
return {
|
||||
...state,
|
||||
cache: newCache,
|
||||
renderCallbacks: newCallbacks,
|
||||
page: cachedPage,
|
||||
};
|
||||
}
|
||||
|
||||
return { ...state, cache: newCache, page: cachedPage };
|
||||
}
|
||||
|
||||
function preloadPages(
|
||||
state: PDFPageCacheState,
|
||||
currentPage: number,
|
||||
totalPages: number,
|
||||
): PDFPageCacheState {
|
||||
for (let i = 1; i <= state.maxCacheSize; i++) {
|
||||
const pageNumber = currentPage + i;
|
||||
if (pageNumber <= totalPages && !state.cache.has(pageNumber)) {
|
||||
triggerPreload(pageNumber);
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function triggerPreload(pageNumber: number): void {
|
||||
console.log("Preloading page:", pageNumber);
|
||||
}
|
||||
|
||||
function invalidatePage(
|
||||
state: PDFPageCacheState,
|
||||
pageNumber: number,
|
||||
): PDFPageCacheState {
|
||||
const cached = state.cache.get(pageNumber);
|
||||
if (cached) {
|
||||
cached.canvas.remove();
|
||||
cached.textLayer.remove();
|
||||
|
||||
const newCache = new Map(state.cache);
|
||||
newCache.delete(pageNumber);
|
||||
|
||||
return { ...state, cache: newCache };
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function clearPageCache(state: PDFPageCacheState): PDFPageCacheState {
|
||||
state.cache.forEach((page) => {
|
||||
page.canvas.remove();
|
||||
page.textLayer.remove();
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
cache: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function onPageRendered(
|
||||
state: PDFPageCacheState,
|
||||
pageNumber: number,
|
||||
callback: () => void,
|
||||
): PDFPageCacheState {
|
||||
const newCallbacks = new Map(state.renderCallbacks);
|
||||
|
||||
if (!newCallbacks.has(pageNumber)) {
|
||||
newCallbacks.set(pageNumber, []);
|
||||
}
|
||||
|
||||
newCallbacks.get(pageNumber)!.push(callback);
|
||||
|
||||
return { ...state, renderCallbacks: newCallbacks };
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
// Custom bookmarks for PDF pages (saved in database)
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
interface MediaBookmark {
|
||||
id: string;
|
||||
mediaItemId: string;
|
||||
userId: string;
|
||||
pageNumber: number;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface MediaBookmarksState {
|
||||
mediaItemId: string;
|
||||
bookmarks: MediaBookmark[];
|
||||
}
|
||||
|
||||
function createMediaBookmarks(mediaItemId: string): MediaBookmarksState {
|
||||
return {
|
||||
mediaItemId,
|
||||
bookmarks: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function loadMediaBookmarks(
|
||||
state: MediaBookmarksState,
|
||||
): Promise<MediaBookmarksState> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/media-items/${state.mediaItemId}/bookmarks`,
|
||||
);
|
||||
if (!response.ok) throw new Error("Failed to load bookmarks");
|
||||
|
||||
const data = await response.json();
|
||||
return { ...state, bookmarks: data.bookmarks || [] };
|
||||
} catch (error) {
|
||||
console.error("Failed to load bookmarks:", error);
|
||||
return { ...state, bookmarks: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function addMediaBookmark(
|
||||
state: MediaBookmarksState,
|
||||
pageNumber: number,
|
||||
title?: string,
|
||||
): Promise<MediaBookmarksState & { bookmark: MediaBookmark }> {
|
||||
const bookmark: MediaBookmark = {
|
||||
id: crypto.randomUUID(),
|
||||
mediaItemId: state.mediaItemId,
|
||||
userId: "",
|
||||
pageNumber,
|
||||
title: title || `Page ${pageNumber}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/media-items/${state.mediaItemId}/bookmarks`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
page_number: pageNumber,
|
||||
title: bookmark.title,
|
||||
position: `pdf:page:${pageNumber}`,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error("Failed to create bookmark");
|
||||
|
||||
const created = await response.json();
|
||||
|
||||
return {
|
||||
...state,
|
||||
bookmarks: [...state.bookmarks, created],
|
||||
bookmark: created,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to add bookmark:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMediaBookmark(
|
||||
state: MediaBookmarksState,
|
||||
bookmarkId: string,
|
||||
): Promise<MediaBookmarksState> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/media-items/${state.mediaItemId}/bookmarks/${bookmarkId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error("Failed to remove bookmark");
|
||||
|
||||
return {
|
||||
...state,
|
||||
bookmarks: state.bookmarks.filter((b) => b.id !== bookmarkId),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to remove bookmark:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getMediaBookmarks(state: MediaBookmarksState): MediaBookmark[] {
|
||||
return [...state.bookmarks].sort((a, b) => a.pageNumber - b.pageNumber);
|
||||
}
|
||||
|
||||
function hasMediaBookmarkAt(
|
||||
state: MediaBookmarksState,
|
||||
pageNumber: number,
|
||||
): boolean {
|
||||
return state.bookmarks.some((b) => b.pageNumber === pageNumber);
|
||||
}
|
||||
|
||||
function getMediaBookmarkAt(
|
||||
state: MediaBookmarksState,
|
||||
pageNumber: number,
|
||||
): MediaBookmark | null {
|
||||
return state.bookmarks.find((b) => b.pageNumber === pageNumber) || null;
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
// Copy selected text to clipboard (plain text, preserve line breaks)
|
||||
// Critical for technical textbooks with code examples
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
function setupPDFClipboard(container: HTMLElement): void {
|
||||
container.addEventListener("copy", (e) => {
|
||||
handlePDFCopy(e);
|
||||
});
|
||||
}
|
||||
|
||||
function handlePDFCopy(event: ClipboardEvent): void {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
|
||||
const selectedText = selection.toString();
|
||||
|
||||
if (!selectedText) return;
|
||||
|
||||
const plainText = formatPDFPlainText(selectedText);
|
||||
|
||||
event.clipboardData?.setData("text/plain", plainText);
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
showPDFCopyFeedback();
|
||||
}
|
||||
|
||||
function formatPDFPlainText(text: string): string {
|
||||
let formatted = text;
|
||||
|
||||
formatted = formatted.replace(/[ \t]+/g, " ");
|
||||
|
||||
formatted = formatted
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.join("\n");
|
||||
|
||||
formatted = formatted.replace(/\n{3,}/g, "\n\n");
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
async function copyPDFText(text: string): Promise<boolean> {
|
||||
const formatted = formatPDFPlainText(text);
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(formatted);
|
||||
showPDFCopyFeedback();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to copy text:", error);
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = formatted;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
const success = document.execCommand("copy");
|
||||
if (success) {
|
||||
showPDFCopyFeedback();
|
||||
}
|
||||
return success;
|
||||
} catch (fallbackError) {
|
||||
console.error("Fallback copy failed:", fallbackError);
|
||||
return false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showPDFCopyFeedback(): void {
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "pdf-copy-toast";
|
||||
toast.textContent = "Copied to clipboard";
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
z-index: 10000;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.animation = "fadeOut 0.2s ease-out";
|
||||
setTimeout(() => toast.remove(), 200);
|
||||
}, 1500);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
// Dual page spread view for PDFs
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
type DualPageMode = "single" | "dual";
|
||||
|
||||
interface PDFDualPageViewState {
|
||||
currentMode: DualPageMode;
|
||||
minViewportWidth: number;
|
||||
}
|
||||
|
||||
function createPDFDualPageView(
|
||||
container: HTMLElement,
|
||||
onModeChange: (mode: DualPageMode) => void,
|
||||
): PDFDualPageViewState {
|
||||
const state: PDFDualPageViewState = {
|
||||
currentMode: "single",
|
||||
minViewportWidth: 1200,
|
||||
};
|
||||
|
||||
setupResponsiveDualPageToggle(container, state, onModeChange);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function setupResponsiveDualPageToggle(
|
||||
container: HTMLElement,
|
||||
state: PDFDualPageViewState,
|
||||
onModeChange: (mode: DualPageMode) => void,
|
||||
): void {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
handleDualPageResize(container, state, onModeChange);
|
||||
});
|
||||
|
||||
resizeObserver.observe(container);
|
||||
}
|
||||
|
||||
function handleDualPageResize(
|
||||
container: HTMLElement,
|
||||
state: PDFDualPageViewState,
|
||||
onModeChange: (mode: DualPageMode) => void,
|
||||
): PDFDualPageViewState {
|
||||
const viewportWidth = window.innerWidth;
|
||||
|
||||
if (
|
||||
viewportWidth >= state.minViewportWidth &&
|
||||
state.currentMode === "single"
|
||||
) {
|
||||
if (!hasManualDualPageOverride()) {
|
||||
return setDualPageMode(container, state, "dual", false, onModeChange);
|
||||
}
|
||||
} else if (
|
||||
viewportWidth < state.minViewportWidth &&
|
||||
state.currentMode === "dual"
|
||||
) {
|
||||
return setDualPageMode(container, state, "single", false, onModeChange);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function setDualPageMode(
|
||||
container: HTMLElement,
|
||||
state: PDFDualPageViewState,
|
||||
mode: DualPageMode,
|
||||
manual: boolean,
|
||||
onModeChange: (mode: DualPageMode) => void,
|
||||
): PDFDualPageViewState {
|
||||
if (state.currentMode === mode) return state;
|
||||
|
||||
container.classList.remove("pdf-single-page", "pdf-dual-page");
|
||||
container.classList.add(
|
||||
mode === "dual" ? "pdf-dual-page" : "pdf-single-page",
|
||||
);
|
||||
|
||||
if (manual) {
|
||||
setManualDualPageOverride(mode);
|
||||
}
|
||||
|
||||
onModeChange(mode);
|
||||
|
||||
return { ...state, currentMode: mode };
|
||||
}
|
||||
|
||||
function toggleDualPageMode(
|
||||
container: HTMLElement,
|
||||
state: PDFDualPageViewState,
|
||||
onModeChange: (mode: DualPageMode) => void,
|
||||
): PDFDualPageViewState {
|
||||
const newMode = state.currentMode === "single" ? "dual" : "single";
|
||||
return setDualPageMode(container, state, newMode, true, onModeChange);
|
||||
}
|
||||
|
||||
function getDualPagePagePair(
|
||||
state: PDFDualPageViewState,
|
||||
currentPage: number,
|
||||
totalPages: number,
|
||||
): { left?: number; right: number } {
|
||||
if (state.currentMode === "single") {
|
||||
return { right: currentPage };
|
||||
}
|
||||
|
||||
if (currentPage % 2 === 1) {
|
||||
return {
|
||||
left: currentPage > 1 ? currentPage - 1 : undefined,
|
||||
right: currentPage,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
left: currentPage,
|
||||
right: currentPage < totalPages ? currentPage + 1 : currentPage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function hasManualDualPageOverride(): boolean {
|
||||
return localStorage.getItem("pdf-dual-page-manual") === "true";
|
||||
}
|
||||
|
||||
function setManualDualPageOverride(mode: DualPageMode): void {
|
||||
localStorage.setItem("pdf-dual-page-manual", "true");
|
||||
localStorage.setItem("pdf-dual-page-mode", mode);
|
||||
}
|
||||
|
||||
function getDualPageStyles(): string {
|
||||
return `
|
||||
.pdf-dual-page .pdf-page-container {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.pdf-dual-page .pdf-scroll-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pdf-single-page .pdf-page-container {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
// Handle internal PDF links (cross-references, citations, TOC links)
|
||||
// External links open in new tab
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
interface PDFLink {
|
||||
url: string;
|
||||
pageNumber?: number;
|
||||
bounds: { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
interface PDFLinkHandlerState {
|
||||
doc: PDFDocumentProxy | null;
|
||||
container: HTMLElement;
|
||||
onPageNavigate: (pageNumber: number) => void;
|
||||
}
|
||||
|
||||
async function initializePDFLinkHandler(
|
||||
container: HTMLElement,
|
||||
onPageNavigate: (pageNumber: number) => void,
|
||||
doc: PDFDocumentProxy,
|
||||
): Promise<PDFLinkHandlerState> {
|
||||
const state: PDFLinkHandlerState = {
|
||||
doc,
|
||||
container,
|
||||
onPageNavigate,
|
||||
};
|
||||
|
||||
await setupPDFLinks(state);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
async function setupPDFLinks(state: PDFLinkHandlerState): Promise<void> {
|
||||
if (!state.doc) return;
|
||||
|
||||
for (let pageNum = 1; pageNum <= state.doc.numPages; pageNum++) {
|
||||
const page = await state.doc.getPage(pageNum);
|
||||
const annotations = await page.getAnnotations();
|
||||
|
||||
for (const annotation of annotations) {
|
||||
if (annotation.subtype === "Link") {
|
||||
createPDFLinkElement(state, annotation, pageNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createPDFLinkElement(
|
||||
state: PDFLinkHandlerState,
|
||||
annotation: any,
|
||||
pageNumber: number,
|
||||
): void {
|
||||
const pageElement = state.container.querySelector(
|
||||
`[data-page-number="${pageNumber}"]`,
|
||||
);
|
||||
if (!pageElement) return;
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.className = "pdf-internal-link";
|
||||
link.href = "javascript:void(0)";
|
||||
|
||||
if (annotation.rect) {
|
||||
const rect = annotation.rect;
|
||||
link.style.position = "absolute";
|
||||
link.style.left = `${rect[0]}px`;
|
||||
link.style.top = `${rect[1]}px`;
|
||||
link.style.width = `${rect[2] - rect[0]}px`;
|
||||
link.style.height = `${rect[3] - rect[1]}px`;
|
||||
link.style.cursor = "pointer";
|
||||
}
|
||||
|
||||
link.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
handlePDFLinkClick(state, annotation);
|
||||
});
|
||||
|
||||
pageElement.appendChild(link);
|
||||
}
|
||||
|
||||
async function handlePDFLinkClick(
|
||||
state: PDFLinkHandlerState,
|
||||
annotation: any,
|
||||
): Promise<void> {
|
||||
if (!state.doc) return;
|
||||
|
||||
if (annotation.url) {
|
||||
if (
|
||||
annotation.url.startsWith("http://") ||
|
||||
annotation.url.startsWith("https://")
|
||||
) {
|
||||
window.open(annotation.url, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
console.warn("Unhandled URL:", annotation.url);
|
||||
}
|
||||
} else if (annotation.dest) {
|
||||
const pageNumber = await resolvePDFLinkDestination(state, annotation.dest);
|
||||
state.onPageNavigate(pageNumber);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePDFLinkDestination(
|
||||
state: PDFLinkHandlerState,
|
||||
dest: string | any[],
|
||||
): Promise<number> {
|
||||
if (!state.doc) return 1;
|
||||
|
||||
try {
|
||||
let explicitDest: any[];
|
||||
|
||||
if (typeof dest === "string") {
|
||||
const destObj = await state.doc.getDestination(dest);
|
||||
if (!destObj) return 1;
|
||||
explicitDest = destObj;
|
||||
} else {
|
||||
explicitDest = dest;
|
||||
}
|
||||
|
||||
const ref = explicitDest[0];
|
||||
|
||||
if (typeof ref === "object" && ref !== null) {
|
||||
const pageIndex = await state.doc.getPageIndex(ref);
|
||||
return pageIndex + 1;
|
||||
} else if (typeof ref === "number") {
|
||||
return ref + 1;
|
||||
}
|
||||
|
||||
return 1;
|
||||
} catch (error) {
|
||||
console.error("Failed to resolve link destination:", error);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
// Mini-map navigation for PDF pages
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
interface PDFMiniMapState {
|
||||
miniMap: HTMLElement;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
thumbnails: Map<number, HTMLCanvasElement>;
|
||||
onPageNavigate: (pageNumber: number) => void;
|
||||
}
|
||||
|
||||
function createPDFMiniMap(
|
||||
container: HTMLElement,
|
||||
onPageNavigate: (pageNumber: number) => void,
|
||||
): PDFMiniMapState {
|
||||
const miniMap = createMiniMapElement(container);
|
||||
container.appendChild(miniMap);
|
||||
|
||||
return {
|
||||
miniMap,
|
||||
currentPage: 1,
|
||||
totalPages: 0,
|
||||
thumbnails: new Map(),
|
||||
onPageNavigate,
|
||||
};
|
||||
}
|
||||
|
||||
function createMiniMapElement(container: HTMLElement): HTMLElement {
|
||||
const miniMap = document.createElement("div");
|
||||
miniMap.className = "pdf-minimap";
|
||||
miniMap.innerHTML = `
|
||||
<div class="pdf-minimap-header">Pages</div>
|
||||
<div class="pdf-minimap-thumbnails"></div>
|
||||
<div class="pdf-minimap-indicator"></div>
|
||||
`;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.textContent = getMiniMapStyles();
|
||||
miniMap.appendChild(style);
|
||||
|
||||
return miniMap;
|
||||
}
|
||||
|
||||
async function initializePDFMiniMap(
|
||||
state: PDFMiniMapState,
|
||||
totalPages: number,
|
||||
renderThumbnail: (page: number) => Promise<HTMLCanvasElement>,
|
||||
): Promise<PDFMiniMapState> {
|
||||
const newState = { ...state, totalPages };
|
||||
|
||||
await generateMiniMapThumbnails(newState, renderThumbnail);
|
||||
setupMiniMapEventListeners(newState);
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
async function generateMiniMapThumbnails(
|
||||
state: PDFMiniMapState,
|
||||
renderThumbnail: (page: number) => Promise<HTMLCanvasElement>,
|
||||
): Promise<void> {
|
||||
const container = state.miniMap.querySelector(
|
||||
".pdf-minimap-thumbnails",
|
||||
) as HTMLElement;
|
||||
container.innerHTML = "";
|
||||
|
||||
for (let page = 1; page <= state.totalPages; page++) {
|
||||
try {
|
||||
const thumbnail = await renderThumbnail(page);
|
||||
thumbnail.className = "pdf-minimap-thumbnail";
|
||||
thumbnail.dataset.pageNumber = page.toString();
|
||||
thumbnail.style.width = "80px";
|
||||
thumbnail.style.height = "auto";
|
||||
thumbnail.style.cursor = "pointer";
|
||||
thumbnail.style.marginBottom = "4px";
|
||||
|
||||
container.appendChild(thumbnail);
|
||||
state.thumbnails.set(page, thumbnail);
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate thumbnail for page ${page}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setupMiniMapEventListeners(state: PDFMiniMapState): void {
|
||||
const container = state.miniMap.querySelector(".pdf-minimap-thumbnails");
|
||||
|
||||
container?.addEventListener("click", (e) => {
|
||||
const thumbnail = (e.target as HTMLElement).closest(
|
||||
".pdf-minimap-thumbnail",
|
||||
) as HTMLElement;
|
||||
if (thumbnail) {
|
||||
const pageNumber = parseInt(thumbnail.dataset.pageNumber || "1");
|
||||
state.onPageNavigate(pageNumber);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateMiniMapCurrentPage(
|
||||
state: PDFMiniMapState,
|
||||
pageNumber: number,
|
||||
): PDFMiniMapState {
|
||||
const indicator = state.miniMap.querySelector(
|
||||
".pdf-minimap-indicator",
|
||||
) as HTMLElement;
|
||||
const thumbnail = state.thumbnails.get(pageNumber);
|
||||
|
||||
if (thumbnail && indicator) {
|
||||
const rect = thumbnail.getBoundingClientRect();
|
||||
indicator.style.top = `${thumbnail.offsetTop}px`;
|
||||
indicator.style.height = `${rect.height}px`;
|
||||
}
|
||||
|
||||
state.thumbnails.forEach((thumb, page) => {
|
||||
if (page === pageNumber) {
|
||||
thumb.style.outline = "2px solid var(--accent)";
|
||||
thumb.style.opacity = "1";
|
||||
} else {
|
||||
thumb.style.outline = "none";
|
||||
thumb.style.opacity = "0.7";
|
||||
}
|
||||
});
|
||||
|
||||
return { ...state, currentPage: pageNumber };
|
||||
}
|
||||
|
||||
function showMiniMap(state: PDFMiniMapState): void {
|
||||
state.miniMap.style.display = "block";
|
||||
}
|
||||
|
||||
function hideMiniMap(state: PDFMiniMapState): void {
|
||||
state.miniMap.style.display = "none";
|
||||
}
|
||||
|
||||
function toggleMiniMap(state: PDFMiniMapState): void {
|
||||
const isVisible = state.miniMap.style.display !== "none";
|
||||
state.miniMap.style.display = isVisible ? "none" : "block";
|
||||
}
|
||||
|
||||
function getMiniMapStyles(): string {
|
||||
return `
|
||||
.pdf-minimap {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 100px;
|
||||
max-height: 80vh;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--text-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.pdf-minimap-header {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pdf-minimap-thumbnails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pdf-minimap-thumbnail {
|
||||
transition: outline 0.2s, opacity 0.2s;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.pdf-minimap-thumbnail:hover {
|
||||
opacity: 1 !important;
|
||||
outline: 1px solid var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.pdf-minimap-indicator {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-left: 3px solid var(--accent);
|
||||
pointer-events: none;
|
||||
transition: top 0.3s ease-out;
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -1,22 +1,27 @@
|
||||
// PDF navigation: page turning, zoom, fit modes
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
import { ReaderContext } from "../core/reader-context";
|
||||
|
||||
export function init(context: ReaderContext): void {
|
||||
let navState: PDFNavigationState | null = null;
|
||||
|
||||
context.events.on("reader:loaded", (detail: { container: HTMLElement; totalPages: number }) => {
|
||||
navState = {
|
||||
currentPage: 1,
|
||||
totalPages: detail.totalPages,
|
||||
currentScale: 1.0,
|
||||
fitMode: "fit-width",
|
||||
scrollContainer: detail.container.querySelector(".pdf-scroll-container") || detail.container,
|
||||
};
|
||||
setupPDFKeyboardNav(context, navState);
|
||||
setupPDFScrollTracking(context, navState);
|
||||
});
|
||||
context.events.on(
|
||||
"reader:loaded",
|
||||
(detail: { container: HTMLElement; totalPages: number }) => {
|
||||
navState = {
|
||||
currentPage: 1,
|
||||
totalPages: detail.totalPages,
|
||||
currentScale: 1.0,
|
||||
fitMode: "fit-width",
|
||||
scrollContainer:
|
||||
detail.container.querySelector(".pdf-scroll-container") ||
|
||||
detail.container,
|
||||
};
|
||||
setupPDFKeyboardNav(context, navState);
|
||||
setupPDFScrollTracking(context, navState);
|
||||
},
|
||||
);
|
||||
|
||||
context.events.on("pdf:navigate:to-page", (detail: { page: number }) => {
|
||||
if (navState) {
|
||||
@@ -54,11 +59,14 @@ export function init(context: ReaderContext): void {
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("pdf:fit:set", (detail: { mode: "fit-width" | "fit-page" | "fit-height" | "none" }) => {
|
||||
if (navState) {
|
||||
setPDFFitMode(navState, detail.mode, context);
|
||||
}
|
||||
});
|
||||
context.events.on(
|
||||
"pdf:fit:set",
|
||||
(detail: { mode: "fit-width" | "fit-page" | "fit-height" | "none" }) => {
|
||||
if (navState) {
|
||||
setPDFFitMode(navState, detail.mode, context);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
context.events.on("reader:unload", () => {
|
||||
navState = null;
|
||||
@@ -75,7 +83,11 @@ interface PDFNavigationState {
|
||||
scrollContainer: HTMLElement | null;
|
||||
}
|
||||
|
||||
function goToPDFPage(state: PDFNavigationState, pageNumber: number, context: ReaderContext): void {
|
||||
function goToPDFPage(
|
||||
state: PDFNavigationState,
|
||||
pageNumber: number,
|
||||
context: ReaderContext,
|
||||
): void {
|
||||
if (pageNumber < 1 || pageNumber > state.totalPages) return;
|
||||
|
||||
state.currentPage = pageNumber;
|
||||
@@ -89,7 +101,10 @@ function nextPDFPage(state: PDFNavigationState, context: ReaderContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
function previousPDFPage(state: PDFNavigationState, context: ReaderContext): void {
|
||||
function previousPDFPage(
|
||||
state: PDFNavigationState,
|
||||
context: ReaderContext,
|
||||
): void {
|
||||
if (state.currentPage > 1) {
|
||||
goToPDFPage(state, state.currentPage - 1, context);
|
||||
}
|
||||
@@ -106,14 +121,22 @@ function scrollToPDFPage(state: PDFNavigationState, pageNumber: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
function setPDFZoom(state: PDFNavigationState, scale: number, context: ReaderContext): void {
|
||||
function setPDFZoom(
|
||||
state: PDFNavigationState,
|
||||
scale: number,
|
||||
context: ReaderContext,
|
||||
): void {
|
||||
state.currentScale = scale;
|
||||
state.fitMode = "none";
|
||||
updatePDFZoom(state);
|
||||
context.events.emit("pdf:zoom-changed", { scale });
|
||||
}
|
||||
|
||||
function setPDFFitMode(state: PDFNavigationState, mode: PageFitMode, context: ReaderContext): void {
|
||||
function setPDFFitMode(
|
||||
state: PDFNavigationState,
|
||||
mode: PageFitMode,
|
||||
context: ReaderContext,
|
||||
): void {
|
||||
state.fitMode = mode;
|
||||
updatePDFZoom(state);
|
||||
context.events.emit("pdf:fit-changed", { mode });
|
||||
@@ -137,7 +160,10 @@ function updatePDFZoom(state: PDFNavigationState): void {
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
function setupPDFKeyboardNav(context: ReaderContext, state: PDFNavigationState): void {
|
||||
function setupPDFKeyboardNav(
|
||||
context: ReaderContext,
|
||||
state: PDFNavigationState,
|
||||
): void {
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
||||
nextPDFPage(state, context);
|
||||
@@ -151,7 +177,10 @@ function setupPDFKeyboardNav(context: ReaderContext, state: PDFNavigationState):
|
||||
});
|
||||
}
|
||||
|
||||
function setupPDFScrollTracking(context: ReaderContext, state: PDFNavigationState): void {
|
||||
function setupPDFScrollTracking(
|
||||
context: ReaderContext,
|
||||
state: PDFNavigationState,
|
||||
): void {
|
||||
if (!state.scrollContainer) return;
|
||||
|
||||
state.scrollContainer.addEventListener("scroll", () => {
|
||||
@@ -182,4 +211,5 @@ function getCurrentPDFPage(state: PDFNavigationState): number {
|
||||
}
|
||||
|
||||
return state.currentPage;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
// PDF outline/TOC navigation
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
interface PDFOutlineNode {
|
||||
id: string;
|
||||
title: string;
|
||||
destination: number | null;
|
||||
pageNumber?: number;
|
||||
children: PDFOutlineNode[];
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
interface PDFOutlineState {
|
||||
doc: PDFDocumentProxy | null;
|
||||
outline: PDFOutlineNode[];
|
||||
flatMap: Map<string, number>;
|
||||
}
|
||||
|
||||
async function initializePDFOutline(
|
||||
doc: PDFDocumentProxy,
|
||||
): Promise<PDFOutlineState> {
|
||||
const state: PDFOutlineState = {
|
||||
doc,
|
||||
outline: [],
|
||||
flatMap: new Map(),
|
||||
};
|
||||
|
||||
return await loadPDFOutline(state);
|
||||
}
|
||||
|
||||
async function loadPDFOutline(
|
||||
state: PDFOutlineState,
|
||||
): Promise<PDFOutlineState> {
|
||||
if (!state.doc) return state;
|
||||
|
||||
const pdfOutline = await state.doc.getOutline();
|
||||
|
||||
if (!pdfOutline || pdfOutline.length === 0) {
|
||||
return { ...state, outline: [] };
|
||||
}
|
||||
|
||||
const outline = await parseOutlineNodes(state, pdfOutline);
|
||||
|
||||
return { ...state, outline };
|
||||
}
|
||||
|
||||
async function parseOutlineNodes(
|
||||
state: PDFOutlineState,
|
||||
nodes: OutlineTreeNode[],
|
||||
): Promise<PDFOutlineNode[]> {
|
||||
const result: PDFOutlineNode[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
const outlineNode: PDFOutlineNode = {
|
||||
id: generateOutlineId(),
|
||||
title: node.title,
|
||||
destination: null,
|
||||
children: [],
|
||||
expanded: false,
|
||||
};
|
||||
|
||||
if (node.dest) {
|
||||
const pageNumber = await resolvePDFDestination(state, node.dest);
|
||||
outlineNode.destination = pageNumber;
|
||||
outlineNode.pageNumber = pageNumber;
|
||||
state.flatMap.set(node.title, pageNumber);
|
||||
}
|
||||
|
||||
if (node.items && node.items.length > 0) {
|
||||
outlineNode.children = await parseOutlineNodes(state, node.items);
|
||||
}
|
||||
|
||||
result.push(outlineNode);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function resolvePDFDestination(
|
||||
state: PDFOutlineState,
|
||||
dest: string | any[],
|
||||
): Promise<number> {
|
||||
if (!state.doc) return 1;
|
||||
|
||||
try {
|
||||
let explicitDest: any[];
|
||||
|
||||
if (typeof dest === "string") {
|
||||
const destObj = await state.doc.getDestination(dest);
|
||||
if (!destObj) return 1;
|
||||
explicitDest = destObj;
|
||||
} else {
|
||||
explicitDest = dest;
|
||||
}
|
||||
|
||||
const ref = explicitDest[0];
|
||||
|
||||
if (typeof ref === "object" && ref !== null) {
|
||||
const pageIndex = await state.doc.getPageIndex(ref);
|
||||
return pageIndex + 1;
|
||||
} else if (typeof ref === "number") {
|
||||
return ref + 1;
|
||||
}
|
||||
|
||||
return 1;
|
||||
} catch (error) {
|
||||
console.error("Failed to resolve destination:", dest, error);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function generateOutlineId(): string {
|
||||
return `outline-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
function getOutline(state: PDFOutlineState): PDFOutlineNode[] {
|
||||
return state.outline;
|
||||
}
|
||||
|
||||
function getOutlineFlatMap(state: PDFOutlineState): Map<string, number> {
|
||||
return state.flatMap;
|
||||
}
|
||||
|
||||
function getCurrentChapter(
|
||||
state: PDFOutlineState,
|
||||
pageNumber: number,
|
||||
): PDFOutlineNode | null {
|
||||
return findChapterForPage(state.outline, pageNumber);
|
||||
}
|
||||
|
||||
function findChapterForPage(
|
||||
nodes: PDFOutlineNode[],
|
||||
pageNumber: number,
|
||||
): PDFOutlineNode | null {
|
||||
for (const node of nodes) {
|
||||
if (node.pageNumber && node.pageNumber <= pageNumber) {
|
||||
if (node.children.length > 0) {
|
||||
const childMatch = findChapterForPage(node.children, pageNumber);
|
||||
if (childMatch) return childMatch;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
if (node.children.length > 0) {
|
||||
const childMatch = findChapterForPage(node.children, pageNumber);
|
||||
if (childMatch) return childMatch;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toggleOutlineNode(
|
||||
state: PDFOutlineState,
|
||||
nodeId: string,
|
||||
): PDFOutlineState {
|
||||
const updateNode = (nodes: PDFOutlineNode[]): PDFOutlineNode[] => {
|
||||
return nodes.map((node) => {
|
||||
if (node.id === nodeId) {
|
||||
return { ...node, expanded: !node.expanded };
|
||||
}
|
||||
if (node.children.length > 0) {
|
||||
return { ...node, children: updateNode(node.children) };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
|
||||
return { ...state, outline: updateNode(state.outline) };
|
||||
}
|
||||
|
||||
function findOutlineNode(
|
||||
nodes: PDFOutlineNode[],
|
||||
id: string,
|
||||
): PDFOutlineNode | null {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return node;
|
||||
if (node.children.length > 0) {
|
||||
const found = findOutlineNode(node.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
// Handle PDFs with variable page sizes
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
interface PageInfo {
|
||||
pageNumber: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
interface PDFPageSizesState {
|
||||
pageSizes: Map<number, PageInfo>;
|
||||
defaultSize: { width: number; height: number };
|
||||
}
|
||||
|
||||
function createPDFPageSizes(): PDFPageSizesState {
|
||||
return {
|
||||
pageSizes: new Map(),
|
||||
defaultSize: { width: 595, height: 842 },
|
||||
};
|
||||
}
|
||||
|
||||
async function loadPDFPageSizes(
|
||||
state: PDFPageSizesState,
|
||||
doc: any,
|
||||
): Promise<PDFPageSizesState> {
|
||||
const pageSizes = new Map<number, PageInfo>();
|
||||
|
||||
for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
|
||||
const page = await doc.getPage(pageNum);
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
|
||||
const pageInfo: PageInfo = {
|
||||
pageNumber: pageNum,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
rotation: viewport.rotation,
|
||||
};
|
||||
|
||||
pageSizes.set(pageNum, pageInfo);
|
||||
}
|
||||
|
||||
return { ...state, pageSizes };
|
||||
}
|
||||
|
||||
function getPDFPageSize(
|
||||
state: PDFPageSizesState,
|
||||
pageNumber: number,
|
||||
): PageInfo | null {
|
||||
return state.pageSizes.get(pageNumber) || null;
|
||||
}
|
||||
|
||||
function isPDFPageLandscape(
|
||||
state: PDFPageSizesState,
|
||||
pageNumber: number,
|
||||
): boolean {
|
||||
const size = getPDFPageSize(state, pageNumber);
|
||||
if (!size) return false;
|
||||
|
||||
const effectiveWidth =
|
||||
size.rotation === 90 || size.rotation === 270 ? size.height : size.width;
|
||||
const effectiveHeight =
|
||||
size.rotation === 90 || size.rotation === 270 ? size.width : size.height;
|
||||
|
||||
return effectiveWidth > effectiveHeight;
|
||||
}
|
||||
|
||||
function getPDFCommonSize(state: PDFPageSizesState): {
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
if (state.pageSizes.size === 0) {
|
||||
return state.defaultSize;
|
||||
}
|
||||
|
||||
const sizeGroups: Map<
|
||||
string,
|
||||
{ width: number; height: number; count: number }
|
||||
> = new Map();
|
||||
|
||||
state.pageSizes.forEach((size) => {
|
||||
const key = getPageSizeKey(size.width, size.height);
|
||||
const existing = sizeGroups.get(key);
|
||||
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
sizeGroups.set(key, { width: size.width, height: size.height, count: 1 });
|
||||
}
|
||||
});
|
||||
|
||||
let mostCommon = state.defaultSize;
|
||||
let maxCount = 0;
|
||||
|
||||
sizeGroups.forEach((size) => {
|
||||
if (size.count > maxCount) {
|
||||
maxCount = size.count;
|
||||
mostCommon = { width: size.width, height: size.height };
|
||||
}
|
||||
});
|
||||
|
||||
return mostCommon;
|
||||
}
|
||||
|
||||
function getPageSizeKey(width: number, height: number): string {
|
||||
const w = Math.round(width / 10) * 10;
|
||||
const h = Math.round(height / 10) * 10;
|
||||
return `${w}x${h}`;
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
// Handle rotated/landscape pages in PDFs
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
interface PDFRotationState {
|
||||
rotations: Map<number, number>;
|
||||
}
|
||||
|
||||
function createPDFRotation(): PDFRotationState {
|
||||
return {
|
||||
rotations: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadPDFPageRotations(
|
||||
state: PDFRotationState,
|
||||
doc: any,
|
||||
): Promise<PDFRotationState> {
|
||||
const rotations = new Map<number, number>();
|
||||
|
||||
for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
|
||||
const page = await doc.getPage(pageNum);
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
const rotation = viewport.rotation;
|
||||
|
||||
if (rotation !== 0) {
|
||||
rotations.set(pageNum, rotation);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...state, rotations };
|
||||
}
|
||||
|
||||
function getPDFPageRotation(
|
||||
state: PDFRotationState,
|
||||
pageNumber: number,
|
||||
): number {
|
||||
return state.rotations.get(pageNumber) || 0;
|
||||
}
|
||||
|
||||
function hasPDFPageRotation(
|
||||
state: PDFRotationState,
|
||||
pageNumber: number,
|
||||
): boolean {
|
||||
return state.rotations.has(pageNumber);
|
||||
}
|
||||
|
||||
function applyPDFRotation(
|
||||
state: PDFRotationState,
|
||||
canvas: HTMLCanvasElement,
|
||||
pageNumber: number,
|
||||
): void {
|
||||
const rotation = getPDFPageRotation(state, pageNumber);
|
||||
|
||||
if (rotation === 0) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2);
|
||||
ctx.rotate((rotation * Math.PI) / 180);
|
||||
ctx.translate(-canvas.width / 2, -canvas.height / 2);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function getPDFAdjustedViewport(
|
||||
state: PDFRotationState,
|
||||
pageNumber: number,
|
||||
viewport: any,
|
||||
): any {
|
||||
const rotation = getPDFPageRotation(state, pageNumber);
|
||||
|
||||
if (rotation === 0 || rotation === 180) {
|
||||
return viewport;
|
||||
}
|
||||
|
||||
return {
|
||||
...viewport,
|
||||
width: viewport.height,
|
||||
height: viewport.width,
|
||||
};
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
// Full-text search within PDF documents
|
||||
|
||||
import { PDFDocumentProxy } from "pdfjs-dist";
|
||||
|
||||
interface SearchResult {
|
||||
pageNumber: number;
|
||||
text: string;
|
||||
index: number;
|
||||
context: string;
|
||||
}
|
||||
|
||||
// Full-text search within PDF documents
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
interface SearchResult {
|
||||
pageNumber: number;
|
||||
text: string;
|
||||
index: number;
|
||||
context: string;
|
||||
}
|
||||
|
||||
interface PDFSearchState {
|
||||
doc: PDFDocumentProxy | null;
|
||||
searchResults: SearchResult[];
|
||||
currentResultIndex: number;
|
||||
}
|
||||
|
||||
async function initializePDFSearch(
|
||||
doc: PDFDocumentProxy,
|
||||
): Promise<PDFSearchState> {
|
||||
return {
|
||||
doc,
|
||||
searchResults: [],
|
||||
currentResultIndex: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function searchPDF(
|
||||
state: PDFSearchState,
|
||||
query: string,
|
||||
): Promise<PDFSearchState> {
|
||||
if (!state.doc) return state;
|
||||
|
||||
const searchResults: SearchResult[] = [];
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
for (let pageNum = 1; pageNum <= state.doc.numPages; pageNum++) {
|
||||
const page = await state.doc.getPage(pageNum);
|
||||
const textContent = await page.getTextContent();
|
||||
|
||||
let fullText = "";
|
||||
const textItems = textContent.items.map((item) => {
|
||||
if (typeof item === "string") return "";
|
||||
fullText += item.str;
|
||||
return item.str;
|
||||
});
|
||||
|
||||
const pageText = textItems.join(" ");
|
||||
const matches = findSearchMatches(pageText, lowerQuery, pageNum);
|
||||
|
||||
searchResults.push(...matches);
|
||||
}
|
||||
|
||||
return { ...state, searchResults };
|
||||
}
|
||||
|
||||
function findSearchMatches(
|
||||
text: string,
|
||||
query: string,
|
||||
pageNumber: number,
|
||||
): SearchResult[] {
|
||||
const matches: SearchResult[] = [];
|
||||
const lowerText = text.toLowerCase();
|
||||
let index = 0;
|
||||
|
||||
while ((index = lowerText.indexOf(query, index)) !== -1) {
|
||||
const start = Math.max(0, index - 50);
|
||||
const end = Math.min(text.length, index + query.length + 50);
|
||||
const context = text.slice(start, end);
|
||||
|
||||
matches.push({
|
||||
pageNumber,
|
||||
text: text.slice(index, index + query.length),
|
||||
index,
|
||||
context,
|
||||
});
|
||||
|
||||
index += query.length;
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
function goToNextSearchResult(
|
||||
state: PDFSearchState,
|
||||
): PDFSearchState & { result: SearchResult | null } {
|
||||
if (state.searchResults.length === 0) {
|
||||
return { ...state, result: null };
|
||||
}
|
||||
|
||||
const newIndex = (state.currentResultIndex + 1) % state.searchResults.length;
|
||||
return {
|
||||
...state,
|
||||
currentResultIndex: newIndex,
|
||||
result: state.searchResults[newIndex],
|
||||
};
|
||||
}
|
||||
|
||||
function goToPreviousSearchResult(
|
||||
state: PDFSearchState,
|
||||
): PDFSearchState & { result: SearchResult | null } {
|
||||
if (state.searchResults.length === 0) {
|
||||
return { ...state, result: null };
|
||||
}
|
||||
|
||||
const newIndex =
|
||||
(state.currentResultIndex - 1 + state.searchResults.length) %
|
||||
state.searchResults.length;
|
||||
return {
|
||||
...state,
|
||||
currentResultIndex: newIndex,
|
||||
result: state.searchResults[newIndex],
|
||||
};
|
||||
}
|
||||
|
||||
function getSearchResultCount(state: PDFSearchState): number {
|
||||
return state.searchResults.length;
|
||||
}
|
||||
|
||||
function clearSearchResults(state: PDFSearchState): PDFSearchState {
|
||||
return {
|
||||
...state,
|
||||
searchResults: [],
|
||||
currentResultIndex: 0,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// PDF text selection - Uses backend API for highlight creation
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
import { ReaderContext } from "../core/reader-context";
|
||||
|
||||
export function init(context: ReaderContext): void {
|
||||
let currentMediaItemId: string | null = null;
|
||||
@@ -15,22 +15,32 @@ export function init(context: ReaderContext): void {
|
||||
context.events.emit("pdf:selection-current", selection);
|
||||
});
|
||||
|
||||
context.events.on("pdf:highlight:create", async (detail: { selection: PDFTextSelection; color: string }) => {
|
||||
if (currentMediaItemId) {
|
||||
try {
|
||||
const highlight = await createPDFHighlight(currentMediaItemId, detail.selection, detail.color);
|
||||
context.events.emit("pdf:highlight-created", highlight);
|
||||
} catch (error) {
|
||||
console.error("Failed to create highlight:", error);
|
||||
context.events.on(
|
||||
"pdf:highlight:create",
|
||||
async (detail: { selection: PDFTextSelection; color: string }) => {
|
||||
if (currentMediaItemId) {
|
||||
try {
|
||||
const highlight = await createPDFHighlight(
|
||||
currentMediaItemId,
|
||||
detail.selection,
|
||||
detail.color,
|
||||
);
|
||||
context.events.emit("pdf:highlight-created", highlight);
|
||||
} catch (error) {
|
||||
console.error("Failed to create highlight:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
context.events.on("pdf:highlights:load", async (detail: { container: HTMLElement }) => {
|
||||
if (currentMediaItemId) {
|
||||
await loadAndRenderPDFHighlights(currentMediaItemId, detail.container);
|
||||
}
|
||||
});
|
||||
context.events.on(
|
||||
"pdf:highlights:load",
|
||||
async (detail: { container: HTMLElement }) => {
|
||||
if (currentMediaItemId) {
|
||||
await loadAndRenderPDFHighlights(currentMediaItemId, detail.container);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
context.events.on("reader:unload", () => {
|
||||
currentMediaItemId = null;
|
||||
@@ -52,8 +62,9 @@ export function getPDFTextSelection(): PDFTextSelection | null {
|
||||
|
||||
if (!text) return null;
|
||||
|
||||
const pageElement =
|
||||
range.commonAncestorContainer.parentElement?.closest?.("[data-page-number]") as HTMLElement;
|
||||
const pageElement = range.commonAncestorContainer.parentElement?.closest?.(
|
||||
"[data-page-number]",
|
||||
) as HTMLElement;
|
||||
const pageNumber = pageElement?.dataset.pageNumber
|
||||
? parseInt(pageElement.dataset.pageNumber)
|
||||
: getCurrentPDFPage();
|
||||
@@ -114,10 +125,7 @@ export async function loadAndRenderPDFHighlights(
|
||||
}
|
||||
}
|
||||
|
||||
function renderPDFHighlight(
|
||||
container: HTMLElement,
|
||||
highlight: any,
|
||||
): void {
|
||||
function renderPDFHighlight(container: HTMLElement, highlight: any): void {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "pdf-highlight-annotation";
|
||||
overlay.dataset.highlightId = highlight.id;
|
||||
@@ -150,5 +158,8 @@ function parseColor(color: string): string {
|
||||
|
||||
function getCurrentPDFPage(): number {
|
||||
const pageElement = document.querySelector("[data-page-number]");
|
||||
return pageElement ? parseInt(pageElement.getAttribute("data-page-number") || "1") : 1;
|
||||
}
|
||||
return pageElement
|
||||
? parseInt(pageElement.getAttribute("data-page-number") || "1")
|
||||
: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
// Mozilla pdf.js integration for PDF rendering
|
||||
// Procedural style: Functions, not classes
|
||||
|
||||
import * as pdfjsLib from "pdfjs-dist";
|
||||
|
||||
// ============================================================
|
||||
// PDF.js Configuration
|
||||
// ============================================================
|
||||
|
||||
export function configurePDFJS(): void {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = "/static/js/pdf.worker.min.mjs";
|
||||
pdfjsLib.GlobalWorkerOptions.standardFontDataUrl = "/static/standard_fonts/";
|
||||
pdfjsLib.GlobalWorkerOptions.cMapUrl = "/static/cmaps/";
|
||||
pdfjsLib.GlobalWorkerOptions.cMapPacked = true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PDF Document State
|
||||
// ============================================================
|
||||
|
||||
interface PDFDocumentState {
|
||||
doc: pdfjsLib.PDFDocumentProxy | null;
|
||||
pages: Map<number, pdfjsLib.PDFPageProxy>;
|
||||
metadata: PDFMetadata | null;
|
||||
}
|
||||
|
||||
interface PDFMetadata {
|
||||
title: string;
|
||||
author: string;
|
||||
subject?: string;
|
||||
keywords?: string;
|
||||
creator?: string;
|
||||
producer?: string;
|
||||
creationDate?: Date;
|
||||
modificationDate?: Date;
|
||||
pageCount: number;
|
||||
}
|
||||
|
||||
let pdfState: PDFDocumentState = {
|
||||
doc: null,
|
||||
pages: new Map(),
|
||||
metadata: null,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Document Loading
|
||||
// ============================================================
|
||||
|
||||
export async function loadPDFDocument(pdfBlob: Blob): Promise<PDFMetadata> {
|
||||
// Cleanup previous document
|
||||
unloadPDFDocument();
|
||||
|
||||
const arrayBuffer = await pdfBlob.arrayBuffer();
|
||||
const loadingTask = pdfjsLib.getDocument({
|
||||
data: arrayBuffer,
|
||||
});
|
||||
|
||||
pdfState.doc = await loadingTask.promise;
|
||||
|
||||
// Extract metadata
|
||||
const metadata = await pdfState.doc.getMetadata().catch(() => null);
|
||||
const info = metadata?.info || {};
|
||||
|
||||
pdfState.metadata = {
|
||||
title: info.Title || "Untitled",
|
||||
author: info.Author || "Unknown",
|
||||
subject: info.Subject,
|
||||
keywords: info.Keywords,
|
||||
creator: info.Creator,
|
||||
producer: info.Producer,
|
||||
creationDate: info.CreationDate ? new Date(info.CreationDate) : undefined,
|
||||
modificationDate: info.ModDate ? new Date(info.ModDate) : undefined,
|
||||
pageCount: pdfState.doc.numPages,
|
||||
};
|
||||
|
||||
return pdfState.metadata;
|
||||
}
|
||||
|
||||
export async function getPDFPage(
|
||||
pageNumber: number,
|
||||
): Promise<pdfjsLib.PDFPageProxy> {
|
||||
if (!pdfState.doc) {
|
||||
throw new Error("PDF document not loaded");
|
||||
}
|
||||
|
||||
// Check cache
|
||||
if (pdfState.pages.has(pageNumber)) {
|
||||
return pdfState.pages.get(pageNumber)!;
|
||||
}
|
||||
|
||||
// Load page
|
||||
const page = await pdfState.doc.getPage(pageNumber);
|
||||
pdfState.pages.set(pageNumber, page);
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Reader Initialization
|
||||
// ============================================================
|
||||
interface ReaderMetadata {
|
||||
media_item_id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
cover_image_path: string;
|
||||
library_type: "ebook" | "comic" | "manga" | "pdf";
|
||||
mime_type: string;
|
||||
file_path: string;
|
||||
total_pages?: number;
|
||||
}
|
||||
interface PDFReader {
|
||||
type: "pdf";
|
||||
doc: any;
|
||||
currentPage: number;
|
||||
}
|
||||
export async function initializePDFReader(
|
||||
metadata: ReaderMetadata,
|
||||
): Promise<PDFReader> {
|
||||
configurePDFJS();
|
||||
const response = await fetch(metadata.file_path);
|
||||
const pdfBlob = await response.blob();
|
||||
const pdfMetadata = await loadPDFDocument(pdfBlob);
|
||||
return {
|
||||
type: "pdf",
|
||||
doc: pdfState.doc,
|
||||
currentPage: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPDFPageText(pageNumber: number): Promise<any> {
|
||||
const page = await getPDFPage(pageNumber);
|
||||
return await page.getTextContent();
|
||||
}
|
||||
|
||||
export function getPDFMetadata(): PDFMetadata | null {
|
||||
return pdfState.metadata;
|
||||
}
|
||||
|
||||
export function getPDFPageCount(): number {
|
||||
return pdfState.doc?.numPages || 0;
|
||||
}
|
||||
|
||||
export function unloadPDFDocument(): void {
|
||||
pdfState.pages.clear();
|
||||
pdfState.doc = null;
|
||||
pdfState.metadata = null;
|
||||
}
|
||||
|
||||
export function unloadPDFPage(pageNumber: number): void {
|
||||
pdfState.pages.delete(pageNumber);
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
// Text layer rendering for PDF text selection and highlighting
|
||||
// Procedural style: Functions, not classes
|
||||
|
||||
// ============================================================
|
||||
// Render Functions
|
||||
// ============================================================
|
||||
|
||||
export function renderTextLayer(
|
||||
container: HTMLElement,
|
||||
viewport: any,
|
||||
textContent: any,
|
||||
config: TextLayerConfig,
|
||||
): void {
|
||||
// Clear container
|
||||
container.innerHTML = "";
|
||||
|
||||
// Apply styles
|
||||
applyTextLayerStyles(container, config);
|
||||
|
||||
// Render text items
|
||||
const { items } = textContent;
|
||||
|
||||
items.forEach((item: any, index: number) => {
|
||||
if (typeof item === "string") return;
|
||||
|
||||
const textDiv = createTextDiv(item, viewport, index);
|
||||
container.appendChild(textDiv);
|
||||
});
|
||||
}
|
||||
|
||||
function createTextDiv(item: any, viewport: any, index: number): HTMLElement {
|
||||
const div = document.createElement("div");
|
||||
div.className = "pdf-text-layer-text";
|
||||
div.textContent = item.str;
|
||||
div.dataset.index = index.toString();
|
||||
|
||||
// Position the text div
|
||||
const tx = pdfjsLib.Util.transform(viewport.transform, item.transform);
|
||||
|
||||
const fontSize = Math.sqrt(tx[0] * tx[0] + tx[1] * tx[1]);
|
||||
|
||||
div.style.left = `${tx[4]}px`;
|
||||
div.style.top = `${tx[5] - fontSize}px`;
|
||||
div.style.fontSize = `${fontSize}px`;
|
||||
div.style.fontFamily = item.fontName || "sans-serif";
|
||||
|
||||
// Handle text direction
|
||||
if (item.dir === "ttb") {
|
||||
div.style.writingMode = "vertical-rl";
|
||||
}
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
interface TextLayerConfig {
|
||||
theme: "light" | "sepia" | "dark" | "night" | "high-contrast";
|
||||
}
|
||||
|
||||
function applyTextLayerStyles(
|
||||
container: HTMLElement,
|
||||
config: TextLayerConfig,
|
||||
): void {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = getTextLayerCSS(config.theme);
|
||||
container.appendChild(style);
|
||||
}
|
||||
|
||||
function getTextLayerCSS(theme: string): string {
|
||||
const colors = getThemeColors(theme);
|
||||
|
||||
return `
|
||||
.pdf-text-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
line-height: 1;
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pdf-text-layer-text {
|
||||
position: absolute;
|
||||
white-space: pre;
|
||||
cursor: text;
|
||||
transform-origin: 0% 0%;
|
||||
color: transparent;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.pdf-text-layer-text::selection {
|
||||
background: ${colors.highlight};
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.pdf-text-layer-text::-moz-selection {
|
||||
background: ${colors.highlight};
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.pdf-highlight-overlay {
|
||||
position: absolute;
|
||||
background-color: ${colors.highlight};
|
||||
mix-blend-mode: multiply;
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function getThemeColors(theme: string): { highlight: string } {
|
||||
const themes: Record<string, { highlight: string }> = {
|
||||
light: { highlight: "rgba(255, 255, 0, 0.3)" },
|
||||
sepia: { highlight: "rgba(255, 200, 0, 0.4)" },
|
||||
dark: { highlight: "rgba(255, 255, 0, 0.3)" },
|
||||
night: { highlight: "rgba(100, 150, 255, 0.3)" },
|
||||
"high-contrast": { highlight: "rgba(255, 255, 0, 0.5)" },
|
||||
};
|
||||
|
||||
return themes[theme] || themes["dark"];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Selection Functions
|
||||
// ============================================================
|
||||
|
||||
export function getPDFTextSelection(): { text: string; range: Range } | null {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return null;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const text = range.toString();
|
||||
|
||||
if (!text) return null;
|
||||
|
||||
return { text, range };
|
||||
}
|
||||
|
||||
export function getPDFSelectionRects(): DOMRect[] {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return [];
|
||||
|
||||
const rects: DOMRect[] = [];
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
for (const rect of range.getClientRects()) {
|
||||
rects.push(rect);
|
||||
}
|
||||
|
||||
return rects;
|
||||
}
|
||||
Reference in New Issue
Block a user