- Add comprehensive implementation guide for feature-based architecture - Define core infrastructure components (context, events, state, navigation) - Establish pattern for feature modules with init(context) functions - Include migration checklist and code examples
1386 lines
38 KiB
Markdown
1386 lines
38 KiB
Markdown
# Feature Registration Pattern - Implementation
|
|
|
|
## File Structure
|
|
|
|
```
|
|
web/src/reader/
|
|
├── reader-shell.ts
|
|
├── core/
|
|
│ ├── reader-context.ts
|
|
│ ├── reader-events.ts
|
|
│ ├── reader-state.ts
|
|
│ ├── reader-navigation.ts
|
|
│ └── reader-services.ts
|
|
├── features/
|
|
│ ├── gestures.ts
|
|
│ ├── keyboard-shortcuts.ts
|
|
│ ├── panel-dock-system.ts
|
|
│ ├── navigator-panel.ts
|
|
│ ├── offline-manager.ts
|
|
│ └── reading-speed-tracker.ts
|
|
└── ...
|
|
```
|
|
|
|
---
|
|
|
|
## Core Infrastructure
|
|
|
|
### `web/src/reader/core/reader-context.ts`
|
|
|
|
```typescript
|
|
import type { ReaderMetadata } from "../../types/reader";
|
|
|
|
type ReaderType = "ebook" | "pdf" | "comic" | "manga";
|
|
|
|
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;
|
|
|
|
export interface ReaderContext {
|
|
getState: () => {
|
|
currentReader: CurrentReader | null;
|
|
readerMetadata: ReaderMetadata | null;
|
|
};
|
|
setState: (updates: Partial<ReaderState>) => void;
|
|
navigation: {
|
|
nextPage: () => void;
|
|
previousPage: () => void;
|
|
goToPage: (page: number) => void;
|
|
goToChapter: (chapterIndex: number) => void;
|
|
};
|
|
render: () => void;
|
|
elements: {
|
|
readerContent: HTMLElement;
|
|
chrome: HTMLElement | null;
|
|
progressDisplay: HTMLElement | null;
|
|
};
|
|
events: {
|
|
on: (event: string, handler: Function) => void;
|
|
emit: (event: string, data?: any) => void;
|
|
};
|
|
}
|
|
|
|
export interface ReaderState {
|
|
currentReader: CurrentReader | null;
|
|
readerMetadata: ReaderMetadata | null;
|
|
}
|
|
|
|
export function createReaderContext(
|
|
getState: () => ReaderState,
|
|
setState: (updates: Partial<ReaderState>) => void,
|
|
navigation: ReaderContext['navigation'],
|
|
render: () => void,
|
|
): ReaderContext {
|
|
return {
|
|
getState,
|
|
setState,
|
|
navigation,
|
|
render,
|
|
elements: {
|
|
readerContent: document.getElementById("reader-content")!,
|
|
chrome: document.getElementById("reader-chrome"),
|
|
progressDisplay: document.getElementById("progress-display"),
|
|
},
|
|
events: {
|
|
on: (event: string, handler: Function) => readerEvents.on(event, handler),
|
|
emit: (event: string, data?: any) => readerEvents.emit(event, data),
|
|
},
|
|
};
|
|
}
|
|
|
|
import { readerEvents } from "./reader-events";
|
|
```
|
|
|
|
### `web/src/reader/core/reader-events.ts`
|
|
|
|
```typescript
|
|
type ReaderEventType =
|
|
| "readerReady"
|
|
| "pageChanged"
|
|
| "chapterChanged"
|
|
| "zoomChanged"
|
|
| "themeChanged"
|
|
| "progressUpdated"
|
|
| "beforePageChange"
|
|
| "afterPageChange";
|
|
|
|
class EventBus {
|
|
private listeners = new Map<ReaderEventType, Function[]>();
|
|
|
|
on(event: ReaderEventType, handler: Function): void {
|
|
if (!this.listeners.has(event)) {
|
|
this.listeners.set(event, []);
|
|
}
|
|
this.listeners.get(event)!.push(handler);
|
|
}
|
|
|
|
off(event: ReaderEventType, handler: Function): void {
|
|
const handlers = this.listeners.get(event);
|
|
if (handlers) {
|
|
const index = handlers.indexOf(handler);
|
|
if (index > -1) {
|
|
handlers.splice(index, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
emit(event: ReaderEventType, data?: any): void {
|
|
this.listeners.get(event)?.forEach(handler => handler(data));
|
|
}
|
|
|
|
clear(): void {
|
|
this.listeners.clear();
|
|
}
|
|
}
|
|
|
|
export const readerEvents = new EventBus();
|
|
export type { ReaderEventType };
|
|
```
|
|
|
|
### `web/src/reader/core/reader-state.ts`
|
|
|
|
```typescript
|
|
import type { ReaderMetadata, ReaderState } from "./reader-context";
|
|
|
|
type CurrentReader = import("./reader-context").CurrentReader;
|
|
|
|
let currentState: ReaderState = {
|
|
currentReader: null,
|
|
readerMetadata: null,
|
|
};
|
|
|
|
export function getState(): ReaderState {
|
|
return { ...currentState };
|
|
}
|
|
|
|
export function setState(updates: Partial<ReaderState>): void {
|
|
const oldState = { ...currentState };
|
|
currentState = { ...currentState, ...updates };
|
|
|
|
// Emit events for significant changes
|
|
if (updates.currentReader && oldState.currentReader !== updates.currentReader) {
|
|
readerEvents.emit("readerReady", currentState.currentReader);
|
|
}
|
|
}
|
|
|
|
export function getCurrentReader(): CurrentReader | null {
|
|
return currentState.currentReader;
|
|
}
|
|
|
|
export function getReaderMetadata(): ReaderMetadata | null {
|
|
return currentState.readerMetadata;
|
|
}
|
|
|
|
export function getCurrentPage(): number {
|
|
const reader = currentState.currentReader;
|
|
if (!reader) return 0;
|
|
|
|
if (reader.type === "ebook") {
|
|
return reader.currentSpineIndex;
|
|
}
|
|
return reader.currentPage;
|
|
}
|
|
|
|
import { readerEvents } from "./reader-events";
|
|
```
|
|
|
|
### `web/src/reader/core/reader-navigation.ts`
|
|
|
|
```typescript
|
|
import type { ReaderMetadata } from "../../types/reader";
|
|
import type { CurrentReader } from "./reader-context";
|
|
import { getState, setState } from "./reader-state";
|
|
import { readerEvents } from "./reader-events";
|
|
import { updateReadingProgress } from "./reader-services";
|
|
|
|
export function createNavigationAPI() {
|
|
return {
|
|
nextPage: () => {
|
|
const state = getState();
|
|
if (!state.currentReader) return;
|
|
|
|
readerEvents.emit("beforePageChange", state.currentReader);
|
|
|
|
if (state.currentReader.type === "ebook") {
|
|
if (state.currentReader.currentSpineIndex < state.currentReader.cif.spine.length - 1) {
|
|
state.currentReader.currentSpineIndex++;
|
|
renderSpineItem();
|
|
}
|
|
} else if (state.currentReader.type === "pdf") {
|
|
const totalPages = state.readerMetadata?.total_pages || 0;
|
|
if (state.currentReader.currentPage < totalPages) {
|
|
state.currentReader.currentPage++;
|
|
renderPDFPage();
|
|
}
|
|
} else if (state.currentReader.type === "comic" || state.currentReader.type === "manga") {
|
|
if (state.currentReader.currentPage < state.currentReader.images.length - 1) {
|
|
state.currentReader.currentPage++;
|
|
renderComicPage();
|
|
}
|
|
}
|
|
|
|
setState({ currentReader: state.currentReader });
|
|
sendProgressUpdate();
|
|
readerEvents.emit("pageChanged", getCurrentPage());
|
|
readerEvents.emit("afterPageChange", state.currentReader);
|
|
},
|
|
|
|
previousPage: () => {
|
|
const state = getState();
|
|
if (!state.currentReader) return;
|
|
|
|
readerEvents.emit("beforePageChange", state.currentReader);
|
|
|
|
if (state.currentReader.type === "ebook") {
|
|
if (state.currentReader.currentSpineIndex > 0) {
|
|
state.currentReader.currentSpineIndex--;
|
|
renderSpineItem();
|
|
}
|
|
} else if (state.currentReader.type === "pdf") {
|
|
if (state.currentReader.currentPage > 1) {
|
|
state.currentReader.currentPage--;
|
|
renderPDFPage();
|
|
}
|
|
} else if (state.currentReader.type === "comic" || state.currentReader.type === "manga") {
|
|
if (state.currentReader.currentPage > 0) {
|
|
state.currentReader.currentPage--;
|
|
renderComicPage();
|
|
}
|
|
}
|
|
|
|
setState({ currentReader: state.currentReader });
|
|
sendProgressUpdate();
|
|
readerEvents.emit("pageChanged", getCurrentPage());
|
|
readerEvents.emit("afterPageChange", state.currentReader);
|
|
},
|
|
|
|
goToPage: (page: number) => {
|
|
const state = getState();
|
|
if (!state.currentReader) return;
|
|
|
|
readerEvents.emit("beforePageChange", state.currentReader);
|
|
|
|
if (state.currentReader.type === "ebook") {
|
|
if (page >= 0 && page < state.currentReader.cif.spine.length) {
|
|
state.currentReader.currentSpineIndex = page;
|
|
renderSpineItem();
|
|
}
|
|
} else if (state.currentReader.type === "pdf") {
|
|
if (page >= 1 && page <= (state.readerMetadata?.total_pages || 0)) {
|
|
state.currentReader.currentPage = page;
|
|
renderPDFPage();
|
|
}
|
|
} else if (state.currentReader.type === "comic" || state.currentReader.type === "manga") {
|
|
if (page >= 0 && page < state.currentReader.images.length) {
|
|
state.currentReader.currentPage = page;
|
|
renderComicPage();
|
|
}
|
|
}
|
|
|
|
setState({ currentReader: state.currentReader });
|
|
sendProgressUpdate();
|
|
readerEvents.emit("pageChanged", page);
|
|
readerEvents.emit("afterPageChange", state.currentReader);
|
|
},
|
|
|
|
goToChapter: (chapterIndex: number) => {
|
|
const state = getState();
|
|
if (!state.readerMetadata?.chapter_metadata?.chapters) return;
|
|
|
|
const chapters = state.readerMetadata.chapter_metadata.chapters;
|
|
if (chapterIndex < 0 || chapterIndex >= chapters.length) return;
|
|
|
|
const chapter = chapters[chapterIndex];
|
|
const pageAPI = createNavigationAPI();
|
|
pageAPI.goToPage(chapter.start_page);
|
|
|
|
readerEvents.emit("chapterChanged", chapter);
|
|
},
|
|
};
|
|
}
|
|
|
|
function renderSpineItem() {
|
|
const state = getState();
|
|
if (state.currentReader?.type !== "ebook") return;
|
|
|
|
const spineItem = state.currentReader.cif.spine[state.currentReader.currentSpineIndex];
|
|
const container = document.getElementById("reader-content");
|
|
if (!container) return;
|
|
|
|
container.innerHTML = spineItem.content;
|
|
applyReaderTheme();
|
|
applyTypography();
|
|
}
|
|
|
|
async function renderPDFPage() {
|
|
const state = getState();
|
|
if (state.currentReader?.type !== "pdf") return;
|
|
|
|
const container = document.getElementById("reader-content");
|
|
if (!container) return;
|
|
|
|
const { getPDFPage } = await import("../pdf/pdfjs-wrapper");
|
|
|
|
try {
|
|
const page = await getPDFPage(state.currentReader.currentPage);
|
|
const viewport = page.getViewport({ scale: 1.5 });
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = viewport.width;
|
|
canvas.height = viewport.height;
|
|
canvas.className = "pdf-page mx-auto";
|
|
const ctx = canvas.getContext("2d");
|
|
if (ctx) {
|
|
await page.render({
|
|
canvasContext: ctx,
|
|
viewport: viewport,
|
|
canvas: canvas,
|
|
}).promise;
|
|
container.innerHTML = "";
|
|
container.appendChild(canvas);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to render PDF page:", error);
|
|
}
|
|
}
|
|
|
|
function renderComicPage() {
|
|
const state = getState();
|
|
if (state.currentReader?.type !== "comic" && state.currentReader?.type !== "manga") return;
|
|
|
|
const container = document.getElementById("reader-content");
|
|
if (!container || !state.currentReader.images[state.currentReader.currentPage]) return;
|
|
|
|
const img = document.createElement("img");
|
|
img.src = URL.createObjectURL(state.currentReader.images[state.currentReader.currentPage]);
|
|
img.className = "w-full h-contain object-contain";
|
|
img.alt = `Page ${state.currentReader.currentPage + 1}`;
|
|
container.innerHTML = "";
|
|
container.appendChild(img);
|
|
}
|
|
|
|
function applyReaderTheme() {
|
|
const state = getState();
|
|
const settings = getReaderSettings();
|
|
const container = document.getElementById("reader-content");
|
|
if (!container) return;
|
|
|
|
container.className = `ebook-content theme-${settings.reading_theme}`;
|
|
}
|
|
|
|
function applyTypography() {
|
|
const state = getState();
|
|
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() {
|
|
// TODO: Load from settings manager
|
|
return {} as any;
|
|
}
|
|
|
|
function sendProgressUpdate() {
|
|
const state = getState();
|
|
if (!state.currentReader || !state.readerMetadata) return;
|
|
|
|
let currentPage = 0;
|
|
let totalPages = 0;
|
|
|
|
if (state.currentReader.type === "ebook") {
|
|
currentPage = state.currentReader.currentSpineIndex + 1;
|
|
totalPages = state.currentReader.cif.spine.length;
|
|
} else if (state.currentReader.type === "pdf") {
|
|
totalPages = state.readerMetadata.total_pages || 0;
|
|
currentPage = state.currentReader.currentPage;
|
|
} else if (state.currentReader.type === "comic" || state.currentReader.type === "manga") {
|
|
totalPages = state.currentReader.images.length;
|
|
currentPage = state.currentReader.currentPage;
|
|
}
|
|
|
|
updateReadingProgress(state.readerMetadata.media_item_id, {
|
|
current_page: currentPage,
|
|
total_pages: totalPages,
|
|
});
|
|
|
|
readerEvents.emit("progressUpdated", { currentPage, totalPages });
|
|
}
|
|
|
|
import { getCurrentPage } from "./reader-state";
|
|
```
|
|
|
|
### `web/src/reader/core/reader-services.ts`
|
|
|
|
```typescript
|
|
import { apiPut } from "../../api";
|
|
|
|
interface ReadingProgress {
|
|
current_page: number;
|
|
total_pages: number;
|
|
}
|
|
|
|
export async function updateReadingProgress(
|
|
mediaItemId: string,
|
|
progress: ReadingProgress,
|
|
): Promise<void> {
|
|
const response = await apiPut(`/media-items/${mediaItemId}/progress`, progress);
|
|
await response.json();
|
|
}
|
|
|
|
export function getChapterNavigation(chapters: any[]) {
|
|
return {
|
|
getNextChapter: (currentPage: number) => {
|
|
for (let i = 0; i < chapters.length - 1; i++) {
|
|
const chapter = chapters[i];
|
|
const nextChapter = chapters[i + 1];
|
|
|
|
if (currentPage >= chapter.start_page && currentPage < nextChapter.start_page) {
|
|
return nextChapter.start_page;
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
|
|
getPreviousChapter: (currentPage: number) => {
|
|
for (let i = 1; i < chapters.length; i++) {
|
|
const chapter = chapters[i];
|
|
|
|
if (currentPage >= chapter.start_page && currentPage < chapter.start_page + chapter.page_count) {
|
|
return chapters[i - 1].start_page;
|
|
}
|
|
}
|
|
|
|
if (currentPage < chapters[0].start_page) {
|
|
return null;
|
|
}
|
|
|
|
return chapters[0].start_page;
|
|
},
|
|
};
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Feature Modules
|
|
|
|
### `web/src/reader/features/gestures.ts`
|
|
|
|
```typescript
|
|
import type { Panel } from "../comic/panel-detector";
|
|
import { detectPanels } from "../comic/panel-detection.service";
|
|
import type { ReaderContext } from "../core/reader-context";
|
|
|
|
let currentPanelIndex = 0;
|
|
let currentPagePanels: Panel[] = [];
|
|
|
|
export async function init(context: ReaderContext): Promise<void> {
|
|
setupGestures();
|
|
await loadPanelsIfComic();
|
|
}
|
|
|
|
function setupGestures() {
|
|
const container = context.elements.readerContent;
|
|
if (!container) return;
|
|
|
|
const state = {
|
|
touchStartX: 0,
|
|
touchStartY: 0,
|
|
touchStartTime: 0,
|
|
lastTapTime: 0,
|
|
initialPinchDistance: 0,
|
|
scale: 1,
|
|
};
|
|
|
|
container.addEventListener("touchstart", (e) => {
|
|
if (e.touches.length === 1) {
|
|
state.touchStartX = e.touches[0].clientX;
|
|
state.touchStartY = e.touches[0].clientY;
|
|
state.touchStartTime = Date.now();
|
|
} else if (e.touches.length === 2) {
|
|
state.initialPinchDistance = getPinchDistance(e.touches);
|
|
}
|
|
}, { passive: true });
|
|
|
|
container.addEventListener("touchend", (e) => {
|
|
const deltaX = e.changedTouches[0].clientX - state.touchStartX;
|
|
const deltaY = e.changedTouches[0].clientY - state.touchStartY;
|
|
const deltaTime = Date.now() - state.touchStartTime;
|
|
|
|
if (Math.abs(deltaX) < 30 && Math.abs(deltaY) < 30 && deltaTime < 300) {
|
|
const now = Date.now();
|
|
if (now - state.lastTapTime < 300) {
|
|
handleDoubleTap();
|
|
state.lastTapTime = 0;
|
|
} else {
|
|
state.lastTapTime = now;
|
|
setTimeout(() => {
|
|
if (state.lastTapTime !== 0) {
|
|
handleTap();
|
|
}
|
|
}, 300);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const minSwipeDistance = 50;
|
|
const maxSwipeTime = 500;
|
|
|
|
if (deltaTime > maxSwipeTime) return;
|
|
|
|
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
|
if (deltaX > minSwipeDistance) {
|
|
handleSwipeRight();
|
|
} else if (deltaX < -minSwipeDistance) {
|
|
handleSwipeLeft();
|
|
}
|
|
} else {
|
|
if (deltaY > minSwipeDistance) {
|
|
handleSwipeDown();
|
|
} else if (deltaY < -minSwipeDistance) {
|
|
handleSwipeUp();
|
|
}
|
|
}
|
|
}, { passive: true });
|
|
|
|
container.addEventListener("touchmove", (e) => {
|
|
if (e.touches.length === 2) {
|
|
const currentDistance = getPinchDistance(e.touches);
|
|
if (state.initialPinchDistance > 0) {
|
|
const scale = currentDistance / state.initialPinchDistance;
|
|
handlePinch(scale);
|
|
}
|
|
}
|
|
}, { passive: true });
|
|
}
|
|
|
|
function handleSwipeLeft() {
|
|
const metadata = context.getState().readerMetadata;
|
|
if (metadata?.library_type === "manga") {
|
|
navigateWithPanels("next");
|
|
} else {
|
|
context.navigation.previousPage();
|
|
}
|
|
}
|
|
|
|
function handleSwipeRight() {
|
|
const metadata = context.getState().readerMetadata;
|
|
if (metadata?.library_type === "manga") {
|
|
navigateWithPanels("previous");
|
|
} else {
|
|
context.navigation.nextPage();
|
|
}
|
|
}
|
|
|
|
function handleSwipeUp() {
|
|
const chrome = context.elements.chrome;
|
|
if (chrome) chrome.classList.remove("visible");
|
|
}
|
|
|
|
function handleSwipeDown() {
|
|
const chrome = context.elements.chrome;
|
|
if (chrome) chrome.classList.add("visible");
|
|
}
|
|
|
|
function handleTap() {
|
|
const chrome = context.elements.chrome;
|
|
if (chrome) chrome.classList.toggle("visible");
|
|
}
|
|
|
|
function handleDoubleTap() {
|
|
const container = context.elements.readerContent;
|
|
if (container) {
|
|
const currentTransform = container.style.transform || "";
|
|
const currentScale = currentTransform.match(/scale\(([\d.]+)\)/);
|
|
const scale = currentScale ? parseFloat(currentScale[1]) : 1;
|
|
const newScale = scale === 1 ? 1.5 : 1;
|
|
container.style.transform = `scale(${newScale})`;
|
|
container.style.transformOrigin = "center center";
|
|
context.events.emit("zoomChanged", newScale);
|
|
}
|
|
}
|
|
|
|
function handlePinch(scale: number) {
|
|
const container = context.elements.readerContent;
|
|
if (container && scale >= 0.5 && scale <= 3) {
|
|
container.style.transform = `scale(${scale})`;
|
|
container.style.transformOrigin = "center center";
|
|
context.events.emit("zoomChanged", scale);
|
|
}
|
|
}
|
|
|
|
function getPinchDistance(touches: TouchList): number {
|
|
const dx = touches[0].clientX - touches[1].clientX;
|
|
const dy = touches[0].clientY - touches[1].clientY;
|
|
return Math.sqrt(dx * dx + dy * dy);
|
|
}
|
|
|
|
async function loadPanelsIfComic() {
|
|
const state = context.getState();
|
|
if (!state.currentReader || (state.currentReader.type !== "comic" && state.currentReader.type !== "manga")) {
|
|
return;
|
|
}
|
|
|
|
const reader = state.currentReader as any;
|
|
if (!reader.images || reader.currentPage === undefined ||
|
|
reader.currentPage < 0 || reader.currentPage >= reader.images.length) {
|
|
currentPagePanels = [];
|
|
currentPanelIndex = 0;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const imageBlob = reader.images[reader.currentPage];
|
|
const imageData = await blobToImageData(imageBlob);
|
|
const result = await detectPanels(imageData, false);
|
|
currentPagePanels = result.panels;
|
|
currentPanelIndex = 0;
|
|
} catch (error) {
|
|
console.warn("Failed to load panels:", error);
|
|
currentPagePanels = [];
|
|
currentPanelIndex = 0;
|
|
}
|
|
}
|
|
|
|
async function blobToImageData(blob: Blob): Promise<ImageData> {
|
|
const img = new Image();
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
img.onload = () => {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = img.width;
|
|
canvas.height = img.height;
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) {
|
|
reject(new Error("Failed to get canvas context"));
|
|
return;
|
|
}
|
|
ctx.drawImage(img, 0, 0);
|
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
URL.revokeObjectURL(url);
|
|
resolve(imageData);
|
|
};
|
|
img.onerror = () => {
|
|
URL.revokeObjectURL(url);
|
|
reject(new Error("Failed to load image"));
|
|
};
|
|
img.src = url;
|
|
});
|
|
}
|
|
|
|
function navigateWithPanels(direction: "next" | "previous") {
|
|
if (currentPagePanels.length === 0) {
|
|
if (direction === "next") {
|
|
context.navigation.nextPage();
|
|
} else {
|
|
context.navigation.previousPage();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (direction === "next" && currentPanelIndex < currentPagePanels.length - 1) {
|
|
currentPanelIndex++;
|
|
scrollToPanel(currentPanelIndex);
|
|
return;
|
|
}
|
|
|
|
if (direction === "previous" && currentPanelIndex > 0) {
|
|
currentPanelIndex--;
|
|
scrollToPanel(currentPanelIndex);
|
|
return;
|
|
}
|
|
|
|
// No more panels, go to next/previous page
|
|
if (direction === "next") {
|
|
context.navigation.nextPage();
|
|
} else {
|
|
context.navigation.previousPage();
|
|
}
|
|
}
|
|
|
|
function scrollToPanel(panelIndex: number) {
|
|
const panel = currentPagePanels[panelIndex];
|
|
if (!panel) return;
|
|
|
|
const container = context.elements.readerContent;
|
|
if (!container) return;
|
|
|
|
const panelElement = container.querySelector(`[data-panel-id="${panel.id}"]`) ||
|
|
container.querySelector(`#${panel.id}`);
|
|
|
|
if (panelElement) {
|
|
panelElement.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
document.querySelectorAll(".panel-current").forEach(el => el.classList.remove("panel-current"));
|
|
panelElement.classList.add("panel-current");
|
|
}
|
|
}
|
|
```
|
|
|
|
### `web/src/reader/features/keyboard-shortcuts.ts`
|
|
|
|
```typescript
|
|
import type { ReaderContext } from "../core/reader-context";
|
|
|
|
export async function init(context: ReaderContext): Promise<void> {
|
|
setupKeyboardShortcuts();
|
|
}
|
|
|
|
function setupKeyboardShortcuts() {
|
|
const container = context.elements.readerContent;
|
|
if (!container) return;
|
|
|
|
const state = context.getState();
|
|
let maxPage = 0;
|
|
|
|
if (state.currentReader?.type === "ebook") {
|
|
maxPage = state.currentReader.cif.spine.length;
|
|
} else if (state.currentReader?.type === "pdf") {
|
|
maxPage = state.readerMetadata?.total_pages || 0;
|
|
} else if (state.currentReader?.type === "comic" || state.currentReader?.type === "manga") {
|
|
const reader = state.currentReader as any;
|
|
maxPage = reader.images.length;
|
|
}
|
|
|
|
container.addEventListener("keydown", (e) => {
|
|
if (
|
|
e.target instanceof HTMLInputElement ||
|
|
e.target instanceof HTMLTextAreaElement
|
|
) {
|
|
return;
|
|
}
|
|
|
|
switch (e.key) {
|
|
case "ArrowRight":
|
|
case "PageDown":
|
|
case "l":
|
|
e.preventDefault();
|
|
context.navigation.nextPage();
|
|
break;
|
|
|
|
case "ArrowLeft":
|
|
case "PageUp":
|
|
case "h":
|
|
e.preventDefault();
|
|
context.navigation.previousPage();
|
|
break;
|
|
|
|
case "ArrowUp":
|
|
case "k":
|
|
e.preventDefault();
|
|
context.navigation.previousPage();
|
|
break;
|
|
|
|
case "ArrowDown":
|
|
case "j":
|
|
e.preventDefault();
|
|
context.navigation.nextPage();
|
|
break;
|
|
|
|
case " ":
|
|
e.preventDefault();
|
|
context.navigation.nextPage();
|
|
break;
|
|
|
|
case "Home":
|
|
e.preventDefault();
|
|
context.navigation.goToPage(1);
|
|
break;
|
|
|
|
case "End":
|
|
e.preventDefault();
|
|
context.navigation.goToPage(maxPage);
|
|
break;
|
|
|
|
case "b":
|
|
if (!e.ctrlKey && !e.metaKey) {
|
|
e.preventDefault();
|
|
toggleBookmark();
|
|
}
|
|
break;
|
|
|
|
case "+":
|
|
case "=":
|
|
e.preventDefault();
|
|
zoomIn();
|
|
break;
|
|
|
|
case "-":
|
|
case "_":
|
|
e.preventDefault();
|
|
zoomOut();
|
|
break;
|
|
|
|
case "0":
|
|
e.preventDefault();
|
|
zoomReset();
|
|
break;
|
|
|
|
case "?":
|
|
e.preventDefault();
|
|
showShortcutHelp();
|
|
break;
|
|
|
|
case "f":
|
|
if (!e.ctrlKey && !e.metaKey) {
|
|
e.preventDefault();
|
|
toggleFullscreen();
|
|
}
|
|
break;
|
|
|
|
case "Escape":
|
|
e.preventDefault();
|
|
exitFullscreen();
|
|
break;
|
|
|
|
default:
|
|
if (e.key >= "1" && e.key <= "9") {
|
|
const targetPage = Math.floor((parseInt(e.key) / 10) * maxPage);
|
|
e.preventDefault();
|
|
context.navigation.goToPage(targetPage);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function toggleBookmark() {
|
|
// TODO: Implement bookmark toggle
|
|
console.log("Toggle bookmark");
|
|
}
|
|
|
|
function zoomIn() {
|
|
const container = context.elements.readerContent;
|
|
if (container) {
|
|
const currentTransform = container.style.transform || "";
|
|
const currentScale = currentTransform.match(/scale\(([\d.]+)\)/);
|
|
const scale = currentScale ? parseFloat(currentScale[1]) : 1;
|
|
const newScale = Math.min(scale + 0.25, 3);
|
|
container.style.transform = `scale(${newScale})`;
|
|
container.style.transformOrigin = "center center";
|
|
context.events.emit("zoomChanged", newScale);
|
|
}
|
|
}
|
|
|
|
function zoomOut() {
|
|
const container = context.elements.readerContent;
|
|
if (container) {
|
|
const currentTransform = container.style.transform || "";
|
|
const currentScale = currentTransform.match(/scale\(([\d.]+)\)/);
|
|
const scale = currentScale ? parseFloat(currentScale[1]) : 1;
|
|
const newScale = Math.max(scale - 0.25, 0.5);
|
|
container.style.transform = `scale(${newScale})`;
|
|
container.style.transformOrigin = "center center";
|
|
context.events.emit("zoomChanged", newScale);
|
|
}
|
|
}
|
|
|
|
function zoomReset() {
|
|
const container = context.elements.readerContent;
|
|
if (container) {
|
|
container.style.transform = "scale(1)";
|
|
container.style.transformOrigin = "center center";
|
|
context.events.emit("zoomChanged", 1);
|
|
}
|
|
}
|
|
|
|
function toggleFullscreen() {
|
|
if (document.fullscreenElement) {
|
|
document.exitFullscreen();
|
|
} else {
|
|
document.documentElement.requestFullscreen();
|
|
}
|
|
}
|
|
|
|
function exitFullscreen() {
|
|
if (document.fullscreenElement) {
|
|
document.exitFullscreen();
|
|
}
|
|
}
|
|
|
|
function showShortcutHelp() {
|
|
const help = document.createElement("div");
|
|
help.className =
|
|
"keyboard-shortcut-help fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-50";
|
|
help.innerHTML = `
|
|
<div class="bg-gray-800 rounded-lg p-6 max-w-md">
|
|
<h2 class="text-xl font-bold mb-4">Keyboard Shortcuts</h2>
|
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
|
<div><kbd class="bg-gray-700 px-2 py-1 rounded">→</kbd> / <kbd class="bg-gray-700 px-2 py-1 rounded">Space</kbd> Next page</div>
|
|
<div><kbd class="bg-gray-700 px-2 py-1 rounded">←</kbd> Previous page</div>
|
|
<div><kbd class="bg-gray-700 px-2 py-1 rounded">Home</kbd> First page</div>
|
|
<div><kbd class="bg-gray-700 px-2 py-1 rounded">End</kbd> Last page</div>
|
|
<div><kbd class="bg-gray-700 px-2 py-1 rounded">+</kbd> / <kbd class="bg-gray-700 px-2 py-1 rounded">-</kbd> Zoom</div>
|
|
<div><kbd class="bg-gray-700 px-2 py-1 rounded">B</kbd> Toggle bookmark</div>
|
|
<div><kbd class="bg-gray-700 px-2 py-1 rounded">?</kbd> Show help</div>
|
|
<div><kbd class="bg-gray-700 px-2 py-1 rounded">Esc</kbd> Exit fullscreen</div>
|
|
</div>
|
|
<button class="mt-4 px-4 py-2 bg-blue-600 rounded" onclick="this.closest('.keyboard-shortcut-help').remove()">
|
|
Close
|
|
</button>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(help);
|
|
help.addEventListener("click", (e) => {
|
|
if (e.target === help) help.remove();
|
|
});
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Main Reader Shell (Refactored)
|
|
|
|
### `web/src/reader/reader-shell.ts`
|
|
|
|
```typescript
|
|
import { Alpine } from "../alpine";
|
|
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";
|
|
|
|
type ReaderType = "ebook" | "pdf" | "comic" | "manga";
|
|
|
|
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: import("../types/reader").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)),
|
|
|
|
// 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;
|
|
|
|
readerMetadata = await getReaderMetadata(mediaItemId);
|
|
|
|
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) 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: any): Promise<UniversalReader> {
|
|
const { parseEbook, requiresServerParsing } = await import("./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", () => ({
|
|
init() {
|
|
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;
|
|
}
|
|
},
|
|
}));
|
|
```
|
|
|
|
---
|
|
|
|
## Remaining Features (Stubs)
|
|
|
|
### `web/src/reader/features/panel-dock-system.ts`
|
|
|
|
```typescript
|
|
import type { ReaderContext } from "../core/reader-context";
|
|
import type { PanelState } from "../settings-manager";
|
|
|
|
export async function init(context: ReaderContext): Promise<void> {
|
|
initializePanelDockSystem();
|
|
}
|
|
|
|
function initializePanelDockSystem() {
|
|
const settings = loadSettings();
|
|
|
|
Object.entries(settings.panel_layout).forEach(([panelId, panelState]) => {
|
|
setupPanel(panelId, panelState as PanelState);
|
|
});
|
|
|
|
setupDragAndDrop();
|
|
}
|
|
|
|
function loadSettings() {
|
|
// TODO: Load from settings manager
|
|
return {
|
|
panel_layout: {}
|
|
};
|
|
}
|
|
|
|
function setupPanel(panelId: string, state: PanelState) {
|
|
// TODO: Setup panel with state
|
|
}
|
|
|
|
function setupDragAndDrop() {
|
|
// TODO: Setup drag and drop
|
|
}
|
|
```
|
|
|
|
### `web/src/reader/features/navigator-panel.ts`
|
|
|
|
```typescript
|
|
import type { ReaderContext } from "../core/reader-context";
|
|
|
|
export async function init(context: ReaderContext): Promise<void> {
|
|
context.events.on("pageChanged", (page: number) => {
|
|
updateNavigator(page);
|
|
});
|
|
|
|
context.events.on("chapterChanged", (chapter: any) => {
|
|
updateChapterIndicator(chapter);
|
|
});
|
|
}
|
|
|
|
function updateNavigator(page: number) {
|
|
// TODO: Update navigator thumbnail
|
|
}
|
|
|
|
function updateChapterIndicator(chapter: any) {
|
|
// TODO: Update chapter display
|
|
}
|
|
```
|
|
|
|
### `web/src/reader/features/offline-manager.ts`
|
|
|
|
```typescript
|
|
import type { ReaderContext } from "../core/reader-context";
|
|
|
|
export async function init(context: ReaderContext): Promise<void> {
|
|
if ("serviceWorker" in navigator) {
|
|
navigator.serviceWorker.register("/static/service-worker.js")
|
|
.then((registration) => {
|
|
console.log("Service worker registered:", registration);
|
|
})
|
|
.catch((error) => {
|
|
console.error("Service worker registration failed:", error);
|
|
});
|
|
}
|
|
|
|
setupOnlineListeners();
|
|
}
|
|
|
|
function setupOnlineListeners() {
|
|
window.addEventListener("online", () => {
|
|
context.events.emit("connectionChanged", { online: true });
|
|
});
|
|
|
|
window.addEventListener("offline", () => {
|
|
context.events.emit("connectionChanged", { online: false });
|
|
});
|
|
}
|
|
```
|
|
|
|
### `web/src/reader/features/reading-speed-tracker.ts`
|
|
|
|
```typescript
|
|
import type { ReaderContext } from "../core/reader-context";
|
|
|
|
let startTime: number | null = null;
|
|
let pagesRead = 0;
|
|
|
|
export async function init(context: ReaderContext): Promise<void> {
|
|
context.events.on("pageChanged", trackPageRead);
|
|
|
|
setInterval(calculateReadingSpeed, 60000); // Every minute
|
|
}
|
|
|
|
function trackPageRead() {
|
|
if (!startTime) {
|
|
startTime = Date.now();
|
|
}
|
|
pagesRead++;
|
|
}
|
|
|
|
function calculateReadingSpeed() {
|
|
if (!startTime || pagesRead === 0) return;
|
|
|
|
const elapsed = Date.now() - startTime;
|
|
const minutes = elapsed / 60000;
|
|
const pagesPerMinute = pagesRead / minutes;
|
|
|
|
console.log(`Reading speed: ${pagesPerMinute.toFixed(2)} pages/minute`);
|
|
|
|
// TODO: Save to database
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Feature Interface Definitions
|
|
|
|
All feature modules should export:
|
|
|
|
```typescript
|
|
import type { ReaderContext } from "../core/reader-context";
|
|
|
|
export async function init(context: ReaderContext): Promise<void> {
|
|
// Feature initialization
|
|
}
|
|
```
|
|
|
|
Features can optionally export cleanup:
|
|
|
|
```typescript
|
|
export async function cleanup(context: ReaderContext): Promise<void> {
|
|
// Feature cleanup
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Migration Checklist
|
|
|
|
### Phase 1: Core Infrastructure
|
|
- [ ] Create `core/reader-context.ts`
|
|
- [ ] Create `core/reader-events.ts`
|
|
- [ ] Create `core/reader-state.ts`
|
|
- [ ] Create `core/reader-navigation.ts`
|
|
- [ ] Create `core/reader-services.ts`
|
|
|
|
### Phase 2: Refactor Features
|
|
- [ ] Move `gestures.ts` → `features/gestures.ts`
|
|
- [ ] Move `keyboard-shortcuts.ts` → `features/keyboard-shortcuts.ts`
|
|
- [ ] Refactor to use `init(context)` pattern
|
|
- [ ] Remove callback parameters
|
|
|
|
### Phase 3: Update reader-shell.ts
|
|
- [ ] Create feature registry
|
|
- [ ] Simplify to orchestrator pattern
|
|
- [ ] Keep Alpine integration minimal
|
|
|
|
### Phase 4: Remaining Features
|
|
- [ ] Convert `panel-dock-system.ts`
|
|
- [ ] Convert `navigator-panel.ts`
|
|
- [ ] Convert `offline-manager.ts`
|
|
- [ ] Convert `reading-speed-tracker.ts`
|
|
- [ ] Convert all PDF features
|
|
- [ ] Convert all ebook features
|
|
- [ ] Convert all manga features
|
|
|
|
### Phase 5: Cleanup
|
|
- [ ] Delete old callback-based code
|
|
- [ ] Remove unused imports
|
|
- [ ] Update type definitions
|
|
- [ ] Test all features
|