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.
183 lines
4.8 KiB
TypeScript
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();
|
|
} |