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:
+291
-37
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user