refactor: Remove broken reader implementation for foliate-js migration

Remove 60+ files from the old reader implementation that relied on
CSS columns pagination, which was fundamentally broken. This includes:
- Comic/Manga format handlers (panel detection, reading direction)
- PDF rendering, bookmarks, annotations, outlines
- Reflowable content pagination (EPUB, FB2, TXT, HTML parsers)
- UI components (gestures, keyboard shortcuts, panel dock system)
- Core navigation and state management

The old implementation used CSS columns for EPUB pagination, but this
approach is fundamentally incompatible with horizontal book layouts
because CSS columns fill vertically first, then wrap horizontally.
This causes only 1 column to be created instead of the expected 92+.

This cleanup prepares the codebase for foliate-js integration, which
uses JavaScript-driven pagination with CFI-based positioning that
actually works for book reading.

Files removed:
- formats/: comic/, manga/, pdf/, reflowable/ (65 files)
- parsers/: EPUB, FB2, TXT, HTML (4 files)
- ui/: gestures, keyboard shortcuts, panel dock, progress tracker (8 files)
- core/: parser-manager, reader-navigation, reader-services, reader-state (4 files)
- reader-shell.ts: Main reader orchestrator (565 lines)

Total: 9,361 lines removed

Reader functionality will be restored via foliate-js integration.
This commit is contained in:
2026-04-12 12:09:39 -04:00
parent 87cb11cef2
commit 231a1c64e3
58 changed files with 0 additions and 9336 deletions
-166
View File
@@ -1,166 +0,0 @@
// Parser Manager - Routes files to appropriate parsers
// Procedural style: Functions, not classes
// ============================================================
// Parser Registry
// ============================================================
const PARSER_REGISTRY: ParserEntry[] = [
{
format: "epub",
mimeType: "application/epub+zip",
extensions: [".epub"],
side: "client",
},
{
format: "fb2",
mimeType: "application/fb2",
extensions: [".fb2", ".fb2.zip"],
side: "client",
},
{
format: "txt",
mimeType: "text/plain",
extensions: [".txt"],
side: "client",
},
{
format: "html",
mimeType: "text/html",
extensions: [".html", ".htm"],
side: "client",
},
{
format: "mobi",
mimeType: "application/x-mobipocket-ebook",
extensions: [".mobi", ".azw"],
side: "server",
},
{
format: "azw3",
mimeType: "application/vnd.amazon.mobi8-ebook",
extensions: [".azw3"],
side: "server",
},
{
format: "docx",
mimeType:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
extensions: [".docx"],
side: "server",
},
{
format: "rtf",
mimeType: "application/rtf",
extensions: [".rtf"],
side: "server",
},
];
interface ParserEntry {
format: string;
mimeType: string;
extensions: string[];
side: "client" | "server";
}
// ============================================================
// Parser Detection
// ============================================================
export function detectParserFormat(
mimeType: string,
extension: string,
): ParserEntry | null {
return (
PARSER_REGISTRY.find(
(entry) =>
entry.mimeType === mimeType ||
entry.extensions.includes(extension.toLowerCase()),
) || null
);
}
export function requiresServerParsing(
mimeType: string,
extension: string,
): boolean {
const entry = detectParserFormat(mimeType, extension);
return entry?.side === "server" || false;
}
// ============================================================
// Main Parse Function (Router)
// ============================================================
export async function parseEbook(
file: Blob,
mimeType: string,
extension: string,
): Promise<EbookCIF> {
const entry = detectParserFormat(mimeType, extension);
if (!entry) {
throw new Error(`Unsupported ebook format: ${mimeType}, ${extension}`);
}
if (entry.side === "server") {
return parseEbookOnServer(file, entry.format);
} else {
return parseEbookOnClient(file, entry.format);
}
}
// ============================================================
// Client-Side Parsing
// ============================================================
async function parseEbookOnClient(
file: Blob,
format: string,
): Promise<EbookCIF> {
switch (format) {
case "epub": {
const { parseEPUB } = await import("../parsers/epub-parsers");
return parseEPUB(file);
}
case "fb2": {
const { parseFB2 } = await import("../parsers/fb2-parser");
return parseFB2(file);
}
case "txt": {
const { parseTXT } = await import("../parsers/txt-parser");
return parseTXT(file);
}
case "html": {
const { parseHTML } = await import("../parsers/html-parser");
return parseHTML(file);
}
default:
throw new Error(`Client-side parser not implemented for: ${format}`);
}
}
// ============================================================
// Server-Side Parsing (API Call)
// ============================================================
async function parseEbookOnServer(
file: Blob,
format: string,
): Promise<EbookCIF> {
const formData = new FormData();
formData.append("file", file);
formData.append("format", format);
const response = await fetch("/readers/parse", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(`Server parsing failed: ${response.statusText}`);
}
return await response.json();
}
-534
View File
@@ -1,534 +0,0 @@
import { getDefaultSettings } from "../settings-manager";
import { getState, setState } from "./reader-state";
import { readerEvents } from "./reader-events";
import { updateReadingProgress } from "./reader-services";
import { ComicReader, MangaReader, UniversalReader } from "../reader-shell";
import * as reflowableNav from "../formats/reflowable/navigation";
import * as progressTracker from "../formats/reflowable/progress-tracker";
import * as contentRenderer from "../formats/reflowable/content-renderer";
import { CurrentReader } from "./reader-context";
export function createNavigationAPI() {
return {
nextPage: () => {
const state = getState();
if (!state.currentReader) return;
readerEvents.emit("beforePageChange", state.currentReader);
if (state.currentReader.type === "ebook") {
// NEW: Use reflowable navigation
const book = state.currentReader as UniversalReader;
if (!reflowableNav.canGoNext(book)) {
console.log("canGoNext returned false:", {
currentPage: book.position.currentPage,
totalPages: book.pagination?.totalPages,
pagination: book.pagination,
});
return; // Already at last page
}
const { success, position, content } = reflowableNav.nextPage(book);
if (success) {
const container = document.getElementById("reader-content");
if (!container) return;
// Update position
const updatedBook = progressTracker.updateCurrentPosition(
book,
position,
);
setState({ currentReader: updatedBook });
// Render content
contentRenderer.renderPage(container, content);
// Update UI
updatePageIndicator(updatedBook);
sendProgressUpdate();
// Emit progress event for page counter
readerEvents.emit("progressUpdated", {
currentPage: updatedBook.currentPage,
totalPages: updatedBook.pagination?.totalPages || 0,
percentage: updatedBook.position?.progress || 0,
});
}
} else if (state.currentReader.type === "pdf") {
// Keep existing PDF code (lines 45-51)
const totalPages = state.readerMetadata?.total_pages || 0;
if (state.currentReader.currentPage < totalPages) {
state.currentReader.currentPage++;
renderPDFPage();
sendProgressUpdate();
}
} else if (
state.currentReader.type === "comic" ||
state.currentReader.type === "manga"
) {
if (
state.currentReader.currentPage <
state.currentReader.images.length - 1
) {
state.currentReader.currentPage++;
renderComicPage();
sendProgressUpdate();
}
}
setState({ currentReader: state.currentReader });
readerEvents.emit("afterPageChange", state.currentReader);
},
previousPage: () => {
const state = getState();
if (!state.currentReader) return;
readerEvents.emit("beforePageChange", state.currentReader);
if (state.currentReader.type === "ebook") {
// NEW: Use reflowable navigation
const book = state.currentReader as UniversalReader;
if (!reflowableNav.canGoPrevious(book)) {
return; // Already at first page
}
const { success, position, content } = reflowableNav.previousPage(book);
if (success) {
const container = document.getElementById("reader-content");
if (!container) return;
// Update position
const updatedBook = progressTracker.updateCurrentPosition(
book,
position,
);
setState({ currentReader: updatedBook });
// Render content
contentRenderer.renderPage(container, content);
// Update UI
updatePageIndicator(updatedBook);
sendProgressUpdate();
// Emit progress event for page counter
readerEvents.emit("progressUpdated", {
currentPage: updatedBook.currentPage,
totalPages: updatedBook.pagination?.totalPages || 0,
percentage: updatedBook.position?.progress || 0,
});
}
} else if (state.currentReader.type === "pdf") {
if (state.currentReader.currentPage > 1) {
state.currentReader.currentPage--;
renderPDFPage();
sendProgressUpdate();
}
} else if (
state.currentReader.type === "comic" ||
state.currentReader.type === "manga"
) {
if (state.currentReader.currentPage > 0) {
state.currentReader.currentPage--;
renderComicPage();
sendProgressUpdate();
}
}
setState({ currentReader: state.currentReader });
readerEvents.emit("afterPageChange", state.currentReader);
},
goToPage: (page: number) => {
const state = getState();
if (!state.currentReader) return;
readerEvents.emit("beforePageChange", state.currentReader);
if (state.currentReader.type === "ebook") {
// NEW: Use reflowable navigation
const book = state.currentReader as UniversalReader;
const { success, position, content } = reflowableNav.goToPage(
book,
page,
);
if (success) {
const container = document.getElementById("reader-content");
if (!container) return;
// Update position
const updatedBook = progressTracker.updateCurrentPosition(
book,
position,
);
setState({ currentReader: updatedBook });
// Render content
contentRenderer.renderPage(container, content);
// Update UI
updatePageIndicator(updatedBook);
sendProgressUpdate();
}
} else if (state.currentReader.type === "pdf") {
if (page >= 1 && page <= (state.readerMetadata?.total_pages || 0)) {
state.currentReader.currentPage = page;
renderPDFPage();
sendProgressUpdate();
}
} else if (
state.currentReader &&
isComicOrMangaReader(state.currentReader)
) {
if (page >= 0 && page < state.currentReader.images.length) {
state.currentReader.currentPage = page;
renderComicPage();
sendProgressUpdate();
}
}
setState({ currentReader: state.currentReader });
readerEvents.emit("pageChanged", page);
readerEvents.emit("afterPageChange", state.currentReader);
},
goToChapter: (chapterIndex: number) => {
const state = getState();
if (!state.readerMetadata?.chapter_metadata?.chapters) return;
if (state.currentReader?.type !== "ebook") return;
const chapters = state.readerMetadata.chapter_metadata.chapters;
if (chapterIndex < 0 || chapterIndex >= chapters.length) return;
const chapter = chapters[chapterIndex];
if (chapter.spine_index !== undefined) {
state.currentReader.currentSpineIndex = chapter.spine_index;
setState({ currentReader: state.currentReader });
renderSpineItem();
sendProgressUpdate();
} else {
const pageAPI = createNavigationAPI();
pageAPI.goToPage(chapter.start_page);
}
readerEvents.emit("chapterChanged", chapter);
},
};
}
function isComicOrMangaReader(
reader: CurrentReader,
): reader is ComicReader | MangaReader {
return reader.type === "comic" || reader.type === "manga";
}
// Helper function to update page indicator
function updatePageIndicator(book: UniversalReader): void {
const { currentPage, totalPages, percentage } =
progressTracker.calculateProgress(book);
const container = document.getElementById("reader-container");
if (!container) return;
// Update page display
const pageDisplay = document.querySelector(".page-display");
if (pageDisplay) {
pageDisplay.textContent = `Page ${currentPage} of ${totalPages}`;
}
// Update progress bar
const progressBar = document.querySelector(
".progress-bar-fill",
) as HTMLElement;
if (progressBar) {
progressBar.style.width = `${percentage}%`;
}
}
export async function initializePageCalculation() {
setupScrollTracking();
console.log("Page tracking initialized (CSS columns mode)");
}
function setupScrollTracking(): void {
let scrollTimeout: ReturnType<typeof setTimeout> | null = null;
document.addEventListener(
"scroll",
() => {
const container = document.getElementById("reader-content");
if (!container) return;
const state = getState();
if (!state.currentReader || state.currentReader.type !== "ebook") return;
if (scrollTimeout) clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
updatePageFromScroll(container);
}, 50);
},
{ passive: true },
);
}
function updatePageFromScroll(container: HTMLElement): void {
const state = getState();
if (!state.currentReader || state.currentReader.type !== "ebook") return;
const scrollTop = container.scrollTop;
const contentHeight = container.scrollHeight;
const percentage = contentHeight > 0 ? scrollTop / contentHeight : 0;
setState({
currentReader: {
...state.currentReader,
currentScrollPosition: scrollTop,
},
});
readerEvents.emit("progressUpdated", {
currentPage: state.currentReader.currentPage,
totalPages: state.currentReader.pagination?.totalPages || 0,
percentage,
scrollTop,
});
}
export async function renderSpineItem(): Promise<void> {
const state = getState();
if (state.currentReader?.type !== "ebook") return;
const container = document.getElementById("reader-content");
if (!container) return;
const spineItem =
state.currentReader.cif.spine[state.currentReader.currentSpineIndex];
if (!spineItem) return;
const resources = state.currentReader.cif.resources;
const contentBlob = resources?.get(spineItem.content);
if (!contentBlob) {
console.error("Spine item content not found:", spineItem.content);
container.innerHTML = `<p>Error: Could not load chapter content</p>`;
return;
}
let currentPageContent = await contentBlob.text();
const modifiedContent = rewriteImageUrls(
currentPageContent,
state.currentReader.cif.resources,
);
const parser = new DOMParser();
const doc = parser.parseFromString(modifiedContent, "text/html");
const imgElements = Array.from(doc.querySelectorAll("img"));
for (const img of imgElements) {
const src = img.getAttribute("src");
if (!src) continue;
const blob = findImageInResources(state.currentReader.cif.resources, src);
if (blob) {
img.setAttribute("src", URL.createObjectURL(blob));
}
}
const svgImgElements = Array.from(doc.querySelectorAll("image"));
for (const img of svgImgElements) {
const src = img.getAttribute("xlink:href");
if (!src) continue;
const blob = findImageInResources(state.currentReader.cif.resources, src);
if (blob) {
img.setAttribute("xlink:href", URL.createObjectURL(blob));
}
}
container.innerHTML = `<div class="ebook-content">${doc.body.innerHTML}</div>`;
applyReaderTheme();
applyTypography();
requestAnimationFrame(() => {
updatePageFromScroll(container);
});
}
function rewriteImageUrls(
htmlContent: string,
resources: Map<string, Blob>,
): string {
// Find all img tags and rewrite their src to blob URLs
const imgRegex = /<img\s+[^>]*src="([^"]+)"[^>]*>/gi;
return htmlContent.replace(imgRegex, (match, src) => {
// Try to find the image in resources
const imageBlob = findImageInResources(resources, src);
if (imageBlob) {
const blobUrl = URL.createObjectURL(imageBlob);
return match.replace(src, blobUrl);
}
return match; // Keep original if not found
});
}
export function findImageInResources(
resources: Map<string, Blob>,
src: string,
): Blob | undefined {
// Try full path as stored
if (resources.has(src)) return resources.get(src);
// Try relative path (everything after first /)
const firstSlash = src.indexOf("/");
if (firstSlash > 0) {
const relativePath = src.substring(firstSlash + 1);
if (resources.has(relativePath)) return resources.get(relativePath);
}
// Try filename only
const filename = src.split("/").pop();
if (filename && resources.has(filename)) return resources.get(filename);
// Try without extension
const withoutExt = filename?.replace(/\.[^.]+$/, "");
if (withoutExt && resources.has(withoutExt)) return resources.get(withoutExt);
// Try with common extensions
const extensions = [".jpg", ".jpeg", ".gif", ".webp", ".svg", ".png"];
for (const ext of extensions) {
const withExt = withoutExt + ext;
if (resources.has(withExt)) return resources.get(withExt);
}
return undefined;
}
async function renderPDFPage() {
const state = getState();
if (state.currentReader?.type !== "pdf") return;
const container = document.getElementById("reader-content");
if (!container) return;
const { getPDFPage } = await import("../formats/pdf/pdfjs-wrapper");
try {
const page = await getPDFPage(state.currentReader.currentPage);
const viewport = page.getViewport({ scale: 1.5 });
const canvas = document.createElement("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.className = "pdf-page mx-auto";
const ctx = canvas.getContext("2d");
if (ctx) {
await page.render({
canvasContext: ctx,
viewport: viewport,
canvas: canvas,
}).promise;
container.innerHTML = "";
container.appendChild(canvas);
}
} catch (error) {
console.error("Failed to render PDF page:", error);
}
}
function renderComicPage() {
const state = getState();
if (
state.currentReader?.type !== "comic" &&
state.currentReader?.type !== "manga"
)
return;
const container = document.getElementById("reader-content");
if (
!container ||
!state.currentReader.images[state.currentReader.currentPage]
)
return;
const img = document.createElement("img");
img.src = URL.createObjectURL(
state.currentReader.images[state.currentReader.currentPage],
);
img.className = "w-full h-contain object-contain";
img.alt = `Page ${state.currentReader.currentPage + 1}`;
container.innerHTML = "";
container.appendChild(img);
}
function applyReaderTheme() {
const settings = getDefaultSettings();
const container = document.getElementById("reader-content");
if (!container) return;
container.classList.add(`theme-${settings.reading_theme}`);
}
function applyTypography() {
const settings = getDefaultSettings();
const container = document.getElementById("reader-content");
if (!container) return;
container.style.fontSize = `${settings.font_size}px`;
container.style.lineHeight = settings.line_height.toString();
container.style.fontFamily = getFontStack(settings.reading_font);
}
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"];
}
function sendProgressUpdate(): void {
const state = getState();
if (!state.currentReader || !state.readerMetadata) return;
const mediaItemId = state.readerMetadata.id;
if (!mediaItemId) {
console.warn("No mediaItemId available for progress update");
return;
}
let currentPage = 1;
let totalPages = 1;
let percentage = 0;
if (state.currentReader.type === "ebook") {
const book = state.currentReader as UniversalReader;
const posData = progressTracker.getPositionForSave(book);
currentPage = posData.page;
totalPages = book.pagination?.totalPages || 1;
percentage = posData.progress;
// Correct: 3 separate parameters
updateReadingProgress(
mediaItemId, // 1st param: mediaItemId
{
// 2nd param: ReadingProgress object
current_page: posData.page,
total_pages: totalPages,
cfi: posData.cfi,
percentage,
last_read_at: new Date().toISOString(),
},
{
// 3rd param (optional): ExtendedProgress
percentage,
},
);
} else if (state.currentReader.type === "pdf") {
totalPages = state.readerMetadata.total_pages || 1;
currentPage = state.currentReader.currentPage;
percentage = currentPage / totalPages;
updateReadingProgress(
mediaItemId,
{
current_page: currentPage,
total_pages: totalPages,
last_read_at: new Date().toISOString(),
percentage,
},
{ percentage },
);
} else if (
state.currentReader.type === "comic" ||
state.currentReader.type === "manga"
) {
totalPages = state.currentReader.images.length;
currentPage = state.currentReader.currentPage;
percentage = currentPage / totalPages;
updateReadingProgress(
mediaItemId,
{
current_page: currentPage,
total_pages: totalPages,
last_read_at: new Date().toISOString(),
percentage,
},
{ percentage },
);
}
readerEvents.emit("progressUpdated", { currentPage, totalPages, percentage });
}
-74
View File
@@ -1,74 +0,0 @@
import { apiPut, ReadingProgress } from "../../api";
interface ExtendedProgress {
character?: number;
chapter?: number;
percentage?: number;
}
export async function updateReadingProgress(
mediaItemId: string,
progress: ReadingProgress,
extended?: ExtendedProgress,
): Promise<void> {
if (!mediaItemId || mediaItemId === "undefined") {
console.warn("Skipping progress update - no valid mediaItemId");
return;
}
const payload: ReadingProgress = {
current_page: progress.current_page,
total_pages: progress.total_pages,
cfi: progress.cfi || "",
percentage: extended?.percentage || 0,
last_read_at: new Date().toISOString(),
};
const response = await apiPut(
`/media-items/${mediaItemId}/progress`,
payload,
);
if (!response.ok) {
const clonedResponse = response.clone();
const errorText = await clonedResponse.text();
console.warn("Progress update failed:", clonedResponse.status, errorText);
return;
}
}
export function getChapterNavigation(chapters: Chapter[]) {
return {
getNextChapter: (currentPage: number) => {
for (let i = 0; i < chapters.length - 1; i++) {
const chapter = chapters[i];
const nextChapter = chapters[i + 1];
if (
currentPage >= chapter.start_page &&
currentPage < nextChapter.start_page
) {
return nextChapter.start_page;
}
}
return null;
},
getPreviousChapter: (currentPage: number) => {
for (let i = 1; i < chapters.length; i++) {
const chapter = chapters[i];
if (
currentPage >= chapter.start_page &&
currentPage < chapter.start_page + chapter.page_count
) {
return chapters[i - 1].start_page;
}
}
if (currentPage < chapters[0].start_page) {
return null;
}
return chapters[0].start_page;
},
};
}
-44
View File
@@ -1,44 +0,0 @@
import type { ReaderState } from "./reader-context";
type CurrentReader = import("./reader-context").CurrentReader;
let currentState: ReaderState = {
currentReader: null,
readerMetadata: null,
};
export function getState(): ReaderState {
return { ...currentState };
}
export function setState(updates: Partial<ReaderState>): void {
const oldState = { ...currentState };
currentState = { ...currentState, ...updates };
// Emit events for significant changes
if (
updates.currentReader &&
oldState.currentReader !== updates.currentReader
) {
readerEvents.emit("readerReady", currentState.currentReader);
}
}
export function getCurrentReader(): CurrentReader | null {
return currentState.currentReader;
}
export function getReaderMetadata(): ReaderMetadata | null {
return currentState.readerMetadata;
}
export function getCurrentPage(): number {
const reader = currentState.currentReader;
if (!reader) return 0;
if (reader.type === "ebook") {
return reader.currentPage ?? reader.currentSpineIndex + 1;
}
return reader.currentPage;
}
import { readerEvents } from "./reader-events";
@@ -1,147 +0,0 @@
// Background color options for manga/comics
// Feature Registration Pattern implementation
import { ReaderContext } from "../../core/reader-context";
export function init(context: ReaderContext): void {
const state = createBackgroundColorState();
applyBackgroundColor(state.current);
context.events.on(
"background-color:set",
(detail: { color: BackgroundColor; customColor?: string }) => {
setBackgroundColor(state, detail.color, detail.customColor);
},
);
context.events.on("background-color:toggle", () => {
toggleBackgroundColor(state);
});
context.events.on(
"ui:show-settings",
(detail: { container: HTMLElement }) => {
renderBackgroundColorPicker(detail.container, state);
},
);
context.events.on("reader:unload", () => {
const picker = document.querySelector(".background-color-picker");
picker?.remove();
});
}
type BackgroundColor = "black" | "white" | "gray" | "sepia" | "custom";
interface BackgroundColorState {
current: BackgroundColor;
customColor: string;
}
const backgroundColors: Record<BackgroundColor, string> = {
black: "#000000",
white: "#ffffff",
gray: "#333333",
sepia: "#f4ecd8",
custom: "",
};
function createBackgroundColorState(
initial: BackgroundColor = "black",
): BackgroundColorState {
const saved = localStorage.getItem(
"reader-background-color",
) as BackgroundColor;
return {
current: saved || initial,
customColor: "#000000",
};
}
function applyBackgroundColor(color: BackgroundColor): void {
const bgColor = backgroundColors[color];
document.documentElement.style.setProperty("--reader-bg-color", bgColor);
const viewer = document.querySelector(".reader-content") as HTMLElement;
if (viewer) {
viewer.style.backgroundColor = bgColor;
}
}
function setBackgroundColor(
state: BackgroundColorState,
color: BackgroundColor,
customColor?: string,
): BackgroundColorState {
state.current = color;
state.customColor = customColor || state.customColor;
const bgColor =
color === "custom" ? state.customColor : backgroundColors[color];
document.documentElement.style.setProperty("--reader-bg-color", bgColor);
const viewer = document.querySelector(".reader-content") as HTMLElement;
if (viewer) {
viewer.style.backgroundColor = bgColor;
}
localStorage.setItem("reader-background-color", color);
const picker = document.querySelector(".background-color-picker");
if (picker) {
updateBackgroundColorUI(picker as HTMLElement, state);
}
return state;
}
function toggleBackgroundColor(
state: BackgroundColorState,
): BackgroundColorState {
const order: BackgroundColor[] = ["black", "white", "gray", "sepia"];
const currentIndex = order.indexOf(state.current);
const nextIndex = (currentIndex + 1) % order.length;
return setBackgroundColor(state, order[nextIndex]);
}
function renderBackgroundColorPicker(
container: HTMLElement,
state: BackgroundColorState,
): void {
const existing = container.querySelector(".background-color-picker");
existing?.remove();
const picker = document.createElement("div");
picker.className =
"background-color-picker fixed bottom-24 left-4 bg-gray-900 bg-opacity-90 rounded-lg p-2 flex gap-2 z-40";
const colors: BackgroundColor[] = ["black", "white", "gray", "sepia"];
colors.forEach((color) => {
const btn = document.createElement("button");
btn.className = `w-8 h-8 rounded-full border-2 ${
state.current === color ? "border-blue-500" : "border-transparent"
}`;
btn.style.backgroundColor = backgroundColors[color];
btn.title = color.charAt(0).toUpperCase() + color.slice(1);
btn.addEventListener("click", () => {
setBackgroundColor(state, color);
updateBackgroundColorUI(picker, state);
});
picker.appendChild(btn);
});
container.appendChild(picker);
}
function updateBackgroundColorUI(
container: HTMLElement,
state: BackgroundColorState,
): void {
const buttons = container.querySelectorAll("button");
const colors: BackgroundColor[] = ["black", "white", "gray", "sepia"];
buttons.forEach((btn, index) => {
btn.classList.toggle("border-blue-500", colors[index] === state.current);
});
}
@@ -1,155 +0,0 @@
// Chapter markers for manga/comics
// Visual indicators for chapter boundaries
// Feature Registration Pattern implementation
import type { ReaderContext } from "../../core/reader-context";
export function init(context: ReaderContext): void {
let state: ChapterMarkerState | null = null;
context.events.on(
"reader:loaded",
(detail: { chapters: ChapterInfo[]; currentPage: number }) => {
state = createChapterMarkerState(detail.chapters, detail.currentPage);
renderChapterMarkers(context.elements.readerContent, state);
},
);
context.events.on("page-changed", (detail: { page: number }) => {
if (state) {
updateCurrentChapter(state, detail.page);
}
});
context.events.on("chapter-markers:toggle", () => {
if (state) {
toggleChapterMarkers(state);
}
});
context.events.on(
"chapter-markers:navigate",
(detail: { chapterNumber: number }) => {
if (state) {
scrollToChapter(state, detail.chapterNumber);
}
},
);
context.events.on("reader:unload", () => {
const markers = document.querySelector(".chapter-markers");
markers?.remove();
});
}
interface ChapterInfo {
chapterNumber: number;
pageStart: number;
pageEnd: number;
title?: string;
}
interface ChapterMarkerState {
chapters: ChapterInfo[];
currentChapter: number;
showMarkers: boolean;
}
function createChapterMarkerState(
chapters: ChapterInfo[],
currentPage: number,
): ChapterMarkerState {
const currentChapter =
chapters.find((c) => currentPage >= c.pageStart && currentPage <= c.pageEnd)
?.chapterNumber || 1;
return {
chapters,
currentChapter,
showMarkers: true,
};
}
function renderChapterMarkers(
container: HTMLElement,
state: ChapterMarkerState,
): void {
if (!state.showMarkers) return;
const markersContainer = document.createElement("div");
markersContainer.className =
"chapter-markers absolute left-0 right-0 pointer-events-none z-10";
state.chapters.forEach((chapter) => {
const marker = document.createElement("div");
marker.className =
"chapter-marker flex items-center gap-2 text-sm text-gray-400";
const isCurrentChapter = chapter.chapterNumber === state.currentChapter;
marker.style.position = "absolute";
marker.style.top = `${((chapter.pageStart - 1) / 100) * 100}%`;
marker.style.left = "10px";
marker.innerHTML = `
<span class="chapter-number ${isCurrentChapter ? "text-blue-400 font-bold" : ""}">
${chapter.title || `Chapter ${chapter.chapterNumber}`}
</span>
<span class="page-number text-xs">p.${chapter.pageStart}</span>
${isCurrentChapter ? '<span class="current-indicator">←</span>' : ""}
`;
markersContainer.appendChild(marker);
});
const existing = container.querySelector(".chapter-markers");
existing?.remove();
container.appendChild(markersContainer);
}
function updateCurrentChapter(
state: ChapterMarkerState,
currentPage: number,
): ChapterMarkerState {
const currentChapter =
state.chapters.find(
(c) => currentPage >= c.pageStart && currentPage <= c.pageEnd,
)?.chapterNumber || state.currentChapter;
if (currentChapter !== state.currentChapter) {
state.currentChapter = currentChapter;
const markers = document.querySelector(".chapter-markers");
if (markers) {
renderChapterMarkers(markers.parentElement!, state);
}
}
return state;
}
function toggleChapterMarkers(state: ChapterMarkerState): ChapterMarkerState {
state.showMarkers = !state.showMarkers;
const markers = document.querySelector(".chapter-markers");
if (markers) {
markers.classList.toggle("hidden", !state.showMarkers);
}
return state;
}
function scrollToChapter(
state: ChapterMarkerState,
chapterNumber: number,
): void {
const chapter = state.chapters.find((c) => c.chapterNumber === chapterNumber);
if (chapter) {
window.dispatchEvent(
new CustomEvent("navigate-to-page", {
detail: { page: chapter.pageStart },
}),
);
}
}
@@ -1,71 +0,0 @@
// Comic/Manga Reader - Image-based pages
// Handles CBZ, comic archives, image directories
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;
}
-195
View File
@@ -1,195 +0,0 @@
// Lazy-loading page cache with 5-page ahead prefetch
// Shared by both comic and manga readers
// Feature Registration Pattern implementation
import { 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;
}
-152
View File
@@ -1,152 +0,0 @@
// Page order presets for manga/comics
// Auto-detect Japanese vs Western reading order
// Feature Registration Pattern implementation
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("page-order:set", (detail: { mode: PageOrderMode }) => {
if (state) {
setPageOrderMode(state, detail.mode, context);
}
});
context.events.on("page-order:get", () => {
if (state) {
const order = getPageOrder(state);
context.events.emit("page-order:current", { order });
}
});
context.events.on(
"page-order:reorder",
(detail: { pageNumbers: number[] }) => {
if (state) {
const reordered = reorderPages(state, detail.pageNumbers);
context.events.emit("page-order:reordered", { pages: reordered });
}
},
);
context.events.on(
"page-order:display-number",
(detail: { actualPage: number }) => {
if (state) {
const displayPage = getDisplayPageNumber(state, detail.actualPage);
context.events.emit("page-order:display-page", { displayPage });
}
},
);
}
type PageOrderMode = "auto" | "japanese" | "western";
interface PageOrderConfig {
mode: PageOrderMode;
detectedOrder: PageOrderMode;
userOverride: boolean;
}
interface PageOrderState {
config: PageOrderConfig;
totalPages: number;
}
function detectPageOrder(pageNames: string[]): PageOrderMode {
if (pageNames.length < 2) return "western";
const firstPage = pageNames[0].toLowerCase();
const lastPage = pageNames[pageNames.length - 1].toLowerCase();
const hasFrontCover = /cover|front|001/.test(firstPage);
const hasBackCover = /back|end|最后的/.test(lastPage);
if (hasFrontCover && !hasBackCover) {
return "western";
}
if (hasBackCover && !hasFrontCover) {
return "japanese";
}
const chapterMatches = pageNames.filter((n) => /ch-\d+|chapter/i.test(n));
if (chapterMatches.length > 0) {
const firstChapter = chapterMatches[0];
const pageNum = parseInt(firstChapter.match(/\d+/)?.[0] || "0");
return pageNum > 0 ? "western" : "japanese";
}
return "western";
}
function createPageOrderState(
totalPages: number,
pageNames: string[],
): PageOrderState {
const detectedOrder = detectPageOrder(pageNames);
return {
config: {
mode: "auto",
detectedOrder,
userOverride: false,
},
totalPages,
};
}
function setPageOrderMode(
state: PageOrderState,
mode: PageOrderMode,
context: ReaderContext,
): PageOrderState {
state.config = {
...state.config,
mode,
userOverride: mode !== "auto",
};
const order = getPageOrder(state);
context.events.emit("page-order:changed", { mode, order });
return state;
}
function getPageOrder(state: PageOrderState): PageOrderMode {
if (state.config.mode === "auto") {
return state.config.detectedOrder;
}
return state.config.mode;
}
function reorderPages(state: PageOrderState, pageNumbers: number[]): number[] {
const order = getPageOrder(state);
if (order === "japanese") {
return [...pageNumbers].reverse();
}
return pageNumbers;
}
function getDisplayPageNumber(
state: PageOrderState,
actualPage: number,
): number {
const order = getPageOrder(state);
if (order === "japanese") {
return state.totalPages - actualPage + 1;
}
return actualPage;
}
@@ -1,132 +0,0 @@
// Page slider/scrubber for quick navigation
// Feature Registration Pattern implementation
import { 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 };
@@ -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,
};
@@ -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 };
-160
View File
@@ -1,160 +0,0 @@
// Adjustable panel gap controls
// Feature Registration Pattern implementation
import { ReaderContext } from "../../core/reader-context";
export function init(context: ReaderContext): void {
const state = createPanelGapState();
applyPanelGap(state.gapSize, state.showBorders);
context.events.on("panel-gap:set", (detail: { gap: number }) => {
setPanelGap(state, detail.gap);
});
context.events.on("panel-gap:increase", (detail?: { amount: number }) => {
increasePanelGap(state, detail?.amount);
});
context.events.on("panel-gap:decrease", (detail?: { amount: number }) => {
decreasePanelGap(state, detail?.amount);
});
context.events.on("panel-gap:borders:toggle", () => {
togglePanelBorders(state);
});
context.events.on(
"ui:show-settings",
(detail: { container: HTMLElement }) => {
renderPanelGapControls(detail.container, state);
},
);
context.events.on("reader:unload", () => {
const controls = document.querySelector(".panel-gap-controls");
controls?.remove();
});
}
interface PanelGapState {
gapSize: number;
showBorders: boolean;
}
function createPanelGapState(initialGap: number = 4): PanelGapState {
const saved = localStorage.getItem("reader-panel-gap");
return {
gapSize: saved ? parseInt(saved) : initialGap,
showBorders: false,
};
}
function applyPanelGap(gap: number, showBorders: boolean): void {
document.documentElement.style.setProperty("--panel-gap", `${gap}px`);
document.documentElement.style.setProperty(
"--panel-border-width",
showBorders ? "1px" : "0px",
);
localStorage.setItem("reader-panel-gap", String(gap));
}
function setPanelGap(state: PanelGapState, gap: number): PanelGapState {
const clampedGap = Math.max(0, Math.min(20, gap));
state.gapSize = clampedGap;
document.documentElement.style.setProperty("--panel-gap", `${clampedGap}px`);
localStorage.setItem("reader-panel-gap", String(clampedGap));
const controls = document.querySelector(".panel-gap-controls");
if (controls) {
updatePanelGapUI(controls as HTMLElement, state);
}
return state;
}
function increasePanelGap(
state: PanelGapState,
amount: number = 2,
): PanelGapState {
return setPanelGap(state, state.gapSize + amount);
}
function decreasePanelGap(
state: PanelGapState,
amount: number = 2,
): PanelGapState {
return setPanelGap(state, state.gapSize - amount);
}
function togglePanelBorders(state: PanelGapState): PanelGapState {
state.showBorders = !state.showBorders;
document.documentElement.style.setProperty(
"--panel-border-width",
state.showBorders ? "1px" : "0px",
);
const controls = document.querySelector(".panel-gap-controls");
if (controls) {
updatePanelGapUI(controls as HTMLElement, state);
}
return state;
}
function renderPanelGapControls(
container: HTMLElement,
state: PanelGapState,
): void {
const existing = container.querySelector(".panel-gap-controls");
existing?.remove();
const controls = document.createElement("div");
controls.className =
"panel-gap-controls fixed bottom-24 right-4 bg-gray-900 bg-opacity-90 rounded-lg p-2 flex flex-col gap-2 z-40";
controls.innerHTML = `
<button class="panel-gap-increase p-2 hover:bg-gray-700 rounded" title="Increase gap">+</button>
<span class="text-center text-sm">${state.gapSize}px</span>
<button class="panel-gap-decrease p-2 hover:bg-gray-700 rounded" title="Decrease gap">-</button>
<button class="panel-gap-borders p-2 hover:bg-gray-700 rounded" title="Toggle borders">
${state.showBorders ? "▦" : "▢"}
</button>
`;
controls
.querySelector(".panel-gap-increase")
?.addEventListener("click", () => {
increasePanelGap(state);
updatePanelGapUI(controls, state);
});
controls
.querySelector(".panel-gap-decrease")
?.addEventListener("click", () => {
decreasePanelGap(state);
updatePanelGapUI(controls, state);
});
controls
.querySelector(".panel-gap-borders")
?.addEventListener("click", () => {
togglePanelBorders(state);
updatePanelGapUI(controls, state);
});
container.appendChild(controls);
}
function updatePanelGapUI(container: HTMLElement, state: PanelGapState): void {
const gapLabel = container.querySelector("span");
if (gapLabel) {
gapLabel.textContent = `${state.gapSize}px`;
}
const bordersBtn = container.querySelector(".panel-gap-borders");
if (bordersBtn) {
bordersBtn.textContent = state.showBorders ? "▦" : "▢";
}
}
@@ -1,146 +0,0 @@
// Detect reading direction from metadata or user preference
// Feature Registration Pattern implementation
import { ReaderContext } from "../../core/reader-context";
interface MangaMetadata {
manga_type?: string;
reading_direction?: string;
filePath: string;
}
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: MangaMetadata,
): "ltr" | "rtl" | "vertical" {
const mangaType = metadata.manga_type;
if (mangaType === "yes_and_right_to_left" || mangaType === "yes") {
return "rtl";
}
const readingDirection = metadata.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";
}
@@ -1,100 +0,0 @@
// Right-to-left navigation for manga
// Feature Registration Pattern implementation
import { 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;
}
-103
View File
@@ -1,103 +0,0 @@
// Manga-specific settings integration
// Feature Registration Pattern implementation
import { 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);
}
@@ -1,200 +0,0 @@
// Vertical scroll mode for webtoons/manhwa
// Feature Registration Pattern implementation
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(
"vertical-scroll:load-page",
async (detail: { pageNumber: number }) => {
if (state) {
await loadPage(state, detail.pageNumber);
}
},
);
context.events.on("vertical-scroll:get-current", () => {
if (state) {
const currentPage = getCurrentPageFromScroll(state);
context.events.emit("vertical-scroll:current-page", {
page: currentPage,
});
}
});
context.events.on("reader:unload", () => {
if (state) {
destroyVerticalScroll(state);
state = null;
}
});
}
interface VerticalScrollState {
container: HTMLElement;
loadedPages: Set<number>;
loadingPages: Set<number>;
currentPage: number;
totalPages: number;
threshold: number;
mediaItemId: string;
}
function createVerticalScroll(
container: HTMLElement,
mediaItemId: string,
totalPages: number,
): VerticalScrollState {
const state: VerticalScrollState = {
container,
loadedPages: new Set(),
loadingPages: new Set(),
currentPage: 1,
totalPages,
threshold: 500,
mediaItemId,
};
loadPage(state, 1);
setupScrollListener(state);
return state;
}
async function loadPage(
state: VerticalScrollState,
pageNumber: number,
): Promise<void> {
if (state.loadedPages.has(pageNumber) || state.loadingPages.has(pageNumber)) {
return;
}
state.loadingPages.add(pageNumber);
try {
const token = localStorage.getItem("token");
const response = await fetch(
`/readers/${state.mediaItemId}/pages/${pageNumber}`,
{
headers: { Authorization: `Bearer ${token}` },
},
);
if (!response.ok) {
throw new Error(`Failed to load page ${pageNumber}`);
}
const blob = await response.blob();
const imgUrl = URL.createObjectURL(blob);
const pageContainer = document.createElement("div");
pageContainer.className = "vertical-page";
pageContainer.dataset.pageNumber = pageNumber.toString();
const img = document.createElement("img");
img.src = imgUrl;
img.alt = `Page ${pageNumber}`;
img.loading = "lazy";
pageContainer.appendChild(img);
state.container.appendChild(pageContainer);
state.loadedPages.add(pageNumber);
state.loadingPages.delete(pageNumber);
if (pageNumber < state.totalPages) {
loadPage(state, pageNumber + 1);
if (pageNumber + 1 < state.totalPages) {
loadPage(state, pageNumber + 2);
}
}
} catch (error) {
console.error(`Failed to load page ${pageNumber}:`, error);
state.loadingPages.delete(pageNumber);
}
}
function setupScrollListener(state: VerticalScrollState): void {
let scrollTimeout: number | undefined;
state.container.addEventListener("scroll", () => {
clearTimeout(scrollTimeout);
scrollTimeout = window.setTimeout(() => {
checkScrollPosition(state);
}, 100);
});
}
function checkScrollPosition(state: VerticalScrollState): void {
const scrollBottom =
state.container.scrollHeight -
state.container.scrollTop -
state.container.clientHeight;
if (scrollBottom < state.threshold) {
const lastPage = Math.max(...state.loadedPages);
if (lastPage < state.totalPages) {
loadPage(state, lastPage + 1);
}
}
const currentPage = getCurrentPageFromScroll(state);
if (currentPage !== state.currentPage) {
state.currentPage = currentPage;
window.dispatchEvent(
new CustomEvent("page-change", {
detail: { page: currentPage },
}),
);
}
}
function getCurrentPageFromScroll(state: VerticalScrollState): number {
const pages = Array.from(state.container.querySelectorAll(".vertical-page"));
for (const page of pages) {
const rect = page.getBoundingClientRect();
const containerRect = state.container.getBoundingClientRect();
const pageMiddle = rect.top + rect.height / 2;
const viewportMiddle = containerRect.top + containerRect.height / 2;
if (Math.abs(pageMiddle - viewportMiddle) < containerRect.height / 4) {
return parseInt((page as HTMLElement).dataset.pageNumber || "1");
}
}
return state.currentPage;
}
function destroyVerticalScroll(state: VerticalScrollState): void {
const images = Array.from(state.container.querySelectorAll("img"));
images.forEach((img) => {
const url = img.src;
if (url.startsWith("blob:")) {
URL.revokeObjectURL(url);
}
});
state.container.innerHTML = "";
state.loadedPages.clear();
state.loadingPages.clear();
}
@@ -1,123 +0,0 @@
// Annotation layer for rendering highlights and notes on PDFs
// Feature Registration Pattern implementation
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:clear",
(detail: { container: HTMLElement }) => {
clearPDFHighlights(detail.container);
},
);
context.events.on(
"pdf:highlight:remove",
(detail: { highlightId: string }) => {
removePDFHighlight(detail.highlightId, highlights);
},
);
context.events.on("reader:unload", () => {
highlights.forEach((element) => element.remove());
highlights.clear();
});
}
interface PDFHighlight {
id: string;
pageNumber: number;
rects: DOMRect[];
text: string;
color: string;
noteId?: string;
}
function renderSinglePDFHighlight(
container: HTMLElement,
highlight: PDFHighlight,
highlights: Map<string, HTMLElement>,
): void {
const overlay = document.createElement("div");
overlay.className = "pdf-highlight-annotation";
overlay.dataset.highlightId = highlight.id;
overlay.style.backgroundColor = parseColor(highlight.color);
for (const rect of highlight.rects) {
const rectDiv = document.createElement("div");
rectDiv.className = "pdf-highlight-rect";
rectDiv.style.left = `${rect.left}px`;
rectDiv.style.top = `${rect.top}px`;
rectDiv.style.width = `${rect.width}px`;
rectDiv.style.height = `${rect.height}px`;
overlay.appendChild(rectDiv);
}
if (highlight.noteId) {
overlay.style.cursor = "pointer";
overlay.addEventListener("click", () => {
showNotePopup(highlight);
});
}
overlay.addEventListener("mouseenter", () => {
overlay.style.opacity = "0.8";
});
overlay.addEventListener("mouseleave", () => {
overlay.style.opacity = "0.5";
});
container.appendChild(overlay);
highlights.set(highlight.id, overlay);
}
function parseColor(color: string): string {
if (color.startsWith("#")) {
const hex = color.slice(1);
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, 0.4)`;
}
return color;
}
function showNotePopup(highlight: PDFHighlight): void {
console.log("Show note for highlight:", highlight.id);
const event = new CustomEvent("pdf:note-show", {
detail: { highlightId: highlight.id },
});
window.dispatchEvent(event);
}
export function clearPDFHighlights(container: HTMLElement): void {
const highlights = container.querySelectorAll(".pdf-highlight-annotation");
Array.from(highlights).forEach((element) => element.remove());
}
export function removePDFHighlight(
highlightId: string,
highlights: Map<string, HTMLElement>,
): void {
const element = highlights.get(highlightId);
if (element) {
element.remove();
highlights.delete(highlightId);
}
}
-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;
}
@@ -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%;
}
`;
}
-134
View File
@@ -1,134 +0,0 @@
// Handle internal PDF links (cross-references, citations, TOC links)
// External links open in new tab
// Procedural implementation (no OOP)
import { PDFDocumentProxy } from "pdfjs-dist";
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.appendChild(miniMap);
return {
miniMap,
currentPage: 1,
totalPages: 0,
thumbnails: new Map(),
onPageNavigate,
};
}
function createMiniMapElement(): HTMLElement {
const miniMap = document.createElement("div");
miniMap.className = "pdf-minimap";
miniMap.innerHTML = `
<div class="pdf-minimap-header">Pages</div>
<div class="pdf-minimap-thumbnails"></div>
<div class="pdf-minimap-indicator"></div>
`;
const style = document.createElement("style");
style.textContent = getMiniMapStyles();
miniMap.appendChild(style);
return miniMap;
}
async function initializePDFMiniMap(
state: PDFMiniMapState,
totalPages: number,
renderThumbnail: (page: number) => Promise<HTMLCanvasElement>,
): Promise<PDFMiniMapState> {
const newState = { ...state, totalPages };
await generateMiniMapThumbnails(newState, renderThumbnail);
setupMiniMapEventListeners(newState);
return newState;
}
async function generateMiniMapThumbnails(
state: PDFMiniMapState,
renderThumbnail: (page: number) => Promise<HTMLCanvasElement>,
): Promise<void> {
const container = state.miniMap.querySelector(
".pdf-minimap-thumbnails",
) as HTMLElement;
container.innerHTML = "";
for (let page = 1; page <= state.totalPages; page++) {
try {
const thumbnail = await renderThumbnail(page);
thumbnail.className = "pdf-minimap-thumbnail";
thumbnail.dataset.pageNumber = page.toString();
thumbnail.style.width = "80px";
thumbnail.style.height = "auto";
thumbnail.style.cursor = "pointer";
thumbnail.style.marginBottom = "4px";
container.appendChild(thumbnail);
state.thumbnails.set(page, thumbnail);
} catch (error) {
console.error(`Failed to generate thumbnail for page ${page}:`, error);
}
}
}
function setupMiniMapEventListeners(state: PDFMiniMapState): void {
const container = state.miniMap.querySelector(".pdf-minimap-thumbnails");
container?.addEventListener("click", (e) => {
const thumbnail = (e.target as HTMLElement).closest(
".pdf-minimap-thumbnail",
) as HTMLElement;
if (thumbnail) {
const pageNumber = parseInt(thumbnail.dataset.pageNumber || "1");
state.onPageNavigate(pageNumber);
}
});
}
function updateMiniMapCurrentPage(
state: PDFMiniMapState,
pageNumber: number,
): PDFMiniMapState {
const indicator = state.miniMap.querySelector(
".pdf-minimap-indicator",
) as HTMLElement;
const thumbnail = state.thumbnails.get(pageNumber);
if (thumbnail && indicator) {
const rect = thumbnail.getBoundingClientRect();
indicator.style.top = `${thumbnail.offsetTop}px`;
indicator.style.height = `${rect.height}px`;
}
state.thumbnails.forEach((thumb, page) => {
if (page === pageNumber) {
thumb.style.outline = "2px solid var(--accent)";
thumb.style.opacity = "1";
} else {
thumb.style.outline = "none";
thumb.style.opacity = "0.7";
}
});
return { ...state, currentPage: pageNumber };
}
function showMiniMap(state: PDFMiniMapState): void {
state.miniMap.style.display = "block";
}
function hideMiniMap(state: PDFMiniMapState): void {
state.miniMap.style.display = "none";
}
function toggleMiniMap(state: PDFMiniMapState): void {
const isVisible = state.miniMap.style.display !== "none";
state.miniMap.style.display = isVisible ? "none" : "block";
}
function getMiniMapStyles(): string {
return `
.pdf-minimap {
position: fixed;
right: 20px;
top: 50%;
transform: translateY(-50%);
width: 100px;
max-height: 80vh;
background: var(--bg-primary);
border: 1px solid var(--text-secondary);
border-radius: 8px;
padding: 8px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.pdf-minimap-header {
font-size: 12px;
font-weight: bold;
text-align: center;
margin-bottom: 8px;
color: var(--text-primary);
}
.pdf-minimap-thumbnails {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.pdf-minimap-thumbnail {
transition: outline 0.2s, opacity 0.2s;
border-radius: 2px;
}
.pdf-minimap-thumbnail:hover {
opacity: 1 !important;
outline: 1px solid var(--text-secondary) !important;
}
.pdf-minimap-indicator {
position: absolute;
left: 0;
right: 0;
border-left: 3px solid var(--accent);
pointer-events: none;
transition: top 0.3s ease-out;
}
`;
}
@@ -1,215 +0,0 @@
// PDF navigation: page turning, zoom, fit modes
// Feature Registration Pattern implementation
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("pdf:navigate:to-page", (detail: { page: number }) => {
if (navState) {
goToPDFPage(navState, detail.page, context);
}
});
context.events.on("pdf:navigate:next", () => {
if (navState) {
nextPDFPage(navState, context);
}
});
context.events.on("pdf:navigate:previous", () => {
if (navState) {
previousPDFPage(navState, context);
}
});
context.events.on("pdf:zoom:set", (detail: { scale: number }) => {
if (navState) {
setPDFZoom(navState, detail.scale, context);
}
});
context.events.on("pdf:zoom:in", () => {
if (navState) {
zoomPDFIn(navState, context);
}
});
context.events.on("pdf:zoom:out", () => {
if (navState) {
zoomPDFOut(navState, context);
}
});
context.events.on(
"pdf:fit:set",
(detail: { mode: "fit-width" | "fit-page" | "fit-height" | "none" }) => {
if (navState) {
setPDFFitMode(navState, detail.mode, context);
}
},
);
context.events.on("reader:unload", () => {
navState = null;
});
}
type PageFitMode = "fit-width" | "fit-page" | "fit-height" | "none";
interface PDFNavigationState {
currentPage: number;
totalPages: number;
currentScale: number;
fitMode: PageFitMode;
scrollContainer: HTMLElement | null;
}
function goToPDFPage(
state: PDFNavigationState,
pageNumber: number,
context: ReaderContext,
): void {
if (pageNumber < 1 || pageNumber > state.totalPages) return;
state.currentPage = pageNumber;
scrollToPDFPage(state, pageNumber);
context.events.emit("pdf:page-changed", { page: pageNumber });
}
function nextPDFPage(state: PDFNavigationState, context: ReaderContext): void {
if (state.currentPage < state.totalPages) {
goToPDFPage(state, state.currentPage + 1, context);
}
}
function previousPDFPage(
state: PDFNavigationState,
context: ReaderContext,
): void {
if (state.currentPage > 1) {
goToPDFPage(state, state.currentPage - 1, context);
}
}
function scrollToPDFPage(state: PDFNavigationState, pageNumber: number): void {
if (!state.scrollContainer) return;
const pageElement = state.scrollContainer.querySelector(
`[data-page-number="${pageNumber}"]`,
);
if (pageElement) {
pageElement.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
function setPDFZoom(
state: PDFNavigationState,
scale: number,
context: ReaderContext,
): void {
state.currentScale = scale;
state.fitMode = "none";
updatePDFZoom(state);
context.events.emit("pdf:zoom-changed", { scale });
}
function setPDFFitMode(
state: PDFNavigationState,
mode: PageFitMode,
context: ReaderContext,
): void {
state.fitMode = mode;
updatePDFZoom(state);
context.events.emit("pdf:fit-changed", { mode });
}
function zoomPDFIn(state: PDFNavigationState, context: ReaderContext): void {
setPDFZoom(state, state.currentScale * 1.2, context);
}
function zoomPDFOut(state: PDFNavigationState, context: ReaderContext): void {
setPDFZoom(state, state.currentScale / 1.2, context);
}
function updatePDFZoom(state: PDFNavigationState): void {
const event = new CustomEvent("pdf-update-zoom", {
detail: {
scale: state.currentScale,
fitMode: state.fitMode,
},
});
window.dispatchEvent(event);
}
function setupPDFKeyboardNav(
context: ReaderContext,
state: PDFNavigationState,
): void {
document.addEventListener("keydown", (e) => {
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
nextPDFPage(state, context);
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
previousPDFPage(state, context);
} else if (e.key === "+" || e.key === "=") {
zoomPDFIn(state, context);
} else if (e.key === "-" || e.key === "_") {
zoomPDFOut(state, context);
}
});
}
function setupPDFScrollTracking(
context: ReaderContext,
state: PDFNavigationState,
): void {
if (!state.scrollContainer) return;
state.scrollContainer.addEventListener("scroll", () => {
const currentPage = getCurrentPDFPage(state);
if (currentPage !== state.currentPage) {
state.currentPage = currentPage;
context.events.emit("pdf:page-changed", { page: currentPage });
}
});
}
function getCurrentPDFPage(state: PDFNavigationState): number {
if (!state.scrollContainer) return state.currentPage;
const containerRect = state.scrollContainer.getBoundingClientRect();
const viewportMiddle = containerRect.top + containerRect.height / 2;
for (let i = 1; i <= state.totalPages; i++) {
const pageElement = state.scrollContainer.querySelector(
`[data-page-number="${i}"]`,
);
if (pageElement) {
const rect = pageElement.getBoundingClientRect();
if (rect.top <= viewportMiddle && rect.bottom >= viewportMiddle) {
return i;
}
}
}
return state.currentPage;
}
-186
View File
@@ -1,186 +0,0 @@
// PDF outline/TOC navigation
// Procedural implementation (no OOP)
import { PDFDocumentProxy } from "pdfjs-dist";
interface PDFOutlineNode {
id: string;
title: string;
destination: number | null;
pageNumber?: number;
children: PDFOutlineNode[];
expanded: boolean;
}
interface PDFOutlineState {
doc: PDFDocumentProxy | null;
outline: PDFOutlineNode[];
flatMap: Map<string, number>;
}
async function initializePDFOutline(
doc: PDFDocumentProxy,
): Promise<PDFOutlineState> {
const state: PDFOutlineState = {
doc,
outline: [],
flatMap: new Map(),
};
return await loadPDFOutline(state);
}
async function loadPDFOutline(
state: PDFOutlineState,
): Promise<PDFOutlineState> {
if (!state.doc) return state;
const pdfOutline = await state.doc.getOutline();
if (!pdfOutline || pdfOutline.length === 0) {
return { ...state, outline: [] };
}
const outline = await parseOutlineNodes(state, pdfOutline);
return { ...state, outline };
}
async function parseOutlineNodes(
state: PDFOutlineState,
nodes: OutlineTreeNode[],
): Promise<PDFOutlineNode[]> {
const result: PDFOutlineNode[] = [];
for (const node of nodes) {
const outlineNode: PDFOutlineNode = {
id: generateOutlineId(),
title: node.title,
destination: null,
children: [],
expanded: false,
};
if (node.dest) {
const pageNumber = await resolvePDFDestination(state, node.dest);
outlineNode.destination = pageNumber;
outlineNode.pageNumber = pageNumber;
state.flatMap.set(node.title, pageNumber);
}
if (node.items && node.items.length > 0) {
outlineNode.children = await parseOutlineNodes(state, node.items);
}
result.push(outlineNode);
}
return result;
}
async function resolvePDFDestination(
state: PDFOutlineState,
dest: string | any[],
): Promise<number> {
if (!state.doc) return 1;
try {
let explicitDest: any[];
if (typeof dest === "string") {
const destObj = await state.doc.getDestination(dest);
if (!destObj) return 1;
explicitDest = destObj;
} else {
explicitDest = dest;
}
const ref = explicitDest[0];
if (typeof ref === "object" && ref !== null) {
const pageIndex = await state.doc.getPageIndex(ref);
return pageIndex + 1;
} else if (typeof ref === "number") {
return ref + 1;
}
return 1;
} catch (error) {
console.error("Failed to resolve destination:", dest, error);
return 1;
}
}
function generateOutlineId(): string {
return `outline-${Math.random().toString(36).substr(2, 9)}`;
}
function getOutline(state: PDFOutlineState): PDFOutlineNode[] {
return state.outline;
}
function getOutlineFlatMap(state: PDFOutlineState): Map<string, number> {
return state.flatMap;
}
function getCurrentChapter(
state: PDFOutlineState,
pageNumber: number,
): PDFOutlineNode | null {
return findChapterForPage(state.outline, pageNumber);
}
function findChapterForPage(
nodes: PDFOutlineNode[],
pageNumber: number,
): PDFOutlineNode | null {
for (const node of nodes) {
if (node.pageNumber && node.pageNumber <= pageNumber) {
if (node.children.length > 0) {
const childMatch = findChapterForPage(node.children, pageNumber);
if (childMatch) return childMatch;
}
return node;
}
if (node.children.length > 0) {
const childMatch = findChapterForPage(node.children, pageNumber);
if (childMatch) return childMatch;
}
}
return null;
}
function toggleOutlineNode(
state: PDFOutlineState,
nodeId: string,
): PDFOutlineState {
const updateNode = (nodes: PDFOutlineNode[]): PDFOutlineNode[] => {
return nodes.map((node) => {
if (node.id === nodeId) {
return { ...node, expanded: !node.expanded };
}
if (node.children.length > 0) {
return { ...node, children: updateNode(node.children) };
}
return node;
});
};
return { ...state, outline: updateNode(state.outline) };
}
function findOutlineNode(
nodes: PDFOutlineNode[],
id: string,
): PDFOutlineNode | null {
for (const node of nodes) {
if (node.id === id) return node;
if (node.children.length > 0) {
const found = findOutlineNode(node.children, id);
if (found) return found;
}
}
return null;
}
@@ -1,109 +0,0 @@
// Handle PDFs with variable page sizes
// Procedural implementation (no OOP)
interface PageInfo {
pageNumber: number;
width: number;
height: number;
rotation: number;
}
interface PDFPageSizesState {
pageSizes: Map<number, PageInfo>;
defaultSize: { width: number; height: number };
}
function createPDFPageSizes(): PDFPageSizesState {
return {
pageSizes: new Map(),
defaultSize: { width: 595, height: 842 },
};
}
async function loadPDFPageSizes(
state: PDFPageSizesState,
doc: any,
): Promise<PDFPageSizesState> {
const pageSizes = new Map<number, PageInfo>();
for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
const page = await doc.getPage(pageNum);
const viewport = page.getViewport({ scale: 1 });
const pageInfo: PageInfo = {
pageNumber: pageNum,
width: viewport.width,
height: viewport.height,
rotation: viewport.rotation,
};
pageSizes.set(pageNum, pageInfo);
}
return { ...state, pageSizes };
}
function getPDFPageSize(
state: PDFPageSizesState,
pageNumber: number,
): PageInfo | null {
return state.pageSizes.get(pageNumber) || null;
}
function isPDFPageLandscape(
state: PDFPageSizesState,
pageNumber: number,
): boolean {
const size = getPDFPageSize(state, pageNumber);
if (!size) return false;
const effectiveWidth =
size.rotation === 90 || size.rotation === 270 ? size.height : size.width;
const effectiveHeight =
size.rotation === 90 || size.rotation === 270 ? size.width : size.height;
return effectiveWidth > effectiveHeight;
}
function getPDFCommonSize(state: PDFPageSizesState): {
width: number;
height: number;
} {
if (state.pageSizes.size === 0) {
return state.defaultSize;
}
const sizeGroups: Map<
string,
{ width: number; height: number; count: number }
> = new Map();
state.pageSizes.forEach((size) => {
const key = getPageSizeKey(size.width, size.height);
const existing = sizeGroups.get(key);
if (existing) {
existing.count++;
} else {
sizeGroups.set(key, { width: size.width, height: size.height, count: 1 });
}
});
let mostCommon = state.defaultSize;
let maxCount = 0;
sizeGroups.forEach((size) => {
if (size.count > maxCount) {
maxCount = size.count;
mostCommon = { width: size.width, height: size.height };
}
});
return mostCommon;
}
function getPageSizeKey(width: number, height: number): string {
const w = Math.round(width / 10) * 10;
const h = Math.round(height / 10) * 10;
return `${w}x${h}`;
}
@@ -1,82 +0,0 @@
// Handle rotated/landscape pages in PDFs
// Procedural implementation (no OOP)
interface PDFRotationState {
rotations: Map<number, number>;
}
function createPDFRotation(): PDFRotationState {
return {
rotations: new Map(),
};
}
async function loadPDFPageRotations(
state: PDFRotationState,
doc: any,
): Promise<PDFRotationState> {
const rotations = new Map<number, number>();
for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
const page = await doc.getPage(pageNum);
const viewport = page.getViewport({ scale: 1 });
const rotation = viewport.rotation;
if (rotation !== 0) {
rotations.set(pageNum, rotation);
}
}
return { ...state, rotations };
}
function getPDFPageRotation(
state: PDFRotationState,
pageNumber: number,
): number {
return state.rotations.get(pageNumber) || 0;
}
function hasPDFPageRotation(
state: PDFRotationState,
pageNumber: number,
): boolean {
return state.rotations.has(pageNumber);
}
function applyPDFRotation(
state: PDFRotationState,
canvas: HTMLCanvasElement,
pageNumber: number,
): void {
const rotation = getPDFPageRotation(state, pageNumber);
if (rotation === 0) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.save();
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate((rotation * Math.PI) / 180);
ctx.translate(-canvas.width / 2, -canvas.height / 2);
ctx.restore();
}
function getPDFAdjustedViewport(
state: PDFRotationState,
pageNumber: number,
viewport: any,
): any {
const rotation = getPDFPageRotation(state, pageNumber);
if (rotation === 0 || rotation === 180) {
return viewport;
}
return {
...viewport,
width: viewport.height,
height: viewport.width,
};
}
-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,
};
}
@@ -1,165 +0,0 @@
// PDF text selection - Uses backend API for highlight creation
// Feature Registration Pattern implementation
import { ReaderContext } from "../../core/reader-context";
export function init(context: ReaderContext): void {
let currentMediaItemId: string | null = null;
context.events.on("reader:loaded", (detail: { mediaItemId: string }) => {
currentMediaItemId = detail.mediaItemId;
});
context.events.on("pdf:selection:get", () => {
const selection = getPDFTextSelection();
context.events.emit("pdf:selection-current", selection);
});
context.events.on(
"pdf:highlight:create",
async (detail: { selection: PDFTextSelection; color: string }) => {
if (currentMediaItemId) {
try {
const highlight = await createPDFHighlight(
currentMediaItemId,
detail.selection,
detail.color,
);
context.events.emit("pdf:highlight-created", highlight);
} catch (error) {
console.error("Failed to create highlight:", error);
}
}
},
);
context.events.on(
"pdf:highlights:load",
async (detail: { container: HTMLElement }) => {
if (currentMediaItemId) {
await loadAndRenderPDFHighlights(currentMediaItemId, detail.container);
}
},
);
context.events.on("reader:unload", () => {
currentMediaItemId = null;
});
}
interface PDFTextSelection {
pageNumber: number;
text: string;
rects: DOMRect[];
}
export function getPDFTextSelection(): PDFTextSelection | null {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
const text = range.toString();
if (!text) return null;
const pageElement = range.commonAncestorContainer.parentElement?.closest?.(
"[data-page-number]",
) as HTMLElement;
const pageNumber = pageElement?.dataset.pageNumber
? parseInt(pageElement.dataset.pageNumber)
: getCurrentPDFPage();
const rects: DOMRect[] = [];
for (const rect of Array.from(range.getClientRects())) {
rects.push(rect);
}
return {
pageNumber,
text,
rects,
};
}
export async function createPDFHighlight(
mediaItemId: string,
selection: PDFTextSelection,
color: string,
): Promise<any> {
const selectionData = {
selection_text: selection.text,
page_number: selection.pageNumber,
rects: selection.rects.map((rect) => ({
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
})),
color,
};
const response = await fetch(`/api/media-items/${mediaItemId}/highlights`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(selectionData),
});
if (!response.ok) {
throw new Error(`Failed to create highlight: ${response.statusText}`);
}
return await response.json();
}
export async function loadAndRenderPDFHighlights(
mediaItemId: string,
container: HTMLElement,
): Promise<void> {
const response = await fetch(`/api/media-items/${mediaItemId}/highlights`);
if (!response.ok) return;
const highlights: any[] = await response.json();
for (const highlight of highlights) {
renderPDFHighlight(container, highlight);
}
}
function renderPDFHighlight(container: HTMLElement, highlight: any): void {
const overlay = document.createElement("div");
overlay.className = "pdf-highlight-annotation";
overlay.dataset.highlightId = highlight.id;
overlay.style.backgroundColor = parseColor(highlight.color || "#ffff00");
for (const rect of highlight.rects || []) {
const rectDiv = document.createElement("div");
rectDiv.className = "pdf-highlight-rect";
rectDiv.style.left = `${rect.x}px`;
rectDiv.style.top = `${rect.y}px`;
rectDiv.style.width = `${rect.width}px`;
rectDiv.style.height = `${rect.height}px`;
overlay.appendChild(rectDiv);
}
container.appendChild(overlay);
}
function parseColor(color: string): string {
if (color.startsWith("#")) {
const hex = color.slice(1);
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, 0.4)`;
}
return color;
}
function getCurrentPDFPage(): number {
const pageElement = document.querySelector("[data-page-number]");
return pageElement
? parseInt(pageElement.getAttribute("data-page-number") || "1")
: 1;
}
-138
View File
@@ -1,138 +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";
}
// ============================================================
// 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 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();
await loadPDFDocument(pdfBlob);
return {
type: "pdf",
doc: pdfState.doc,
currentPage: 1,
};
}
export async function getPDFPageText(pageNumber: number): Promise<any> {
const page = await getPDFPage(pageNumber);
return await page.getTextContent();
}
export function getPDFMetadata(): PDFMetadata | null {
return pdfState.metadata;
}
export function getPDFPageCount(): number {
return pdfState.doc?.numPages || 0;
}
export function unloadPDFDocument(): void {
pdfState.pages.clear();
pdfState.doc = null;
pdfState.metadata = null;
}
export function unloadPDFPage(pageNumber: number): void {
pdfState.pages.delete(pageNumber);
}
@@ -1,155 +0,0 @@
// Text layer rendering for PDF text selection and highlighting
// Procedural style: Functions, not classes
// ============================================================
// Render Functions
// ============================================================
export function renderTextLayer(
container: HTMLElement,
viewport: any,
textContent: any,
config: TextLayerConfig,
): void {
// Clear container
container.innerHTML = "";
// Apply styles
applyTextLayerStyles(container, config);
// Render text items
const { items } = textContent;
items.forEach((item: any, index: number) => {
if (typeof item === "string") return;
const textDiv = createTextDiv(item, viewport, index);
container.appendChild(textDiv);
});
}
function createTextDiv(item: any, viewport: any, index: number): HTMLElement {
const div = document.createElement("div");
div.className = "pdf-text-layer-text";
div.textContent = item.str;
div.dataset.index = index.toString();
// Position the text div
const tx = pdfjsLib.Util.transform(viewport.transform, item.transform);
const fontSize = Math.sqrt(tx[0] * tx[0] + tx[1] * tx[1]);
div.style.left = `${tx[4]}px`;
div.style.top = `${tx[5] - fontSize}px`;
div.style.fontSize = `${fontSize}px`;
div.style.fontFamily = item.fontName || "sans-serif";
// Handle text direction
if (item.dir === "ttb") {
div.style.writingMode = "vertical-rl";
}
return div;
}
interface TextLayerConfig {
theme: "light" | "sepia" | "dark" | "night" | "high-contrast";
}
function applyTextLayerStyles(
container: HTMLElement,
config: TextLayerConfig,
): void {
const style = document.createElement("style");
style.textContent = getTextLayerCSS(config.theme);
container.appendChild(style);
}
function getTextLayerCSS(theme: string): string {
const colors = getThemeColors(theme);
return `
.pdf-text-layer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
opacity: 1;
line-height: 1;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
}
.pdf-text-layer-text {
position: absolute;
white-space: pre;
cursor: text;
transform-origin: 0% 0%;
color: transparent;
pointer-events: auto;
}
.pdf-text-layer-text::selection {
background: ${colors.highlight};
color: transparent;
}
.pdf-text-layer-text::-moz-selection {
background: ${colors.highlight};
color: transparent;
}
.pdf-highlight-overlay {
position: absolute;
background-color: ${colors.highlight};
mix-blend-mode: multiply;
pointer-events: none;
}
`;
}
function getThemeColors(theme: string): { highlight: string } {
const themes: Record<string, { highlight: string }> = {
light: { highlight: "rgba(255, 255, 0, 0.3)" },
sepia: { highlight: "rgba(255, 200, 0, 0.4)" },
dark: { highlight: "rgba(255, 255, 0, 0.3)" },
night: { highlight: "rgba(100, 150, 255, 0.3)" },
"high-contrast": { highlight: "rgba(255, 255, 0, 0.5)" },
};
return themes[theme] || themes["dark"];
}
// ============================================================
// Selection Functions
// ============================================================
export function getPDFTextSelection(): { text: string; range: Range } | null {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
const text = range.toString();
if (!text) return null;
return { text, range };
}
export function getPDFSelectionRects(): DOMRect[] {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return [];
const rects: DOMRect[] = [];
const range = selection.getRangeAt(0);
for (const rect of range.getClientRects()) {
rects.push(rect);
}
return rects;
}
@@ -1,101 +0,0 @@
// Render a page's content to the DOM
export function renderPage(container: HTMLElement, content: string): void {
container.innerHTML = "";
const wrapper = document.createElement("div");
wrapper.className = "reflowable-page";
wrapper.style.height = "calc(100vh - 120px)";
wrapper.style.overflow = "hidden";
wrapper.style.position = "relative";
wrapper.style.display = "flex";
wrapper.style.flexDirection = "column";
// Parse the HTML content (which is already sliced by getPageContent)
const tempDiv = document.createElement("div");
tempDiv.innerHTML = content;
const pageContent = tempDiv.querySelector(".page-content-wrapper");
if (!pageContent) {
// Fallback if wrapper not found
const contentDiv = document.createElement("div");
contentDiv.className = "page-content";
contentDiv.innerHTML = content;
contentDiv.style.height = "100%";
contentDiv.style.overflow = "hidden";
contentDiv.style.flex = "1";
contentDiv.style.overflowY = "auto";
wrapper.appendChild(contentDiv);
} else {
// Transfer the sliced content to our wrapper
const contentDiv = document.createElement("div");
contentDiv.className = "page-content";
contentDiv.style.height = "100%";
contentDiv.style.overflow = "hidden";
contentDiv.style.flex = "1";
contentDiv.style.padding = "20px";
while (pageContent.firstChild) {
contentDiv.appendChild(pageContent.firstChild);
}
wrapper.appendChild(contentDiv);
}
container.appendChild(wrapper);
}
// Update container styles for paginated mode
export function applyPaginatedStyles(): void {
const existing = document.getElementById("reflowable-styles");
existing?.remove();
const style = document.createElement("style");
style.id = "reflowable-styles";
style.textContent = `
.reflowable-page {
height: calc(100vh - 120px) !important;
overflow: hidden !important;
position: relative !important;
}
.page-content {
height: 100% !important;
overflow: hidden !important;
-webkit-column-width: auto !important;
column-width: auto !important;
-webkit-column-count: 1 !important;
column-count: 1 !important;
-webkit-column-fill: auto !important;
column-fill: auto !important;
}
.page-content img {
max-width: 100% !important;
height: auto !important;
display: block !important;
}
.page-content p {
margin: 0.5em 0 !important;
text-align: justify !important;
}
.page-content h1,
.page-content h2,
.page-content h3,
.page-content h4,
.page-content h5,
.page-content h6 {
margin: 1em 0 0.5em 0 !important;
page-break-after: avoid !important;
break-after: avoid !important;
}
`;
document.head.appendChild(style);
}
// Clear all styles
export function clearPaginatedStyles(): void {
const existing = document.getElementById("reflowable-styles");
existing?.remove();
}
@@ -1,71 +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);
}
});
}
@@ -1,87 +0,0 @@
// Dictionary lookup popup for ebooks
// Feature Registration Pattern implementation
import { 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();
}
@@ -1,97 +0,0 @@
// Font loading with performance optimization
// Feature Registration Pattern implementation
import { ReaderContext } from "../../../core/reader-context";
export function init(context: ReaderContext): void {
const userPreferredFont = localStorage.getItem("reader-font") || "literata";
context.events.on("reader:loaded", async () => {
await preloadFonts(userPreferredFont);
});
context.events.on("font:change", async (detail: { fontId: string }) => {
const stack = getFontStack(detail.fontId);
applyFontStack(stack);
await preloadFonts(detail.fontId);
});
context.events.on("font:get-stack", (detail: { fontId: string }) => {
const stack = getFontStack(detail.fontId);
context.events.emit("font:stack-ready", { stack });
});
}
const READING_FONTS = [
{
id: "literata",
name: "Literata",
stack: "Literata, serif",
description: "Designed for Google Play Books",
},
{
id: "crimson",
name: "Crimson Text",
stack: "Crimson Text, serif",
description: "Optimized for screen reading",
},
{
id: "source-serif",
name: "Source Serif 4",
stack: "Source Serif 4, serif",
description: "Professional Adobe quality",
},
{
id: "eb-garamond",
name: "EB Garamond",
stack: "EB Garamond, serif",
description: "Classic elegance",
},
{
id: "libertinus",
name: "Libertinus Serif",
stack: "Libertinus Serif, serif",
description: "Excellent for technical content",
},
{
id: "noto-serif",
name: "Noto Serif",
stack: "Noto Serif, serif",
description: "Maximum language support",
},
{
id: "charis-sil",
name: "Charis SIL",
stack: "Charis SIL, serif",
description: "Multilingual specialist",
},
{
id: "ibm-plex",
name: "IBM Plex Serif",
stack: "IBM Plex Serif, serif",
description: "Modern & versatile",
},
];
async function preloadFonts(userPreferredFont: string): Promise<void> {
const fontsToPreload = new Set(["literata", userPreferredFont]);
for (const fontId of fontsToPreload) {
const font = READING_FONTS.find((f) => f.id === fontId);
if (font) {
document.fonts.load(`16px "${font.stack}"`);
}
}
}
function getFontStack(fontId: string): string {
const font = READING_FONTS.find((f) => f.id === fontId);
return font?.stack || "Literata, serif";
}
function applyFontStack(stack: string): void {
document.documentElement.style.setProperty("--reader-font-family", stack);
}
export { READING_FONTS, preloadFonts, getFontStack };
@@ -1,143 +0,0 @@
// Search within ebook content
// Feature Registration Pattern implementation
import type { ReaderContext } from "../../../core/reader-context";
export function init(context: ReaderContext): void {
let ebookData: any = null;
context.events.on("reader:loaded", (detail: { ebookData: any }) => {
ebookData = detail.ebookData;
});
context.events.on("search:execute", async (detail: { query: string }) => {
if (ebookData) {
const results = await searchEbook(ebookData, detail.query);
context.events.emit("search:results", { results });
}
});
context.events.on("reader:unload", () => {
ebookData = null;
});
}
interface SearchResult {
cfi: string;
snippet: string;
chapterTitle: string;
}
export async function searchEbook(
ebookData: any,
query: string,
): Promise<SearchResult[]> {
const results: SearchResult[] = [];
const lowerQuery = query.toLowerCase();
if (!ebookData.spine) return results;
for (const spineItem of ebookData.spine) {
const doc = await getSpineItemDocument(ebookData, spineItem);
if (!doc) continue;
const chapterTitle = getChapterTitle(spineItem);
const textNodes = findTextNodes(doc.body);
for (const node of textNodes) {
const text = node.textContent || "";
const lowerText = text.toLowerCase();
let foundAt = 0;
while ((foundAt = lowerText.indexOf(lowerQuery, foundAt)) !== -1) {
const cfi = generateCFIForNode(node, foundAt);
const snippet = extractSnippet(text, foundAt, query.length);
results.push({
cfi,
snippet,
chapterTitle,
});
foundAt += lowerQuery.length;
}
}
}
return results;
}
async function getSpineItemDocument(
ebookData: any,
spineItem: any,
): Promise<Document | null> {
try {
const resources = ebookData.resources;
if (!resources) return null;
const content = await resources.get(spineItem.href)?.text();
if (!content) return null;
const parser = new DOMParser();
return parser.parseFromString(content, "text/html");
} catch (error) {
console.error("Failed to load spine item:", spineItem.href, error);
return null;
}
}
function getChapterTitle(spineItem: any): string {
return spineItem.id || `Section ${spineItem.index || ""}`;
}
function findTextNodes(root: Node): Text[] {
const textNodes: Text[] = [];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
const parent = node.parentElement;
if (parent && ["SCRIPT", "STYLE", "NOSCRIPT"].includes(parent.tagName)) {
return NodeFilter.FILTER_REJECT;
}
if (!node.textContent?.trim()) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
},
});
let node: Node | null;
while ((node = walker.nextNode())) {
textNodes.push(node as Text);
}
return textNodes;
}
function generateCFIForNode(node: Node, offset: number): string {
const path: number[] = [];
let current: Node | null = node;
while (current && current.parentNode) {
const siblings = Array.from(current.parentNode.childNodes);
const index = siblings.indexOf(current as ChildNode);
path.unshift(index);
current = current.parentNode;
}
return `/6/4${path.map((i) => `/${i + 2}`).join("")}:${offset}`;
}
function extractSnippet(text: string, offset: number, length: number): string {
const start = Math.max(0, offset - 40);
const end = Math.min(text.length, offset + length + 40);
let snippet = text.substring(start, end);
if (start > 0) snippet = "..." + snippet;
if (end < text.length) snippet = snippet + "...";
return snippet;
}
@@ -1,175 +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,107 +0,0 @@
// Import types
import type { ReadingPosition } from "./types";
import {
getPageContent,
findPageByCFI,
createPositionFromPage,
} from "./page-calculator";
import { UniversalReader } from "../../reader-shell";
// Navigate to specific page
export function goToPage(
book: UniversalReader,
targetPage: number,
): {
success: boolean;
position: ReadingPosition;
content: string;
} {
if (!book.pagination) {
return { success: false, position: createDefaultPosition(), content: "" };
}
const pageIndex = Math.max(
0,
Math.min(targetPage - 1, book.pagination.totalPages - 1),
);
const content = getPageContent(book.pagination, pageIndex);
const position = createPositionFromPage(book, pageIndex + 1);
return { success: true, position, content };
}
// Navigate to next page
export function nextPage(book: UniversalReader): {
success: boolean;
position: ReadingPosition;
content: string;
} {
const nextPageNum = book.position.currentPage + 1;
return goToPage(book, nextPageNum);
}
// Navigate to previous page
export function previousPage(book: UniversalReader): {
success: boolean;
position: ReadingPosition;
content: string;
} {
const prevPageNum = book.position.currentPage - 1;
return goToPage(book, prevPageNum);
}
// Jump to specific CFI
export function goToCFI(
book: UniversalReader,
cfi: string,
): {
success: boolean;
position: ReadingPosition;
content: string;
} {
if (!book.pagination) {
return { success: false, position: createDefaultPosition(), content: "" };
}
const pageNum = findPageByCFI(book.pagination, cfi);
return goToPage(book, pageNum);
}
// Create default position
function createDefaultPosition(): ReadingPosition {
return {
currentPage: 1,
spineIndex: 0,
localPageIndex: 0,
cfi: "",
progress: 0,
};
}
// Check if navigation is possible
export function canGoNext(book: UniversalReader): boolean {
return book.position.currentPage < (book.pagination?.totalPages || 1);
}
// Check if previous navigation is possible
export function canGoPrevious(book: UniversalReader): boolean {
return book.position.currentPage > 1;
}
// Get progress percentage
export function getProgressPercentage(book: UniversalReader): number {
return Math.round(book.position.progress * 100);
}
// Update book position (after resize/recalculation)
export function updatePosition(
book: UniversalReader,
newCFI?: string,
): ReadingPosition {
if (newCFI && book.pagination) {
const pageNum = findPageByCFI(book.pagination, newCFI);
return createPositionFromPage(book, pageNum);
}
return book.position;
}
@@ -1,564 +0,0 @@
// Import types
import { UniversalReader } from "../../reader-shell";
import type {
SpineItem,
SpineInfo,
PageBoundary,
PaginationData,
PaginationSettings,
ReadingPosition,
} from "./types";
interface TextNodeInfo {
node: Text;
startChar: number; // Character position in HTML
endChar: number; // Character position in HTML
textStart: number; // Word position in plain text
textEnd: number; // Word position in plain text
}
function buildTextNodeMapping(html: string): TextNodeInfo[] {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const textNodes: TextNodeInfo[] = [];
let htmlCharPos = 0;
let textWordPos = 0;
function traverse(node: Node) {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent || "";
const words = countWords(text);
textNodes.push({
node: node as Text,
startChar: htmlCharPos,
endChar: htmlCharPos + text.length,
textStart: textWordPos,
textEnd: textWordPos + words,
});
textWordPos += words;
htmlCharPos += text.length;
} else {
// For element nodes, just count the opening tag length
if (node.nodeType === Node.ELEMENT_NODE) {
const outerHTML = (node as Element).outerHTML;
const tagEnd = outerHTML.indexOf(">") + 1;
htmlCharPos += tagEnd;
// Recurse into children
node.childNodes.forEach(traverse);
// Count closing tag
const tagName = (node as Element).tagName;
htmlCharPos += `</${tagName}>`.length;
}
}
}
doc.body.childNodes.forEach(traverse);
return textNodes;
}
function mapWordToHtmlChar(mapping: TextNodeInfo[], wordPos: number): number {
for (const info of mapping) {
if (wordPos >= info.textStart && wordPos <= info.textEnd) {
// Word is in this text node
const ratio =
(wordPos - info.textStart) / (info.textEnd - info.textStart);
return Math.round(
info.startChar + ratio * (info.endChar - info.startChar),
);
}
}
return 0; // Fallback
}
// Constants for word count estimation (from Kavita)
const WORDS_PER_PAGE_BASE = 250; // At 16px font, 1.6 line height
// Calculate words per page based on settings
function calculateWordsPerPage(settings: PaginationSettings): number {
const fontSizeFactor = 16 / settings.fontSize;
const lineHeightFactor = 1.6 / settings.lineHeight;
const areaFactor =
(settings.viewportWidth * settings.viewportHeight) / (800 * 600);
return Math.round(
WORDS_PER_PAGE_BASE * fontSizeFactor * lineHeightFactor * areaFactor,
);
}
// Extract plain text from HTML
function extractTextFromHTML(html: string): string {
// Remove script and style tags
const withoutScripts = html.replace(
/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
"",
);
const withoutStyles = withoutScripts.replace(
/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi,
"",
);
// Extract text content (simple version, no DOM)
return withoutStyles
.replace(/<[^>]*>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
// Count words in text
function countWords(text: string): number {
return text
.trim()
.split(/\s+/)
.filter((w) => w.length > 0).length;
}
// Split text into word ranges for pages
function splitIntoWordRanges(
wordCount: number,
wordsPerPage: number,
): Array<{ start: number; end: number }> {
const ranges: Array<{ start: number; end: number }> = [];
let start = 0;
while (start < wordCount) {
const end = Math.min(start + wordsPerPage, wordCount);
ranges.push({ start, end });
start = end;
}
return ranges;
}
// Escape special characters in CFI
function escapeCFIString(str: string): string {
return str
.replace(/\[/g, "\\[")
.replace(/\]/g, "\\]")
.replace(/\(/g, "\\(")
.replace(/\)/g, "\\)")
.replace(/,/g, "\\,")
.replace(/;/g, "\\;")
.replace(/=/g, "\\=");
}
// Generate EPUB CFI for a position in spine
// Follows EPUB CFI spec: https://www.w3.org/TR/epub-cfi/
// Format: epubcfi(/6/spine_index!/path/element/offset)
function generateCFI(
spineIndex: number,
charOffset: number,
totalChars: number,
spineItemId: string,
): string {
const escapedId = spineItemId ? `[${escapeCFIString(spineItemId)}]` : "";
const offset = Math.min(charOffset, totalChars);
const spinePath = `/6/${spineIndex + 2}${escapedId}`;
return `epubcfi(${spinePath}!/4/2/1:${offset})`;
}
// Parse EPUB CFI to extract position
function parseCFI(
cfi: string,
): { spineIndex: number; charOffset: number } | null {
if (!cfi.startsWith("epubcfi(")) {
return null;
}
// Remove epubcfi( wrapper
const inner = cfi.slice(8, -1);
if (!inner) return null;
// Split on ! to separate spine path from content path
const parts = inner.split("!");
if (parts.length < 2) return null;
// Extract spine index from /6/4 or /6/4[id] format
const spineMatch = parts[0].match(/\/6\/(\d+)/);
if (!spineMatch) return null;
const spineIndex = parseInt(spineMatch[1]) - 2; // Adjust for offset
if (spineIndex < 0) return null;
// Extract character offset from :123 format
const offsetMatch = parts[1].match(/:(\d+)$/);
if (!offsetMatch) return null;
const charOffset = parseInt(offsetMatch[1]);
return { spineIndex, charOffset };
}
// Calculate pagination for entire book
export async function calculatePagination(
spineItems: SpineItem[],
contentMap: Map<string, Blob>,
settings: PaginationSettings,
): Promise<PaginationData> {
const wordsPerPage = calculateWordsPerPage(settings);
const spines: SpineInfo[] = [];
const pageMap = new Map<number, PageBoundary>();
let globalPageIndex = 0;
// Process each spine item
for (let i = 0; i < spineItems.length; i++) {
const spineItem = spineItems[i];
// Skip non-HTML items (cover pages, etc)
if (spineItem.type !== "html") {
spines.push({
spineIndex: i,
spineItemId: spineItem.id,
content: "",
charCount: 0,
wordCount: 0,
cfiStart: "",
pages: [],
});
continue;
}
// Get content
const contentBlob = contentMap.get(spineItem.content);
if (!contentBlob) {
console.warn(`Content not found for spine ${spineItem.id}`);
continue;
}
const contentHTML = await contentBlob.text();
const plainText = extractTextFromHTML(contentHTML);
const wordCount = countWords(plainText);
const charCount = plainText.length;
// Skip empty spines
if (wordCount === 0) {
spines.push({
spineIndex: i,
spineItemId: spineItem.id,
content: contentHTML,
charCount,
wordCount,
cfiStart: generateCFI(i, 0, charCount, spineItem.id),
pages: [],
});
continue;
}
// Split into pages based on TEXT content, not HTML
const wordRanges = splitIntoWordRanges(wordCount, wordsPerPage);
const pages: PageBoundary[] = [];
// Build a map of word positions to HTML character positions
const textNodeInfo = buildTextNodeMapping(contentHTML);
for (let j = 0; j < wordRanges.length; j++) {
const range = wordRanges[j];
// Map word positions to ACTUAL HTML character positions
const htmlCharStart = mapWordToHtmlChar(textNodeInfo, range.start);
const htmlCharEnd = mapWordToHtmlChar(textNodeInfo, range.end);
const page: PageBoundary = {
pageIndex: globalPageIndex,
localPageIndex: j,
charStart: htmlCharStart,
charEnd: htmlCharEnd,
wordStart: range.start,
wordEnd: range.end,
cfi: generateCFI(i, htmlCharStart, charCount, spineItem.id),
};
pages.push(page);
pageMap.set(globalPageIndex, page);
globalPageIndex++;
}
spines.push({
spineIndex: i,
spineItemId: spineItem.id,
content: contentHTML,
charCount,
wordCount,
cfiStart: generateCFI(i, 0, charCount, spineItem.id),
pages,
});
}
// Build map
const spineMap = new Map<number, SpineInfo>();
for (const spine of spines) {
spineMap.set(spine.spineIndex, spine);
}
return {
totalPages: globalPageIndex,
spines,
spineMap,
pageMap,
calculatedAt: Date.now(),
settings: { ...settings, wordsPerPage },
};
}
// Find which page contains a CFI
export function findPageByCFI(
pagination: PaginationData,
targetCFI: string,
): number {
const parsed = parseCFI(targetCFI);
if (!parsed) return 1;
const { spineIndex, charOffset } = parsed;
const spine = pagination.spineMap.get(spineIndex);
if (!spine || spine.pages.length === 0) return 1;
// Find page containing this character offset
for (const page of spine.pages) {
if (charOffset >= page.charStart && charOffset < page.charEnd) {
return page.pageIndex + 1; // 1-indexed
}
}
return 1;
}
// Extract HTML slice between character offsets
function extractHTMLSlice(
html: string,
charStart: number,
charEnd: number,
): string {
if (charStart === 0 && charEnd >= html.length) {
return html;
}
// Parse HTML and extract text nodes within the character range
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const body = doc.body;
// Find all text nodes and their cumulative character counts
type TextNodeInfo = { node: Text; startChar: number; endChar: number };
const textNodes: TextNodeInfo[] = [];
let cumulativeChars = 0;
function traverse(node: Node) {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent || "";
const startChar = cumulativeChars;
cumulativeChars += text.length;
const endChar = cumulativeChars;
textNodes.push({ node: node as Text, startChar, endChar });
} else if (node.nodeType === Node.ELEMENT_NODE) {
// Skip script and style tags
if (node instanceof HTMLElement) {
const tagName = node.tagName.toLowerCase();
if (tagName === "script" || tagName === "style") {
return;
}
}
// Recursively traverse children
for (const child of Array.from(node.childNodes)) {
traverse(child);
}
}
}
traverse(body);
// Find which text nodes intersect with the requested range
const relevantNodes: { node: Text; before: string; after: string }[] = [];
for (const { node, startChar, endChar } of textNodes) {
if (endChar <= charStart || startChar >= charEnd) {
// No overlap
continue;
}
const text = node.textContent || "";
let resultText = text;
// Trim from left if node starts before page
if (startChar < charStart) {
resultText = text.substring(charStart - startChar);
}
// Trim from right if node extends past page end
if (endChar > charEnd) {
// Calculate where to cut within the (potentially already trimmed) text
const cutPosition = charEnd - startChar;
resultText = text.substring(0, cutPosition);
}
// Handle case where both trims are needed
if (startChar < charStart && endChar > charEnd) {
const leftTrim = charStart - startChar;
const rightTrim = endChar - charEnd;
resultText = text.substring(leftTrim, text.length - rightTrim);
}
relevantNodes.push({ node, before: "", after: resultText });
}
// Preserve original HTML structure for nodes in range
const startNode = textNodes.find((n) => n.endChar > charStart);
const endNode = textNodes.find((n) => n.startChar < charEnd);
if (!startNode || !endNode) {
return html;
}
// Find element boundaries
let startElement: Node | null = startNode.node;
while (startElement && startElement.parentNode !== body) {
startElement = startElement.parentNode;
}
let endElement: Node | null = endNode.node;
while (endElement && endElement.parentNode !== body) {
endElement = endElement.parentNode;
}
// Extract and modify the relevant portion
if (startElement && endElement) {
const fragment = document.createDocumentFragment();
let currentElement: Node | null = startElement;
let foundEnd = false;
while (currentElement && !foundEnd) {
if (currentElement.nodeType === Node.ELEMENT_NODE) {
const clone = (currentElement as Element).cloneNode(false);
fragment.appendChild(clone);
// Process children
for (const child of Array.from(currentElement.childNodes)) {
if (child.nodeType === Node.TEXT_NODE) {
const textNodeInfo = textNodes.find((n) => n.node === child);
if (textNodeInfo) {
const modified = document.createTextNode(
relevantNodes.find((n) => n.node === child)?.after || "",
);
clone.appendChild(modified);
}
} else if (child.nodeType === Node.ELEMENT_NODE) {
// Recursively handle element children
const childClone = child.cloneNode(true);
clone.appendChild(childClone);
}
}
if (currentElement === endElement) {
foundEnd = true;
}
}
currentElement = currentElement.nextSibling;
}
// Serialize fragment back to HTML
const tempDiv = document.createElement("div");
tempDiv.appendChild(fragment);
return tempDiv.innerHTML;
}
// Fallback: return original HTML if extraction fails
return html;
}
// Get page content (HTML slice for a page)
export function getPageContent(
pagination: PaginationData,
pageIndex: number,
): string {
const page = pagination.pageMap.get(pageIndex);
if (!page) return "";
// Find the spine that contains this page
// Pages are stored in order, so we can find the spine by checking which pages it contains
let spine: SpineInfo | undefined;
for (const s of pagination.spines) {
if (s.pages.some((p) => p.pageIndex === pageIndex)) {
spine = s;
break;
}
}
if (!spine) return "";
// Extract HTML content between page boundaries
const htmlSlice = extractHTMLSlice(
spine.content,
page.charStart,
page.charEnd,
);
// Wrap in a div to ensure valid HTML structure
return `<div class="page-content-wrapper">${htmlSlice}</div>`;
}
// Recalculate pagination on viewport change
export function shouldRecalculate(
pagination: PaginationData | null,
newSettings: PaginationSettings,
): boolean {
if (!pagination) return true;
const sizeChanged =
Math.abs(pagination.settings.viewportWidth - newSettings.viewportWidth) >
50 ||
Math.abs(pagination.settings.viewportHeight - newSettings.viewportHeight) >
50;
const fontChanged = pagination.settings.fontSize !== newSettings.fontSize;
const lineChanged = pagination.settings.lineHeight !== newSettings.lineHeight;
return sizeChanged || fontChanged || lineChanged;
}
// Create position object from page number
export function createPositionFromPage(
book: UniversalReader,
page: number,
): ReadingPosition {
if (!book.pagination) {
return {
currentPage: 1,
spineIndex: 0,
localPageIndex: 0,
cfi: "",
progress: 0,
};
}
const pageIndex = page - 1;
const pageData = book.pagination.pageMap.get(pageIndex);
if (!pageData) {
return {
currentPage: 1,
spineIndex: 0,
localPageIndex: 0,
cfi: "",
progress: 0,
};
}
// Find which spine this page belongs to
let spineIndex = 0;
for (const spine of book.pagination.spines) {
if (pageData.localPageIndex < spine.pages.length) {
spineIndex = spine.spineIndex;
break;
}
}
return {
currentPage: page,
spineIndex,
localPageIndex: pageData.localPageIndex,
cfi: pageData.cfi,
progress:
book.pagination.totalPages > 0 ? page / book.pagination.totalPages : 0,
};
}
-124
View File
@@ -1,124 +0,0 @@
// Import types and existing parsers
import type { TOCItem } from "./types";
import { parseEPUB } from "../../parsers/epub-parsers";
import { parseFB2 } from "../../parsers/fb2-parser";
import { parseTXT } from "../../parsers/txt-parser";
import { parseHTML } from "../../parsers/html-parser";
import { UniversalReader } from "../../reader-shell";
// Parse any reflowable format
export async function parseReflowable(
file: File,
format: "epub" | "fb2" | "txt" | "html",
): Promise<UniversalReader> {
switch (format) {
case "epub": {
const cif = await parseEPUB(file); // Get EbookCIF
return {
// Wrap in UniversalReader structure
type: "ebook",
cif, // ← The EbookCIF goes here
currentSpineIndex: 0,
currentPage: 1,
pagination: null,
position: {
currentPage: 1,
spineIndex: 0,
localPageIndex: 0,
cfi: "",
progress: 0,
},
};
}
case "fb2": {
const cif = await parseFB2(file);
return {
type: "ebook",
cif,
currentSpineIndex: 0,
currentPage: 1,
pagination: null,
position: {
currentPage: 1,
spineIndex: 0,
localPageIndex: 0,
cfi: "",
progress: 0,
},
};
}
case "txt": {
const cif = await parseTXT(file);
return {
type: "ebook",
cif,
currentSpineIndex: 0,
currentPage: 1,
pagination: null,
position: {
currentPage: 1,
spineIndex: 0,
localPageIndex: 0,
cfi: "",
progress: 0,
},
};
}
case "html": {
const cif = await parseHTML(file);
return {
type: "ebook",
cif,
currentSpineIndex: 0,
currentPage: 1,
pagination: null,
position: {
currentPage: 1,
spineIndex: 0,
localPageIndex: 0,
cfi: "",
progress: 0,
},
};
}
default:
throw new Error(`Unsupported reflowable format: ${format}`);
}
}
// Validate parsed book data
export function validateBook(book: UniversalReader): boolean {
return book.cif.spine.length > 0 && book.cif.metadata.title !== "";
}
// Get book title
export function getBookTitle(book: UniversalReader): string {
return book.cif.metadata.title || "Untitled";
}
// Get book author
export function getBookAuthor(book: UniversalReader): string {
return book.cif.metadata.author || "Unknown";
}
// Get total spine count
export function getSpineCount(book: UniversalReader): number {
return book.cif.spine.length;
}
// Get TOC as flat list
export function getFlatTOC(book: UniversalReader): TOCItem[] {
const flat: TOCItem[] = [];
function traverse(items: TOCItem[]) {
for (const item of items) {
flat.push(item);
if (item.children.length > 0) {
traverse(item.children);
}
}
}
traverse(book.cif.toc);
return flat;
}
@@ -1,84 +0,0 @@
// Import types
import type { ReadingPosition } from "./types";
import { findPageByCFI, createPositionFromPage } from "./page-calculator";
import { UniversalReader } from "../../reader-shell";
// Update current position
export function updateCurrentPosition(
book: UniversalReader,
position: ReadingPosition,
): UniversalReader {
return {
...book,
position,
currentSpineIndex: position.spineIndex,
};
}
// Extract CFI from position
export function getCurrentCFI(book: UniversalReader): string {
return book.position.cfi;
}
// Calculate progress for display
export function calculateProgress(book: UniversalReader): {
currentPage: number;
totalPages: number;
percentage: number;
} {
const totalPages = book.pagination?.totalPages || 1;
const currentPage = book.position.currentPage;
const percentage =
totalPages > 0 ? Math.round((currentPage / totalPages) * 100) : 0;
return { currentPage, totalPages, percentage };
}
// Get position for saving to database
export function getPositionForSave(book: UniversalReader): {
cfi: string;
progress: number;
page: number;
} {
return {
cfi: book.position.cfi,
progress: book.position.progress,
page: book.position.currentPage,
};
}
// Restore position from database
export function restorePosition(
book: UniversalReader,
savedCFI: string,
savedPage?: number,
): ReadingPosition {
if (!book.pagination) {
return book.position;
}
// If we have saved CFI, try to find exact position
if (savedCFI) {
const pageNum = findPageByCFI(book.pagination, savedCFI);
return createPositionFromPage(book, pageNum);
}
// Otherwise use saved page number
if (savedPage && savedPage > 0) {
return createPositionFromPage(book, savedPage);
}
return book.position;
}
// Check if position changed significantly
export function didPositionChange(
oldPos: ReadingPosition,
newPos: ReadingPosition,
): boolean {
return (
oldPos.currentPage !== newPos.currentPage ||
oldPos.cfi !== newPos.cfi ||
Math.abs(oldPos.progress - newPos.progress) > 0.01
);
}
@@ -1,84 +0,0 @@
// Spine item structure from parsed EPUB/FB2/etc
export interface SpineItem {
id: string;
type: "html" | "image" | "other";
content: string; // Blob URL or content ID
href?: string; // For CFI generation
}
// Information about a single spine item
export interface SpineInfo {
spineIndex: number;
spineItemId: string;
content: string; // Full HTML content
charCount: number; // Total characters
wordCount: number; // Total words (for pagination)
cfiStart: string; // CFI at start of this spine
pages: PageBoundary[]; // Page boundaries within this spine
}
// A single page boundary within a spine
export interface PageBoundary {
pageIndex: number; // Global page index
localPageIndex: number; // Page index within this spine
charStart: number; // Character offset from start of spine
charEnd: number; // Character offset at end of page
wordStart: number; // Word offset from start of spine
wordEnd: number; // Word offset at end of page
cfi: string; // CFI for this position
}
// Complete pagination data
export interface PaginationData {
totalPages: number;
spines: SpineInfo[];
spineMap: Map<number, SpineInfo>;
pageMap: Map<number, PageBoundary>; // pageIndex -> PageBoundary
calculatedAt: number;
settings: PaginationSettings;
}
// Settings used for calculation
export interface PaginationSettings {
fontSize: number;
lineHeight: number;
viewportWidth: number;
viewportHeight: number;
wordsPerPage: number; // Calculated from above
}
// Current reading position
export interface ReadingPosition {
currentPage: number;
spineIndex: number;
localPageIndex: number;
cfi: string;
progress: number; // 0-1
}
// Reflowable book data
export interface ReflowableBook {
type: "epub" | "fb2" | "txt" | "html";
spine: SpineItem[];
resources: Map<string, Blob>;
toc: TOCItem[];
metadata: BookMetadata;
pagination: PaginationData | null;
position: ReadingPosition;
}
// Table of contents item
export interface TOCItem {
id: string;
title: string;
href: string;
children: TOCItem[];
}
// Book metadata
export interface BookMetadata {
title: string;
author: string;
identifier: string;
[key: string]: any;
}
-335
View File
@@ -1,335 +0,0 @@
// EPUB Parser - Converts EPUB 2/3 to Common Intermediate Format
// Procedural style: Functions, not classes
import JSZip from "jszip";
// ============================================================
// Main Parse Function
// ============================================================
export async function parseEPUB(epubBlob: Blob): Promise<EbookCIF> {
const JSZip = (await import("jszip")).default;
const zip = await JSZip.loadAsync(epubBlob);
// Parse container.xml to find OPF file
const containerXml = await getZipFileContent(zip, "META-INF/container.xml");
const opfPath = extractOPFPath(containerXml);
if (!opfPath) {
throw new Error("Invalid EPUB: no OPF file found");
}
const opfXml = await getZipFileContent(zip, opfPath);
const packageDoc = parseXML(opfXml);
const metadata = extractMetadata(packageDoc);
const spine = parseSpine(packageDoc, opfPath);
const toc = await parseTOC(zip, packageDoc, opfPath);
const resources = await loadResources(zip);
const coverImage = await extractCover(zip, packageDoc);
// Calculate locations (minimal - backend handles detailed tracking)
const totalCharacters = await calculateTotalCharacters(spine, resources);
return {
metadata: {
...metadata,
coverImage,
},
toc,
spine,
resources,
locations: {
totalCharacters,
estimatedPages: Math.ceil(totalCharacters / 1500),
},
};
}
// ============================================================
// Helper Functions
// ============================================================
async function getZipFileContent(zip: any, path: string): Promise<string> {
const file = zip.file(path);
if (!file) {
throw new Error(`File not found: ${path}`);
}
return await file.async("text");
}
function parseXML(xmlString: string): XMLDocument {
const parser = new DOMParser();
return parser.parseFromString(xmlString, "text/xml");
}
function extractOPFPath(containerXml: string): string | null {
const containerDoc = parseXML(containerXml);
return (
containerDoc.querySelector("rootfile")?.getAttribute("full-path") || null
);
}
function extractMetadata(packageDoc: XMLDocument): EbookCIF["metadata"] {
const metadata = packageDoc.querySelector("metadata");
if (!metadata) {
throw new Error("No metadata found in OPF");
}
return {
title: metadata.querySelector("title")?.textContent || "",
author: metadata.querySelector("creator")?.textContent || "",
language: metadata.querySelector("language")?.textContent || "en",
publisher: metadata.querySelector("publisher")?.textContent || undefined,
isbn: metadata.querySelector("identifier")?.textContent || undefined,
};
}
function parseSpine(
packageDoc: XMLDocument,
opfPath: string,
): EbookCIF["spine"] {
const spine = packageDoc.querySelector("spine");
const manifest = packageDoc.querySelector("manifest");
if (!spine || !manifest) {
throw new Error("No spine or manifest found in OPF");
}
const spineItems = spine.querySelectorAll("itemref");
const result: EbookCIF["spine"] = [];
spineItems.forEach((itemref) => {
const idref = itemref.getAttribute("idref");
console.log("Spine item idref:", idref); // Your debug log - keep or remove
if (!idref) return;
const manifestItem = manifest.querySelector(`[id="${idref}"]`);
console.log("Manifest item:", manifestItem); // Your debug log
if (!manifestItem) return;
const href = manifestItem.getAttribute("href");
console.log("Href:", href); // Your debug log
if (!href) return;
const resolvedPath = resolvePath(opfPath, href);
result.push({
id: idref,
type: "html" as const,
content: resolvedPath,
properties: (itemref.getAttribute("properties") || "")
.split(" ")
.filter(Boolean),
index: result.length,
});
});
return result;
}
async function parseTOC(
zip: any,
packageDoc: XMLDocument,
opfPath: string,
): Promise<EbookCIF["toc"]> {
// Try EPUB 3.0 navigation document first
const navItem = packageDoc.querySelector('manifest item[properties~="nav"]');
if (navItem) {
const navHref = navItem.getAttribute("href");
if (navHref) {
const navPath = resolvePath(opfPath, navHref);
return parseNavTOC(zip, navPath);
}
}
// Fallback to EPUB 2.0 NCX
const spine = packageDoc.querySelector("spine");
const ncxId = spine?.getAttribute("toc");
if (ncxId) {
const ncxItem = packageDoc.querySelector(`manifest [id="${ncxId}"]`);
if (ncxItem) {
const ncxHref = ncxItem.getAttribute("href");
if (ncxHref) {
const ncxPath = resolvePath(opfPath, ncxHref);
return parseNCXTOC(zip, ncxPath);
}
}
}
return [];
}
async function parseNavTOC(
zip: any,
navPath: string,
): Promise<EbookCIF["toc"]> {
const navXml = await getZipFileContent(zip, navPath);
const navDoc = parseXML(navXml);
const nav = navDoc.querySelector("nav");
if (!nav) return [];
const ol = nav.querySelector("ol");
if (!ol) return [];
const items = ol.querySelectorAll(":scope > li");
const result: EbookCIF["toc"] = [];
for (const li of Array.from(items)) {
const link = li.querySelector("a");
if (link) {
result.push({
id: link.getAttribute("href") || "",
title: link.textContent || "",
href: link.getAttribute("href") || "",
children: [],
});
}
}
return result;
}
async function parseNCXTOC(
zip: any,
ncxPath: string,
): Promise<EbookCIF["toc"]> {
const ncxXml = await getZipFileContent(zip, ncxPath);
const ncxDoc = parseXML(ncxXml);
const navMap = ncxDoc.querySelector("navMap");
if (!navMap) return [];
return parseNCXNode(navMap);
}
function parseNCXNode(node: Element): EbookCIF["toc"] {
const navPoints = node.querySelectorAll(":scope > navPoint");
const result: EbookCIF["toc"] = [];
navPoints.forEach((navPoint) => {
const label = navPoint.querySelector("navLabel text")?.textContent || "";
const content = navPoint.querySelector("content");
const href = content?.getAttribute("src") || "";
result.push({
id: href,
title: label,
href,
children: parseNCXNode(navPoint),
});
});
return result;
}
async function loadResources(zip: any): Promise<Map<string, Blob>> {
const resources = new Map<string, Blob>();
const files = Object.keys(zip.files);
for (const path of files) {
const file = zip.file(path);
if (file && !file.dir) {
const blob = await file.async("blob");
// Store with full path (e.g., "OEBPS/image/1.png")
resources.set(path, blob);
// Store with filename only (e.g., "1.png")
const filename = path.split("/").pop();
if (filename && filename !== path) {
if (!resources.has(filename)) {
resources.set(filename, blob);
}
}
// Store with relative path (everything after first /)
// e.g., "OEBPS/image/1.png" -> "image/1.png"
const firstSlash = path.indexOf("/");
if (firstSlash > 0) {
const relativePath = path.substring(firstSlash + 1);
if (!resources.has(relativePath)) {
resources.set(relativePath, blob);
}
}
}
}
return resources;
}
async function extractCover(
zip: any,
packageDoc: XMLDocument,
): Promise<Blob | undefined> {
// Try cover-id metadata
const coverId = packageDoc
.querySelector('meta[name="cover"]')
?.getAttribute("content");
if (coverId) {
const coverItem = packageDoc.querySelector(`manifest [id="${coverId}"]`);
if (coverItem) {
const coverHref = coverItem.getAttribute("href");
if (coverHref) {
const coverFile = zip.file(coverHref);
if (coverFile) {
return await coverFile.async("blob");
}
}
}
}
// Fallback: look for cover image in manifest
const coverItem = packageDoc.querySelector(
'manifest item[properties~="cover-image"]',
);
if (coverItem) {
const coverHref = coverItem.getAttribute("href");
if (coverHref) {
const coverFile = zip.file(coverHref);
if (coverFile) {
return await coverFile.async("blob");
}
}
}
return undefined;
}
function resolvePath(basePath: string, relativePath: string): string {
const baseDir = basePath.substring(0, basePath.lastIndexOf("/") + 1);
return baseDir + relativePath;
}
async function calculateTotalCharacters(
spine: EbookCIF["spine"],
resources: Map<string, Blob>,
): Promise<number> {
let total = 0;
for (const item of spine) {
if (item.type === "html") {
const content = resources.get(item.content);
if (content) {
const text = await content.text();
total += text.length;
}
}
}
return total;
}
// ============================================================
// Metadata Quick Extract (for library view)
// ============================================================
export async function extractEPUBMetadata(
epubBlob: Blob,
): Promise<Partial<EbookCIF["metadata"]>> {
const zip = await JSZip.loadAsync(epubBlob);
const containerXml = await getZipFileContent(zip, "META-INF/container.xml");
const opfPath = extractOPFPath(containerXml);
if (!opfPath) {
return {};
}
const opfXml = await getZipFileContent(zip, opfPath);
const packageDoc = parseXML(opfXml);
return extractMetadata(packageDoc);
}
-249
View File
@@ -1,249 +0,0 @@
// FB2 Parser - Converts FictionBook 2 to Common Intermediate Format
// FB2 is XML-based, similar to EPUB structure
// Procedural style: Functions, not classes
// ============================================================
// Main Parse Function
// ============================================================
export async function parseFB2(fb2Blob: Blob): Promise<EbookCIF> {
// FB2 can be plain XML or zipped (.fb2.zip)
let xmlContent: string;
if (
fb2Blob.type === "application/zip" ||
fb2Blob.type === "application/x-zip-compressed"
) {
const JSZip = (await import("jszip")).default;
const zip = await JSZip.loadAsync(fb2Blob);
const files = Object.keys(zip.files);
// Find the first .fb2 file in the zip
const fb2File = files.find((f) => f.endsWith(".fb2"));
if (!fb2File) {
throw new Error("No .fb2 file found in archive");
}
xmlContent = await zip.file(fb2File)!.async("text");
} else {
xmlContent = await fb2Blob.text();
}
const xmlDoc = parseXML(xmlContent);
const metadata = extractFB2Metadata(xmlDoc);
const toc = parseFB2TOC(xmlDoc);
const spine = createFB2Spine(xmlDoc);
const resources = await extractFB2Resources(xmlDoc, fb2Blob);
// Calculate locations (minimal - backend handles detailed tracking)
const totalCharacters = calculateFB2Characters(xmlDoc);
return {
metadata,
toc,
spine,
resources,
locations: {
totalCharacters,
estimatedPages: Math.ceil(totalCharacters / 1500),
},
};
}
// ============================================================
// Helper Functions
// ============================================================
function parseXML(xmlString: string): XMLDocument {
const parser = new DOMParser();
return parser.parseFromString(xmlString, "text/xml");
}
function extractFB2Metadata(xmlDoc: XMLDocument): EbookCIF["metadata"] {
const titleInfo = xmlDoc.querySelector("title-info");
const documentInfo = xmlDoc.querySelector("document-info");
if (!titleInfo) {
throw new Error("Invalid FB2: no title-info found");
}
return {
title: titleInfo.querySelector("book-title")?.textContent || "",
author: extractFB2Author(titleInfo),
language: titleInfo.querySelector("lang")?.textContent || "en",
publisher:
documentInfo?.querySelector("publisher")?.textContent || undefined,
isbn: undefined, // FB2 doesn't typically have ISBN
};
}
function extractFB2Author(titleInfo: Element): string {
const author = titleInfo.querySelector("author");
if (!author) return "";
const firstName = author.querySelector("first-name")?.textContent || "";
const lastName = author.querySelector("last-name")?.textContent || "";
const middleName = author.querySelector("middle-name")?.textContent || "";
const parts = [firstName, middleName, lastName].filter(Boolean);
return parts.join(" ") || "Unknown";
}
function parseFB2TOC(xmlDoc: XMLDocument): EbookCIF["toc"] {
const toc: EbookCIF["toc"] = [];
const body = xmlDoc.querySelector("body");
if (!body) return toc;
const sections = body.querySelectorAll(":scope > section");
let sectionIndex = 0;
for (const section of sections) {
const title = section.querySelector("title");
const titleText =
title?.textContent.trim() || `Section ${sectionIndex + 1}`;
toc.push({
id: `section-${sectionIndex}`,
title: titleText,
href: `#section-${sectionIndex}`,
children: [],
});
sectionIndex++;
}
return toc;
}
function createFB2Spine(xmlDoc: XMLDocument): EbookCIF["spine"] {
const spine: EbookCIF["spine"] = [];
const body = xmlDoc.querySelector("body");
if (!body) return spine;
// Convert each section to HTML
const sections = body.querySelectorAll(":scope > section");
sections.forEach((section, index) => {
const htmlContent = convertFB2SectionToHTML(section, index);
spine.push({
id: `section-${index}`,
type: "html",
content: htmlContent,
index,
});
});
return spine;
}
function convertFB2SectionToHTML(section: Element, index: number): string {
const title = section.querySelector("title");
let html = `<div id="section-${index}" class="fb2-section">`;
if (title) {
html += `<h1>${title.textContent}</h1>`;
}
// Convert paragraphs
const paragraphs = section.querySelectorAll("p");
paragraphs.forEach((p) => {
html += `<p>${p.innerHTML}</p>`;
});
// Convert images
const images = section.querySelectorAll("image");
images.forEach((img) => {
const href = img.getAttribute("l:href");
const alt = img.getAttribute("alt") || "";
if (href) {
html += `<img src="${href}" alt="${alt}" />`;
}
});
html += "</div>";
return html;
}
async function extractFB2Resources(
xmlDoc: XMLDocument,
fb2Blob: Blob,
): Promise<Map<string, Blob>> {
const resources = new Map<string, Blob>();
// FB2 can have embedded images (base64) or external references
const binary = xmlDoc.querySelector("binary");
if (binary) {
const contentType = binary.getAttribute("content-type");
const id = binary.getAttribute("id");
if (contentType && id && binary.textContent) {
// Decode base64
const base64Data = binary.textContent.trim();
const byteString = atob(base64Data);
const byteArray = new Uint8Array(byteString.length);
for (let i = 0; i < byteString.length; i++) {
byteArray[i] = byteString.charCodeAt(i);
}
const blob = new Blob([byteArray], { type: contentType });
resources.set(`#${id}`, blob);
}
}
return resources;
}
function calculateFB2Characters(xmlDoc: XMLDocument): number {
const body = xmlDoc.querySelector("body");
if (!body) return 0;
return body.textContent?.length || 0;
}
function generatePageBreaks(totalCharacters: number): number[] {
const breaks: number[] = [];
const charsPerPage = 1000;
for (let i = charsPerPage; i < totalCharacters; i += charsPerPage) {
breaks.push(i);
}
return breaks;
}
// ============================================================
// Metadata Quick Extract
// ============================================================
export async function getFB2Metadata(
fb2Blob: Blob,
): Promise<Partial<EbookCIF["metadata"]>> {
let xmlContent: string;
if (
fb2Blob.type === "application/zip" ||
fb2Blob.type === "application/x-zip-compressed"
) {
const JSZip = (await import("jszip")).default;
const zip = await JSZip.loadAsync(fb2Blob);
const files = Object.keys(zip.files);
// Find the first .fb2 file in the zip
const fb2File = files.find((f) => f.endsWith(".fb2"));
if (!fb2File) return {};
xmlContent = await zip.file(fb2File)!.async("text");
} else {
xmlContent = await fb2Blob.text();
}
const xmlDoc = parseXML(xmlContent);
return extractFB2Metadata(xmlDoc);
}
-158
View File
@@ -1,158 +0,0 @@
// HTML Parser - Wraps standalone HTML files
// Procedural style: Functions, not classes
// ============================================================
// Main Parse Function
// ============================================================
export async function parseHTML(htmlBlob: Blob): Promise<EbookCIF> {
const htmlContent = await htmlBlob.text();
const metadata = extractHTMLMetadata(htmlBlob, htmlContent);
const toc = createHTMLTOC(htmlContent);
const spine = createHTMLSpine(htmlContent);
const resources = await extractHTMLResources(htmlBlob, htmlContent);
const totalCharacters = stripHTML(htmlContent).length;
const pageBreaks = generatePageBreaks(totalCharacters);
return {
metadata,
toc,
spine,
resources,
locations: {
totalCharacters,
pageBreaks,
},
};
}
// ============================================================
// Helper Functions
// ============================================================
function extractHTMLMetadata(
htmlBlob: Blob,
htmlContent: string,
): EbookCIF["metadata"] {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, "text/html");
const title =
doc.querySelector("title")?.textContent ||
htmlBlob.name.replace(/\.(html?|htm)$/i, "");
const metaAuthor = doc
.querySelector('meta[name="author"]')
?.getAttribute("content");
const metaLang = doc.querySelector("html")?.getAttribute("lang") || "en";
return {
title,
author: metaAuthor || "Unknown",
language: metaLang,
};
}
function createHTMLTOC(htmlContent: string): EbookCIF["toc"] {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, "text/html");
const toc: EbookCIF["toc"] = [];
// Try to find headings
const headings = doc.querySelectorAll("h1, h2, h3");
let headingIndex = 0;
headings.forEach((heading) => {
toc.push({
id: `heading-${headingIndex}`,
title: heading.textContent || "",
href: `#${heading.id || `heading-${headingIndex}`}`,
children: [],
});
headingIndex++;
});
// If no headings, create single entry
if (toc.length === 0) {
toc.push({
id: "full-document",
title: "Full Document",
href: "#full-document",
children: [],
});
}
return toc;
}
function createHTMLSpine(htmlContent: string): EbookCIF["spine"] {
return [
{
id: "full-document",
type: "html",
content: htmlContent,
index: 0,
},
];
}
async function extractHTMLResources(
htmlBlob: Blob,
htmlContent: string,
): Promise<Map<string, Blob>> {
const resources = new Map<string, Blob>();
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, "text/html");
// Extract images
const images = doc.querySelectorAll("img[src]");
for (const img of Array.from(images)) {
const src = img.getAttribute("src");
if (!src) continue;
// Try to resolve relative URLs
if (src.startsWith("data:")) {
// Data URI - extract blob
const match = src.match(/^data:([^;]+);base64,(.+)$/);
if (match) {
const mimeType = match[1];
const base64 = match[2];
const byteString = atob(base64);
const byteArray = new Uint8Array(byteString.length);
for (let i = 0; i < byteString.length; i++) {
byteArray[i] = byteString.charCodeAt(i);
}
const blob = new Blob([byteArray], { type: mimeType });
resources.set(src, blob);
}
}
// External resources would need to be fetched
// For now, skip them (browser will load them naturally)
}
return resources;
}
function stripHTML(html: string): string {
const div = document.createElement("div");
div.innerHTML = html;
return div.textContent || "";
}
// ============================================================
// Metadata Quick Extract
// ============================================================
export async function getHTMLMetadata(
htmlBlob: Blob,
): Promise<Partial<EbookCIF["metadata"]>> {
const htmlContent = await htmlBlob.text();
return extractHTMLMetadata(htmlBlob, htmlContent);
}
-121
View File
@@ -1,121 +0,0 @@
// TXT Parser - Wraps plain text in HTML structure
// Procedural style: Functions, not classes
// ============================================================
// Main Parse Function
// ============================================================
export async function parseTXT(txtBlob: Blob): Promise<EbookCIF> {
const textContent = await txtBlob.text();
const metadata = extractTXTMetadata(txtBlob);
const toc = createTXTTOC(textContent);
const spine = createTXTSpine(textContent);
const resources = new Map(); // No external resources for plain text
const totalCharacters = textContent.length;
return {
metadata,
toc,
spine,
resources,
locations: {
totalCharacters,
estimatedPages: Math.ceil(totalCharacters / 1500),
},
};
}
// ============================================================
// Helper Functions
// ============================================================
function extractTXTMetadata(txtBlob: Blob): EbookCIF["metadata"] {
const filename = txtBlob.name || "Unknown";
return {
title: filename.replace(/\.(txt|text)$/i, ""),
author: "Unknown",
language: "en",
};
}
function createTXTTOC(textContent: string): EbookCIF["toc"] {
// Try to detect chapters (simple heuristic)
const toc: EbookCIF["toc"] = [];
const lines = textContent.split("\n");
let chapterIndex = 0;
lines.forEach((line, index) => {
// Common chapter patterns
const chapterPattern = /^(chapter|part|section)\s+\d+/i;
if (chapterPattern.test(line.trim())) {
toc.push({
id: `chapter-${chapterIndex}`,
title: line.trim(),
href: `#chapter-${chapterIndex}`,
children: [],
});
chapterIndex++;
}
});
// If no chapters found, create single entry
if (toc.length === 0) {
toc.push({
id: "full-text",
title: "Full Text",
href: "#full-text",
children: [],
});
}
return toc;
}
function createTXTSpine(textContent: string): EbookCIF["spine"] {
// Convert plain text to HTML paragraphs
const lines = textContent.split("\n");
let htmlContent = '<div class="txt-content">';
lines.forEach((line) => {
const trimmed = line.trim();
if (trimmed) {
htmlContent += `<p>${escapeHTML(trimmed)}</p>`;
} else {
htmlContent += "<br />";
}
});
htmlContent += "</div>";
return [
{
id: "full-text",
type: "html",
content: htmlContent,
index: 0,
},
];
}
function escapeHTML(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
// Removed - backend handles detailed position tracking
// ============================================================
// Metadata Quick Extract
// ============================================================
export async function getTXTMetadata(
txtBlob: Blob,
): Promise<Partial<EbookCIF["metadata"]>> {
return extractTXTMetadata(txtBlob);
}
-565
View File
@@ -1,565 +0,0 @@
import { Alpine } from "../alpine";
import { getReaderMetadata, getReadingProgress } from "../api";
import "./formats/comic/panel-editor";
import { createReaderContext, type ReaderContext } from "./core/reader-context";
import { getState, setState } from "./core/reader-state";
import { createNavigationAPI } from "./core/reader-navigation";
import { readerEvents } from "./core/reader-events";
import { renderSpineItem } from "./core/reader-navigation";
import { loadSettings } from "./settings-manager";
import {
calculatePagination,
shouldRecalculate,
} from "./formats/reflowable/page-calculator";
import { restorePosition } from "./formats/reflowable/progress-tracker";
import { applyPaginatedStyles } from "./formats/reflowable/content-renderer";
import type {
PaginationSettings,
PaginationData,
ReadingPosition,
} from "./formats/reflowable/types";
import { updatePageDisplay, updateProgressBar } from "./ui/page-display";
import { PDFDocumentProxy } from "pdfjs-dist";
export interface UniversalReader {
type: "ebook";
cif: EbookCIF;
currentSpineIndex: number;
currentScrollPosition?: number;
currentPage: number;
pagination?: PaginationData;
position?: ReadingPosition;
}
export interface PDFReader {
type: "pdf";
doc: PDFDocumentProxy;
currentPage: number;
}
export interface ComicReader {
type: "comic";
images: Blob[];
currentPage: number;
}
export interface MangaReader {
type: "manga";
images: Blob[];
currentPage: number;
readingDirection: "rtl" | "vertical";
}
type CurrentReader = UniversalReader | PDFReader | ComicReader | MangaReader;
let currentReader: CurrentReader | null = null;
let readerMetadata: ReaderMetadata | null = null;
// ============================================================
// Feature Registry
// ============================================================
type FeatureInit = (context: ReaderContext) => void | Promise<void>;
const featureModules: FeatureInit[] = [
// Core features
(ctx) => import("./ui/gestures").then((m) => m.init(ctx)),
(ctx) => import("./ui/keyboard-shortcuts").then((m) => m.init(ctx)),
(ctx) => import("./ui/panel-dock-system").then((m) => m.init(ctx)),
(ctx) => import("./ui/navigator-panel").then((m) => m.init(ctx)),
(ctx) => import("./ui/offline-manager").then((m) => m.init(ctx)),
(ctx) => import("./ui/reading-speed-tracker").then((m) => m.init(ctx)),
(ctx) => import("./ui/progress-indicator").then((m) => m.init(ctx)),
// Comic features
(ctx) =>
import("./formats/comic/background-color").then((m) => m.init?.(ctx)),
(ctx) => import("./formats/comic/chapter-markers").then((m) => m.init?.(ctx)),
(ctx) => import("./formats/comic/page-cache").then((m) => m.init?.(ctx)),
// PDF features
(ctx) => import("./formats/pdf/pdf-navigation").then((m) => m.init?.(ctx)),
(ctx) =>
import("./formats/pdf/pdf-text-selection").then((m) => m.init?.(ctx)),
(ctx) => import("./formats/pdf/annotation-layer").then((m) => m.init?.(ctx)),
// Ebook features
(ctx) =>
import("./formats/reflowable/ebook/copy-handler").then((m) =>
m.init?.(ctx),
),
(ctx) =>
import("./formats/reflowable/ebook/font-loader").then((m) => m.init?.(ctx)),
// Manga features
(ctx) =>
import("./formats/manga/reading-direction").then((m) => m.init?.(ctx)),
(ctx) =>
import("./formats/manga/vertical-scroll-mode").then((m) => m.init?.(ctx)),
];
async function initializeFeatures(context: ReaderContext): Promise<void> {
const results = await Promise.allSettled(
featureModules.map((getInit) => getInit(context)),
);
results.forEach((result, index) => {
if (result.status === "rejected") {
console.warn(`Feature ${index} failed to initialize:`, result.reason);
}
});
}
// ============================================================
// Reader Initialization
// ============================================================
async function initializeReader(): Promise<void> {
const mediaItemId = document.body.dataset.mediaItemId;
console.log("Initializing reader for:", mediaItemId);
if (!mediaItemId) {
console.error("No mediaItemId on body");
return;
}
try {
readerMetadata = await getReaderMetadata(mediaItemId);
console.log("Got metadata:", readerMetadata);
console.log("Library type:", readerMetadata.library_type_name);
console.log("Mime type:", readerMetadata.mime_type);
switch (readerMetadata.format_group) {
case "reflowable":
if (
readerMetadata.mime_type === "application/epub+zip" ||
readerMetadata.file_path.endsWith(".epub")
) {
currentReader = await initializeEbookReader(readerMetadata);
}
break;
case "fixed_layout":
currentReader = await initializePDFReader(readerMetadata);
break;
case "comic_archive":
// Check manga_type or library_type_name
if (
readerMetadata.manga_type === "yes" ||
readerMetadata.manga_type === "yes_and_right_to_left" ||
readerMetadata.library_type_name === "manga"
) {
currentReader = await initializeMangaReader(readerMetadata);
} else {
currentReader = await initializeComicReader(readerMetadata);
}
break;
}
if (!currentReader) return;
setState({ currentReader, readerMetadata });
const context = createReaderContext(
getState,
setState,
createNavigationAPI(),
() =>
(context.render = () => {
// Delegates to navigation module
}),
);
await initializeFeatures(context);
readerEvents.emit("readerReady", currentReader);
// Initialize page calculation FIRST, before any rendering
const { initializePageCalculation } =
await import("./core/reader-navigation");
await initializePageCalculation();
// Then render the initial chapter content
await renderSpineItem();
// Focus the content container so keyboard navigation works immediately
const container = document.getElementById("reader-content");
container?.focus();
} catch (error) {
console.error("Render initialization failed:", error);
}
}
async function initializeEbookReader(
metadata: ReaderMetadata,
): Promise<UniversalReader> {
const { parseEbook, requiresServerParsing } =
await import("./core/parser-manager");
const needsServer = requiresServerParsing(
metadata.mime_type,
getFileExtension(metadata.file_path),
);
let ebookFile: Blob;
if (needsServer) {
const response = await fetch(`/readers/${metadata.id}/parse`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mime_type: metadata.mime_type,
file_path: metadata.file_path,
}),
});
if (!response.ok) {
throw new Error(`Server parsing failed: ${response.statusText}`);
}
ebookFile = await response.blob();
} else {
const response = await fetch(metadata.file_path);
ebookFile = await response.blob();
}
const cif = await parseEbook(
ebookFile,
metadata.mime_type,
getFileExtension(metadata.file_path),
);
console.log("EPUB Parse Result:", {
spineLength: cif.spine?.length,
spineItems: cif.spine?.map((s: SpineItem) => s.content),
tocLength: cif.toc?.length,
keys: Object.keys(cif),
});
// ============================================================
// Fetch saved progress from backend
// ============================================================
const savedProgress = await getReadingProgress(metadata.id);
const savedCFI = savedProgress?.cfi;
const savedPage = savedProgress?.current_page;
console.log("Saved progress:", { savedCFI, savedPage });
// ============================================================
// Calculate pagination
// ============================================================
const userSettings = await loadSettings().catch(() => ({
font_size: 16,
line_height: 1.6,
margin_width: 20,
}));
const settings: PaginationSettings = {
fontSize: userSettings.font_size,
lineHeight: userSettings.line_height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight - 120,
wordsPerPage: 250,
};
console.log("Using settings:", {
fontSize: userSettings.font_size,
lineHeight: userSettings.line_height,
marginWidth: userSettings.margin_width,
});
const spineItems = cif.spine.map((s: SpineItem) => ({
id: s.id,
type: "html" as const,
content: s.content,
index: s.index,
}));
const pagination = await calculatePagination(
spineItems,
cif.resources,
settings,
);
console.log("Pagination calculated:", {
totalPages: pagination.totalPages,
spines: pagination.spines.length,
});
// ============================================================
// Restore saved position or start at page 1
// ============================================================
const position: ReadingPosition = restorePosition(
{
type: "ebook",
cif: {
spine: spineItems,
resources: cif.resources,
toc: cif.toc || [],
metadata: {
title: cif.metadata.title || metadata.title,
author: cif.metadata.author || metadata.author,
language: cif.metadata.language || "en",
publisher: cif.metadata.publisher,
isbn: cif.metadata.isbn,
coverImage: cif.metadata.coverImage,
},
locations: cif.locations || {
totalCharacters: 0,
estimatedPages: 0,
},
},
currentSpineIndex: 0,
currentPage: 1,
pagination,
position: {
currentPage: 1,
spineIndex: 0,
localPageIndex: 0,
cfi: "",
progress: 0,
},
},
savedCFI || "",
savedPage,
);
console.log("Restored position:", position);
// ============================================================
// Apply paginated styles to container
// ============================================================
const container = document.getElementById("reader-content");
if (container) {
applyPaginatedStyles();
}
// ============================================================
// Render the first page
// ============================================================
const reflowableNav = await import("./formats/reflowable/navigation");
const { success, content: pageContent } = reflowableNav.goToPage(
{
type: "ebook",
cif: {
spine: spineItems,
resources: cif.resources,
toc: cif.toc || [],
metadata: {
title: cif.metadata.title || metadata.title,
author: cif.metadata.author || metadata.author,
language: cif.metadata.language || "en",
publisher: cif.metadata.publisher,
isbn: cif.metadata.isbn,
coverImage: cif.metadata.coverImage,
},
locations: cif.locations || {
totalCharacters: 0,
estimatedPages: 0,
},
},
currentSpineIndex: position.spineIndex,
currentPage: position.currentPage,
pagination,
position,
},
position.currentPage,
);
if (success && container) {
const { renderPage } =
await import("./formats/reflowable/content-renderer");
renderPage(container, pageContent);
// Update UI
updatePageDisplay(container, position.currentPage, pagination.totalPages);
updateProgressBar(container, Math.round(position.progress * 100));
}
return {
type: "ebook",
cif,
currentSpineIndex: position.spineIndex,
currentPage: position.currentPage,
pagination,
position,
};
}
async function initializePDFReader(
metadata: ReaderMetadata,
): Promise<PDFReader> {
const { initializePDFReader } = await import("./formats/pdf/pdfjs-wrapper");
return initializePDFReader(metadata);
}
async function initializeComicReader(
metadata: ReaderMetadata,
): Promise<ComicReader> {
const { initializeComicReader } =
await import("./formats/comic/image-parser");
return initializeComicReader(metadata);
}
async function initializeMangaReader(
metadata: ReaderMetadata,
): Promise<MangaReader> {
const { initializeMangaReader } =
await import("./formats/comic/image-parser");
return initializeMangaReader(metadata);
}
function getFileExtension(filepath: string): string {
const match = filepath.match(/\.([^.]+)$/);
return match ? `.${match[1]}` : "";
}
// ============================================================
// Viewport Resize Handler
// ============================================================
function handleResize(): void {
const state = getState();
if (!state.currentReader || state.currentReader.type !== "ebook") return;
const book = state.currentReader as UniversalReader;
if (!book.pagination) return;
loadSettings()
.then((userSettings) => {
const newSettings: PaginationSettings = {
fontSize: userSettings.font_size,
lineHeight: userSettings.line_height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight - 120,
wordsPerPage: 250,
};
if (shouldRecalculate(book.pagination, newSettings)) {
const currentCFI = book.position.cfi;
const currentPage = book.position.currentPage;
calculatePagination(book.cif.spine, book.cif.resources, newSettings)
.then((newPagination) => {
const updatedBook: UniversalReader = {
type: "ebook",
cif: book.cif,
currentSpineIndex: book.position.spineIndex,
currentPage: book.position.currentPage,
pagination: newPagination,
position: {
currentPage: currentPage,
spineIndex: book.position.spineIndex,
localPageIndex: book.position.localPageIndex,
cfi: currentCFI,
progress: currentPage / newPagination.totalPages,
},
};
setState({ currentReader: updatedBook });
const container = document.getElementById("reader-content");
if (container) {
const reflowableNav = require("./formats/reflowable/navigation");
const { success, content: pageContent } = reflowableNav.goToPage(
updatedBook,
updatedBook.position.currentPage,
);
if (success) {
const {
renderPage,
} = require("./formats/reflowable/content-renderer");
renderPage(container, pageContent);
updatePageDisplay(
container,
updatedBook.position.currentPage,
newPagination.totalPages,
);
updateProgressBar(
container,
Math.round(updatedBook.position.progress * 100),
);
}
}
})
.catch((error) => {
console.error("Pagination recalculation failed:", error);
});
}
})
.catch((error) => {
console.warn(
"Failed to load user settings for resize, using defaults:",
error,
);
const newSettings: PaginationSettings = {
fontSize: 16,
lineHeight: 1.6,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight - 120,
wordsPerPage: 250,
};
if (shouldRecalculate(book.pagination, newSettings)) {
const currentCFI = book.position.cfi;
const currentPage = book.position.currentPage;
calculatePagination(book.cif.spine, book.cif.resources, newSettings)
.then((newPagination) => {
const updatedBook: UniversalReader = {
type: "ebook",
cif: book.cif,
currentSpineIndex: book.position.spineIndex,
currentPage: book.position.currentPage,
pagination: newPagination,
position: {
currentPage: currentPage,
spineIndex: book.position.spineIndex,
localPageIndex: book.position.localPageIndex,
cfi: currentCFI,
progress: currentPage / newPagination.totalPages,
},
};
setState({ currentReader: updatedBook });
const container = document.getElementById("reader-content");
if (container) {
const reflowableNav = require("./formats/reflowable/navigation");
const { success, content: pageContent } = reflowableNav.goToPage(
updatedBook,
updatedBook.position.currentPage,
);
if (success) {
const {
renderPage,
} = require("./formats/reflowable/content-renderer");
renderPage(container, pageContent);
updatePageDisplay(
container,
updatedBook.position.currentPage,
newPagination.totalPages,
);
updateProgressBar(
container,
Math.round(updatedBook.position.progress * 100),
);
}
}
})
.catch((error) => {
console.error("Pagination recalculation failed:", error);
});
}
});
}
function debounce(
func: (...args: any[]) => void,
wait: number,
): (...args: any[]) => void {
let timeout: any;
return function (this: any, ...args: any[]) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
window.addEventListener("resize", debounce(handleResize, 300));
// ============================================================
// Alpine.js Integration
// ============================================================
Alpine.data("readerShell", () => ({
initReader() {
initializeReader();
},
get currentPage() {
const state = getState();
if (!state.currentReader) return 0;
if (state.currentReader.type === "ebook") {
// Use stored computed page if available
if (state.currentReader.currentPage) {
return state.currentReader.currentPage;
}
return state.currentReader.currentSpineIndex + 1;
}
return state.currentReader.currentPage;
},
get totalPages() {
const state = getState();
if (!state.currentReader || !state.readerMetadata) return 0;
if (state.currentReader.type === "ebook") {
// Use pagination if available
if ((state.currentReader as UniversalReader).pagination) {
return (state.currentReader as UniversalReader).pagination!.totalPages;
}
return state.currentReader.cif.spine.length;
} else if (state.currentReader.type === "pdf") {
return state.readerMetadata.total_pages || 0;
} else {
return state.currentReader.images.length;
}
},
}));
-276
View File
@@ -1,276 +0,0 @@
import type { Panel } from "../formats/comic/panel-detector";
import { detectPanels } from "../formats/comic/panel-detection.service";
import type { ReaderContext } from "../core/reader-context";
let currentPanelIndex = 0;
let currentPagePanels: Panel[] = [];
let context: ReaderContext;
export async function init(readerContext: ReaderContext): Promise<void> {
context = readerContext;
setupGestures();
await loadPanelsIfComic();
}
function setupGestures() {
const container = context.elements.readerContent;
if (!container) return;
const state = {
touchStartX: 0,
touchStartY: 0,
touchStartTime: 0,
lastTapTime: 0,
initialPinchDistance: 0,
scale: 1,
};
container.addEventListener(
"touchstart",
(e) => {
if (e.touches.length === 1) {
state.touchStartX = e.touches[0].clientX;
state.touchStartY = e.touches[0].clientY;
state.touchStartTime = Date.now();
} else if (e.touches.length === 2) {
state.initialPinchDistance = getPinchDistance(e.touches);
}
},
{ passive: true },
);
container.addEventListener(
"touchend",
(e) => {
const deltaX = e.changedTouches[0].clientX - state.touchStartX;
const deltaY = e.changedTouches[0].clientY - state.touchStartY;
const deltaTime = Date.now() - state.touchStartTime;
if (Math.abs(deltaX) < 30 && Math.abs(deltaY) < 30 && deltaTime < 300) {
const now = Date.now();
if (now - state.lastTapTime < 300) {
handleDoubleTap();
state.lastTapTime = 0;
} else {
state.lastTapTime = now;
setTimeout(() => {
if (state.lastTapTime !== 0) {
handleTap();
}
}, 300);
}
return;
}
const minSwipeDistance = 50;
const maxSwipeTime = 500;
if (deltaTime > maxSwipeTime) return;
if (Math.abs(deltaX) > Math.abs(deltaY)) {
if (deltaX > minSwipeDistance) {
handleSwipeRight();
} else if (deltaX < -minSwipeDistance) {
handleSwipeLeft();
}
} else {
if (deltaY > minSwipeDistance) {
handleSwipeDown();
} else if (deltaY < -minSwipeDistance) {
handleSwipeUp();
}
}
},
{ passive: true },
);
container.addEventListener(
"touchmove",
(e) => {
if (e.touches.length === 2) {
const currentDistance = getPinchDistance(e.touches);
if (state.initialPinchDistance > 0) {
const scale = currentDistance / state.initialPinchDistance;
handlePinch(scale);
}
}
},
{ passive: true },
);
}
function handleSwipeLeft() {
const metadata = context.getState().readerMetadata;
if (metadata?.library_type_name === "manga") {
navigateWithPanels("next");
} else {
context.navigation.previousPage();
}
}
function handleSwipeRight() {
const metadata = context.getState().readerMetadata;
if (metadata?.library_type_name === "manga") {
navigateWithPanels("previous");
} else {
context.navigation.nextPage();
}
}
function handleSwipeUp() {
const chrome = context.elements.chrome;
if (chrome) chrome.classList.remove("visible");
}
function handleSwipeDown() {
const chrome = context.elements.chrome;
if (chrome) chrome.classList.add("visible");
}
function handleTap() {
const chrome = context.elements.chrome;
if (chrome) chrome.classList.toggle("visible");
}
function handleDoubleTap() {
const container = context.elements.readerContent;
if (container) {
const currentTransform = container.style.transform || "";
const currentScale = currentTransform.match(/scale\(([\d.]+)\)/);
const scale = currentScale ? parseFloat(currentScale[1]) : 1;
const newScale = scale === 1 ? 1.5 : 1;
container.style.transform = `scale(${newScale})`;
container.style.transformOrigin = "center center";
context.events.emit("zoomChanged", newScale);
}
}
function handlePinch(scale: number) {
const container = context.elements.readerContent;
if (container && scale >= 0.5 && scale <= 3) {
container.style.transform = `scale(${scale})`;
container.style.transformOrigin = "center center";
context.events.emit("zoomChanged", scale);
}
}
function getPinchDistance(touches: TouchList): number {
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.sqrt(dx * dx + dy * dy);
}
async function loadPanelsIfComic() {
const state = context.getState();
if (
!state.currentReader ||
(state.currentReader.type !== "comic" &&
state.currentReader.type !== "manga")
) {
return;
}
const reader = state.currentReader;
if (
!reader.images ||
reader.currentPage === undefined ||
reader.currentPage < 0 ||
reader.currentPage >= reader.images.length
) {
currentPagePanels = [];
currentPanelIndex = 0;
return;
}
try {
const imageBlob = reader.images[reader.currentPage];
const imageData = await blobToImageData(imageBlob);
const result = await detectPanels(imageData, false);
currentPagePanels = result.panels;
currentPanelIndex = 0;
} catch (error) {
console.warn("Failed to load panels:", error);
currentPagePanels = [];
currentPanelIndex = 0;
}
}
async function blobToImageData(blob: Blob): Promise<ImageData> {
const img = new Image();
const url = URL.createObjectURL(blob);
return new Promise((resolve, reject) => {
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("Failed to get canvas context"));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
URL.revokeObjectURL(url);
resolve(imageData);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("Failed to load image"));
};
img.src = url;
});
}
function navigateWithPanels(direction: "next" | "previous") {
if (currentPagePanels.length === 0) {
if (direction === "next") {
context.navigation.nextPage();
} else {
context.navigation.previousPage();
}
return;
}
if (
direction === "next" &&
currentPanelIndex < currentPagePanels.length - 1
) {
currentPanelIndex++;
scrollToPanel(currentPanelIndex);
return;
}
if (direction === "previous" && currentPanelIndex > 0) {
currentPanelIndex--;
scrollToPanel(currentPanelIndex);
return;
}
// No more panels, go to next/previous page
if (direction === "next") {
context.navigation.nextPage();
} else {
context.navigation.previousPage();
}
}
function scrollToPanel(panelIndex: number) {
const panel = currentPagePanels[panelIndex];
if (!panel) return;
const container = context.elements.readerContent;
if (!container) return;
const panelElement =
container.querySelector(`[data-panel-id="${panel.id}"]`) ||
container.querySelector(`#${panel.id}`);
if (panelElement) {
panelElement.scrollIntoView({ behavior: "smooth", block: "center" });
document
.querySelectorAll(".panel-current")
.forEach((el) => el.classList.remove("panel-current"));
panelElement.classList.add("panel-current");
}
}
-210
View File
@@ -1,210 +0,0 @@
import type { ReaderContext } from "../core/reader-context";
let context: ReaderContext;
export async function init(readerContext: ReaderContext): Promise<void> {
context = readerContext;
setupKeyboardShortcuts();
}
function setupKeyboardShortcuts() {
const container = context.elements.readerContent;
if (!container) return;
const state = context.getState();
let maxPage = 0;
if (state.currentReader?.type === "ebook") {
maxPage = state.currentReader.cif.spine.length;
} else if (state.currentReader?.type === "pdf") {
maxPage = state.readerMetadata?.total_pages || 0;
} else if (
state.currentReader?.type === "comic" ||
state.currentReader?.type === "manga"
) {
const reader = state.currentReader;
maxPage = reader.images.length;
}
container.addEventListener("keydown", (e) => {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
) {
return;
}
switch (e.key) {
case "ArrowRight":
case "PageDown":
case "l":
e.preventDefault();
context.navigation.nextPage();
break;
case "ArrowLeft":
case "PageUp":
case "h":
e.preventDefault();
context.navigation.previousPage();
break;
case "ArrowUp":
case "k":
e.preventDefault();
context.navigation.previousPage();
break;
case "ArrowDown":
case "j":
e.preventDefault();
context.navigation.nextPage();
break;
case " ":
e.preventDefault();
context.navigation.nextPage();
break;
case "Home":
e.preventDefault();
context.navigation.goToPage(1);
break;
case "End":
e.preventDefault();
context.navigation.goToPage(maxPage);
break;
case "b":
if (!e.ctrlKey && !e.metaKey) {
e.preventDefault();
toggleBookmark();
}
break;
case "+":
case "=":
e.preventDefault();
zoomIn();
break;
case "-":
case "_":
e.preventDefault();
zoomOut();
break;
case "0":
e.preventDefault();
zoomReset();
break;
case "?":
e.preventDefault();
showShortcutHelp();
break;
case "f":
if (!e.ctrlKey && !e.metaKey) {
e.preventDefault();
toggleFullscreen();
}
break;
case "Escape":
e.preventDefault();
exitFullscreen();
break;
default:
if (e.key >= "1" && e.key <= "9") {
const targetPage = Math.floor((parseInt(e.key) / 10) * maxPage);
e.preventDefault();
context.navigation.goToPage(targetPage);
}
}
});
}
function toggleBookmark() {
// TODO: Implement bookmark toggle
console.log("Toggle bookmark");
}
function zoomIn() {
const container = context.elements.readerContent;
if (container) {
const currentTransform = container.style.transform || "";
const currentScale = currentTransform.match(/scale\(([\d.]+)\)/);
const scale = currentScale ? parseFloat(currentScale[1]) : 1;
const newScale = Math.min(scale + 0.25, 3);
container.style.transform = `scale(${newScale})`;
container.style.transformOrigin = "center center";
context.events.emit("zoomChanged", newScale);
}
}
function zoomOut() {
const container = context.elements.readerContent;
if (container) {
const currentTransform = container.style.transform || "";
const currentScale = currentTransform.match(/scale\(([\d.]+)\)/);
const scale = currentScale ? parseFloat(currentScale[1]) : 1;
const newScale = Math.max(scale - 0.25, 0.5);
container.style.transform = `scale(${newScale})`;
container.style.transformOrigin = "center center";
context.events.emit("zoomChanged", newScale);
}
}
function zoomReset() {
const container = context.elements.readerContent;
if (container) {
container.style.transform = "scale(1)";
container.style.transformOrigin = "center center";
context.events.emit("zoomChanged", 1);
}
}
function toggleFullscreen() {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
document.documentElement.requestFullscreen();
}
}
function exitFullscreen() {
if (document.fullscreenElement) {
document.exitFullscreen();
}
}
function showShortcutHelp() {
const help = document.createElement("div");
help.className =
"keyboard-shortcut-help fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-50";
help.innerHTML = `
<div class="bg-gray-800 rounded-lg p-6 max-w-md">
<h2 class="text-xl font-bold mb-4">Keyboard Shortcuts</h2>
<div class="grid grid-cols-2 gap-4 text-sm">
<div><kbd class="bg-gray-700 px-2 py-1 rounded">→</kbd> / <kbd class="bg-gray-700 px-2 py-1 rounded">Space</kbd> Next page</div>
<div><kbd class="bg-gray-700 px-2 py-1 rounded">←</kbd> Previous page</div>
<div><kbd class="bg-gray-700 px-2 py-1 rounded">Home</kbd> First page</div>
<div><kbd class="bg-gray-700 px-2 py-1 rounded">End</kbd> Last page</div>
<div><kbd class="bg-gray-700 px-2 py-1 rounded">+</kbd> / <kbd class="bg-gray-700 px-2 py-1 rounded">-</kbd> Zoom</div>
<div><kbd class="bg-gray-700 px-2 py-1 rounded">B</kbd> Toggle bookmark</div>
<div><kbd class="bg-gray-700 px-2 py-1 rounded">?</kbd> Show help</div>
<div><kbd class="bg-gray-700 px-2 py-1 rounded">Esc</kbd> Exit fullscreen</div>
</div>
<button class="mt-4 px-4 py-2 bg-blue-600 rounded" onclick="this.closest('.keyboard-shortcut-help').remove()">
Close
</button>
</div>
`;
document.body.appendChild(help);
help.addEventListener("click", (e) => {
if (e.target === help) help.remove();
});
}
-139
View File
@@ -1,139 +0,0 @@
// Navigator panel - shows full page with draggable viewport box
// Affinity/Photoshop-style mini-map for page navigation
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
let context: ReaderContext;
export function init(readerContext: ReaderContext): void {
context = readerContext;
let state: NavigatorState | null = null;
context.events.on(
"navigator:initialize",
(detail: { containerSelector: string; totalPages: number }) => {
state = initializeNavigator(detail.containerSelector);
state.totalPages = detail.totalPages;
},
);
context.events.on(
"navigator:update",
(detail: { currentPage: number; contentImage?: HTMLImageElement }) => {
if (state) {
state.currentPage = detail.currentPage;
if (detail.contentImage) {
state.contentImage = detail.contentImage;
updateNavigatorViewport(state);
}
}
},
);
context.events.on("navigator:pan", (detail: { x: number; y: number }) => {
if (state) {
handleNavigatorPan(state, detail.x, detail.y);
}
});
}
interface NavigatorState {
panelId: string;
container: HTMLElement;
viewport: HTMLElement;
currentPage: number;
totalPages: number;
scale: number;
contentImage: HTMLImageElement | null;
isDragging: boolean;
}
function initializeNavigator(containerSelector: string): NavigatorState {
const container = document.querySelector(containerSelector) as HTMLElement;
if (!container) throw new Error("Navigator container not found");
const viewport = document.createElement("div");
viewport.className = "navigator-viewport-box";
viewport.style.cssText = `
position: absolute;
border: 2px solid var(--accent-color, #3b82f6);
background: rgba(59, 130, 246, 0.1);
cursor: move;
z-index: 10;
`;
container.appendChild(viewport);
const state: NavigatorState = {
panelId: "navigator",
container,
viewport,
currentPage: 1,
totalPages: 1,
scale: 0.1,
contentImage: null,
isDragging: false,
};
setupNavigatorDragHandler(state);
return state;
}
function setupNavigatorDragHandler(state: NavigatorState): void {
state.viewport.addEventListener("mousedown", (e) => {
e.preventDefault();
state.isDragging = true;
state.viewport.style.cursor = "grabbing";
});
document.addEventListener("mousemove", (e) => {
if (!state.isDragging || !state.contentImage) return;
const imgRect = state.contentImage.getBoundingClientRect();
const relX = (e.clientX - imgRect.left) / imgRect.width;
const relY = (e.clientY - imgRect.top) / imgRect.height;
const mainViewer = document.getElementById("reader-content");
if (mainViewer) {
mainViewer.dispatchEvent(
new CustomEvent("navigator-pan", {
detail: { x: relX, y: relY },
}),
);
}
});
document.addEventListener("mouseup", () => {
if (state.isDragging) {
state.isDragging = false;
state.viewport.style.cursor = "move";
}
});
}
function updateNavigatorViewport(state: NavigatorState): void {
if (!state.contentImage) return;
const containerRect = state.container.getBoundingClientRect();
const imgRect = state.contentImage.getBoundingClientRect();
state.scale = containerRect.width / imgRect.width;
state.viewport.style.width = `${containerRect.width}px`;
state.viewport.style.height = `${containerRect.height * state.scale}px`;
state.viewport.style.left = "0";
state.viewport.style.top = `${(state.currentPage - 1) * imgRect.height * state.scale}px`;
}
function handleNavigatorPan(state: NavigatorState, x: number, y: number): void {
if (!state.contentImage) return;
const viewportX = x * state.container.offsetWidth;
const viewportY = y * state.container.offsetHeight;
state.viewport.style.left = `${viewportX}px`;
state.viewport.style.top = `${viewportY}px`;
}
-52
View File
@@ -1,52 +0,0 @@
// Offline manager for PWA functionality
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
let context: ReaderContext;
export function init(readerContext: ReaderContext): void {
context = readerContext;
registerServiceWorker();
window.addEventListener("online", () => {
const isOnline = checkOnlineStatus();
if (isOnline) {
context.events.emit("offline:online", {});
syncPendingChanges(context);
}
});
window.addEventListener("offline", () => {
context.events.emit("offline:offline", {});
});
context.events.on("offline:check", () => {
const isOnline = checkOnlineStatus();
context.events.emit("offline:status", { isOnline });
});
}
export function registerServiceWorker(): void {
if ("serviceWorker" in navigator) {
navigator.serviceWorker
.register("/static/service-worker.js")
.then((registration) => {
console.log("Service worker registered:", registration);
})
.catch((error) => {
console.error("Service worker registration failed:", error);
});
}
}
export function checkOnlineStatus(): boolean {
if (typeof navigator !== "undefined" && navigator.onLine) {
return true;
}
return false;
}
function syncPendingChanges(context: ReaderContext): void {
context.events.emit("offline:sync", {});
}
-73
View File
@@ -1,73 +0,0 @@
// Display page info (Page X of Y)
export function updatePageDisplay(
container: HTMLElement,
currentPage: number,
totalPages: number,
): void {
const existing = container.querySelector(".page-display");
existing?.remove();
const display = document.createElement("div");
display.className = "page-display";
display.textContent = `Page ${currentPage} of ${totalPages}`;
display.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: var(--bg-secondary);
color: var(--text-primary);
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
z-index: 100;
`;
container.appendChild(display);
}
// Remove page display
export function removePageDisplay(container: HTMLElement): void {
const existing = container.querySelector(".page-display");
existing?.remove();
}
// Update progress bar
export function updateProgressBar(
container: HTMLElement,
percentage: number,
): void {
let bar = container.querySelector(".progress-bar-fill") as HTMLElement;
if (!bar) {
const wrapper = document.createElement("div");
wrapper.className = "progress-bar";
wrapper.style.cssText = `
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: var(--bg-secondary);
z-index: 100;
`;
bar = document.createElement("div");
bar.className = "progress-bar-fill";
bar.style.cssText = `
height: 100%;
background: var(--accent);
transition: width 0.3s ease;
`;
wrapper.appendChild(bar);
container.appendChild(wrapper);
}
bar.style.width = `${percentage}%`;
}
// Remove progress bar
export function removeProgressBar(container: HTMLElement): void {
const existing = container.querySelector(".progress-bar");
existing?.remove();
}
-216
View File
@@ -1,216 +0,0 @@
// Modular dockable panel system - handles drag, lock, snap-back, window-shade
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
let context: ReaderContext;
export function init(readerContext: ReaderContext): void {
context = readerContext;
const dockZones: DockZone[] = [
{ side: "left", x: 0, width: 400, height: window.innerHeight },
{
side: "right",
x: window.innerWidth - 400,
width: 400,
height: window.innerHeight,
},
];
const state: PanelDockState = {
panels: new Map(),
dragState: null,
dockZones,
};
context.events.on("panels:initialize", async () => {
const settings = await loadSettings();
for (const [panelId, panelState] of Object.entries(settings.panel_layout)) {
state.panels.set(panelId, panelState as PanelState);
createPanel(context, panelId, panelState as PanelState);
}
setupGlobalDragHandlers(context, state);
});
context.events.on("panel:toggle", (detail: { panelId: string }) => {
const panelState = state.panels.get(detail.panelId);
if (panelState) {
panelState.visible = !panelState.visible;
updatePanelVisibility(detail.panelId, panelState.visible);
}
});
context.events.on("panel:collapse", (detail: { panelId: string }) => {
const panelState = state.panels.get(detail.panelId);
if (panelState) {
panelState.collapsed = !panelState.collapsed;
updatePanelCollapsed(detail.panelId, panelState.collapsed);
}
});
context.events.on(
"panel:move",
(detail: { panelId: string; side: "left" | "right" }) => {
movePanelToSide(context, state, detail.panelId, detail.side);
},
);
}
interface PanelDockState {
panels: Map<string, PanelState>;
dragState: DragState | null;
dockZones: DockZone[];
}
interface DragState {
panelId: string;
startX: number;
startY: number;
currentX: number;
currentY: number;
isLocked: boolean;
}
interface DockZone {
side: "left" | "right";
x: number;
width: number;
height: number;
}
interface PanelState {
side: "left" | "right";
visible: boolean;
collapsed: boolean;
width_px: number;
order: number;
locked: boolean;
last_valid_side: "left" | "right";
}
async function loadSettings(): Promise<any> {
const response = await fetch("/readers/settings");
if (!response.ok) return {};
return await response.json();
}
function createPanel(
context: ReaderContext,
panelId: string,
panelState: PanelState,
): void {
const panel = document.createElement("div");
panel.id = `panel-${panelId}`;
panel.className = `reader-panel panel-${panelState.side}`;
panel.dataset.panelId = panelId;
panel.style.width = `${panelState.width_px}px`;
if (!panelState.visible) {
panel.classList.add("panel-hidden");
}
if (panelState.collapsed) {
panel.classList.add("panel-collapsed");
}
context.elements.readerContent.appendChild(panel);
}
function updatePanelVisibility(panelId: string, visible: boolean): void {
const panel = document.getElementById(`panel-${panelId}`);
if (panel) {
panel.classList.toggle("panel-hidden", !visible);
}
}
function updatePanelCollapsed(panelId: string, collapsed: boolean): void {
const panel = document.getElementById(`panel-${panelId}`);
if (panel) {
panel.classList.toggle("panel-collapsed", collapsed);
}
}
function setupGlobalDragHandlers(
context: ReaderContext,
state: PanelDockState,
): void {
document.addEventListener("mousedown", (e) => {
const panelHeader = (e.target as HTMLElement).closest(".panel-header");
if (panelHeader) {
const panelId = (panelHeader as HTMLElement).dataset.panelId;
if (panelId) {
const panelState = state.panels.get(panelId);
if (panelState && !panelState.locked) {
state.dragState = {
panelId,
startX: e.clientX,
startY: e.clientY,
currentX: e.clientX,
currentY: e.clientY,
isLocked: false,
};
}
}
}
});
document.addEventListener("mousemove", (e) => {
if (!state.dragState) return;
state.dragState.currentX = e.clientX;
state.dragState.currentY = e.clientY;
});
document.addEventListener("mouseup", () => {
if (state.dragState) {
const nearestZone = findNearestDockZone(
state.dragState.currentX,
state.dockZones,
);
if (nearestZone) {
movePanelToSide(
context,
state,
state.dragState.panelId,
nearestZone.side,
);
}
state.dragState = null;
}
});
}
function findNearestDockZone(x: number, zones: DockZone[]): DockZone | null {
let nearest: DockZone | null = null;
let minDistance = Infinity;
for (const zone of zones) {
const distance = Math.abs(x - (zone.x + zone.width / 2));
if (distance < minDistance) {
minDistance = distance;
nearest = zone;
}
}
return nearest;
}
function movePanelToSide(
context: ReaderContext,
state: PanelDockState,
panelId: string,
side: "left" | "right",
): void {
const panelState = state.panels.get(panelId);
if (!panelState) return;
panelState.side = side;
panelState.last_valid_side = side;
const panel = document.getElementById(`panel-${panelId}`);
if (panel) {
panel.classList.remove("panel-left", "panel-right");
panel.classList.add(`panel-${side}`);
}
context.events.emit("panel:moved", { panelId, side });
}
-175
View File
@@ -1,175 +0,0 @@
import type { ReaderContext } from "../core/reader-context";
interface ProgressDisplay {
mode: "pages" | "chapter" | "percentage" | "time-left";
text: string;
}
let context: ReaderContext;
export async function init(readerContext: ReaderContext): Promise<void> {
context = readerContext;
context.events.on("pageChanged", () => {
updateProgressDisplay(context);
});
context.events.on("progressUpdated", () => {
updateProgressDisplay(context);
});
context.events.on("readerReady", () => {
updateProgressDisplay(context);
});
setupProgressModeCycling(context);
}
export { calculateProgress, cycleProgressMode };
export type { ProgressDisplay };
function calculateProgress(
currentPage: number,
totalPages: number,
currentChapterPage: number,
chapterPages: number,
readingSpeed?: ReadingSpeed,
): ProgressDisplay {
const mode = getCurrentProgressMode();
switch (mode) {
case "pages":
return {
mode: "pages",
text: `${currentPage}/${totalPages}`,
};
case "chapter":
return {
mode: "chapter",
text: `${currentChapterPage}/${chapterPages}`,
};
case "percentage":
const percentage = Math.round((currentPage / totalPages) * 100);
return {
mode: "percentage",
text: `${percentage}%`,
};
case "time-left":
if (!readingSpeed || readingSpeed.pages_per_minute === 0) {
return { mode: "time-left", text: "--:--" };
}
const pagesLeft = totalPages - currentPage;
const minutesLeft = pagesLeft / readingSpeed.pages_per_minute;
const hours = Math.floor(minutesLeft / 60);
const mins = Math.round(minutesLeft % 60);
return {
mode: "time-left",
text: `${hours}h ${mins}m`,
};
default:
return { mode: "pages", text: `${currentPage}/${totalPages}` };
}
}
function cycleProgressMode(): void {
const modes: Array<"pages" | "chapter" | "percentage" | "time-left"> = [
"pages",
"chapter",
"percentage",
"time-left",
];
const currentMode = getCurrentProgressMode();
const currentIndex = modes.indexOf(currentMode);
const nextMode = modes[(currentIndex + 1) % modes.length];
setProgressMode(nextMode);
}
function getCurrentProgressMode(): ReaderSettings["progress_mode"] {
const settingsStr = localStorage.getItem("reader_settings_progress_mode");
if (settingsStr) {
try {
return JSON.parse(settingsStr);
} catch {
return "pages";
}
}
return "pages";
}
function setProgressMode(mode: ReaderSettings["progress_mode"]): void {
localStorage.setItem("reader_settings_progress_mode", JSON.stringify(mode));
const display = document.getElementById("progress-display");
if (display) {
display.dataset.mode = mode;
}
}
async function getReadingSpeed(): Promise<ReadingSpeed | null> {
const speedStr = localStorage.getItem("reader_speed_data");
if (speedStr) {
try {
return JSON.parse(speedStr);
} catch {
return null;
}
}
return null;
}
function updateProgressDisplay(context: ReaderContext): void {
const state = context.getState();
const display = context.elements.progressDisplay;
if (!display || !state.currentReader) return;
let currentPage = 0;
let totalPages = 0;
let currentChapterPage = 0;
let chapterPages = 0;
if (state.currentReader.type === "ebook") {
const reader = state.currentReader;
// Use currentPage directly if available (page-based navigation)
if (reader.currentPage) {
currentPage = reader.currentPage;
} else if (reader.currentSpineIndex !== undefined) {
currentPage = reader.currentSpineIndex + 1;
}
// Get total pages from calculation result
const pageInfo = reader.pagination;
if (pageInfo && pageInfo.totalPages > 0) {
totalPages = pageInfo.totalPages;
// Get chapter progress from spine info
const spine = pageInfo.spineMap.get(reader.currentSpineIndex);
if (spine && spine.pages.length > 0) {
const chapterStartPage = spine.pages[0].pageIndex;
currentChapterPage = currentPage - chapterStartPage + 1;
chapterPages = spine.pages.length;
}
} else {
// Fallback
totalPages =
state.readerMetadata?.total_pages ||
state.currentReader.cif.locations?.estimatedPages ||
state.currentReader.cif.spine.length;
}
} else if (state.currentReader.type === "pdf") {
currentPage = state.currentReader.currentPage;
totalPages = state.readerMetadata?.total_pages || 0;
} else {
// Comic/manga
const reader = state.currentReader;
if (reader.type === "comic" || reader.type === "manga") {
currentPage = reader.currentPage + 1;
totalPages = reader.images?.length || 0;
}
}
getReadingSpeed().then((speed) => {
const result = calculateProgress(
currentPage,
totalPages,
currentChapterPage,
chapterPages,
speed || undefined,
);
display.textContent = result.text;
display.dataset.mode = result.mode;
});
}
function setupProgressModeCycling(context: ReaderContext): void {
const display = context.elements.progressDisplay;
if (!display) return;
display.style.cursor = "pointer";
display.addEventListener("click", () => {
cycleProgressMode();
updateProgressDisplay(context);
});
}
-128
View File
@@ -1,128 +0,0 @@
// Track reading speed and update database
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
let context: ReaderContext;
export function init(readerContext: ReaderContext): void {
context = readerContext;
let state: ReadingSpeedTrackerState | null = null;
context.events.on("reader:loaded", (detail: { mediaItemId: string }) => {
state = createReadingSpeedTracker(detail.mediaItemId);
});
context.events.on("reading-session:start", () => {
if (state) {
startReadingSession(state);
}
});
context.events.on("page-changed", () => {
if (state) {
recordPageTurn(state);
}
});
context.events.on("words-read", (detail: { wordCount: number }) => {
if (state) {
recordWordsRead(state, detail.wordCount);
}
});
context.events.on("reading-session:end", async () => {
if (state) {
await syncReadingSpeed(state);
}
});
context.events.on("reader:unload", async () => {
if (state) {
await syncReadingSpeed(state);
state = null;
}
});
}
interface ReadingSpeedTrackerState {
startTime: number | null;
pagesRead: number;
wordsRead: number;
lastSync: number;
mediaItemId: string;
}
function createReadingSpeedTracker(
mediaItemId: string,
): ReadingSpeedTrackerState {
return {
startTime: null,
pagesRead: 0,
wordsRead: 0,
lastSync: Date.now(),
mediaItemId,
};
}
function startReadingSession(
state: ReadingSpeedTrackerState,
): ReadingSpeedTrackerState {
state.startTime = Date.now();
state.pagesRead = 0;
state.wordsRead = 0;
return state;
}
function recordPageTurn(
state: ReadingSpeedTrackerState,
): ReadingSpeedTrackerState {
if (!state.startTime) return state;
state.pagesRead += 1;
const now = Date.now();
if (state.pagesRead % 5 === 0 || now - state.lastSync > 5 * 60 * 1000) {
syncReadingSpeed(state);
state.lastSync = now;
}
return state;
}
function recordWordsRead(
state: ReadingSpeedTrackerState,
wordCount: number,
): ReadingSpeedTrackerState {
state.wordsRead += wordCount;
return state;
}
async function syncReadingSpeed(
state: ReadingSpeedTrackerState,
): Promise<void> {
if (!state.startTime) return;
const minutesElapsed = (Date.now() - state.startTime) / (1000 * 60);
const pagesPerMinute = state.pagesRead / minutesElapsed;
const wordsPerMinute = state.wordsRead / minutesElapsed;
try {
const token = localStorage.getItem("token");
await fetch(`/readers/${state.mediaItemId}/reading-speed`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
pages_per_minute: pagesPerMinute,
words_per_minute: wordsPerMinute,
pages_read: state.pagesRead,
total_reading_minutes: minutesElapsed,
}),
});
} catch (error) {
console.error("Failed to sync reading speed:", error);
}
}