Files
bookhoard/web/src/reader/formats/manga/vertical-scroll-mode.ts
T
john-okeefe e4c18e51f9 refactor(reader): create modular format-specific architecture
Implement complete modularization of reader code by separating format-specific
functionality into dedicated modules. This replaces the monolithic structure
with a clean, maintainable architecture that separates concerns by format type.

## New Architecture

### Format-Specific Modules
- **formats/reflowable/**: EPUB, FB2, TXT, HTML (page-based pagination)
  - types.ts: Shared type definitions for reflowable formats
  - page-calculator.ts: Word-count based pagination with HTML slicing
  - navigation.ts: Page-based navigation logic
  - progress-tracker.ts: CFI-based progress tracking
  - content-renderer.ts: DOM rendering for page content
  - parser.ts: Unified parser interface for all reflowable formats
  - ebook/**: Migrated ebook-specific features

- **formats/pdf/**: PDF format support
  - Core PDF functionality (navigation, text selection, annotations)
  - Advanced features (bookmarks, search, outlines, dual-page)
  - Page cache and rendering optimizations

- **formats/comic/**: Comic format support
  - Background color, chapter markers, page caching
  - Page ordering, gap adjustments

- **formats/manga/**: Manga format support
  - RTL navigation, vertical scrolling, reading direction

## Key Improvements

1. **Separation of Concerns**: Each format has its own dedicated module
2. **No Circular Dependencies**: Clean import structure
3. **Type Safety**: Comprehensive TypeScript types throughout
4. **Functional Programming**: Pure functions, no OOP complexity
5. **Scalability**: Easy to add new formats without touching core code

## Migration Path

- Old format-specific code in reader/, ebook/, pdf/, comic/, manga/
- New code in formats/[format]/ structure
- Maintains backward compatibility during transition
- Core reader logic remains format-agnostic

This change enables the implementation of page-based pagination for reflowable
formats while keeping PDF, comic, and manga functionality unchanged.
2026-04-09 14:53:12 -04:00

183 lines
4.8 KiB
TypeScript

// Vertical scroll mode for webtoons/manhwa
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
let state: VerticalScrollState | null = null;
context.events.on("reader:loaded", (detail: { container: HTMLElement; mediaItemId: string; totalPages: number }) => {
state = createVerticalScroll(detail.container, detail.mediaItemId, detail.totalPages);
});
context.events.on("vertical-scroll:load-page", async (detail: { pageNumber: number }) => {
if (state) {
await loadPage(state, detail.pageNumber);
}
});
context.events.on("vertical-scroll:get-current", () => {
if (state) {
const currentPage = getCurrentPageFromScroll(state);
context.events.emit("vertical-scroll:current-page", { page: currentPage });
}
});
context.events.on("reader:unload", () => {
if (state) {
destroyVerticalScroll(state);
state = null;
}
});
}
interface VerticalScrollState {
container: HTMLElement;
loadedPages: Set<number>;
loadingPages: Set<number>;
currentPage: number;
totalPages: number;
threshold: number;
mediaItemId: string;
}
function createVerticalScroll(
container: HTMLElement,
mediaItemId: string,
totalPages: number,
): VerticalScrollState {
const state: VerticalScrollState = {
container,
loadedPages: new Set(),
loadingPages: new Set(),
currentPage: 1,
totalPages,
threshold: 500,
mediaItemId,
};
loadPage(state, 1);
setupScrollListener(state);
return state;
}
async function loadPage(
state: VerticalScrollState,
pageNumber: number,
): Promise<void> {
if (state.loadedPages.has(pageNumber) || state.loadingPages.has(pageNumber)) {
return;
}
state.loadingPages.add(pageNumber);
try {
const token = localStorage.getItem("token");
const response = await fetch(
`/readers/${state.mediaItemId}/pages/${pageNumber}`,
{
headers: { Authorization: `Bearer ${token}` },
},
);
if (!response.ok) {
throw new Error(`Failed to load page ${pageNumber}`);
}
const blob = await response.blob();
const imgUrl = URL.createObjectURL(blob);
const pageContainer = document.createElement("div");
pageContainer.className = "vertical-page";
pageContainer.dataset.pageNumber = pageNumber.toString();
const img = document.createElement("img");
img.src = imgUrl;
img.alt = `Page ${pageNumber}`;
img.loading = "lazy";
pageContainer.appendChild(img);
state.container.appendChild(pageContainer);
state.loadedPages.add(pageNumber);
state.loadingPages.delete(pageNumber);
if (pageNumber < state.totalPages) {
loadPage(state, pageNumber + 1);
if (pageNumber + 1 < state.totalPages) {
loadPage(state, pageNumber + 2);
}
}
} catch (error) {
console.error(`Failed to load page ${pageNumber}:`, error);
state.loadingPages.delete(pageNumber);
}
}
function setupScrollListener(state: VerticalScrollState): void {
let scrollTimeout: number | undefined;
state.container.addEventListener("scroll", () => {
clearTimeout(scrollTimeout);
scrollTimeout = window.setTimeout(() => {
checkScrollPosition(state);
}, 100);
});
}
function checkScrollPosition(state: VerticalScrollState): void {
const scrollBottom =
state.container.scrollHeight -
state.container.scrollTop -
state.container.clientHeight;
if (scrollBottom < state.threshold) {
const lastPage = Math.max(...state.loadedPages);
if (lastPage < state.totalPages) {
loadPage(state, lastPage + 1);
}
}
const currentPage = getCurrentPageFromScroll(state);
if (currentPage !== state.currentPage) {
state.currentPage = currentPage;
window.dispatchEvent(
new CustomEvent("page-change", {
detail: { page: currentPage },
}),
);
}
}
function getCurrentPageFromScroll(state: VerticalScrollState): number {
const pages = Array.from(state.container.querySelectorAll(".vertical-page"));
for (const page of pages) {
const rect = page.getBoundingClientRect();
const containerRect = state.container.getBoundingClientRect();
const pageMiddle = rect.top + rect.height / 2;
const viewportMiddle = containerRect.top + containerRect.height / 2;
if (Math.abs(pageMiddle - viewportMiddle) < containerRect.height / 4) {
return parseInt((page as HTMLElement).dataset.pageNumber || "1");
}
}
return state.currentPage;
}
function destroyVerticalScroll(state: VerticalScrollState): void {
const images = Array.from(state.container.querySelectorAll("img"));
images.forEach((img) => {
const url = img.src;
if (url.startsWith("blob:")) {
URL.revokeObjectURL(url);
}
});
state.container.innerHTML = "";
state.loadedPages.clear();
state.loadingPages.clear();
}