Files
bookhoard/web/src/reader/reader-shell.ts
T
john-okeefe be7ff3f1c9 reader: implement Feature Registration Pattern infrastructure
- Add Feature Registry system in reader-shell.ts to track features
- Update format detection to use format_group with switch statement
- Add manga detection via library_type_name or manga_type fields
- Import and register features via init() functions on load
- Fix parser imports in parser-manager.ts (add missing function imports)
- Simplify epub-parsers and fb2-parser by removing unused imports
2026-04-04 18:17:20 -04:00

246 lines
7.4 KiB
TypeScript

import { Alpine } from "../alpine";
import { getReaderMetadata } from "../api";
import "./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";
interface UniversalReader {
type: "ebook";
cif: any;
currentSpineIndex: number;
}
interface PDFReader {
type: "pdf";
doc: any;
currentPage: number;
}
interface ComicReader {
type: "comic";
images: Blob[];
currentPage: number;
}
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("./features/gestures").then((m) => m.init(ctx)),
(ctx) => import("./features/keyboard-shortcuts").then((m) => m.init(ctx)),
(ctx) => import("./features/panel-dock-system").then((m) => m.init(ctx)),
(ctx) => import("./features/navigator-panel").then((m) => m.init(ctx)),
(ctx) => import("./features/offline-manager").then((m) => m.init(ctx)),
(ctx) => import("./features/reading-speed-tracker").then((m) => m.init(ctx)),
(ctx) => import("./features/progress-indicator").then((m) => m.init(ctx)),
// Comic features
(ctx) => import("./comic/background-color").then((m) => m.init?.(ctx)),
(ctx) => import("./comic/chapter-markers").then((m) => m.init?.(ctx)),
(ctx) => import("./comic/page-cache").then((m) => m.init?.(ctx)),
// PDF features
(ctx) => import("./pdf/pdf-navigation").then((m) => m.init?.(ctx)),
(ctx) => import("./pdf/pdf-text-selection").then((m) => m.init?.(ctx)),
(ctx) => import("./pdf/annotation-layer").then((m) => m.init?.(ctx)),
// Ebook features
(ctx) => import("./ebook/copy-handler").then((m) => m.init?.(ctx)),
(ctx) => import("./ebook/font-loader").then((m) => m.init?.(ctx)),
// Manga features
(ctx) => import("./manga/reading-direction").then((m) => m.init?.(ctx)),
(ctx) => import("./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);
} catch (error) {
console.error("Render initialization failed:", error);
}
}
async function initializeEbookReader(metadata: any): 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.media_item_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),
);
return {
type: "ebook",
cif,
currentSpineIndex: 0,
};
}
async function initializePDFReader(metadata: any): Promise<PDFReader> {
const { initializePDFReader } = await import("./pdf/pdfjs-wrapper");
return initializePDFReader(metadata);
}
async function initializeComicReader(metadata: any): Promise<ComicReader> {
const { initializeComicReader } = await import("./comic/image-parser");
return initializeComicReader(metadata);
}
async function initializeMangaReader(metadata: any): Promise<MangaReader> {
const { initializeMangaReader } = await import("./comic/image-parser");
return initializeMangaReader(metadata);
}
function getFileExtension(filepath: string): string {
const match = filepath.match(/\.([^.]+)$/);
return match ? `.${match[1]}` : "";
}
// ============================================================
// Alpine.js Integration
// ============================================================
Alpine.data("readerShell", () => ({
initReader() {
initializeReader();
},
get currentPage() {
const state = getState();
if (!state.currentReader) return 0;
if (state.currentReader.type === "ebook") {
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") {
return state.currentReader.cif.spine.length;
} else if (state.currentReader.type === "pdf") {
return state.readerMetadata.total_pages || 0;
} else {
return (state.currentReader as any).images.length;
}
},
}));