Files
bookhoard/FEATURE_REFACTOR.md
T
john-okeefe 3162d9b7ee docs: add Feature Registration Pattern refactor plan
- 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
2026-04-04 13:37:44 -04:00

38 KiB

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

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

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

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

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

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

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

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

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

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

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

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

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:

import type { ReaderContext } from "../core/reader-context";

export async function init(context: ReaderContext): Promise<void> {
  // Feature initialization
}

Features can optionally export cleanup:

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.tsfeatures/gestures.ts
  • Move keyboard-shortcuts.tsfeatures/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