import { LazyPageLoader } from "./loading/LazyPageLoader.js";
import { NavigationController } from "./controllers/NavigationController.js";
import { ZoomController } from "./controllers/ZoomController.js";
import { ViewerBook } from "./model/ViewerBook.js";
import { computeMargins } from "./rendering/layout.js";
import { applyPaperPreset, DEFAULT_PAPER_PRESET_ID } from "./model/paper.js";
const DEFAULT_LAYOUT = {
pw: 5.5, ph: 8.5,
ratio: 0, b: 1,
mInner: 0, mTop: 0, mBottom: 0,
};
/**
* Options for {@link BookViewer}.
*
* @typedef {Object} BookViewerOptions
* @property {HTMLCanvasElement} spreadCanvas Canvas the renderer draws into.
* @property {HTMLElement|null} [viewport=null] Element used for zoom measurement and scroll preservation.
* @property {Function} rendererClass Renderer constructor, usually {@link WebGPUSpreadRenderer} or {@link SpreadRenderer}.
* @property {PageSource|null} [source=null] Initial page source.
* @property {Partial<Layout>|null} [layout=null] Initial layout overrides.
* @property {Partial<Display>|null} [display=null] Initial display overrides.
* @property {string} [paperPreset="natural"] Paper preset id.
* @property {string} [contentBlendMode="multiply"] Blend mode for page content.
* @property {number} [paperThickness=0.5] Paper edge and turn-lighting strength from 0 to 1.
* @property {number} [paperTextureStrength=0.18] Paper texture/normal strength from 0 to 1.
* @property {boolean} [showPageBorder=true] Whether to render the page edge treatment.
* @property {number} [maxHighResPages=8] High-resolution page bitmap LRU capacity.
* @property {number} [renderScale=1] Pixel supersampling multiplier for the rendered spread canvas.
*/
/**
* Renderer-facing viewer class.
*
* Hosts supply a canvas and a {@link PageSource}; `BookViewer` drives
* navigation, page-turn animation, zoom, lazy bitmap loading, and events.
*/
export class BookViewer {
/**
* @param {BookViewerOptions} options Viewer options.
*/
constructor({
spreadCanvas,
viewport = null,
rendererClass,
source = null,
layout = null,
display = null,
paperPreset = DEFAULT_PAPER_PRESET_ID,
contentBlendMode = "multiply",
paperThickness = 0.5,
paperTextureStrength = 0.18,
showPageBorder = true,
maxHighResPages = 8,
renderScale = 1,
} = {}) {
if (!spreadCanvas) throw new Error("BookViewer: spreadCanvas is required");
if (!rendererClass) throw new Error("BookViewer: rendererClass is required");
// The renderer draws into `spreadCanvas`. CSS sizing for zoom is applied
// to the canvas itself. `viewport` is a separate element used for zoom
// math (its bounding rect is the visible area, its scrollLeft/Top is
// adjusted on zoom). If not passed, ZoomController falls back to the
// canvas's nearest scrollable ancestor.
this.spreadCanvas = spreadCanvas;
this.viewport = viewport;
// Viewer state (replaces the app.uiState the controllers used to read).
this.layout = layout ? { ...DEFAULT_LAYOUT, ...layout } : { ...DEFAULT_LAYOUT };
this.display = applyPaperPreset({
contentBlendMode,
paperThickness,
paperTextureStrength,
...display,
}, paperPreset);
this.showPageBorder = showPageBorder;
this.currentSpread = 0;
this.effectiveSpread = 0;
this.lastMargins = computeMargins(this.layout, 1);
// Public reactive surface.
this.listeners = new Map();
this.latestGeometry = null;
this.source = null;
this.book = new ViewerBook({ getPageCount: () => 0, getPageMetadata: () => null, on: () => () => {} });
this.resizeObserver = null;
this.observedResizeTarget = null;
this.resizeFrame = 0;
this.resizeDebounceTimer = 0;
this.boundResize = () => this.#scheduleResizeRedraw();
// Renderer + loaders.
this.spreadRenderer = new rendererClass(spreadCanvas);
this.lazyPageLoader = new LazyPageLoader(this.#loaderBook(), pageIndex => this.#onPageReady(pageIndex), { maxHighResPages });
// Controllers — they read fields off `this` (the viewer).
this.navigationController = new NavigationController(this);
this.zoomController = new ZoomController(this, { renderScale });
if (typeof ResizeObserver !== "undefined") {
this.resizeObserver = new ResizeObserver(this.boundResize);
} else {
spreadCanvas.ownerDocument?.defaultView?.addEventListener("resize", this.boundResize);
}
if (source) this.setSource(source);
}
// ---- public API ----
get backendName() { return this.spreadRenderer.backendName; }
get contentZoom() { return this.zoomController.contentZoom; }
get renderZoom() { return this.zoomController.renderZoom; }
get isAnimating() { return this.spreadRenderer.isAnimating; }
get numSpreads() { return this.book.numSpreads(); }
get viewerBook() { return this.book; } // alias for legacy host code
/**
* Replaces the page source, resets navigation to spread 0, warms previews,
* redraws, and emits `sourcechange`.
*
* @param {PageSource} source New page source.
* @returns {void}
*/
setSource(source) {
this.source = source;
this.book = new ViewerBook(source);
// LazyPageLoader operates on a book-shaped object whose pages are mutable
// (it writes srcCanvas/previewCanvas onto them). We give it the source's
// own internal book if available, else fall back to a derived passthrough.
this.lazyPageLoader.book = source.getInternalBook?.() ?? this.#loaderBook();
this.currentSpread = 0;
this.effectiveSpread = 0;
this.lazyPageLoader.reset();
if (this.book.pages.length) {
this.lazyPageLoader.ensureSpreadLoaded(0, 1, { allowHighRes: false });
this.lazyPageLoader.warmAllPreviews();
}
this.redraw();
this.schedulePreviewRedraw();
this.emit("sourcechange", { source });
}
/**
* Merges layout fields and redraws.
*
* @param {Partial<Layout>} layout Layout fields to update.
* @returns {void}
*/
setLayout(layout) {
this.layout = { ...this.layout, ...layout };
this.redraw();
}
/**
* Merges display fields and redraws.
*
* @param {Partial<Display>} display Display fields to update.
* @returns {void}
*/
setDisplay(display) {
this.display = { ...this.display, ...display };
this.redraw();
}
/**
* Toggles page edge rendering.
*
* @param {boolean} show Whether the page edge treatment should render.
* @returns {void}
*/
setShowPageBorder(show) {
this.showPageBorder = !!show;
this.zoomController.syncCanvasStage();
this.redraw();
}
/**
* Sets the element used for zoom measurement and scroll preservation.
*
* @param {HTMLElement|null} viewport Viewport element.
* @returns {void}
*/
setViewport(viewport) {
this.viewport = viewport;
this.#observeResizeTarget();
this.redraw();
}
/**
* Navigates to a spread. Long jumps are queued as multiple page turns.
*
* @param {number} spreadIndex Target spread index.
* @param {number|null} [preferredPageIndex=null] Page index to prefer when updating selection/readouts.
* @returns {void}
*/
navigateTo(spreadIndex, preferredPageIndex = null) {
const target = Math.max(0, Math.min(spreadIndex, this.numSpreads - 1));
const distance = Math.abs(target - this.navigationController.getEffectiveSpread());
if (distance > 1) {
this.navigationController.queueSpreadTurnsTo(target, preferredPageIndex);
} else {
this.navigationController.navigateTo(target, preferredPageIndex);
}
}
/**
* Adjusts content zoom. Positive values zoom in; negative values zoom out.
*
* @param {number} direction Zoom direction.
* @returns {void}
*/
adjustZoom(direction) { this.zoomController.adjustContentZoom(direction); }
/**
* Resets content zoom to 1.
*
* @returns {void}
*/
resetZoom() { this.zoomController.resetContentZoom(); }
/**
* Subscribes to a viewer event.
*
* @param {string} event Event name.
* @param {Function} fn Listener callback.
* @returns {Function} Unsubscribe function.
*/
on(event, fn) {
let arr = this.listeners.get(event);
if (!arr) { arr = []; this.listeners.set(event, arr); }
arr.push(fn);
return () => this.off(event, fn);
}
/**
* Removes a viewer event listener.
*
* @param {string} event Event name.
* @param {Function} fn Listener callback.
* @returns {void}
*/
off(event, fn) {
const arr = this.listeners.get(event);
if (!arr) return;
const idx = arr.indexOf(fn);
if (idx >= 0) arr.splice(idx, 1);
}
/**
* Returns the latest geometry emitted by the renderer.
*
* @returns {SpreadGeometry|null} Latest spread geometry.
*/
getSpreadGeometry() { return this.latestGeometry; }
// ---- internal ----
redraw() {
if (!this.spreadRenderer || !this.book) return;
this.#observeResizeTarget();
const scale = this.zoomController.getRenderScale();
const margins = computeMargins(this.layout, scale);
this.lastMargins = margins;
this.currentSpread = Math.min(this.currentSpread, Math.max(0, this.numSpreads - 1));
this.effectiveSpread = this.navigationController.getEffectiveSpread();
if (this.book.pages.length) {
this.lazyPageLoader.ensureSpreadLoaded(this.currentSpread, 1, { allowHighRes: false });
}
const spreadPages = this.#renderableSpreadPages(this.currentSpread);
const result = this.spreadRenderer.render(
spreadPages,
margins,
{ left: { pipeline: [], key: "" }, right: { pipeline: [], key: "" } },
this.display,
{
showPlaceholder: !this.book.pages.length,
previewZoom: this.renderZoom,
showPageBorder: this.showPageBorder,
pageCount: this.book.pages.length,
}
);
this.latestGeometry = {
spreadRects: result?.spreadRects ?? null,
sideStates: result?.sideStates ?? null,
margins,
};
this.zoomController.syncCanvasStage();
this.emit("geometrychange", this.latestGeometry);
}
schedulePreviewRedraw() {
if (this.spreadRenderer.isAnimating) return;
const targetSpread = this.navigationController.getEffectiveSpread();
if (this.book.pages.length) {
this.lazyPageLoader.ensureSpreadLoaded(targetSpread, this.contentZoom, { allowHighRes: true });
this.#prefetchAdjacentHighRes(targetSpread);
}
if (this.zoomController.applySafeRenderZoom()) this.redraw();
}
// Build a renderable-spread payload for the renderer's render() call.
#renderableSpreadPages(spreadIndex) {
if (!this.book.pages.length) return null;
const entries = this.book.spreadPageEntries(spreadIndex);
return {
left: { ...entries.left, showThroughEffectEntry: { pipeline: [], key: "" } },
right: { ...entries.right, showThroughEffectEntry: { pipeline: [], key: "" } },
};
}
#prefetchAdjacentHighRes(targetSpread) {
const numSpreads = this.numSpreads;
for (const adj of [targetSpread - 1, targetSpread + 1]) {
if (adj < 0 || adj >= numSpreads) continue;
const { left, right } = this.book.spreadPageEntries(adj);
for (const pageIndex of [left.pageIndex, right.pageIndex]) {
if (pageIndex < 0) continue;
if (this.lazyPageLoader.isPageHighResReady(pageIndex, this.contentZoom)) continue;
this.lazyPageLoader.ensurePageHighRes(pageIndex, this.contentZoom, { priority: false });
}
}
}
// Snapshot a spread to a canvas for queued multi-spread animations.
createSpreadSnapshot(spreadIndex) {
const margins = computeMargins(this.layout, this.zoomController.getRenderScale());
const pages = this.#renderableSpreadPages(spreadIndex);
const { canvas } = this.spreadRenderer.snapshot(
pages,
margins,
{ left: { pipeline: [], key: "" }, right: { pipeline: [], key: "" } },
this.display,
{ previewZoom: this.renderZoom, showPageBorder: this.showPageBorder, pageCount: this.book.pages.length },
);
return canvas;
}
#onPageReady(pageIndex) {
const viewerPage = this.book.pages[pageIndex] ?? null;
if (this.spreadRenderer.isAnimating) {
// Let host code (composition pipelines, thumbnail managers) react to
// the fresh bitmap before the renderer reads through ViewerPage's
// getter chain.
this.emit("pageready", { pageIndex, animating: true });
if (viewerPage) this.spreadRenderer.refreshPageSource?.(viewerPage);
return;
}
// Emit first so host listeners can populate composed canvases / placed
// previews before the redraw samples ViewerPage.displayCanvas.
this.emit("pageready", { pageIndex, animating: false });
const { left, right } = this.book.spreadPageEntries(this.currentSpread);
const isOnCurrent = pageIndex === left.pageIndex || pageIndex === right.pageIndex;
if (isOnCurrent) {
this.zoomController.applySafeRenderZoom();
this.redraw();
}
}
#loaderBook() {
// LazyPageLoader expects book.numSpreads() / book.spreadPageEntries() /
// book.pages — and writes srcCanvas etc. onto the page objects. Our
// ViewerBook returns ViewerPage instances whose bitmap fields are
// getters delegating to metadata. If the source provides its own
// internal book (via getInternalBook), prefer that; otherwise build a
// minimal proxy that walks the source's passthrough pages.
if (this.source?.getInternalBook) return this.source.getInternalBook();
return {
get pages() { return []; },
numSpreads() { return 1; },
spreadPageEntries() { return { left: { page: null, pageIndex: -1, showThroughPage: null }, right: { page: null, pageIndex: -1, showThroughPage: null } }; },
};
}
#observeResizeTarget() {
if (!this.resizeObserver) return;
const target = this.zoomController.getViewportElement?.() ?? null;
if (target === this.observedResizeTarget) return;
if (this.observedResizeTarget) this.resizeObserver.unobserve(this.observedResizeTarget);
this.observedResizeTarget = target;
if (target) this.resizeObserver.observe(target);
}
#scheduleResizeRedraw() {
if (this.resizeFrame) return;
const win = this.spreadCanvas.ownerDocument?.defaultView ?? globalThis;
this.resizeFrame = win.requestAnimationFrame?.(() => {
this.resizeFrame = 0;
this.zoomController.syncCanvasStage();
if (this.resizeDebounceTimer) win.clearTimeout?.(this.resizeDebounceTimer);
this.resizeDebounceTimer = win.setTimeout?.(() => {
this.resizeDebounceTimer = 0;
this.zoomController.applySafeRenderZoom();
this.redraw();
this.schedulePreviewRedraw();
}, 160) ?? 0;
if (!this.resizeDebounceTimer) {
this.zoomController.applySafeRenderZoom();
this.redraw();
this.schedulePreviewRedraw();
}
}) ?? 0;
if (!this.resizeFrame) {
this.zoomController.syncCanvasStage();
this.zoomController.applySafeRenderZoom();
this.redraw();
this.schedulePreviewRedraw();
}
}
emit(event, ...args) {
const arr = this.listeners.get(event);
if (!arr) return;
for (const fn of arr.slice()) fn(...args);
}
}