refactor(reader): create modular format-specific architecture
Implement complete modularization of reader code by separating format-specific functionality into dedicated modules. This replaces the monolithic structure with a clean, maintainable architecture that separates concerns by format type. ## New Architecture ### Format-Specific Modules - **formats/reflowable/**: EPUB, FB2, TXT, HTML (page-based pagination) - types.ts: Shared type definitions for reflowable formats - page-calculator.ts: Word-count based pagination with HTML slicing - navigation.ts: Page-based navigation logic - progress-tracker.ts: CFI-based progress tracking - content-renderer.ts: DOM rendering for page content - parser.ts: Unified parser interface for all reflowable formats - ebook/**: Migrated ebook-specific features - **formats/pdf/**: PDF format support - Core PDF functionality (navigation, text selection, annotations) - Advanced features (bookmarks, search, outlines, dual-page) - Page cache and rendering optimizations - **formats/comic/**: Comic format support - Background color, chapter markers, page caching - Page ordering, gap adjustments - **formats/manga/**: Manga format support - RTL navigation, vertical scrolling, reading direction ## Key Improvements 1. **Separation of Concerns**: Each format has its own dedicated module 2. **No Circular Dependencies**: Clean import structure 3. **Type Safety**: Comprehensive TypeScript types throughout 4. **Functional Programming**: Pure functions, no OOP complexity 5. **Scalability**: Easy to add new formats without touching core code ## Migration Path - Old format-specific code in reader/, ebook/, pdf/, comic/, manga/ - New code in formats/[format]/ structure - Maintains backward compatibility during transition - Core reader logic remains format-agnostic This change enables the implementation of page-based pagination for reflowable formats while keeping PDF, comic, and manga functionality unchanged.
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
// Background color options for manga/comics
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
|
||||
export function init(context: ReaderContext): void {
|
||||
const state = createBackgroundColorState();
|
||||
applyBackgroundColor(state.current);
|
||||
|
||||
context.events.on("background-color:set", (detail: { color: BackgroundColor; customColor?: string }) => {
|
||||
setBackgroundColor(state, detail.color, detail.customColor);
|
||||
});
|
||||
|
||||
context.events.on("background-color:toggle", () => {
|
||||
toggleBackgroundColor(state);
|
||||
});
|
||||
|
||||
context.events.on("ui:show-settings", (detail: { container: HTMLElement }) => {
|
||||
renderBackgroundColorPicker(detail.container, state);
|
||||
});
|
||||
|
||||
context.events.on("reader:unload", () => {
|
||||
const picker = document.querySelector(".background-color-picker");
|
||||
picker?.remove();
|
||||
});
|
||||
}
|
||||
|
||||
type BackgroundColor = "black" | "white" | "gray" | "sepia" | "custom";
|
||||
|
||||
interface BackgroundColorState {
|
||||
current: BackgroundColor;
|
||||
customColor: string;
|
||||
}
|
||||
|
||||
const backgroundColors: Record<BackgroundColor, string> = {
|
||||
black: "#000000",
|
||||
white: "#ffffff",
|
||||
gray: "#333333",
|
||||
sepia: "#f4ecd8",
|
||||
custom: "",
|
||||
};
|
||||
|
||||
function createBackgroundColorState(
|
||||
initial: BackgroundColor = "black",
|
||||
): BackgroundColorState {
|
||||
const saved = localStorage.getItem("reader-background-color") as BackgroundColor;
|
||||
return {
|
||||
current: saved || initial,
|
||||
customColor: "#000000",
|
||||
};
|
||||
}
|
||||
|
||||
function applyBackgroundColor(color: BackgroundColor): void {
|
||||
const bgColor = backgroundColors[color];
|
||||
document.documentElement.style.setProperty("--reader-bg-color", bgColor);
|
||||
const viewer = document.querySelector(".reader-content") as HTMLElement;
|
||||
if (viewer) {
|
||||
viewer.style.backgroundColor = bgColor;
|
||||
}
|
||||
}
|
||||
|
||||
function setBackgroundColor(
|
||||
state: BackgroundColorState,
|
||||
color: BackgroundColor,
|
||||
customColor?: string,
|
||||
): BackgroundColorState {
|
||||
state.current = color;
|
||||
state.customColor = customColor || state.customColor;
|
||||
|
||||
const bgColor = color === "custom" ? state.customColor : backgroundColors[color];
|
||||
document.documentElement.style.setProperty("--reader-bg-color", bgColor);
|
||||
|
||||
const viewer = document.querySelector(".reader-content") as HTMLElement;
|
||||
if (viewer) {
|
||||
viewer.style.backgroundColor = bgColor;
|
||||
}
|
||||
|
||||
localStorage.setItem("reader-background-color", color);
|
||||
|
||||
const picker = document.querySelector(".background-color-picker");
|
||||
if (picker) {
|
||||
updateBackgroundColorUI(picker as HTMLElement, state);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function toggleBackgroundColor(state: BackgroundColorState): BackgroundColorState {
|
||||
const order: BackgroundColor[] = ["black", "white", "gray", "sepia"];
|
||||
const currentIndex = order.indexOf(state.current);
|
||||
const nextIndex = (currentIndex + 1) % order.length;
|
||||
return setBackgroundColor(state, order[nextIndex]);
|
||||
}
|
||||
|
||||
function renderBackgroundColorPicker(
|
||||
container: HTMLElement,
|
||||
state: BackgroundColorState,
|
||||
): void {
|
||||
const existing = container.querySelector(".background-color-picker");
|
||||
existing?.remove();
|
||||
|
||||
const picker = document.createElement("div");
|
||||
picker.className =
|
||||
"background-color-picker fixed bottom-24 left-4 bg-gray-900 bg-opacity-90 rounded-lg p-2 flex gap-2 z-40";
|
||||
|
||||
const colors: BackgroundColor[] = ["black", "white", "gray", "sepia"];
|
||||
|
||||
colors.forEach((color) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = `w-8 h-8 rounded-full border-2 ${
|
||||
state.current === color ? "border-blue-500" : "border-transparent"
|
||||
}`;
|
||||
btn.style.backgroundColor = backgroundColors[color];
|
||||
btn.title = color.charAt(0).toUpperCase() + color.slice(1);
|
||||
btn.addEventListener("click", () => {
|
||||
setBackgroundColor(state, color);
|
||||
updateBackgroundColorUI(picker, state);
|
||||
});
|
||||
picker.appendChild(btn);
|
||||
});
|
||||
|
||||
container.appendChild(picker);
|
||||
}
|
||||
|
||||
function updateBackgroundColorUI(
|
||||
container: HTMLElement,
|
||||
state: BackgroundColorState,
|
||||
): void {
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const colors: BackgroundColor[] = ["black", "white", "gray", "sepia"];
|
||||
|
||||
buttons.forEach((btn, index) => {
|
||||
btn.classList.toggle("border-blue-500", colors[index] === state.current);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Chapter markers for manga/comics
|
||||
// Visual indicators for chapter boundaries
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
|
||||
export function init(context: ReaderContext): void {
|
||||
let state: ChapterMarkerState | null = null;
|
||||
|
||||
context.events.on("reader:loaded", (detail: { chapters: ChapterInfo[]; currentPage: number }) => {
|
||||
state = createChapterMarkerState(detail.chapters, detail.currentPage);
|
||||
renderChapterMarkers(context.elements.readerContent, state);
|
||||
});
|
||||
|
||||
context.events.on("page-changed", (detail: { page: number }) => {
|
||||
if (state) {
|
||||
updateCurrentChapter(state, detail.page);
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("chapter-markers:toggle", () => {
|
||||
if (state) {
|
||||
toggleChapterMarkers(state);
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("chapter-markers:navigate", (detail: { chapterNumber: number }) => {
|
||||
if (state) {
|
||||
scrollToChapter(state, detail.chapterNumber);
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("reader:unload", () => {
|
||||
const markers = document.querySelector(".chapter-markers");
|
||||
markers?.remove();
|
||||
});
|
||||
}
|
||||
|
||||
interface ChapterInfo {
|
||||
chapterNumber: number;
|
||||
pageStart: number;
|
||||
pageEnd: number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface ChapterMarkerState {
|
||||
chapters: ChapterInfo[];
|
||||
currentChapter: number;
|
||||
showMarkers: boolean;
|
||||
}
|
||||
|
||||
function createChapterMarkerState(
|
||||
chapters: ChapterInfo[],
|
||||
currentPage: number,
|
||||
): ChapterMarkerState {
|
||||
const currentChapter =
|
||||
chapters.find((c) => currentPage >= c.pageStart && currentPage <= c.pageEnd)
|
||||
?.chapterNumber || 1;
|
||||
|
||||
return {
|
||||
chapters,
|
||||
currentChapter,
|
||||
showMarkers: true,
|
||||
};
|
||||
}
|
||||
|
||||
function renderChapterMarkers(
|
||||
container: HTMLElement,
|
||||
state: ChapterMarkerState,
|
||||
): void {
|
||||
if (!state.showMarkers) return;
|
||||
|
||||
const markersContainer = document.createElement("div");
|
||||
markersContainer.className =
|
||||
"chapter-markers absolute left-0 right-0 pointer-events-none z-10";
|
||||
|
||||
state.chapters.forEach((chapter) => {
|
||||
const marker = document.createElement("div");
|
||||
marker.className =
|
||||
"chapter-marker flex items-center gap-2 text-sm text-gray-400";
|
||||
|
||||
const isCurrentChapter = chapter.chapterNumber === state.currentChapter;
|
||||
|
||||
marker.style.position = "absolute";
|
||||
marker.style.top = `${((chapter.pageStart - 1) / 100) * 100}%`;
|
||||
marker.style.left = "10px";
|
||||
|
||||
marker.innerHTML = `
|
||||
<span class="chapter-number ${isCurrentChapter ? "text-blue-400 font-bold" : ""}">
|
||||
${chapter.title || `Chapter ${chapter.chapterNumber}`}
|
||||
</span>
|
||||
<span class="page-number text-xs">p.${chapter.pageStart}</span>
|
||||
${isCurrentChapter ? '<span class="current-indicator">←</span>' : ""}
|
||||
`;
|
||||
|
||||
markersContainer.appendChild(marker);
|
||||
});
|
||||
|
||||
const existing = container.querySelector(".chapter-markers");
|
||||
existing?.remove();
|
||||
container.appendChild(markersContainer);
|
||||
}
|
||||
|
||||
function updateCurrentChapter(
|
||||
state: ChapterMarkerState,
|
||||
currentPage: number,
|
||||
): ChapterMarkerState {
|
||||
const currentChapter =
|
||||
state.chapters.find(
|
||||
(c) => currentPage >= c.pageStart && currentPage <= c.pageEnd,
|
||||
)?.chapterNumber || state.currentChapter;
|
||||
|
||||
if (currentChapter !== state.currentChapter) {
|
||||
state.currentChapter = currentChapter;
|
||||
|
||||
const markers = document.querySelector(".chapter-markers");
|
||||
if (markers) {
|
||||
renderChapterMarkers(markers.parentElement!, state);
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function toggleChapterMarkers(state: ChapterMarkerState): ChapterMarkerState {
|
||||
state.showMarkers = !state.showMarkers;
|
||||
|
||||
const markers = document.querySelector(".chapter-markers");
|
||||
if (markers) {
|
||||
markers.classList.toggle("hidden", !state.showMarkers);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function scrollToChapter(
|
||||
state: ChapterMarkerState,
|
||||
chapterNumber: number,
|
||||
): void {
|
||||
const chapter = state.chapters.find((c) => c.chapterNumber === chapterNumber);
|
||||
if (chapter) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("navigate-to-page", {
|
||||
detail: { page: chapter.pageStart },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Comic/Manga Reader - Image-based pages
|
||||
// Handles CBZ, comic archives, image directories
|
||||
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 ComicReader {
|
||||
type: "comic";
|
||||
images: Blob[];
|
||||
currentPage: number;
|
||||
}
|
||||
interface MangaReader {
|
||||
type: "manga";
|
||||
images: Blob[];
|
||||
currentPage: number;
|
||||
readingDirection: "rtl" | "vertical";
|
||||
}
|
||||
// ============================================================
|
||||
// Comic Reader Initialization
|
||||
// ============================================================
|
||||
export async function initializeComicReader(
|
||||
metadata: ReaderMetadata,
|
||||
): Promise<ComicReader> {
|
||||
const response = await fetch(metadata.file_path);
|
||||
const archiveBlob = await response.blob();
|
||||
// Parse comic archive (CBZ) or image directory
|
||||
const images = await parseComicArchive(archiveBlob);
|
||||
return {
|
||||
type: "comic",
|
||||
images,
|
||||
currentPage: 1,
|
||||
};
|
||||
}
|
||||
// ============================================================
|
||||
// Manga Reader Initialization
|
||||
// ============================================================
|
||||
export async function initializeMangaReader(
|
||||
metadata: ReaderMetadata,
|
||||
): Promise<MangaReader> {
|
||||
const response = await fetch(metadata.file_path);
|
||||
const archiveBlob = await response.blob();
|
||||
const images = await parseComicArchive(archiveBlob);
|
||||
return {
|
||||
type: "manga",
|
||||
images,
|
||||
currentPage: 1,
|
||||
readingDirection: "rtl", // Default for manga
|
||||
};
|
||||
}
|
||||
// ============================================================
|
||||
// Comic Archive Parser
|
||||
// ============================================================
|
||||
async function parseComicArchive(archiveBlob: Blob): Promise<Blob[]> {
|
||||
const JSZip = (await import("jszip")).default;
|
||||
const zip = await JSZip.loadAsync(archiveBlob);
|
||||
const images: Blob[] = [];
|
||||
// Get all image files from archive
|
||||
const files = Object.keys(zip.files).filter((filename) =>
|
||||
filename.match(/\.(jpg|jpeg|png|gif|webp)$/i),
|
||||
);
|
||||
// Sort files naturally (page-01.jpg, page-02.jpg, etc.)
|
||||
files.sort((a, b) => {
|
||||
const aName = a.split("/").pop() || a;
|
||||
const bName = b.split("/").pop() || b;
|
||||
return aName.localeCompare(bName, undefined, { numeric: true });
|
||||
});
|
||||
// Extract images
|
||||
for (const file of files) {
|
||||
const fileData = await zip.file(file)?.async("blob");
|
||||
if (fileData) {
|
||||
images.push(fileData);
|
||||
}
|
||||
}
|
||||
return images;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// Lazy-loading page cache with 5-page ahead prefetch
|
||||
// Shared by both comic and manga readers
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
import { detectPanels } from "./panel-detection.service";
|
||||
|
||||
export function init(context: ReaderContext): void {
|
||||
let state: PageCacheState | null = null;
|
||||
|
||||
context.events.on("reader:loaded", (detail: { mediaItemId: string }) => {
|
||||
state = createPageCache(detail.mediaItemId);
|
||||
});
|
||||
|
||||
context.events.on("page-cache:get", async (detail: { pageNumber: number }) => {
|
||||
if (state) {
|
||||
const result = await getCachedPage(state, detail.pageNumber);
|
||||
context.events.emit("page-cache:loaded", {
|
||||
page: detail.pageNumber,
|
||||
image: result.page,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("page-cache:prefetch", (detail: { startPage: number }) => {
|
||||
if (state) {
|
||||
prefetchPages(state, detail.startPage);
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("page-cache:cleanup", (detail: { currentPage: number }) => {
|
||||
if (state) {
|
||||
cleanupPageCache(state, detail.currentPage);
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("page-cache:detected-panels", async (detail: { pageNumber: number }) => {
|
||||
if (state) {
|
||||
const panels = await detectPagePanels(state, detail.pageNumber);
|
||||
context.events.emit("page-cache:panels-ready", {
|
||||
pageNumber: detail.pageNumber,
|
||||
panels,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("reader:unload", () => {
|
||||
if (state) {
|
||||
state.cache.clear();
|
||||
state.loading.clear();
|
||||
state.panelData.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export interface PageCacheState {
|
||||
cache: Map<number, HTMLImageElement>;
|
||||
loading: Set<number>;
|
||||
maxAhead: number;
|
||||
mediaItemId: string;
|
||||
panelData: Map<number, { panels: any[]; method: string; confidence: number }>;
|
||||
}
|
||||
|
||||
export function createPageCache(mediaItemId: string): PageCacheState {
|
||||
return {
|
||||
cache: new Map(),
|
||||
loading: new Set(),
|
||||
maxAhead: 5,
|
||||
mediaItemId,
|
||||
panelData: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCachedPage(
|
||||
state: PageCacheState,
|
||||
pageNumber: number,
|
||||
): Promise<PageCacheState & { page: HTMLImageElement }> {
|
||||
if (state.cache.has(pageNumber)) {
|
||||
return { ...state, page: state.cache.get(pageNumber)! };
|
||||
}
|
||||
|
||||
if (state.loading.has(pageNumber)) {
|
||||
return new Promise((resolve) => {
|
||||
const checkInterval = setInterval(() => {
|
||||
if (state.cache.has(pageNumber)) {
|
||||
clearInterval(checkInterval);
|
||||
resolve({ ...state, page: state.cache.get(pageNumber)! });
|
||||
}
|
||||
}, 100);
|
||||
}) as Promise<PageCacheState & { page: HTMLImageElement }>;
|
||||
}
|
||||
|
||||
state.loading.add(pageNumber);
|
||||
|
||||
const img = await loadComicPage(state, pageNumber);
|
||||
|
||||
state.cache.set(pageNumber, img);
|
||||
state.loading.delete(pageNumber);
|
||||
|
||||
prefetchPages(state, pageNumber + 1);
|
||||
cleanupPageCache(state, pageNumber);
|
||||
|
||||
return { ...state, page: img };
|
||||
}
|
||||
|
||||
export async function loadComicPage(
|
||||
state: PageCacheState,
|
||||
pageNumber: number,
|
||||
): Promise<HTMLImageElement> {
|
||||
const token = localStorage.getItem("token");
|
||||
const response = await fetch(
|
||||
`/readers/${state.mediaItemId}/pages/${pageNumber}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load page ${pageNumber}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const img = new Image();
|
||||
img.src = URL.createObjectURL(blob);
|
||||
await new Promise((resolve) => {
|
||||
img.onload = resolve;
|
||||
});
|
||||
return img;
|
||||
}
|
||||
|
||||
export function prefetchPages(state: PageCacheState, startPage: number): void {
|
||||
for (let i = startPage; i < startPage + state.maxAhead; i++) {
|
||||
if (!state.cache.has(i) && !state.loading.has(i)) {
|
||||
loadComicPage(state, i).then((img) => {
|
||||
state.cache.set(i, img);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanupPageCache(
|
||||
state: PageCacheState,
|
||||
currentPage: number,
|
||||
): PageCacheState {
|
||||
const keepPages = 10;
|
||||
const newCache = new Map(state.cache);
|
||||
|
||||
for (const [page] of state.cache) {
|
||||
if (page < currentPage - keepPages) {
|
||||
newCache.delete(page);
|
||||
}
|
||||
}
|
||||
|
||||
state.cache = newCache;
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function detectPagePanels(
|
||||
state: PageCacheState,
|
||||
pageNumber: number,
|
||||
): Promise<any[]> {
|
||||
if (state.panelData?.has(pageNumber)) {
|
||||
return state.panelData.get(pageNumber)!.panels;
|
||||
}
|
||||
|
||||
let image: HTMLImageElement;
|
||||
if (state.cache.has(pageNumber)) {
|
||||
image = state.cache.get(pageNumber)!;
|
||||
} else {
|
||||
image = await loadComicPage(state, pageNumber);
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const result = await detectPanels(imageData, true);
|
||||
|
||||
if (!state.panelData) {
|
||||
state.panelData = new Map();
|
||||
}
|
||||
state.panelData.set(pageNumber, result);
|
||||
|
||||
return result.panels;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Page order presets for manga/comics
|
||||
// Auto-detect Japanese vs Western reading order
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
|
||||
export function init(context: ReaderContext): void {
|
||||
let state: PageOrderState | null = null;
|
||||
|
||||
context.events.on("reader:loaded", (detail: { totalPages: number; pageNames: string[] }) => {
|
||||
state = createPageOrderState(detail.totalPages, detail.pageNames);
|
||||
});
|
||||
|
||||
context.events.on("page-order:set", (detail: { mode: PageOrderMode }) => {
|
||||
if (state) {
|
||||
setPageOrderMode(state, detail.mode, context);
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("page-order:get", () => {
|
||||
if (state) {
|
||||
const order = getPageOrder(state);
|
||||
context.events.emit("page-order:current", { order });
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("page-order:reorder", (detail: { pageNumbers: number[] }) => {
|
||||
if (state) {
|
||||
const reordered = reorderPages(state, detail.pageNumbers);
|
||||
context.events.emit("page-order:reordered", { pages: reordered });
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("page-order:display-number", (detail: { actualPage: number }) => {
|
||||
if (state) {
|
||||
const displayPage = getDisplayPageNumber(state, detail.actualPage);
|
||||
context.events.emit("page-order:display-page", { displayPage });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type PageOrderMode = "auto" | "japanese" | "western";
|
||||
|
||||
interface PageOrderConfig {
|
||||
mode: PageOrderMode;
|
||||
detectedOrder: PageOrderMode;
|
||||
userOverride: boolean;
|
||||
}
|
||||
|
||||
interface PageOrderState {
|
||||
config: PageOrderConfig;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
function detectPageOrder(pageNames: string[]): PageOrderMode {
|
||||
if (pageNames.length < 2) return "western";
|
||||
|
||||
const firstPage = pageNames[0].toLowerCase();
|
||||
const lastPage = pageNames[pageNames.length - 1].toLowerCase();
|
||||
|
||||
const hasFrontCover = /cover|front|001/.test(firstPage);
|
||||
const hasBackCover = /back|end|最后的/.test(lastPage);
|
||||
|
||||
if (hasFrontCover && !hasBackCover) {
|
||||
return "western";
|
||||
}
|
||||
if (hasBackCover && !hasFrontCover) {
|
||||
return "japanese";
|
||||
}
|
||||
|
||||
const chapterMatches = pageNames.filter((n) => /ch-\d+|chapter/i.test(n));
|
||||
if (chapterMatches.length > 0) {
|
||||
const firstChapter = chapterMatches[0];
|
||||
const pageNum = parseInt(firstChapter.match(/\d+/)?.[0] || "0");
|
||||
return pageNum > 0 ? "western" : "japanese";
|
||||
}
|
||||
|
||||
return "western";
|
||||
}
|
||||
|
||||
function createPageOrderState(
|
||||
totalPages: number,
|
||||
pageNames: string[],
|
||||
): PageOrderState {
|
||||
const detectedOrder = detectPageOrder(pageNames);
|
||||
|
||||
return {
|
||||
config: {
|
||||
mode: "auto",
|
||||
detectedOrder,
|
||||
userOverride: false,
|
||||
},
|
||||
totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
function setPageOrderMode(
|
||||
state: PageOrderState,
|
||||
mode: PageOrderMode,
|
||||
context: ReaderContext,
|
||||
): PageOrderState {
|
||||
state.config = {
|
||||
...state.config,
|
||||
mode,
|
||||
userOverride: mode !== "auto",
|
||||
};
|
||||
|
||||
const order = getPageOrder(state);
|
||||
context.events.emit("page-order:changed", { mode, order });
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function getPageOrder(state: PageOrderState): PageOrderMode {
|
||||
if (state.config.mode === "auto") {
|
||||
return state.config.detectedOrder;
|
||||
}
|
||||
return state.config.mode;
|
||||
}
|
||||
|
||||
function reorderPages(state: PageOrderState, pageNumbers: number[]): number[] {
|
||||
const order = getPageOrder(state);
|
||||
|
||||
if (order === "japanese") {
|
||||
return [...pageNumbers].reverse();
|
||||
}
|
||||
|
||||
return pageNumbers;
|
||||
}
|
||||
|
||||
function getDisplayPageNumber(
|
||||
state: PageOrderState,
|
||||
actualPage: number,
|
||||
): number {
|
||||
const order = getPageOrder(state);
|
||||
|
||||
if (order === "japanese") {
|
||||
return state.totalPages - actualPage + 1;
|
||||
}
|
||||
|
||||
return actualPage;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Page slider/scrubber for quick navigation
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
|
||||
export function init(context: ReaderContext): void {
|
||||
let state: PageScrubberState | null = null;
|
||||
let scrubberElement: HTMLElement | null = null;
|
||||
|
||||
context.events.on("reader:loaded", (detail: { currentPage: number; totalPages: number; container: HTMLElement }) => {
|
||||
state = createPageScrubber(detail.container, detail.currentPage, detail.totalPages);
|
||||
});
|
||||
|
||||
context.events.on("page-scrubber:show", () => {
|
||||
if (state) {
|
||||
showPageScrubber(state);
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("page-scrubber:hide", () => {
|
||||
if (state) {
|
||||
hidePageScrubber(state);
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("page-changed", (detail: { page: number }) => {
|
||||
if (state) {
|
||||
updatePageScrubber(state, detail.page);
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("reader:unload", () => {
|
||||
scrubberElement?.remove();
|
||||
scrubberElement = null;
|
||||
});
|
||||
}
|
||||
|
||||
interface PageScrubberState {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
container: HTMLElement;
|
||||
}
|
||||
|
||||
function createPageScrubber(
|
||||
container: HTMLElement,
|
||||
currentPage: number,
|
||||
totalPages: number,
|
||||
): PageScrubberState {
|
||||
const state: PageScrubberState = {
|
||||
currentPage,
|
||||
totalPages,
|
||||
container,
|
||||
};
|
||||
|
||||
renderPageScrubber(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function renderPageScrubber(state: PageScrubberState): void {
|
||||
const existing = state.container.querySelector(".page-scrubber");
|
||||
existing?.remove();
|
||||
|
||||
const scrubber = document.createElement("div");
|
||||
scrubber.className =
|
||||
"page-scrubber fixed bottom-20 left-1/2 transform -translate-x-1/2 bg-gray-900 bg-opacity-90 rounded-full px-4 py-2 flex items-center gap-4 z-40";
|
||||
scrubber.innerHTML = `
|
||||
<span class="page-label">${state.currentPage}</span>
|
||||
<input
|
||||
type="range"
|
||||
class="page-slider w-64 h-2 bg-gray-700 rounded-full appearance-none cursor-pointer"
|
||||
min="1"
|
||||
max="${state.totalPages}"
|
||||
value="${state.currentPage}"
|
||||
/>
|
||||
<span class="page-total">${state.totalPages}</span>
|
||||
`;
|
||||
|
||||
const slider = scrubber.querySelector(".page-slider") as HTMLInputElement;
|
||||
slider.addEventListener("input", (e) => {
|
||||
const targetPage = parseInt((e.target as HTMLInputElement).value);
|
||||
updatePageScrubber(state, targetPage);
|
||||
});
|
||||
|
||||
slider.addEventListener("change", () => {
|
||||
const targetPage = parseInt(slider.value);
|
||||
dispatchPageNavigationEvent(targetPage);
|
||||
});
|
||||
|
||||
state.container.appendChild(scrubber);
|
||||
}
|
||||
|
||||
function updatePageScrubber(
|
||||
state: PageScrubberState,
|
||||
currentPage: number,
|
||||
): PageScrubberState {
|
||||
state.currentPage = currentPage;
|
||||
|
||||
const label = state.container.querySelector(".page-label");
|
||||
if (label) {
|
||||
label.textContent = String(currentPage);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function showPageScrubber(state: PageScrubberState): void {
|
||||
const scrubber = state.container.querySelector(".page-scrubber");
|
||||
scrubber?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hidePageScrubber(state: PageScrubberState): void {
|
||||
const scrubber = state.container.querySelector(".page-scrubber");
|
||||
scrubber?.classList.add("hidden");
|
||||
}
|
||||
|
||||
function dispatchPageNavigationEvent(page: number): void {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("navigate-to-page", { detail: { page } }),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// ML-based panel detection using COCO-SSD pre-trained model
|
||||
|
||||
interface Panel {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
reading_order: number;
|
||||
}
|
||||
|
||||
let model: any = null;
|
||||
let tfLoaded = false;
|
||||
|
||||
async function loadTF(): Promise<void> {
|
||||
if (tfLoaded) return;
|
||||
|
||||
// Load TensorFlow.js
|
||||
await import("@tensorflow/tfjs");
|
||||
tfLoaded = true;
|
||||
}
|
||||
|
||||
async function loadModel(): Promise<void> {
|
||||
if (model) return;
|
||||
|
||||
await loadTF();
|
||||
|
||||
// Load COCO-SSD model (pre-trained on millions of images)
|
||||
const cocoSsd = await import("@tensorflow-models/coco-ssd");
|
||||
model = await cocoSsd.load({
|
||||
base: "lite_mobilenet_v2", // Smaller, faster model
|
||||
});
|
||||
}
|
||||
|
||||
async function detectPanelsML(imageData: ImageData): Promise<Panel[]> {
|
||||
await loadModel();
|
||||
|
||||
// Create HTMLCanvasElement to run model inference
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = imageData.width;
|
||||
canvas.height = imageData.height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
// Run COCO-SSD model
|
||||
const predictions = await model.detect(canvas);
|
||||
|
||||
// Filter predictions to find rectangular regions (panels)
|
||||
// COCO-SSD detects common objects, we look for rectangular ones
|
||||
const panels: Panel[] = [];
|
||||
const imgWidth = imageData.width;
|
||||
const imgHeight = imageData.height;
|
||||
|
||||
for (let i = 0; i < predictions.length; i++) {
|
||||
const pred = predictions[i];
|
||||
|
||||
// COCO-SSD detects "book" and similar objects
|
||||
// We filter for reasonable panel-like detections
|
||||
const [x, y, w, h] = pred.bbox;
|
||||
const aspectRatio = w / h;
|
||||
|
||||
const isRectangular =
|
||||
aspectRatio > 0.3 && // Not too tall/thin
|
||||
aspectRatio < 5 && // Not too wide
|
||||
w > imgWidth * 0.05 && // Not too small
|
||||
h > imgHeight * 0.05;
|
||||
|
||||
if (isRectangular) {
|
||||
panels.push({
|
||||
id: `ml-panel-${i}`,
|
||||
x: (x / imgWidth) * 100,
|
||||
y: (y / imgHeight) * 100,
|
||||
width: (w / imgWidth) * 100,
|
||||
height: (h / imgHeight) * 100,
|
||||
reading_order: i,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort panels by reading order
|
||||
panels.sort((a, b) => {
|
||||
const rowA = Math.floor(a.y / 25);
|
||||
const rowB = Math.floor(b.y / 25);
|
||||
if (rowA !== rowB) return rowA - rowB;
|
||||
return a.x - b.x;
|
||||
});
|
||||
|
||||
panels.forEach((p, i) => (p.reading_order = i));
|
||||
|
||||
return panels;
|
||||
}
|
||||
|
||||
export { detectPanelsML, loadModel };
|
||||
@@ -0,0 +1,113 @@
|
||||
// OpenCV.js-based edge detection for panel boundaries
|
||||
|
||||
interface Panel {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
reading_order: number;
|
||||
}
|
||||
|
||||
let openCVLoaded = false;
|
||||
|
||||
async function loadOpenCV(): Promise<void> {
|
||||
if (openCVLoaded) return;
|
||||
|
||||
// OpenCV.js loads asynchronously and registers globally
|
||||
await import("@techstark/opencv-js");
|
||||
|
||||
// Wait for OpenCV to be ready
|
||||
return new Promise<void>((resolve) => {
|
||||
const check = () => {
|
||||
if ((window as any).cv && (window as any).cv.Mat) {
|
||||
openCVLoaded = true;
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(check, 50);
|
||||
}
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
async function detectPanelsOpenCV(imageData: ImageData): Promise<Panel[]> {
|
||||
await loadOpenCV();
|
||||
|
||||
const cv = (window as any).cv;
|
||||
|
||||
// Create matrices from ImageData
|
||||
const src = cv.matFromImageData(imageData);
|
||||
const gray = new cv.Mat();
|
||||
const blurred = new cv.Mat();
|
||||
const edges = new cv.Mat();
|
||||
const contours = new cv.Mat();
|
||||
const hierarchy = new cv.Mat();
|
||||
|
||||
try {
|
||||
// Convert to grayscale
|
||||
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY, 0);
|
||||
|
||||
// Apply Gaussian blur to reduce noise
|
||||
cv.GaussianBlur(gray, blurred, new cv.Size(5, 5), 0, 0, cv.BORDER_DEFAULT);
|
||||
|
||||
// Detect edges using Canny
|
||||
cv.Canny(blurred, edges, 50, 150, 3, false);
|
||||
|
||||
// Find contours
|
||||
cv.findContours(
|
||||
edges,
|
||||
contours,
|
||||
hierarchy,
|
||||
cv.RETR_EXTERNAL,
|
||||
cv.CHAIN_APPROX_SIMPLE,
|
||||
);
|
||||
|
||||
// Convert contours to panels
|
||||
const panels: Panel[] = [];
|
||||
const imgWidth = imageData.width;
|
||||
const imgHeight = imageData.height;
|
||||
|
||||
for (let i = 0; i < contours.size(); i++) {
|
||||
const rect = cv.boundingRect(contours.get(i));
|
||||
const aspectRatio = rect.width / rect.height;
|
||||
|
||||
// Filter: reject very small or very thin contours
|
||||
const minSize = Math.min(imgWidth, imgHeight) * 0.05;
|
||||
if (rect.width < minSize || rect.height < minSize) continue;
|
||||
if (aspectRatio < 0.1 || aspectRatio > 10) continue;
|
||||
|
||||
panels.push({
|
||||
id: `opencv-panel-${i}`,
|
||||
x: (rect.x / imgWidth) * 100,
|
||||
y: (rect.y / imgHeight) * 100,
|
||||
width: (rect.width / imgWidth) * 100,
|
||||
height: (rect.height / imgHeight) * 100,
|
||||
reading_order: i,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort panels by reading order (top-left to bottom-right)
|
||||
panels.sort((a, b) => {
|
||||
const rowA = Math.floor(a.y / 25);
|
||||
const rowB = Math.floor(b.y / 25);
|
||||
if (rowA !== rowB) return rowA - rowB;
|
||||
return a.x - b.x;
|
||||
});
|
||||
|
||||
// Reassign reading order after sorting
|
||||
panels.forEach((p, i) => (p.reading_order = i));
|
||||
|
||||
return panels;
|
||||
} finally {
|
||||
// Clean up OpenCV matrices
|
||||
src.delete();
|
||||
gray.delete();
|
||||
blurred.delete();
|
||||
edges.delete();
|
||||
contours.delete();
|
||||
hierarchy.delete();
|
||||
}
|
||||
}
|
||||
|
||||
export { detectPanelsOpenCV, loadOpenCV };
|
||||
@@ -0,0 +1,77 @@
|
||||
// Main panel detection service with fallback chain
|
||||
// Priority: OpenCV → ML → Grid → Manual Editor
|
||||
import { detectPanelsOpenCV } from "./panel-detection.opencv";
|
||||
import { detectPanelsML } from "./panel-detection.ml";
|
||||
import { detectPanelsGrid } from "./panel-detector";
|
||||
|
||||
interface DetectionResult {
|
||||
panels: Panel[];
|
||||
method: "opencv" | "ml" | "grid" | "manual";
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface Panel {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
reading_order: number;
|
||||
}
|
||||
|
||||
async function detectPanels(
|
||||
imageData: ImageData,
|
||||
allowManual: boolean = true,
|
||||
): Promise<DetectionResult> {
|
||||
// Tier 1: OpenCV Edge Detection
|
||||
try {
|
||||
const panels = await detectPanelsOpenCV(imageData);
|
||||
if (validatePanels(panels, imageData)) {
|
||||
return { panels, method: "opencv", confidence: 0.85 };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("OpenCV detection failed:", e);
|
||||
}
|
||||
|
||||
// Tier 2: ML Detection (COCO-SSD)
|
||||
try {
|
||||
const panels = await detectPanelsML(imageData);
|
||||
if (validatePanels(panels, imageData)) {
|
||||
return { panels, method: "ml", confidence: 0.9 };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("ML detection failed:", e);
|
||||
}
|
||||
|
||||
// Tier 3: Grid Detection (baseline)
|
||||
const panels = detectPanelsGrid(imageData);
|
||||
if (allowManual && panels.length === 0) {
|
||||
return {
|
||||
panels: [],
|
||||
method: "manual" as const,
|
||||
confidence: 0,
|
||||
};
|
||||
}
|
||||
return { panels, method: "grid", confidence: 0.5 };
|
||||
}
|
||||
|
||||
function validatePanels(panels: Panel[], imageData: ImageData): boolean {
|
||||
// Must have at least 1 panel
|
||||
if (panels.length === 0) return false;
|
||||
// Should not have too many panels (probably noise)
|
||||
if (panels.length > 30) return false;
|
||||
// Panels should cover reasonable area (not all empty space)
|
||||
let totalArea = panels.reduce((sum, p) => sum + p.width * p.height, 0);
|
||||
if (totalArea < 10 || totalArea > 100) return false;
|
||||
// Check panel sizes are reasonable relative to image dimensions
|
||||
const minPanelSize = Math.min(imageData.width, imageData.height) * 0.02;
|
||||
const tooSmall = panels.some(
|
||||
(p) =>
|
||||
(p.width / 100) * imageData.width < minPanelSize ||
|
||||
(p.height / 100) * imageData.height < minPanelSize,
|
||||
);
|
||||
if (tooSmall) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export { detectPanels, DetectionResult, Panel };
|
||||
@@ -0,0 +1,172 @@
|
||||
// Grid-based panel detection (fast, lightweight)
|
||||
// Keep as final fallback
|
||||
|
||||
export interface Panel {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
reading_order: number;
|
||||
}
|
||||
|
||||
interface GridConfig {
|
||||
rows: number;
|
||||
cols: number;
|
||||
}
|
||||
|
||||
function detectPanelsGrid(
|
||||
imageData: ImageData,
|
||||
config: GridConfig = { rows: 3, cols: 3 },
|
||||
): Panel[] {
|
||||
const panels: Panel[] = [];
|
||||
const cellWidth = imageData.width / config.cols;
|
||||
const cellHeight = imageData.height / config.rows;
|
||||
|
||||
for (let y = 0; y < config.rows; y++) {
|
||||
for (let x = 0; x < config.cols; x++) {
|
||||
const cell = extractCell(imageData, x, y, cellWidth, cellHeight);
|
||||
|
||||
if (!isEmpty(cell)) {
|
||||
panels.push({
|
||||
id: `panel-${panels.length}`,
|
||||
x: (x / config.cols) * 100,
|
||||
y: (y / config.rows) * 100,
|
||||
width: (1 / config.cols) * 100,
|
||||
height: (1 / config.rows) * 100,
|
||||
reading_order: panels.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mergeAdjacentPanels(panels);
|
||||
}
|
||||
|
||||
function isEmpty(cellData: ImageData): boolean {
|
||||
// Simple edge detection to find empty space
|
||||
// Count white/transparent pixels
|
||||
let emptyPixels = 0;
|
||||
const totalPixels = cellData.width * cellData.height;
|
||||
const threshold = 0.95; // 95% empty = empty cell
|
||||
|
||||
for (let i = 0; i < cellData.data.length; i += 4) {
|
||||
const r = cellData.data[i];
|
||||
const g = cellData.data[i + 1];
|
||||
const b = cellData.data[i + 2];
|
||||
const a = cellData.data[i + 3];
|
||||
|
||||
// Consider white or transparent as empty
|
||||
if (a < 10 || (r > 250 && g > 250 && b > 250)) {
|
||||
emptyPixels++;
|
||||
}
|
||||
}
|
||||
|
||||
return emptyPixels / totalPixels > threshold;
|
||||
}
|
||||
|
||||
function mergeAdjacentPanels(panels: Panel[]): Panel[] {
|
||||
// Merge panels that are next to each other
|
||||
// Simplified algorithm - can be enhanced
|
||||
const merged: Panel[] = [];
|
||||
const used = new Set<number>();
|
||||
|
||||
for (let i = 0; i < panels.length; i++) {
|
||||
if (used.has(i)) continue;
|
||||
|
||||
let current = { ...panels[i] };
|
||||
used.add(i);
|
||||
|
||||
// Look for adjacent panels
|
||||
for (let j = i + 1; j < panels.length; j++) {
|
||||
if (used.has(j)) continue;
|
||||
if (isAdjacent(current, panels[j])) {
|
||||
current = mergePanels(current, panels[j]);
|
||||
used.add(j);
|
||||
}
|
||||
}
|
||||
|
||||
merged.push(current);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function extractCell(
|
||||
imageData: ImageData,
|
||||
gridX: number,
|
||||
gridY: number,
|
||||
cellWidth: number,
|
||||
cellHeight: number,
|
||||
): ImageData {
|
||||
const startX = Math.floor(gridX * cellWidth);
|
||||
const startY = Math.floor(gridY * cellHeight);
|
||||
const width = Math.floor(cellWidth);
|
||||
const height = Math.floor(cellHeight);
|
||||
|
||||
const cellData = new Uint8ClampedArray(width * height * 4);
|
||||
// Copy pixels for the cell region
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const srcIdx = ((startY + y) * imageData.width + (startX + x)) * 4;
|
||||
const destIdx = (y * width + x) * 4;
|
||||
cellData[destIdx] = imageData.data[srcIdx];
|
||||
cellData[destIdx + 1] = imageData.data[srcIdx + 1];
|
||||
cellData[destIdx + 2] = imageData.data[srcIdx + 2];
|
||||
cellData[destIdx + 3] = imageData.data[srcIdx + 3];
|
||||
}
|
||||
}
|
||||
|
||||
return new ImageData(cellData, width, height);
|
||||
}
|
||||
|
||||
function isAdjacent(p1: Panel, p2: Panel): boolean {
|
||||
const tolerance = 5; // 5% tolerance for alignment
|
||||
// Check horizontal adjacency
|
||||
if (
|
||||
Math.abs(p1.y - p2.y) < tolerance &&
|
||||
Math.abs(p1.height - p2.height) < tolerance
|
||||
) {
|
||||
return (
|
||||
Math.abs(p1.x + p1.width - p2.x) < tolerance ||
|
||||
Math.abs(p2.x + p2.width - p1.x) < tolerance
|
||||
);
|
||||
}
|
||||
// Check vertical adjacency
|
||||
if (
|
||||
Math.abs(p1.x - p2.x) < tolerance &&
|
||||
Math.abs(p1.width - p2.width) < tolerance
|
||||
) {
|
||||
return (
|
||||
Math.abs(p1.y + p1.height - p2.y) < tolerance ||
|
||||
Math.abs(p2.y + p2.height - p1.y) < tolerance
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function mergePanels(p1: Panel, p2: Panel): Panel {
|
||||
const minX = Math.min(p1.x, p2.x);
|
||||
const minY = Math.min(p1.y, p2.y);
|
||||
const maxX = Math.max(p1.x + p1.width, p2.x + p2.width);
|
||||
const maxY = Math.max(p1.y + p1.height, p2.y + p2.height);
|
||||
|
||||
return {
|
||||
id: p1.id,
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
reading_order: Math.min(p1.reading_order, p2.reading_order),
|
||||
};
|
||||
}
|
||||
|
||||
// ADD THIS EXPORT AT THE END OF THE FILE
|
||||
export {
|
||||
detectPanelsGrid,
|
||||
isEmpty,
|
||||
mergeAdjacentPanels,
|
||||
extractCell,
|
||||
isAdjacent,
|
||||
mergePanels,
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
// Manual panel editor for admins/power users
|
||||
|
||||
import { Alpine } from "../../alpine";
|
||||
import { apiPut } from "../../api";
|
||||
import { Panel } from "./panel-detector";
|
||||
import { detectPanels } from "./panel-detection.service";
|
||||
|
||||
async function loadImageForPage(pageNumber: number): Promise<HTMLImageElement> {
|
||||
const mediaItemId = document.body.dataset.mediaItemId;
|
||||
if (!mediaItemId) {
|
||||
throw new Error("No mediaItemId found");
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("token");
|
||||
const response = await fetch(`/readers/${mediaItemId}/pages/${pageNumber}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load page ${pageNumber}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const img = new Image();
|
||||
img.src = URL.createObjectURL(blob);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
img.onload = () => resolve();
|
||||
});
|
||||
|
||||
return img;
|
||||
}
|
||||
|
||||
function getCurrentPageNumber(): number {
|
||||
// Try Alpine first
|
||||
const Alpine = (window as any).Alpine;
|
||||
if (Alpine) {
|
||||
const readerEl = document.querySelector('[x-data="readerShell"]');
|
||||
if (readerEl) {
|
||||
const readerShell = Alpine.$data(readerEl);
|
||||
if (readerShell?.currentPage) {
|
||||
return readerShell.currentPage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check for dataset attribute on reader content
|
||||
const content = document.getElementById("reader-content");
|
||||
const pageFromDataset = content?.dataset.currentPage;
|
||||
if (pageFromDataset) {
|
||||
return parseInt(pageFromDataset, 10);
|
||||
}
|
||||
|
||||
// Final fallback
|
||||
return 1;
|
||||
}
|
||||
function loadPage(pageNumber: number): void {
|
||||
// Dispatch event for reader to handle navigation
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("navigate-to-page", { detail: { page: pageNumber } }),
|
||||
);
|
||||
}
|
||||
|
||||
function openPanelEditor(pageNumber: number): void {
|
||||
const modal = document.getElementById("panel-editor-modal");
|
||||
modal?.classList.remove("hidden");
|
||||
|
||||
// Load page image
|
||||
const canvas = document.getElementById(
|
||||
"panel-editor-canvas",
|
||||
) as HTMLCanvasElement;
|
||||
const ctx = canvas?.getContext("2d");
|
||||
|
||||
// Load image and draw to canvas
|
||||
loadImageForPage(pageNumber).then((image) => {
|
||||
canvas!.width = image.width;
|
||||
canvas!.height = image.height;
|
||||
ctx?.drawImage(image, 0, 0);
|
||||
|
||||
// Allow user to draw panels
|
||||
enablePanelDrawing(canvas!);
|
||||
});
|
||||
}
|
||||
|
||||
function enablePanelDrawing(canvas: HTMLCanvasElement): void {
|
||||
let isDrawing = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
|
||||
canvas.addEventListener("mousedown", (e) => {
|
||||
isDrawing = true;
|
||||
startX = e.offsetX;
|
||||
startY = e.offsetY;
|
||||
});
|
||||
|
||||
canvas.addEventListener("mousemove", (e) => {
|
||||
if (!isDrawing) return;
|
||||
|
||||
// Draw selection rectangle
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx?.strokeRect(startX, startY, e.offsetX - startX, e.offsetY - startY);
|
||||
});
|
||||
|
||||
canvas.addEventListener("mouseup", (e) => {
|
||||
if (!isDrawing) return;
|
||||
isDrawing = false;
|
||||
|
||||
// Save panel
|
||||
const panel: Panel = {
|
||||
id: `manual-${Date.now()}`,
|
||||
x: (startX / canvas.width) * 100,
|
||||
y: (startY / canvas.height) * 100,
|
||||
width: ((e.offsetX - startX) / canvas.width) * 100,
|
||||
height: ((e.offsetY - startY) / canvas.height) * 100,
|
||||
reading_order: 0, // Will be set by server
|
||||
};
|
||||
|
||||
saveManualPanel(panel);
|
||||
});
|
||||
}
|
||||
|
||||
async function saveManualPanel(panel: Panel): Promise<void> {
|
||||
const mediaItemId = document.body.dataset.mediaItemId;
|
||||
const pageNumber = getCurrentPageNumber();
|
||||
|
||||
await apiPut(`/readers/${mediaItemId}/panels/${pageNumber}`, {
|
||||
detection_method: "manual",
|
||||
panels: [panel],
|
||||
});
|
||||
|
||||
// Reload with new panels
|
||||
loadPage(pageNumber);
|
||||
}
|
||||
|
||||
// Re-detect panels using detection service
|
||||
async function reDetectPanels(pageNumber: number): Promise<Panel[]> {
|
||||
const image = await loadImageForPage(pageNumber);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(image, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const result = await detectPanels(imageData, true);
|
||||
|
||||
return result.panels;
|
||||
}
|
||||
|
||||
// Alpine component
|
||||
Alpine.data("panelEditor", () => ({
|
||||
get isComicOrManga(): boolean {
|
||||
const libraryType = document.body.dataset.mediaType;
|
||||
return libraryType === "comic" || libraryType === "manga";
|
||||
},
|
||||
|
||||
openPanelEditor(pageNumber: number) {
|
||||
openPanelEditor(pageNumber);
|
||||
},
|
||||
|
||||
async reDetectPanels(pageNumber: number) {
|
||||
const panels = await reDetectPanels(pageNumber);
|
||||
return panels;
|
||||
},
|
||||
}));
|
||||
|
||||
export { openPanelEditor, reDetectPanels };
|
||||
@@ -0,0 +1,150 @@
|
||||
// Adjustable panel gap controls
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
|
||||
export function init(context: ReaderContext): void {
|
||||
const state = createPanelGapState();
|
||||
applyPanelGap(state.gapSize, state.showBorders);
|
||||
|
||||
context.events.on("panel-gap:set", (detail: { gap: number }) => {
|
||||
setPanelGap(state, detail.gap);
|
||||
});
|
||||
|
||||
context.events.on("panel-gap:increase", (detail?: { amount: number }) => {
|
||||
increasePanelGap(state, detail?.amount);
|
||||
});
|
||||
|
||||
context.events.on("panel-gap:decrease", (detail?: { amount: number }) => {
|
||||
decreasePanelGap(state, detail?.amount);
|
||||
});
|
||||
|
||||
context.events.on("panel-gap:borders:toggle", () => {
|
||||
togglePanelBorders(state);
|
||||
});
|
||||
|
||||
context.events.on("ui:show-settings", (detail: { container: HTMLElement }) => {
|
||||
renderPanelGapControls(detail.container, state);
|
||||
});
|
||||
|
||||
context.events.on("reader:unload", () => {
|
||||
const controls = document.querySelector(".panel-gap-controls");
|
||||
controls?.remove();
|
||||
});
|
||||
}
|
||||
|
||||
interface PanelGapState {
|
||||
gapSize: number;
|
||||
showBorders: boolean;
|
||||
}
|
||||
|
||||
function createPanelGapState(initialGap: number = 4): PanelGapState {
|
||||
const saved = localStorage.getItem("reader-panel-gap");
|
||||
return {
|
||||
gapSize: saved ? parseInt(saved) : initialGap,
|
||||
showBorders: false,
|
||||
};
|
||||
}
|
||||
|
||||
function applyPanelGap(gap: number, showBorders: boolean): void {
|
||||
document.documentElement.style.setProperty("--panel-gap", `${gap}px`);
|
||||
document.documentElement.style.setProperty(
|
||||
"--panel-border-width",
|
||||
showBorders ? "1px" : "0px",
|
||||
);
|
||||
localStorage.setItem("reader-panel-gap", String(gap));
|
||||
}
|
||||
|
||||
function setPanelGap(state: PanelGapState, gap: number): PanelGapState {
|
||||
const clampedGap = Math.max(0, Math.min(20, gap));
|
||||
state.gapSize = clampedGap;
|
||||
|
||||
document.documentElement.style.setProperty("--panel-gap", `${clampedGap}px`);
|
||||
localStorage.setItem("reader-panel-gap", String(clampedGap));
|
||||
|
||||
const controls = document.querySelector(".panel-gap-controls");
|
||||
if (controls) {
|
||||
updatePanelGapUI(controls as HTMLElement, state);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function increasePanelGap(state: PanelGapState, amount: number = 2): PanelGapState {
|
||||
return setPanelGap(state, state.gapSize + amount);
|
||||
}
|
||||
|
||||
function decreasePanelGap(state: PanelGapState, amount: number = 2): PanelGapState {
|
||||
return setPanelGap(state, state.gapSize - amount);
|
||||
}
|
||||
|
||||
function togglePanelBorders(state: PanelGapState): PanelGapState {
|
||||
state.showBorders = !state.showBorders;
|
||||
|
||||
document.documentElement.style.setProperty(
|
||||
"--panel-border-width",
|
||||
state.showBorders ? "1px" : "0px",
|
||||
);
|
||||
|
||||
const controls = document.querySelector(".panel-gap-controls");
|
||||
if (controls) {
|
||||
updatePanelGapUI(controls as HTMLElement, state);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function renderPanelGapControls(
|
||||
container: HTMLElement,
|
||||
state: PanelGapState,
|
||||
): void {
|
||||
const existing = container.querySelector(".panel-gap-controls");
|
||||
existing?.remove();
|
||||
|
||||
const controls = document.createElement("div");
|
||||
controls.className =
|
||||
"panel-gap-controls fixed bottom-24 right-4 bg-gray-900 bg-opacity-90 rounded-lg p-2 flex flex-col gap-2 z-40";
|
||||
controls.innerHTML = `
|
||||
<button class="panel-gap-increase p-2 hover:bg-gray-700 rounded" title="Increase gap">+</button>
|
||||
<span class="text-center text-sm">${state.gapSize}px</span>
|
||||
<button class="panel-gap-decrease p-2 hover:bg-gray-700 rounded" title="Decrease gap">-</button>
|
||||
<button class="panel-gap-borders p-2 hover:bg-gray-700 rounded" title="Toggle borders">
|
||||
${state.showBorders ? "▦" : "▢"}
|
||||
</button>
|
||||
`;
|
||||
|
||||
controls
|
||||
.querySelector(".panel-gap-increase")
|
||||
?.addEventListener("click", () => {
|
||||
increasePanelGap(state);
|
||||
updatePanelGapUI(controls, state);
|
||||
});
|
||||
|
||||
controls
|
||||
.querySelector(".panel-gap-decrease")
|
||||
?.addEventListener("click", () => {
|
||||
decreasePanelGap(state);
|
||||
updatePanelGapUI(controls, state);
|
||||
});
|
||||
|
||||
controls
|
||||
.querySelector(".panel-gap-borders")
|
||||
?.addEventListener("click", () => {
|
||||
togglePanelBorders(state);
|
||||
updatePanelGapUI(controls, state);
|
||||
});
|
||||
|
||||
container.appendChild(controls);
|
||||
}
|
||||
|
||||
function updatePanelGapUI(container: HTMLElement, state: PanelGapState): void {
|
||||
const gapLabel = container.querySelector("span");
|
||||
if (gapLabel) {
|
||||
gapLabel.textContent = `${state.gapSize}px`;
|
||||
}
|
||||
|
||||
const bordersBtn = container.querySelector(".panel-gap-borders");
|
||||
if (bordersBtn) {
|
||||
bordersBtn.textContent = state.showBorders ? "▦" : "▢";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Detect reading direction from metadata or user preference
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
|
||||
export function init(context: ReaderContext): void {
|
||||
let state: ReadingDirectionState | null = null;
|
||||
|
||||
context.events.on("reader:loaded", async (detail: { metadata: any }) => {
|
||||
state = await detectReadingDirection(detail.metadata);
|
||||
const effectiveDirection = getEffectiveDirection(state);
|
||||
context.events.emit("reading-direction:detected", { direction: effectiveDirection });
|
||||
});
|
||||
|
||||
context.events.on("reading-direction:set", (detail: { direction: "auto" | "ltr" | "rtl" | "vertical" }) => {
|
||||
if (state) {
|
||||
state.direction = detail.direction;
|
||||
const effectiveDirection = getEffectiveDirection(state);
|
||||
context.events.emit("reading-direction:changed", { direction: effectiveDirection });
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("reading-direction:get", () => {
|
||||
if (state) {
|
||||
const effectiveDirection = getEffectiveDirection(state);
|
||||
context.events.emit("reading-direction:current", { direction: effectiveDirection });
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("reading-direction:is-rtl", () => {
|
||||
if (state) {
|
||||
const isRTL = shouldUseRTL(state);
|
||||
context.events.emit("reading-direction:is-rtl-result", { isRTL });
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("reading-direction:is-vertical", () => {
|
||||
if (state) {
|
||||
const isVertical = shouldUseVerticalScroll(state);
|
||||
context.events.emit("reading-direction:is-vertical-result", { isVertical });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type ReadingDirection = "auto" | "ltr" | "rtl" | "vertical";
|
||||
|
||||
interface ReadingDirectionState {
|
||||
direction: ReadingDirection;
|
||||
detectedDirection: "ltr" | "rtl" | "vertical";
|
||||
userPreference: ReadingDirection | null;
|
||||
}
|
||||
|
||||
async function detectReadingDirection(
|
||||
metadata: any,
|
||||
): Promise<ReadingDirectionState> {
|
||||
const userPreference = await getUserReadingDirectionPreference();
|
||||
if (userPreference && userPreference !== "auto") {
|
||||
return {
|
||||
direction: userPreference,
|
||||
detectedDirection: "ltr",
|
||||
userPreference,
|
||||
};
|
||||
}
|
||||
|
||||
const detectedDirection = detectFromMetadata(metadata);
|
||||
|
||||
return {
|
||||
direction: "auto",
|
||||
detectedDirection,
|
||||
userPreference: null,
|
||||
};
|
||||
}
|
||||
|
||||
function detectFromMetadata(
|
||||
metadata: any,
|
||||
): "ltr" | "rtl" | "vertical" {
|
||||
const mangaType = (metadata as any).manga_type;
|
||||
if (mangaType === "yes_and_right_to_left" || mangaType === "yes") {
|
||||
return "rtl";
|
||||
}
|
||||
|
||||
const readingDirection = (metadata as any).reading_direction;
|
||||
if (readingDirection === "rtl" || readingDirection === "vertical") {
|
||||
return readingDirection;
|
||||
}
|
||||
|
||||
const filename = metadata.filePath.toLowerCase();
|
||||
if (
|
||||
filename.includes("manga") ||
|
||||
filename.includes("manhwa") ||
|
||||
filename.includes("webtoon")
|
||||
) {
|
||||
return "vertical";
|
||||
}
|
||||
|
||||
return "ltr";
|
||||
}
|
||||
|
||||
async function getUserReadingDirectionPreference(): Promise<ReadingDirection | null> {
|
||||
const userId = localStorage.getItem("userId");
|
||||
if (!userId) return null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}/settings`);
|
||||
if (!response.ok) return null;
|
||||
|
||||
const settings = await response.json();
|
||||
return settings.reading_direction || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getEffectiveDirection(
|
||||
state: ReadingDirectionState,
|
||||
): "ltr" | "rtl" | "vertical" {
|
||||
if (state.direction !== "auto") {
|
||||
return state.direction as "ltr" | "rtl" | "vertical";
|
||||
}
|
||||
return state.detectedDirection;
|
||||
}
|
||||
|
||||
function shouldUseRTL(state: ReadingDirectionState): boolean {
|
||||
return getEffectiveDirection(state) === "rtl";
|
||||
}
|
||||
|
||||
function shouldUseVerticalScroll(state: ReadingDirectionState): boolean {
|
||||
return getEffectiveDirection(state) === "vertical";
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Right-to-left navigation for manga
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
|
||||
export function init(context: ReaderContext): void {
|
||||
let state: RTLNavigatorState | null = null;
|
||||
|
||||
context.events.on("reader:loaded", (detail: { totalPages: number; currentPage?: number }) => {
|
||||
state = createRTLNavigator(detail.totalPages);
|
||||
if (detail.currentPage) {
|
||||
state.currentPage = detail.currentPage;
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("navigation:next-page", () => {
|
||||
if (state) {
|
||||
const nextPage = getNextPage(state);
|
||||
state.currentPage = nextPage;
|
||||
context.events.emit("navigation:to-page", { page: nextPage });
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("navigation:previous-page", () => {
|
||||
if (state) {
|
||||
const previousPage = getPreviousPage(state);
|
||||
state.currentPage = previousPage;
|
||||
context.events.emit("navigation:to-page", { page: previousPage });
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("navigation:to-page", (detail: { page: number }) => {
|
||||
if (state) {
|
||||
state = navigateToPage(state, detail.page);
|
||||
const progress = getReadingProgressPercentage(state);
|
||||
context.events.emit("navigation:progress", { progress });
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("navigation:get-progress", () => {
|
||||
if (state) {
|
||||
const progress = getProgress(state);
|
||||
context.events.emit("navigation:progress-current", progress);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interface RTLNavigatorState {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
readingDirection: "rtl" | "ltr";
|
||||
}
|
||||
|
||||
function createRTLNavigator(totalPages: number): RTLNavigatorState {
|
||||
return {
|
||||
currentPage: 1,
|
||||
totalPages,
|
||||
readingDirection: "rtl",
|
||||
};
|
||||
}
|
||||
|
||||
function getNextPage(state: RTLNavigatorState): number {
|
||||
if (state.readingDirection === "rtl") {
|
||||
return Math.max(1, state.currentPage - 1);
|
||||
}
|
||||
return Math.min(state.totalPages, state.currentPage + 1);
|
||||
}
|
||||
|
||||
function getPreviousPage(state: RTLNavigatorState): number {
|
||||
if (state.readingDirection === "rtl") {
|
||||
return Math.min(state.totalPages, state.currentPage + 1);
|
||||
}
|
||||
return Math.max(1, state.currentPage - 1);
|
||||
}
|
||||
|
||||
function navigateToPage(
|
||||
state: RTLNavigatorState,
|
||||
pageNumber: number,
|
||||
): RTLNavigatorState {
|
||||
state.currentPage = Math.max(1, Math.min(state.totalPages, pageNumber));
|
||||
return state;
|
||||
}
|
||||
|
||||
function getProgress(state: RTLNavigatorState): {
|
||||
current: number;
|
||||
total: number;
|
||||
} {
|
||||
return {
|
||||
current: state.currentPage,
|
||||
total: state.totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
function getReadingProgressPercentage(state: RTLNavigatorState): number {
|
||||
return (state.currentPage / state.totalPages) * 100;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Manga-specific settings integration
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
|
||||
export function init(context: ReaderContext): void {
|
||||
let currentSettings: MangaSettings | null = null;
|
||||
|
||||
context.events.on("reader:loaded", async () => {
|
||||
currentSettings = await getMangaSettings();
|
||||
applyMangaSettings(currentSettings);
|
||||
context.events.emit("manga-settings:loaded", currentSettings);
|
||||
});
|
||||
|
||||
context.events.on("manga-settings:update", async (detail: { settings: Partial<MangaSettings> }) => {
|
||||
if (currentSettings) {
|
||||
currentSettings = { ...currentSettings, ...detail.settings };
|
||||
await updateMangaSettings(detail.settings);
|
||||
applyMangaSettings(currentSettings);
|
||||
context.events.emit("manga-settings:changed", currentSettings);
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("manga-settings:get", () => {
|
||||
if (currentSettings) {
|
||||
context.events.emit("manga-settings:current", currentSettings);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interface MangaSettings {
|
||||
readingDirection: "auto" | "ltr" | "rtl" | "vertical";
|
||||
verticalScrollSpeed: "slow" | "normal" | "fast";
|
||||
rtlPageTransition: "slide" | "fade" | "none";
|
||||
webtoonMode: boolean;
|
||||
}
|
||||
|
||||
async function getMangaSettings(): Promise<MangaSettings> {
|
||||
const defaultSettings: MangaSettings = {
|
||||
readingDirection: "auto",
|
||||
verticalScrollSpeed: "normal",
|
||||
rtlPageTransition: "slide",
|
||||
webtoonMode: false,
|
||||
};
|
||||
|
||||
try {
|
||||
const userId = localStorage.getItem("userId");
|
||||
const response = await fetch(`/api/users/${userId}/settings`);
|
||||
|
||||
if (response.ok) {
|
||||
const settings = await response.json();
|
||||
return { ...defaultSettings, ...settings };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load manga settings:", error);
|
||||
}
|
||||
|
||||
return defaultSettings;
|
||||
}
|
||||
|
||||
async function updateMangaSettings(
|
||||
settings: Partial<MangaSettings>,
|
||||
): Promise<void> {
|
||||
const userId = localStorage.getItem("userId");
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}/settings`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to update manga settings");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save manga settings:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function applyMangaSettings(settings: MangaSettings): void {
|
||||
document.documentElement.dataset.readingDirection = settings.readingDirection;
|
||||
|
||||
if (settings.verticalScrollSpeed === "slow") {
|
||||
document.documentElement.style.scrollBehavior = "smooth";
|
||||
} else if (settings.verticalScrollSpeed === "fast") {
|
||||
document.documentElement.style.scrollBehavior = "auto";
|
||||
}
|
||||
|
||||
if (settings.rtlPageTransition !== "none") {
|
||||
document.documentElement.dataset.pageTransition =
|
||||
settings.rtlPageTransition;
|
||||
}
|
||||
|
||||
document.documentElement.dataset.webtoonMode = String(settings.webtoonMode);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// Vertical scroll mode for webtoons/manhwa
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
|
||||
export function init(context: ReaderContext): void {
|
||||
let state: VerticalScrollState | null = null;
|
||||
|
||||
context.events.on("reader:loaded", (detail: { container: HTMLElement; mediaItemId: string; totalPages: number }) => {
|
||||
state = createVerticalScroll(detail.container, detail.mediaItemId, detail.totalPages);
|
||||
});
|
||||
|
||||
context.events.on("vertical-scroll:load-page", async (detail: { pageNumber: number }) => {
|
||||
if (state) {
|
||||
await loadPage(state, detail.pageNumber);
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("vertical-scroll:get-current", () => {
|
||||
if (state) {
|
||||
const currentPage = getCurrentPageFromScroll(state);
|
||||
context.events.emit("vertical-scroll:current-page", { page: currentPage });
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("reader:unload", () => {
|
||||
if (state) {
|
||||
destroyVerticalScroll(state);
|
||||
state = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interface VerticalScrollState {
|
||||
container: HTMLElement;
|
||||
loadedPages: Set<number>;
|
||||
loadingPages: Set<number>;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
threshold: number;
|
||||
mediaItemId: string;
|
||||
}
|
||||
|
||||
function createVerticalScroll(
|
||||
container: HTMLElement,
|
||||
mediaItemId: string,
|
||||
totalPages: number,
|
||||
): VerticalScrollState {
|
||||
const state: VerticalScrollState = {
|
||||
container,
|
||||
loadedPages: new Set(),
|
||||
loadingPages: new Set(),
|
||||
currentPage: 1,
|
||||
totalPages,
|
||||
threshold: 500,
|
||||
mediaItemId,
|
||||
};
|
||||
|
||||
loadPage(state, 1);
|
||||
setupScrollListener(state);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
async function loadPage(
|
||||
state: VerticalScrollState,
|
||||
pageNumber: number,
|
||||
): Promise<void> {
|
||||
if (state.loadedPages.has(pageNumber) || state.loadingPages.has(pageNumber)) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.loadingPages.add(pageNumber);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("token");
|
||||
const response = await fetch(
|
||||
`/readers/${state.mediaItemId}/pages/${pageNumber}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load page ${pageNumber}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const imgUrl = URL.createObjectURL(blob);
|
||||
|
||||
const pageContainer = document.createElement("div");
|
||||
pageContainer.className = "vertical-page";
|
||||
pageContainer.dataset.pageNumber = pageNumber.toString();
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.src = imgUrl;
|
||||
img.alt = `Page ${pageNumber}`;
|
||||
img.loading = "lazy";
|
||||
|
||||
pageContainer.appendChild(img);
|
||||
state.container.appendChild(pageContainer);
|
||||
|
||||
state.loadedPages.add(pageNumber);
|
||||
state.loadingPages.delete(pageNumber);
|
||||
|
||||
if (pageNumber < state.totalPages) {
|
||||
loadPage(state, pageNumber + 1);
|
||||
if (pageNumber + 1 < state.totalPages) {
|
||||
loadPage(state, pageNumber + 2);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load page ${pageNumber}:`, error);
|
||||
state.loadingPages.delete(pageNumber);
|
||||
}
|
||||
}
|
||||
|
||||
function setupScrollListener(state: VerticalScrollState): void {
|
||||
let scrollTimeout: number | undefined;
|
||||
|
||||
state.container.addEventListener("scroll", () => {
|
||||
clearTimeout(scrollTimeout);
|
||||
scrollTimeout = window.setTimeout(() => {
|
||||
checkScrollPosition(state);
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
function checkScrollPosition(state: VerticalScrollState): void {
|
||||
const scrollBottom =
|
||||
state.container.scrollHeight -
|
||||
state.container.scrollTop -
|
||||
state.container.clientHeight;
|
||||
|
||||
if (scrollBottom < state.threshold) {
|
||||
const lastPage = Math.max(...state.loadedPages);
|
||||
if (lastPage < state.totalPages) {
|
||||
loadPage(state, lastPage + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const currentPage = getCurrentPageFromScroll(state);
|
||||
if (currentPage !== state.currentPage) {
|
||||
state.currentPage = currentPage;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("page-change", {
|
||||
detail: { page: currentPage },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentPageFromScroll(state: VerticalScrollState): number {
|
||||
const pages = Array.from(state.container.querySelectorAll(".vertical-page"));
|
||||
|
||||
for (const page of pages) {
|
||||
const rect = page.getBoundingClientRect();
|
||||
const containerRect = state.container.getBoundingClientRect();
|
||||
|
||||
const pageMiddle = rect.top + rect.height / 2;
|
||||
const viewportMiddle = containerRect.top + containerRect.height / 2;
|
||||
|
||||
if (Math.abs(pageMiddle - viewportMiddle) < containerRect.height / 4) {
|
||||
return parseInt((page as HTMLElement).dataset.pageNumber || "1");
|
||||
}
|
||||
}
|
||||
|
||||
return state.currentPage;
|
||||
}
|
||||
|
||||
function destroyVerticalScroll(state: VerticalScrollState): void {
|
||||
const images = Array.from(state.container.querySelectorAll("img"));
|
||||
images.forEach((img) => {
|
||||
const url = img.src;
|
||||
if (url.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
});
|
||||
|
||||
state.container.innerHTML = "";
|
||||
state.loadedPages.clear();
|
||||
state.loadingPages.clear();
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Annotation layer for rendering highlights and notes on PDFs
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { 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:clear", (detail: { container: HTMLElement }) => {
|
||||
clearPDFHighlights(detail.container);
|
||||
});
|
||||
|
||||
context.events.on("pdf:highlight:remove", (detail: { highlightId: string }) => {
|
||||
removePDFHighlight(detail.highlightId, highlights);
|
||||
});
|
||||
|
||||
context.events.on("reader:unload", () => {
|
||||
highlights.forEach((element) => element.remove());
|
||||
highlights.clear();
|
||||
});
|
||||
}
|
||||
|
||||
interface PDFHighlight {
|
||||
id: string;
|
||||
pageNumber: number;
|
||||
rects: DOMRect[];
|
||||
text: string;
|
||||
color: string;
|
||||
noteId?: string;
|
||||
}
|
||||
|
||||
function renderSinglePDFHighlight(
|
||||
container: HTMLElement,
|
||||
highlight: PDFHighlight,
|
||||
highlights: Map<string, HTMLElement>,
|
||||
): void {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "pdf-highlight-annotation";
|
||||
overlay.dataset.highlightId = highlight.id;
|
||||
overlay.style.backgroundColor = parseColor(highlight.color);
|
||||
|
||||
for (const rect of highlight.rects) {
|
||||
const rectDiv = document.createElement("div");
|
||||
rectDiv.className = "pdf-highlight-rect";
|
||||
rectDiv.style.left = `${rect.left}px`;
|
||||
rectDiv.style.top = `${rect.top}px`;
|
||||
rectDiv.style.width = `${rect.width}px`;
|
||||
rectDiv.style.height = `${rect.height}px`;
|
||||
|
||||
overlay.appendChild(rectDiv);
|
||||
}
|
||||
|
||||
if (highlight.noteId) {
|
||||
overlay.style.cursor = "pointer";
|
||||
overlay.addEventListener("click", () => {
|
||||
showNotePopup(highlight);
|
||||
});
|
||||
}
|
||||
|
||||
overlay.addEventListener("mouseenter", () => {
|
||||
overlay.style.opacity = "0.8";
|
||||
});
|
||||
|
||||
overlay.addEventListener("mouseleave", () => {
|
||||
overlay.style.opacity = "0.5";
|
||||
});
|
||||
|
||||
container.appendChild(overlay);
|
||||
highlights.set(highlight.id, overlay);
|
||||
}
|
||||
|
||||
function parseColor(color: string): string {
|
||||
if (color.startsWith("#")) {
|
||||
const hex = color.slice(1);
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, 0.4)`;
|
||||
}
|
||||
|
||||
return color;
|
||||
}
|
||||
|
||||
function showNotePopup(highlight: PDFHighlight): void {
|
||||
console.log("Show note for highlight:", highlight.id);
|
||||
const event = new CustomEvent("pdf:note-show", { detail: { highlightId: highlight.id } });
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
export function clearPDFHighlights(container: HTMLElement): void {
|
||||
const highlights = container.querySelectorAll(".pdf-highlight-annotation");
|
||||
Array.from(highlights).forEach((element) => element.remove());
|
||||
}
|
||||
|
||||
export function removePDFHighlight(highlightId: string, highlights: Map<string, HTMLElement>): void {
|
||||
const element = highlights.get(highlightId);
|
||||
if (element) {
|
||||
element.remove();
|
||||
highlights.delete(highlightId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// 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 };
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// 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%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// 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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// PDF navigation: page turning, zoom, fit modes
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { 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("pdf:navigate:to-page", (detail: { page: number }) => {
|
||||
if (navState) {
|
||||
goToPDFPage(navState, detail.page, context);
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("pdf:navigate:next", () => {
|
||||
if (navState) {
|
||||
nextPDFPage(navState, context);
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("pdf:navigate:previous", () => {
|
||||
if (navState) {
|
||||
previousPDFPage(navState, context);
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("pdf:zoom:set", (detail: { scale: number }) => {
|
||||
if (navState) {
|
||||
setPDFZoom(navState, detail.scale, context);
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("pdf:zoom:in", () => {
|
||||
if (navState) {
|
||||
zoomPDFIn(navState, context);
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("pdf:zoom:out", () => {
|
||||
if (navState) {
|
||||
zoomPDFOut(navState, 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;
|
||||
});
|
||||
}
|
||||
|
||||
type PageFitMode = "fit-width" | "fit-page" | "fit-height" | "none";
|
||||
|
||||
interface PDFNavigationState {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
currentScale: number;
|
||||
fitMode: PageFitMode;
|
||||
scrollContainer: HTMLElement | null;
|
||||
}
|
||||
|
||||
function goToPDFPage(state: PDFNavigationState, pageNumber: number, context: ReaderContext): void {
|
||||
if (pageNumber < 1 || pageNumber > state.totalPages) return;
|
||||
|
||||
state.currentPage = pageNumber;
|
||||
scrollToPDFPage(state, pageNumber);
|
||||
context.events.emit("pdf:page-changed", { page: pageNumber });
|
||||
}
|
||||
|
||||
function nextPDFPage(state: PDFNavigationState, context: ReaderContext): void {
|
||||
if (state.currentPage < state.totalPages) {
|
||||
goToPDFPage(state, state.currentPage + 1, context);
|
||||
}
|
||||
}
|
||||
|
||||
function previousPDFPage(state: PDFNavigationState, context: ReaderContext): void {
|
||||
if (state.currentPage > 1) {
|
||||
goToPDFPage(state, state.currentPage - 1, context);
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToPDFPage(state: PDFNavigationState, pageNumber: number): void {
|
||||
if (!state.scrollContainer) return;
|
||||
|
||||
const pageElement = state.scrollContainer.querySelector(
|
||||
`[data-page-number="${pageNumber}"]`,
|
||||
);
|
||||
if (pageElement) {
|
||||
pageElement.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
state.fitMode = mode;
|
||||
updatePDFZoom(state);
|
||||
context.events.emit("pdf:fit-changed", { mode });
|
||||
}
|
||||
|
||||
function zoomPDFIn(state: PDFNavigationState, context: ReaderContext): void {
|
||||
setPDFZoom(state, state.currentScale * 1.2, context);
|
||||
}
|
||||
|
||||
function zoomPDFOut(state: PDFNavigationState, context: ReaderContext): void {
|
||||
setPDFZoom(state, state.currentScale / 1.2, context);
|
||||
}
|
||||
|
||||
function updatePDFZoom(state: PDFNavigationState): void {
|
||||
const event = new CustomEvent("pdf-update-zoom", {
|
||||
detail: {
|
||||
scale: state.currentScale,
|
||||
fitMode: state.fitMode,
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
function setupPDFKeyboardNav(context: ReaderContext, state: PDFNavigationState): void {
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
||||
nextPDFPage(state, context);
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
||||
previousPDFPage(state, context);
|
||||
} else if (e.key === "+" || e.key === "=") {
|
||||
zoomPDFIn(state, context);
|
||||
} else if (e.key === "-" || e.key === "_") {
|
||||
zoomPDFOut(state, context);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setupPDFScrollTracking(context: ReaderContext, state: PDFNavigationState): void {
|
||||
if (!state.scrollContainer) return;
|
||||
|
||||
state.scrollContainer.addEventListener("scroll", () => {
|
||||
const currentPage = getCurrentPDFPage(state);
|
||||
if (currentPage !== state.currentPage) {
|
||||
state.currentPage = currentPage;
|
||||
context.events.emit("pdf:page-changed", { page: currentPage });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getCurrentPDFPage(state: PDFNavigationState): number {
|
||||
if (!state.scrollContainer) return state.currentPage;
|
||||
|
||||
const containerRect = state.scrollContainer.getBoundingClientRect();
|
||||
const viewportMiddle = containerRect.top + containerRect.height / 2;
|
||||
|
||||
for (let i = 1; i <= state.totalPages; i++) {
|
||||
const pageElement = state.scrollContainer.querySelector(
|
||||
`[data-page-number="${i}"]`,
|
||||
);
|
||||
if (pageElement) {
|
||||
const rect = pageElement.getBoundingClientRect();
|
||||
if (rect.top <= viewportMiddle && rect.bottom >= viewportMiddle) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return state.currentPage;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// 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}`;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// PDF text selection - Uses backend API for highlight creation
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
|
||||
export function init(context: ReaderContext): void {
|
||||
let currentMediaItemId: string | null = null;
|
||||
|
||||
context.events.on("reader:loaded", (detail: { mediaItemId: string }) => {
|
||||
currentMediaItemId = detail.mediaItemId;
|
||||
});
|
||||
|
||||
context.events.on("pdf:selection:get", () => {
|
||||
const selection = getPDFTextSelection();
|
||||
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:highlights:load", async (detail: { container: HTMLElement }) => {
|
||||
if (currentMediaItemId) {
|
||||
await loadAndRenderPDFHighlights(currentMediaItemId, detail.container);
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("reader:unload", () => {
|
||||
currentMediaItemId = null;
|
||||
});
|
||||
}
|
||||
|
||||
interface PDFTextSelection {
|
||||
pageNumber: number;
|
||||
text: string;
|
||||
rects: DOMRect[];
|
||||
}
|
||||
|
||||
export function getPDFTextSelection(): PDFTextSelection | 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;
|
||||
|
||||
const pageElement =
|
||||
range.commonAncestorContainer.parentElement?.closest?.("[data-page-number]") as HTMLElement;
|
||||
const pageNumber = pageElement?.dataset.pageNumber
|
||||
? parseInt(pageElement.dataset.pageNumber)
|
||||
: getCurrentPDFPage();
|
||||
|
||||
const rects: DOMRect[] = [];
|
||||
for (const rect of Array.from(range.getClientRects())) {
|
||||
rects.push(rect);
|
||||
}
|
||||
|
||||
return {
|
||||
pageNumber,
|
||||
text,
|
||||
rects,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createPDFHighlight(
|
||||
mediaItemId: string,
|
||||
selection: PDFTextSelection,
|
||||
color: string,
|
||||
): Promise<any> {
|
||||
const selectionData = {
|
||||
selection_text: selection.text,
|
||||
page_number: selection.pageNumber,
|
||||
rects: selection.rects.map((rect) => ({
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
})),
|
||||
color,
|
||||
};
|
||||
|
||||
const response = await fetch(`/api/media-items/${mediaItemId}/highlights`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(selectionData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create highlight: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function loadAndRenderPDFHighlights(
|
||||
mediaItemId: string,
|
||||
container: HTMLElement,
|
||||
): Promise<void> {
|
||||
const response = await fetch(`/api/media-items/${mediaItemId}/highlights`);
|
||||
if (!response.ok) return;
|
||||
|
||||
const highlights: any[] = await response.json();
|
||||
|
||||
for (const highlight of highlights) {
|
||||
renderPDFHighlight(container, highlight);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPDFHighlight(
|
||||
container: HTMLElement,
|
||||
highlight: any,
|
||||
): void {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "pdf-highlight-annotation";
|
||||
overlay.dataset.highlightId = highlight.id;
|
||||
overlay.style.backgroundColor = parseColor(highlight.color || "#ffff00");
|
||||
|
||||
for (const rect of highlight.rects || []) {
|
||||
const rectDiv = document.createElement("div");
|
||||
rectDiv.className = "pdf-highlight-rect";
|
||||
rectDiv.style.left = `${rect.x}px`;
|
||||
rectDiv.style.top = `${rect.y}px`;
|
||||
rectDiv.style.width = `${rect.width}px`;
|
||||
rectDiv.style.height = `${rect.height}px`;
|
||||
overlay.appendChild(rectDiv);
|
||||
}
|
||||
|
||||
container.appendChild(overlay);
|
||||
}
|
||||
|
||||
function parseColor(color: string): string {
|
||||
if (color.startsWith("#")) {
|
||||
const hex = color.slice(1);
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, 0.4)`;
|
||||
}
|
||||
|
||||
return color;
|
||||
}
|
||||
|
||||
function getCurrentPDFPage(): number {
|
||||
const pageElement = document.querySelector("[data-page-number]");
|
||||
return pageElement ? parseInt(pageElement.getAttribute("data-page-number") || "1") : 1;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Render a page's content to the DOM
|
||||
export function renderPage(container: HTMLElement, content: string): void {
|
||||
container.innerHTML = "";
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "reflowable-page";
|
||||
wrapper.style.height = "calc(100vh - 120px)";
|
||||
wrapper.style.overflow = "hidden";
|
||||
wrapper.style.position = "relative";
|
||||
wrapper.style.display = "flex";
|
||||
wrapper.style.flexDirection = "column";
|
||||
|
||||
// Parse the HTML content (which is already sliced by getPageContent)
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.innerHTML = content;
|
||||
const pageContent = tempDiv.querySelector(".page-content-wrapper");
|
||||
|
||||
if (!pageContent) {
|
||||
// Fallback if wrapper not found
|
||||
const contentDiv = document.createElement("div");
|
||||
contentDiv.className = "page-content";
|
||||
contentDiv.innerHTML = content;
|
||||
contentDiv.style.height = "100%";
|
||||
contentDiv.style.overflow = "hidden";
|
||||
contentDiv.style.flex = "1";
|
||||
contentDiv.style.overflowY = "auto";
|
||||
wrapper.appendChild(contentDiv);
|
||||
} else {
|
||||
// Transfer the sliced content to our wrapper
|
||||
const contentDiv = document.createElement("div");
|
||||
contentDiv.className = "page-content";
|
||||
contentDiv.style.height = "100%";
|
||||
contentDiv.style.overflow = "hidden";
|
||||
contentDiv.style.flex = "1";
|
||||
contentDiv.style.padding = "20px";
|
||||
|
||||
while (pageContent.firstChild) {
|
||||
contentDiv.appendChild(pageContent.firstChild);
|
||||
}
|
||||
|
||||
wrapper.appendChild(contentDiv);
|
||||
}
|
||||
|
||||
container.appendChild(wrapper);
|
||||
}
|
||||
|
||||
// Update container styles for paginated mode
|
||||
export function applyPaginatedStyles(): void {
|
||||
const existing = document.getElementById("reflowable-styles");
|
||||
existing?.remove();
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = "reflowable-styles";
|
||||
style.textContent = `
|
||||
.reflowable-page {
|
||||
height: calc(100vh - 120px) !important;
|
||||
overflow: hidden !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
height: 100% !important;
|
||||
overflow: hidden !important;
|
||||
-webkit-column-width: auto !important;
|
||||
column-width: auto !important;
|
||||
-webkit-column-count: 1 !important;
|
||||
column-count: 1 !important;
|
||||
-webkit-column-fill: auto !important;
|
||||
column-fill: auto !important;
|
||||
}
|
||||
|
||||
.page-content img {
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.page-content p {
|
||||
margin: 0.5em 0 !important;
|
||||
text-align: justify !important;
|
||||
}
|
||||
|
||||
.page-content h1,
|
||||
.page-content h2,
|
||||
.page-content h3,
|
||||
.page-content h4,
|
||||
.page-content h5,
|
||||
.page-content h6 {
|
||||
margin: 1em 0 0.5em 0 !important;
|
||||
page-break-after: avoid !important;
|
||||
break-after: avoid !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// Clear all styles
|
||||
export function clearPaginatedStyles(): void {
|
||||
const existing = document.getElementById("reflowable-styles");
|
||||
existing?.remove();
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Handle text copying with citation
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
import { showToast } from "../../toast";
|
||||
|
||||
export function init(context: ReaderContext): void {
|
||||
let mediaItem: MediaItemSummary | null = null;
|
||||
|
||||
context.events.on("reader:loaded", (detail: { mediaItem: MediaItemSummary }) => {
|
||||
mediaItem = detail.mediaItem;
|
||||
enableContextMenuCopy(mediaItem);
|
||||
});
|
||||
|
||||
context.events.on("copy:selection", async () => {
|
||||
if (mediaItem) {
|
||||
await copySelection(mediaItem);
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("reader:unload", () => {
|
||||
mediaItem = null;
|
||||
});
|
||||
}
|
||||
|
||||
async function copySelection(mediaItem: MediaItemSummary): Promise<boolean> {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return false;
|
||||
|
||||
const selectedText = selection.toString();
|
||||
if (!selectedText.trim()) return false;
|
||||
|
||||
const citation = createCitation(selectedText, mediaItem);
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(citation);
|
||||
showToast("Copied to clipboard", "success");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to copy:", error);
|
||||
showToast("Failed to copy to clipboard", "error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createCitation(text: string, mediaItem: MediaItemSummary): string {
|
||||
let citation = `"${text}"\n`;
|
||||
citation += `— ${mediaItem.title}`;
|
||||
if (mediaItem.author) {
|
||||
citation += ` by ${mediaItem.author}`;
|
||||
}
|
||||
citation += `\n(Source: Bookhoard)`;
|
||||
|
||||
return citation;
|
||||
}
|
||||
|
||||
function enableContextMenuCopy(mediaItem: MediaItemSummary): void {
|
||||
document.addEventListener("contextmenu", async (e) => {
|
||||
const selection = window.getSelection();
|
||||
const selectedText = selection?.toString().trim();
|
||||
|
||||
if (selectedText) {
|
||||
e.preventDefault();
|
||||
await copySelection(mediaItem);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Dictionary lookup popup for ebooks
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
|
||||
export function init(context: ReaderContext): void {
|
||||
context.events.on("dictionary:lookup", (detail: { word: string; position: { x: number; y: number } }) => {
|
||||
showDictionaryPopup(detail.word, detail.position);
|
||||
});
|
||||
|
||||
context.events.on("reader:loaded", () => {
|
||||
handleTextSelection();
|
||||
});
|
||||
|
||||
context.events.on("reader:unload", () => {
|
||||
const popup = document.getElementById("dictionary-popup");
|
||||
popup?.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function showDictionaryPopup(
|
||||
word: string,
|
||||
position: { x: number; y: number },
|
||||
): void {
|
||||
const existing = document.getElementById("dictionary-popup");
|
||||
existing?.remove();
|
||||
|
||||
const popup = document.createElement("div");
|
||||
popup.id = "dictionary-popup";
|
||||
popup.className =
|
||||
"absolute bg-white text-black p-4 rounded-lg shadow-xl max-w-md z-50";
|
||||
popup.style.left = `${position.x}px`;
|
||||
popup.style.top = `${position.y}px`;
|
||||
|
||||
popup.innerHTML = '<p class="text-sm">Loading...</p>';
|
||||
document.body.appendChild(popup);
|
||||
|
||||
lookupWord(word)
|
||||
.then((entry) => {
|
||||
popup.innerHTML = `
|
||||
<h3 class="font-bold text-lg">${entry.word}</h3>
|
||||
<p class="text-sm italic">${entry.part_of_speech || ""}</p>
|
||||
<p class="mt-2">${entry.definition}</p>
|
||||
${entry.example ? `<p class="mt-2 text-sm italic">"${entry.example}"</p>` : ""}
|
||||
`;
|
||||
})
|
||||
.catch(() => {
|
||||
popup.innerHTML = `<p class="text-red-500">Definition not found for "${word}"</p>`;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
document.addEventListener("click", function closePopup(e: MouseEvent) {
|
||||
if (!popup.contains(e.target as Node)) {
|
||||
popup.remove();
|
||||
document.removeEventListener("click", closePopup);
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function handleTextSelection(): void {
|
||||
document.addEventListener("mouseup", () => {
|
||||
const selection = window.getSelection();
|
||||
const selectedText = selection?.toString().trim();
|
||||
|
||||
if (selectedText && selectedText.split(" ").length === 1) {
|
||||
const range = selection?.getRangeAt(0);
|
||||
const rect = range?.getBoundingClientRect();
|
||||
|
||||
if (rect) {
|
||||
showDictionaryPopup(selectedText, { x: rect.left, y: rect.bottom });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function lookupWord(word: string): Promise<any> {
|
||||
const response = await fetch(`/api/dictionary/${word}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to lookup word: ${word}`);
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Font loading with performance optimization
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
|
||||
export function init(context: ReaderContext): void {
|
||||
const userPreferredFont = localStorage.getItem("reader-font") || "literata";
|
||||
|
||||
context.events.on("reader:loaded", async () => {
|
||||
await preloadFonts(userPreferredFont);
|
||||
});
|
||||
|
||||
context.events.on("font:change", async (detail: { fontId: string }) => {
|
||||
const stack = getFontStack(detail.fontId);
|
||||
applyFontStack(stack);
|
||||
await preloadFonts(detail.fontId);
|
||||
});
|
||||
|
||||
context.events.on("font:get-stack", (detail: { fontId: string }) => {
|
||||
const stack = getFontStack(detail.fontId);
|
||||
context.events.emit("font:stack-ready", { stack });
|
||||
});
|
||||
}
|
||||
|
||||
const READING_FONTS = [
|
||||
{
|
||||
id: "literata",
|
||||
name: "Literata",
|
||||
stack: "Literata, serif",
|
||||
description: "Designed for Google Play Books",
|
||||
},
|
||||
{
|
||||
id: "crimson",
|
||||
name: "Crimson Text",
|
||||
stack: "Crimson Text, serif",
|
||||
description: "Optimized for screen reading",
|
||||
},
|
||||
{
|
||||
id: "source-serif",
|
||||
name: "Source Serif 4",
|
||||
stack: "Source Serif 4, serif",
|
||||
description: "Professional Adobe quality",
|
||||
},
|
||||
{
|
||||
id: "eb-garamond",
|
||||
name: "EB Garamond",
|
||||
stack: "EB Garamond, serif",
|
||||
description: "Classic elegance",
|
||||
},
|
||||
{
|
||||
id: "libertinus",
|
||||
name: "Libertinus Serif",
|
||||
stack: "Libertinus Serif, serif",
|
||||
description: "Excellent for technical content",
|
||||
},
|
||||
{
|
||||
id: "noto-serif",
|
||||
name: "Noto Serif",
|
||||
stack: "Noto Serif, serif",
|
||||
description: "Maximum language support",
|
||||
},
|
||||
{
|
||||
id: "charis-sil",
|
||||
name: "Charis SIL",
|
||||
stack: "Charis SIL, serif",
|
||||
description: "Multilingual specialist",
|
||||
},
|
||||
{
|
||||
id: "ibm-plex",
|
||||
name: "IBM Plex Serif",
|
||||
stack: "IBM Plex Serif, serif",
|
||||
description: "Modern & versatile",
|
||||
},
|
||||
];
|
||||
|
||||
async function preloadFonts(userPreferredFont: string): Promise<void> {
|
||||
const fontsToPreload = new Set(["literata", userPreferredFont]);
|
||||
|
||||
for (const fontId of fontsToPreload) {
|
||||
const font = READING_FONTS.find((f) => f.id === fontId);
|
||||
if (font) {
|
||||
document.fonts.load(`16px "${font.stack}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getFontStack(fontId: string): string {
|
||||
const font = READING_FONTS.find((f) => f.id === fontId);
|
||||
return font?.stack || "Literata, serif";
|
||||
}
|
||||
|
||||
function applyFontStack(stack: string): void {
|
||||
document.documentElement.style.setProperty("--reader-font-family", stack);
|
||||
}
|
||||
|
||||
export { READING_FONTS, preloadFonts, getFontStack };
|
||||
@@ -0,0 +1,142 @@
|
||||
// Search within ebook content
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
|
||||
export function init(context: ReaderContext): void {
|
||||
let ebookData: any = null;
|
||||
|
||||
context.events.on("reader:loaded", (detail: { ebookData: any }) => {
|
||||
ebookData = detail.ebookData;
|
||||
});
|
||||
|
||||
context.events.on("search:execute", async (detail: { query: string }) => {
|
||||
if (ebookData) {
|
||||
const results = await searchEbook(ebookData, detail.query);
|
||||
context.events.emit("search:results", { results });
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("reader:unload", () => {
|
||||
ebookData = null;
|
||||
});
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
cfi: string;
|
||||
snippet: string;
|
||||
chapterTitle: string;
|
||||
}
|
||||
|
||||
export async function searchEbook(
|
||||
ebookData: any,
|
||||
query: string,
|
||||
): Promise<SearchResult[]> {
|
||||
const results: SearchResult[] = [];
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
if (!ebookData.spine) return results;
|
||||
|
||||
for (const spineItem of ebookData.spine) {
|
||||
const doc = await getSpineItemDocument(ebookData, spineItem);
|
||||
|
||||
if (!doc) continue;
|
||||
|
||||
const chapterTitle = getChapterTitle(spineItem);
|
||||
const textNodes = findTextNodes(doc.body);
|
||||
|
||||
for (const node of textNodes) {
|
||||
const text = node.textContent || "";
|
||||
const lowerText = text.toLowerCase();
|
||||
|
||||
let foundAt = 0;
|
||||
while ((foundAt = lowerText.indexOf(lowerQuery, foundAt)) !== -1) {
|
||||
const cfi = generateCFIForNode(node, foundAt);
|
||||
const snippet = extractSnippet(text, foundAt, query.length);
|
||||
|
||||
results.push({
|
||||
cfi,
|
||||
snippet,
|
||||
chapterTitle,
|
||||
});
|
||||
|
||||
foundAt += lowerQuery.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function getSpineItemDocument(
|
||||
ebookData: any,
|
||||
spineItem: any,
|
||||
): Promise<Document | null> {
|
||||
try {
|
||||
const resources = ebookData.resources;
|
||||
if (!resources) return null;
|
||||
|
||||
const content = await resources.get(spineItem.href)?.text();
|
||||
if (!content) return null;
|
||||
|
||||
const parser = new DOMParser();
|
||||
return parser.parseFromString(content, "text/html");
|
||||
} catch (error) {
|
||||
console.error("Failed to load spine item:", spineItem.href, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getChapterTitle(spineItem: any): string {
|
||||
return spineItem.id || `Section ${spineItem.index || ""}`;
|
||||
}
|
||||
|
||||
function findTextNodes(root: Node): Text[] {
|
||||
const textNodes: Text[] = [];
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: (node) => {
|
||||
const parent = node.parentElement;
|
||||
if (parent && ["SCRIPT", "STYLE", "NOSCRIPT"].includes(parent.tagName)) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
|
||||
if (!node.textContent?.trim()) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
},
|
||||
});
|
||||
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
textNodes.push(node as Text);
|
||||
}
|
||||
|
||||
return textNodes;
|
||||
}
|
||||
|
||||
function generateCFIForNode(node: Node, offset: number): string {
|
||||
const path: number[] = [];
|
||||
let current: Node | null = node;
|
||||
|
||||
while (current && current.parentNode) {
|
||||
const siblings = Array.from(current.parentNode.childNodes);
|
||||
const index = siblings.indexOf(current as ChildNode);
|
||||
path.unshift(index);
|
||||
current = current.parentNode;
|
||||
}
|
||||
|
||||
return `/6/4${path.map((i) => `/${i + 2}`).join("")}:${offset}`;
|
||||
}
|
||||
|
||||
function extractSnippet(text: string, offset: number, length: number): string {
|
||||
const start = Math.max(0, offset - 40);
|
||||
const end = Math.min(text.length, offset + length + 40);
|
||||
let snippet = text.substring(start, end);
|
||||
|
||||
if (start > 0) snippet = "..." + snippet;
|
||||
if (end < text.length) snippet = snippet + "...";
|
||||
|
||||
return snippet;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Typography engine for ebook rendering
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
|
||||
export function init(context: ReaderContext): void {
|
||||
let currentConfig: TypographyConfig | null = null;
|
||||
|
||||
context.events.on("reader:loaded", (detail: { container: HTMLElement; config?: Partial<TypographyConfig> }) => {
|
||||
currentConfig = {
|
||||
readingFont: "literata",
|
||||
fontSize: 18,
|
||||
lineHeight: 1.6,
|
||||
marginTop: 0,
|
||||
marginBottom: 16,
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
textAlign: "left",
|
||||
textIndent: 0,
|
||||
hyphenate: false,
|
||||
ligatures: true,
|
||||
fontSmoothing: "auto",
|
||||
...detail.config,
|
||||
};
|
||||
applyTypography(detail.container, currentConfig);
|
||||
});
|
||||
|
||||
context.events.on("typography:update", (detail: { container: HTMLElement; config: Partial<TypographyConfig> }) => {
|
||||
if (currentConfig) {
|
||||
currentConfig = updateTypographyConfig(currentConfig, detail.config);
|
||||
applyTypography(detail.container, currentConfig);
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("typography:measure", (detail: { container: HTMLElement }) => {
|
||||
const time = measureReadingTime(detail.container);
|
||||
context.events.emit("typography:reading-time", { minutes: time });
|
||||
});
|
||||
}
|
||||
|
||||
interface TypographyConfig {
|
||||
readingFont:
|
||||
| "literata"
|
||||
| "crimson"
|
||||
| "source-serif"
|
||||
| "eb-garamond"
|
||||
| "libertinus"
|
||||
| "noto-serif"
|
||||
| "charis-sil"
|
||||
| "ibm-plex";
|
||||
fontSize: number;
|
||||
lineHeight: number;
|
||||
marginTop: number;
|
||||
marginBottom: number;
|
||||
marginLeft: number;
|
||||
marginRight: number;
|
||||
textAlign: "left" | "right" | "center" | "justify";
|
||||
textIndent: number;
|
||||
hyphenate: boolean;
|
||||
ligatures: boolean;
|
||||
fontSmoothing: "auto" | "antialiased" | "subpixel-antialiased";
|
||||
}
|
||||
|
||||
function applyTypography(
|
||||
container: HTMLElement,
|
||||
config: TypographyConfig,
|
||||
): void {
|
||||
const content = container.querySelector(".ebook-content");
|
||||
if (!content) return;
|
||||
|
||||
const fontStack = getFontStack(config.readingFont);
|
||||
|
||||
content.setAttribute(
|
||||
"style",
|
||||
`
|
||||
font-family: ${fontStack};
|
||||
font-size: ${config.fontSize}px;
|
||||
line-height: ${config.lineHeight};
|
||||
text-align: ${config.textAlign};
|
||||
margin-top: ${config.marginTop}px;
|
||||
margin-bottom: ${config.marginBottom}px;
|
||||
margin-left: ${config.marginLeft}px;
|
||||
margin-right: ${config.marginRight}px;
|
||||
text-indent: ${config.textIndent}px;
|
||||
-webkit-font-smoothing: ${config.fontSmoothing};
|
||||
-moz-osx-font-smoothing: auto;
|
||||
`,
|
||||
);
|
||||
|
||||
if (config.hyphenate) {
|
||||
enableHyphenation(container, content as HTMLElement);
|
||||
}
|
||||
|
||||
setLigatures(content as HTMLElement, config.ligatures);
|
||||
|
||||
if (config.textAlign === "justify") {
|
||||
enableJustification(content as HTMLElement);
|
||||
}
|
||||
}
|
||||
|
||||
function getFontStack(fontId: string): string {
|
||||
const fonts: Record<string, string> = {
|
||||
"literata": "Literata, serif",
|
||||
"crimson": "Crimson Text, serif",
|
||||
"source-serif": "Source Serif 4, serif",
|
||||
"eb-garamond": "EB Garamond, serif",
|
||||
"libertinus": "Libertinus Serif, serif",
|
||||
"noto-serif": "Noto Serif, serif",
|
||||
"charis-sil": "Charis SIL, serif",
|
||||
"ibm-plex": "IBM Plex Serif, serif",
|
||||
};
|
||||
return fonts[fontId] || "Literata, serif";
|
||||
}
|
||||
|
||||
function enableHyphenation(container: HTMLElement, element: HTMLElement): void {
|
||||
element.style.hyphens = "auto";
|
||||
element.style.hyphenateLimitChars = "6 3 3";
|
||||
|
||||
const lang =
|
||||
container.closest("[data-language]")?.getAttribute("data-language") || "en";
|
||||
element.setAttribute("lang", lang);
|
||||
}
|
||||
|
||||
function setLigatures(element: HTMLElement, enabled: boolean): void {
|
||||
if (enabled) {
|
||||
element.style.fontVariantLigatures = "common-ligatures";
|
||||
element.style.fontFeatureSettings = '"liga", "dlig"';
|
||||
} else {
|
||||
element.style.fontVariantLigatures = "no-common-ligatures";
|
||||
element.style.fontFeatureSettings = "normal";
|
||||
}
|
||||
}
|
||||
|
||||
function enableJustification(element: HTMLElement): void {
|
||||
element.style.wordBreak = "normal";
|
||||
element.style.overflowWrap = "break-word";
|
||||
element.style.wordWrap = "break-word";
|
||||
element.style.letterSpacing = "0.01em";
|
||||
}
|
||||
|
||||
function updateTypographyConfig(
|
||||
currentConfig: TypographyConfig,
|
||||
newConfig: Partial<TypographyConfig>,
|
||||
): TypographyConfig {
|
||||
return { ...currentConfig, ...newConfig };
|
||||
}
|
||||
|
||||
function measureReadingTime(
|
||||
container: HTMLElement,
|
||||
wordsPerMinute: number = 250,
|
||||
): number {
|
||||
const content = container.querySelector(".ebook-content");
|
||||
if (!content) return 0;
|
||||
|
||||
const text = content.textContent || "";
|
||||
const words = text.split(/\s+/).length;
|
||||
const minutes = words / wordsPerMinute;
|
||||
|
||||
return Math.ceil(minutes);
|
||||
}
|
||||
|
||||
export { applyTypography, getFontStack };
|
||||
@@ -0,0 +1,106 @@
|
||||
// Import types
|
||||
import type { ReadingPosition, ReflowableBook } from "./types";
|
||||
import {
|
||||
getPageContent,
|
||||
findPageByCFI,
|
||||
createPositionFromPage,
|
||||
} from "./page-calculator";
|
||||
|
||||
// Navigate to specific page
|
||||
export function goToPage(
|
||||
book: ReflowableBook,
|
||||
targetPage: number,
|
||||
): {
|
||||
success: boolean;
|
||||
position: ReadingPosition;
|
||||
content: string;
|
||||
} {
|
||||
if (!book.pagination) {
|
||||
return { success: false, position: createDefaultPosition(), content: "" };
|
||||
}
|
||||
|
||||
const pageIndex = Math.max(
|
||||
0,
|
||||
Math.min(targetPage - 1, book.pagination.totalPages - 1),
|
||||
);
|
||||
const content = getPageContent(book.pagination, pageIndex);
|
||||
const position = createPositionFromPage(book, pageIndex + 1);
|
||||
|
||||
return { success: true, position, content };
|
||||
}
|
||||
|
||||
// Navigate to next page
|
||||
export function nextPage(book: ReflowableBook): {
|
||||
success: boolean;
|
||||
position: ReadingPosition;
|
||||
content: string;
|
||||
} {
|
||||
const nextPageNum = book.position.currentPage + 1;
|
||||
return goToPage(book, nextPageNum);
|
||||
}
|
||||
|
||||
// Navigate to previous page
|
||||
export function previousPage(book: ReflowableBook): {
|
||||
success: boolean;
|
||||
position: ReadingPosition;
|
||||
content: string;
|
||||
} {
|
||||
const prevPageNum = book.position.currentPage - 1;
|
||||
return goToPage(book, prevPageNum);
|
||||
}
|
||||
|
||||
// Jump to specific CFI
|
||||
export function goToCFI(
|
||||
book: ReflowableBook,
|
||||
cfi: string,
|
||||
): {
|
||||
success: boolean;
|
||||
position: ReadingPosition;
|
||||
content: string;
|
||||
} {
|
||||
if (!book.pagination) {
|
||||
return { success: false, position: createDefaultPosition(), content: "" };
|
||||
}
|
||||
|
||||
const pageNum = findPageByCFI(book.pagination, cfi);
|
||||
return goToPage(book, pageNum);
|
||||
}
|
||||
|
||||
// Create default position
|
||||
function createDefaultPosition(): ReadingPosition {
|
||||
return {
|
||||
currentPage: 1,
|
||||
spineIndex: 0,
|
||||
localPageIndex: 0,
|
||||
cfi: "",
|
||||
progress: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if navigation is possible
|
||||
export function canGoNext(book: ReflowableBook): boolean {
|
||||
return book.position.currentPage < (book.pagination?.totalPages || 1);
|
||||
}
|
||||
|
||||
// Check if previous navigation is possible
|
||||
export function canGoPrevious(book: ReflowableBook): boolean {
|
||||
return book.position.currentPage > 1;
|
||||
}
|
||||
|
||||
// Get progress percentage
|
||||
export function getProgressPercentage(book: ReflowableBook): number {
|
||||
return Math.round(book.position.progress * 100);
|
||||
}
|
||||
|
||||
// Update book position (after resize/recalculation)
|
||||
export function updatePosition(
|
||||
book: ReflowableBook,
|
||||
newCFI?: string,
|
||||
): ReadingPosition {
|
||||
if (newCFI && book.pagination) {
|
||||
const pageNum = findPageByCFI(book.pagination, newCFI);
|
||||
return createPositionFromPage(book, pageNum);
|
||||
}
|
||||
|
||||
return book.position;
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
// Import types
|
||||
import type {
|
||||
SpineItem,
|
||||
SpineInfo,
|
||||
PageBoundary,
|
||||
PaginationData,
|
||||
PaginationSettings,
|
||||
ReadingPosition,
|
||||
ReflowableBook,
|
||||
} from "./types";
|
||||
|
||||
// Constants for word count estimation (from Kavita)
|
||||
const WORDS_PER_PAGE_BASE = 250; // At 16px font, 1.6 line height
|
||||
|
||||
// Calculate words per page based on settings
|
||||
function calculateWordsPerPage(settings: PaginationSettings): number {
|
||||
const fontSizeFactor = 16 / settings.fontSize;
|
||||
const lineHeightFactor = 1.6 / settings.lineHeight;
|
||||
const areaFactor =
|
||||
(settings.viewportWidth * settings.viewportHeight) / (800 * 600);
|
||||
|
||||
return Math.round(
|
||||
WORDS_PER_PAGE_BASE * fontSizeFactor * lineHeightFactor * areaFactor,
|
||||
);
|
||||
}
|
||||
|
||||
// Extract plain text from HTML
|
||||
function extractTextFromHTML(html: string): string {
|
||||
// Remove script and style tags
|
||||
const withoutScripts = html.replace(
|
||||
/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
|
||||
"",
|
||||
);
|
||||
const withoutStyles = withoutScripts.replace(
|
||||
/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi,
|
||||
"",
|
||||
);
|
||||
|
||||
// Extract text content (simple version, no DOM)
|
||||
return withoutStyles
|
||||
.replace(/<[^>]*>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Count words in text
|
||||
function countWords(text: string): number {
|
||||
return text
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length > 0).length;
|
||||
}
|
||||
|
||||
// Split text into word ranges for pages
|
||||
function splitIntoWordRanges(
|
||||
wordCount: number,
|
||||
wordsPerPage: number,
|
||||
): Array<{ start: number; end: number }> {
|
||||
const ranges: Array<{ start: number; end: number }> = [];
|
||||
let start = 0;
|
||||
|
||||
while (start < wordCount) {
|
||||
const end = Math.min(start + wordsPerPage, wordCount);
|
||||
ranges.push({ start, end });
|
||||
start = end;
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
// Escape special characters in CFI
|
||||
function escapeCFIString(str: string): string {
|
||||
return str
|
||||
.replace(/\[/g, "\\[")
|
||||
.replace(/\]/g, "\\]")
|
||||
.replace(/\(/g, "\\(")
|
||||
.replace(/\)/g, "\\)")
|
||||
.replace(/,/g, "\\,")
|
||||
.replace(/;/g, "\\;")
|
||||
.replace(/=/g, "\\=");
|
||||
}
|
||||
|
||||
// Generate EPUB CFI for a position in spine
|
||||
// Follows EPUB CFI spec: https://www.w3.org/TR/epub-cfi/
|
||||
// Format: epubcfi(/6/spine_index!/path/element/offset)
|
||||
function generateCFI(
|
||||
spineIndex: number,
|
||||
charOffset: number,
|
||||
totalChars: number,
|
||||
spineItemId: string,
|
||||
): string {
|
||||
const escapedId = spineItemId ? `[${escapeCFIString(spineItemId)}]` : "";
|
||||
const offset = Math.min(charOffset, totalChars);
|
||||
const spinePath = `/6/${spineIndex + 2}${escapedId}`;
|
||||
|
||||
return `epubcfi(${spinePath}!/4/2/1:${offset})`;
|
||||
}
|
||||
|
||||
// Parse EPUB CFI to extract position
|
||||
function parseCFI(
|
||||
cfi: string,
|
||||
): { spineIndex: number; charOffset: number } | null {
|
||||
if (!cfi.startsWith("epubcfi(")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove epubcfi( wrapper
|
||||
const inner = cfi.slice(8, -1);
|
||||
if (!inner) return null;
|
||||
|
||||
// Split on ! to separate spine path from content path
|
||||
const parts = inner.split("!");
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
// Extract spine index from /6/4 or /6/4[id] format
|
||||
const spineMatch = parts[0].match(/\/6\/(\d+)/);
|
||||
if (!spineMatch) return null;
|
||||
|
||||
const spineIndex = parseInt(spineMatch[1]) - 2; // Adjust for offset
|
||||
if (spineIndex < 0) return null;
|
||||
|
||||
// Extract character offset from :123 format
|
||||
const offsetMatch = parts[1].match(/:(\d+)$/);
|
||||
if (!offsetMatch) return null;
|
||||
|
||||
const charOffset = parseInt(offsetMatch[1]);
|
||||
|
||||
return { spineIndex, charOffset };
|
||||
}
|
||||
|
||||
// Calculate pagination for entire book
|
||||
export async function calculatePagination(
|
||||
spineItems: SpineItem[],
|
||||
contentMap: Map<string, Blob>,
|
||||
settings: PaginationSettings,
|
||||
): Promise<PaginationData> {
|
||||
const wordsPerPage = calculateWordsPerPage(settings);
|
||||
const spines: SpineInfo[] = [];
|
||||
const pageMap = new Map<number, PageBoundary>();
|
||||
let globalPageIndex = 0;
|
||||
|
||||
// Process each spine item
|
||||
for (let i = 0; i < spineItems.length; i++) {
|
||||
const spineItem = spineItems[i];
|
||||
|
||||
// Skip non-HTML items (cover pages, etc)
|
||||
if (spineItem.type !== "html") {
|
||||
spines.push({
|
||||
spineIndex: i,
|
||||
spineItemId: spineItem.id,
|
||||
content: "",
|
||||
charCount: 0,
|
||||
wordCount: 0,
|
||||
cfiStart: "",
|
||||
pages: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get content
|
||||
const contentBlob = contentMap.get(spineItem.content);
|
||||
if (!contentBlob) {
|
||||
console.warn(`Content not found for spine ${spineItem.id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const contentHTML = await contentBlob.text();
|
||||
const plainText = extractTextFromHTML(contentHTML);
|
||||
const wordCount = countWords(plainText);
|
||||
const charCount = plainText.length;
|
||||
|
||||
// Skip empty spines
|
||||
if (wordCount === 0) {
|
||||
spines.push({
|
||||
spineIndex: i,
|
||||
spineItemId: spineItem.id,
|
||||
content: contentHTML,
|
||||
charCount,
|
||||
wordCount,
|
||||
cfiStart: generateCFI(i, 0, charCount, spineItem.id),
|
||||
pages: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split into pages
|
||||
const wordRanges = splitIntoWordRanges(wordCount, wordsPerPage);
|
||||
const pages: PageBoundary[] = [];
|
||||
|
||||
for (let j = 0; j < wordRanges.length; j++) {
|
||||
const range = wordRanges[j];
|
||||
const page: PageBoundary = {
|
||||
pageIndex: globalPageIndex,
|
||||
localPageIndex: j,
|
||||
charStart: Math.round((range.start / wordCount) * charCount),
|
||||
charEnd: Math.round((range.end / wordCount) * charCount),
|
||||
wordStart: range.start,
|
||||
wordEnd: range.end,
|
||||
cfi: generateCFI(
|
||||
i,
|
||||
Math.round((range.start / wordCount) * charCount),
|
||||
charCount,
|
||||
spineItem.id,
|
||||
),
|
||||
};
|
||||
|
||||
pages.push(page);
|
||||
pageMap.set(globalPageIndex, page);
|
||||
globalPageIndex++;
|
||||
}
|
||||
|
||||
spines.push({
|
||||
spineIndex: i,
|
||||
spineItemId: spineItem.id,
|
||||
content: contentHTML,
|
||||
charCount,
|
||||
wordCount,
|
||||
cfiStart: generateCFI(i, 0, charCount, spineItem.id),
|
||||
pages,
|
||||
});
|
||||
}
|
||||
|
||||
// Build map
|
||||
const spineMap = new Map<number, SpineInfo>();
|
||||
for (const spine of spines) {
|
||||
spineMap.set(spine.spineIndex, spine);
|
||||
}
|
||||
|
||||
return {
|
||||
totalPages: globalPageIndex,
|
||||
spines,
|
||||
spineMap,
|
||||
pageMap,
|
||||
calculatedAt: Date.now(),
|
||||
settings: { ...settings, wordsPerPage },
|
||||
};
|
||||
}
|
||||
|
||||
// Find which page contains a CFI
|
||||
export function findPageByCFI(
|
||||
pagination: PaginationData,
|
||||
targetCFI: string,
|
||||
): number {
|
||||
const parsed = parseCFI(targetCFI);
|
||||
if (!parsed) return 1;
|
||||
|
||||
const { spineIndex, charOffset } = parsed;
|
||||
const spine = pagination.spineMap.get(spineIndex);
|
||||
|
||||
if (!spine || spine.pages.length === 0) return 1;
|
||||
|
||||
// Find page containing this character offset
|
||||
for (const page of spine.pages) {
|
||||
if (charOffset >= page.charStart && charOffset < page.charEnd) {
|
||||
return page.pageIndex + 1; // 1-indexed
|
||||
}
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Extract HTML slice between character offsets
|
||||
function extractHTMLSlice(
|
||||
html: string,
|
||||
charStart: number,
|
||||
charEnd: number,
|
||||
): string {
|
||||
if (charStart === 0 && charEnd >= html.length) {
|
||||
return html;
|
||||
}
|
||||
|
||||
// Parse HTML and extract text nodes within the character range
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
const body = doc.body;
|
||||
|
||||
// Find all text nodes and their cumulative character counts
|
||||
type TextNodeInfo = { node: Text; startChar: number; endChar: number };
|
||||
const textNodes: TextNodeInfo[] = [];
|
||||
let cumulativeChars = 0;
|
||||
|
||||
function traverse(node: Node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent || "";
|
||||
const startChar = cumulativeChars;
|
||||
cumulativeChars += text.length;
|
||||
const endChar = cumulativeChars;
|
||||
|
||||
textNodes.push({ node: node as Text, startChar, endChar });
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
// Skip script and style tags
|
||||
if (node instanceof HTMLElement) {
|
||||
const tagName = node.tagName.toLowerCase();
|
||||
if (tagName === "script" || tagName === "style") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively traverse children
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
traverse(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(body);
|
||||
|
||||
// Find which text nodes intersect with the requested range
|
||||
const relevantNodes: { node: Text; before: string; after: string }[] = [];
|
||||
|
||||
for (const { node, startChar, endChar } of textNodes) {
|
||||
if (endChar <= charStart || startChar >= charEnd) {
|
||||
// No overlap
|
||||
continue;
|
||||
}
|
||||
const text = node.textContent || "";
|
||||
let resultText = text;
|
||||
// Trim from left if node starts before page
|
||||
if (startChar < charStart) {
|
||||
resultText = text.substring(charStart - startChar);
|
||||
}
|
||||
// Trim from right if node extends past page end
|
||||
if (endChar > charEnd) {
|
||||
// Calculate where to cut within the (potentially already trimmed) text
|
||||
const cutPosition = charEnd - startChar;
|
||||
resultText = text.substring(0, cutPosition);
|
||||
}
|
||||
// Handle case where both trims are needed
|
||||
if (startChar < charStart && endChar > charEnd) {
|
||||
const leftTrim = charStart - startChar;
|
||||
const rightTrim = endChar - charEnd;
|
||||
resultText = text.substring(leftTrim, text.length - rightTrim);
|
||||
}
|
||||
relevantNodes.push({ node, before: "", after: resultText });
|
||||
}
|
||||
|
||||
// Preserve original HTML structure for nodes in range
|
||||
const startNode = textNodes.find((n) => n.endChar > charStart);
|
||||
const endNode = textNodes.find((n) => n.startChar < charEnd);
|
||||
|
||||
if (!startNode || !endNode) {
|
||||
return html;
|
||||
}
|
||||
|
||||
// Find element boundaries
|
||||
let startElement: Node | null = startNode.node;
|
||||
while (startElement && startElement.parentNode !== body) {
|
||||
startElement = startElement.parentNode;
|
||||
}
|
||||
|
||||
let endElement: Node | null = endNode.node;
|
||||
while (endElement && endElement.parentNode !== body) {
|
||||
endElement = endElement.parentNode;
|
||||
}
|
||||
|
||||
// Extract and modify the relevant portion
|
||||
if (startElement && endElement) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
let currentElement: Node | null = startElement;
|
||||
let foundEnd = false;
|
||||
|
||||
while (currentElement && !foundEnd) {
|
||||
if (currentElement.nodeType === Node.ELEMENT_NODE) {
|
||||
const clone = (currentElement as Element).cloneNode(false);
|
||||
fragment.appendChild(clone);
|
||||
|
||||
// Process children
|
||||
for (const child of Array.from(currentElement.childNodes)) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const textNodeInfo = textNodes.find((n) => n.node === child);
|
||||
if (textNodeInfo) {
|
||||
const modified = document.createTextNode(
|
||||
relevantNodes.find((n) => n.node === child)?.after || "",
|
||||
);
|
||||
clone.appendChild(modified);
|
||||
}
|
||||
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
// Recursively handle element children
|
||||
const childClone = child.cloneNode(true);
|
||||
clone.appendChild(childClone);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentElement === endElement) {
|
||||
foundEnd = true;
|
||||
}
|
||||
}
|
||||
|
||||
currentElement = currentElement.nextSibling;
|
||||
}
|
||||
|
||||
// Serialize fragment back to HTML
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.appendChild(fragment);
|
||||
return tempDiv.innerHTML;
|
||||
}
|
||||
|
||||
// Fallback: return original HTML if extraction fails
|
||||
return html;
|
||||
}
|
||||
|
||||
// Get page content (HTML slice for a page)
|
||||
export function getPageContent(
|
||||
pagination: PaginationData,
|
||||
pageIndex: number,
|
||||
): string {
|
||||
const page = pagination.pageMap.get(pageIndex);
|
||||
if (!page) return "";
|
||||
|
||||
// Find the spine that contains this page
|
||||
// Pages are stored in order, so we can find the spine by checking which pages it contains
|
||||
let spine: SpineInfo | undefined;
|
||||
for (const s of pagination.spines) {
|
||||
if (s.pages.some((p) => p.pageIndex === pageIndex)) {
|
||||
spine = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!spine) return "";
|
||||
|
||||
// Extract HTML content between page boundaries
|
||||
const htmlSlice = extractHTMLSlice(
|
||||
spine.content,
|
||||
page.charStart,
|
||||
page.charEnd,
|
||||
);
|
||||
|
||||
// Wrap in a div to ensure valid HTML structure
|
||||
return `<div class="page-content-wrapper">${htmlSlice}</div>`;
|
||||
}
|
||||
|
||||
// Recalculate pagination on viewport change
|
||||
export function shouldRecalculate(
|
||||
pagination: PaginationData | null,
|
||||
newSettings: PaginationSettings,
|
||||
): boolean {
|
||||
if (!pagination) return true;
|
||||
|
||||
const sizeChanged =
|
||||
Math.abs(pagination.settings.viewportWidth - newSettings.viewportWidth) >
|
||||
50 ||
|
||||
Math.abs(pagination.settings.viewportHeight - newSettings.viewportHeight) >
|
||||
50;
|
||||
|
||||
const fontChanged = pagination.settings.fontSize !== newSettings.fontSize;
|
||||
const lineChanged = pagination.settings.lineHeight !== newSettings.lineHeight;
|
||||
|
||||
return sizeChanged || fontChanged || lineChanged;
|
||||
}
|
||||
|
||||
// Create position object from page number
|
||||
export function createPositionFromPage(
|
||||
book: ReflowableBook,
|
||||
page: number,
|
||||
): ReadingPosition {
|
||||
if (!book.pagination) {
|
||||
return {
|
||||
currentPage: 1,
|
||||
spineIndex: 0,
|
||||
localPageIndex: 0,
|
||||
cfi: "",
|
||||
progress: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const pageIndex = page - 1;
|
||||
const pageData = book.pagination.pageMap.get(pageIndex);
|
||||
|
||||
if (!pageData) {
|
||||
return {
|
||||
currentPage: 1,
|
||||
spineIndex: 0,
|
||||
localPageIndex: 0,
|
||||
cfi: "",
|
||||
progress: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Find which spine this page belongs to
|
||||
let spineIndex = 0;
|
||||
for (const spine of book.pagination.spines) {
|
||||
if (pageData.localPageIndex < spine.pages.length) {
|
||||
spineIndex = spine.spineIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentPage: page,
|
||||
spineIndex,
|
||||
localPageIndex: pageData.localPageIndex,
|
||||
cfi: pageData.cfi,
|
||||
progress:
|
||||
book.pagination.totalPages > 0 ? page / book.pagination.totalPages : 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Import types and existing parsers
|
||||
import type { ReflowableBook, SpineItem, TOCItem } from "./types";
|
||||
import { parseEPUB } from "../../parsers/epub-parsers";
|
||||
import { parseFB2 } from "../../parsers/fb2-parser";
|
||||
import { parseTXT } from "../../parsers/txt-parser";
|
||||
import { parseHTML } from "../../parsers/html-parser";
|
||||
|
||||
// Parse any reflowable format
|
||||
export async function parseReflowable(
|
||||
file: File,
|
||||
format: "epub" | "fb2" | "txt" | "html",
|
||||
): Promise<ReflowableBook> {
|
||||
switch (format) {
|
||||
case "epub":
|
||||
return await parseEPUB(file);
|
||||
case "fb2":
|
||||
return await parseFB2(file);
|
||||
case "txt":
|
||||
return await parseTXT(file);
|
||||
case "html":
|
||||
return await parseHTML(file);
|
||||
default:
|
||||
throw new Error(`Unsupported reflowable format: ${format}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate parsed book data
|
||||
export function validateBook(book: ReflowableBook): boolean {
|
||||
return book.spine.length > 0 && book.metadata.title !== "";
|
||||
}
|
||||
|
||||
// Get book title
|
||||
export function getBookTitle(book: ReflowableBook): string {
|
||||
return book.metadata.title || "Untitled";
|
||||
}
|
||||
|
||||
// Get book author
|
||||
export function getBookAuthor(book: ReflowableBook): string {
|
||||
return book.metadata.author || "Unknown";
|
||||
}
|
||||
|
||||
// Get total spine count
|
||||
export function getSpineCount(book: ReflowableBook): number {
|
||||
return book.spine.length;
|
||||
}
|
||||
|
||||
// Get TOC as flat list
|
||||
export function getFlatTOC(book: ReflowableBook): TOCItem[] {
|
||||
const flat: TOCItem[] = [];
|
||||
|
||||
function traverse(items: TOCItem[]) {
|
||||
for (const item of items) {
|
||||
flat.push(item);
|
||||
if (item.children.length > 0) {
|
||||
traverse(item.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(book.toc);
|
||||
return flat;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Import types
|
||||
import type { ReflowableBook, ReadingPosition } from "./types";
|
||||
import { findPageByCFI, createPositionFromPage } from "./page-calculator";
|
||||
|
||||
// Update current position
|
||||
export function updateCurrentPosition(
|
||||
book: ReflowableBook,
|
||||
position: ReadingPosition,
|
||||
): ReflowableBook {
|
||||
return {
|
||||
...book,
|
||||
position,
|
||||
};
|
||||
}
|
||||
|
||||
// Extract CFI from position
|
||||
export function getCurrentCFI(book: ReflowableBook): string {
|
||||
return book.position.cfi;
|
||||
}
|
||||
|
||||
// Calculate progress for display
|
||||
export function calculateProgress(book: ReflowableBook): {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
percentage: number;
|
||||
} {
|
||||
const totalPages = book.pagination?.totalPages || 1;
|
||||
const currentPage = book.position.currentPage;
|
||||
const percentage =
|
||||
totalPages > 0 ? Math.round((currentPage / totalPages) * 100) : 0;
|
||||
|
||||
return { currentPage, totalPages, percentage };
|
||||
}
|
||||
|
||||
// Get position for saving to database
|
||||
export function getPositionForSave(book: ReflowableBook): {
|
||||
cfi: string;
|
||||
progress: number;
|
||||
page: number;
|
||||
} {
|
||||
return {
|
||||
cfi: book.position.cfi,
|
||||
progress: book.position.progress,
|
||||
page: book.position.currentPage,
|
||||
};
|
||||
}
|
||||
|
||||
// Restore position from database
|
||||
export function restorePosition(
|
||||
book: ReflowableBook,
|
||||
savedCFI: string,
|
||||
savedPage?: number,
|
||||
): ReadingPosition {
|
||||
if (!book.pagination) {
|
||||
return book.position;
|
||||
}
|
||||
|
||||
// If we have saved CFI, try to find exact position
|
||||
if (savedCFI) {
|
||||
const pageNum = findPageByCFI(book.pagination, savedCFI);
|
||||
return createPositionFromPage(book, pageNum);
|
||||
}
|
||||
|
||||
// Otherwise use saved page number
|
||||
if (savedPage && savedPage > 0) {
|
||||
return createPositionFromPage(book, savedPage);
|
||||
}
|
||||
|
||||
return book.position;
|
||||
}
|
||||
|
||||
// Check if position changed significantly
|
||||
export function didPositionChange(
|
||||
oldPos: ReadingPosition,
|
||||
newPos: ReadingPosition,
|
||||
): boolean {
|
||||
return (
|
||||
oldPos.currentPage !== newPos.currentPage ||
|
||||
oldPos.cfi !== newPos.cfi ||
|
||||
Math.abs(oldPos.progress - newPos.progress) > 0.01
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Spine item structure from parsed EPUB/FB2/etc
|
||||
export interface SpineItem {
|
||||
id: string;
|
||||
type: "html" | "image" | "other";
|
||||
content: string; // Blob URL or content ID
|
||||
href?: string; // For CFI generation
|
||||
}
|
||||
|
||||
// Information about a single spine item
|
||||
export interface SpineInfo {
|
||||
spineIndex: number;
|
||||
spineItemId: string;
|
||||
content: string; // Full HTML content
|
||||
charCount: number; // Total characters
|
||||
wordCount: number; // Total words (for pagination)
|
||||
cfiStart: string; // CFI at start of this spine
|
||||
pages: PageBoundary[]; // Page boundaries within this spine
|
||||
}
|
||||
|
||||
// A single page boundary within a spine
|
||||
export interface PageBoundary {
|
||||
pageIndex: number; // Global page index
|
||||
localPageIndex: number; // Page index within this spine
|
||||
charStart: number; // Character offset from start of spine
|
||||
charEnd: number; // Character offset at end of page
|
||||
wordStart: number; // Word offset from start of spine
|
||||
wordEnd: number; // Word offset at end of page
|
||||
cfi: string; // CFI for this position
|
||||
}
|
||||
|
||||
// Complete pagination data
|
||||
export interface PaginationData {
|
||||
totalPages: number;
|
||||
spines: SpineInfo[];
|
||||
spineMap: Map<number, SpineInfo>;
|
||||
pageMap: Map<number, PageBoundary>; // pageIndex -> PageBoundary
|
||||
calculatedAt: number;
|
||||
settings: PaginationSettings;
|
||||
}
|
||||
|
||||
// Settings used for calculation
|
||||
export interface PaginationSettings {
|
||||
fontSize: number;
|
||||
lineHeight: number;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
wordsPerPage: number; // Calculated from above
|
||||
}
|
||||
|
||||
// Current reading position
|
||||
export interface ReadingPosition {
|
||||
currentPage: number;
|
||||
spineIndex: number;
|
||||
localPageIndex: number;
|
||||
cfi: string;
|
||||
progress: number; // 0-1
|
||||
}
|
||||
|
||||
// Reflowable book data
|
||||
export interface ReflowableBook {
|
||||
type: "epub" | "fb2" | "txt" | "html";
|
||||
spine: SpineItem[];
|
||||
resources: Map<string, Blob>;
|
||||
toc: TOCItem[];
|
||||
metadata: BookMetadata;
|
||||
pagination: PaginationData | null;
|
||||
position: ReadingPosition;
|
||||
}
|
||||
|
||||
// Table of contents item
|
||||
export interface TOCItem {
|
||||
id: string;
|
||||
title: string;
|
||||
href: string;
|
||||
children: TOCItem[];
|
||||
}
|
||||
|
||||
// Book metadata
|
||||
export interface BookMetadata {
|
||||
title: string;
|
||||
author: string;
|
||||
identifier: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
Reference in New Issue
Block a user