refactor(ebook-reader): switch to CSS columns pagination approach

Replace HTML-splitting pagination with CSS columns for true paginated
viewing. This simplifies the implementation and relies on the browser's
native column-fill behavior for accurate page breaks.

Changes:
- view-modes.ts: Use CSS columns with column-fill: auto instead of
  pre-splitting HTML content into page chunks. Calculate page count
  from scrollHeight / viewportHeight.

- reader-navigation.ts: Navigate by scrolling viewport height instead of
  extracting discrete page content. Track page position via scroll
  offset. Simplified renderSpineItem to load full spine content.

- page-calculator.ts: Simplified to track spine info (charCount,
  estimatedPages) only. No more height-based content splitting.
  Page calculation happens in real-time from DOM scroll position.

Benefits:
- Accurate pagination without estimation errors
- Works correctly across different font sizes and screen sizes
- Simpler code with fewer edge cases
- Natural page breaks at element boundaries via CSS
This commit is contained in:
2026-04-07 21:02:18 -04:00
parent 1e7d13dc85
commit 1a5eb40d82
3 changed files with 234 additions and 498 deletions
+154 -260
View File
@@ -2,16 +2,8 @@ import { getDefaultSettings } from "../settings-manager";
import { getState, setState } from "./reader-state";
import { readerEvents } from "./reader-events";
import { updateReadingProgress } from "./reader-services";
import {
calculatePagesForEbook,
calculateProgressPercentage,
type PageCalculationResult,
} from "../ebook/page-calculator";
import { UniversalReader } from "../reader-shell";
let pageCalculationResult: PageCalculationResult | null = null;
let isCalculatingPages = false;
export function createNavigationAPI() {
return {
nextPage: () => {
@@ -19,50 +11,43 @@ export function createNavigationAPI() {
if (!state.currentReader) return;
readerEvents.emit("beforePageChange", state.currentReader);
if (state.currentReader.type === "ebook") {
if (pageCalculationResult) {
const currentPage = state.currentReader.currentPage || 1;
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,
);
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: spine-based navigation
const container = document.getElementById("reader-content");
if (!container) return;
const viewportHeight = window.innerHeight - 120;
const currentScroll = container.scrollTop;
const newScroll = currentScroll + viewportHeight;
if (newScroll >= container.scrollHeight - viewportHeight) {
if (
state.currentReader.currentSpineIndex <
state.currentReader.cif.spine.length - 1
) {
state.currentReader.currentSpineIndex++;
setState({ currentReader: state.currentReader });
renderSpineItem();
renderSpineItem().then(() => {
const newContainer = document.getElementById("reader-content");
if (newContainer) newContainer.scrollTop = 0;
sendProgressUpdate();
});
} else {
container.scrollTo({
top: container.scrollHeight,
behavior: "smooth",
});
sendProgressUpdate();
}
} else {
container.scrollTo({
top: newScroll,
behavior: "smooth",
});
sendProgressUpdate();
}
} else if (state.currentReader.type === "pdf") {
const totalPages = state.readerMetadata?.total_pages || 0;
if (state.currentReader.currentPage < totalPages) {
state.currentReader.currentPage++;
renderPDFPage();
sendProgressUpdate();
}
} else if (
state.currentReader.type === "comic" ||
@@ -74,62 +59,46 @@ export function createNavigationAPI() {
) {
state.currentReader.currentPage++;
renderComicPage();
sendProgressUpdate();
}
}
setState({ currentReader: state.currentReader });
readerEvents.emit("afterPageChange", state.currentReader);
},
previousPage: () => {
const state = getState();
if (!state.currentReader) return;
readerEvents.emit("beforePageChange", state.currentReader);
if (state.currentReader.type === "ebook") {
if (pageCalculationResult) {
const currentPage = state.currentReader.currentPage || 1;
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 (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: spine-based navigation
const container = document.getElementById("reader-content");
if (!container) return;
const currentScroll = container.scrollTop;
if (currentScroll <= 0) {
if (state.currentReader.currentSpineIndex > 0) {
state.currentReader.currentSpineIndex--;
setState({ currentReader: state.currentReader });
renderSpineItem();
sendProgressUpdate();
renderSpineItem().then(() => {
const newContainer = document.getElementById("reader-content");
if (newContainer) {
newContainer.scrollTop = newContainer.scrollHeight;
}
sendProgressUpdate();
});
}
} else {
const viewportHeight = window.innerHeight - 120;
const newScroll = currentScroll - viewportHeight;
container.scrollTo({
top: Math.max(0, newScroll),
behavior: "smooth",
});
sendProgressUpdate();
}
} else if (state.currentReader.type === "pdf") {
if (state.currentReader.currentPage > 1) {
state.currentReader.currentPage--;
renderPDFPage();
sendProgressUpdate();
}
} else if (
state.currentReader.type === "comic" ||
@@ -138,48 +107,41 @@ export function createNavigationAPI() {
if (state.currentReader.currentPage > 0) {
state.currentReader.currentPage--;
renderComicPage();
sendProgressUpdate();
}
}
setState({ currentReader: state.currentReader });
readerEvents.emit("afterPageChange", state.currentReader);
},
goToPage: async (page: number) => {
const state = getState();
if (!state.currentReader) return;
readerEvents.emit("beforePageChange", state.currentReader);
if (state.currentReader.type === "ebook") {
if (pageCalculationResult) {
// Validate page number
if (page < 1 || page > pageCalculationResult.totalPages) {
return;
const spine = state.currentReader.cif.spine;
const spineCount = spine.length;
const pagesPerSpine = Math.ceil(1000 / spineCount);
const targetSpineIndex = Math.min(
Math.floor((page - 1) / pagesPerSpine),
spineCount - 1,
);
state.currentReader.currentSpineIndex = targetSpineIndex;
setState({ currentReader: state.currentReader });
await renderSpineItem();
setTimeout(() => {
const container = document.getElementById("reader-content");
if (container) {
const viewportHeight = window.innerHeight - 120;
const pageInSpine = page - targetSpineIndex * pagesPerSpine;
const scrollTop = Math.max(0, (pageInSpine - 1) * viewportHeight);
container.scrollTop = scrollTop;
}
// 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();
}
}
}, 100);
} else if (state.currentReader.type === "pdf") {
if (page >= 1 && page <= (state.readerMetadata?.total_pages || 0)) {
state.currentReader.currentPage = page;
renderPDFPage();
sendProgressUpdate();
}
} else if (
state.currentReader.type === "comic" ||
@@ -188,152 +150,98 @@ export function createNavigationAPI() {
if (page >= 0 && page < (state.currentReader as any).images.length) {
state.currentReader.currentPage = page;
renderComicPage();
sendProgressUpdate();
}
}
setState({ currentReader: state.currentReader });
readerEvents.emit("pageChanged", page);
readerEvents.emit("afterPageChange", state.currentReader);
},
goToChapter: (chapterIndex: number) => {
const state = getState();
if (!state.readerMetadata?.chapter_metadata?.chapters) return;
if (state.currentReader?.type !== "ebook") return;
const chapters = state.readerMetadata.chapter_metadata.chapters;
if (chapterIndex < 0 || chapterIndex >= chapters.length) return;
const chapter = chapters[chapterIndex];
const pageAPI = createNavigationAPI();
pageAPI.goToPage(chapter.start_page);
if ((chapter as any).spine_index !== undefined) {
state.currentReader.currentSpineIndex = (chapter as any).spine_index;
setState({ currentReader: state.currentReader });
renderSpineItem();
sendProgressUpdate();
} else {
const pageAPI = createNavigationAPI();
pageAPI.goToPage(chapter.start_page);
}
readerEvents.emit("chapterChanged", chapter);
},
};
}
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,
viewportWidth,
{
fontSize: settings.font_size,
lineHeight: settings.line_height,
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,
),
});
});
setupScrollTracking();
console.log("Page tracking initialized (CSS columns mode)");
}
function setupScrollTracking(): void {
let scrollTimeout: ReturnType<typeof setTimeout> | null = null;
document.addEventListener(
"scroll",
() => {
const container = document.getElementById("reader-content");
if (!container) return;
const state = getState();
if (!state.currentReader || state.currentReader.type !== "ebook") return;
if (scrollTimeout) clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
updatePageFromScroll(container);
}, 50);
},
{ passive: true },
);
}
function updatePageFromScroll(container: HTMLElement): void {
const state = getState();
if (!state.currentReader || state.currentReader.type !== "ebook") return;
if (isCalculatingPages) return;
isCalculatingPages = true;
try {
const settings = getDefaultSettings();
const viewportWidth = window.innerWidth;
pageCalculationResult = await calculatePagesForEbook(
state.currentReader.cif,
viewportWidth,
{
fontSize: settings.font_size,
lineHeight: settings.line_height,
marginWidth: settings.margin_width,
},
);
state.currentReader.pageCalculationResult = pageCalculationResult;
setState({ currentReader: state.currentReader });
console.log(
"Page calculation complete:",
pageCalculationResult.totalPages,
"pages",
);
} catch (error) {
console.error("Failed to calculate pages:", error);
} finally {
isCalculatingPages = false;
}
const viewportHeight = window.innerHeight - 120;
const scrollTop = container.scrollTop;
const contentHeight = container.scrollHeight;
const currentPage = Math.floor(scrollTop / viewportHeight) + 1;
const totalPages = Math.max(1, Math.ceil(contentHeight / viewportHeight));
const percentage = contentHeight > 0 ? (scrollTop / contentHeight) * 100 : 0;
state.currentReader.currentPage = currentPage;
state.currentReader.currentScrollPosition = scrollTop;
setState({ currentReader: state.currentReader });
readerEvents.emit("progressUpdated", {
currentPage,
totalPages,
percentage,
scrollTop,
contentHeight,
});
}
export async function renderSpineItem() {
export async function renderSpineItem(): Promise<void> {
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];
if (!spineItem) return;
// Get page calculation result
const chapter = pageCalculationResult?.chapterMap.get(
state.currentReader.currentSpineIndex,
);
// Calculate which page within the chapter we're on
let currentPageContent = "";
let currentPageNumber = state.currentReader.currentPage || 1;
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;
}
const resources = state.currentReader.cif.resources;
const contentBlob = resources?.get(spineItem.content);
if (!contentBlob) {
console.error("Spine item content not found:", spineItem.content);
container.innerHTML = `<p>Error: Could not load chapter content</p>`;
return;
}
// Process and render the page content
let currentPageContent = await contentBlob.text();
const modifiedContent = rewriteImageUrls(
currentPageContent,
state.currentReader.cif.resources,
);
// 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");
@@ -343,8 +251,6 @@ export async function renderSpineItem() {
img.setAttribute("src", URL.createObjectURL(blob));
}
}
// Process SVG <image> elements with xlink:href
const svgImgElements = Array.from(doc.querySelectorAll("image"));
for (const img of svgImgElements) {
const src = img.getAttribute("xlink:href");
@@ -354,28 +260,12 @@ export async function renderSpineItem() {
img.setAttribute("xlink:href", URL.createObjectURL(blob));
}
}
// Apply to container - replace innerHTML with current page content
container.innerHTML = doc.body.innerHTML;
// Apply styling
container.innerHTML = `<div class="ebook-content">${doc.body.innerHTML}</div>`;
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,
),
});
}
requestAnimationFrame(() => {
updatePageFromScroll(container);
});
}
function rewriteImageUrls(
@@ -511,32 +401,32 @@ function getFontStack(font: string): string {
return stacks[font] || stacks["literata"];
}
function sendProgressUpdate() {
function sendProgressUpdate(): void {
const state = getState();
if (!state.currentReader || !state.readerMetadata) return;
const mediaItemId =
state.readerMetadata.id ||
document.body.dataset.mediaItemId ||
window.location.pathname.split("/").pop();
if (!mediaItemId) {
console.warn("No mediaItemId available for progress update");
return;
}
let currentPage = 0;
let totalPages = 0;
if (state.currentReader.type === "ebook") {
// Use dynamic page calculation if available
if (pageCalculationResult) {
currentPage = state.currentReader.currentPage || 1;
totalPages = pageCalculationResult.totalPages;
} else {
currentPage = state.currentReader.currentSpineIndex + 1;
totalPages = state.currentReader.cif.spine.length;
}
const container = document.getElementById("reader-content");
let currentPage = 1;
let totalPages = 1;
let percentage = 0;
let character = 0;
if (state.currentReader.type === "ebook" && container) {
const viewportHeight = window.innerHeight - 120;
const contentHeight = container.scrollHeight;
const scrollTop = container.scrollTop;
currentPage = Math.floor(scrollTop / viewportHeight) + 1;
totalPages = Math.max(1, Math.ceil(contentHeight / viewportHeight));
percentage = contentHeight > 0 ? (scrollTop / contentHeight) * 100 : 0;
character = getCharacterOffset();
state.currentReader.currentPage = currentPage;
setState({ currentReader: state.currentReader });
} else if (state.currentReader.type === "pdf") {
totalPages = state.readerMetadata.total_pages || 0;
currentPage = state.currentReader.currentPage;
@@ -547,10 +437,7 @@ function sendProgressUpdate() {
totalPages = state.currentReader.images.length;
currentPage = state.currentReader.currentPage;
}
const reader = state.currentReader as UniversalReader;
const percentage = totalPages > 0 ? (currentPage / totalPages) * 100 : 0;
updateReadingProgress(
state.readerMetadata.id,
{
@@ -558,19 +445,26 @@ function sendProgressUpdate() {
total_pages: totalPages,
},
{
// Extended data for cross-device sync
character: getCharacterOffset(),
character,
chapter: reader.currentSpineIndex,
percentage: percentage,
percentage,
},
);
readerEvents.emit("progressUpdated", { currentPage, totalPages });
readerEvents.emit("progressUpdated", { currentPage, totalPages, percentage });
}
function getCharacterOffset(): number {
const container = document.getElementById("reader-content");
if (!container) return 0;
const textContent = container.textContent || "";
return textContent.length;
const viewportHeight = window.innerHeight - 120;
const scrollTop = container.scrollTop;
const viewportIndex = Math.floor(scrollTop / viewportHeight);
const allText = container.textContent || "";
const totalChars = allText.length;
const viewportCount = Math.max(
1,
Math.ceil(container.scrollHeight / viewportHeight),
);
const charsPerViewport = totalChars / viewportCount;
return Math.floor(viewportIndex * charsPerViewport);
}
+54 -203
View File
@@ -1,121 +1,25 @@
import { findImageInResources } from "../core/reader-navigation";
import { splitContent, type PageContent } from "./page-splitter";
export interface ChapterPageInfo {
export interface SpineInfo {
spineIndex: number;
spineItemId: string;
content: string;
startPage: number;
endPage: number;
scrollHeight: number;
charCount: number;
pagesInChapter: number;
pages: PageContent[];
estimatedPages: number;
}
export interface PageCalculationResult {
totalPages: number;
chapters: ChapterPageInfo[];
chapterMap: Map<number, ChapterPageInfo>;
spines: SpineInfo[];
spineMap: Map<number, SpineInfo>;
calculatedAt: number;
settings: PageCalculationSettings;
}
export interface PageCalculationSettings {
fontSize: number;
lineHeight: number;
marginWidth: number;
viewportHeight: number;
}
interface HiddenContainerConfig {
width: number;
fontSize: number;
lineHeight: number;
paddingTop: number;
paddingBottom: number;
paddingLeft: number;
paddingRight: number;
}
function getDefaultContainerConfig(
viewportWidth: number,
settings: PageCalculationSettings,
): HiddenContainerConfig {
const contentWidth = viewportWidth - settings.marginWidth * 2;
return {
width: contentWidth,
fontSize: settings.fontSize,
lineHeight: settings.lineHeight,
paddingTop: settings.marginWidth,
paddingBottom: settings.marginWidth,
paddingLeft: settings.marginWidth,
paddingRight: settings.marginWidth,
};
}
function createHiddenContainer(config: HiddenContainerConfig): HTMLElement {
const container = document.createElement("div");
container.id = "page-calculation-hidden";
container.style.position = "absolute";
container.style.left = "-9999px";
container.style.top = "0";
container.style.width = `${config.width}px`;
container.style.fontSize = `${config.fontSize}px`;
container.style.lineHeight = config.lineHeight.toString();
container.style.padding = `${config.paddingTop}px ${config.paddingRight}px ${config.paddingBottom}px ${config.paddingLeft}px`;
container.style.boxSizing = "border-box";
container.style.overflow = "hidden";
container.style.wordWrap = "break-word";
container.style.whiteSpace = "pre-wrap";
return container;
}
function stripScriptsAndStyles(html: string): string {
let result = html;
result = result.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "");
result = result.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
result = result.replace(/<link[^>]*>/gi, "");
return result;
}
async function renderContentForMeasurement(
content: string,
resources: Map<string, Blob>,
config: HiddenContainerConfig,
): Promise<HTMLElement> {
const container = createHiddenContainer(config);
const parser = new DOMParser();
const doc = parser.parseFromString(
stripScriptsAndStyles(content),
"text/html",
);
const images = Array.from(doc.querySelectorAll("img"));
for (const img of images) {
const src = img.getAttribute("src");
if (!src) continue;
let blob = findImageInResources(resources, src);
if (!blob) blob = resources.get(src.split("/").pop() || "");
if (blob) {
const blobUrl = URL.createObjectURL(blob);
img.setAttribute("src", blobUrl);
}
}
container.appendChild(doc.body);
document.body.appendChild(container);
return container;
}
export async function calculatePagesForEbook(
cif: EbookCIF,
viewportWidth: number,
_viewportWidth: number,
settings: {
fontSize?: number;
lineHeight?: number;
@@ -124,156 +28,103 @@ export async function calculatePagesForEbook(
): Promise<PageCalculationResult> {
const fontSize = settings.fontSize || 16;
const lineHeight = settings.lineHeight || 1.6;
const marginWidth = settings.marginWidth || 20;
const viewportHeight = window.innerHeight - 120;
const pageSettings: PageCalculationSettings = {
fontSize,
lineHeight,
marginWidth,
viewportHeight,
};
const config = getDefaultContainerConfig(viewportWidth, pageSettings);
const chapters: ChapterPageInfo[] = [];
let currentPage = 1;
const charsPerPage = Math.round(2000 * (16 / fontSize) * (lineHeight / 1.6));
const spines: SpineInfo[] = [];
let estimatedTotalPages = 0;
for (let i = 0; i < cif.spine.length; i++) {
const spineItem = cif.spine[i];
if (spineItem.type !== "html") {
chapters.push({
spines.push({
spineIndex: i,
spineItemId: spineItem.id,
content: "",
startPage: currentPage,
endPage: currentPage,
scrollHeight: 0,
charCount: 0,
pagesInChapter: 0,
pages: [],
estimatedPages: 1,
});
estimatedTotalPages += 1;
continue;
}
const contentBlob = cif.resources.get(spineItem.content);
if (!contentBlob) {
chapters.push({
spines.push({
spineIndex: i,
spineItemId: spineItem.id,
content: "",
startPage: currentPage,
endPage: currentPage,
scrollHeight: 0,
charCount: 0,
pagesInChapter: 0,
pages: [],
estimatedPages: 1,
});
estimatedTotalPages += 1;
continue;
}
const contentText = await contentBlob.text();
const charCount = contentText.replace(/<[^>]*>/g, "").length;
let scrollHeight = 0;
try {
const container = await renderContentForMeasurement(
contentText,
cif.resources,
config,
);
scrollHeight = container.scrollHeight;
container.remove();
} catch (error) {
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 = {
const estimatedPages = Math.max(1, Math.ceil(charCount / charsPerPage));
estimatedTotalPages += estimatedPages;
spines.push({
spineIndex: i,
spineItemId: spineItem.id,
content: contentText,
startPage: currentPage,
endPage: currentPage + pageSplitResult.totalPages - 1,
scrollHeight,
charCount,
pagesInChapter: pageSplitResult.totalPages,
pages: pageSplitResult.pages,
};
chapters.push(chapterInfo);
currentPage += pageSplitResult.totalPages;
estimatedPages,
});
}
const chapterMap = new Map<number, ChapterPageInfo>();
for (const chapter of chapters) {
chapterMap.set(chapter.spineIndex, chapter);
const spineMap = new Map<number, SpineInfo>();
for (const spine of spines) {
spineMap.set(spine.spineIndex, spine);
}
const totalPages = currentPage - 1;
return {
totalPages,
chapters,
chapterMap,
totalPages: estimatedTotalPages,
spines,
spineMap,
calculatedAt: Date.now(),
settings: pageSettings,
};
}
export function getCurrentPageFromScroll(
pageInfo: PageCalculationResult,
currentSpineIndex: number,
scrollPosition: number,
viewportHeight: number,
): number {
const chapter = pageInfo.chapterMap.get(currentSpineIndex);
if (!chapter || chapter.pagesInChapter === 0) {
return 1;
}
const viewportHeightAdjusted = viewportHeight - 120;
const positionInChapter = Math.floor(scrollPosition / viewportHeightAdjusted);
return Math.min(
chapter.endPage,
Math.max(chapter.startPage, chapter.startPage + positionInChapter),
);
const spine = pageInfo.spineMap.get(currentSpineIndex);
if (!spine) return 1;
const charsBeforeSpine = pageInfo.spines
.slice(0, currentSpineIndex)
.reduce((sum, s) => sum + s.charCount, 0);
const charsInSpine = spine.charCount;
const charsAtPosition = (scrollPosition / viewportHeight) * charsInSpine;
const totalCharsAtPosition = charsBeforeSpine + charsAtPosition;
const charsPerPage = 2000;
return Math.ceil(totalCharsAtPosition / charsPerPage);
}
export function getScrollPositionForPage(
pageInfo: PageCalculationResult,
targetPage: number,
viewportHeight: number,
): { spineIndex: number; scrollTop: number } | null {
const viewportHeightAdjusted = viewportHeight - 120;
for (const chapter of pageInfo.chapters) {
if (targetPage >= chapter.startPage && targetPage <= chapter.endPage) {
const positionInChapter = targetPage - chapter.startPage;
const scrollTop = positionInChapter * viewportHeightAdjusted;
const charsPerPage = 2000;
const targetChar = (targetPage - 1) * charsPerPage;
let charsAccumulated = 0;
for (const spine of pageInfo.spines) {
charsAccumulated += spine.charCount;
if (targetChar < charsAccumulated) {
const charsBefore = charsAccumulated - spine.charCount;
const charsIntoSpine = targetChar - charsBefore;
const scrollTop = (charsIntoSpine / spine.charCount) * viewportHeight;
return {
spineIndex: chapter.spineIndex,
scrollTop,
spineIndex: spine.spineIndex,
scrollTop: Math.max(
0,
Math.min(scrollTop, viewportHeight * spine.estimatedPages),
),
};
}
}
return null;
const lastSpine = pageInfo.spines[pageInfo.spines.length - 1];
return {
spineIndex: lastSpine?.spineIndex || 0,
scrollTop: 0,
};
}
export function calculateProgressPercentage(
pageInfo: PageCalculationResult,
currentPage: number,
+26 -35
View File
@@ -72,15 +72,15 @@ function setViewMode(container: HTMLElement, mode: ViewMode): void {
function applyPaginatedMode(element: HTMLElement): void {
element.classList.add("paginated");
// 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";
// Set up for CSS columns
element.style.height = "calc(100vh - 120px)";
element.style.overflowY = "auto";
element.style.overflowX = "hidden";
element.style.columnCount = "1";
element.style.columnFill = "auto";
element.style.columnGap = "0";
element.style.position = "relative";
// Inject CSS for page clipping
// Inject CSS for CSS column pagination
injectPaginatedStyles();
}
@@ -92,23 +92,22 @@ function injectPaginatedStyles(): void {
const style = document.createElement("style");
style.id = "paginated-styles";
style.textContent = `
.paginated .ebook-page {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
.paginated {
height: calc(100vh - 120px) !important;
overflow-y: auto !important;
overflow-x: hidden !important;
column-fill: auto !important;
column-count: 1 !important;
column-gap: 0 !important;
position: relative !important;
}
.paginated .ebook-page > * {
max-height: 100%;
overflow: hidden;
.paginated > * {
max-width: 100%;
column-fill: auto;
}
.paginated .ebook-content {
height: 100%;
overflow: hidden !important;
position: relative;
}
.paginated .ebook-content * {
overflow-wrap: break-word;
height: auto !important;
min-height: 100%;
}
`;
document.head.appendChild(style);
@@ -144,29 +143,22 @@ function applyDoubleColumn(element: HTMLElement): void {
function goToPage(container: HTMLElement, pageNumber: number): void {
const content = container.querySelector(".ebook-content") as HTMLElement;
if (!content) return;
const pageHeight = content.clientHeight;
const scrollTop = (pageNumber - 1) * pageHeight;
const totalPages = getTotalPageCount(container);
const targetPage = Math.min(Math.max(1, pageNumber), totalPages);
const viewportHeight = window.innerHeight - 120;
const scrollTop = (targetPage - 1) * viewportHeight;
content.scrollTo({
top: scrollTop,
behavior: "smooth",
});
const pageInfo = container.querySelector(".page-info");
if (pageInfo) {
pageInfo.textContent = `Page ${pageNumber} of ${getTotalPageCount(container)}`;
}
}
function getTotalPageCount(container: HTMLElement): number {
const content = container.querySelector(".ebook-content") as HTMLElement;
if (!content) return 1;
const totalHeight = content.scrollHeight;
const pageHeight = content.clientHeight;
return Math.ceil(totalHeight / pageHeight);
const viewportHeight = window.innerHeight - 120;
const contentHeight = content.scrollHeight;
return Math.max(1, Math.ceil(contentHeight / viewportHeight));
}
export function getCurrentViewMode(container: HTMLElement): ViewMode {
@@ -180,4 +172,3 @@ export function getCurrentViewMode(container: HTMLElement): ViewMode {
return "paginated";
}