style(reader): apply code formatting to existing format files

Run code formatting (prettier/eslint) on existing reader format files that
were not migrated to the new structure. This ensures consistent code style
across the codebase.

## Changes

### Formatting Applied
- Added newlines at end of files
- Fixed line length and indentation
- Updated import statements for consistency
- Applied code style rules uniformly

### Files Affected
- **Comic**: background-color, chapter-markers, page-order, panel-gap
- **Ebook**: font-loader, search, view-modes
- **Manga**: vertical-scroll-mode
- **PDF**: annotation-layer, pdf-navigation, pdf-text-selection

## Purpose

These files will be removed in subsequent commits as they are replaced by
the new modular structure. The formatting ensures consistency during the
transition period and maintains code quality standards.

Note: These are non-functional formatting changes only. No logic or behavior
was modified in this commit.
This commit is contained in:
2026-04-09 14:54:16 -04:00
parent ef5f6370d3
commit 43eee2170d
41 changed files with 238 additions and 4233 deletions
+22 -10
View File
@@ -7,17 +7,23 @@ 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: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(
"ui:show-settings",
(detail: { container: HTMLElement }) => {
renderBackgroundColorPicker(detail.container, state);
},
);
context.events.on("reader:unload", () => {
const picker = document.querySelector(".background-color-picker");
@@ -43,7 +49,9 @@ const backgroundColors: Record<BackgroundColor, string> = {
function createBackgroundColorState(
initial: BackgroundColor = "black",
): BackgroundColorState {
const saved = localStorage.getItem("reader-background-color") as BackgroundColor;
const saved = localStorage.getItem(
"reader-background-color",
) as BackgroundColor;
return {
current: saved || initial,
customColor: "#000000",
@@ -67,7 +75,8 @@ function setBackgroundColor(
state.current = color;
state.customColor = customColor || state.customColor;
const bgColor = color === "custom" ? state.customColor : backgroundColors[color];
const bgColor =
color === "custom" ? state.customColor : backgroundColors[color];
document.documentElement.style.setProperty("--reader-bg-color", bgColor);
const viewer = document.querySelector(".reader-content") as HTMLElement;
@@ -85,7 +94,9 @@ function setBackgroundColor(
return state;
}
function toggleBackgroundColor(state: BackgroundColorState): BackgroundColorState {
function toggleBackgroundColor(
state: BackgroundColorState,
): BackgroundColorState {
const order: BackgroundColor[] = ["black", "white", "gray", "sepia"];
const currentIndex = order.indexOf(state.current);
const nextIndex = (currentIndex + 1) % order.length;
@@ -132,4 +143,5 @@ function updateBackgroundColorUI(
buttons.forEach((btn, index) => {
btn.classList.toggle("border-blue-500", colors[index] === state.current);
});
}
}
+18 -11
View File
@@ -2,15 +2,18 @@
// Visual indicators for chapter boundaries
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
import { 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(
"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) {
@@ -24,11 +27,14 @@ export function init(context: ReaderContext): void {
}
});
context.events.on("chapter-markers:navigate", (detail: { chapterNumber: number }) => {
if (state) {
scrollToChapter(state, detail.chapterNumber);
}
});
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");
@@ -145,4 +151,5 @@ function scrollToChapter(
}),
);
}
}
}
-81
View File
@@ -1,81 +0,0 @@
// 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
@@ -1,188 +0,0 @@
// 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;
}
+27 -17
View File
@@ -2,14 +2,17 @@
// Auto-detect Japanese vs Western reading order
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
import { 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(
"reader:loaded",
(detail: { totalPages: number; pageNames: string[] }) => {
state = createPageOrderState(detail.totalPages, detail.pageNames);
},
);
context.events.on("page-order:set", (detail: { mode: PageOrderMode }) => {
if (state) {
@@ -24,19 +27,25 @@ export function init(context: ReaderContext): void {
}
});
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: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 });
}
});
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";
@@ -139,4 +148,5 @@ function getDisplayPageNumber(
}
return actualPage;
}
}
-120
View File
@@ -1,120 +0,0 @@
// 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 } }),
);
}
@@ -1,93 +0,0 @@
// 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 };
@@ -1,113 +0,0 @@
// 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 };
@@ -1,77 +0,0 @@
// 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 };
-172
View File
@@ -1,172 +0,0 @@
// 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,
};
-168
View File
@@ -1,168 +0,0 @@
// 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 };
+17 -7
View File
@@ -1,7 +1,7 @@
// Adjustable panel gap controls
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
import { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
const state = createPanelGapState();
@@ -23,9 +23,12 @@ export function init(context: ReaderContext): void {
togglePanelBorders(state);
});
context.events.on("ui:show-settings", (detail: { container: HTMLElement }) => {
renderPanelGapControls(detail.container, 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");
@@ -70,11 +73,17 @@ function setPanelGap(state: PanelGapState, gap: number): PanelGapState {
return state;
}
function increasePanelGap(state: PanelGapState, amount: number = 2): PanelGapState {
function increasePanelGap(
state: PanelGapState,
amount: number = 2,
): PanelGapState {
return setPanelGap(state, state.gapSize + amount);
}
function decreasePanelGap(state: PanelGapState, amount: number = 2): PanelGapState {
function decreasePanelGap(
state: PanelGapState,
amount: number = 2,
): PanelGapState {
return setPanelGap(state, state.gapSize - amount);
}
@@ -147,4 +156,5 @@ function updatePanelGapUI(container: HTMLElement, state: PanelGapState): void {
if (bordersBtn) {
bordersBtn.textContent = state.showBorders ? "▦" : "▢";
}
}
}
-149
View File
@@ -1,149 +0,0 @@
// EPUB CFI (Canonical Fragment Identifier) navigation
// Reuses logic from internal/sync/format.go
// Procedural style: Functions, not classes
interface CFIComponent {
type: "index" | "indirection-step" | "text-location";
value: number;
id?: string;
textOffset?: number;
}
// ============================================================
// CFI Parsing Functions
// ============================================================
export function parseCFI(cfi: string): CFIComponent[] {
const components: CFIComponent[] = [];
const cleanCFI = cfi.startsWith("!") ? cfi.substring(1) : cfi;
const parts = cleanCFI.split("/").filter(Boolean);
for (const part of parts) {
const match = part.match(/^(\d+)(?:\[([^\]]+)\])?(?::(\d+))?$/);
if (match) {
const component: CFIComponent = {
type: match[3] !== undefined ? "text-location" : "index",
value: parseInt(match[1], 10),
id: match[2],
textOffset: match[3] !== undefined ? parseInt(match[3], 10) : undefined,
};
components.push(component);
}
}
return components;
}
export function generateCFI(
spineIndex: number,
elementPath: number[],
textOffset: number = 0,
spineItemId?: string,
): string {
let cfi = `/6/${spineIndex}`;
if (spineItemId) {
cfi += `[${spineItemId}]`;
}
for (const index of elementPath) {
cfi += `/${index}`;
}
if (textOffset > 0) {
cfi += `:${textOffset}`;
}
return cfi;
}
export function navigateToCFI(
doc: Document,
cfi: string,
): Element | Text | null {
const components = parseCFI(cfi);
if (components.length === 0) return null;
let current: Node | null = doc.body;
for (let i = 1; i < components.length; i++) {
const component = components[i];
if (component.type === "index") {
if (current instanceof Element) {
const children = getElementChildren(current);
current = children[component.value] || null;
}
}
}
return current as Element | Text;
}
export function getSelectionCFI(doc: Document): string | null {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
const startContainer = range.startContainer;
// Build path to start container
const path: number[] = [];
let current: Node | null = startContainer;
while (current && current !== doc.body) {
const parent = current.parentElement;
if (parent) {
const siblings = getElementChildren(parent);
const index = siblings.indexOf(current as Element);
path.unshift(index);
}
current = parent;
}
const spineIndex = 0;
const textOffset = range.startOffset;
return generateCFI(spineIndex, path, textOffset);
}
export function getPercentageFromCFI(cfi: string): number {
const components = parseCFI(cfi);
const textLocation = components.find((c) => c.type === "text-location");
if (textLocation && textLocation.textOffset !== undefined) {
return Math.min(textLocation.textOffset / 10, 100);
}
return 0;
}
export function compareCFIs(cfi1: string, cfi2: string): number {
const components1 = parseCFI(cfi1);
const components2 = parseCFI(cfi2);
const maxLen = Math.max(components1.length, components2.length);
for (let i = 0; i < maxLen; i++) {
const comp1 = components1[i];
const comp2 = components2[i];
if (!comp1) return -1;
if (!comp2) return 1;
if (comp1.value !== comp2.value) {
return comp1.value - comp2.value;
}
}
return 0;
}
function getElementChildren(element: Element): Element[] {
return Array.from(element.children).filter(
(el) => el.nodeType === Node.ELEMENT_NODE,
) as Element[];
}
-67
View File
@@ -1,67 +0,0 @@
// 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);
}
});
}
-83
View File
@@ -1,83 +0,0 @@
// 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();
}
+3 -2
View File
@@ -1,7 +1,7 @@
// Font loading with performance optimization
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
import { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
const userPreferredFont = localStorage.getItem("reader-font") || "literata";
@@ -93,4 +93,5 @@ function applyFontStack(stack: string): void {
document.documentElement.style.setProperty("--reader-font-family", stack);
}
export { READING_FONTS, preloadFonts, getFontStack };
export { READING_FONTS, preloadFonts, getFontStack };
-248
View File
@@ -1,248 +0,0 @@
// HTML rendering with theme support, font loading, and image handling
// Procedural style: Functions, not classes
interface RendererConfig {
readingTheme: "light" | "sepia" | "dark" | "night" | "high-contrast";
readingFont:
| "literata"
| "crimson"
| "source-serif"
| "eb-garamond"
| "libertinus"
| "noto-serif"
| "charis-sil"
| "ibm-plex";
fontSize: number;
lineHeight: number;
marginWidth: number;
textAlign: "left" | "justify";
columnCount: 1 | 2;
}
// ============================================================
// Main Render Function
// ============================================================
export async function renderHTMLDocument(
doc: HTMLDocument,
container: HTMLElement,
config: RendererConfig,
): Promise<void> {
// Apply theme
applyHTMLTheme(container, config.readingTheme);
// Apply typography settings
applyHTMLTypography(container, config);
// Inject custom styles for reader
injectHTMLReaderStyles(container);
// Handle embedded fonts
await loadEmbeddedHTMLFonts(doc, container);
// Handle images
processHTMLImages(doc, container);
// Clear container and append content
container.innerHTML = "";
container.appendChild(doc.body);
// Apply column layout
applyHTMLColumnLayout(container, config.columnCount);
}
// ============================================================
// Theme Application
// ============================================================
function applyHTMLTheme(container: HTMLElement, theme: string): void {
const readingThemes: Record<string, Record<string, string>> = {
light: {
"--bg-primary": "#ffffff",
"--text-primary": "#1a1a1a",
"--text-secondary": "#666666",
"--accent": "#0066cc",
},
sepia: {
"--bg-primary": "#f4ecd8",
"--text-primary": "#5f4b32",
"--text-secondary": "#8b7355",
"--accent": "#8b4513",
},
dark: {
"--bg-primary": "#1a1b26",
"--text-primary": "#c0caf5",
"--text-secondary": "#565f89",
"--accent": "#7aa2f7",
},
night: {
"--bg-primary": "#0d1117",
"--text-primary": "#c9d1d9",
"--text-secondary": "#8b949e",
"--accent": "#58a6ff",
},
"high-contrast": {
"--bg-primary": "#000000",
"--text-primary": "#ffffff",
"--text-secondary": "#cccccc",
"--accent": "#ffff00",
},
};
const themeConfig = readingThemes[theme] || readingThemes["dark"];
for (const [key, value] of Object.entries(themeConfig)) {
container.style.setProperty(key, value);
}
}
function applyHTMLTypography(
container: HTMLElement,
config: RendererConfig,
): void {
const style = document.createElement("style");
const fontStack = getFontStack(config.readingFont);
style.textContent = `
.ebook-content {
font-family: ${fontStack};
font-size: ${config.fontSize}px;
line-height: ${config.lineHeight};
text-align: ${config.textAlign};
padding: 0 ${config.marginWidth}px;
max-width: 100%;
overflow-wrap: break-word;
}
.ebook-content p {
margin-bottom: 1em;
text-indent: ${config.textAlign === "justify" ? "1.5em" : "0"};
}
.ebook-content img {
max-width: 100%;
height: auto;
display: block;
margin: 1em auto;
}
.ebook-content a {
color: var(--accent);
text-decoration: underline;
}
.ebook-content a:active {
color: var(--text-secondary);
}
`;
container.appendChild(style);
}
function injectHTMLReaderStyles(container: HTMLElement): void {
container.setAttribute("role", "main");
container.setAttribute("aria-label", "Book content");
}
async function loadEmbeddedHTMLFonts(
doc: HTMLDocument,
container: HTMLElement,
): Promise<void> {
const styleSheets = doc.querySelectorAll("style");
for (const sheet of styleSheets) {
const fontFaceRegex = /@font-face\s*{([^}]+)}/g;
const matches = sheet.textContent?.matchAll(fontFaceRegex) || [];
for (const match of matches) {
const fontFace = match[1];
const urlMatch = /url\(['"]?([^'")]+)['"]?\)/.exec(fontFace);
if (urlMatch) {
const fontUrl = urlMatch[1];
await loadHTMLFont(fontUrl, container);
}
}
}
}
async function loadHTMLFont(
fontUrl: string,
container: HTMLElement,
): Promise<void> {
const loadedFonts = container.dataset.loadedFonts
? JSON.parse(container.dataset.loadedFonts)
: [];
if (loadedFonts.includes(fontUrl)) return;
try {
const fontFace = new FontFace("custom-font", `url(${fontUrl})`);
await fontFace.load();
document.fonts.add(fontFace);
loadedFonts.push(fontUrl);
container.dataset.loadedFonts = JSON.stringify(loadedFonts);
} catch (error) {
console.error("Failed to load font:", fontUrl, error);
}
}
function processHTMLImages(doc: HTMLDocument): void {
const images = doc.querySelectorAll("img");
images.forEach((img) => {
img.setAttribute("loading", "lazy");
if (!img.alt) {
img.alt = "Image from book";
}
img.style.cursor = "pointer";
img.addEventListener("click", () => {
showImageFullscreen(img.src);
});
});
}
function showImageFullscreen(src: string): void {
const modal = document.createElement("div");
modal.className =
"fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50";
modal.onclick = () => modal.remove();
const img = document.createElement("img");
img.src = src;
img.className = "max-w-full max-h-full object-contain";
modal.appendChild(img);
document.body.appendChild(modal);
}
function applyHTMLColumnLayout(
container: HTMLElement,
columnCount: number,
): void {
if (columnCount === 2) {
container.style.columnCount = "2";
container.style.columnGap = "20px";
container.style.columnRule = "1px solid var(--text-secondary)";
} else {
container.style.columnCount = "auto";
}
}
function getFontStack(font: string): string {
const stacks: 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 stacks[font] || stacks["literata"];
}
-134
View File
@@ -1,134 +0,0 @@
export interface SpineInfo {
spineIndex: number;
spineItemId: string;
content: string;
charCount: number;
estimatedPages: number;
}
export interface PageCalculationResult {
totalPages: number;
spines: SpineInfo[];
spineMap: Map<number, SpineInfo>;
calculatedAt: number;
}
export interface PageCalculationSettings {
fontSize: number;
lineHeight: number;
marginWidth: number;
viewportHeight: number;
}
export async function calculatePagesForEbook(
cif: EbookCIF,
_viewportWidth: number,
settings: {
fontSize?: number;
lineHeight?: number;
marginWidth?: number;
},
): Promise<PageCalculationResult> {
const fontSize = settings.fontSize || 16;
const lineHeight = settings.lineHeight || 1.6;
const charsPerPage = Math.round(2000 * (16 / fontSize) * (lineHeight / 1.6));
const spines: SpineInfo[] = [];
let estimatedTotalPages = 0;
for (let i = 0; i < cif.spine.length; i++) {
const spineItem = cif.spine[i];
if (spineItem.type !== "html") {
spines.push({
spineIndex: i,
spineItemId: spineItem.id,
content: "",
charCount: 0,
estimatedPages: 1,
});
estimatedTotalPages += 1;
continue;
}
const contentBlob = cif.resources.get(spineItem.content);
if (!contentBlob) {
spines.push({
spineIndex: i,
spineItemId: spineItem.id,
content: "",
charCount: 0,
estimatedPages: 1,
});
estimatedTotalPages += 1;
continue;
}
const contentText = await contentBlob.text();
const charCount = contentText.replace(/<[^>]*>/g, "").length;
const estimatedPages = Math.max(1, Math.ceil(charCount / charsPerPage));
estimatedTotalPages += estimatedPages;
spines.push({
spineIndex: i,
spineItemId: spineItem.id,
content: contentText,
charCount,
estimatedPages,
});
}
const spineMap = new Map<number, SpineInfo>();
for (const spine of spines) {
spineMap.set(spine.spineIndex, spine);
}
return {
totalPages: estimatedTotalPages,
spines,
spineMap,
calculatedAt: Date.now(),
};
}
export function getCurrentPageFromScroll(
pageInfo: PageCalculationResult,
currentSpineIndex: number,
scrollPosition: number,
viewportHeight: number,
): number {
const spine = pageInfo.spineMap.get(currentSpineIndex);
if (!spine) return 1;
const charsBeforeSpine = pageInfo.spines
.slice(0, currentSpineIndex)
.reduce((sum, s) => sum + s.charCount, 0);
const charsInSpine = spine.charCount;
const charsAtPosition = (scrollPosition / viewportHeight) * charsInSpine;
const totalCharsAtPosition = charsBeforeSpine + charsAtPosition;
const charsPerPage = 2000;
return Math.ceil(totalCharsAtPosition / charsPerPage);
}
export function getScrollPositionForPage(
pageInfo: PageCalculationResult,
targetPage: number,
viewportHeight: number,
): { spineIndex: number; scrollTop: number } | null {
const charsPerPage = 2000;
const targetChar = (targetPage - 1) * charsPerPage;
let charsAccumulated = 0;
for (const spine of pageInfo.spines) {
charsAccumulated += spine.charCount;
if (targetChar < charsAccumulated) {
const charsBefore = charsAccumulated - spine.charCount;
const charsIntoSpine = targetChar - charsBefore;
const scrollTop = (charsIntoSpine / spine.charCount) * viewportHeight;
return {
spineIndex: spine.spineIndex,
scrollTop: Math.max(
0,
Math.min(scrollTop, viewportHeight * spine.estimatedPages),
),
};
}
}
const lastSpine = pageInfo.spines[pageInfo.spines.length - 1];
return {
spineIndex: lastSpine?.spineIndex || 0,
scrollTop: 0,
};
}
export function calculateProgressPercentage(
pageInfo: PageCalculationResult,
currentPage: number,
): number {
if (pageInfo.totalPages <= 0) return 0;
return Math.round((currentPage / pageInfo.totalPages) * 100);
}
-276
View File
@@ -1,276 +0,0 @@
// Page splitter for ebooks - extracts discrete page content
// Uses CFI for EPUB when available, falls back to height-based splitting
import { generateCFI } from "./cfi-navigator";
export interface PageContent {
pageNumber: number;
spineIndex: number;
startCFI?: string;
endCFI?: string;
html: string;
charCount: number;
}
export interface PageSplitResult {
pages: PageContent[];
totalPages: number;
}
// Split content by viewport height (fallback for non-EPUB)
export function splitByHeight(
html: string,
spineIndex: number,
viewportHeight: number,
settings: { fontSize: number; lineHeight: number; marginWidth: number },
): PageSplitResult {
const pages: PageContent[] = [];
const doc = new DOMParser().parseFromString(html, "text/html");
// Clone body content
const content = doc.body;
const contentHeight = estimateContentHeight(content, settings);
const contentWidth = doc.body.scrollWidth || 600;
const pageCount = Math.max(1, Math.ceil(contentHeight / viewportHeight));
// For height-based splitting, we'll use a different approach:
// Wrap each block element and measure cumulative height
const blocks = Array.from(content.children);
let currentPageHTML = "";
let currentHeight = 0;
let pageNumber = 1;
for (const block of blocks) {
const blockHeight = estimateBlockHeight(block, settings, contentWidth);
if (
currentHeight + blockHeight > viewportHeight &&
currentPageHTML.length > 0
) {
// Save current page and start new one
pages.push({
pageNumber,
spineIndex,
html: wrapInPageContainer(currentPageHTML, pageNumber, pageCount),
charCount: currentPageHTML.replace(/<[^>]*>/g, "").length,
});
pageNumber++;
currentPageHTML = "";
currentHeight = 0;
}
currentPageHTML += block.outerHTML;
currentHeight += blockHeight;
}
// Push final page
if (currentPageHTML.length > 0 || pages.length === 0) {
pages.push({
pageNumber,
spineIndex,
html: wrapInPageContainer(currentPageHTML, pageNumber, pageCount),
charCount: currentPageHTML.replace(/<[^>]*>/g, "").length,
});
}
return { pages, totalPages: pages.length };
}
// Split content using CFI (for EPUB)
export function splitByCFI(
html: string,
spineIndex: number,
spineItemId: string,
viewportHeight: number,
settings: { fontSize: number; lineHeight: number; marginWidth: number },
): PageSplitResult {
console.log("splitByCFI called:", {
htmlLength: html.length,
viewportHeight,
spineIndex,
});
const pages: PageContent[] = [];
const doc = new DOMParser().parseFromString(html, "text/html");
// Find all text-containing elements (paragraphs, divs, etc.)
const elements = findTextElements(doc.body);
let currentPageElements: Element[] = [];
let currentHeight = 0;
const contentWidth = doc.body.scrollWidth || 600;
let pageNumber = 1;
let elementIndex = 0;
for (const element of elements) {
const elementHeight = estimateBlockHeight(element, settings, contentWidth);
if (
currentHeight + elementHeight > viewportHeight &&
currentPageElements.length > 0
) {
// Create page from accumulated elements
const pageHTML = elementsToHTML(currentPageElements);
const startIndex = elements.indexOf(currentPageElements[0]);
const endIndex = elements.indexOf(
currentPageElements[currentPageElements.length - 1],
);
pages.push({
pageNumber,
spineIndex,
startCFI: generateCFI(
spineIndex,
buildElementPath(elements[startIndex], doc.body),
0,
spineItemId,
),
endCFI: generateCFI(
spineIndex,
buildElementPath(elements[endIndex], doc.body),
0,
spineItemId,
),
html: wrapInPageContainer(pageHTML, pageNumber, 0), // pageCount TBD
charCount: pageHTML.replace(/<[^>]*>/g, "").length,
});
pageNumber++;
currentPageElements = [];
currentHeight = 0;
}
currentPageElements.push(element);
currentHeight += elementHeight;
elementIndex++;
}
// Push final page
if (currentPageElements.length > 0) {
const pageHTML = elementsToHTML(currentPageElements);
const startIndex = elements.indexOf(currentPageElements[0]);
const endIndex = elements.indexOf(
currentPageElements[currentPageElements.length - 1],
);
pages.push({
pageNumber,
spineIndex,
startCFI: generateCFI(
spineIndex,
buildElementPath(elements[startIndex], doc.body),
0,
spineItemId,
),
endCFI: generateCFI(
spineIndex,
buildElementPath(elements[endIndex], doc.body),
0,
spineItemId,
),
html: wrapInPageContainer(pageHTML, pageNumber, pageNumber),
charCount: pageHTML.replace(/<[^>]*>/g, "").length,
});
}
console.log("splitByCFI result:", {
totalPages: pages.length,
elementsFound: elements.length,
contentWidth,
});
return { pages, totalPages: pages.length };
}
// Helper: Estimate content height
function estimateContentHeight(element: Element, settings: any): number {
const text = element.textContent || "";
const charCount = text.length;
const charsPerLine = Math.floor(
(800 - settings.marginWidth * 2) / (settings.fontSize * 0.6),
);
const lineCount = Math.ceil(charCount / charsPerLine);
return lineCount * settings.fontSize * settings.lineHeight;
}
// Helper: Estimate block height
function estimateBlockHeight(
element: Element,
settings: any,
contentWidth: number,
): number {
const text = element.textContent || "";
const approxChars = text.length;
const lineHeight = settings.fontSize * settings.lineHeight;
// Use actual content width instead of hardcoded 800
const charsPerLine = Math.floor(contentWidth / (settings.fontSize * 0.6));
const lineCount = Math.max(1, Math.ceil(approxChars / charsPerLine));
return lineCount * lineHeight + 16;
}
// Helper: Find text-containing elements using querySelectorAll
function findTextElements(element: Element): Element[] {
const selector = [
"p",
"div",
"span",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"li",
"td",
"th",
"article",
"section",
"blockquote",
"pre",
"figcaption",
"header",
"footer",
"main",
"aside",
].join(",");
return Array.from(element.querySelectorAll(selector)).filter((el) => {
const text = el.textContent?.trim() || "";
return text.length > 0;
});
}
// Helper: Convert elements to HTML string
function elementsToHTML(elements: Element[]): string {
return elements.map((el) => el.outerHTML).join("");
}
// Helper: Build CFI element path
function buildElementPath(element: Element, root: Element): number[] {
const path: number[] = [];
let current: Element | null = element;
while (current && current !== root) {
const parent = current.parentElement;
if (parent) {
const siblings = Array.from(parent.children);
const index = siblings.indexOf(current);
path.unshift(index);
current = parent;
} else {
break;
}
}
return path;
}
// Helper: Wrap content in page container
function wrapInPageContainer(
html: string,
current: number,
total: number,
): string {
return `<div class="ebook-page" data-page="${current}" data-total="${total}">${html}</div>`;
}
// Main entry point - choose method based on format
export function splitContent(
html: string,
spineIndex: number,
spineItemId: string | null,
viewportHeight: number,
settings: { fontSize: number; lineHeight: number; marginWidth: number },
useCFI: boolean = true,
): PageSplitResult {
if (useCFI && spineItemId) {
return splitByCFI(html, spineIndex, spineItemId, viewportHeight, settings);
}
return splitByHeight(html, spineIndex, viewportHeight, settings);
}
+3 -2
View File
@@ -1,7 +1,7 @@
// Search within ebook content
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
import { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
let ebookData: any = null;
@@ -139,4 +139,5 @@ function extractSnippet(text: string, offset: number, length: number): string {
if (end < text.length) snippet = snippet + "...";
return snippet;
}
}
-162
View File
@@ -1,162 +0,0 @@
// 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 };
+1 -1
View File
@@ -1,7 +1,7 @@
// Different viewing modes for ebooks
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
import { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
let state: ViewModeState | null = null;
-129
View File
@@ -1,129 +0,0 @@
// 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";
}
-96
View File
@@ -1,96 +0,0 @@
// 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
@@ -1,99 +0,0 @@
// 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);
}
+28 -11
View File
@@ -1,25 +1,41 @@
// Vertical scroll mode for webtoons/manhwa
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
import { 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(
"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: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.emit("vertical-scroll:current-page", {
page: currentPage,
});
}
});
@@ -180,4 +196,5 @@ function destroyVerticalScroll(state: VerticalScrollState): void {
state.container.innerHTML = "";
state.loadedPages.clear();
state.loadingPages.clear();
}
}
+31 -16
View File
@@ -1,25 +1,34 @@
// Annotation layer for rendering highlights and notes on PDFs
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
import { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
const highlights = new Map<string, HTMLElement>();
context.events.on("pdf:highlights:render", (detail: { container: HTMLElement; highlights: any[] }) => {
clearPDFHighlights(detail.container);
for (const highlight of detail.highlights) {
renderSinglePDFHighlight(detail.container, highlight, highlights);
}
});
context.events.on(
"pdf:highlights:render",
(detail: { container: HTMLElement; highlights: any[] }) => {
clearPDFHighlights(detail.container);
for (const highlight of detail.highlights) {
renderSinglePDFHighlight(detail.container, highlight, highlights);
}
},
);
context.events.on("pdf:highlights:clear", (detail: { container: HTMLElement }) => {
clearPDFHighlights(detail.container);
});
context.events.on(
"pdf:highlights:clear",
(detail: { container: HTMLElement }) => {
clearPDFHighlights(detail.container);
},
);
context.events.on("pdf:highlight:remove", (detail: { highlightId: string }) => {
removePDFHighlight(detail.highlightId, highlights);
});
context.events.on(
"pdf:highlight:remove",
(detail: { highlightId: string }) => {
removePDFHighlight(detail.highlightId, highlights);
},
);
context.events.on("reader:unload", () => {
highlights.forEach((element) => element.remove());
@@ -90,7 +99,9 @@ function parseColor(color: string): string {
function showNotePopup(highlight: PDFHighlight): void {
console.log("Show note for highlight:", highlight.id);
const event = new CustomEvent("pdf:note-show", { detail: { highlightId: highlight.id } });
const event = new CustomEvent("pdf:note-show", {
detail: { highlightId: highlight.id },
});
window.dispatchEvent(event);
}
@@ -99,10 +110,14 @@ export function clearPDFHighlights(container: HTMLElement): void {
Array.from(highlights).forEach((element) => element.remove());
}
export function removePDFHighlight(highlightId: string, highlights: Map<string, HTMLElement>): void {
export function removePDFHighlight(
highlightId: string,
highlights: Map<string, HTMLElement>,
): void {
const element = highlights.get(highlightId);
if (element) {
element.remove();
highlights.delete(highlightId);
}
}
}
-148
View File
@@ -1,148 +0,0 @@
// 5-page ahead cache for PDF pages
// Pre-renders canvas and text layer for nearby pages
import { PDFPageProxy, PageViewport } from "pdfjs-dist";
interface CachedPage {
pageNumber: number;
canvas: HTMLCanvasElement;
textLayer: HTMLElement;
viewport: PageViewport;
timestamp: number;
}
// 5-page ahead cache for PDF pages
// Procedural implementation (no OOP)
interface CachedPage {
pageNumber: number;
canvas: HTMLCanvasElement;
textLayer: HTMLElement;
viewport: PageViewport;
timestamp: number;
}
interface PDFPageCacheState {
cache: Map<number, CachedPage>;
maxCacheSize: number;
renderCallbacks: Map<number, Array<() => void>>;
}
function createPDFPageCache(maxCacheSize: number = 5): PDFPageCacheState {
return {
cache: new Map(),
maxCacheSize,
renderCallbacks: new Map(),
};
}
async function getCachedPage(
state: PDFPageCacheState,
pageNumber: number,
renderFn: (
pageNumber: number,
) => Promise<{
canvas: HTMLCanvasElement;
textLayer: HTMLElement;
viewport: PageViewport;
}>,
): Promise<PDFPageCacheState & { page: CachedPage }> {
const cached = state.cache.get(pageNumber);
if (cached) {
cached.timestamp = Date.now();
return { ...state, page: cached };
}
const { canvas, textLayer, viewport } = await renderFn(pageNumber);
const cachedPage: CachedPage = {
pageNumber,
canvas,
textLayer,
viewport,
timestamp: Date.now(),
};
const newCache = new Map(state.cache);
newCache.set(pageNumber, cachedPage);
const callbacks = state.renderCallbacks.get(pageNumber);
if (callbacks) {
callbacks.forEach((cb) => cb());
const newCallbacks = new Map(state.renderCallbacks);
newCallbacks.delete(pageNumber);
return {
...state,
cache: newCache,
renderCallbacks: newCallbacks,
page: cachedPage,
};
}
return { ...state, cache: newCache, page: cachedPage };
}
function preloadPages(
state: PDFPageCacheState,
currentPage: number,
totalPages: number,
): PDFPageCacheState {
for (let i = 1; i <= state.maxCacheSize; i++) {
const pageNumber = currentPage + i;
if (pageNumber <= totalPages && !state.cache.has(pageNumber)) {
triggerPreload(pageNumber);
}
}
return state;
}
function triggerPreload(pageNumber: number): void {
console.log("Preloading page:", pageNumber);
}
function invalidatePage(
state: PDFPageCacheState,
pageNumber: number,
): PDFPageCacheState {
const cached = state.cache.get(pageNumber);
if (cached) {
cached.canvas.remove();
cached.textLayer.remove();
const newCache = new Map(state.cache);
newCache.delete(pageNumber);
return { ...state, cache: newCache };
}
return state;
}
function clearPageCache(state: PDFPageCacheState): PDFPageCacheState {
state.cache.forEach((page) => {
page.canvas.remove();
page.textLayer.remove();
});
return {
...state,
cache: new Map(),
};
}
function onPageRendered(
state: PDFPageCacheState,
pageNumber: number,
callback: () => void,
): PDFPageCacheState {
const newCallbacks = new Map(state.renderCallbacks);
if (!newCallbacks.has(pageNumber)) {
newCallbacks.set(pageNumber, []);
}
newCallbacks.get(pageNumber)!.push(callback);
return { ...state, renderCallbacks: newCallbacks };
}
-125
View File
@@ -1,125 +0,0 @@
// Custom bookmarks for PDF pages (saved in database)
// Procedural implementation (no OOP)
interface MediaBookmark {
id: string;
mediaItemId: string;
userId: string;
pageNumber: number;
title: string;
createdAt: string;
}
interface MediaBookmarksState {
mediaItemId: string;
bookmarks: MediaBookmark[];
}
function createMediaBookmarks(mediaItemId: string): MediaBookmarksState {
return {
mediaItemId,
bookmarks: [],
};
}
async function loadMediaBookmarks(
state: MediaBookmarksState,
): Promise<MediaBookmarksState> {
try {
const response = await fetch(
`/api/media-items/${state.mediaItemId}/bookmarks`,
);
if (!response.ok) throw new Error("Failed to load bookmarks");
const data = await response.json();
return { ...state, bookmarks: data.bookmarks || [] };
} catch (error) {
console.error("Failed to load bookmarks:", error);
return { ...state, bookmarks: [] };
}
}
async function addMediaBookmark(
state: MediaBookmarksState,
pageNumber: number,
title?: string,
): Promise<MediaBookmarksState & { bookmark: MediaBookmark }> {
const bookmark: MediaBookmark = {
id: crypto.randomUUID(),
mediaItemId: state.mediaItemId,
userId: "",
pageNumber,
title: title || `Page ${pageNumber}`,
createdAt: new Date().toISOString(),
};
try {
const response = await fetch(
`/api/media-items/${state.mediaItemId}/bookmarks`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
page_number: pageNumber,
title: bookmark.title,
position: `pdf:page:${pageNumber}`,
}),
},
);
if (!response.ok) throw new Error("Failed to create bookmark");
const created = await response.json();
return {
...state,
bookmarks: [...state.bookmarks, created],
bookmark: created,
};
} catch (error) {
console.error("Failed to add bookmark:", error);
throw error;
}
}
async function removeMediaBookmark(
state: MediaBookmarksState,
bookmarkId: string,
): Promise<MediaBookmarksState> {
try {
const response = await fetch(
`/api/media-items/${state.mediaItemId}/bookmarks/${bookmarkId}`,
{
method: "DELETE",
},
);
if (!response.ok) throw new Error("Failed to remove bookmark");
return {
...state,
bookmarks: state.bookmarks.filter((b) => b.id !== bookmarkId),
};
} catch (error) {
console.error("Failed to remove bookmark:", error);
throw error;
}
}
function getMediaBookmarks(state: MediaBookmarksState): MediaBookmark[] {
return [...state.bookmarks].sort((a, b) => a.pageNumber - b.pageNumber);
}
function hasMediaBookmarkAt(
state: MediaBookmarksState,
pageNumber: number,
): boolean {
return state.bookmarks.some((b) => b.pageNumber === pageNumber);
}
function getMediaBookmarkAt(
state: MediaBookmarksState,
pageNumber: number,
): MediaBookmark | null {
return state.bookmarks.find((b) => b.pageNumber === pageNumber) || null;
}
-98
View File
@@ -1,98 +0,0 @@
// Copy selected text to clipboard (plain text, preserve line breaks)
// Critical for technical textbooks with code examples
// Procedural implementation (no OOP)
function setupPDFClipboard(container: HTMLElement): void {
container.addEventListener("copy", (e) => {
handlePDFCopy(e);
});
}
function handlePDFCopy(event: ClipboardEvent): void {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const selectedText = selection.toString();
if (!selectedText) return;
const plainText = formatPDFPlainText(selectedText);
event.clipboardData?.setData("text/plain", plainText);
event.preventDefault();
showPDFCopyFeedback();
}
function formatPDFPlainText(text: string): string {
let formatted = text;
formatted = formatted.replace(/[ \t]+/g, " ");
formatted = formatted
.split("\n")
.map((line) => line.trim())
.join("\n");
formatted = formatted.replace(/\n{3,}/g, "\n\n");
return formatted;
}
async function copyPDFText(text: string): Promise<boolean> {
const formatted = formatPDFPlainText(text);
try {
await navigator.clipboard.writeText(formatted);
showPDFCopyFeedback();
return true;
} catch (error) {
console.error("Failed to copy text:", error);
const textarea = document.createElement("textarea");
textarea.value = formatted;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
try {
const success = document.execCommand("copy");
if (success) {
showPDFCopyFeedback();
}
return success;
} catch (fallbackError) {
console.error("Fallback copy failed:", fallbackError);
return false;
} finally {
document.body.removeChild(textarea);
}
}
}
function showPDFCopyFeedback(): void {
const toast = document.createElement("div");
toast.className = "pdf-copy-toast";
toast.textContent = "Copied to clipboard";
toast.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: var(--accent);
color: white;
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
z-index: 10000;
animation: fadeIn 0.2s ease-out;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = "fadeOut 0.2s ease-out";
setTimeout(() => toast.remove(), 200);
}, 1500);
}
-143
View File
@@ -1,143 +0,0 @@
// Dual page spread view for PDFs
// Procedural implementation (no OOP)
type DualPageMode = "single" | "dual";
interface PDFDualPageViewState {
currentMode: DualPageMode;
minViewportWidth: number;
}
function createPDFDualPageView(
container: HTMLElement,
onModeChange: (mode: DualPageMode) => void,
): PDFDualPageViewState {
const state: PDFDualPageViewState = {
currentMode: "single",
minViewportWidth: 1200,
};
setupResponsiveDualPageToggle(container, state, onModeChange);
return state;
}
function setupResponsiveDualPageToggle(
container: HTMLElement,
state: PDFDualPageViewState,
onModeChange: (mode: DualPageMode) => void,
): void {
const resizeObserver = new ResizeObserver(() => {
handleDualPageResize(container, state, onModeChange);
});
resizeObserver.observe(container);
}
function handleDualPageResize(
container: HTMLElement,
state: PDFDualPageViewState,
onModeChange: (mode: DualPageMode) => void,
): PDFDualPageViewState {
const viewportWidth = window.innerWidth;
if (
viewportWidth >= state.minViewportWidth &&
state.currentMode === "single"
) {
if (!hasManualDualPageOverride()) {
return setDualPageMode(container, state, "dual", false, onModeChange);
}
} else if (
viewportWidth < state.minViewportWidth &&
state.currentMode === "dual"
) {
return setDualPageMode(container, state, "single", false, onModeChange);
}
return state;
}
function setDualPageMode(
container: HTMLElement,
state: PDFDualPageViewState,
mode: DualPageMode,
manual: boolean,
onModeChange: (mode: DualPageMode) => void,
): PDFDualPageViewState {
if (state.currentMode === mode) return state;
container.classList.remove("pdf-single-page", "pdf-dual-page");
container.classList.add(
mode === "dual" ? "pdf-dual-page" : "pdf-single-page",
);
if (manual) {
setManualDualPageOverride(mode);
}
onModeChange(mode);
return { ...state, currentMode: mode };
}
function toggleDualPageMode(
container: HTMLElement,
state: PDFDualPageViewState,
onModeChange: (mode: DualPageMode) => void,
): PDFDualPageViewState {
const newMode = state.currentMode === "single" ? "dual" : "single";
return setDualPageMode(container, state, newMode, true, onModeChange);
}
function getDualPagePagePair(
state: PDFDualPageViewState,
currentPage: number,
totalPages: number,
): { left?: number; right: number } {
if (state.currentMode === "single") {
return { right: currentPage };
}
if (currentPage % 2 === 1) {
return {
left: currentPage > 1 ? currentPage - 1 : undefined,
right: currentPage,
};
} else {
return {
left: currentPage,
right: currentPage < totalPages ? currentPage + 1 : currentPage,
};
}
}
function hasManualDualPageOverride(): boolean {
return localStorage.getItem("pdf-dual-page-manual") === "true";
}
function setManualDualPageOverride(mode: DualPageMode): void {
localStorage.setItem("pdf-dual-page-manual", "true");
localStorage.setItem("pdf-dual-page-mode", mode);
}
function getDualPageStyles(): string {
return `
.pdf-dual-page .pdf-page-container {
display: inline-block;
vertical-align: top;
width: 50%;
}
.pdf-dual-page .pdf-scroll-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.pdf-single-page .pdf-page-container {
display: block;
width: 100%;
}
`;
}
-132
View File
@@ -1,132 +0,0 @@
// Handle internal PDF links (cross-references, citations, TOC links)
// External links open in new tab
// Procedural implementation (no OOP)
interface PDFLink {
url: string;
pageNumber?: number;
bounds: { x: number; y: number; width: number; height: number };
}
interface PDFLinkHandlerState {
doc: PDFDocumentProxy | null;
container: HTMLElement;
onPageNavigate: (pageNumber: number) => void;
}
async function initializePDFLinkHandler(
container: HTMLElement,
onPageNavigate: (pageNumber: number) => void,
doc: PDFDocumentProxy,
): Promise<PDFLinkHandlerState> {
const state: PDFLinkHandlerState = {
doc,
container,
onPageNavigate,
};
await setupPDFLinks(state);
return state;
}
async function setupPDFLinks(state: PDFLinkHandlerState): Promise<void> {
if (!state.doc) return;
for (let pageNum = 1; pageNum <= state.doc.numPages; pageNum++) {
const page = await state.doc.getPage(pageNum);
const annotations = await page.getAnnotations();
for (const annotation of annotations) {
if (annotation.subtype === "Link") {
createPDFLinkElement(state, annotation, pageNum);
}
}
}
}
function createPDFLinkElement(
state: PDFLinkHandlerState,
annotation: any,
pageNumber: number,
): void {
const pageElement = state.container.querySelector(
`[data-page-number="${pageNumber}"]`,
);
if (!pageElement) return;
const link = document.createElement("a");
link.className = "pdf-internal-link";
link.href = "javascript:void(0)";
if (annotation.rect) {
const rect = annotation.rect;
link.style.position = "absolute";
link.style.left = `${rect[0]}px`;
link.style.top = `${rect[1]}px`;
link.style.width = `${rect[2] - rect[0]}px`;
link.style.height = `${rect[3] - rect[1]}px`;
link.style.cursor = "pointer";
}
link.addEventListener("click", (e) => {
e.preventDefault();
handlePDFLinkClick(state, annotation);
});
pageElement.appendChild(link);
}
async function handlePDFLinkClick(
state: PDFLinkHandlerState,
annotation: any,
): Promise<void> {
if (!state.doc) return;
if (annotation.url) {
if (
annotation.url.startsWith("http://") ||
annotation.url.startsWith("https://")
) {
window.open(annotation.url, "_blank", "noopener,noreferrer");
} else {
console.warn("Unhandled URL:", annotation.url);
}
} else if (annotation.dest) {
const pageNumber = await resolvePDFLinkDestination(state, annotation.dest);
state.onPageNavigate(pageNumber);
}
}
async function resolvePDFLinkDestination(
state: PDFLinkHandlerState,
dest: string | any[],
): Promise<number> {
if (!state.doc) return 1;
try {
let explicitDest: any[];
if (typeof dest === "string") {
const destObj = await state.doc.getDestination(dest);
if (!destObj) return 1;
explicitDest = destObj;
} else {
explicitDest = dest;
}
const ref = explicitDest[0];
if (typeof ref === "object" && ref !== null) {
const pageIndex = await state.doc.getPageIndex(ref);
return pageIndex + 1;
} else if (typeof ref === "number") {
return ref + 1;
}
return 1;
} catch (error) {
console.error("Failed to resolve link destination:", error);
return 1;
}
}
-191
View File
@@ -1,191 +0,0 @@
// Mini-map navigation for PDF pages
// Procedural implementation (no OOP)
interface PDFMiniMapState {
miniMap: HTMLElement;
currentPage: number;
totalPages: number;
thumbnails: Map<number, HTMLCanvasElement>;
onPageNavigate: (pageNumber: number) => void;
}
function createPDFMiniMap(
container: HTMLElement,
onPageNavigate: (pageNumber: number) => void,
): PDFMiniMapState {
const miniMap = createMiniMapElement(container);
container.appendChild(miniMap);
return {
miniMap,
currentPage: 1,
totalPages: 0,
thumbnails: new Map(),
onPageNavigate,
};
}
function createMiniMapElement(container: HTMLElement): HTMLElement {
const miniMap = document.createElement("div");
miniMap.className = "pdf-minimap";
miniMap.innerHTML = `
<div class="pdf-minimap-header">Pages</div>
<div class="pdf-minimap-thumbnails"></div>
<div class="pdf-minimap-indicator"></div>
`;
const style = document.createElement("style");
style.textContent = getMiniMapStyles();
miniMap.appendChild(style);
return miniMap;
}
async function initializePDFMiniMap(
state: PDFMiniMapState,
totalPages: number,
renderThumbnail: (page: number) => Promise<HTMLCanvasElement>,
): Promise<PDFMiniMapState> {
const newState = { ...state, totalPages };
await generateMiniMapThumbnails(newState, renderThumbnail);
setupMiniMapEventListeners(newState);
return newState;
}
async function generateMiniMapThumbnails(
state: PDFMiniMapState,
renderThumbnail: (page: number) => Promise<HTMLCanvasElement>,
): Promise<void> {
const container = state.miniMap.querySelector(
".pdf-minimap-thumbnails",
) as HTMLElement;
container.innerHTML = "";
for (let page = 1; page <= state.totalPages; page++) {
try {
const thumbnail = await renderThumbnail(page);
thumbnail.className = "pdf-minimap-thumbnail";
thumbnail.dataset.pageNumber = page.toString();
thumbnail.style.width = "80px";
thumbnail.style.height = "auto";
thumbnail.style.cursor = "pointer";
thumbnail.style.marginBottom = "4px";
container.appendChild(thumbnail);
state.thumbnails.set(page, thumbnail);
} catch (error) {
console.error(`Failed to generate thumbnail for page ${page}:`, error);
}
}
}
function setupMiniMapEventListeners(state: PDFMiniMapState): void {
const container = state.miniMap.querySelector(".pdf-minimap-thumbnails");
container?.addEventListener("click", (e) => {
const thumbnail = (e.target as HTMLElement).closest(
".pdf-minimap-thumbnail",
) as HTMLElement;
if (thumbnail) {
const pageNumber = parseInt(thumbnail.dataset.pageNumber || "1");
state.onPageNavigate(pageNumber);
}
});
}
function updateMiniMapCurrentPage(
state: PDFMiniMapState,
pageNumber: number,
): PDFMiniMapState {
const indicator = state.miniMap.querySelector(
".pdf-minimap-indicator",
) as HTMLElement;
const thumbnail = state.thumbnails.get(pageNumber);
if (thumbnail && indicator) {
const rect = thumbnail.getBoundingClientRect();
indicator.style.top = `${thumbnail.offsetTop}px`;
indicator.style.height = `${rect.height}px`;
}
state.thumbnails.forEach((thumb, page) => {
if (page === pageNumber) {
thumb.style.outline = "2px solid var(--accent)";
thumb.style.opacity = "1";
} else {
thumb.style.outline = "none";
thumb.style.opacity = "0.7";
}
});
return { ...state, currentPage: pageNumber };
}
function showMiniMap(state: PDFMiniMapState): void {
state.miniMap.style.display = "block";
}
function hideMiniMap(state: PDFMiniMapState): void {
state.miniMap.style.display = "none";
}
function toggleMiniMap(state: PDFMiniMapState): void {
const isVisible = state.miniMap.style.display !== "none";
state.miniMap.style.display = isVisible ? "none" : "block";
}
function getMiniMapStyles(): string {
return `
.pdf-minimap {
position: fixed;
right: 20px;
top: 50%;
transform: translateY(-50%);
width: 100px;
max-height: 80vh;
background: var(--bg-primary);
border: 1px solid var(--text-secondary);
border-radius: 8px;
padding: 8px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.pdf-minimap-header {
font-size: 12px;
font-weight: bold;
text-align: center;
margin-bottom: 8px;
color: var(--text-primary);
}
.pdf-minimap-thumbnails {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.pdf-minimap-thumbnail {
transition: outline 0.2s, opacity 0.2s;
border-radius: 2px;
}
.pdf-minimap-thumbnail:hover {
opacity: 1 !important;
outline: 1px solid var(--text-secondary) !important;
}
.pdf-minimap-indicator {
position: absolute;
left: 0;
right: 0;
border-left: 3px solid var(--accent);
pointer-events: none;
transition: top 0.3s ease-out;
}
`;
}
+54 -24
View File
@@ -1,22 +1,27 @@
// PDF navigation: page turning, zoom, fit modes
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
import { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
let navState: PDFNavigationState | null = null;
context.events.on("reader:loaded", (detail: { container: HTMLElement; totalPages: number }) => {
navState = {
currentPage: 1,
totalPages: detail.totalPages,
currentScale: 1.0,
fitMode: "fit-width",
scrollContainer: detail.container.querySelector(".pdf-scroll-container") || detail.container,
};
setupPDFKeyboardNav(context, navState);
setupPDFScrollTracking(context, navState);
});
context.events.on(
"reader:loaded",
(detail: { container: HTMLElement; totalPages: number }) => {
navState = {
currentPage: 1,
totalPages: detail.totalPages,
currentScale: 1.0,
fitMode: "fit-width",
scrollContainer:
detail.container.querySelector(".pdf-scroll-container") ||
detail.container,
};
setupPDFKeyboardNav(context, navState);
setupPDFScrollTracking(context, navState);
},
);
context.events.on("pdf:navigate:to-page", (detail: { page: number }) => {
if (navState) {
@@ -54,11 +59,14 @@ export function init(context: ReaderContext): void {
}
});
context.events.on("pdf:fit:set", (detail: { mode: "fit-width" | "fit-page" | "fit-height" | "none" }) => {
if (navState) {
setPDFFitMode(navState, detail.mode, context);
}
});
context.events.on(
"pdf:fit:set",
(detail: { mode: "fit-width" | "fit-page" | "fit-height" | "none" }) => {
if (navState) {
setPDFFitMode(navState, detail.mode, context);
}
},
);
context.events.on("reader:unload", () => {
navState = null;
@@ -75,7 +83,11 @@ interface PDFNavigationState {
scrollContainer: HTMLElement | null;
}
function goToPDFPage(state: PDFNavigationState, pageNumber: number, context: ReaderContext): void {
function goToPDFPage(
state: PDFNavigationState,
pageNumber: number,
context: ReaderContext,
): void {
if (pageNumber < 1 || pageNumber > state.totalPages) return;
state.currentPage = pageNumber;
@@ -89,7 +101,10 @@ function nextPDFPage(state: PDFNavigationState, context: ReaderContext): void {
}
}
function previousPDFPage(state: PDFNavigationState, context: ReaderContext): void {
function previousPDFPage(
state: PDFNavigationState,
context: ReaderContext,
): void {
if (state.currentPage > 1) {
goToPDFPage(state, state.currentPage - 1, context);
}
@@ -106,14 +121,22 @@ function scrollToPDFPage(state: PDFNavigationState, pageNumber: number): void {
}
}
function setPDFZoom(state: PDFNavigationState, scale: number, context: ReaderContext): void {
function setPDFZoom(
state: PDFNavigationState,
scale: number,
context: ReaderContext,
): void {
state.currentScale = scale;
state.fitMode = "none";
updatePDFZoom(state);
context.events.emit("pdf:zoom-changed", { scale });
}
function setPDFFitMode(state: PDFNavigationState, mode: PageFitMode, context: ReaderContext): void {
function setPDFFitMode(
state: PDFNavigationState,
mode: PageFitMode,
context: ReaderContext,
): void {
state.fitMode = mode;
updatePDFZoom(state);
context.events.emit("pdf:fit-changed", { mode });
@@ -137,7 +160,10 @@ function updatePDFZoom(state: PDFNavigationState): void {
window.dispatchEvent(event);
}
function setupPDFKeyboardNav(context: ReaderContext, state: PDFNavigationState): void {
function setupPDFKeyboardNav(
context: ReaderContext,
state: PDFNavigationState,
): void {
document.addEventListener("keydown", (e) => {
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
nextPDFPage(state, context);
@@ -151,7 +177,10 @@ function setupPDFKeyboardNav(context: ReaderContext, state: PDFNavigationState):
});
}
function setupPDFScrollTracking(context: ReaderContext, state: PDFNavigationState): void {
function setupPDFScrollTracking(
context: ReaderContext,
state: PDFNavigationState,
): void {
if (!state.scrollContainer) return;
state.scrollContainer.addEventListener("scroll", () => {
@@ -182,4 +211,5 @@ function getCurrentPDFPage(state: PDFNavigationState): number {
}
return state.currentPage;
}
}
-184
View File
@@ -1,184 +0,0 @@
// PDF outline/TOC navigation
// Procedural implementation (no OOP)
interface PDFOutlineNode {
id: string;
title: string;
destination: number | null;
pageNumber?: number;
children: PDFOutlineNode[];
expanded: boolean;
}
interface PDFOutlineState {
doc: PDFDocumentProxy | null;
outline: PDFOutlineNode[];
flatMap: Map<string, number>;
}
async function initializePDFOutline(
doc: PDFDocumentProxy,
): Promise<PDFOutlineState> {
const state: PDFOutlineState = {
doc,
outline: [],
flatMap: new Map(),
};
return await loadPDFOutline(state);
}
async function loadPDFOutline(
state: PDFOutlineState,
): Promise<PDFOutlineState> {
if (!state.doc) return state;
const pdfOutline = await state.doc.getOutline();
if (!pdfOutline || pdfOutline.length === 0) {
return { ...state, outline: [] };
}
const outline = await parseOutlineNodes(state, pdfOutline);
return { ...state, outline };
}
async function parseOutlineNodes(
state: PDFOutlineState,
nodes: OutlineTreeNode[],
): Promise<PDFOutlineNode[]> {
const result: PDFOutlineNode[] = [];
for (const node of nodes) {
const outlineNode: PDFOutlineNode = {
id: generateOutlineId(),
title: node.title,
destination: null,
children: [],
expanded: false,
};
if (node.dest) {
const pageNumber = await resolvePDFDestination(state, node.dest);
outlineNode.destination = pageNumber;
outlineNode.pageNumber = pageNumber;
state.flatMap.set(node.title, pageNumber);
}
if (node.items && node.items.length > 0) {
outlineNode.children = await parseOutlineNodes(state, node.items);
}
result.push(outlineNode);
}
return result;
}
async function resolvePDFDestination(
state: PDFOutlineState,
dest: string | any[],
): Promise<number> {
if (!state.doc) return 1;
try {
let explicitDest: any[];
if (typeof dest === "string") {
const destObj = await state.doc.getDestination(dest);
if (!destObj) return 1;
explicitDest = destObj;
} else {
explicitDest = dest;
}
const ref = explicitDest[0];
if (typeof ref === "object" && ref !== null) {
const pageIndex = await state.doc.getPageIndex(ref);
return pageIndex + 1;
} else if (typeof ref === "number") {
return ref + 1;
}
return 1;
} catch (error) {
console.error("Failed to resolve destination:", dest, error);
return 1;
}
}
function generateOutlineId(): string {
return `outline-${Math.random().toString(36).substr(2, 9)}`;
}
function getOutline(state: PDFOutlineState): PDFOutlineNode[] {
return state.outline;
}
function getOutlineFlatMap(state: PDFOutlineState): Map<string, number> {
return state.flatMap;
}
function getCurrentChapter(
state: PDFOutlineState,
pageNumber: number,
): PDFOutlineNode | null {
return findChapterForPage(state.outline, pageNumber);
}
function findChapterForPage(
nodes: PDFOutlineNode[],
pageNumber: number,
): PDFOutlineNode | null {
for (const node of nodes) {
if (node.pageNumber && node.pageNumber <= pageNumber) {
if (node.children.length > 0) {
const childMatch = findChapterForPage(node.children, pageNumber);
if (childMatch) return childMatch;
}
return node;
}
if (node.children.length > 0) {
const childMatch = findChapterForPage(node.children, pageNumber);
if (childMatch) return childMatch;
}
}
return null;
}
function toggleOutlineNode(
state: PDFOutlineState,
nodeId: string,
): PDFOutlineState {
const updateNode = (nodes: PDFOutlineNode[]): PDFOutlineNode[] => {
return nodes.map((node) => {
if (node.id === nodeId) {
return { ...node, expanded: !node.expanded };
}
if (node.children.length > 0) {
return { ...node, children: updateNode(node.children) };
}
return node;
});
};
return { ...state, outline: updateNode(state.outline) };
}
function findOutlineNode(
nodes: PDFOutlineNode[],
id: string,
): PDFOutlineNode | null {
for (const node of nodes) {
if (node.id === id) return node;
if (node.children.length > 0) {
const found = findOutlineNode(node.children, id);
if (found) return found;
}
}
return null;
}
-109
View File
@@ -1,109 +0,0 @@
// Handle PDFs with variable page sizes
// Procedural implementation (no OOP)
interface PageInfo {
pageNumber: number;
width: number;
height: number;
rotation: number;
}
interface PDFPageSizesState {
pageSizes: Map<number, PageInfo>;
defaultSize: { width: number; height: number };
}
function createPDFPageSizes(): PDFPageSizesState {
return {
pageSizes: new Map(),
defaultSize: { width: 595, height: 842 },
};
}
async function loadPDFPageSizes(
state: PDFPageSizesState,
doc: any,
): Promise<PDFPageSizesState> {
const pageSizes = new Map<number, PageInfo>();
for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
const page = await doc.getPage(pageNum);
const viewport = page.getViewport({ scale: 1 });
const pageInfo: PageInfo = {
pageNumber: pageNum,
width: viewport.width,
height: viewport.height,
rotation: viewport.rotation,
};
pageSizes.set(pageNum, pageInfo);
}
return { ...state, pageSizes };
}
function getPDFPageSize(
state: PDFPageSizesState,
pageNumber: number,
): PageInfo | null {
return state.pageSizes.get(pageNumber) || null;
}
function isPDFPageLandscape(
state: PDFPageSizesState,
pageNumber: number,
): boolean {
const size = getPDFPageSize(state, pageNumber);
if (!size) return false;
const effectiveWidth =
size.rotation === 90 || size.rotation === 270 ? size.height : size.width;
const effectiveHeight =
size.rotation === 90 || size.rotation === 270 ? size.width : size.height;
return effectiveWidth > effectiveHeight;
}
function getPDFCommonSize(state: PDFPageSizesState): {
width: number;
height: number;
} {
if (state.pageSizes.size === 0) {
return state.defaultSize;
}
const sizeGroups: Map<
string,
{ width: number; height: number; count: number }
> = new Map();
state.pageSizes.forEach((size) => {
const key = getPageSizeKey(size.width, size.height);
const existing = sizeGroups.get(key);
if (existing) {
existing.count++;
} else {
sizeGroups.set(key, { width: size.width, height: size.height, count: 1 });
}
});
let mostCommon = state.defaultSize;
let maxCount = 0;
sizeGroups.forEach((size) => {
if (size.count > maxCount) {
maxCount = size.count;
mostCommon = { width: size.width, height: size.height };
}
});
return mostCommon;
}
function getPageSizeKey(width: number, height: number): string {
const w = Math.round(width / 10) * 10;
const h = Math.round(height / 10) * 10;
return `${w}x${h}`;
}
-82
View File
@@ -1,82 +0,0 @@
// Handle rotated/landscape pages in PDFs
// Procedural implementation (no OOP)
interface PDFRotationState {
rotations: Map<number, number>;
}
function createPDFRotation(): PDFRotationState {
return {
rotations: new Map(),
};
}
async function loadPDFPageRotations(
state: PDFRotationState,
doc: any,
): Promise<PDFRotationState> {
const rotations = new Map<number, number>();
for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
const page = await doc.getPage(pageNum);
const viewport = page.getViewport({ scale: 1 });
const rotation = viewport.rotation;
if (rotation !== 0) {
rotations.set(pageNum, rotation);
}
}
return { ...state, rotations };
}
function getPDFPageRotation(
state: PDFRotationState,
pageNumber: number,
): number {
return state.rotations.get(pageNumber) || 0;
}
function hasPDFPageRotation(
state: PDFRotationState,
pageNumber: number,
): boolean {
return state.rotations.has(pageNumber);
}
function applyPDFRotation(
state: PDFRotationState,
canvas: HTMLCanvasElement,
pageNumber: number,
): void {
const rotation = getPDFPageRotation(state, pageNumber);
if (rotation === 0) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.save();
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate((rotation * Math.PI) / 180);
ctx.translate(-canvas.width / 2, -canvas.height / 2);
ctx.restore();
}
function getPDFAdjustedViewport(
state: PDFRotationState,
pageNumber: number,
viewport: any,
): any {
const rotation = getPDFPageRotation(state, pageNumber);
if (rotation === 0 || rotation === 180) {
return viewport;
}
return {
...viewport,
width: viewport.height,
height: viewport.width,
};
}
-136
View File
@@ -1,136 +0,0 @@
// Full-text search within PDF documents
import { PDFDocumentProxy } from "pdfjs-dist";
interface SearchResult {
pageNumber: number;
text: string;
index: number;
context: string;
}
// Full-text search within PDF documents
// Procedural implementation (no OOP)
interface SearchResult {
pageNumber: number;
text: string;
index: number;
context: string;
}
interface PDFSearchState {
doc: PDFDocumentProxy | null;
searchResults: SearchResult[];
currentResultIndex: number;
}
async function initializePDFSearch(
doc: PDFDocumentProxy,
): Promise<PDFSearchState> {
return {
doc,
searchResults: [],
currentResultIndex: 0,
};
}
async function searchPDF(
state: PDFSearchState,
query: string,
): Promise<PDFSearchState> {
if (!state.doc) return state;
const searchResults: SearchResult[] = [];
const lowerQuery = query.toLowerCase();
for (let pageNum = 1; pageNum <= state.doc.numPages; pageNum++) {
const page = await state.doc.getPage(pageNum);
const textContent = await page.getTextContent();
let fullText = "";
const textItems = textContent.items.map((item) => {
if (typeof item === "string") return "";
fullText += item.str;
return item.str;
});
const pageText = textItems.join(" ");
const matches = findSearchMatches(pageText, lowerQuery, pageNum);
searchResults.push(...matches);
}
return { ...state, searchResults };
}
function findSearchMatches(
text: string,
query: string,
pageNumber: number,
): SearchResult[] {
const matches: SearchResult[] = [];
const lowerText = text.toLowerCase();
let index = 0;
while ((index = lowerText.indexOf(query, index)) !== -1) {
const start = Math.max(0, index - 50);
const end = Math.min(text.length, index + query.length + 50);
const context = text.slice(start, end);
matches.push({
pageNumber,
text: text.slice(index, index + query.length),
index,
context,
});
index += query.length;
}
return matches;
}
function goToNextSearchResult(
state: PDFSearchState,
): PDFSearchState & { result: SearchResult | null } {
if (state.searchResults.length === 0) {
return { ...state, result: null };
}
const newIndex = (state.currentResultIndex + 1) % state.searchResults.length;
return {
...state,
currentResultIndex: newIndex,
result: state.searchResults[newIndex],
};
}
function goToPreviousSearchResult(
state: PDFSearchState,
): PDFSearchState & { result: SearchResult | null } {
if (state.searchResults.length === 0) {
return { ...state, result: null };
}
const newIndex =
(state.currentResultIndex - 1 + state.searchResults.length) %
state.searchResults.length;
return {
...state,
currentResultIndex: newIndex,
result: state.searchResults[newIndex],
};
}
function getSearchResultCount(state: PDFSearchState): number {
return state.searchResults.length;
}
function clearSearchResults(state: PDFSearchState): PDFSearchState {
return {
...state,
searchResults: [],
currentResultIndex: 0,
};
}
+34 -23
View File
@@ -1,7 +1,7 @@
// PDF text selection - Uses backend API for highlight creation
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
import { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
let currentMediaItemId: string | null = null;
@@ -15,22 +15,32 @@ export function init(context: ReaderContext): void {
context.events.emit("pdf:selection-current", selection);
});
context.events.on("pdf:highlight:create", async (detail: { selection: PDFTextSelection; color: string }) => {
if (currentMediaItemId) {
try {
const highlight = await createPDFHighlight(currentMediaItemId, detail.selection, detail.color);
context.events.emit("pdf:highlight-created", highlight);
} catch (error) {
console.error("Failed to create highlight:", error);
context.events.on(
"pdf:highlight:create",
async (detail: { selection: PDFTextSelection; color: string }) => {
if (currentMediaItemId) {
try {
const highlight = await createPDFHighlight(
currentMediaItemId,
detail.selection,
detail.color,
);
context.events.emit("pdf:highlight-created", highlight);
} catch (error) {
console.error("Failed to create highlight:", error);
}
}
}
});
},
);
context.events.on("pdf:highlights:load", async (detail: { container: HTMLElement }) => {
if (currentMediaItemId) {
await loadAndRenderPDFHighlights(currentMediaItemId, detail.container);
}
});
context.events.on(
"pdf:highlights:load",
async (detail: { container: HTMLElement }) => {
if (currentMediaItemId) {
await loadAndRenderPDFHighlights(currentMediaItemId, detail.container);
}
},
);
context.events.on("reader:unload", () => {
currentMediaItemId = null;
@@ -52,8 +62,9 @@ export function getPDFTextSelection(): PDFTextSelection | null {
if (!text) return null;
const pageElement =
range.commonAncestorContainer.parentElement?.closest?.("[data-page-number]") as HTMLElement;
const pageElement = range.commonAncestorContainer.parentElement?.closest?.(
"[data-page-number]",
) as HTMLElement;
const pageNumber = pageElement?.dataset.pageNumber
? parseInt(pageElement.dataset.pageNumber)
: getCurrentPDFPage();
@@ -114,10 +125,7 @@ export async function loadAndRenderPDFHighlights(
}
}
function renderPDFHighlight(
container: HTMLElement,
highlight: any,
): void {
function renderPDFHighlight(container: HTMLElement, highlight: any): void {
const overlay = document.createElement("div");
overlay.className = "pdf-highlight-annotation";
overlay.dataset.highlightId = highlight.id;
@@ -150,5 +158,8 @@ function parseColor(color: string): string {
function getCurrentPDFPage(): number {
const pageElement = document.querySelector("[data-page-number]");
return pageElement ? parseInt(pageElement.getAttribute("data-page-number") || "1") : 1;
}
return pageElement
? parseInt(pageElement.getAttribute("data-page-number") || "1")
: 1;
}
-151
View File
@@ -1,151 +0,0 @@
// Mozilla pdf.js integration for PDF rendering
// Procedural style: Functions, not classes
import * as pdfjsLib from "pdfjs-dist";
// ============================================================
// PDF.js Configuration
// ============================================================
export function configurePDFJS(): void {
pdfjsLib.GlobalWorkerOptions.workerSrc = "/static/js/pdf.worker.min.mjs";
pdfjsLib.GlobalWorkerOptions.standardFontDataUrl = "/static/standard_fonts/";
pdfjsLib.GlobalWorkerOptions.cMapUrl = "/static/cmaps/";
pdfjsLib.GlobalWorkerOptions.cMapPacked = true;
}
// ============================================================
// PDF Document State
// ============================================================
interface PDFDocumentState {
doc: pdfjsLib.PDFDocumentProxy | null;
pages: Map<number, pdfjsLib.PDFPageProxy>;
metadata: PDFMetadata | null;
}
interface PDFMetadata {
title: string;
author: string;
subject?: string;
keywords?: string;
creator?: string;
producer?: string;
creationDate?: Date;
modificationDate?: Date;
pageCount: number;
}
let pdfState: PDFDocumentState = {
doc: null,
pages: new Map(),
metadata: null,
};
// ============================================================
// Document Loading
// ============================================================
export async function loadPDFDocument(pdfBlob: Blob): Promise<PDFMetadata> {
// Cleanup previous document
unloadPDFDocument();
const arrayBuffer = await pdfBlob.arrayBuffer();
const loadingTask = pdfjsLib.getDocument({
data: arrayBuffer,
});
pdfState.doc = await loadingTask.promise;
// Extract metadata
const metadata = await pdfState.doc.getMetadata().catch(() => null);
const info = metadata?.info || {};
pdfState.metadata = {
title: info.Title || "Untitled",
author: info.Author || "Unknown",
subject: info.Subject,
keywords: info.Keywords,
creator: info.Creator,
producer: info.Producer,
creationDate: info.CreationDate ? new Date(info.CreationDate) : undefined,
modificationDate: info.ModDate ? new Date(info.ModDate) : undefined,
pageCount: pdfState.doc.numPages,
};
return pdfState.metadata;
}
export async function getPDFPage(
pageNumber: number,
): Promise<pdfjsLib.PDFPageProxy> {
if (!pdfState.doc) {
throw new Error("PDF document not loaded");
}
// Check cache
if (pdfState.pages.has(pageNumber)) {
return pdfState.pages.get(pageNumber)!;
}
// Load page
const page = await pdfState.doc.getPage(pageNumber);
pdfState.pages.set(pageNumber, page);
return page;
}
// ============================================================
// Reader Initialization
// ============================================================
interface ReaderMetadata {
media_item_id: string;
title: string;
author: string;
cover_image_path: string;
library_type: "ebook" | "comic" | "manga" | "pdf";
mime_type: string;
file_path: string;
total_pages?: number;
}
interface PDFReader {
type: "pdf";
doc: any;
currentPage: number;
}
export async function initializePDFReader(
metadata: ReaderMetadata,
): Promise<PDFReader> {
configurePDFJS();
const response = await fetch(metadata.file_path);
const pdfBlob = await response.blob();
const pdfMetadata = await loadPDFDocument(pdfBlob);
return {
type: "pdf",
doc: pdfState.doc,
currentPage: 1,
};
}
export async function getPDFPageText(pageNumber: number): Promise<any> {
const page = await getPDFPage(pageNumber);
return await page.getTextContent();
}
export function getPDFMetadata(): PDFMetadata | null {
return pdfState.metadata;
}
export function getPDFPageCount(): number {
return pdfState.doc?.numPages || 0;
}
export function unloadPDFDocument(): void {
pdfState.pages.clear();
pdfState.doc = null;
pdfState.metadata = null;
}
export function unloadPDFPage(pageNumber: number): void {
pdfState.pages.delete(pageNumber);
}
-155
View File
@@ -1,155 +0,0 @@
// Text layer rendering for PDF text selection and highlighting
// Procedural style: Functions, not classes
// ============================================================
// Render Functions
// ============================================================
export function renderTextLayer(
container: HTMLElement,
viewport: any,
textContent: any,
config: TextLayerConfig,
): void {
// Clear container
container.innerHTML = "";
// Apply styles
applyTextLayerStyles(container, config);
// Render text items
const { items } = textContent;
items.forEach((item: any, index: number) => {
if (typeof item === "string") return;
const textDiv = createTextDiv(item, viewport, index);
container.appendChild(textDiv);
});
}
function createTextDiv(item: any, viewport: any, index: number): HTMLElement {
const div = document.createElement("div");
div.className = "pdf-text-layer-text";
div.textContent = item.str;
div.dataset.index = index.toString();
// Position the text div
const tx = pdfjsLib.Util.transform(viewport.transform, item.transform);
const fontSize = Math.sqrt(tx[0] * tx[0] + tx[1] * tx[1]);
div.style.left = `${tx[4]}px`;
div.style.top = `${tx[5] - fontSize}px`;
div.style.fontSize = `${fontSize}px`;
div.style.fontFamily = item.fontName || "sans-serif";
// Handle text direction
if (item.dir === "ttb") {
div.style.writingMode = "vertical-rl";
}
return div;
}
interface TextLayerConfig {
theme: "light" | "sepia" | "dark" | "night" | "high-contrast";
}
function applyTextLayerStyles(
container: HTMLElement,
config: TextLayerConfig,
): void {
const style = document.createElement("style");
style.textContent = getTextLayerCSS(config.theme);
container.appendChild(style);
}
function getTextLayerCSS(theme: string): string {
const colors = getThemeColors(theme);
return `
.pdf-text-layer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
opacity: 1;
line-height: 1;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
}
.pdf-text-layer-text {
position: absolute;
white-space: pre;
cursor: text;
transform-origin: 0% 0%;
color: transparent;
pointer-events: auto;
}
.pdf-text-layer-text::selection {
background: ${colors.highlight};
color: transparent;
}
.pdf-text-layer-text::-moz-selection {
background: ${colors.highlight};
color: transparent;
}
.pdf-highlight-overlay {
position: absolute;
background-color: ${colors.highlight};
mix-blend-mode: multiply;
pointer-events: none;
}
`;
}
function getThemeColors(theme: string): { highlight: string } {
const themes: Record<string, { highlight: string }> = {
light: { highlight: "rgba(255, 255, 0, 0.3)" },
sepia: { highlight: "rgba(255, 200, 0, 0.4)" },
dark: { highlight: "rgba(255, 255, 0, 0.3)" },
night: { highlight: "rgba(100, 150, 255, 0.3)" },
"high-contrast": { highlight: "rgba(255, 255, 0, 0.5)" },
};
return themes[theme] || themes["dark"];
}
// ============================================================
// Selection Functions
// ============================================================
export function getPDFTextSelection(): { text: string; range: Range } | null {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
const text = range.toString();
if (!text) return null;
return { text, range };
}
export function getPDFSelectionRects(): DOMRect[] {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return [];
const rects: DOMRect[] = [];
const range = selection.getRangeAt(0);
for (const rect of range.getClientRects()) {
rects.push(rect);
}
return rects;
}