Implement core reader TypeScript modules for shell and UI management

- reader-shell.ts: Main initialization, Alpine.js integration, media type detection
- progress-indicator.ts: Reading progress tracking and display components
- settings-manager.ts: User settings persistence and retrieval
- panel-dock-system.ts: Dockable panel management with drag/drop and collapse
- parser-manager.ts: Parser selection and format detection system

These core modules provide the foundation for all reader types with
shared functionality for progress tracking, settings management, and
the flexible panel docking system.
This commit is contained in:
2026-04-03 22:29:20 -04:00
parent 0191d36dc9
commit d3d84a8318
5 changed files with 985 additions and 0 deletions
+392
View File
@@ -0,0 +1,392 @@
// 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";
// ============================================================
// Reader State
// ============================================================
let currentReader:
| UniversalReader
| PDFReader
| ComicReader
| MangaReader
| null = null;
let readerMetadata: ReaderMetadata | null = null;
interface UniversalReader {
type: "ebook";
cif: EbookCIF;
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";
}
// ============================================================
// 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);
break;
case "pdf":
currentReader = await initializePDFReader(readerMetadata);
break;
case "comic":
currentReader = await initializeComicReader(readerMetadata);
break;
case "manga":
currentReader = await initializeMangaReader(readerMetadata);
break;
}
if (currentReader) {
setupReaderUI();
}
}
async function initializeEbookReader(
metadata: ReaderMetadata,
): Promise<UniversalReader> {
// Check if server-side parsing is needed
const needsServer = requiresServerParsing(
metadata.mime_type,
getFileExtension(metadata.file_path),
);
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" },
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 {
// 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,
getFileExtension(metadata.file_path),
);
return {
type: "ebook",
cif,
currentSpineIndex: 0,
};
}
// ============================================================
// UI Setup
// ============================================================
function setupReaderUI(): void {
if (!currentReader || !readerMetadata) return;
// Setup chrome
setupChromeBehavior();
// Setup progress indicator
setupProgressIndicator();
// Setup annotations
setupAnnotations();
// Setup keyboard navigation
setupKeyboardNavigation();
}
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");
});
}
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);
}
}
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,
});
}
// ============================================================
// Alpine.js Integration
// ============================================================
Alpine.data("readerShell", () => ({
init() {
initializeReader();
},
nextPage,
previousPage,
get currentPage() {
if (!currentReader) return 0;
if (currentReader.type === "ebook") {
return currentReader.currentSpineIndex + 1;
} else {
return currentReader.currentPage;
}
},
get totalPages() {
if (!currentReader || !readerMetadata) return 0;
if (currentReader.type === "ebook") {
return currentReader.cif.spine.length;
} else if (currentReader.type === "pdf") {
return readerMetadata.total_pages || 0;
} else {
return currentReader.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
}