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:
@@ -2,16 +2,8 @@ import { getDefaultSettings } from "../settings-manager";
|
|||||||
import { getState, setState } 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 {
|
|
||||||
calculatePagesForEbook,
|
|
||||||
calculateProgressPercentage,
|
|
||||||
type PageCalculationResult,
|
|
||||||
} from "../ebook/page-calculator";
|
|
||||||
import { UniversalReader } from "../reader-shell";
|
import { UniversalReader } from "../reader-shell";
|
||||||
|
|
||||||
let pageCalculationResult: PageCalculationResult | null = null;
|
|
||||||
let isCalculatingPages = false;
|
|
||||||
|
|
||||||
export function createNavigationAPI() {
|
export function createNavigationAPI() {
|
||||||
return {
|
return {
|
||||||
nextPage: () => {
|
nextPage: () => {
|
||||||
@@ -19,50 +11,43 @@ export function createNavigationAPI() {
|
|||||||
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 (pageCalculationResult) {
|
const container = document.getElementById("reader-content");
|
||||||
const currentPage = state.currentReader.currentPage || 1;
|
if (!container) return;
|
||||||
|
const viewportHeight = window.innerHeight - 120;
|
||||||
if (currentPage < pageCalculationResult.totalPages) {
|
const currentScroll = container.scrollTop;
|
||||||
const nextPage = currentPage + 1;
|
const newScroll = currentScroll + viewportHeight;
|
||||||
state.currentReader.currentPage = nextPage;
|
if (newScroll >= container.scrollHeight - viewportHeight) {
|
||||||
|
|
||||||
// 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
|
|
||||||
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 });
|
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();
|
sendProgressUpdate();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
container.scrollTo({
|
||||||
|
top: newScroll,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
sendProgressUpdate();
|
||||||
}
|
}
|
||||||
} else if (state.currentReader.type === "pdf") {
|
} else if (state.currentReader.type === "pdf") {
|
||||||
const totalPages = state.readerMetadata?.total_pages || 0;
|
const totalPages = state.readerMetadata?.total_pages || 0;
|
||||||
if (state.currentReader.currentPage < totalPages) {
|
if (state.currentReader.currentPage < totalPages) {
|
||||||
state.currentReader.currentPage++;
|
state.currentReader.currentPage++;
|
||||||
renderPDFPage();
|
renderPDFPage();
|
||||||
|
sendProgressUpdate();
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
state.currentReader.type === "comic" ||
|
state.currentReader.type === "comic" ||
|
||||||
@@ -74,62 +59,46 @@ export function createNavigationAPI() {
|
|||||||
) {
|
) {
|
||||||
state.currentReader.currentPage++;
|
state.currentReader.currentPage++;
|
||||||
renderComicPage();
|
renderComicPage();
|
||||||
|
sendProgressUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setState({ currentReader: state.currentReader });
|
setState({ currentReader: state.currentReader });
|
||||||
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") {
|
||||||
if (pageCalculationResult) {
|
const container = document.getElementById("reader-content");
|
||||||
const currentPage = state.currentReader.currentPage || 1;
|
if (!container) return;
|
||||||
|
const currentScroll = container.scrollTop;
|
||||||
if (currentPage > 1) {
|
if (currentScroll <= 0) {
|
||||||
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
|
|
||||||
if (state.currentReader.currentSpineIndex > 0) {
|
if (state.currentReader.currentSpineIndex > 0) {
|
||||||
state.currentReader.currentSpineIndex--;
|
state.currentReader.currentSpineIndex--;
|
||||||
setState({ currentReader: state.currentReader });
|
setState({ currentReader: state.currentReader });
|
||||||
renderSpineItem();
|
renderSpineItem().then(() => {
|
||||||
sendProgressUpdate();
|
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") {
|
} else if (state.currentReader.type === "pdf") {
|
||||||
if (state.currentReader.currentPage > 1) {
|
if (state.currentReader.currentPage > 1) {
|
||||||
state.currentReader.currentPage--;
|
state.currentReader.currentPage--;
|
||||||
renderPDFPage();
|
renderPDFPage();
|
||||||
|
sendProgressUpdate();
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
state.currentReader.type === "comic" ||
|
state.currentReader.type === "comic" ||
|
||||||
@@ -138,48 +107,41 @@ export function createNavigationAPI() {
|
|||||||
if (state.currentReader.currentPage > 0) {
|
if (state.currentReader.currentPage > 0) {
|
||||||
state.currentReader.currentPage--;
|
state.currentReader.currentPage--;
|
||||||
renderComicPage();
|
renderComicPage();
|
||||||
|
sendProgressUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setState({ currentReader: state.currentReader });
|
setState({ currentReader: state.currentReader });
|
||||||
readerEvents.emit("afterPageChange", state.currentReader);
|
readerEvents.emit("afterPageChange", state.currentReader);
|
||||||
},
|
},
|
||||||
|
|
||||||
goToPage: async (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 (pageCalculationResult) {
|
const spine = state.currentReader.cif.spine;
|
||||||
// Validate page number
|
const spineCount = spine.length;
|
||||||
if (page < 1 || page > pageCalculationResult.totalPages) {
|
const pagesPerSpine = Math.ceil(1000 / spineCount);
|
||||||
return;
|
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;
|
||||||
}
|
}
|
||||||
|
}, 100);
|
||||||
// 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)) {
|
||||||
state.currentReader.currentPage = page;
|
state.currentReader.currentPage = page;
|
||||||
renderPDFPage();
|
renderPDFPage();
|
||||||
|
sendProgressUpdate();
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
state.currentReader.type === "comic" ||
|
state.currentReader.type === "comic" ||
|
||||||
@@ -188,152 +150,98 @@ export function createNavigationAPI() {
|
|||||||
if (page >= 0 && page < (state.currentReader as any).images.length) {
|
if (page >= 0 && page < (state.currentReader as any).images.length) {
|
||||||
state.currentReader.currentPage = page;
|
state.currentReader.currentPage = page;
|
||||||
renderComicPage();
|
renderComicPage();
|
||||||
|
sendProgressUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setState({ currentReader: state.currentReader });
|
setState({ currentReader: state.currentReader });
|
||||||
readerEvents.emit("pageChanged", page);
|
readerEvents.emit("pageChanged", page);
|
||||||
readerEvents.emit("afterPageChange", state.currentReader);
|
readerEvents.emit("afterPageChange", state.currentReader);
|
||||||
},
|
},
|
||||||
|
|
||||||
goToChapter: (chapterIndex: number) => {
|
goToChapter: (chapterIndex: number) => {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
if (!state.readerMetadata?.chapter_metadata?.chapters) return;
|
if (!state.readerMetadata?.chapter_metadata?.chapters) return;
|
||||||
|
if (state.currentReader?.type !== "ebook") return;
|
||||||
const chapters = state.readerMetadata.chapter_metadata.chapters;
|
const chapters = state.readerMetadata.chapter_metadata.chapters;
|
||||||
if (chapterIndex < 0 || chapterIndex >= chapters.length) return;
|
if (chapterIndex < 0 || chapterIndex >= chapters.length) return;
|
||||||
|
|
||||||
const chapter = chapters[chapterIndex];
|
const chapter = chapters[chapterIndex];
|
||||||
const pageAPI = createNavigationAPI();
|
if ((chapter as any).spine_index !== undefined) {
|
||||||
pageAPI.goToPage(chapter.start_page);
|
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);
|
readerEvents.emit("chapterChanged", chapter);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initializePageCalculation() {
|
export async function initializePageCalculation() {
|
||||||
readerEvents.on("settings:changed", async (settings: any) => {
|
setupScrollTracking();
|
||||||
const state = getState();
|
console.log("Page tracking initialized (CSS columns mode)");
|
||||||
if (!state.currentReader || state.currentReader.type !== "ebook") return;
|
}
|
||||||
console.log("Recalculating pages due to settings change...");
|
function setupScrollTracking(): void {
|
||||||
const viewportWidth = window.innerWidth;
|
let scrollTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
pageCalculationResult = await calculatePagesForEbook(
|
document.addEventListener(
|
||||||
state.currentReader.cif,
|
"scroll",
|
||||||
viewportWidth,
|
() => {
|
||||||
{
|
const container = document.getElementById("reader-content");
|
||||||
fontSize: settings.font_size,
|
if (!container) return;
|
||||||
lineHeight: settings.line_height,
|
const state = getState();
|
||||||
marginWidth: settings.margin_width,
|
if (!state.currentReader || state.currentReader.type !== "ebook") return;
|
||||||
},
|
if (scrollTimeout) clearTimeout(scrollTimeout);
|
||||||
);
|
scrollTimeout = setTimeout(() => {
|
||||||
state.currentReader.pageCalculationResult = pageCalculationResult;
|
updatePageFromScroll(container);
|
||||||
setState({ currentReader: state.currentReader });
|
}, 50);
|
||||||
// Re-render the current page with new settings
|
},
|
||||||
await renderSpineItem();
|
{ passive: true },
|
||||||
|
);
|
||||||
// Emit event so progress display updates
|
}
|
||||||
const currentPage = state.currentReader.currentPage || 1;
|
function updatePageFromScroll(container: HTMLElement): void {
|
||||||
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;
|
||||||
if (isCalculatingPages) return;
|
const viewportHeight = window.innerHeight - 120;
|
||||||
|
const scrollTop = container.scrollTop;
|
||||||
isCalculatingPages = true;
|
const contentHeight = container.scrollHeight;
|
||||||
|
const currentPage = Math.floor(scrollTop / viewportHeight) + 1;
|
||||||
try {
|
const totalPages = Math.max(1, Math.ceil(contentHeight / viewportHeight));
|
||||||
const settings = getDefaultSettings();
|
const percentage = contentHeight > 0 ? (scrollTop / contentHeight) * 100 : 0;
|
||||||
const viewportWidth = window.innerWidth;
|
state.currentReader.currentPage = currentPage;
|
||||||
|
state.currentReader.currentScrollPosition = scrollTop;
|
||||||
pageCalculationResult = await calculatePagesForEbook(
|
setState({ currentReader: state.currentReader });
|
||||||
state.currentReader.cif,
|
readerEvents.emit("progressUpdated", {
|
||||||
viewportWidth,
|
currentPage,
|
||||||
{
|
totalPages,
|
||||||
fontSize: settings.font_size,
|
percentage,
|
||||||
lineHeight: settings.line_height,
|
scrollTop,
|
||||||
marginWidth: settings.margin_width,
|
contentHeight,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renderSpineItem() {
|
export async function renderSpineItem(): Promise<void> {
|
||||||
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");
|
const container = document.getElementById("reader-content");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const spineItem =
|
const spineItem =
|
||||||
state.currentReader.cif.spine[state.currentReader.currentSpineIndex];
|
state.currentReader.cif.spine[state.currentReader.currentSpineIndex];
|
||||||
if (!spineItem) return;
|
if (!spineItem) return;
|
||||||
// Get page calculation result
|
const resources = state.currentReader.cif.resources;
|
||||||
const chapter = pageCalculationResult?.chapterMap.get(
|
const contentBlob = resources?.get(spineItem.content);
|
||||||
state.currentReader.currentSpineIndex,
|
if (!contentBlob) {
|
||||||
);
|
console.error("Spine item content not found:", spineItem.content);
|
||||||
|
container.innerHTML = `<p>Error: Could not load chapter content</p>`;
|
||||||
// Calculate which page within the chapter we're on
|
return;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Process and render the page content
|
let currentPageContent = await contentBlob.text();
|
||||||
const modifiedContent = rewriteImageUrls(
|
const modifiedContent = rewriteImageUrls(
|
||||||
currentPageContent,
|
currentPageContent,
|
||||||
state.currentReader.cif.resources,
|
state.currentReader.cif.resources,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Parse HTML and process images
|
|
||||||
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");
|
||||||
@@ -343,8 +251,6 @@ export async function renderSpineItem() {
|
|||||||
img.setAttribute("src", URL.createObjectURL(blob));
|
img.setAttribute("src", URL.createObjectURL(blob));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process SVG <image> elements with xlink:href
|
|
||||||
const svgImgElements = Array.from(doc.querySelectorAll("image"));
|
const svgImgElements = Array.from(doc.querySelectorAll("image"));
|
||||||
for (const img of svgImgElements) {
|
for (const img of svgImgElements) {
|
||||||
const src = img.getAttribute("xlink:href");
|
const src = img.getAttribute("xlink:href");
|
||||||
@@ -354,28 +260,12 @@ export async function renderSpineItem() {
|
|||||||
img.setAttribute("xlink:href", URL.createObjectURL(blob));
|
img.setAttribute("xlink:href", URL.createObjectURL(blob));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
container.innerHTML = `<div class="ebook-content">${doc.body.innerHTML}</div>`;
|
||||||
// Apply to container - replace innerHTML with current page content
|
|
||||||
container.innerHTML = doc.body.innerHTML;
|
|
||||||
|
|
||||||
// Apply styling
|
|
||||||
applyReaderTheme();
|
applyReaderTheme();
|
||||||
applyTypography();
|
applyTypography();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
// Emit progress update
|
updatePageFromScroll(container);
|
||||||
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(
|
||||||
@@ -511,32 +401,32 @@ function getFontStack(font: string): string {
|
|||||||
return stacks[font] || stacks["literata"];
|
return stacks[font] || stacks["literata"];
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendProgressUpdate() {
|
function sendProgressUpdate(): void {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
if (!state.currentReader || !state.readerMetadata) return;
|
if (!state.currentReader || !state.readerMetadata) return;
|
||||||
|
|
||||||
const mediaItemId =
|
const mediaItemId =
|
||||||
state.readerMetadata.id ||
|
state.readerMetadata.id ||
|
||||||
document.body.dataset.mediaItemId ||
|
document.body.dataset.mediaItemId ||
|
||||||
window.location.pathname.split("/").pop();
|
window.location.pathname.split("/").pop();
|
||||||
|
|
||||||
if (!mediaItemId) {
|
if (!mediaItemId) {
|
||||||
console.warn("No mediaItemId available for progress update");
|
console.warn("No mediaItemId available for progress update");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const container = document.getElementById("reader-content");
|
||||||
let currentPage = 0;
|
let currentPage = 1;
|
||||||
let totalPages = 0;
|
let totalPages = 1;
|
||||||
|
let percentage = 0;
|
||||||
if (state.currentReader.type === "ebook") {
|
let character = 0;
|
||||||
// Use dynamic page calculation if available
|
if (state.currentReader.type === "ebook" && container) {
|
||||||
if (pageCalculationResult) {
|
const viewportHeight = window.innerHeight - 120;
|
||||||
currentPage = state.currentReader.currentPage || 1;
|
const contentHeight = container.scrollHeight;
|
||||||
totalPages = pageCalculationResult.totalPages;
|
const scrollTop = container.scrollTop;
|
||||||
} else {
|
currentPage = Math.floor(scrollTop / viewportHeight) + 1;
|
||||||
currentPage = state.currentReader.currentSpineIndex + 1;
|
totalPages = Math.max(1, Math.ceil(contentHeight / viewportHeight));
|
||||||
totalPages = state.currentReader.cif.spine.length;
|
percentage = contentHeight > 0 ? (scrollTop / contentHeight) * 100 : 0;
|
||||||
}
|
character = getCharacterOffset();
|
||||||
|
state.currentReader.currentPage = currentPage;
|
||||||
|
setState({ currentReader: state.currentReader });
|
||||||
} else if (state.currentReader.type === "pdf") {
|
} else if (state.currentReader.type === "pdf") {
|
||||||
totalPages = state.readerMetadata.total_pages || 0;
|
totalPages = state.readerMetadata.total_pages || 0;
|
||||||
currentPage = state.currentReader.currentPage;
|
currentPage = state.currentReader.currentPage;
|
||||||
@@ -547,10 +437,7 @@ function sendProgressUpdate() {
|
|||||||
totalPages = state.currentReader.images.length;
|
totalPages = state.currentReader.images.length;
|
||||||
currentPage = state.currentReader.currentPage;
|
currentPage = state.currentReader.currentPage;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = state.currentReader as UniversalReader;
|
const reader = state.currentReader as UniversalReader;
|
||||||
const percentage = totalPages > 0 ? (currentPage / totalPages) * 100 : 0;
|
|
||||||
|
|
||||||
updateReadingProgress(
|
updateReadingProgress(
|
||||||
state.readerMetadata.id,
|
state.readerMetadata.id,
|
||||||
{
|
{
|
||||||
@@ -558,19 +445,26 @@ function sendProgressUpdate() {
|
|||||||
total_pages: totalPages,
|
total_pages: totalPages,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Extended data for cross-device sync
|
character,
|
||||||
character: getCharacterOffset(),
|
|
||||||
chapter: reader.currentSpineIndex,
|
chapter: reader.currentSpineIndex,
|
||||||
percentage: percentage,
|
percentage,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
readerEvents.emit("progressUpdated", { currentPage, totalPages, percentage });
|
||||||
readerEvents.emit("progressUpdated", { currentPage, totalPages });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCharacterOffset(): number {
|
function getCharacterOffset(): number {
|
||||||
const container = document.getElementById("reader-content");
|
const container = document.getElementById("reader-content");
|
||||||
if (!container) return 0;
|
if (!container) return 0;
|
||||||
const textContent = container.textContent || "";
|
const viewportHeight = window.innerHeight - 120;
|
||||||
return textContent.length;
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,121 +1,25 @@
|
|||||||
import { findImageInResources } from "../core/reader-navigation";
|
export interface SpineInfo {
|
||||||
import { splitContent, type PageContent } from "./page-splitter";
|
|
||||||
|
|
||||||
export interface ChapterPageInfo {
|
|
||||||
spineIndex: number;
|
spineIndex: number;
|
||||||
spineItemId: string;
|
spineItemId: string;
|
||||||
content: string;
|
content: string;
|
||||||
startPage: number;
|
|
||||||
endPage: number;
|
|
||||||
scrollHeight: number;
|
|
||||||
charCount: number;
|
charCount: number;
|
||||||
pagesInChapter: number;
|
estimatedPages: number;
|
||||||
pages: PageContent[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PageCalculationResult {
|
export interface PageCalculationResult {
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
chapters: ChapterPageInfo[];
|
spines: SpineInfo[];
|
||||||
chapterMap: Map<number, ChapterPageInfo>;
|
spineMap: Map<number, SpineInfo>;
|
||||||
calculatedAt: number;
|
calculatedAt: number;
|
||||||
settings: PageCalculationSettings;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PageCalculationSettings {
|
export interface PageCalculationSettings {
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
lineHeight: number;
|
lineHeight: number;
|
||||||
marginWidth: number;
|
marginWidth: number;
|
||||||
viewportHeight: 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(
|
export async function calculatePagesForEbook(
|
||||||
cif: EbookCIF,
|
cif: EbookCIF,
|
||||||
viewportWidth: number,
|
_viewportWidth: number,
|
||||||
settings: {
|
settings: {
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
lineHeight?: number;
|
lineHeight?: number;
|
||||||
@@ -124,156 +28,103 @@ export async function calculatePagesForEbook(
|
|||||||
): Promise<PageCalculationResult> {
|
): Promise<PageCalculationResult> {
|
||||||
const fontSize = settings.fontSize || 16;
|
const fontSize = settings.fontSize || 16;
|
||||||
const lineHeight = settings.lineHeight || 1.6;
|
const lineHeight = settings.lineHeight || 1.6;
|
||||||
const marginWidth = settings.marginWidth || 20;
|
const charsPerPage = Math.round(2000 * (16 / fontSize) * (lineHeight / 1.6));
|
||||||
|
const spines: SpineInfo[] = [];
|
||||||
const viewportHeight = window.innerHeight - 120;
|
let estimatedTotalPages = 0;
|
||||||
|
|
||||||
const pageSettings: PageCalculationSettings = {
|
|
||||||
fontSize,
|
|
||||||
lineHeight,
|
|
||||||
marginWidth,
|
|
||||||
viewportHeight,
|
|
||||||
};
|
|
||||||
|
|
||||||
const config = getDefaultContainerConfig(viewportWidth, pageSettings);
|
|
||||||
|
|
||||||
const chapters: ChapterPageInfo[] = [];
|
|
||||||
let currentPage = 1;
|
|
||||||
|
|
||||||
for (let i = 0; i < cif.spine.length; i++) {
|
for (let i = 0; i < cif.spine.length; i++) {
|
||||||
const spineItem = cif.spine[i];
|
const spineItem = cif.spine[i];
|
||||||
|
|
||||||
if (spineItem.type !== "html") {
|
if (spineItem.type !== "html") {
|
||||||
chapters.push({
|
spines.push({
|
||||||
spineIndex: i,
|
spineIndex: i,
|
||||||
spineItemId: spineItem.id,
|
spineItemId: spineItem.id,
|
||||||
content: "",
|
content: "",
|
||||||
startPage: currentPage,
|
|
||||||
endPage: currentPage,
|
|
||||||
scrollHeight: 0,
|
|
||||||
charCount: 0,
|
charCount: 0,
|
||||||
pagesInChapter: 0,
|
estimatedPages: 1,
|
||||||
pages: [],
|
|
||||||
});
|
});
|
||||||
|
estimatedTotalPages += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentBlob = cif.resources.get(spineItem.content);
|
const contentBlob = cif.resources.get(spineItem.content);
|
||||||
if (!contentBlob) {
|
if (!contentBlob) {
|
||||||
chapters.push({
|
spines.push({
|
||||||
spineIndex: i,
|
spineIndex: i,
|
||||||
spineItemId: spineItem.id,
|
spineItemId: spineItem.id,
|
||||||
content: "",
|
content: "",
|
||||||
startPage: currentPage,
|
|
||||||
endPage: currentPage,
|
|
||||||
scrollHeight: 0,
|
|
||||||
charCount: 0,
|
charCount: 0,
|
||||||
pagesInChapter: 0,
|
estimatedPages: 1,
|
||||||
pages: [],
|
|
||||||
});
|
});
|
||||||
|
estimatedTotalPages += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentText = await contentBlob.text();
|
const contentText = await contentBlob.text();
|
||||||
const charCount = contentText.replace(/<[^>]*>/g, "").length;
|
const charCount = contentText.replace(/<[^>]*>/g, "").length;
|
||||||
|
const estimatedPages = Math.max(1, Math.ceil(charCount / charsPerPage));
|
||||||
let scrollHeight = 0;
|
estimatedTotalPages += estimatedPages;
|
||||||
try {
|
spines.push({
|
||||||
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 = {
|
|
||||||
spineIndex: i,
|
spineIndex: i,
|
||||||
spineItemId: spineItem.id,
|
spineItemId: spineItem.id,
|
||||||
content: contentText,
|
content: contentText,
|
||||||
startPage: currentPage,
|
|
||||||
endPage: currentPage + pageSplitResult.totalPages - 1,
|
|
||||||
scrollHeight,
|
|
||||||
charCount,
|
charCount,
|
||||||
pagesInChapter: pageSplitResult.totalPages,
|
estimatedPages,
|
||||||
pages: pageSplitResult.pages,
|
});
|
||||||
};
|
|
||||||
|
|
||||||
chapters.push(chapterInfo);
|
|
||||||
currentPage += pageSplitResult.totalPages;
|
|
||||||
}
|
}
|
||||||
|
const spineMap = new Map<number, SpineInfo>();
|
||||||
const chapterMap = new Map<number, ChapterPageInfo>();
|
for (const spine of spines) {
|
||||||
for (const chapter of chapters) {
|
spineMap.set(spine.spineIndex, spine);
|
||||||
chapterMap.set(chapter.spineIndex, chapter);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalPages = currentPage - 1;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalPages,
|
totalPages: estimatedTotalPages,
|
||||||
chapters,
|
spines,
|
||||||
chapterMap,
|
spineMap,
|
||||||
calculatedAt: Date.now(),
|
calculatedAt: Date.now(),
|
||||||
settings: pageSettings,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCurrentPageFromScroll(
|
export function getCurrentPageFromScroll(
|
||||||
pageInfo: PageCalculationResult,
|
pageInfo: PageCalculationResult,
|
||||||
currentSpineIndex: number,
|
currentSpineIndex: number,
|
||||||
scrollPosition: number,
|
scrollPosition: number,
|
||||||
viewportHeight: number,
|
viewportHeight: number,
|
||||||
): number {
|
): number {
|
||||||
const chapter = pageInfo.chapterMap.get(currentSpineIndex);
|
const spine = pageInfo.spineMap.get(currentSpineIndex);
|
||||||
if (!chapter || chapter.pagesInChapter === 0) {
|
if (!spine) return 1;
|
||||||
return 1;
|
const charsBeforeSpine = pageInfo.spines
|
||||||
}
|
.slice(0, currentSpineIndex)
|
||||||
|
.reduce((sum, s) => sum + s.charCount, 0);
|
||||||
const viewportHeightAdjusted = viewportHeight - 120;
|
const charsInSpine = spine.charCount;
|
||||||
const positionInChapter = Math.floor(scrollPosition / viewportHeightAdjusted);
|
const charsAtPosition = (scrollPosition / viewportHeight) * charsInSpine;
|
||||||
|
const totalCharsAtPosition = charsBeforeSpine + charsAtPosition;
|
||||||
return Math.min(
|
const charsPerPage = 2000;
|
||||||
chapter.endPage,
|
return Math.ceil(totalCharsAtPosition / charsPerPage);
|
||||||
Math.max(chapter.startPage, chapter.startPage + positionInChapter),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getScrollPositionForPage(
|
export function getScrollPositionForPage(
|
||||||
pageInfo: PageCalculationResult,
|
pageInfo: PageCalculationResult,
|
||||||
targetPage: number,
|
targetPage: number,
|
||||||
viewportHeight: number,
|
viewportHeight: number,
|
||||||
): { spineIndex: number; scrollTop: number } | null {
|
): { spineIndex: number; scrollTop: number } | null {
|
||||||
const viewportHeightAdjusted = viewportHeight - 120;
|
const charsPerPage = 2000;
|
||||||
|
const targetChar = (targetPage - 1) * charsPerPage;
|
||||||
for (const chapter of pageInfo.chapters) {
|
let charsAccumulated = 0;
|
||||||
if (targetPage >= chapter.startPage && targetPage <= chapter.endPage) {
|
for (const spine of pageInfo.spines) {
|
||||||
const positionInChapter = targetPage - chapter.startPage;
|
charsAccumulated += spine.charCount;
|
||||||
const scrollTop = positionInChapter * viewportHeightAdjusted;
|
if (targetChar < charsAccumulated) {
|
||||||
|
const charsBefore = charsAccumulated - spine.charCount;
|
||||||
|
const charsIntoSpine = targetChar - charsBefore;
|
||||||
|
const scrollTop = (charsIntoSpine / spine.charCount) * viewportHeight;
|
||||||
return {
|
return {
|
||||||
spineIndex: chapter.spineIndex,
|
spineIndex: spine.spineIndex,
|
||||||
scrollTop,
|
scrollTop: Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(scrollTop, viewportHeight * spine.estimatedPages),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const lastSpine = pageInfo.spines[pageInfo.spines.length - 1];
|
||||||
return null;
|
return {
|
||||||
|
spineIndex: lastSpine?.spineIndex || 0,
|
||||||
|
scrollTop: 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculateProgressPercentage(
|
export function calculateProgressPercentage(
|
||||||
pageInfo: PageCalculationResult,
|
pageInfo: PageCalculationResult,
|
||||||
currentPage: number,
|
currentPage: number,
|
||||||
|
|||||||
@@ -72,15 +72,15 @@ function setViewMode(container: HTMLElement, mode: ViewMode): void {
|
|||||||
|
|
||||||
function applyPaginatedMode(element: HTMLElement): void {
|
function applyPaginatedMode(element: HTMLElement): void {
|
||||||
element.classList.add("paginated");
|
element.classList.add("paginated");
|
||||||
|
// Set up for CSS columns
|
||||||
// True pagination: content fits exactly in viewport, no scrolling
|
element.style.height = "calc(100vh - 120px)";
|
||||||
element.style.height = "calc(100vh - 120px)"; // Account for chrome (top 60px + bottom 60px)
|
element.style.overflowY = "auto";
|
||||||
element.style.overflow = "hidden";
|
element.style.overflowX = "hidden";
|
||||||
element.style.columnCount = "1";
|
element.style.columnCount = "1";
|
||||||
|
element.style.columnFill = "auto";
|
||||||
element.style.columnGap = "0";
|
element.style.columnGap = "0";
|
||||||
element.style.position = "relative";
|
element.style.position = "relative";
|
||||||
|
// Inject CSS for CSS column pagination
|
||||||
// Inject CSS for page clipping
|
|
||||||
injectPaginatedStyles();
|
injectPaginatedStyles();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,23 +92,22 @@ function injectPaginatedStyles(): void {
|
|||||||
const style = document.createElement("style");
|
const style = document.createElement("style");
|
||||||
style.id = "paginated-styles";
|
style.id = "paginated-styles";
|
||||||
style.textContent = `
|
style.textContent = `
|
||||||
.paginated .ebook-page {
|
.paginated {
|
||||||
height: 100%;
|
height: calc(100vh - 120px) !important;
|
||||||
overflow: hidden;
|
overflow-y: auto !important;
|
||||||
display: flex;
|
overflow-x: hidden !important;
|
||||||
flex-direction: column;
|
column-fill: auto !important;
|
||||||
|
column-count: 1 !important;
|
||||||
|
column-gap: 0 !important;
|
||||||
|
position: relative !important;
|
||||||
}
|
}
|
||||||
.paginated .ebook-page > * {
|
.paginated > * {
|
||||||
max-height: 100%;
|
max-width: 100%;
|
||||||
overflow: hidden;
|
column-fill: auto;
|
||||||
}
|
}
|
||||||
.paginated .ebook-content {
|
.paginated .ebook-content {
|
||||||
height: 100%;
|
height: auto !important;
|
||||||
overflow: hidden !important;
|
min-height: 100%;
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.paginated .ebook-content * {
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
@@ -144,29 +143,22 @@ function applyDoubleColumn(element: HTMLElement): void {
|
|||||||
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;
|
||||||
|
const totalPages = getTotalPageCount(container);
|
||||||
const pageHeight = content.clientHeight;
|
const targetPage = Math.min(Math.max(1, pageNumber), totalPages);
|
||||||
const scrollTop = (pageNumber - 1) * pageHeight;
|
const viewportHeight = window.innerHeight - 120;
|
||||||
|
const scrollTop = (targetPage - 1) * viewportHeight;
|
||||||
content.scrollTo({
|
content.scrollTo({
|
||||||
top: scrollTop,
|
top: scrollTop,
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
|
|
||||||
const pageInfo = container.querySelector(".page-info");
|
|
||||||
if (pageInfo) {
|
|
||||||
pageInfo.textContent = `Page ${pageNumber} of ${getTotalPageCount(container)}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTotalPageCount(container: HTMLElement): number {
|
function getTotalPageCount(container: HTMLElement): number {
|
||||||
const content = container.querySelector(".ebook-content") as HTMLElement;
|
const content = container.querySelector(".ebook-content") as HTMLElement;
|
||||||
if (!content) return 1;
|
if (!content) return 1;
|
||||||
|
const viewportHeight = window.innerHeight - 120;
|
||||||
const totalHeight = content.scrollHeight;
|
const contentHeight = content.scrollHeight;
|
||||||
const pageHeight = content.clientHeight;
|
return Math.max(1, Math.ceil(contentHeight / viewportHeight));
|
||||||
|
|
||||||
return Math.ceil(totalHeight / pageHeight);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCurrentViewMode(container: HTMLElement): ViewMode {
|
export function getCurrentViewMode(container: HTMLElement): ViewMode {
|
||||||
@@ -180,4 +172,3 @@ export function getCurrentViewMode(container: HTMLElement): ViewMode {
|
|||||||
|
|
||||||
return "paginated";
|
return "paginated";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user