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:
2026-04-09 14:53:12 -04:00
parent 07ec8afa5f
commit e4c18e51f9
42 changed files with 5678 additions and 0 deletions
@@ -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;
}
+188
View File
@@ -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;
}
+142
View File
@@ -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 };
+150
View File
@@ -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;
}
+99
View File
@@ -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);
}
}
+148
View File
@@ -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 };
}
+125
View File
@@ -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);
}
+143
View File
@@ -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%;
}
`;
}
+132
View File
@@ -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;
}
}
+191
View File
@@ -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;
}
+184
View File
@@ -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,
};
}
+136
View File
@@ -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;
}
+151
View File
@@ -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;
}