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 { getDefaultSettings } from "../settings-manager";
|
||||||
import { getState, setState, getCurrentPage } from "./reader-state";
|
import { getState, setState } from "./reader-state";
|
||||||
import { readerEvents } from "./reader-events";
|
import { readerEvents } from "./reader-events";
|
||||||
import { updateReadingProgress } from "./reader-services";
|
import { updateReadingProgress } from "./reader-services";
|
||||||
import {
|
import {
|
||||||
calculatePagesForEbook,
|
calculatePagesForEbook,
|
||||||
calculateProgressPercentage,
|
calculateProgressPercentage,
|
||||||
getCurrentPageFromScroll,
|
|
||||||
type PageCalculationResult,
|
type PageCalculationResult,
|
||||||
} from "../ebook/page-calculator";
|
} from "../ebook/page-calculator";
|
||||||
import { UniversalReader } from "../reader-shell";
|
import { UniversalReader } from "../reader-shell";
|
||||||
@@ -18,46 +17,45 @@ export function createNavigationAPI() {
|
|||||||
nextPage: () => {
|
nextPage: () => {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
if (!state.currentReader) return;
|
if (!state.currentReader) return;
|
||||||
|
|
||||||
readerEvents.emit("beforePageChange", state.currentReader);
|
readerEvents.emit("beforePageChange", state.currentReader);
|
||||||
|
|
||||||
if (state.currentReader.type === "ebook") {
|
if (state.currentReader.type === "ebook") {
|
||||||
const container = document.getElementById("reader-content");
|
|
||||||
const viewportHeightAdjusted = window.innerHeight - 120;
|
|
||||||
|
|
||||||
if (pageCalculationResult) {
|
if (pageCalculationResult) {
|
||||||
const currentChapter = pageCalculationResult.chapterMap.get(
|
const currentPage = state.currentReader.currentPage || 1;
|
||||||
state.currentReader.currentSpineIndex,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (currentChapter && container) {
|
if (currentPage < pageCalculationResult.totalPages) {
|
||||||
const currentPageInChapter = Math.floor(
|
const nextPage = currentPage + 1;
|
||||||
container.scrollTop / viewportHeightAdjusted,
|
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) {
|
if (currentChapter && nextPage > currentChapter.endPage) {
|
||||||
// Still pages left in current chapter - just scroll down
|
// Move to next spine
|
||||||
container.scrollTop =
|
if (
|
||||||
(currentPageInChapter + 1) * viewportHeightAdjusted;
|
state.currentReader.currentSpineIndex <
|
||||||
} else if (
|
state.currentReader.cif.spine.length - 1
|
||||||
state.currentReader.currentSpineIndex <
|
) {
|
||||||
state.currentReader.cif.spine.length - 1
|
state.currentReader.currentSpineIndex++;
|
||||||
) {
|
}
|
||||||
// At end of chapter, move to next spine
|
|
||||||
state.currentReader.currentSpineIndex++;
|
|
||||||
renderSpineItem();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setState({ currentReader: state.currentReader });
|
||||||
|
renderSpineItem();
|
||||||
|
sendProgressUpdate();
|
||||||
|
readerEvents.emit("pageChanged", nextPage);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback: just move to next spine
|
// Fallback: spine-based navigation
|
||||||
if (
|
if (
|
||||||
state.currentReader.currentSpineIndex <
|
state.currentReader.currentSpineIndex <
|
||||||
state.currentReader.cif.spine.length - 1
|
state.currentReader.cif.spine.length - 1
|
||||||
) {
|
) {
|
||||||
state.currentReader.currentSpineIndex++;
|
state.currentReader.currentSpineIndex++;
|
||||||
|
setState({ currentReader: state.currentReader });
|
||||||
renderSpineItem();
|
renderSpineItem();
|
||||||
|
sendProgressUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (state.currentReader.type === "pdf") {
|
} else if (state.currentReader.type === "pdf") {
|
||||||
@@ -78,48 +76,54 @@ export function createNavigationAPI() {
|
|||||||
renderComicPage();
|
renderComicPage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setState({ currentReader: state.currentReader });
|
setState({ currentReader: state.currentReader });
|
||||||
sendProgressUpdate();
|
|
||||||
readerEvents.emit("pageChanged", getCurrentPage());
|
|
||||||
readerEvents.emit("afterPageChange", state.currentReader);
|
readerEvents.emit("afterPageChange", state.currentReader);
|
||||||
},
|
},
|
||||||
|
|
||||||
previousPage: () => {
|
previousPage: () => {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
if (!state.currentReader) return;
|
if (!state.currentReader) return;
|
||||||
|
|
||||||
readerEvents.emit("beforePageChange", state.currentReader);
|
readerEvents.emit("beforePageChange", state.currentReader);
|
||||||
|
|
||||||
if (state.currentReader.type === "ebook") {
|
if (state.currentReader.type === "ebook") {
|
||||||
const container = document.getElementById("reader-content");
|
|
||||||
const viewportHeightAdjusted = window.innerHeight - 120;
|
|
||||||
|
|
||||||
if (pageCalculationResult) {
|
if (pageCalculationResult) {
|
||||||
const currentChapter = pageCalculationResult.chapterMap.get(
|
const currentPage = state.currentReader.currentPage || 1;
|
||||||
state.currentReader.currentSpineIndex,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (currentChapter && container) {
|
if (currentPage > 1) {
|
||||||
const currentPageInChapter = Math.floor(
|
const prevPage = currentPage - 1;
|
||||||
container.scrollTop / viewportHeightAdjusted,
|
|
||||||
|
// Check if we need to move to previous spine
|
||||||
|
const currentChapter = pageCalculationResult.chapterMap.get(
|
||||||
|
state.currentReader.currentSpineIndex,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (currentPageInChapter > 0) {
|
if (currentChapter && prevPage < currentChapter.startPage) {
|
||||||
// Not at start of chapter - just scroll up
|
// Move to previous spine
|
||||||
container.scrollTop =
|
if (state.currentReader.currentSpineIndex > 0) {
|
||||||
(currentPageInChapter - 1) * viewportHeightAdjusted;
|
state.currentReader.currentSpineIndex--;
|
||||||
} else if (state.currentReader.currentSpineIndex > 0) {
|
const prevChapter = pageCalculationResult.chapterMap.get(
|
||||||
// At start of chapter, move to previous spine
|
state.currentReader.currentSpineIndex,
|
||||||
state.currentReader.currentSpineIndex--;
|
);
|
||||||
renderSpineItem();
|
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 {
|
} else {
|
||||||
// Fallback: just move to previous spine
|
// Fallback: spine-based navigation
|
||||||
if (state.currentReader.currentSpineIndex > 0) {
|
if (state.currentReader.currentSpineIndex > 0) {
|
||||||
state.currentReader.currentSpineIndex--;
|
state.currentReader.currentSpineIndex--;
|
||||||
|
setState({ currentReader: state.currentReader });
|
||||||
renderSpineItem();
|
renderSpineItem();
|
||||||
|
sendProgressUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (state.currentReader.type === "pdf") {
|
} else if (state.currentReader.type === "pdf") {
|
||||||
@@ -136,23 +140,41 @@ export function createNavigationAPI() {
|
|||||||
renderComicPage();
|
renderComicPage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setState({ currentReader: state.currentReader });
|
setState({ currentReader: state.currentReader });
|
||||||
sendProgressUpdate();
|
|
||||||
readerEvents.emit("pageChanged", getCurrentPage());
|
|
||||||
readerEvents.emit("afterPageChange", state.currentReader);
|
readerEvents.emit("afterPageChange", state.currentReader);
|
||||||
},
|
},
|
||||||
|
|
||||||
goToPage: (page: number) => {
|
goToPage: async (page: number) => {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
if (!state.currentReader) return;
|
if (!state.currentReader) return;
|
||||||
|
|
||||||
readerEvents.emit("beforePageChange", state.currentReader);
|
readerEvents.emit("beforePageChange", state.currentReader);
|
||||||
|
|
||||||
if (state.currentReader.type === "ebook") {
|
if (state.currentReader.type === "ebook") {
|
||||||
if (page >= 0 && page < state.currentReader.cif.spine.length) {
|
if (pageCalculationResult) {
|
||||||
state.currentReader.currentSpineIndex = page;
|
// Validate page number
|
||||||
renderSpineItem();
|
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") {
|
} else if (state.currentReader.type === "pdf") {
|
||||||
if (page >= 1 && page <= (state.readerMetadata?.total_pages || 0)) {
|
if (page >= 1 && page <= (state.readerMetadata?.total_pages || 0)) {
|
||||||
@@ -163,14 +185,12 @@ export function createNavigationAPI() {
|
|||||||
state.currentReader.type === "comic" ||
|
state.currentReader.type === "comic" ||
|
||||||
state.currentReader.type === "manga"
|
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;
|
state.currentReader.currentPage = page;
|
||||||
renderComicPage();
|
renderComicPage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setState({ currentReader: state.currentReader });
|
setState({ currentReader: state.currentReader });
|
||||||
sendProgressUpdate();
|
|
||||||
readerEvents.emit("pageChanged", page);
|
readerEvents.emit("pageChanged", page);
|
||||||
readerEvents.emit("afterPageChange", state.currentReader);
|
readerEvents.emit("afterPageChange", state.currentReader);
|
||||||
},
|
},
|
||||||
@@ -195,9 +215,7 @@ export async function initializePageCalculation() {
|
|||||||
readerEvents.on("settings:changed", async (settings: any) => {
|
readerEvents.on("settings:changed", async (settings: any) => {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
if (!state.currentReader || state.currentReader.type !== "ebook") return;
|
if (!state.currentReader || state.currentReader.type !== "ebook") return;
|
||||||
|
|
||||||
console.log("Recalculating pages due to settings change...");
|
console.log("Recalculating pages due to settings change...");
|
||||||
|
|
||||||
const viewportWidth = window.innerWidth;
|
const viewportWidth = window.innerWidth;
|
||||||
pageCalculationResult = await calculatePagesForEbook(
|
pageCalculationResult = await calculatePagesForEbook(
|
||||||
state.currentReader.cif,
|
state.currentReader.cif,
|
||||||
@@ -208,9 +226,21 @@ export async function initializePageCalculation() {
|
|||||||
marginWidth: settings.margin_width,
|
marginWidth: settings.margin_width,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
state.currentReader.pageCalculationResult = pageCalculationResult;
|
state.currentReader.pageCalculationResult = pageCalculationResult;
|
||||||
setState({ currentReader: state.currentReader });
|
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();
|
const state = getState();
|
||||||
if (!state.currentReader || state.currentReader.type !== "ebook") return;
|
if (!state.currentReader || state.currentReader.type !== "ebook") return;
|
||||||
@@ -250,104 +280,67 @@ export async function initializePageCalculation() {
|
|||||||
export async function renderSpineItem() {
|
export async function renderSpineItem() {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
if (state.currentReader?.type !== "ebook") return;
|
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 =
|
const spineItem =
|
||||||
state.currentReader.cif.spine[state.currentReader.currentSpineIndex];
|
state.currentReader.cif.spine[state.currentReader.currentSpineIndex];
|
||||||
const container = document.getElementById("reader-content");
|
if (!spineItem) return;
|
||||||
if (pageCalculationResult) {
|
// Get page calculation result
|
||||||
const viewportHeight = window.innerHeight;
|
const chapter = pageCalculationResult?.chapterMap.get(
|
||||||
const currentPage = getCurrentPageFromScroll(
|
state.currentReader.currentSpineIndex,
|
||||||
pageCalculationResult,
|
);
|
||||||
state.currentReader.currentSpineIndex,
|
|
||||||
container.scrollTop,
|
|
||||||
viewportHeight,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Store computed page for UI display
|
// Calculate which page within the chapter we're on
|
||||||
state.currentReader.currentPage = currentPage;
|
let currentPageContent = "";
|
||||||
setState({ currentReader: state.currentReader });
|
let currentPageNumber = state.currentReader.currentPage || 1;
|
||||||
|
|
||||||
const percentage = calculateProgressPercentage(
|
if (chapter?.pages && chapter.pages.length > 0) {
|
||||||
pageCalculationResult,
|
// Calculate which page of this spine item to show
|
||||||
currentPage,
|
const globalPage = state.currentReader.currentPage || 1;
|
||||||
);
|
const pageWithinChapter = Math.max(0, globalPage - chapter.startPage);
|
||||||
readerEvents.emit("progressUpdated", {
|
const pageIndex = Math.min(pageWithinChapter, chapter.pages.length - 1);
|
||||||
currentPage,
|
|
||||||
totalPages: pageCalculationResult.totalPages,
|
currentPageContent = chapter.pages[pageIndex]?.html || chapter.content;
|
||||||
percentage,
|
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;
|
// Process and render the page content
|
||||||
// Get the actual content from resources using the href
|
const modifiedContent = rewriteImageUrls(
|
||||||
const resources = state.currentReader.cif.resources;
|
currentPageContent,
|
||||||
console.log("ALL resource keys:", Array.from(resources.keys()));
|
state.currentReader.cif.resources,
|
||||||
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;
|
|
||||||
|
|
||||||
state.currentReader.currentScrollPosition = container.scrollTop;
|
// Parse HTML and process images
|
||||||
|
|
||||||
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
|
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const doc = parser.parseFromString(modifiedContent, "text/html");
|
const doc = parser.parseFromString(modifiedContent, "text/html");
|
||||||
|
|
||||||
|
// Process <img> elements
|
||||||
const imgElements = Array.from(doc.querySelectorAll("img"));
|
const imgElements = Array.from(doc.querySelectorAll("img"));
|
||||||
for (const img of imgElements) {
|
for (const img of imgElements) {
|
||||||
const src = img.getAttribute("src");
|
const src = img.getAttribute("src");
|
||||||
if (!src) continue;
|
if (!src) continue;
|
||||||
|
const blob = findImageInResources(state.currentReader.cif.resources, src);
|
||||||
// Try different path formats to find the image
|
|
||||||
let blob = findImageInResources(resources, src);
|
|
||||||
|
|
||||||
if (blob) {
|
if (blob) {
|
||||||
// Create blob URL - need to do this properly
|
img.setAttribute("src", URL.createObjectURL(blob));
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
|
||||||
img.setAttribute("src", blobUrl);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,18 +349,33 @@ export async function renderSpineItem() {
|
|||||||
for (const img of svgImgElements) {
|
for (const img of svgImgElements) {
|
||||||
const src = img.getAttribute("xlink:href");
|
const src = img.getAttribute("xlink:href");
|
||||||
if (!src) continue;
|
if (!src) continue;
|
||||||
|
const blob = findImageInResources(state.currentReader.cif.resources, src);
|
||||||
let blob = findImageInResources(resources, src);
|
|
||||||
if (blob) {
|
if (blob) {
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
img.setAttribute("xlink:href", URL.createObjectURL(blob));
|
||||||
img.setAttribute("xlink:href", blobUrl);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply to container - replace innerHTML with current page content
|
||||||
container.innerHTML = doc.body.innerHTML;
|
container.innerHTML = doc.body.innerHTML;
|
||||||
// Apply reader styling
|
|
||||||
|
// Apply styling
|
||||||
applyReaderTheme();
|
applyReaderTheme();
|
||||||
applyTypography();
|
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(
|
function rewriteImageUrls(
|
||||||
@@ -523,14 +531,7 @@ function sendProgressUpdate() {
|
|||||||
if (state.currentReader.type === "ebook") {
|
if (state.currentReader.type === "ebook") {
|
||||||
// Use dynamic page calculation if available
|
// Use dynamic page calculation if available
|
||||||
if (pageCalculationResult) {
|
if (pageCalculationResult) {
|
||||||
const container = document.getElementById("reader-content");
|
currentPage = state.currentReader.currentPage || 1;
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
currentPage = getCurrentPageFromScroll(
|
|
||||||
pageCalculationResult,
|
|
||||||
state.currentReader.currentSpineIndex,
|
|
||||||
container?.scrollTop || 0,
|
|
||||||
viewportHeight,
|
|
||||||
);
|
|
||||||
totalPages = pageCalculationResult.totalPages;
|
totalPages = pageCalculationResult.totalPages;
|
||||||
} else {
|
} else {
|
||||||
currentPage = state.currentReader.currentSpineIndex + 1;
|
currentPage = state.currentReader.currentSpineIndex + 1;
|
||||||
|
|||||||
@@ -35,9 +35,8 @@ export function getReaderMetadata(): ReaderMetadata | null {
|
|||||||
export function getCurrentPage(): number {
|
export function getCurrentPage(): number {
|
||||||
const reader = currentState.currentReader;
|
const reader = currentState.currentReader;
|
||||||
if (!reader) return 0;
|
if (!reader) return 0;
|
||||||
|
|
||||||
if (reader.type === "ebook") {
|
if (reader.type === "ebook") {
|
||||||
return reader.currentSpineIndex;
|
return reader.currentPage ?? reader.currentSpineIndex + 1;
|
||||||
}
|
}
|
||||||
return reader.currentPage;
|
return reader.currentPage;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { findImageInResources } from "../core/reader-navigation";
|
import { findImageInResources } from "../core/reader-navigation";
|
||||||
|
import { splitContent, type PageContent } from "./page-splitter";
|
||||||
|
|
||||||
export interface ChapterPageInfo {
|
export interface ChapterPageInfo {
|
||||||
spineIndex: number;
|
spineIndex: number;
|
||||||
@@ -9,6 +10,7 @@ export interface ChapterPageInfo {
|
|||||||
scrollHeight: number;
|
scrollHeight: number;
|
||||||
charCount: number;
|
charCount: number;
|
||||||
pagesInChapter: number;
|
pagesInChapter: number;
|
||||||
|
pages: PageContent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PageCalculationResult {
|
export interface PageCalculationResult {
|
||||||
@@ -111,14 +113,6 @@ async function renderContentForMeasurement(
|
|||||||
return container;
|
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(
|
export async function calculatePagesForEbook(
|
||||||
cif: EbookCIF,
|
cif: EbookCIF,
|
||||||
viewportWidth: number,
|
viewportWidth: number,
|
||||||
@@ -159,6 +153,7 @@ export async function calculatePagesForEbook(
|
|||||||
scrollHeight: 0,
|
scrollHeight: 0,
|
||||||
charCount: 0,
|
charCount: 0,
|
||||||
pagesInChapter: 0,
|
pagesInChapter: 0,
|
||||||
|
pages: [],
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -174,6 +169,7 @@ export async function calculatePagesForEbook(
|
|||||||
scrollHeight: 0,
|
scrollHeight: 0,
|
||||||
charCount: 0,
|
charCount: 0,
|
||||||
pagesInChapter: 0,
|
pagesInChapter: 0,
|
||||||
|
pages: [],
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -182,41 +178,42 @@ export async function calculatePagesForEbook(
|
|||||||
const charCount = contentText.replace(/<[^>]*>/g, "").length;
|
const charCount = contentText.replace(/<[^>]*>/g, "").length;
|
||||||
|
|
||||||
let scrollHeight = 0;
|
let scrollHeight = 0;
|
||||||
let pagesInChapter = 1;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const container = await renderContentForMeasurement(
|
const container = await renderContentForMeasurement(
|
||||||
contentText,
|
contentText,
|
||||||
cif.resources,
|
cif.resources,
|
||||||
config,
|
config,
|
||||||
);
|
);
|
||||||
|
|
||||||
scrollHeight = container.scrollHeight;
|
scrollHeight = container.scrollHeight;
|
||||||
pagesInChapter = calculatePageBreaks(scrollHeight, viewportHeight);
|
|
||||||
|
|
||||||
container.remove();
|
container.remove();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn("Failed to measure content:", spineItem.id, error);
|
||||||
"Failed to measure content for spine item:",
|
|
||||||
spineItem.id,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
pagesInChapter = Math.max(1, Math.ceil(charCount / 1500));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Split content into discrete pages
|
||||||
|
const pageSplitResult = splitContent(
|
||||||
|
contentText,
|
||||||
|
i,
|
||||||
|
spineItem.id,
|
||||||
|
viewportHeight,
|
||||||
|
{ fontSize, lineHeight, marginWidth },
|
||||||
|
true, // useCFI
|
||||||
|
);
|
||||||
|
|
||||||
const chapterInfo: ChapterPageInfo = {
|
const chapterInfo: ChapterPageInfo = {
|
||||||
spineIndex: i,
|
spineIndex: i,
|
||||||
spineItemId: spineItem.id,
|
spineItemId: spineItem.id,
|
||||||
content: contentText,
|
content: contentText,
|
||||||
startPage: currentPage,
|
startPage: currentPage,
|
||||||
endPage: currentPage + pagesInChapter - 1,
|
endPage: currentPage + pageSplitResult.totalPages - 1,
|
||||||
scrollHeight,
|
scrollHeight,
|
||||||
charCount,
|
charCount,
|
||||||
pagesInChapter,
|
pagesInChapter: pageSplitResult.totalPages,
|
||||||
|
pages: pageSplitResult.pages,
|
||||||
};
|
};
|
||||||
|
|
||||||
chapters.push(chapterInfo);
|
chapters.push(chapterInfo);
|
||||||
currentPage += pagesInChapter;
|
currentPage += pageSplitResult.totalPages;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chapterMap = new Map<number, ChapterPageInfo>();
|
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) {
|
switch (mode) {
|
||||||
case "paginated":
|
case "paginated":
|
||||||
applyPaginatedMode(container, element);
|
applyPaginatedMode(element);
|
||||||
break;
|
break;
|
||||||
case "scrolled":
|
case "scrolled":
|
||||||
applyScrolledMode(container, element);
|
applyScrolledMode(element);
|
||||||
break;
|
break;
|
||||||
case "single-column":
|
case "single-column":
|
||||||
applySingleColumn(element);
|
applySingleColumn(element);
|
||||||
@@ -70,28 +70,56 @@ function setViewMode(container: HTMLElement, mode: ViewMode): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyPaginatedMode(
|
function applyPaginatedMode(element: HTMLElement): void {
|
||||||
container: HTMLElement,
|
|
||||||
element: HTMLElement,
|
|
||||||
): void {
|
|
||||||
element.classList.add("paginated");
|
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.overflow = "hidden";
|
||||||
element.style.columnCount = "1";
|
element.style.columnCount = "1";
|
||||||
element.style.columnGap = "0";
|
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.classList.add("scrolled");
|
||||||
|
|
||||||
element.style.height = "auto";
|
element.style.height = "auto";
|
||||||
element.style.overflowY = "auto";
|
element.style.overflowY = "auto";
|
||||||
element.style.columnCount = "1";
|
element.style.columnCount = "1";
|
||||||
|
|
||||||
disablePagination(container);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySingleColumn(element: HTMLElement): void {
|
function applySingleColumn(element: HTMLElement): void {
|
||||||
@@ -113,51 +141,6 @@ function applyDoubleColumn(element: HTMLElement): void {
|
|||||||
element.style.margin = "0 auto";
|
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 {
|
function goToPage(container: HTMLElement, pageNumber: number): void {
|
||||||
const content = container.querySelector(".ebook-content") as HTMLElement;
|
const content = container.querySelector(".ebook-content") as HTMLElement;
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
@@ -196,4 +179,5 @@ export function getCurrentViewMode(container: HTMLElement): ViewMode {
|
|||||||
if (content.classList.contains("double-column")) return "double-column";
|
if (content.classList.contains("double-column")) return "double-column";
|
||||||
|
|
||||||
return "paginated";
|
return "paginated";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { ReaderContext } from "../core/reader-context";
|
import type { ReaderContext } from "../core/reader-context";
|
||||||
import { getCurrentPageFromScroll } from "../ebook/page-calculator";
|
|
||||||
interface ProgressDisplay {
|
interface ProgressDisplay {
|
||||||
mode: "pages" | "chapter" | "percentage" | "time-left";
|
mode: "pages" | "chapter" | "percentage" | "time-left";
|
||||||
text: string;
|
text: string;
|
||||||
@@ -111,35 +110,50 @@ function updateProgressDisplay(context: ReaderContext): void {
|
|||||||
if (!display || !state.currentReader) return;
|
if (!display || !state.currentReader) return;
|
||||||
let currentPage = 0;
|
let currentPage = 0;
|
||||||
let totalPages = 0;
|
let totalPages = 0;
|
||||||
|
let currentChapterPage = 0;
|
||||||
|
let chapterPages = 0;
|
||||||
if (state.currentReader.type === "ebook") {
|
if (state.currentReader.type === "ebook") {
|
||||||
const reader = state.currentReader as any;
|
const reader = state.currentReader as any;
|
||||||
const pageInfo = reader.pageCalculationResult;
|
|
||||||
|
|
||||||
if (pageInfo && pageInfo.totalPages > 0) {
|
// Use currentPage directly if available (page-based navigation)
|
||||||
const container = document.getElementById("reader-content");
|
if (reader.currentPage) {
|
||||||
const viewportHeight = window.innerHeight;
|
currentPage = reader.currentPage;
|
||||||
currentPage = getCurrentPageFromScroll(
|
} else if (reader.currentSpineIndex !== undefined) {
|
||||||
pageInfo,
|
|
||||||
reader.currentSpineIndex,
|
|
||||||
container?.scrollTop || 0,
|
|
||||||
viewportHeight,
|
|
||||||
);
|
|
||||||
totalPages = pageInfo.totalPages;
|
|
||||||
} else {
|
|
||||||
// Fallback to estimated pages while calculating
|
|
||||||
currentPage = reader.currentSpineIndex + 1;
|
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 =
|
totalPages =
|
||||||
state.readerMetadata?.total_pages ||
|
state.readerMetadata?.total_pages ||
|
||||||
state.currentReader.cif.locations?.estimatedPages ||
|
state.currentReader.cif.locations?.estimatedPages ||
|
||||||
state.currentReader.cif.spine.length;
|
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) => {
|
getReadingSpeed().then((speed) => {
|
||||||
const result = calculateProgress(
|
const result = calculateProgress(
|
||||||
currentPage,
|
currentPage,
|
||||||
totalPages,
|
totalPages,
|
||||||
0,
|
currentChapterPage,
|
||||||
0,
|
chapterPages,
|
||||||
speed || undefined,
|
speed || undefined,
|
||||||
);
|
);
|
||||||
display.textContent = result.text;
|
display.textContent = result.text;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface UniversalReader {
|
|||||||
type: "ebook";
|
type: "ebook";
|
||||||
cif: any;
|
cif: any;
|
||||||
currentSpineIndex: number;
|
currentSpineIndex: number;
|
||||||
|
currentPage: number;
|
||||||
|
pageCalculationResult?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PDFReader {
|
interface PDFReader {
|
||||||
@@ -143,17 +145,15 @@ async function initializeReader(): Promise<void> {
|
|||||||
|
|
||||||
await initializeFeatures(context);
|
await initializeFeatures(context);
|
||||||
readerEvents.emit("readerReady", currentReader);
|
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();
|
await renderSpineItem();
|
||||||
// Focus the content container so keyboard navigation works immediately
|
// Focus the content container so keyboard navigation works immediately
|
||||||
const container = document.getElementById("reader-content");
|
const container = document.getElementById("reader-content");
|
||||||
container?.focus();
|
container?.focus();
|
||||||
// Initialize page calculation for dynamic page numbers
|
|
||||||
setTimeout(async () => {
|
|
||||||
const { initializePageCalculation } =
|
|
||||||
await import("./core/reader-navigation");
|
|
||||||
initializePageCalculation();
|
|
||||||
}, 100);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Render initialization failed:", error);
|
console.error("Render initialization failed:", error);
|
||||||
}
|
}
|
||||||
@@ -206,6 +206,7 @@ async function initializeEbookReader(metadata: any): Promise<UniversalReader> {
|
|||||||
type: "ebook",
|
type: "ebook",
|
||||||
cif,
|
cif,
|
||||||
currentSpineIndex: 0,
|
currentSpineIndex: 0,
|
||||||
|
currentPage: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user