refactor(reader): implement page-based pagination with progress restoration

Complete rewrite of ebook reader initialization to use page-based
pagination with CFI position tracking:

Progress restoration:
- Fetch saved reading progress on initialization via getReadingProgress
- Restore position using saved CFI or fallback to page number
- Maintain accurate position across reflowable content changes

Pagination system:
- Calculate pagination based on user settings and viewport
- Load user settings for font size, line height, and margins
- Generate page map for accurate page-to-content mapping

Viewport responsiveness:
- Implement resize handler with 300ms debounce
- Recalculate pagination on viewport changes
- Preserve reading position during recalculation

Architecture improvements:
- Update feature module imports to new directory structure
- Add pagination and position to UniversalReader interface
- Implement renderPage for initial content display
- Update UI components with accurate page/progress data

This provides the foundation for robust EPUB reading with
accurate position tracking and responsive pagination.
This commit is contained in:
2026-04-09 21:17:14 -04:00
parent cb3f282538
commit afc41642ba
+291 -37
View File
@@ -1,12 +1,12 @@
import { Alpine } from "../alpine";
import { getReaderMetadata } from "../api";
import "./comic/panel-editor";
import { getReaderMetadata, getReadingProgress } from "../api";
import "./formats/comic/panel-editor";
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";
import { renderSpineItem } from "./core/reader-navigation";
import { parseReflowable } from "./formats/reflowable/parser";
import { loadSettings } from "./settings-manager";
import {
calculatePagination,
shouldRecalculate,
@@ -14,8 +14,9 @@ import {
import { restorePosition } from "./formats/reflowable/progress-tracker";
import { applyPaginatedStyles } from "./formats/reflowable/content-renderer";
import type {
ReflowableBook,
PaginationSettings,
PaginationData,
ReadingPosition,
} from "./formats/reflowable/types";
import { updatePageDisplay, updateProgressBar } from "./ui/page-display";
@@ -25,6 +26,8 @@ export interface UniversalReader {
currentSpineIndex: number;
currentPage: number;
pageCalculationResult?: any;
pagination?: PaginationData;
position?: ReadingPosition;
}
interface PDFReader {
@@ -59,31 +62,39 @@ 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)),
(ctx) => import("./features/progress-indicator").then((m) => m.init(ctx)),
(ctx) => import("./ui/gestures").then((m) => m.init(ctx)),
(ctx) => import("./ui/keyboard-shortcuts").then((m) => m.init(ctx)),
(ctx) => import("./ui/panel-dock-system").then((m) => m.init(ctx)),
(ctx) => import("./ui/navigator-panel").then((m) => m.init(ctx)),
(ctx) => import("./ui/offline-manager").then((m) => m.init(ctx)),
(ctx) => import("./ui/reading-speed-tracker").then((m) => m.init(ctx)),
(ctx) => import("./ui/progress-indicator").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)),
(ctx) =>
import("./formats/comic/background-color").then((m) => m.init?.(ctx)),
(ctx) => import("./formats/comic/chapter-markers").then((m) => m.init?.(ctx)),
(ctx) => import("./formats/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)),
(ctx) => import("./formats/pdf/pdf-navigation").then((m) => m.init?.(ctx)),
(ctx) =>
import("./formats/pdf/pdf-text-selection").then((m) => m.init?.(ctx)),
(ctx) => import("./formats/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)),
(ctx) =>
import("./formats/reflowable/ebook/copy-handler").then((m) =>
m.init?.(ctx),
),
(ctx) =>
import("./formats/reflowable/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)),
(ctx) =>
import("./formats/manga/reading-direction").then((m) => m.init?.(ctx)),
(ctx) =>
import("./formats/manga/vertical-scroll-mode").then((m) => m.init?.(ctx)),
];
async function initializeFeatures(context: ReaderContext): Promise<void> {
@@ -174,14 +185,11 @@ async function initializeReader(): Promise<void> {
async function initializeEbookReader(metadata: any): Promise<UniversalReader> {
const { parseEbook, requiresServerParsing } =
await import("./core/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",
@@ -191,17 +199,14 @@ async function initializeEbookReader(metadata: any): Promise<UniversalReader> {
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,
@@ -213,27 +218,134 @@ async function initializeEbookReader(metadata: any): Promise<UniversalReader> {
tocLength: cif.toc?.length,
keys: Object.keys(cif),
});
console.log("Spine items detail:", JSON.stringify(cif.spine));
// ============================================================
// Fetch saved progress from backend
// ============================================================
const savedProgress = await getReadingProgress(metadata.media_item_id);
const savedCFI = savedProgress?.epubcfi;
const savedPage = savedProgress?.current_page;
console.log("Saved progress:", { savedCFI, savedPage });
// ============================================================
// Calculate pagination
// ============================================================
const userSettings = await loadSettings().catch(() => ({
font_size: 16,
line_height: 1.6,
margin_width: 20,
}));
const settings: PaginationSettings = {
fontSize: userSettings.font_size,
lineHeight: userSettings.line_height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight - 120,
wordsPerPage: 250,
};
console.log("Using settings:", {
fontSize: userSettings.font_size,
lineHeight: userSettings.line_height,
marginWidth: userSettings.margin_width,
});
const spineItems = cif.spine.map((s: any) => ({
id: s.id || s.href,
type: "html" as const,
content: s.href,
href: s.href,
}));
const pagination = await calculatePagination(
spineItems,
cif.resources,
settings,
);
console.log("Pagination calculated:", {
totalPages: pagination.totalPages,
spines: pagination.spines.length,
});
// ============================================================
// Restore saved position or start at page 1
// ============================================================
const position: ReadingPosition = restorePosition(
{
type: "epub",
spine: spineItems,
resources: cif.resources,
toc: cif.toc || [],
metadata: {
title: cif.metadata.title || metadata.title,
author: cif.metadata.author || metadata.author,
identifier: metadata.id,
},
pagination,
position: {
currentPage: 1,
spineIndex: 0,
localPageIndex: 0,
cfi: "",
progress: 0,
},
},
savedCFI || "",
savedPage,
);
console.log("Restored position:", position);
// ============================================================
// Apply paginated styles to container
// ============================================================
const container = document.getElementById("reader-content");
if (container) {
applyPaginatedStyles();
}
// ============================================================
// Render the first page
// ============================================================
const reflowableNav = await import("./formats/reflowable/navigation");
const { success, content: pageContent } = reflowableNav.goToPage(
{
type: "epub",
spine: spineItems,
resources: cif.resources,
toc: cif.toc || [],
metadata: {
title: cif.metadata.title || metadata.title,
author: cif.metadata.author || metadata.author,
identifier: metadata.id,
},
pagination,
position,
},
position.currentPage,
);
if (success && container) {
const { renderPage } =
await import("./formats/reflowable/content-renderer");
renderPage(container, pageContent);
// Update UI
updatePageDisplay(container, position.currentPage, pagination.totalPages);
updateProgressBar(container, Math.round(position.progress * 100));
}
return {
type: "ebook",
cif,
currentSpineIndex: 0,
currentPage: 1,
currentSpineIndex: position.spineIndex,
currentPage: position.currentPage,
pagination,
position,
};
}
async function initializePDFReader(metadata: any): Promise<PDFReader> {
const { initializePDFReader } = await import("./pdf/pdfjs-wrapper");
const { initializePDFReader } = await import("./formats/pdf/pdfjs-wrapper");
return initializePDFReader(metadata);
}
async function initializeComicReader(metadata: any): Promise<ComicReader> {
const { initializeComicReader } = await import("./comic/image-parser");
const { initializeComicReader } =
await import("./formats/comic/image-parser");
return initializeComicReader(metadata);
}
async function initializeMangaReader(metadata: any): Promise<MangaReader> {
const { initializeMangaReader } = await import("./comic/image-parser");
const { initializeMangaReader } =
await import("./formats/comic/image-parser");
return initializeMangaReader(metadata);
}
@@ -242,6 +354,149 @@ function getFileExtension(filepath: string): string {
return match ? `.${match[1]}` : "";
}
// ============================================================
// Viewport Resize Handler
// ============================================================
function handleResize(): void {
const state = getState();
if (!state.currentReader || state.currentReader.type !== "ebook") return;
const book = state.currentReader as UniversalReader;
if (!book.pagination) return;
loadSettings()
.then((userSettings) => {
const newSettings: PaginationSettings = {
fontSize: userSettings.font_size,
lineHeight: userSettings.line_height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight - 120,
wordsPerPage: 250,
};
if (shouldRecalculate(book.pagination, newSettings)) {
const currentCFI = book.position.cfi;
const currentPage = book.position.currentPage;
calculatePagination(book.cif.spine, book.cif.resources, newSettings)
.then((newPagination) => {
const updatedBook: UniversalReader = {
type: "ebook",
cif: book.cif,
currentSpineIndex: book.position.spineIndex,
currentPage: book.position.currentPage,
pagination: newPagination,
position: {
currentPage: currentPage,
spineIndex: book.position.spineIndex,
localPageIndex: book.position.localPageIndex,
cfi: currentCFI,
progress: currentPage / newPagination.totalPages,
},
};
setState({ currentReader: updatedBook });
const container = document.getElementById("reader-content");
if (container) {
const reflowableNav = require("./formats/reflowable/navigation");
const { success, content: pageContent } = reflowableNav.goToPage(
updatedBook,
updatedBook.position.currentPage,
);
if (success) {
const {
renderPage,
} = require("./formats/reflowable/content-renderer");
renderPage(container, pageContent);
updatePageDisplay(
container,
updatedBook.position.currentPage,
newPagination.totalPages,
);
updateProgressBar(
container,
Math.round(updatedBook.position.progress * 100),
);
}
}
})
.catch((error) => {
console.error("Pagination recalculation failed:", error);
});
}
})
.catch((error) => {
console.warn(
"Failed to load user settings for resize, using defaults:",
error,
);
const newSettings: PaginationSettings = {
fontSize: 16,
lineHeight: 1.6,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight - 120,
wordsPerPage: 250,
};
if (shouldRecalculate(book.pagination, newSettings)) {
const currentCFI = book.position.cfi;
const currentPage = book.position.currentPage;
calculatePagination(book.cif.spine, book.cif.resources, newSettings)
.then((newPagination) => {
const updatedBook: UniversalReader = {
type: "ebook",
cif: book.cif,
currentSpineIndex: book.position.spineIndex,
currentPage: book.position.currentPage,
pagination: newPagination,
position: {
currentPage: currentPage,
spineIndex: book.position.spineIndex,
localPageIndex: book.position.localPageIndex,
cfi: currentCFI,
progress: currentPage / newPagination.totalPages,
},
};
setState({ currentReader: updatedBook });
const container = document.getElementById("reader-content");
if (container) {
const reflowableNav = require("./formats/reflowable/navigation");
const { success, content: pageContent } = reflowableNav.goToPage(
updatedBook,
updatedBook.position.currentPage,
);
if (success) {
const pageData =
newPagination.pageMap.get(
updatedBook.position.currentPage - 1,
) || null;
const {
renderPage,
} = require("./formats/reflowable/content-renderer");
renderPage(container, pageContent, pageData);
updatePageDisplay(
container,
updatedBook.position.currentPage,
newPagination.totalPages,
);
updateProgressBar(
container,
Math.round(updatedBook.position.progress * 100),
);
}
}
})
.catch((error) => {
console.error("Pagination recalculation failed:", error);
});
}
});
}
function debounce(
func: (...args: any[]) => void,
wait: number,
): (...args: any[]) => void {
let timeout: any;
return function (this: any, ...args: any[]) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
window.addEventListener("resize", debounce(handleResize, 300));
// ============================================================
// Alpine.js Integration
// ============================================================
@@ -268,11 +523,10 @@ Alpine.data("readerShell", () => ({
get totalPages() {
const state = getState();
if (!state.currentReader || !state.readerMetadata) return 0;
if (state.currentReader.type === "ebook") {
// Use dynamic page calculation if available
if (state.currentReader.pageCalculationResult) {
return state.currentReader.pageCalculationResult.totalPages;
// Use pagination if available
if ((state.currentReader as UniversalReader).pagination) {
return (state.currentReader as UniversalReader).pagination!.totalPages;
}
return state.currentReader.cif.spine.length;
} else if (state.currentReader.type === "pdf") {