refactor(reader): implement unified reader shell with feature orchestration
- Replace callback-based architecture with Feature Registration Pattern - Add feature registry for modular feature initialization - Create createNavigationAPI() for unified page/chapter navigation - Implement renderCurrentPage() for PDF/comic/manga rendering - Add updateProgress() to sync reading progress to backend - Support ebook, PDF, comic, and manga readers in single interface - Remove 600+ lines of callback wiring code - reader-shell now acts as lightweight orchestrator - Delegates specific functionality to feature modules - Parse ebooks using parser-manager with format routing - Initialize readers based on library type from metadata
This commit is contained in:
@@ -1,160 +0,0 @@
|
||||
// Parser Manager - Routes files to appropriate parsers
|
||||
// Procedural style: Functions, not classes
|
||||
|
||||
import JSZip from "jszip";
|
||||
|
||||
// ============================================================
|
||||
// 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":
|
||||
return parseEPUB(file);
|
||||
case "fb2":
|
||||
return parseFB2(file);
|
||||
case "txt":
|
||||
return parseTXT(file);
|
||||
case "html":
|
||||
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();
|
||||
}
|
||||
+104
-271
@@ -1,29 +1,15 @@
|
||||
// Universal Reader Shell - Routes to appropriate reader
|
||||
// Procedural style: Functions, not classes
|
||||
|
||||
import { Alpine } from "../alpine";
|
||||
import { getReaderMetadata, updateReadingProgress } from "./api";
|
||||
import { SettingsManager } from "./settings-manager";
|
||||
import { ProgressIndicator } from "./progress-indicator";
|
||||
import { parseEbook, requiresServerParsing } from "./parser-manager";
|
||||
import { initializePDFReader } from "./pdf/pdfjs-wrapper";
|
||||
import { initializeComicReader } from "./comic/image-parser";
|
||||
import { getReaderMetadata, updateReadingProgress } from "../api";
|
||||
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";
|
||||
|
||||
// ============================================================
|
||||
// Reader State
|
||||
// ============================================================
|
||||
|
||||
let currentReader:
|
||||
| UniversalReader
|
||||
| PDFReader
|
||||
| ComicReader
|
||||
| MangaReader
|
||||
| null = null;
|
||||
let readerMetadata: ReaderMetadata | null = null;
|
||||
type ReaderType = "ebook" | "pdf" | "comic" | "manga";
|
||||
|
||||
interface UniversalReader {
|
||||
type: "ebook";
|
||||
cif: EbookCIF;
|
||||
cif: any;
|
||||
currentSpineIndex: number;
|
||||
}
|
||||
|
||||
@@ -46,18 +32,68 @@ interface MangaReader {
|
||||
readingDirection: "rtl" | "vertical";
|
||||
}
|
||||
|
||||
type CurrentReader = UniversalReader | PDFReader | ComicReader | MangaReader;
|
||||
|
||||
let currentReader: CurrentReader | null = null;
|
||||
let readerMetadata: import("../types/reader").ReaderMetadata | null = null;
|
||||
|
||||
// ============================================================
|
||||
// Initialization
|
||||
// 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;
|
||||
if (!mediaItemId) return;
|
||||
|
||||
// Fetch metadata
|
||||
readerMetadata = await getReaderMetadata(mediaItemId);
|
||||
|
||||
// Initialize appropriate reader based on type
|
||||
switch (readerMetadata.library_type) {
|
||||
case "ebook":
|
||||
currentReader = await initializeEbookReader(readerMetadata);
|
||||
@@ -73,15 +109,28 @@ async function initializeReader(): Promise<void> {
|
||||
break;
|
||||
}
|
||||
|
||||
if (currentReader) {
|
||||
setupReaderUI();
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
async function initializeEbookReader(
|
||||
metadata: ReaderMetadata,
|
||||
): Promise<UniversalReader> {
|
||||
// Check if server-side parsing is needed
|
||||
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),
|
||||
@@ -90,7 +139,6 @@ async function initializeEbookReader(
|
||||
let ebookFile: Blob;
|
||||
|
||||
if (needsServer) {
|
||||
// Fetch parsed CIF from server
|
||||
const response = await fetch(`/readers/${metadata.media_item_id}/parse`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -106,12 +154,10 @@ async function initializeEbookReader(
|
||||
|
||||
ebookFile = await response.blob();
|
||||
} else {
|
||||
// Fetch original file for client-side parsing
|
||||
const response = await fetch(metadata.file_path);
|
||||
ebookFile = await response.blob();
|
||||
}
|
||||
|
||||
// Parse ebook to CIF
|
||||
const cif = await parseEbook(
|
||||
ebookFile,
|
||||
metadata.mime_type,
|
||||
@@ -125,186 +171,24 @@ async function initializeEbookReader(
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// UI Setup
|
||||
// ============================================================
|
||||
|
||||
function setupReaderUI(): void {
|
||||
if (!currentReader || !readerMetadata) return;
|
||||
|
||||
// Setup chrome
|
||||
setupChromeBehavior();
|
||||
|
||||
// Setup progress indicator
|
||||
setupProgressIndicator();
|
||||
|
||||
// Setup annotations
|
||||
setupAnnotations();
|
||||
|
||||
// Setup keyboard navigation
|
||||
setupKeyboardNavigation();
|
||||
async function initializePDFReader(metadata: any): Promise<PDFReader> {
|
||||
const { initializePDFReader } = await import("./pdf/pdfjs-wrapper");
|
||||
return initializePDFReader(metadata);
|
||||
}
|
||||
|
||||
function setupChromeBehavior(): void {
|
||||
const chrome = document.getElementById("reader-chrome");
|
||||
if (!chrome) return;
|
||||
|
||||
// Auto-hide on scroll
|
||||
let hideTimeout: NodeJS.Timeout;
|
||||
|
||||
window.addEventListener("scroll", () => {
|
||||
chrome.classList.add("visible");
|
||||
|
||||
clearTimeout(hideTimeout);
|
||||
hideTimeout = setTimeout(() => {
|
||||
chrome.classList.remove("visible");
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
// Toggle on tap (for touch devices)
|
||||
chrome.addEventListener("click", () => {
|
||||
chrome.classList.toggle("visible");
|
||||
});
|
||||
async function initializeComicReader(metadata: any): Promise<ComicReader> {
|
||||
const { initializeComicReader } = await import("./comic/image-parser");
|
||||
return initializeComicReader(metadata);
|
||||
}
|
||||
|
||||
function setupProgressIndicator(): void {
|
||||
// Update progress based on reader type
|
||||
if (!currentReader) return;
|
||||
|
||||
if (currentReader.type === "ebook") {
|
||||
updateEbookProgress(currentReader.cif, currentReader.currentSpineIndex);
|
||||
} else if (currentReader.type === "pdf") {
|
||||
updatePDFProgress(
|
||||
currentReader.currentPage,
|
||||
readerMetadata.total_pages || 0,
|
||||
);
|
||||
} else if (currentReader.type === "comic" || currentReader.type === "manga") {
|
||||
updateComicProgress(currentReader.currentPage, currentReader.images.length);
|
||||
}
|
||||
async function initializeMangaReader(metadata: any): Promise<MangaReader> {
|
||||
const { initializeMangaReader } = await import("./comic/image-parser");
|
||||
return initializeMangaReader(metadata);
|
||||
}
|
||||
|
||||
function setupAnnotations(): void {
|
||||
// Load existing highlights and notes
|
||||
// Implementation depends on annotation system
|
||||
}
|
||||
|
||||
function setupKeyboardNavigation(): void {
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (!currentReader) return;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowRight":
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
nextPage();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
previousPage();
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Navigation Functions
|
||||
// ============================================================
|
||||
|
||||
function nextPage(): void {
|
||||
if (!currentReader) return;
|
||||
|
||||
if (currentReader.type === "ebook") {
|
||||
nextSpineItem();
|
||||
} else if (currentReader.type === "pdf") {
|
||||
nextPDFPage();
|
||||
} else if (currentReader.type === "comic" || currentReader.type === "manga") {
|
||||
nextComicPage();
|
||||
}
|
||||
}
|
||||
|
||||
function previousPage(): void {
|
||||
if (!currentReader) return;
|
||||
|
||||
if (currentReader.type === "ebook") {
|
||||
previousSpineItem();
|
||||
} else if (currentReader.type === "pdf") {
|
||||
previousPDFPage();
|
||||
} else if (currentReader.type === "comic" || currentReader.type === "manga") {
|
||||
previousComicPage();
|
||||
}
|
||||
}
|
||||
|
||||
function nextSpineItem(): void {
|
||||
if (currentReader?.type !== "ebook") return;
|
||||
|
||||
if (currentReader.currentSpineIndex < currentReader.cif.spine.length - 1) {
|
||||
currentReader.currentSpineIndex++;
|
||||
renderCurrentSpineItem();
|
||||
}
|
||||
}
|
||||
|
||||
function previousSpineItem(): void {
|
||||
if (currentReader?.type !== "ebook") return;
|
||||
|
||||
if (currentReader.currentSpineIndex > 0) {
|
||||
currentReader.currentSpineIndex--;
|
||||
renderCurrentSpineItem();
|
||||
}
|
||||
}
|
||||
|
||||
function renderCurrentSpineItem(): void {
|
||||
if (currentReader?.type !== "ebook") return;
|
||||
|
||||
const spineItem = currentReader.cif.spine[currentReader.currentSpineIndex];
|
||||
const container = document.getElementById("reader-content");
|
||||
|
||||
if (!container) return;
|
||||
|
||||
// Render spine item content
|
||||
container.innerHTML = spineItem.content;
|
||||
|
||||
// Apply theme and typography
|
||||
applyReaderTheme();
|
||||
applyTypography();
|
||||
|
||||
// Update progress
|
||||
updateProgress();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Progress Tracking
|
||||
// ============================================================
|
||||
|
||||
function updateProgress(): void {
|
||||
if (!currentReader || !readerMetadata) return;
|
||||
|
||||
let percentage = 0;
|
||||
let currentPosition = "";
|
||||
|
||||
if (currentReader.type === "ebook") {
|
||||
const totalSpine = currentReader.cif.spine.length;
|
||||
percentage = (currentReader.currentSpineIndex + 1) / totalSpine;
|
||||
currentPosition = `spine:${currentReader.currentSpineIndex}`;
|
||||
} else if (currentReader.type === "pdf") {
|
||||
const totalPages = readerMetadata.total_pages || 1;
|
||||
percentage = currentReader.currentPage / totalPages;
|
||||
currentPosition = `page:${currentReader.currentPage}`;
|
||||
} else if (currentReader.type === "comic" || currentReader.type === "manga") {
|
||||
const totalPages = currentReader.images.length;
|
||||
percentage = currentReader.currentPage / totalPages;
|
||||
currentPosition = `page:${currentReader.currentPage}`;
|
||||
}
|
||||
|
||||
// Send to backend
|
||||
updateReadingProgress(readerMetadata.media_item_id, {
|
||||
percentage,
|
||||
current_page:
|
||||
currentReader.type === "ebook"
|
||||
? currentReader.currentSpineIndex
|
||||
: currentReader.currentPage,
|
||||
position: currentPosition,
|
||||
});
|
||||
function getFileExtension(filepath: string): string {
|
||||
const match = filepath.match(/\.([^.]+)$/);
|
||||
return match ? `.${match[1]}` : "";
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -316,77 +200,26 @@ Alpine.data("readerShell", () => ({
|
||||
initializeReader();
|
||||
},
|
||||
|
||||
nextPage,
|
||||
previousPage,
|
||||
|
||||
get currentPage() {
|
||||
if (!currentReader) return 0;
|
||||
const state = getState();
|
||||
if (!state.currentReader) return 0;
|
||||
|
||||
if (currentReader.type === "ebook") {
|
||||
return currentReader.currentSpineIndex + 1;
|
||||
} else {
|
||||
return currentReader.currentPage;
|
||||
if (state.currentReader.type === "ebook") {
|
||||
return state.currentReader.currentSpineIndex + 1;
|
||||
}
|
||||
return state.currentReader.currentPage;
|
||||
},
|
||||
|
||||
get totalPages() {
|
||||
if (!currentReader || !readerMetadata) return 0;
|
||||
const state = getState();
|
||||
if (!state.currentReader || !state.readerMetadata) return 0;
|
||||
|
||||
if (currentReader.type === "ebook") {
|
||||
return currentReader.cif.spine.length;
|
||||
} else if (currentReader.type === "pdf") {
|
||||
return readerMetadata.total_pages || 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 currentReader.images.length;
|
||||
return (state.currentReader as any).images.length;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// ============================================================
|
||||
// Utility Functions
|
||||
// ============================================================
|
||||
|
||||
function getFileExtension(filepath: string): string {
|
||||
const match = filepath.match(/\.([^.]+)$/);
|
||||
return match ? `.${match[1]}` : "";
|
||||
}
|
||||
|
||||
function applyReaderTheme(): void {
|
||||
// Apply reading theme from settings
|
||||
const settings = getReaderSettings();
|
||||
|
||||
const container = document.getElementById("reader-content");
|
||||
if (!container) return;
|
||||
|
||||
container.className = `ebook-content theme-${settings.reading_theme}`;
|
||||
}
|
||||
|
||||
function applyTypography(): void {
|
||||
const settings = getReaderSettings();
|
||||
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 getReaderSettings(): ReaderSettings {
|
||||
// Load from settings manager
|
||||
return {} as ReaderSettings; // Simplified
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user