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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user