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
+247
View File
@@ -0,0 +1,247 @@
// Modular dockable panel system - handles drag, lock, snap-back, window-shade
// Procedural style: Functions, not classes
import {
saveSettings,
loadSettings,
getDefaultSettings,
} from "./settings-manager";
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;
}
const state: PanelDockState = {
panels: new Map(),
dragState: null,
dockZones: [
{ side: "left", x: 0, width: 400, height: window.innerHeight },
{
side: "right",
x: window.innerWidth - 400,
width: 400,
height: window.innerHeight,
},
],
};
// Initialize all panels from settings
function initializePanelDockSystem(): void {
const settings = loadSettings();
for (const [panelId, panelState] of Object.entries(settings.panel_layout)) {
registerPanel(panelId, panelState);
}
setupDragHandlers();
setupWindowShadeHandlers();
setupLockHandlers();
}
// Register a panel with the dock system
function registerPanel(panelId: string, panelState: PanelState): void {
state.panels.set(panelId, panelState);
applyPanelState(panelId, panelState);
}
// Apply panel state to DOM
function applyPanelState(panelId: string, panelState: PanelState): void {
const panel = document.querySelector(`[data-panel="${panelId}"]`);
if (!panel) return;
const container = panel.parentElement;
if (!container) return;
// Apply side positioning
if (panelState.side === "left") {
container.style.left = "0";
container.style.right = "auto";
} else if (panelState.side === "right") {
container.style.right = "0";
container.style.left = "auto";
} else {
container.style.left = "-9999px";
}
// Apply width
panel.style.width = `${panelState.width_px}px`;
// Apply collapsed (window-shade) state
if (panelState.collapsed) {
panel.classList.add("panel-collapsed");
panel.querySelector(".panel-content")?.classList.add("hidden");
} else {
panel.classList.remove("panel-collapsed");
panel.querySelector(".panel-content")?.classList.remove("hidden");
}
// Apply lock state
const lockBtn = panel.querySelector(".panel-lock");
if (lockBtn) {
lockBtn.textContent = panelState.locked ? "🔒" : "🔓";
}
}
// Setup mouse/touch drag handlers
function setupDragHandlers(): void {
document
.querySelectorAll(".dockable-panel .panel-header")
.forEach((header) => {
header.addEventListener("mousedown", handleDragStart);
header.addEventListener("touchstart", handleDragStart, {
passive: false,
});
});
document.addEventListener("mousemove", handleDragMove);
document.addEventListener("touchmove", handleDragMove, { passive: false });
document.addEventListener("mouseup", handleDragEnd);
document.addEventListener("touchend", handleDragEnd);
}
function handleDragStart(e: MouseEvent | TouchEvent): void {
const header = e.target.closest(".panel-header") as HTMLElement;
const panel = header?.closest(".dockable-panel") as HTMLElement;
if (!panel) return;
const panelId = panel.dataset.panel;
const panelState = state.panels.get(panelId);
// Check if locked
if (panelState?.locked) return;
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;
state.dragState = {
panelId: panelId!,
startX: clientX,
startY: clientY,
currentX: clientX,
currentY: clientY,
isLocked: panelState?.locked || false,
};
panel.classList.add("dragging");
}
function handleDragMove(e: MouseEvent | TouchEvent): void {
if (!state.dragState) return;
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;
state.dragState.currentX = clientX;
state.dragState.currentY = clientY;
const panel = document.querySelector(
`[data-panel="${state.dragState.panelId}"]`,
);
const container = panel?.parentElement;
if (container) {
container.style.transform = `translateX(${clientX - state.dragState.startX}px)`;
}
}
function handleDragEnd(e: MouseEvent | TouchEvent): void {
if (!state.dragState) return;
const { panelId, currentX } = state.dragState;
const panel = document.querySelector(`[data-panel="${panelId}"]`);
const container = panel?.parentElement;
// Reset transform
container.style.transform = "";
panel?.classList.remove("dragging");
// Determine drop zone
const newSide = currentX < window.innerWidth / 2 ? "left" : "right";
const panelState = state.panels.get(panelId);
if (panelState) {
// Check if dropped in valid zone
const isValidDrop = newSide === "left" || newSide === "right";
if (isValidDrop) {
panelState.last_valid_side = panelState.side; // Save previous valid position
panelState.side = newSide;
} else {
// Snap back to last valid position
panelState.side = panelState.last_valid_side;
}
applyPanelState(panelId, panelState);
savePanelState(panelId, panelState);
}
state.dragState = null;
}
// Setup window-shade (collapse/expand) handlers
function setupWindowShadeHandlers(): void {
document.querySelectorAll(".window-shade-toggle").forEach((btn) => {
btn.addEventListener("click", (e) => {
const panel = (e.target as HTMLElement).closest(
".dockable-panel",
) as HTMLElement;
const panelId = panel.dataset.panel;
const panelState = state.panels.get(panelId);
if (panelState) {
panelState.collapsed = !panelState.collapsed;
applyPanelState(panelId, panelState);
savePanelState(panelId, panelState);
}
});
});
}
// Setup lock toggle handlers
function setupLockHandlers(): void {
document.querySelectorAll(".panel-lock").forEach((btn) => {
btn.addEventListener("click", (e) => {
const panel = (e.target as HTMLElement).closest(
".dockable-panel",
) as HTMLElement;
const panelId = panel.dataset.panel;
const panelState = state.panels.get(panelId);
if (panelState) {
panelState.locked = !panelState.locked;
applyPanelState(panelId, panelState);
savePanelState(panelId, panelState);
}
});
});
}
// Persist panel state to settings
async function savePanelState(
panelId: string,
panelState: PanelState,
): Promise<void> {
const settings = loadSettings();
settings.panel_layout[panelId as keyof typeof settings.panel_layout] =
panelState;
await saveSettings(settings);
}
export { initializePanelDockSystem, registerPanel, applyPanelState };
+160
View File
@@ -0,0 +1,160 @@
// 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();
}
+66
View File
@@ -0,0 +1,66 @@
// KOReader-style switchable progress indicator
import { Alpine } from "../alpine";
import { getReadingSpeed } from "./api";
interface ProgressDisplay {
mode: "pages" | "chapter" | "percentage" | "time-left";
text: string;
}
function calculateProgress(
currentPage: number,
totalPages: number,
currentChapterPage: number,
chapterPages: number,
readingSpeed?: ReadingSpeed,
): ProgressDisplay {
const mode = getCurrentProgressMode(); // From settings
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) {
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`,
};
}
}
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);
}
+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
}
+120
View File
@@ -0,0 +1,120 @@
// Per-user settings with localStorage fallback
import { apiGet, apiPut } from "../api";
import { getToken, setItem, getItem } from "../storage";
const SETTINGS_KEY = "reader_settings";
const LOCALSTORAGE_KEY = "reader_settings_local";
interface SettingsManager {
load(): Promise<ReaderSettings>;
save(settings: Partial<ReaderSettings>): Promise<void>;
sync(): Promise<void>; // Sync localStorage → DB
get(key: keyof ReaderSettings): any;
set(key: keyof ReaderSettings, value: any): Promise<void>;
}
async function loadSettings(): Promise<ReaderSettings> {
const token = getToken();
if (!token) {
// Fallback to localStorage
const local = getItem(LOCALSTORAGE_KEY);
return local ? JSON.parse(local) : getDefaultSettings();
}
try {
const response = await apiGet("/readers/settings");
const settings = await response.json();
// Cache in localStorage
setItem(LOCALSTORAGE_KEY, JSON.stringify(settings));
return settings;
} catch (error) {
// Fallback to localStorage on error
const local = getItem(LOCALSTORAGE_KEY);
return local ? JSON.parse(local) : getDefaultSettings();
}
}
async function saveSettings(settings: Partial<ReaderSettings>): Promise<void> {
const token = getToken();
if (!token) {
// Save to localStorage only
const current = loadSettings();
const updated = { ...current, ...settings };
setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
return;
}
try {
await apiPut("/readers/settings", settings);
// Update localStorage cache
const current = loadSettings();
const updated = { ...current, ...settings };
setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
} catch (error) {
// Fallback to localStorage
const current = loadSettings();
const updated = { ...current, ...settings };
setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
}
}
function getDefaultSettings(): ReaderSettings {
return {
chrome_behavior: "auto-hide",
progress_mode: "pages",
chrome_theme: "tokyo-night", // UI chrome: All 11 themes available
reading_theme: "dark", // Ebook text: 5 reading-optimized themes
reading_font: "literata", // Default reading font (designed for ebooks)
tap_zone_size: 30,
auto_scroll: false,
panel_zoom_enabled: true,
font_size: 16,
line_height: 1.6,
margin_width: 20,
double_page_spread: false,
reading_direction: "ltr",
hardware_acceleration: true,
// Dockable panel defaults by media type
panel_layout: {
toc: {
side: "left",
visible: true,
collapsed: false,
width_px: 320,
order: 1,
locked: false,
last_valid_side: "left",
},
settings: {
side: "left",
visible: false,
collapsed: true,
width_px: 380,
order: 2,
locked: false,
last_valid_side: "left",
},
navigator: {
side: "right",
visible: true,
collapsed: false,
width_px: 200,
order: 1,
locked: false,
last_valid_side: "right",
},
bookmarks: {
side: "right",
visible: false,
collapsed: true,
width_px: 280,
order: 2,
locked: false,
last_valid_side: "right",
},
mobile_nav_visible: false,
},
};
}