feat(ebook-reader): implement page-based pagination with CFI support
This commit implements true page-based pagination for the ebook reader, similar to Kindle's approach where content is split into discrete pages based on viewport size, font settings, and content layout. Phase 1 - Bug Fixes: - Fix goToPage to use getScrollPositionForPage instead of treating page numbers as spine indices - Move page calculation to initialize before first render to avoid race condition with scroll handler - Add fallback page calculation when pageCalculationResult is null Phase 2 - Page Splitting: - Add new page-splitter.ts module with CFI-based splitting for EPUB and height-based fallback for other formats - Integrate splitContent into page-calculator to generate discrete page content for each spine item - Store pages[] array in ChapterPageInfo for rendering - Rewrite navigation (nextPage, previousPage, renderSpineItem) to use page-based approach with currentPage tracking - Remove old scroll-based pagination from view-modes.ts - Update paginated mode CSS for true page clipping Phase 3 - Progress Display: - Update progress-indicator to use currentPage directly instead of calculating from scroll position - Fix getCurrentPage in reader-state to return currentPage for ebooks Phase 4 - Interface Fixes: - Add currentPage field to UniversalReader interface - Update sendProgressUpdate to use currentPage directly - Initialize currentPage to 1 on reader creation Key features: - Dynamic page count based on font size, line height, margins - CFI-based page splitting preserves reading context - Falls back to height-based splitting for non-EPUB formats - Progress display updates immediately on page change - Settings changes trigger page recalculation and re-render
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
import { getDefaultSettings } from "../settings-manager";
|
||||
import { getState, setState, getCurrentPage } from "./reader-state";
|
||||
import { getState, setState } from "./reader-state";
|
||||
import { readerEvents } from "./reader-events";
|
||||
import { updateReadingProgress } from "./reader-services";
|
||||
import {
|
||||
calculatePagesForEbook,
|
||||
calculateProgressPercentage,
|
||||
getCurrentPageFromScroll,
|
||||
type PageCalculationResult,
|
||||
} from "../ebook/page-calculator";
|
||||
import { UniversalReader } from "../reader-shell";
|
||||
@@ -18,46 +17,45 @@ export function createNavigationAPI() {
|
||||
nextPage: () => {
|
||||
const state = getState();
|
||||
if (!state.currentReader) return;
|
||||
|
||||
readerEvents.emit("beforePageChange", state.currentReader);
|
||||
|
||||
if (state.currentReader.type === "ebook") {
|
||||
const container = document.getElementById("reader-content");
|
||||
const viewportHeightAdjusted = window.innerHeight - 120;
|
||||
|
||||
if (pageCalculationResult) {
|
||||
const currentChapter = pageCalculationResult.chapterMap.get(
|
||||
state.currentReader.currentSpineIndex,
|
||||
);
|
||||
const currentPage = state.currentReader.currentPage || 1;
|
||||
|
||||
if (currentChapter && container) {
|
||||
const currentPageInChapter = Math.floor(
|
||||
container.scrollTop / viewportHeightAdjusted,
|
||||
if (currentPage < pageCalculationResult.totalPages) {
|
||||
const nextPage = currentPage + 1;
|
||||
state.currentReader.currentPage = nextPage;
|
||||
|
||||
// Check if we need to move to next spine
|
||||
const currentChapter = pageCalculationResult.chapterMap.get(
|
||||
state.currentReader.currentSpineIndex,
|
||||
);
|
||||
const pagesInCurrentChapter =
|
||||
currentChapter.endPage - currentChapter.startPage + 1;
|
||||
|
||||
if (currentPageInChapter < pagesInCurrentChapter - 1) {
|
||||
// Still pages left in current chapter - just scroll down
|
||||
container.scrollTop =
|
||||
(currentPageInChapter + 1) * viewportHeightAdjusted;
|
||||
} else if (
|
||||
state.currentReader.currentSpineIndex <
|
||||
state.currentReader.cif.spine.length - 1
|
||||
) {
|
||||
// At end of chapter, move to next spine
|
||||
state.currentReader.currentSpineIndex++;
|
||||
renderSpineItem();
|
||||
if (currentChapter && nextPage > currentChapter.endPage) {
|
||||
// Move to next spine
|
||||
if (
|
||||
state.currentReader.currentSpineIndex <
|
||||
state.currentReader.cif.spine.length - 1
|
||||
) {
|
||||
state.currentReader.currentSpineIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
setState({ currentReader: state.currentReader });
|
||||
renderSpineItem();
|
||||
sendProgressUpdate();
|
||||
readerEvents.emit("pageChanged", nextPage);
|
||||
}
|
||||
} else {
|
||||
// Fallback: just move to next spine
|
||||
// Fallback: spine-based navigation
|
||||
if (
|
||||
state.currentReader.currentSpineIndex <
|
||||
state.currentReader.cif.spine.length - 1
|
||||
) {
|
||||
state.currentReader.currentSpineIndex++;
|
||||
setState({ currentReader: state.currentReader });
|
||||
renderSpineItem();
|
||||
sendProgressUpdate();
|
||||
}
|
||||
}
|
||||
} else if (state.currentReader.type === "pdf") {
|
||||
@@ -78,48 +76,54 @@ export function createNavigationAPI() {
|
||||
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") {
|
||||
const container = document.getElementById("reader-content");
|
||||
const viewportHeightAdjusted = window.innerHeight - 120;
|
||||
|
||||
if (pageCalculationResult) {
|
||||
const currentChapter = pageCalculationResult.chapterMap.get(
|
||||
state.currentReader.currentSpineIndex,
|
||||
);
|
||||
const currentPage = state.currentReader.currentPage || 1;
|
||||
|
||||
if (currentChapter && container) {
|
||||
const currentPageInChapter = Math.floor(
|
||||
container.scrollTop / viewportHeightAdjusted,
|
||||
if (currentPage > 1) {
|
||||
const prevPage = currentPage - 1;
|
||||
|
||||
// Check if we need to move to previous spine
|
||||
const currentChapter = pageCalculationResult.chapterMap.get(
|
||||
state.currentReader.currentSpineIndex,
|
||||
);
|
||||
|
||||
if (currentPageInChapter > 0) {
|
||||
// Not at start of chapter - just scroll up
|
||||
container.scrollTop =
|
||||
(currentPageInChapter - 1) * viewportHeightAdjusted;
|
||||
} else if (state.currentReader.currentSpineIndex > 0) {
|
||||
// At start of chapter, move to previous spine
|
||||
state.currentReader.currentSpineIndex--;
|
||||
renderSpineItem();
|
||||
if (currentChapter && prevPage < currentChapter.startPage) {
|
||||
// Move to previous spine
|
||||
if (state.currentReader.currentSpineIndex > 0) {
|
||||
state.currentReader.currentSpineIndex--;
|
||||
const prevChapter = pageCalculationResult.chapterMap.get(
|
||||
state.currentReader.currentSpineIndex,
|
||||
);
|
||||
state.currentReader.currentPage =
|
||||
prevChapter?.endPage || prevPage;
|
||||
} else {
|
||||
state.currentReader.currentPage = 1;
|
||||
}
|
||||
} else {
|
||||
state.currentReader.currentPage = prevPage;
|
||||
}
|
||||
|
||||
setState({ currentReader: state.currentReader });
|
||||
renderSpineItem();
|
||||
sendProgressUpdate();
|
||||
readerEvents.emit("pageChanged", state.currentReader.currentPage);
|
||||
}
|
||||
} else {
|
||||
// Fallback: just move to previous spine
|
||||
// Fallback: spine-based navigation
|
||||
if (state.currentReader.currentSpineIndex > 0) {
|
||||
state.currentReader.currentSpineIndex--;
|
||||
setState({ currentReader: state.currentReader });
|
||||
renderSpineItem();
|
||||
sendProgressUpdate();
|
||||
}
|
||||
}
|
||||
} else if (state.currentReader.type === "pdf") {
|
||||
@@ -136,23 +140,41 @@ export function createNavigationAPI() {
|
||||
renderComicPage();
|
||||
}
|
||||
}
|
||||
|
||||
setState({ currentReader: state.currentReader });
|
||||
sendProgressUpdate();
|
||||
readerEvents.emit("pageChanged", getCurrentPage());
|
||||
readerEvents.emit("afterPageChange", state.currentReader);
|
||||
},
|
||||
|
||||
goToPage: (page: number) => {
|
||||
goToPage: async (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();
|
||||
if (pageCalculationResult) {
|
||||
// Validate page number
|
||||
if (page < 1 || page > pageCalculationResult.totalPages) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find which spine contains this page
|
||||
for (const chapter of pageCalculationResult.chapters) {
|
||||
if (page >= chapter.startPage && page <= chapter.endPage) {
|
||||
state.currentReader.currentSpineIndex = chapter.spineIndex;
|
||||
state.currentReader.currentPage = page;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setState({ currentReader: state.currentReader });
|
||||
await renderSpineItem();
|
||||
sendProgressUpdate();
|
||||
} else {
|
||||
// Fallback: treat page number as spine index
|
||||
if (page >= 0 && page < state.currentReader.cif.spine.length) {
|
||||
state.currentReader.currentSpineIndex = page;
|
||||
state.currentReader.currentPage = page + 1;
|
||||
setState({ currentReader: state.currentReader });
|
||||
await renderSpineItem();
|
||||
}
|
||||
}
|
||||
} else if (state.currentReader.type === "pdf") {
|
||||
if (page >= 1 && page <= (state.readerMetadata?.total_pages || 0)) {
|
||||
@@ -163,14 +185,12 @@ export function createNavigationAPI() {
|
||||
state.currentReader.type === "comic" ||
|
||||
state.currentReader.type === "manga"
|
||||
) {
|
||||
if (page >= 0 && page < state.currentReader.images.length) {
|
||||
if (page >= 0 && page < (state.currentReader as any).images.length) {
|
||||
state.currentReader.currentPage = page;
|
||||
renderComicPage();
|
||||
}
|
||||
}
|
||||
|
||||
setState({ currentReader: state.currentReader });
|
||||
sendProgressUpdate();
|
||||
readerEvents.emit("pageChanged", page);
|
||||
readerEvents.emit("afterPageChange", state.currentReader);
|
||||
},
|
||||
@@ -195,9 +215,7 @@ export async function initializePageCalculation() {
|
||||
readerEvents.on("settings:changed", async (settings: any) => {
|
||||
const state = getState();
|
||||
if (!state.currentReader || state.currentReader.type !== "ebook") return;
|
||||
|
||||
console.log("Recalculating pages due to settings change...");
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
pageCalculationResult = await calculatePagesForEbook(
|
||||
state.currentReader.cif,
|
||||
@@ -208,9 +226,21 @@ export async function initializePageCalculation() {
|
||||
marginWidth: settings.margin_width,
|
||||
},
|
||||
);
|
||||
|
||||
state.currentReader.pageCalculationResult = pageCalculationResult;
|
||||
setState({ currentReader: state.currentReader });
|
||||
// Re-render the current page with new settings
|
||||
await renderSpineItem();
|
||||
|
||||
// Emit event so progress display updates
|
||||
const currentPage = state.currentReader.currentPage || 1;
|
||||
readerEvents.emit("progressUpdated", {
|
||||
currentPage,
|
||||
totalPages: pageCalculationResult.totalPages,
|
||||
percentage: calculateProgressPercentage(
|
||||
pageCalculationResult,
|
||||
currentPage,
|
||||
),
|
||||
});
|
||||
});
|
||||
const state = getState();
|
||||
if (!state.currentReader || state.currentReader.type !== "ebook") return;
|
||||
@@ -250,104 +280,67 @@ export async function initializePageCalculation() {
|
||||
export async function renderSpineItem() {
|
||||
const state = getState();
|
||||
if (state.currentReader?.type !== "ebook") return;
|
||||
|
||||
if (!state.currentReader.currentPage) {
|
||||
state.currentReader.currentPage = 1;
|
||||
}
|
||||
|
||||
const container = document.getElementById("reader-content");
|
||||
if (!container) return;
|
||||
|
||||
const spineItem =
|
||||
state.currentReader.cif.spine[state.currentReader.currentSpineIndex];
|
||||
const container = document.getElementById("reader-content");
|
||||
if (pageCalculationResult) {
|
||||
const viewportHeight = window.innerHeight;
|
||||
const currentPage = getCurrentPageFromScroll(
|
||||
pageCalculationResult,
|
||||
state.currentReader.currentSpineIndex,
|
||||
container.scrollTop,
|
||||
viewportHeight,
|
||||
);
|
||||
if (!spineItem) return;
|
||||
// Get page calculation result
|
||||
const chapter = pageCalculationResult?.chapterMap.get(
|
||||
state.currentReader.currentSpineIndex,
|
||||
);
|
||||
|
||||
// Store computed page for UI display
|
||||
state.currentReader.currentPage = currentPage;
|
||||
setState({ currentReader: state.currentReader });
|
||||
// Calculate which page within the chapter we're on
|
||||
let currentPageContent = "";
|
||||
let currentPageNumber = state.currentReader.currentPage || 1;
|
||||
|
||||
const percentage = calculateProgressPercentage(
|
||||
pageCalculationResult,
|
||||
currentPage,
|
||||
);
|
||||
readerEvents.emit("progressUpdated", {
|
||||
currentPage,
|
||||
totalPages: pageCalculationResult.totalPages,
|
||||
percentage,
|
||||
});
|
||||
if (chapter?.pages && chapter.pages.length > 0) {
|
||||
// Calculate which page of this spine item to show
|
||||
const globalPage = state.currentReader.currentPage || 1;
|
||||
const pageWithinChapter = Math.max(0, globalPage - chapter.startPage);
|
||||
const pageIndex = Math.min(pageWithinChapter, chapter.pages.length - 1);
|
||||
|
||||
currentPageContent = chapter.pages[pageIndex]?.html || chapter.content;
|
||||
currentPageNumber = chapter.startPage + pageIndex;
|
||||
} else {
|
||||
// Fallback: load full content if pages not calculated yet
|
||||
const resources = state.currentReader.cif.resources;
|
||||
const contentBlob = resources?.get(spineItem.content);
|
||||
if (contentBlob) {
|
||||
currentPageContent = await contentBlob.text();
|
||||
} else {
|
||||
console.error(
|
||||
"Spine item content not found in resources:",
|
||||
spineItem.content,
|
||||
);
|
||||
container.innerHTML = `<p>Error: Could not load chapter content</p>`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!container || !spineItem) return;
|
||||
// Get the actual content from resources using the href
|
||||
const resources = state.currentReader.cif.resources;
|
||||
console.log("ALL resource keys:", Array.from(resources.keys()));
|
||||
const contentBlob = resources?.get(spineItem.content);
|
||||
if (!contentBlob) {
|
||||
// Fallback: try to fetch directly if not in resources
|
||||
console.error(
|
||||
"Spine item content not found in resources:",
|
||||
spineItem.content,
|
||||
);
|
||||
container.innerHTML = `<p>Error: Could not load chapter content</p>`;
|
||||
container.addEventListener(
|
||||
"scroll",
|
||||
() => {
|
||||
const state = getState();
|
||||
if (!state.currentReader || state.currentReader.type !== "ebook")
|
||||
return;
|
||||
// Process and render the page content
|
||||
const modifiedContent = rewriteImageUrls(
|
||||
currentPageContent,
|
||||
state.currentReader.cif.resources,
|
||||
);
|
||||
|
||||
state.currentReader.currentScrollPosition = container.scrollTop;
|
||||
|
||||
if (pageCalculationResult) {
|
||||
const viewportHeight = window.innerHeight;
|
||||
const currentPage = getCurrentPageFromScroll(
|
||||
pageCalculationResult,
|
||||
state.currentReader.currentSpineIndex,
|
||||
container.scrollTop,
|
||||
viewportHeight,
|
||||
);
|
||||
|
||||
const percentage = calculateProgressPercentage(
|
||||
pageCalculationResult,
|
||||
currentPage,
|
||||
);
|
||||
readerEvents.emit("progressUpdated", {
|
||||
currentPage,
|
||||
totalPages: pageCalculationResult.totalPages,
|
||||
percentage,
|
||||
});
|
||||
}
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Convert blob to text
|
||||
const contentText = await contentBlob.text();
|
||||
|
||||
// Rewrite image src paths to use blob URLs from resources
|
||||
const modifiedContent = rewriteImageUrls(contentText, resources);
|
||||
|
||||
const htmlImages = modifiedContent.match(/<img[^>]+>/g);
|
||||
const svgImages =
|
||||
modifiedContent.match(/<image[^>]+xlink:href="([^"]+)"[^>]*>/gi) || [];
|
||||
const allImages = [...htmlImages, ...svgImages];
|
||||
console.log("Found images in content:", allImages);
|
||||
|
||||
// Parse HTML and process images BEFORE setting innerHTML
|
||||
// Parse HTML and process images
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(modifiedContent, "text/html");
|
||||
|
||||
// Process <img> elements
|
||||
const imgElements = Array.from(doc.querySelectorAll("img"));
|
||||
for (const img of imgElements) {
|
||||
const src = img.getAttribute("src");
|
||||
if (!src) continue;
|
||||
|
||||
// Try different path formats to find the image
|
||||
let blob = findImageInResources(resources, src);
|
||||
|
||||
const blob = findImageInResources(state.currentReader.cif.resources, src);
|
||||
if (blob) {
|
||||
// Create blob URL - need to do this properly
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
img.setAttribute("src", blobUrl);
|
||||
img.setAttribute("src", URL.createObjectURL(blob));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,18 +349,33 @@ export async function renderSpineItem() {
|
||||
for (const img of svgImgElements) {
|
||||
const src = img.getAttribute("xlink:href");
|
||||
if (!src) continue;
|
||||
|
||||
let blob = findImageInResources(resources, src);
|
||||
const blob = findImageInResources(state.currentReader.cif.resources, src);
|
||||
if (blob) {
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
img.setAttribute("xlink:href", blobUrl);
|
||||
img.setAttribute("xlink:href", URL.createObjectURL(blob));
|
||||
}
|
||||
}
|
||||
|
||||
// Apply to container - replace innerHTML with current page content
|
||||
container.innerHTML = doc.body.innerHTML;
|
||||
// Apply reader styling
|
||||
|
||||
// Apply styling
|
||||
applyReaderTheme();
|
||||
applyTypography();
|
||||
|
||||
// Emit progress update
|
||||
if (pageCalculationResult) {
|
||||
state.currentReader.currentPage = currentPageNumber;
|
||||
setState({ currentReader: state.currentReader });
|
||||
|
||||
readerEvents.emit("progressUpdated", {
|
||||
currentPage: currentPageNumber,
|
||||
totalPages: pageCalculationResult.totalPages,
|
||||
percentage: calculateProgressPercentage(
|
||||
pageCalculationResult,
|
||||
currentPageNumber,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function rewriteImageUrls(
|
||||
@@ -523,14 +531,7 @@ function sendProgressUpdate() {
|
||||
if (state.currentReader.type === "ebook") {
|
||||
// Use dynamic page calculation if available
|
||||
if (pageCalculationResult) {
|
||||
const container = document.getElementById("reader-content");
|
||||
const viewportHeight = window.innerHeight;
|
||||
currentPage = getCurrentPageFromScroll(
|
||||
pageCalculationResult,
|
||||
state.currentReader.currentSpineIndex,
|
||||
container?.scrollTop || 0,
|
||||
viewportHeight,
|
||||
);
|
||||
currentPage = state.currentReader.currentPage || 1;
|
||||
totalPages = pageCalculationResult.totalPages;
|
||||
} else {
|
||||
currentPage = state.currentReader.currentSpineIndex + 1;
|
||||
|
||||
@@ -35,9 +35,8 @@ export function getReaderMetadata(): ReaderMetadata | null {
|
||||
export function getCurrentPage(): number {
|
||||
const reader = currentState.currentReader;
|
||||
if (!reader) return 0;
|
||||
|
||||
if (reader.type === "ebook") {
|
||||
return reader.currentSpineIndex;
|
||||
return reader.currentPage ?? reader.currentSpineIndex + 1;
|
||||
}
|
||||
return reader.currentPage;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { findImageInResources } from "../core/reader-navigation";
|
||||
import { splitContent, type PageContent } from "./page-splitter";
|
||||
|
||||
export interface ChapterPageInfo {
|
||||
spineIndex: number;
|
||||
@@ -9,6 +10,7 @@ export interface ChapterPageInfo {
|
||||
scrollHeight: number;
|
||||
charCount: number;
|
||||
pagesInChapter: number;
|
||||
pages: PageContent[];
|
||||
}
|
||||
|
||||
export interface PageCalculationResult {
|
||||
@@ -111,14 +113,6 @@ async function renderContentForMeasurement(
|
||||
return container;
|
||||
}
|
||||
|
||||
function calculatePageBreaks(
|
||||
scrollHeight: number,
|
||||
viewportHeight: number,
|
||||
): number {
|
||||
if (scrollHeight <= 0 || viewportHeight <= 0) return 1;
|
||||
return Math.ceil(scrollHeight / viewportHeight);
|
||||
}
|
||||
|
||||
export async function calculatePagesForEbook(
|
||||
cif: EbookCIF,
|
||||
viewportWidth: number,
|
||||
@@ -159,6 +153,7 @@ export async function calculatePagesForEbook(
|
||||
scrollHeight: 0,
|
||||
charCount: 0,
|
||||
pagesInChapter: 0,
|
||||
pages: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -174,6 +169,7 @@ export async function calculatePagesForEbook(
|
||||
scrollHeight: 0,
|
||||
charCount: 0,
|
||||
pagesInChapter: 0,
|
||||
pages: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -182,41 +178,42 @@ export async function calculatePagesForEbook(
|
||||
const charCount = contentText.replace(/<[^>]*>/g, "").length;
|
||||
|
||||
let scrollHeight = 0;
|
||||
let pagesInChapter = 1;
|
||||
|
||||
try {
|
||||
const container = await renderContentForMeasurement(
|
||||
contentText,
|
||||
cif.resources,
|
||||
config,
|
||||
);
|
||||
|
||||
scrollHeight = container.scrollHeight;
|
||||
pagesInChapter = calculatePageBreaks(scrollHeight, viewportHeight);
|
||||
|
||||
container.remove();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Failed to measure content for spine item:",
|
||||
spineItem.id,
|
||||
error,
|
||||
);
|
||||
pagesInChapter = Math.max(1, Math.ceil(charCount / 1500));
|
||||
console.warn("Failed to measure content:", spineItem.id, error);
|
||||
}
|
||||
|
||||
// Split content into discrete pages
|
||||
const pageSplitResult = splitContent(
|
||||
contentText,
|
||||
i,
|
||||
spineItem.id,
|
||||
viewportHeight,
|
||||
{ fontSize, lineHeight, marginWidth },
|
||||
true, // useCFI
|
||||
);
|
||||
|
||||
const chapterInfo: ChapterPageInfo = {
|
||||
spineIndex: i,
|
||||
spineItemId: spineItem.id,
|
||||
content: contentText,
|
||||
startPage: currentPage,
|
||||
endPage: currentPage + pagesInChapter - 1,
|
||||
endPage: currentPage + pageSplitResult.totalPages - 1,
|
||||
scrollHeight,
|
||||
charCount,
|
||||
pagesInChapter,
|
||||
pagesInChapter: pageSplitResult.totalPages,
|
||||
pages: pageSplitResult.pages,
|
||||
};
|
||||
|
||||
chapters.push(chapterInfo);
|
||||
currentPage += pagesInChapter;
|
||||
currentPage += pageSplitResult.totalPages;
|
||||
}
|
||||
|
||||
const chapterMap = new Map<number, ChapterPageInfo>();
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
// Page splitter for ebooks - extracts discrete page content
|
||||
// Uses CFI for EPUB when available, falls back to height-based splitting
|
||||
import { generateCFI } from "./cfi-navigator";
|
||||
export interface PageContent {
|
||||
pageNumber: number;
|
||||
spineIndex: number;
|
||||
startCFI?: string;
|
||||
endCFI?: string;
|
||||
html: string;
|
||||
charCount: number;
|
||||
}
|
||||
export interface PageSplitResult {
|
||||
pages: PageContent[];
|
||||
totalPages: number;
|
||||
}
|
||||
// Split content by viewport height (fallback for non-EPUB)
|
||||
export function splitByHeight(
|
||||
html: string,
|
||||
spineIndex: number,
|
||||
viewportHeight: number,
|
||||
settings: { fontSize: number; lineHeight: number; marginWidth: number },
|
||||
): PageSplitResult {
|
||||
const pages: PageContent[] = [];
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
|
||||
// Clone body content
|
||||
const content = doc.body;
|
||||
const contentHeight = estimateContentHeight(content, settings);
|
||||
const contentWidth = doc.body.scrollWidth || 600;
|
||||
const pageCount = Math.max(1, Math.ceil(contentHeight / viewportHeight));
|
||||
|
||||
// For height-based splitting, we'll use a different approach:
|
||||
// Wrap each block element and measure cumulative height
|
||||
const blocks = Array.from(content.children);
|
||||
|
||||
let currentPageHTML = "";
|
||||
let currentHeight = 0;
|
||||
let pageNumber = 1;
|
||||
|
||||
for (const block of blocks) {
|
||||
const blockHeight = estimateBlockHeight(block, settings, contentWidth);
|
||||
|
||||
if (
|
||||
currentHeight + blockHeight > viewportHeight &&
|
||||
currentPageHTML.length > 0
|
||||
) {
|
||||
// Save current page and start new one
|
||||
pages.push({
|
||||
pageNumber,
|
||||
spineIndex,
|
||||
html: wrapInPageContainer(currentPageHTML, pageNumber, pageCount),
|
||||
charCount: currentPageHTML.replace(/<[^>]*>/g, "").length,
|
||||
});
|
||||
pageNumber++;
|
||||
currentPageHTML = "";
|
||||
currentHeight = 0;
|
||||
}
|
||||
|
||||
currentPageHTML += block.outerHTML;
|
||||
currentHeight += blockHeight;
|
||||
}
|
||||
|
||||
// Push final page
|
||||
if (currentPageHTML.length > 0 || pages.length === 0) {
|
||||
pages.push({
|
||||
pageNumber,
|
||||
spineIndex,
|
||||
html: wrapInPageContainer(currentPageHTML, pageNumber, pageCount),
|
||||
charCount: currentPageHTML.replace(/<[^>]*>/g, "").length,
|
||||
});
|
||||
}
|
||||
|
||||
return { pages, totalPages: pages.length };
|
||||
}
|
||||
// Split content using CFI (for EPUB)
|
||||
export function splitByCFI(
|
||||
html: string,
|
||||
spineIndex: number,
|
||||
spineItemId: string,
|
||||
viewportHeight: number,
|
||||
settings: { fontSize: number; lineHeight: number; marginWidth: number },
|
||||
): PageSplitResult {
|
||||
console.log("splitByCFI called:", {
|
||||
htmlLength: html.length,
|
||||
viewportHeight,
|
||||
spineIndex,
|
||||
});
|
||||
const pages: PageContent[] = [];
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
|
||||
// Find all text-containing elements (paragraphs, divs, etc.)
|
||||
const elements = findTextElements(doc.body);
|
||||
|
||||
let currentPageElements: Element[] = [];
|
||||
let currentHeight = 0;
|
||||
const contentWidth = doc.body.scrollWidth || 600;
|
||||
let pageNumber = 1;
|
||||
let elementIndex = 0;
|
||||
|
||||
for (const element of elements) {
|
||||
const elementHeight = estimateBlockHeight(element, settings, contentWidth);
|
||||
|
||||
if (
|
||||
currentHeight + elementHeight > viewportHeight &&
|
||||
currentPageElements.length > 0
|
||||
) {
|
||||
// Create page from accumulated elements
|
||||
const pageHTML = elementsToHTML(currentPageElements);
|
||||
const startIndex = elements.indexOf(currentPageElements[0]);
|
||||
const endIndex = elements.indexOf(
|
||||
currentPageElements[currentPageElements.length - 1],
|
||||
);
|
||||
|
||||
pages.push({
|
||||
pageNumber,
|
||||
spineIndex,
|
||||
startCFI: generateCFI(
|
||||
spineIndex,
|
||||
buildElementPath(elements[startIndex], doc.body),
|
||||
0,
|
||||
spineItemId,
|
||||
),
|
||||
endCFI: generateCFI(
|
||||
spineIndex,
|
||||
buildElementPath(elements[endIndex], doc.body),
|
||||
0,
|
||||
spineItemId,
|
||||
),
|
||||
html: wrapInPageContainer(pageHTML, pageNumber, 0), // pageCount TBD
|
||||
charCount: pageHTML.replace(/<[^>]*>/g, "").length,
|
||||
});
|
||||
|
||||
pageNumber++;
|
||||
currentPageElements = [];
|
||||
currentHeight = 0;
|
||||
}
|
||||
|
||||
currentPageElements.push(element);
|
||||
currentHeight += elementHeight;
|
||||
elementIndex++;
|
||||
}
|
||||
|
||||
// Push final page
|
||||
if (currentPageElements.length > 0) {
|
||||
const pageHTML = elementsToHTML(currentPageElements);
|
||||
const startIndex = elements.indexOf(currentPageElements[0]);
|
||||
const endIndex = elements.indexOf(
|
||||
currentPageElements[currentPageElements.length - 1],
|
||||
);
|
||||
|
||||
pages.push({
|
||||
pageNumber,
|
||||
spineIndex,
|
||||
startCFI: generateCFI(
|
||||
spineIndex,
|
||||
buildElementPath(elements[startIndex], doc.body),
|
||||
0,
|
||||
spineItemId,
|
||||
),
|
||||
endCFI: generateCFI(
|
||||
spineIndex,
|
||||
buildElementPath(elements[endIndex], doc.body),
|
||||
0,
|
||||
spineItemId,
|
||||
),
|
||||
html: wrapInPageContainer(pageHTML, pageNumber, pageNumber),
|
||||
charCount: pageHTML.replace(/<[^>]*>/g, "").length,
|
||||
});
|
||||
}
|
||||
console.log("splitByCFI result:", {
|
||||
totalPages: pages.length,
|
||||
elementsFound: elements.length,
|
||||
contentWidth,
|
||||
});
|
||||
return { pages, totalPages: pages.length };
|
||||
}
|
||||
// Helper: Estimate content height
|
||||
function estimateContentHeight(element: Element, settings: any): number {
|
||||
const text = element.textContent || "";
|
||||
const charCount = text.length;
|
||||
const charsPerLine = Math.floor(
|
||||
(800 - settings.marginWidth * 2) / (settings.fontSize * 0.6),
|
||||
);
|
||||
const lineCount = Math.ceil(charCount / charsPerLine);
|
||||
return lineCount * settings.fontSize * settings.lineHeight;
|
||||
}
|
||||
// Helper: Estimate block height
|
||||
function estimateBlockHeight(
|
||||
element: Element,
|
||||
settings: any,
|
||||
contentWidth: number,
|
||||
): number {
|
||||
const text = element.textContent || "";
|
||||
const approxChars = text.length;
|
||||
const lineHeight = settings.fontSize * settings.lineHeight;
|
||||
// Use actual content width instead of hardcoded 800
|
||||
const charsPerLine = Math.floor(contentWidth / (settings.fontSize * 0.6));
|
||||
const lineCount = Math.max(1, Math.ceil(approxChars / charsPerLine));
|
||||
return lineCount * lineHeight + 16;
|
||||
}
|
||||
// Helper: Find text-containing elements using querySelectorAll
|
||||
function findTextElements(element: Element): Element[] {
|
||||
const selector = [
|
||||
"p",
|
||||
"div",
|
||||
"span",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"li",
|
||||
"td",
|
||||
"th",
|
||||
"article",
|
||||
"section",
|
||||
"blockquote",
|
||||
"pre",
|
||||
"figcaption",
|
||||
"header",
|
||||
"footer",
|
||||
"main",
|
||||
"aside",
|
||||
].join(",");
|
||||
|
||||
return Array.from(element.querySelectorAll(selector)).filter((el) => {
|
||||
const text = el.textContent?.trim() || "";
|
||||
return text.length > 0;
|
||||
});
|
||||
}
|
||||
// Helper: Convert elements to HTML string
|
||||
function elementsToHTML(elements: Element[]): string {
|
||||
return elements.map((el) => el.outerHTML).join("");
|
||||
}
|
||||
// Helper: Build CFI element path
|
||||
function buildElementPath(element: Element, root: Element): number[] {
|
||||
const path: number[] = [];
|
||||
let current: Element | null = element;
|
||||
|
||||
while (current && current !== root) {
|
||||
const parent = current.parentElement;
|
||||
if (parent) {
|
||||
const siblings = Array.from(parent.children);
|
||||
const index = siblings.indexOf(current);
|
||||
path.unshift(index);
|
||||
current = parent;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
// Helper: Wrap content in page container
|
||||
function wrapInPageContainer(
|
||||
html: string,
|
||||
current: number,
|
||||
total: number,
|
||||
): string {
|
||||
return `<div class="ebook-page" data-page="${current}" data-total="${total}">${html}</div>`;
|
||||
}
|
||||
// Main entry point - choose method based on format
|
||||
export function splitContent(
|
||||
html: string,
|
||||
spineIndex: number,
|
||||
spineItemId: string | null,
|
||||
viewportHeight: number,
|
||||
settings: { fontSize: number; lineHeight: number; marginWidth: number },
|
||||
useCFI: boolean = true,
|
||||
): PageSplitResult {
|
||||
if (useCFI && spineItemId) {
|
||||
return splitByCFI(html, spineIndex, spineItemId, viewportHeight, settings);
|
||||
}
|
||||
return splitByHeight(html, spineIndex, viewportHeight, settings);
|
||||
}
|
||||
@@ -56,10 +56,10 @@ function setViewMode(container: HTMLElement, mode: ViewMode): void {
|
||||
|
||||
switch (mode) {
|
||||
case "paginated":
|
||||
applyPaginatedMode(container, element);
|
||||
applyPaginatedMode(element);
|
||||
break;
|
||||
case "scrolled":
|
||||
applyScrolledMode(container, element);
|
||||
applyScrolledMode(element);
|
||||
break;
|
||||
case "single-column":
|
||||
applySingleColumn(element);
|
||||
@@ -70,28 +70,56 @@ function setViewMode(container: HTMLElement, mode: ViewMode): void {
|
||||
}
|
||||
}
|
||||
|
||||
function applyPaginatedMode(
|
||||
container: HTMLElement,
|
||||
element: HTMLElement,
|
||||
): void {
|
||||
function applyPaginatedMode(element: HTMLElement): void {
|
||||
element.classList.add("paginated");
|
||||
|
||||
element.style.height = "100vh";
|
||||
// True pagination: content fits exactly in viewport, no scrolling
|
||||
element.style.height = "calc(100vh - 120px)"; // Account for chrome (top 60px + bottom 60px)
|
||||
element.style.overflow = "hidden";
|
||||
element.style.columnCount = "1";
|
||||
element.style.columnGap = "0";
|
||||
element.style.position = "relative";
|
||||
|
||||
enablePagination(container, element);
|
||||
// Inject CSS for page clipping
|
||||
injectPaginatedStyles();
|
||||
}
|
||||
|
||||
function applyScrolledMode(container: HTMLElement, element: HTMLElement): void {
|
||||
function injectPaginatedStyles(): void {
|
||||
// Remove existing injected styles if any
|
||||
const existing = document.getElementById("paginated-styles");
|
||||
existing?.remove();
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = "paginated-styles";
|
||||
style.textContent = `
|
||||
.paginated .ebook-page {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.paginated .ebook-page > * {
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.paginated .ebook-content {
|
||||
height: 100%;
|
||||
overflow: hidden !important;
|
||||
position: relative;
|
||||
}
|
||||
.paginated .ebook-content * {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function applyScrolledMode(element: HTMLElement): void {
|
||||
element.classList.add("scrolled");
|
||||
|
||||
element.style.height = "auto";
|
||||
element.style.overflowY = "auto";
|
||||
element.style.columnCount = "1";
|
||||
|
||||
disablePagination(container);
|
||||
}
|
||||
|
||||
function applySingleColumn(element: HTMLElement): void {
|
||||
@@ -113,51 +141,6 @@ function applyDoubleColumn(element: HTMLElement): void {
|
||||
element.style.margin = "0 auto";
|
||||
}
|
||||
|
||||
function enablePagination(container: HTMLElement, element: HTMLElement): void {
|
||||
const totalHeight = element.scrollHeight;
|
||||
const pageHeight = element.clientHeight;
|
||||
const pageCount = Math.ceil(totalHeight / pageHeight);
|
||||
|
||||
addPaginationControls(container, pageCount);
|
||||
}
|
||||
|
||||
function disablePagination(container: HTMLElement): void {
|
||||
const controls = container.querySelector(".pagination-controls");
|
||||
controls?.remove();
|
||||
}
|
||||
|
||||
function addPaginationControls(
|
||||
container: HTMLElement,
|
||||
pageCount: number,
|
||||
): void {
|
||||
let currentPage = 1;
|
||||
|
||||
const controls = document.createElement("div");
|
||||
controls.className =
|
||||
"pagination-controls fixed bottom-0 left-0 right-0 bg-opacity-95 backdrop-blur border-t";
|
||||
controls.innerHTML = `
|
||||
<button class="prev-page" ${currentPage === 1 ? "disabled" : ""}>← Previous</button>
|
||||
<span class="page-info">Page ${currentPage} of ${pageCount}</span>
|
||||
<button class="next-page" ${currentPage === pageCount ? "disabled" : ""}>Next →</button>
|
||||
`;
|
||||
|
||||
controls.querySelector(".prev-page")?.addEventListener("click", () => {
|
||||
if (currentPage > 1) {
|
||||
currentPage--;
|
||||
goToPage(container, currentPage);
|
||||
}
|
||||
});
|
||||
|
||||
controls.querySelector(".next-page")?.addEventListener("click", () => {
|
||||
if (currentPage < pageCount) {
|
||||
currentPage++;
|
||||
goToPage(container, currentPage);
|
||||
}
|
||||
});
|
||||
|
||||
container.appendChild(controls);
|
||||
}
|
||||
|
||||
function goToPage(container: HTMLElement, pageNumber: number): void {
|
||||
const content = container.querySelector(".ebook-content") as HTMLElement;
|
||||
if (!content) return;
|
||||
@@ -196,4 +179,5 @@ export function getCurrentViewMode(container: HTMLElement): ViewMode {
|
||||
if (content.classList.contains("double-column")) return "double-column";
|
||||
|
||||
return "paginated";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
import { getCurrentPageFromScroll } from "../ebook/page-calculator";
|
||||
interface ProgressDisplay {
|
||||
mode: "pages" | "chapter" | "percentage" | "time-left";
|
||||
text: string;
|
||||
@@ -111,35 +110,50 @@ function updateProgressDisplay(context: ReaderContext): void {
|
||||
if (!display || !state.currentReader) return;
|
||||
let currentPage = 0;
|
||||
let totalPages = 0;
|
||||
let currentChapterPage = 0;
|
||||
let chapterPages = 0;
|
||||
if (state.currentReader.type === "ebook") {
|
||||
const reader = state.currentReader as any;
|
||||
const pageInfo = reader.pageCalculationResult;
|
||||
|
||||
if (pageInfo && pageInfo.totalPages > 0) {
|
||||
const container = document.getElementById("reader-content");
|
||||
const viewportHeight = window.innerHeight;
|
||||
currentPage = getCurrentPageFromScroll(
|
||||
pageInfo,
|
||||
reader.currentSpineIndex,
|
||||
container?.scrollTop || 0,
|
||||
viewportHeight,
|
||||
);
|
||||
totalPages = pageInfo.totalPages;
|
||||
} else {
|
||||
// Fallback to estimated pages while calculating
|
||||
// Use currentPage directly if available (page-based navigation)
|
||||
if (reader.currentPage) {
|
||||
currentPage = reader.currentPage;
|
||||
} else if (reader.currentSpineIndex !== undefined) {
|
||||
currentPage = reader.currentSpineIndex + 1;
|
||||
}
|
||||
|
||||
// Get total pages from calculation result
|
||||
const pageInfo = reader.pageCalculationResult;
|
||||
if (pageInfo && pageInfo.totalPages > 0) {
|
||||
totalPages = pageInfo.totalPages;
|
||||
|
||||
// Get chapter progress
|
||||
const chapter = pageInfo.chapterMap.get(reader.currentSpineIndex);
|
||||
if (chapter) {
|
||||
currentChapterPage = currentPage - chapter.startPage + 1;
|
||||
chapterPages = chapter.pagesInChapter;
|
||||
}
|
||||
} else {
|
||||
// Fallback
|
||||
totalPages =
|
||||
state.readerMetadata?.total_pages ||
|
||||
state.currentReader.cif.locations?.estimatedPages ||
|
||||
state.currentReader.cif.spine.length;
|
||||
}
|
||||
} else if (state.currentReader.type === "pdf") {
|
||||
currentPage = state.currentReader.currentPage;
|
||||
totalPages = state.readerMetadata?.total_pages || 0;
|
||||
} else {
|
||||
// Comic/manga
|
||||
currentPage = (state.currentReader as any).currentPage + 1;
|
||||
totalPages = (state.currentReader as any).images?.length || 0;
|
||||
}
|
||||
getReadingSpeed().then((speed) => {
|
||||
const result = calculateProgress(
|
||||
currentPage,
|
||||
totalPages,
|
||||
0,
|
||||
0,
|
||||
currentChapterPage,
|
||||
chapterPages,
|
||||
speed || undefined,
|
||||
);
|
||||
display.textContent = result.text;
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface UniversalReader {
|
||||
type: "ebook";
|
||||
cif: any;
|
||||
currentSpineIndex: number;
|
||||
currentPage: number;
|
||||
pageCalculationResult?: any;
|
||||
}
|
||||
|
||||
interface PDFReader {
|
||||
@@ -143,17 +145,15 @@ async function initializeReader(): Promise<void> {
|
||||
|
||||
await initializeFeatures(context);
|
||||
readerEvents.emit("readerReady", currentReader);
|
||||
// Render the initial chapter content
|
||||
// Initialize page calculation FIRST, before any rendering
|
||||
const { initializePageCalculation } =
|
||||
await import("./core/reader-navigation");
|
||||
await initializePageCalculation();
|
||||
// Then render the initial chapter content
|
||||
await renderSpineItem();
|
||||
// Focus the content container so keyboard navigation works immediately
|
||||
const container = document.getElementById("reader-content");
|
||||
container?.focus();
|
||||
// Initialize page calculation for dynamic page numbers
|
||||
setTimeout(async () => {
|
||||
const { initializePageCalculation } =
|
||||
await import("./core/reader-navigation");
|
||||
initializePageCalculation();
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error("Render initialization failed:", error);
|
||||
}
|
||||
@@ -206,6 +206,7 @@ async function initializeEbookReader(metadata: any): Promise<UniversalReader> {
|
||||
type: "ebook",
|
||||
cif,
|
||||
currentSpineIndex: 0,
|
||||
currentPage: 1,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user