, with blob-based loading, an intrinsic-size SVG placeholder,
+ * and per-mode layout. The image-based equivalent of FXLFrameManager.
+ */
+export class DivinaPageManager {
+ readonly page: DivinaPage;
+ readonly wrapper: HTMLDivElement;
+ readonly img: HTMLImageElement;
+
+ private readonly fetchBlob: (link: Link, signal?: AbortSignal) => Promise
;
+ private readonly getQuality: () => DivinaQuality;
+ private readonly placeholder: string;
+ private objectURL: string | null = null;
+ private loadPromise: Promise | null = null;
+ private abortController: AbortController | null = null;
+ private _loaded = false;
+ private destroyed = false;
+ private generation = 0; // Bumped on unload/destroy to invalidate in-flight loads
+ private mode: DivinaPageMode = "paged";
+
+ constructor(
+ page: DivinaPage,
+ fetchBlob: (link: Link, signal?: AbortSignal) => Promise,
+ getQuality: () => DivinaQuality = () => DivinaQuality.auto
+ ) {
+ this.page = page;
+ this.fetchBlob = fetchBlob;
+ this.getQuality = getQuality;
+
+ this.wrapper = document.createElement("div");
+ this.wrapper.style.position = "relative";
+ this.wrapper.style.overflow = "hidden";
+ this.wrapper.dataset.href = page.link.href;
+ this.wrapper.dataset.pageNumber = `${page.number}`;
+
+ this.img = document.createElement("img");
+ this.img.decoding = "async";
+ this.img.draggable = false;
+ this.img.alt = page.link.title || `Page ${page.number}`;
+ this.img.style.display = "block";
+ this.img.style.userSelect = "none";
+ this.img.style.webkitUserSelect = "none";
+ // @ts-expect-error webkitUserDrag is non-standard but needed for Safari
+ this.img.style.webkitUserDrag = "none";
+
+ this.placeholder = placeholderSVG(this.intrinsicWidth, this.intrinsicHeight, "···", this.page.link.title ? this.page.link.title : `Page ${this.page.number}`);
+ this.img.src = this.placeholder;
+ this.wrapper.appendChild(this.img);
+ }
+
+ get intrinsicWidth(): number {
+ return this.page.link.width || (this._loaded ? this.img.naturalWidth : 0) || FALLBACK_WIDTH;
+ }
+
+ get intrinsicHeight(): number {
+ return this.page.link.height || (this._loaded ? this.img.naturalHeight : 0) || FALLBACK_HEIGHT;
+ }
+
+ get loaded(): boolean {
+ return this._loaded;
+ }
+
+ get element(): HTMLDivElement {
+ return this.wrapper;
+ }
+
+ /**
+ * Configure the wrapper/image styles for the given presentation mode.
+ * Paged: the image is fitted (letterboxed) inside the slot-sized wrapper.
+ * Scrolled: the image dictates the height via its aspect ratio.
+ */
+ setMode(mode: DivinaPageMode) {
+ this.mode = mode;
+ if(isTypedOMSupported()) {
+ if(mode === "paged") {
+ ["margin-top", "margin-right", "margin-bottom", "margin-left"]
+ .forEach(p => this.wrapper.attributeStyleMap.delete(p));
+ this.img.attributeStyleMap.set("position", "absolute");
+ this.img.attributeStyleMap.set("top", CSS.px(0));
+ this.img.attributeStyleMap.set("left", CSS.px(0));
+ this.img.attributeStyleMap.set("width", CSS.percent(100));
+ this.img.attributeStyleMap.set("height", CSS.percent(100));
+ this.img.attributeStyleMap.set("object-fit", "contain");
+ this.img.attributeStyleMap.delete("aspect-ratio");
+ } else {
+ this.wrapper.attributeStyleMap.set("float", "none");
+ this.wrapper.attributeStyleMap.set("width", CSS.percent(100));
+ this.wrapper.attributeStyleMap.set("height", "auto");
+ this.wrapper.attributeStyleMap.set("margin-top", CSS.px(0));
+ this.wrapper.attributeStyleMap.set("margin-bottom", CSS.px(0));
+ this.wrapper.attributeStyleMap.set("margin-left", "auto");
+ this.wrapper.attributeStyleMap.set("margin-right", "auto");
+ this.img.attributeStyleMap.set("position", "static");
+ this.img.attributeStyleMap.delete("top");
+ this.img.attributeStyleMap.delete("left");
+ this.img.attributeStyleMap.set("width", CSS.percent(100));
+ this.img.attributeStyleMap.set("height", "auto");
+ this.img.attributeStyleMap.delete("object-fit");
+ this.img.attributeStyleMap.set("aspect-ratio", `${this.intrinsicWidth} / ${this.intrinsicHeight}`);
+ }
+ } else {
+ if(mode === "paged") {
+ this.wrapper.style.removeProperty("margin");
+ this.img.style.position = "absolute";
+ this.img.style.top = "0";
+ this.img.style.left = "0";
+ this.img.style.width = "100%";
+ this.img.style.height = "100%";
+ this.img.style.objectFit = "contain";
+ this.img.style.removeProperty("aspect-ratio");
+ } else {
+ this.wrapper.style.cssFloat = this.wrapper.style.float = "none";
+ this.wrapper.style.width = "100%";
+ this.wrapper.style.height = "auto";
+ this.wrapper.style.margin = "0 auto";
+ this.img.style.position = "static";
+ this.img.style.removeProperty("top");
+ this.img.style.removeProperty("left");
+ this.img.style.width = "100%";
+ this.img.style.height = "auto";
+ this.img.style.removeProperty("object-fit");
+ this.img.style.aspectRatio = `${this.intrinsicWidth} / ${this.intrinsicHeight}`;
+ }
+ }
+ }
+
+ /**
+ * Position the fitted image within its wrapper (paged mode only).
+ *
+ * @param position In a two-page spread, the side of the gutter this page
+ * sits on (pages butt against the gutter). With `half`, the half of a
+ * full-spread wrapper the page occupies.
+ * @param half The page is alone in a double-width (full-spread) slot but
+ * carries a left/right position hint — e.g. a shifted cover or the
+ * orphaned component of a spread — so it is fitted to and centered
+ * within its half of the spread.
+ */
+ fit(position: Page, half = false) {
+ if(this.mode !== "paged") return;
+ let width: number, left: number, objectPosition: string;
+ if(half && position !== Page.center) {
+ width = 50;
+ left = position === Page.right ? 50 : 0;
+ objectPosition = "center center";
+ } else {
+ width = 100;
+ left = 0;
+ // Pages of a spread butt against the gutter
+ objectPosition = position === Page.left ? "right center"
+ : position === Page.right ? "left center"
+ : "center center";
+ }
+ if(isTypedOMSupported()) {
+ this.img.attributeStyleMap.set("width", CSS.percent(width));
+ this.img.attributeStyleMap.set("left", CSS.percent(left));
+ this.img.attributeStyleMap.set("object-position", objectPosition);
+ } else {
+ this.img.style.width = `${width}%`;
+ this.img.style.left = `${left}%`;
+ this.img.style.objectPosition = objectPosition;
+ }
+ }
+
+ /** Scrolled mode: constrain the display width of this page in the strip */
+ applyStripWidth(displayWidth: number) {
+ if(this.mode !== "scrolled") return;
+ if(isTypedOMSupported()) {
+ this.wrapper.attributeStyleMap.set("width", CSS.px(Math.round(displayWidth)));
+ this.wrapper.attributeStyleMap.set("max-width", CSS.percent(100));
+ } else {
+ this.wrapper.style.width = `${Math.round(displayWidth)}px`;
+ this.wrapper.style.maxWidth = "100%";
+ }
+ }
+
+ /**
+ * Fetch the image as a blob and swap it in for the placeholder.
+ * Idempotent; concurrent calls await the same promise.
+ */
+ /**
+ * The most appropriate variant (main link or alternate) for the page's
+ * current display box and the user's quality preference.
+ */
+ private pickVariant(): Link {
+ const dpr = window.devicePixelRatio || 1;
+ const boxW = this.wrapper.clientWidth || 0;
+ const boxH = this.wrapper.clientHeight || 0;
+ let targetWidth = boxW * dpr;
+ let targetHeight = boxH * dpr;
+ if(this.mode === "paged" && boxW > 0 && boxH > 0) {
+ // The image is contain-fitted in the slot: target its displayed size
+ const scale = Math.min(boxW / this.intrinsicWidth, boxH / this.intrinsicHeight);
+ targetWidth = this.intrinsicWidth * scale * dpr;
+ targetHeight = this.intrinsicHeight * scale * dpr;
+ }
+ return selectVariant(this.page.link, {
+ targetWidth,
+ targetHeight,
+ axis: this.mode === "scrolled" ? "width" : "height",
+ quality: this.getQuality()
+ });
+ }
+
+ async load(): Promise {
+ if(this._loaded || this.destroyed) return;
+ if(this.loadPromise) return this.loadPromise;
+ const gen = this.generation;
+ this.abortController = new AbortController();
+ const signal = this.abortController.signal;
+ this.loadPromise = (async () => {
+ try {
+ const blob = await this.fetchBlob(this.pickVariant(), signal);
+ if(this.destroyed || gen !== this.generation) return;
+ const url = URL.createObjectURL(blob);
+ try {
+ await new Promise((res, rej) => {
+ const onLoad = () => { cleanup(); res(); };
+ const onError = () => { cleanup(); rej(new Error(`Failed to decode ${this.page.link.href}`)); };
+ const cleanup = () => {
+ this.img.removeEventListener("load", onLoad);
+ this.img.removeEventListener("error", onError);
+ };
+ this.img.addEventListener("load", onLoad);
+ this.img.addEventListener("error", onError);
+ this.img.src = url;
+ });
+ } catch (e) {
+ URL.revokeObjectURL(url);
+ throw e;
+ }
+ if(this.destroyed || gen !== this.generation) {
+ URL.revokeObjectURL(url);
+ if(this.img.src === url) this.img.src = this.placeholder;
+ return;
+ }
+ this.objectURL = url;
+ this._loaded = true;
+ // Correct the layout box with the decoded dimensions when the
+ // manifest lacked width/height (the fallback ratio would
+ // otherwise distort the page in scrolled mode)
+ if(this.mode === "scrolled" && this.img.naturalWidth && this.img.naturalHeight) {
+ const ratio = `${this.img.naturalWidth} / ${this.img.naturalHeight}`;
+ if(isTypedOMSupported())
+ this.img.attributeStyleMap.set("aspect-ratio", ratio);
+ else
+ this.img.style.aspectRatio = ratio;
+ }
+ } catch (error) {
+ if(!this.destroyed && gen === this.generation) {
+ console.warn("Divina page load failed", this.page.link.href, error);
+ this.img.src = placeholderSVG(this.intrinsicWidth, this.intrinsicHeight, "!", this.page.link.title ? this.page.link.title : `Page ${this.page.number}`);
+ }
+ } finally {
+ if(gen === this.generation) this.loadPromise = null;
+ }
+ })();
+ return this.loadPromise;
+ }
+
+ /**
+ * Release the blob and go back to the placeholder (memory reclamation),
+ * aborting any in-flight fetch.
+ */
+ unload() {
+ if(!this._loaded && !this.loadPromise) return;
+ this.generation++;
+ this._loaded = false;
+ this.loadPromise = null;
+ this.abortController?.abort();
+ this.abortController = null;
+ this.img.src = this.placeholder;
+ if(this.objectURL) {
+ URL.revokeObjectURL(this.objectURL);
+ this.objectURL = null;
+ }
+ }
+
+ destroy() {
+ this.destroyed = true;
+ this.unload();
+ this.wrapper.remove();
+ }
+}
diff --git a/navigator/src/divina/DivinaPagedPresenter.ts b/navigator/src/divina/DivinaPagedPresenter.ts
new file mode 100644
index 00000000..5e95109c
--- /dev/null
+++ b/navigator/src/divina/DivinaPagedPresenter.ts
@@ -0,0 +1,508 @@
+import { Page, Publication, ReadingProgression } from "@readium/shared";
+import { VisualNavigatorViewport } from "../Navigator.ts";
+import { isTypedOMSupported } from "../epub/fxl/FXLPeripherals.ts";
+import { DivinaPageManager } from "./DivinaPageManager.ts";
+import { DivinaManagerEventKey, DivinaPagedManager, DivinaPeripherals } from "./DivinaPeripherals.ts";
+import { DivinaPage, DivinaSpreader } from "./DivinaSpreader.ts";
+
+const UPPER_BOUNDARY = 8; // Unload pages further than this many items away
+const LOWER_BOUNDARY = 4; // Load pages within this many items
+const OFFSCREEN_LOAD_DELAY = 300; // Delay offscreen loads to avoid janking the slide animation
+const SLIDE_FAST = 150;
+const SLIDE_SLOW = 500;
+
+export type DivinaPresenterListener = (key: DivinaManagerEventKey, data: unknown) => void;
+
+/**
+ * Horizontal paged presenter for Divina publications: a sliding spine of
+ * page slots with synthetic spreads, ported from the FXLFramePoolManager
+ * slider core but rendering
pages instead of iframes.
+ *
+ * Slot model: in double-page mode every spread occupies exactly two slots
+ * (single pages get a double-width slot), so slide indices are always even
+ * and always aligned with spread boundaries.
+ */
+export class DivinaPagedPresenter implements DivinaPagedManager {
+ private readonly container: HTMLElement;
+ private readonly pub: Publication;
+ private readonly spreader: DivinaSpreader;
+ private readonly pool: Map;
+
+ public readonly bookElement: HTMLDivElement;
+ public readonly spineElement: HTMLDivElement;
+ public readonly peripherals: DivinaPeripherals;
+
+ public width = 0;
+ public height = 0;
+ public currentSlide = 0;
+ private transform: number | undefined;
+ private spread: boolean;
+ private orientationInternal = -1; // Portrait = 1, Landscape = 0, Unknown = -1
+ private containerHeightCached: number;
+ private destroyed = false;
+ private loadTimers: Map = new Map();
+
+ public listener: DivinaPresenterListener = () => {};
+
+ constructor(
+ container: HTMLElement,
+ pub: Publication,
+ spreader: DivinaSpreader,
+ pool: Map,
+ spreads: boolean
+ ) {
+ this.container = container;
+ this.pub = pub;
+ this.spreader = spreader;
+ this.pool = pool;
+ this.spread = spreads;
+ this.containerHeightCached = container.clientHeight;
+
+ this.bookElement = document.createElement("div");
+ this.bookElement.ariaLabel = "Book";
+ this.bookElement.tabIndex = -1;
+ this.updateBookStyle(true);
+
+ this.spineElement = document.createElement("div");
+ this.spineElement.ariaLabel = "Spine";
+
+ this.bookElement.appendChild(this.spineElement);
+ this.container.appendChild(this.bookElement);
+
+ const fragment = document.createDocumentFragment();
+ this.spreader.pages.forEach((page) => {
+ const pm = this.pool.get(page.index)!;
+ pm.setMode("paged");
+ pm.wrapper.style.cssFloat = pm.wrapper.style.float = this.rtl ? "right" : "left";
+ pm.wrapper.style.contain = "strict";
+ fragment.appendChild(pm.wrapper);
+ });
+ this.spineElement.appendChild(fragment);
+
+ this.updateSpineStyle(false);
+ this.updateSlotSizes();
+
+ this.peripherals = new DivinaPeripherals(this);
+ }
+
+ public get rtl() {
+ return this.pub.metadata.effectiveReadingProgression === ReadingProgression.rtl;
+ }
+
+ private get single() {
+ return !this.spread || this.portrait;
+ }
+
+ public get perPage() {
+ return (this.spread && !this.portrait) ? 2 : 1;
+ }
+
+ get threshold(): number {
+ return 50;
+ }
+
+ get portrait(): boolean {
+ if(this.orientationInternal === -1) {
+ this.orientationInternal = this.containerHeightCached > this.container.clientWidth ? 1 : 0;
+ }
+ return this.orientationInternal === 1;
+ }
+
+ get slength() {
+ return this.spreader.pages.length;
+ }
+
+ /** Total slot count in the current mode */
+ get length() {
+ return this.single ? this.slength : this.spreader.slotCount;
+ }
+
+ /**
+ * The x position of a slot boundary within the spine, snapped to device
+ * pixels. Fractional boundaries leave hairline gaps at the spread gutter
+ * (the background shows through between the two page images), varying
+ * with the window size.
+ */
+ private slotEdge(slot: number): number {
+ const dpr = window.devicePixelRatio || 1;
+ return Math.round(slot * (this.width / this.perPage) * dpr) / dpr;
+ }
+
+ private get offset() {
+ return (this.rtl ? 1 : -1) * this.slotEdge(this.currentSlide);
+ }
+
+ get doNotDisturb() {
+ return this.peripherals.pan.touchID > 0;
+ }
+
+ /** The reading-order index of the first page of the current spread */
+ get currentIndex(): number {
+ return this.single ?
+ Math.max(0, Math.min(this.currentSlide, this.slength - 1)) :
+ this.spreader.itemOfSlot(this.currentSlide);
+ }
+
+ /** The pages currently (meant to be) visible */
+ get currentSpread(): DivinaPage[] {
+ if(this.perPage < 2) {
+ return [this.spreader.pages[this.currentIndex]];
+ }
+ return this.spreader.spreadOfItem(this.currentIndex);
+ }
+
+ private updateDimensions() {
+ this.width = this.bookElement.clientWidth;
+ this.height = this.bookElement.clientHeight;
+ }
+
+ // Reused typed transform for the per-event zoom/pan write (see the
+ // matching note in DivinaPeripherals: mutation beats reallocation)
+ private bookScale: CSSScale | null = null;
+ private bookTranslate: CSSTranslate | null = null;
+ private bookTransform: CSSTransformValue | null = null;
+
+ public updateBookStyle(initial = false) {
+ if(initial) {
+ const bookStyle = {
+ overflow: "hidden",
+ direction: this.pub.metadata.effectiveReadingProgression,
+ cursor: "",
+ height: "100%",
+ width: "100%",
+ position: "relative",
+ outline: "none",
+ transition: this.peripherals?.dragState ? "none" : "transform .15s ease-in-out",
+ touchAction: "none",
+ } as unknown as CSSStyleDeclaration;
+ Object.assign(this.bookElement.style, bookStyle);
+ }
+ const scale = this.peripherals?.scale || 1;
+ const translateX = this.peripherals?.pan.translateX || 0;
+ const translateY = this.peripherals?.pan.translateY || 0;
+ if(isTypedOMSupported()) {
+ if(!this.bookTransform) {
+ this.bookScale = new CSSScale(1, 1);
+ this.bookTranslate = new CSSTranslate(CSS.px(0), CSS.px(0), CSS.px(0));
+ this.bookTransform = new CSSTransformValue([this.bookScale, this.bookTranslate]);
+ }
+ (this.bookScale!.x as CSSUnitValue).value = scale;
+ (this.bookScale!.y as CSSUnitValue).value = scale;
+ (this.bookTranslate!.x as CSSUnitValue).value = translateX;
+ (this.bookTranslate!.y as CSSUnitValue).value = translateY;
+ this.bookElement.attributeStyleMap.set("transform", this.bookTransform);
+ } else {
+ this.bookElement.style.transform = `scale(${scale}) translate3d(${translateX}px, ${translateY}px, 0px)`;
+ }
+ }
+
+ public updateSpineStyle(animate: boolean, fast = true) {
+ this.updateDimensions();
+ if(isTypedOMSupported()) {
+ this.spineElement.attributeStyleMap.set("transition", new CSSUnparsedValue([animate ? `all ${fast ? SLIDE_FAST : SLIDE_SLOW}ms ease-out` : "all 0ms ease-out"]));
+ this.spineElement.attributeStyleMap.set("width", CSS.px(this.slotEdge(this.length)));
+ this.spineElement.attributeStyleMap.set("height", CSS.percent(100));
+ this.transform !== undefined ? this.spineElement.attributeStyleMap.set("transform", new CSSTransformValue([
+ new CSSTranslate(CSS.px(this.transform || 0), CSS.px(0), CSS.px(0))
+ ])) : this.spineElement.attributeStyleMap.delete("transform");
+ this.spineElement.attributeStyleMap.set("contain", new CSSUnparsedValue(["content"]));
+ } else {
+ const spineStyle = {
+ transition: animate ? `all ${fast ? SLIDE_FAST : SLIDE_SLOW}ms ease-out` : "all 0ms ease-out",
+ width: `${this.slotEdge(this.length)}px`,
+ height: "100%",
+ transform: this.transform !== undefined ? `translate3d(${this.transform}px, 0px, 0px)` : "",
+ contain: "content"
+ } as unknown as CSSStyleDeclaration;
+ Object.assign(this.spineElement.style, spineStyle);
+ }
+ }
+
+ /**
+ * Recompute every slot's width and height. Widths are derived from
+ * device-pixel-snapped slot boundaries so adjacent pages of a spread
+ * meet exactly at the gutter without subpixel seams.
+ */
+ private updateSlotSizes() {
+ this.updateDimensions();
+ const typedOM = isTypedOMSupported();
+ this.spreader.pages.forEach((page) => {
+ const pm = this.pool.get(page.index)!;
+ const span = !this.single && this.spreader.isDouble(page.index) ? 2 : 1;
+ const start = this.single ? page.index : this.spreader.slotOfItem(page.index);
+ const width = this.slotEdge(start + span) - this.slotEdge(start);
+ if(typedOM) {
+ pm.wrapper.attributeStyleMap.set("width", CSS.px(width));
+ pm.wrapper.attributeStyleMap.set("height", CSS.px(this.height));
+ } else {
+ pm.wrapper.style.width = `${width}px`;
+ pm.wrapper.style.height = `${this.height}px`;
+ }
+ });
+ }
+
+ reAlign(index: number = this.currentSlide) {
+ if (index % 2 && !this.single) // Prevent getting out of track
+ index++;
+ return index;
+ }
+
+ /**
+ * Moves the spine to the position of the currently active slide.
+ * Always commits: the spine transform may have been written directly by
+ * the peripherals during a drag (snap-back relies on this), and reading
+ * style.transform back for comparison is unreliable under CSS Typed OM.
+ */
+ slideToCurrent(enableTransition?: boolean, fast = true) {
+ this.updateDimensions();
+ const commit = (animate: boolean) => {
+ this.transform = this.offset;
+ this.updateSpineStyle(animate, fast);
+ this.deselect();
+ };
+ if (enableTransition) {
+ // Double rAF guarantees the transition fires:
+ // https://youtu.be/cCOL7MC4Pl0
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => commit(true));
+ });
+ } else {
+ commit(false);
+ }
+ }
+
+ bounce(rtl = false) {
+ requestAnimationFrame(() => {
+ this.transform = this.offset + (50 * (rtl ? 1 : -1));
+ this.updateSpineStyle(true, true);
+ setTimeout(() => {
+ if(this.destroyed) return;
+ this.transform = this.offset;
+ this.updateSpineStyle(true, true);
+ }, 100);
+ });
+ }
+
+ private onChange() {
+ this.peripherals.scale = 1;
+ this.peripherals.pan.translateX = 0;
+ this.peripherals.pan.translateY = 0;
+ this.updateBookStyle();
+ }
+
+ /**
+ * Go to the next spread/page.
+ * @returns whether moving forward was possible
+ */
+ next(): boolean {
+ if (this.length <= this.perPage) return false;
+
+ const beforeChange = this.currentSlide;
+ this.currentSlide = Math.min(this.currentSlide + this.perPage, this.length - this.perPage);
+ this.currentSlide = this.reAlign();
+
+ if (beforeChange !== this.currentSlide) {
+ this.slideToCurrent(true);
+ this.onChange();
+ return true;
+ } else {
+ this.bounce(this.rtl);
+ return false;
+ }
+ }
+
+ /**
+ * Go to the previous spread/page.
+ * @returns whether moving backward was possible
+ */
+ prev(): boolean {
+ if (this.length <= this.perPage) return false;
+
+ const beforeChange = this.currentSlide;
+ this.currentSlide = this.reAlign(Math.max(this.currentSlide - this.perPage, 0));
+
+ if (beforeChange !== this.currentSlide) {
+ this.slideToCurrent(true);
+ this.onChange();
+ return true;
+ } else {
+ this.bounce(!this.rtl);
+ return false;
+ }
+ }
+
+ /**
+ * Go to the slide showing the given reading order item.
+ * @returns whether the slide changed
+ */
+ /**
+ * The slide showing the given item in the current mode: the item index
+ * itself in single mode, or the start slot of the item's spread in
+ * double-page mode (the item may occupy the spread's second slot).
+ */
+ private slideForItem(itemIndex: number): number {
+ const clamped = Math.max(0, Math.min(itemIndex, this.slength - 1));
+ return this.single ?
+ clamped :
+ this.spreader.slotOfItem(this.spreader.spreadOfItem(clamped)[0].index);
+ }
+
+ goToItem(itemIndex: number, animated = true): boolean {
+ const target = this.slideForItem(itemIndex);
+ if(target === this.currentSlide) return false;
+ this.currentSlide = target;
+ this.slideToCurrent(animated);
+ this.onChange();
+ return true;
+ }
+
+ /**
+ * Loads pages within the load window around the current position,
+ * unloads distant ones, and positions the current spread.
+ */
+ async update(): Promise {
+ if(this.destroyed) return;
+ const i = this.currentIndex;
+ const spread = this.currentSpread;
+ const spreadIndexes = new Set(spread.map(p => p.index));
+
+ this.spreader.pages.forEach((page) => {
+ const pm = this.pool.get(page.index)!;
+ const distance = Math.abs(page.index - i);
+ if(distance > UPPER_BOUNDARY) {
+ const timer = this.loadTimers.get(page.index);
+ if(timer) {
+ clearTimeout(timer);
+ this.loadTimers.delete(page.index);
+ }
+ pm.unload();
+ } else if(distance <= LOWER_BOUNDARY && !spreadIndexes.has(page.index)) {
+ // Load offscreen neighbors after a delay so decoding doesn't
+ // jank the slide animation
+ if(!pm.loaded && !this.loadTimers.has(page.index)) {
+ const timer = window.setTimeout(() => {
+ this.loadTimers.delete(page.index);
+ if(!this.destroyed) pm.load();
+ }, OFFSCREEN_LOAD_DELAY);
+ this.loadTimers.set(page.index, timer);
+ }
+ }
+ });
+
+ // Current spread: load immediately and position against the gutter
+ await Promise.all(spread.map((page) => {
+ const pm = this.pool.get(page.index)!;
+ const timer = this.loadTimers.get(page.index);
+ if(timer) {
+ clearTimeout(timer);
+ this.loadTimers.delete(page.index);
+ }
+ this.placePage(spread, page);
+ return pm.load();
+ }));
+ }
+
+ /**
+ * Fit a page of the current spread within its slot. A lone portrait page
+ * with a left/right hint (shifted cover, orphaned spread half) is fitted
+ * into its half of the full-spread slot; everything else is fitted to the
+ * whole slot and anchored against the gutter (or centered).
+ */
+ private placePage(spread: DivinaPage[], page: DivinaPage) {
+ const pm = this.pool.get(page.index);
+ if(!pm) return;
+ if(this.perPage > 1 && spread.length === 1 && !page.isLandscape && page.page !== Page.center) {
+ pm.fit(page.page, true);
+ } else {
+ pm.fit(this.spreader.spreadPosition(spread, page, this.perPage));
+ }
+ }
+
+ /** Union of the current spread's image bounds, used for zoom/pan clamping */
+ get currentBounds(): DOMRect {
+ const ret = {
+ x: 0, y: 0, width: 0, height: 0,
+ top: 0, right: 0, bottom: 0, left: 0,
+ toJSON() { return this; },
+ };
+ this.currentSpread.forEach(page => {
+ const pm = this.pool.get(page.index);
+ if(!pm) return;
+ const b = pm.img.getBoundingClientRect();
+ ret.x = Math.min(ret.x, b.x);
+ ret.y = Math.min(ret.y, b.y);
+ ret.width += b.width;
+ ret.height = Math.max(ret.height, b.height);
+ ret.top = Math.min(ret.top, b.top);
+ ret.right = Math.min(ret.right, b.right);
+ ret.bottom = Math.min(ret.bottom, b.bottom);
+ ret.left = Math.min(ret.left, b.left);
+ });
+ return ret as DOMRect;
+ }
+
+ get viewport(): VisualNavigatorViewport {
+ const viewport: VisualNavigatorViewport = {
+ readingOrder: [],
+ progressions: new Map(),
+ positions: null
+ };
+ const positions: number[] = [];
+ this.currentSpread.forEach(page => {
+ viewport.readingOrder.push(page.link.href);
+ viewport.progressions.set(page.link.href, { start: 0, end: 1 }); // Paged always uses [0,1]
+ positions.push(page.number);
+ });
+ viewport.positions = positions;
+ return viewport;
+ }
+
+ /**
+ * When the container resizes, resize slider components as well
+ */
+ resizeHandler(slide = true, fast = true) {
+ // Capture the current item BEFORE invalidating the orientation cache:
+ // currentSlide's domain (item vs slot index) depends on the mode, which
+ // may flip with the orientation.
+ const item = this.currentIndex;
+ this.containerHeightCached = this.container.clientHeight;
+ this.orientationInternal = -1;
+ this.currentSlide = this.slideForItem(item);
+
+ this.updateSpineStyle(false);
+ this.updateSlotSizes();
+ if(slide) {
+ this.slideToCurrent(!fast, fast);
+ }
+ // Reposition the current spread against the gutter (perPage may have changed)
+ const spread = this.currentSpread;
+ spread.forEach((page) => this.placePage(spread, page));
+ }
+
+ /** Toggle double-page spreads (from preferences) */
+ setSpreads(spreads: boolean) {
+ if(this.spread === spreads) return;
+ const item = this.currentIndex;
+ this.spread = spreads;
+ this.currentSlide = this.slideForItem(item);
+ requestAnimationFrame(() => {
+ if(this.destroyed) return;
+ this.resizeHandler(true);
+ this.update();
+ });
+ }
+
+ deselect() {
+ this.container.ownerDocument.defaultView?.getSelection()?.removeAllRanges();
+ }
+
+ async destroy() {
+ this.destroyed = true;
+ this.loadTimers.forEach(t => clearTimeout(t));
+ this.loadTimers.clear();
+ this.peripherals.destroy();
+ // The page wrappers belong to the shared pool; detach them without destroying
+ this.spineElement.replaceChildren();
+ this.bookElement.remove();
+ }
+}
diff --git a/navigator/src/divina/DivinaPeripherals.ts b/navigator/src/divina/DivinaPeripherals.ts
new file mode 100644
index 00000000..07da0631
--- /dev/null
+++ b/navigator/src/divina/DivinaPeripherals.ts
@@ -0,0 +1,630 @@
+import { FXLCoordinator, Point } from "../epub/fxl/FXLCoordinator.ts";
+import { isTypedOMSupported, PanTracker, PinchTracker } from "../epub/fxl/FXLPeripherals.ts";
+
+const MAX_SCALE = 6; // 6x zoom
+const MIN_SCALE = 1.02;
+const ZOOM_STEP = 0.5; // Zoom in/out increment (keyboard)
+const ZOOM_OVERSCROLL_THRESHOLD = 50;
+const CLICK_SLOP = 5; // px of movement below which a pointer interaction is a click/tap
+const WHEEL_COOLDOWN = 200; // ms of wheel quiet before a new gesture can turn a page
+const DOUBLE_TAP_WINDOW = 300; // ms between taps to count as a double tap
+const DOUBLE_TAP_SLOP = 60; // px between taps to count as a double tap
+
+export type DivinaManagerEventKey = "no_more" | "no_less" | "zoom" | "tap" | "click";
+
+export interface DivinaPointerEvent {
+ x: number; // relative to the book element, multiplied by devicePixelRatio
+ y: number;
+ clientX: number;
+ clientY: number;
+ doNotDisturb: boolean;
+}
+
+/**
+ * The narrow surface of DivinaPagedPresenter that the peripherals interact with.
+ * Mirrors what FXLPeripherals uses from FXLFramePoolManager.
+ */
+export interface DivinaPagedManager {
+ readonly spineElement: HTMLDivElement;
+ readonly bookElement: HTMLDivElement;
+ readonly width: number;
+ readonly height: number;
+ readonly perPage: number;
+ readonly rtl: boolean;
+ readonly threshold: number;
+ readonly length: number; // Total slot count in the current mode
+ readonly currentSlide: number;
+ readonly currentBounds: DOMRect;
+ listener(key: DivinaManagerEventKey, data: unknown): void;
+ updateBookStyle(initial?: boolean): void;
+ updateSpineStyle(animate: boolean, fast?: boolean): void;
+ slideToCurrent(enableTransition?: boolean, fast?: boolean): void;
+ deselect(): void;
+}
+
+/**
+ * Input handling for the paged Divina presenter. Ported from FXLPeripherals
+ * (touch pan/pinch-zoom/swipe state machines) with additions for content that
+ * lives in the host DOM instead of iframes: click/tap synthesis, mouse wheel
+ * page turning (one page per wheel burst, xbreader/bibi-like), ctrl+wheel and
+ * double-click zoom.
+ */
+export class DivinaPeripherals {
+ private readonly manager: DivinaPagedManager;
+ private readonly coordinator: FXLCoordinator;
+
+ public dragState = 0;
+ private minimumMoved = false;
+ public pan: PanTracker = {
+ startX: 0,
+ endX: 0,
+ startY: 0,
+ overscrollX: 0,
+ overscrollY: 0,
+ letItGo: false,
+ preventClick: false,
+ translateX: 0,
+ translateY: 0,
+ touchID: 0
+ };
+ private pinch: PinchTracker = {
+ startDistance: 0,
+ startScale: 0,
+ target: { X: 0, Y: 0 },
+ touchN: 0,
+ startTranslate: { X: 0, Y: 0 }
+ };
+
+ // Scale
+ private _scale = 1;
+ public get scale() {
+ return this._scale;
+ }
+ private scaleDebouncer = 0;
+ public set scale(value: number) {
+ if(isNaN(value)) value = 1;
+ window.clearTimeout(this.scaleDebouncer);
+ this.scaleDebouncer = window.setTimeout(() => {
+ if(this.dragState === 0) {
+ if(this.scale < MIN_SCALE) {
+ this.pan.translateX = 0;
+ this.pan.translateY = 0;
+ this.clearPan();
+ this.manager.updateBookStyle();
+ }
+ }
+ this.manager.listener("zoom", value);
+ }, 100);
+ this._scale = value;
+ }
+
+ private frameBounds: DOMRect | null = null;
+ private destroyed = false;
+
+ // Mouse click tracking
+ private mouseDown: Point | null = null;
+ private clickTimer = 0;
+ private lastTouchEnd = 0;
+
+ // Tap tracking (double-tap detection)
+ private tapTimer = 0;
+ private lastTapTime = 0;
+ private lastTapX = 0;
+ private lastTapY = 0;
+
+ // Wheel gesture state (one page turn per burst)
+ private wheelHot = false;
+ private wheelLastDir = 0;
+ private wheelLastMag = 0;
+ private wheelDecelCount = 0;
+ private wheelLastEvent = 0;
+ private wheelCooldownTimer = 0;
+
+ constructor(manager: DivinaPagedManager) {
+ this.manager = manager;
+ this.coordinator = new FXLCoordinator();
+ this.observe(this.manager.spineElement);
+ this.manager.bookElement.addEventListener("wheel", this.bwheelHandler, { passive: false });
+ }
+
+ private readonly btouchstartHandler = this.touchstartHandler.bind(this);
+ private readonly btouchendHandler = this.touchendHandler.bind(this);
+ private readonly btouchmoveHandler = this.touchmoveHandler.bind(this);
+ private readonly bdblclickHandler = this.dblclickHandler.bind(this);
+ private readonly bmousedownHandler = this.mousedownHandler.bind(this);
+ private readonly bmouseupHandler = this.mouseupHandler.bind(this);
+ private readonly bmousemoveHandler = this.mousemoveHandler.bind(this);
+ private readonly bwheelHandler = this.wheelHandler.bind(this);
+
+ observe(item: EventTarget) {
+ item.addEventListener("touchstart", this.btouchstartHandler as EventListener);
+ item.addEventListener("touchend", this.btouchendHandler as EventListener);
+ item.addEventListener("touchmove", this.btouchmoveHandler as EventListener, {
+ passive: true
+ });
+ item.addEventListener("dblclick", this.bdblclickHandler as EventListener, {
+ passive: true
+ });
+ item.addEventListener("mousedown", this.bmousedownHandler as EventListener);
+ item.addEventListener("mouseup", this.bmouseupHandler as EventListener);
+ item.addEventListener("mousemove", this.bmousemoveHandler as EventListener);
+ }
+
+ unobserve(item: EventTarget) {
+ item.removeEventListener("touchstart", this.btouchstartHandler as EventListener);
+ item.removeEventListener("touchend", this.btouchendHandler as EventListener);
+ item.removeEventListener("touchmove", this.btouchmoveHandler as EventListener);
+ item.removeEventListener("dblclick", this.bdblclickHandler as EventListener);
+ item.removeEventListener("mousedown", this.bmousedownHandler as EventListener);
+ item.removeEventListener("mouseup", this.bmouseupHandler as EventListener);
+ item.removeEventListener("mousemove", this.bmousemoveHandler as EventListener);
+ }
+
+ destroy() {
+ this.destroyed = true;
+ this.unobserve(this.manager.spineElement);
+ this.manager.bookElement.removeEventListener("wheel", this.bwheelHandler);
+ window.clearTimeout(this.scaleDebouncer);
+ window.clearTimeout(this.wheelCooldownTimer);
+ window.clearTimeout(this.clickTimer);
+ window.clearTimeout(this.tapTimer);
+ cancelAnimationFrame(this.moveFrame);
+ }
+
+ private clearPan() {
+ this.pan.letItGo = false;
+ this.pan.touchID = 0;
+ this.pan.endX = 0;
+ this.pan.overscrollX = 0;
+ this.pan.overscrollY = 0;
+ }
+
+ public clearPinch() {
+ this.pinch = {
+ startDistance: 0,
+ startScale: this.pinch.startScale,
+ target: { X: 0, Y: 0 },
+ touchN: 0,
+ startTranslate: { X: 0, Y: 0 }
+ };
+ }
+
+ public get isScaled() {
+ return this.scale > 1;
+ }
+
+ public resetZoom() {
+ this.scale = 1;
+ this.pan.translateX = 0;
+ this.pan.translateY = 0;
+ this.manager.updateBookStyle();
+ }
+
+ private emitPointer(key: "tap" | "click", clientX: number, clientY: number) {
+ const rect = this.manager.bookElement.getBoundingClientRect();
+ const dpr = window.devicePixelRatio || 1;
+ const data: DivinaPointerEvent = {
+ x: (clientX - rect.left) * dpr,
+ y: (clientY - rect.top) * dpr,
+ clientX,
+ clientY,
+ doNotDisturb: this.pan.touchID > 0 || this.dragState > 0
+ };
+ this.manager.listener(key, data);
+ }
+
+ /**
+ * touchstart event handler (ported from FXLPeripherals)
+ */
+ touchstartHandler(e: TouchEvent) {
+ const ignoreSlider = ["TEXTAREA", "OPTION", "INPUT", "SELECT"].indexOf((e.target as Element).nodeName) !== -1;
+ if (ignoreSlider)
+ return;
+
+ e.stopPropagation();
+ this.frameBounds = this.manager.currentBounds;
+ this.coordinator.refreshOuterPixels(this.frameBounds);
+
+ switch (e.touches.length) {
+ case 3:
+ return;
+ case 2: {
+ // Pinch
+ e.preventDefault();
+ this.pinch.startDistance = this.coordinator.getTouchDistance(e);
+ const st2 = this.startTouch(e);
+ this.pan.startX = st2.X;
+ this.pan.startY = st2.Y;
+
+ this.dragState = 2;
+ this.manager.updateBookStyle(true);
+ if(!this.isScaled) {
+ this.pinch.target = { X: 0, Y: 0 };
+ this.pinch.startScale = this.scale;
+ } else {
+ this.pinch.target.X -= this.pan.translateX * (this.pinch.startScale / this.scale);
+ this.pinch.target.Y -= this.pan.translateY * (this.pinch.startScale / this.scale);
+ this.pinch.target = { X: 0, Y: 0 };
+ this.pinch.startScale = 1 / this.scale;
+ }
+ this.pinch.startTranslate = { X: this.pan.translateX, Y: this.pan.translateY };
+ return;
+ }
+ // @ts-ignore
+ case 1:
+ this.pan.touchID = e.touches[0].identifier;
+ // Fallthrough on purpose
+ default:
+ if(this.dragState < 1) this.dragState = 1;
+ this.manager.updateBookStyle(true);
+ }
+ this.manager.updateSpineStyle(false);
+ const st = this.startTouch(e);
+ this.pan.startX = st.X;
+ this.pan.startY = st.Y;
+ }
+
+ private startTouch(e: TouchEvent): Point {
+ const center = this.coordinator.getTouchCenter(e) || this.coordinator.getBibiEventCoord(e);
+ return {
+ X: (center.X - this.manager.width / 2) - this.pan.translateX * this.scale + this.manager.width / 2,
+ Y: (center.Y - this.manager.height / 2) - this.pan.translateY * this.scale + this.manager.height / 2
+ };
+ }
+
+ /**
+ * touchend event handler (ported from FXLPeripherals, adds tap synthesis)
+ */
+ touchendHandler(e: TouchEvent) {
+ e.stopPropagation();
+
+ if(!e.touches || e.touches.length === 0) {
+ const wasPinch = this.pinch.touchN > 0;
+ if ((this.pan.endX && !this.isScaled)) {
+ if(this.pinch.touchN) {
+ this.pan.endX = this.pan.startX;
+ }
+ this.updateAfterDrag();
+ } else if(!this.pinch.touchN && Math.abs(this.pan.overscrollX) > ZOOM_OVERSCROLL_THRESHOLD && Math.abs(this.pan.overscrollY) < ZOOM_OVERSCROLL_THRESHOLD / 2) {
+ // Panned past the limits on the horizontal axis while zoomed in;
+ // simulates dragging while not scaled.
+ this.pan.startX = 0;
+ this.pan.endX = -this.pan.overscrollX;
+ this.updateAfterDrag();
+ } else if(!this.minimumMoved && !wasPinch && e.changedTouches?.length === 1) {
+ // Stationary single-finger touch: it's a tap. Suppress the
+ // compatibility mouse events and clear the drag state before
+ // emitting, so the tap isn't flagged as an in-progress drag.
+ if(e.cancelable) e.preventDefault();
+ this.lastTouchEnd = performance.now();
+ this.dragState = 0;
+ this.clearPan();
+ const { clientX, clientY } = e.changedTouches[0];
+ const now = performance.now();
+ if(now - this.lastTapTime < DOUBLE_TAP_WINDOW &&
+ Math.abs(clientX - this.lastTapX) + Math.abs(clientY - this.lastTapY) < DOUBLE_TAP_SLOP) {
+ // Double tap: cancel the pending single-tap action and toggle zoom
+ window.clearTimeout(this.tapTimer);
+ this.lastTapTime = 0;
+ this.toggleZoom(clientX, clientY);
+ } else {
+ this.lastTapTime = now;
+ this.lastTapX = clientX;
+ this.lastTapY = clientY;
+ // Delay so a double tap (zoom) can cancel the single-tap action
+ window.clearTimeout(this.tapTimer);
+ this.tapTimer = window.setTimeout(() => this.emitPointer("tap", clientX, clientY), DOUBLE_TAP_WINDOW);
+ }
+ }
+ this.dragState = 0;
+ this.minimumMoved = false;
+ this.clearPinch();
+ } else if(e.touches.length === 1) {
+ // Back to only one touch from 2+
+ this.dragState = 1;
+ if(e.touches[0].identifier !== this.pan.touchID) {
+ this.pan.touchID = e.touches[0].identifier;
+ }
+ const st = this.startTouch(e);
+ this.pan.startX = st.X;
+ this.pan.startY = st.Y;
+ }
+ window.setTimeout(() => {
+ if(this.destroyed) return;
+ this.manager.updateBookStyle(true);
+ if(this.dragState === 0) {
+ if(this.scale < MIN_SCALE) {
+ this.pan.translateX = 0;
+ this.pan.translateY = 0;
+ }
+ this.clearPan();
+ }
+ this.manager.updateBookStyle(true);
+ }, 50);
+ }
+
+ private moveFrame = 0;
+
+ // Reused typed transform for the per-frame drag write: allocating fresh
+ // CSSTransformValue/CSSTranslate objects per write is slower than string
+ // parsing; mutating a persistent one is faster than both
+ private dragTranslate: CSSTranslate | null = null;
+ private dragTransform: CSSTransformValue | null = null;
+
+ /**
+ * touchmove event handler (ported from FXLPeripherals)
+ */
+ touchmoveHandler(e: TouchEvent) {
+ e.stopPropagation();
+ const coords = this.coordinator.getBibiEventCoord(e);
+
+ if ((Math.abs(this.pan.startY - coords.Y) + Math.abs(this.pan.startX - coords.X)) > CLICK_SLOP) {
+ if(!this.minimumMoved) {
+ this.manager.deselect();
+ this.minimumMoved = true;
+ }
+ if(this.dragState < 1) this.dragState = 1;
+ }
+
+ const currentDistance = this.coordinator?.getTouchDistance(e);
+
+ let updateBook = false;
+
+ const oldScale = this.scale;
+ if(this.dragState === 2 && currentDistance) {
+ this.pinch.touchN++;
+ if(this.pinch.touchN < 4) return;
+ let newScale = currentDistance / this.pinch.startDistance * this.scale;
+ if(newScale >= MAX_SCALE)
+ newScale = MAX_SCALE;
+ if(newScale <= MIN_SCALE)
+ newScale = 1;
+ this.scale = newScale;
+ this.pinch.startDistance = currentDistance;
+ updateBook = true;
+ }
+
+ if (this.pan.letItGo === false) {
+ this.pan.letItGo = Math.abs(this.pan.startY - coords.Y) < Math.abs(this.pan.startX - coords.X);
+ }
+
+ if ((this.dragState > 0 && this.isScaled) || this.dragState > 1) {
+ if(this.dragState === 1) {
+ const center = {
+ X: coords.X - this.manager.width / 2,
+ Y: coords.Y - this.manager.height / 2
+ };
+ this.pan.translateX = (center.X - (this.pan.startX - this.manager.width / 2)) * 1 / this.scale;
+ this.pan.translateY = (center.Y - (this.pan.startY - this.manager.height / 2)) * 1 / this.scale;
+ } else if(this.dragState === 2) {
+ const center = this.coordinator.getTouchCenter(e)!;
+ center.X -= this.manager.width / 2;
+ center.Y -= this.manager.height / 2;
+
+ let ptx = -center.X / oldScale;
+ ptx += center.X / this.scale;
+ this.pinch.target.X += ptx;
+ center.X += this.pinch.target.X * this.scale / this.pinch.startScale;
+
+ let pty = -center.Y / oldScale;
+ pty += center.Y / this.scale;
+ this.pinch.target.Y += pty;
+ center.Y += this.pinch.target.Y * this.scale / this.pinch.startScale;
+
+ this.pan.translateX = (center.X - (this.pan.startX - this.manager.width / 2)) * 1 / this.scale;
+ this.pan.translateY = (center.Y - (this.pan.startY - this.manager.height / 2)) * 1 / this.scale;
+ }
+
+ const maxEdgeX = this.frameBounds!.width / 6;
+ const maxEdgeY = this.frameBounds!.height / 6;
+
+ if (this.pan.translateX < -maxEdgeX) {
+ this.pan.overscrollX = -(maxEdgeX + this.pan.translateX);
+ this.pan.translateX = -maxEdgeX;
+ }
+ if (this.pan.translateY < -maxEdgeY) {
+ this.pan.overscrollY = -(maxEdgeY + this.pan.translateY);
+ this.pan.translateY = -maxEdgeY;
+ }
+
+ if (this.pan.translateX > maxEdgeX) {
+ this.pan.overscrollX = maxEdgeX - this.pan.translateX;
+ this.pan.translateX = maxEdgeX;
+ }
+ if (this.pan.translateY > maxEdgeY) {
+ this.pan.overscrollY = maxEdgeY - this.pan.translateY;
+ this.pan.translateY = maxEdgeY;
+ }
+
+ updateBook = true;
+ }
+
+ if(updateBook) {
+ this.manager.updateBookStyle();
+ return;
+ }
+
+ if (this.dragState > 0 && this.pan.letItGo) {
+ this.pan.endX = coords.X;
+
+ const currentSlide = this.manager.currentSlide;
+ const currentOffset = currentSlide * (this.manager.width / this.manager.perPage);
+ const dragOffset = (this.pan.endX - this.pan.startX);
+ const offset = this.manager.rtl ? currentOffset + dragOffset : currentOffset - dragOffset;
+ // Snap the translation to device pixels: at fractional offsets the
+ // spread gutter is rasterized with a hairline gap between the pages
+ const dpr = window.devicePixelRatio || 1;
+ const snapped = Math.round((this.manager.rtl ? 1 : -1) * offset * dpr) / dpr;
+
+ cancelAnimationFrame(this.moveFrame);
+ this.moveFrame = requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ if(this.destroyed) return;
+ if(isTypedOMSupported()) {
+ if(!this.dragTransform) {
+ this.dragTranslate = new CSSTranslate(CSS.px(0), CSS.px(0), CSS.px(0));
+ this.dragTransform = new CSSTransformValue([this.dragTranslate]);
+ }
+ (this.dragTranslate!.x as CSSUnitValue).value = snapped;
+ this.manager.spineElement.attributeStyleMap.set("transform", this.dragTransform);
+ } else {
+ this.manager.spineElement.style.transform = `translate3d(${snapped}px, 0, 0)`;
+ }
+ })
+ });
+ }
+ }
+
+ /**
+ * Double click/tap: toggle zoom between 1x and 2x, centered on the point.
+ */
+ toggleZoom(clientX: number, clientY: number) {
+ if(this.isScaled) {
+ this.resetZoom();
+ return;
+ }
+ const rect = this.manager.bookElement.getBoundingClientRect();
+ const x = clientX - rect.left;
+ const y = clientY - rect.top;
+ this.frameBounds = this.manager.currentBounds;
+ const maxEdgeX = this.frameBounds.width / 6;
+ const maxEdgeY = this.frameBounds.height / 6;
+ this.scale = 2;
+ this.pan.translateX = Math.max(-maxEdgeX, Math.min(maxEdgeX, this.manager.width / 2 - x));
+ this.pan.translateY = Math.max(-maxEdgeY, Math.min(maxEdgeY, this.manager.height / 2 - y));
+ this.manager.updateBookStyle();
+ }
+
+ /** Zoom in/out by a keyboard step, clamped to [1, MAX_SCALE] */
+ zoomBy(delta: number) {
+ let newScale = this.scale + delta;
+ if(newScale >= MAX_SCALE) newScale = MAX_SCALE;
+ if(newScale <= MIN_SCALE) newScale = 1;
+ this.scale = newScale;
+ if(newScale === 1) {
+ this.pan.translateX = 0;
+ this.pan.translateY = 0;
+ }
+ this.manager.updateBookStyle();
+ }
+
+ zoomIn() { this.zoomBy(ZOOM_STEP); }
+ zoomOut() { this.zoomBy(-ZOOM_STEP); }
+
+ dblclickHandler(e: MouseEvent) {
+ window.clearTimeout(this.clickTimer); // Cancel the pending single-click action
+ this.toggleZoom(e.clientX, e.clientY);
+ }
+
+ private addTouch(e: any) {
+ e.touches = [{
+ pageX: e.pageX,
+ pageY: e.pageY
+ }];
+ }
+
+ mousedownHandler(e: MouseEvent) {
+ this.mouseDown = { X: e.clientX, Y: e.clientY };
+ if (this.isScaled) {
+ this.addTouch(e as any);
+ this.touchstartHandler(e as any);
+ }
+ }
+
+ mouseupHandler(e: MouseEvent) {
+ const down = this.mouseDown;
+ this.mouseDown = null;
+ if (this.isScaled) {
+ this.touchendHandler(e as any);
+ }
+ // Ignore compatibility mouse events synthesized after a touch tap,
+ // and the second mouseup of a double click (it belongs to dblclick)
+ if (performance.now() - this.lastTouchEnd < 700 || e.detail > 1) return;
+ if (down && e.button === 0 &&
+ Math.abs(e.clientX - down.X) + Math.abs(e.clientY - down.Y) <= CLICK_SLOP) {
+ // Delay so a double click (zoom) can cancel the single-click action
+ const { clientX, clientY } = e;
+ window.clearTimeout(this.clickTimer);
+ this.clickTimer = window.setTimeout(() => this.emitPointer("click", clientX, clientY), 250);
+ }
+ }
+
+ mousemoveHandler(e: MouseEvent) {
+ if (this.isScaled && e.buttons > 0) {
+ e.preventDefault();
+ this.addTouch(e as any);
+ this.touchmoveHandler(e as any);
+ }
+ }
+
+ /**
+ * Wheel handler: ctrl+wheel zooms (trackpad pinch on Chrome), otherwise a
+ * wheel burst turns exactly one page. A new page turn requires either
+ * 200ms of quiet (fresh gesture), a direction reversal, or a re-acceleration
+ * after a decelerating tail (deliberate consecutive flicks).
+ */
+ wheelHandler(e: WheelEvent) {
+ e.preventDefault();
+
+ if(e.ctrlKey) {
+ // Pinch-zoom gesture (trackpads report it as ctrl+wheel)
+ let newScale = this.scale * (1 - e.deltaY * 0.01);
+ if(newScale >= MAX_SCALE) newScale = MAX_SCALE;
+ if(newScale <= MIN_SCALE) newScale = 1;
+ this.scale = newScale;
+ this.manager.updateBookStyle();
+ return;
+ }
+
+ const horizontal = Math.abs(e.deltaX) > Math.abs(e.deltaY);
+ const delta = horizontal ? e.deltaX : e.deltaY;
+ const mag = Math.abs(delta);
+ if(mag < 1) return;
+ // Horizontal wheel follows the visual direction (RTL flips);
+ // vertical wheel down always advances.
+ let dir = Math.sign(delta);
+ if(horizontal && this.manager.rtl) dir = -dir;
+
+ const now = performance.now();
+ const quiet = now - this.wheelLastEvent;
+ this.wheelLastEvent = now;
+
+ let gesture: "start" | "reverse" | "serial" | null = null;
+ if(quiet > WHEEL_COOLDOWN) gesture = "start";
+ else if(this.wheelLastDir !== 0 && dir !== this.wheelLastDir) gesture = "reverse";
+ else if(this.wheelDecelCount >= 3 && mag > this.wheelLastMag * 1.5) gesture = "serial";
+
+ this.wheelDecelCount = mag <= this.wheelLastMag ? this.wheelDecelCount + 1 : 0;
+ this.wheelLastMag = mag;
+ this.wheelLastDir = dir;
+
+ if(gesture !== null && (!this.wheelHot || gesture === "reverse" || gesture === "serial")) {
+ this.wheelHot = true;
+ if(gesture === "serial" || gesture === "reverse") this.wheelDecelCount = 0;
+ this.manager.listener(dir > 0 ? "no_more" : "no_less", undefined);
+ }
+
+ window.clearTimeout(this.wheelCooldownTimer);
+ this.wheelCooldownTimer = window.setTimeout(() => {
+ this.wheelHot = false;
+ this.wheelLastDir = 0;
+ this.wheelLastMag = 0;
+ this.wheelDecelCount = 0;
+ }, WHEEL_COOLDOWN);
+ }
+
+ /**
+ * Recalculate drag/swipe event and reposition the frame of the slider
+ */
+ private updateAfterDrag() {
+ const movement = (this.manager.rtl ? -1 : 1) * (this.pan.endX - this.pan.startX);
+ const movementDistance = Math.abs(movement);
+
+ if (movement > 0 && movementDistance > this.manager.threshold && this.manager.length > this.manager.perPage) {
+ this.manager.listener("no_less", undefined);
+ } else if (movement < 0 && movementDistance > this.manager.threshold && this.manager.length > this.manager.perPage) {
+ this.manager.listener("no_more", undefined);
+ }
+ this.manager.slideToCurrent(true, true);
+ }
+}
diff --git a/navigator/src/divina/DivinaScrolledPresenter.ts b/navigator/src/divina/DivinaScrolledPresenter.ts
new file mode 100644
index 00000000..58c73674
--- /dev/null
+++ b/navigator/src/divina/DivinaScrolledPresenter.ts
@@ -0,0 +1,516 @@
+import { ProgressionRange, VisualNavigatorViewport } from "../Navigator.ts";
+import { DivinaPageManager } from "./DivinaPageManager.ts";
+import { DivinaSpreader } from "./DivinaSpreader.ts";
+
+const UPPER_BOUNDARY = 10; // Unload pages further than this many items away
+const LOWER_BOUNDARY = 5; // Load pages within this many items
+const SCROLL_STEP_FACTOR = 0.8; // goForward/goBackward scroll this fraction of the viewport
+const POSITION_DEBOUNCE = 150; // ms of scroll quiet before reporting the position
+const PROGRAMMATIC_SCROLL_TIMEOUT = 1200; // Fallback for browsers without scrollend
+// Owned scroll animation (native smooth scrolling restarts its easing curve on
+// every retarget, which crawls under key autorepeat and then sprints the
+// accumulated backlog on release)
+const SCROLL_CRUISE_SPEED = 8; // Top speed, in viewports per second (held page keys reach it, line steps stay demand-limited)
+const SCROLL_APPROACH_RATE = 20; // 1/s: fraction of the remaining distance per second (ease-out)
+const SCROLL_TARGET_MAX_LEAD = 1.5; // Chained targets may lead the position by at most this many viewports
+
+export type DivinaScrolledEventKey = "scroll" | "position" | "tap" | "click";
+export type DivinaScrolledListener = (key: DivinaScrolledEventKey, data: unknown) => void;
+
+/**
+ * Vertical scrolled ("webtoon") presenter for Divina publications: all pages
+ * stacked in a native scroll container with no gaps, constrained to a
+ * configurable strip width, based on xbreader but improved
+ *
+ * Page offsets are cached and looked up with binary search so per-frame
+ * scroll work stays constant regardless of publication size.
+ */
+export class DivinaScrolledPresenter {
+ private readonly container: HTMLElement;
+ private readonly spreader: DivinaSpreader;
+ private readonly pool: Map;
+
+ public readonly scrollerElement: HTMLDivElement;
+ private stripWidth: number;
+ private destroyed = false;
+ private scrollRAF = 0;
+ private restoreRAF = 0;
+ private positionTimer = 0;
+ private lastScrollTop = 0;
+ /** Position within the strip as a fraction of total strip height (resize retention) */
+ private fraction = 0;
+
+ // Cached layout metrics, refreshed on relayout and self-healed on drift
+ private pageTops: number[] = [];
+ private pageHeights: number[] = [];
+ private stripHeightCached = 0;
+ private viewportHeightCached = 0;
+
+ // Load-window bookkeeping
+ private loadedIndices: Set = new Set();
+ private lastWindowIndex = -1;
+
+ // Programmatic scrolling (keyboard nav, goTo, restores): suppresses
+ // "scroll" listener emissions and accumulates chained smooth targets
+ private programmaticScroll = false;
+ private programmaticTimer = 0;
+ private scrollTarget: number | null = null;
+
+ // Owned smooth-scroll animation state
+ private animating = false;
+ private animRAF = 0;
+ private animLastTime = 0;
+
+ public listener: DivinaScrolledListener = () => {};
+
+ constructor(
+ container: HTMLElement,
+ spreader: DivinaSpreader,
+ pool: Map,
+ stripWidth: number
+ ) {
+ this.container = container;
+ this.spreader = spreader;
+ this.pool = pool;
+ this.stripWidth = stripWidth;
+
+ this.scrollerElement = document.createElement("div");
+ this.scrollerElement.ariaLabel = "Book";
+ this.scrollerElement.tabIndex = -1;
+ Object.assign(this.scrollerElement.style, {
+ overflowY: "auto",
+ overflowX: "hidden",
+ height: "100%",
+ width: "100%",
+ position: "relative",
+ outline: "none",
+ overscrollBehavior: "contain",
+ } as unknown as CSSStyleDeclaration);
+
+ const fragment = document.createDocumentFragment();
+ this.spreader.pages.forEach((page) => {
+ const pm = this.pool.get(page.index)!;
+ pm.setMode("scrolled");
+ pm.wrapper.style.removeProperty("contain");
+ fragment.appendChild(pm.wrapper);
+ });
+ this.scrollerElement.appendChild(fragment);
+ this.container.appendChild(this.scrollerElement);
+
+ this.applyStripWidth(stripWidth);
+
+ this.scrollerElement.addEventListener("scroll", this.bscrollHandler, { passive: true });
+ this.scrollerElement.addEventListener("scrollend", this.bscrollendHandler);
+ this.scrollerElement.addEventListener("wheel", this.buserTakeoverHandler, { passive: true });
+ this.scrollerElement.addEventListener("mousedown", this.bmousedownHandler);
+ this.scrollerElement.addEventListener("mouseup", this.bclickHandler);
+ this.scrollerElement.addEventListener("touchend", this.btouchendHandler);
+ this.scrollerElement.addEventListener("touchstart", this.btouchstartHandler, { passive: true });
+ }
+
+ private readonly bscrollHandler = this.scrollHandler.bind(this);
+ private readonly bscrollendHandler = this.scrollendHandler.bind(this);
+ private readonly banimStep = this.animStep.bind(this);
+ private readonly buserTakeoverHandler = this.userTakeoverHandler.bind(this);
+ private readonly bmousedownHandler = this.mousedownHandler.bind(this);
+ private readonly bclickHandler = this.clickHandler.bind(this);
+ private readonly btouchendHandler = this.touchendHandler.bind(this);
+ private readonly btouchstartHandler = this.touchstartHandler.bind(this);
+
+ /** Display width of each page in the strip */
+ private get displayWidth(): number {
+ return Math.max(1, Math.min(this.stripWidth, this.scrollerElement.clientWidth || this.container.clientWidth));
+ }
+
+ /**
+ * Recomputes the cached page offsets and viewport metrics.
+ * Cheap relative to the per-frame layout reads it replaces, but still a
+ * forced layout: only call after actual layout changes.
+ */
+ private refreshLayoutCache() {
+ this.viewportHeightCached = this.scrollerElement.clientHeight;
+ this.stripHeightCached = this.scrollerElement.scrollHeight;
+ const n = this.spreader.pages.length;
+ this.pageTops = new Array(n);
+ this.pageHeights = new Array(n);
+ for (const page of this.spreader.pages) {
+ const w = this.pool.get(page.index)!.wrapper;
+ this.pageTops[page.index] = w.offsetTop;
+ this.pageHeights[page.index] = Math.max(1, w.offsetHeight);
+ }
+ }
+
+ /** The index of the last page whose top is at or above the given strip offset */
+ private indexAtOffset(offset: number): number {
+ const tops = this.pageTops;
+ let lo = 0, hi = tops.length - 1, ans = 0;
+ while (lo <= hi) {
+ const mid = (lo + hi) >> 1;
+ if (tops[mid] <= offset) { ans = mid; lo = mid + 1; } else hi = mid - 1;
+ }
+ return ans;
+ }
+
+ applyStripWidth(stripWidth: number) {
+ this.stripWidth = stripWidth;
+ const fraction = this.fraction;
+ const w = this.displayWidth;
+ this.spreader.pages.forEach((page) => {
+ this.pool.get(page.index)?.applyStripWidth(w);
+ });
+ this.refreshLayoutCache();
+ // Restore the relative position after the relayout
+ this.restoreFraction(fraction);
+ }
+
+ private restoreFraction(fraction: number) {
+ cancelAnimationFrame(this.restoreRAF);
+ this.restoreRAF = requestAnimationFrame(() => {
+ if(this.destroyed) return;
+ const target = fraction * this.stripHeightCached;
+ if(Math.abs(this.scrollerElement.scrollTop - target) >= 1) {
+ this.beginProgrammaticScroll();
+ this.scrollTarget = target;
+ this.scrollerElement.scrollTop = target;
+ }
+ this.updateFraction();
+ });
+ }
+
+ private updateFraction() {
+ const h = this.stripHeightCached;
+ this.fraction = h > 0 ? this.scrollerElement.scrollTop / h : 0;
+ }
+
+ /** Top offset in pixels of the given page within the strip */
+ private pageTop(index: number): number {
+ return this.pageTops[index] ?? 0;
+ }
+
+ private pageHeight(index: number): number {
+ return this.pageHeights[index] ?? 1;
+ }
+
+ /**
+ * The reading-order index of the current page: the last page whose top
+ * is above the middle of the viewport.
+ */
+ get currentIndex(): number {
+ if(!this.pageTops.length) return 0;
+ return this.indexAtOffset(this.scrollerElement.scrollTop + this.viewportHeightCached * 0.5);
+ }
+
+ /** Progression within the current page (0..1) */
+ get currentPageProgression(): number {
+ const i = this.currentIndex;
+ return Math.max(0, Math.min(1, (this.scrollerElement.scrollTop - this.pageTop(i)) / this.pageHeight(i)));
+ }
+
+ /**
+ * Marks the start of a programmatic scroll: "scroll" events are not
+ * reported to the host until the scroll settles (scrollend or timeout),
+ * so navigation doesn't masquerade as user scrolling.
+ */
+ private beginProgrammaticScroll() {
+ this.programmaticScroll = true;
+ window.clearTimeout(this.programmaticTimer);
+ this.programmaticTimer = window.setTimeout(() => {
+ this.programmaticScroll = false;
+ this.scrollTarget = null;
+ this.settleWindow();
+ }, PROGRAMMATIC_SCROLL_TIMEOUT);
+ }
+
+ private scrollendHandler() {
+ // Instant scrollTop writes fire scrollend per write, so while the
+ // owned animation runs this event says nothing about settling —
+ // acting on it would kill the animation one frame in
+ if(this.animating) return;
+ window.clearTimeout(this.programmaticTimer);
+ this.programmaticScroll = false;
+ this.scrollTarget = null;
+ this.settleWindow();
+ }
+
+ /** Load the window around wherever the scroll settled */
+ private settleWindow() {
+ if(this.destroyed || !this.pageTops.length) return;
+ const index = this.currentIndex;
+ this.lastWindowIndex = index;
+ this.updateWindow(index);
+ }
+
+ /** Direct user input takes over any in-flight programmatic scroll */
+ private userTakeoverHandler() {
+ window.clearTimeout(this.programmaticTimer);
+ this.programmaticScroll = false;
+ this.scrollTarget = null;
+ this.stopAnimation();
+ }
+
+ private scrollHandler() {
+ if(this.destroyed) return;
+ cancelAnimationFrame(this.scrollRAF);
+ this.scrollRAF = requestAnimationFrame(() => {
+ if(this.destroyed) return;
+ const st = this.scrollerElement.scrollTop;
+ const delta = st - this.lastScrollTop;
+ this.lastScrollTop = st;
+
+ // Self-heal the layout cache if something (e.g. an image decode
+ // correcting an unknown aspect ratio) changed the strip height
+ if(Math.abs(this.scrollerElement.scrollHeight - this.stripHeightCached) > 1)
+ this.refreshLayoutCache();
+
+ this.updateFraction();
+ if(!this.programmaticScroll && delta !== 0) {
+ this.listener("scroll", delta);
+ }
+
+ // While a programmatic scroll (e.g. a held navigation key) is in
+ // flight, defer loading: pages rushing by would only churn
+ // fetches. The window is loaded when the scroll settles.
+ const index = this.currentIndex;
+ if(index !== this.lastWindowIndex && !this.programmaticScroll) {
+ this.lastWindowIndex = index;
+ this.updateWindow(index);
+ }
+
+ window.clearTimeout(this.positionTimer);
+ this.positionTimer = window.setTimeout(() => {
+ if(this.destroyed) return;
+ this.listener("position", this.currentIndex);
+ }, POSITION_DEBOUNCE);
+ });
+ }
+
+ /** Load nearby pages, unload distant ones */
+ private updateWindow(center: number) {
+ const n = this.spreader.pages.length;
+ for (let i = Math.max(0, center - LOWER_BOUNDARY); i <= Math.min(n - 1, center + LOWER_BOUNDARY); i++) {
+ this.pool.get(i)?.load();
+ this.loadedIndices.add(i);
+ }
+ for (const i of this.loadedIndices) {
+ if(Math.abs(i - center) > UPPER_BOUNDARY) {
+ this.pool.get(i)?.unload();
+ this.loadedIndices.delete(i);
+ }
+ }
+ }
+
+ private touchStartY: number | null = null;
+ private lastTouchEnd = 0;
+
+ private touchstartHandler(e: TouchEvent) {
+ this.userTakeoverHandler();
+ this.touchStartY = e.touches.length === 1 ? e.touches[0].clientY : null;
+ }
+
+ private touchendHandler(e: TouchEvent) {
+ if(this.touchStartY === null || e.changedTouches.length !== 1) return;
+ const dy = Math.abs(e.changedTouches[0].clientY - this.touchStartY);
+ this.touchStartY = null;
+ if(dy <= 5) {
+ // Suppress the compatibility mouse events for this tap
+ if(e.cancelable) e.preventDefault();
+ this.lastTouchEnd = performance.now();
+ this.emitPointer("tap", e.changedTouches[0].clientX, e.changedTouches[0].clientY);
+ }
+ }
+
+ private mouseDownPos: { x: number, y: number } | null = null;
+
+ private mousedownHandler(e: MouseEvent) {
+ this.mouseDownPos = e.button === 0 ? { x: e.clientX, y: e.clientY } : null;
+ }
+
+ private clickHandler(e: MouseEvent) {
+ // Mouseup alone can't distinguish a click from the end of a drag; compare with mousedown
+ if(e.button !== 0 || this.mouseDownPos === null) return;
+ const moved = Math.abs(e.clientX - this.mouseDownPos.x) + Math.abs(e.clientY - this.mouseDownPos.y);
+ this.mouseDownPos = null;
+ // Ignore compatibility mouse events synthesized after a touch tap
+ if(performance.now() - this.lastTouchEnd < 700) return;
+ if(moved <= 5) this.emitPointer("click", e.clientX, e.clientY);
+ }
+
+ private emitPointer(key: "tap" | "click", clientX: number, clientY: number) {
+ const rect = this.scrollerElement.getBoundingClientRect();
+ const dpr = window.devicePixelRatio || 1;
+ this.listener(key, {
+ x: (clientX - rect.left) * dpr,
+ y: (clientY - rect.top) * dpr,
+ clientX,
+ clientY,
+ doNotDisturb: false
+ });
+ }
+
+ /**
+ * Scroll forward/backward by a fraction of the viewport.
+ * Repeated calls chain from the pending target, so rapid key presses
+ * (including autorepeat from a held key) extend one animation that keeps
+ * cruising instead of restarting; the target is clamped to stay within
+ * reach of the position so releasing the key stops promptly rather than
+ * replaying the queued distance.
+ * @returns whether scrolling was possible
+ */
+ next(animated = true, stepPx?: number): boolean {
+ const max = this.scrollerElement.scrollHeight - this.scrollerElement.clientHeight;
+ const base = this.scrollTarget ?? this.scrollerElement.scrollTop;
+ if(base >= max - 1) return false;
+ let target = Math.min(base + (stepPx ?? this.viewportHeightCached * SCROLL_STEP_FACTOR), max);
+ if(animated) target = Math.min(target,
+ this.scrollerElement.scrollTop + this.viewportHeightCached * SCROLL_TARGET_MAX_LEAD);
+ this.scrollToTarget(target, animated);
+ return true;
+ }
+
+ prev(animated = true, stepPx?: number): boolean {
+ const base = this.scrollTarget ?? this.scrollerElement.scrollTop;
+ if(base <= 1) return false;
+ let target = Math.max(base - (stepPx ?? this.viewportHeightCached * SCROLL_STEP_FACTOR), 0);
+ if(animated) target = Math.max(target,
+ this.scrollerElement.scrollTop - this.viewportHeightCached * SCROLL_TARGET_MAX_LEAD);
+ this.scrollToTarget(target, animated);
+ return true;
+ }
+
+ private scrollToTarget(target: number, animated: boolean) {
+ this.beginProgrammaticScroll();
+ this.scrollTarget = target;
+ if(!animated) {
+ this.stopAnimation();
+ this.scrollerElement.scrollTo({ top: target, behavior: "auto" });
+ return;
+ }
+ // A running animation just picks up the new target on its next frame
+ if(!this.animating) {
+ this.animating = true;
+ this.animLastTime = performance.now();
+ this.animRAF = requestAnimationFrame(this.banimStep);
+ }
+ }
+
+ /**
+ * One frame of the owned scroll animation: exponential approach toward
+ * the (re)targetable scrollTarget, capped at a cruise speed. Under a held
+ * key the cap dominates and the scroll advances at constant speed; once
+ * input stops the exponential term eases it out into the target.
+ */
+ private animStep(now: number) {
+ if(this.destroyed) return;
+ const target = this.scrollTarget;
+ if(target === null) { this.stopAnimation(); return; } // User took over
+ const dt = Math.min(Math.max(now - this.animLastTime, 0) / 1000, 0.1);
+ this.animLastTime = now;
+
+ const el = this.scrollerElement;
+ const delta = target - el.scrollTop;
+ if(Math.abs(delta) <= 1) {
+ el.scrollTop = target;
+ this.stopAnimation();
+ return;
+ }
+ const maxStep = this.viewportHeightCached * SCROLL_CRUISE_SPEED * dt;
+ let step = delta * Math.min(1, SCROLL_APPROACH_RATE * dt);
+ if(Math.abs(step) > maxStep) step = Math.sign(delta) * maxStep;
+ el.scrollTop += step;
+ this.animRAF = requestAnimationFrame(this.banimStep);
+ }
+
+ private stopAnimation() {
+ this.animating = false;
+ cancelAnimationFrame(this.animRAF);
+ }
+
+ /**
+ * Go to the top of a page, optionally at a progression within it.
+ * @returns whether the position changed
+ */
+ goToItem(itemIndex: number, animated = false, progression = 0): boolean {
+ // A pending layout-restore would stomp this explicit navigation
+ cancelAnimationFrame(this.restoreRAF);
+ const index = Math.max(0, Math.min(itemIndex, this.spreader.pages.length - 1));
+ const top = this.pageTop(index) + progression * this.pageHeight(index);
+ if(Math.abs(this.scrollerElement.scrollTop - top) < 1) return false;
+ this.scrollToTarget(top, animated);
+ if(!animated) this.updateFraction();
+ return true;
+ }
+
+ async update(): Promise {
+ if(this.destroyed) return;
+ const index = this.currentIndex;
+ this.lastWindowIndex = index;
+ this.updateWindow(index);
+ const pm = this.pool.get(index);
+ if(pm) await pm.load();
+ }
+
+ get atStart(): boolean {
+ return this.scrollerElement.scrollTop <= 1;
+ }
+
+ get atEnd(): boolean {
+ return this.scrollerElement.scrollTop >= this.stripHeightCached - this.viewportHeightCached - 1;
+ }
+
+ get viewport(): VisualNavigatorViewport {
+ const viewport: VisualNavigatorViewport = {
+ readingOrder: [],
+ progressions: new Map(),
+ positions: null
+ };
+ if(!this.pageTops.length) return viewport;
+ const top = this.scrollerElement.scrollTop;
+ const bottom = top + this.viewportHeightCached;
+ const positions: number[] = [];
+ for (let i = this.indexAtOffset(top); i < this.spreader.pages.length; i++) {
+ const pTop = this.pageTop(i);
+ if(pTop >= bottom) break;
+ const h = this.pageHeight(i);
+ if(pTop + h <= top) continue;
+ const page = this.spreader.pages[i];
+ const range: ProgressionRange = {
+ start: Math.max(0, (top - pTop) / h),
+ end: Math.min(1, (bottom - pTop) / h)
+ };
+ viewport.readingOrder.push(page.link.href);
+ viewport.progressions.set(page.link.href, range);
+ positions.push(page.number);
+ }
+ viewport.positions = positions.length ? positions : null;
+ return viewport;
+ }
+
+ resizeHandler() {
+ const fraction = this.fraction;
+ const w = this.displayWidth;
+ this.spreader.pages.forEach((page) => {
+ this.pool.get(page.index)?.applyStripWidth(w);
+ });
+ this.refreshLayoutCache();
+ this.restoreFraction(fraction);
+ }
+
+ async destroy() {
+ this.destroyed = true;
+ cancelAnimationFrame(this.scrollRAF);
+ cancelAnimationFrame(this.restoreRAF);
+ cancelAnimationFrame(this.animRAF);
+ window.clearTimeout(this.positionTimer);
+ window.clearTimeout(this.programmaticTimer);
+ this.scrollerElement.removeEventListener("scroll", this.bscrollHandler);
+ this.scrollerElement.removeEventListener("scrollend", this.bscrollendHandler);
+ this.scrollerElement.removeEventListener("wheel", this.buserTakeoverHandler);
+ this.scrollerElement.removeEventListener("mousedown", this.bmousedownHandler);
+ this.scrollerElement.removeEventListener("mouseup", this.bclickHandler);
+ this.scrollerElement.removeEventListener("touchend", this.btouchendHandler);
+ this.scrollerElement.removeEventListener("touchstart", this.btouchstartHandler);
+ // The page wrappers belong to the shared pool; detach without destroying
+ this.scrollerElement.replaceChildren();
+ this.scrollerElement.remove();
+ }
+}
diff --git a/navigator/src/divina/DivinaSpreader.ts b/navigator/src/divina/DivinaSpreader.ts
new file mode 100644
index 00000000..ec675e9c
--- /dev/null
+++ b/navigator/src/divina/DivinaSpreader.ts
@@ -0,0 +1,202 @@
+import { Link, Page, Publication, ReadingProgression } from "@readium/shared";
+import { Orientation, Spread } from "../epub/fxl/FXLSpreader.ts";
+
+/**
+ * A single page of a Divina publication, with its resolved layout properties.
+ * Unlike FXLSpreader, this model never mutates the publication's links.
+ */
+export class DivinaPage {
+ readonly link: Link;
+ readonly index: number; // Index in the reading order
+ page: Page = Page.center; // Resolved position within a spread
+ isLandscape: boolean;
+ addBlank = false; // Orphaned single page that must occupy a full spread on its own
+
+ constructor(link: Link, index: number) {
+ this.link = link;
+ this.index = index;
+ const orientation = link.properties?.otherProperties["orientation"];
+ this.isLandscape = orientation === Orientation.landscape ||
+ (orientation !== Orientation.portrait && (link.width || 0) > (link.height || 0));
+ }
+
+ /** 1-based page number */
+ get number(): number {
+ return this.index + 1;
+ }
+
+ get authoredPage(): Page | undefined {
+ return this.link.properties?.page;
+ }
+
+ /** Whether this page occupies both slots of a spread in double-page mode */
+ get double(): boolean {
+ return this.page === Page.center || this.isLandscape || this.addBlank;
+ }
+}
+
+/**
+ * Computes synthetic spreads for a Divina publication from the `page`
+ * (left/right/center) link properties, falling back to computed alternation.
+ *
+ * Slot model (used by the paged presenter): in double-page mode every spread
+ * occupies exactly two slots — a pair is 1+1, and a lone page (center,
+ * landscape or orphaned/addBlank) takes a double-width slot. This keeps
+ * slide indices (always even) aligned with spread boundaries.
+ */
+export class DivinaSpreader {
+ readonly rtl: boolean;
+ shift = true; // Whether the first page stands alone (cover)
+ pages: DivinaPage[] = [];
+ spreads: DivinaPage[][] = [];
+ private itemToSlotArr: number[] = [];
+ private slotToItemArr: number[] = [];
+ private spreadOfItemArr: number[] = [];
+
+ constructor(publication: Publication) {
+ this.rtl = publication.metadata.effectiveReadingProgression === ReadingProgression.rtl;
+ this.pages = publication.readingOrder.items.map((link, index) => new DivinaPage(link, index));
+ this.index();
+ this.testShift();
+ this.buildSlots();
+ }
+
+ private index(redo = false) {
+ let nDouble = 0;
+ this.pages.forEach((page, index) => {
+ if(!page.authoredPage || redo) {
+ page.page = page.isLandscape ?
+ Page.center :
+ ((((this.shift ? 0 : 1) + index - nDouble) % 2) ?
+ (this.rtl ? Page.right : Page.left) :
+ (this.rtl ? Page.left : Page.right));
+ } else {
+ page.page = page.authoredPage;
+ }
+ if(page.double)
+ nDouble++;
+ });
+ this.buildSpreads();
+ }
+
+ private testShift() {
+ let wasLastSingle = false;
+ this.spreads.forEach((spread, index) => {
+ if(spread.length > 1) {
+ wasLastSingle = false;
+ return; // Only interested in single-page "spreads"
+ }
+ const single = spread[0];
+
+ // First page is landscape/spread-both means no shift
+ if(index === 0 && (single.isLandscape || single.link.properties?.otherProperties["spread"] === Spread.both))
+ this.shift = false;
+
+ // If the last spread was a true single, and this spread is a center page,
+ // the single was an orphaned half of a double page spread: pad it.
+ if(wasLastSingle && single.page === Page.center)
+ this.spreads[index - 1][0].addBlank = true;
+
+ // An orphaned component of a double page spread (that's not the first page)
+ if(!single.isLandscape && single.page !== Page.center && single.index > 0)
+ wasLastSingle = true;
+ else
+ wasLastSingle = false;
+ });
+ if(!this.shift) {
+ this.spreads = [];
+ this.index(true); // Re-index spreads
+ }
+ }
+
+ private buildSpreads() {
+ this.spreads = [];
+ let currentSet: DivinaPage[] = [];
+ this.pages.forEach((page, index) => {
+ if(!index && this.shift) {
+ this.spreads.push([page]);
+ } else if(page.page === Page.center) {
+ // A center (single) page spread: push immediately and reset current set
+ if(currentSet.length > 0) this.spreads.push(currentSet);
+ this.spreads.push([page]);
+ currentSet = [];
+ } else if(currentSet.length >= 2) { // Spread has max 2 pages
+ this.spreads.push(currentSet);
+ currentSet = [page];
+ } else
+ currentSet.push(page);
+ });
+ if(currentSet.length > 0) this.spreads.push(currentSet);
+ }
+
+ /**
+ * Builds the item <-> slot mappings for double-page mode.
+ * Slots are consumed in spread order; every spread takes exactly two slots.
+ */
+ private buildSlots() {
+ this.itemToSlotArr = new Array(this.pages.length);
+ this.slotToItemArr = [];
+ this.spreadOfItemArr = new Array(this.pages.length);
+ let slot = 0;
+ this.spreads.forEach((spread, spreadIndex) => {
+ spread.forEach((page) => {
+ this.itemToSlotArr[page.index] = slot;
+ this.spreadOfItemArr[page.index] = spreadIndex;
+ const width = spread.length === 1 ? 2 : 1;
+ for (let i = 0; i < width; i++)
+ this.slotToItemArr[slot + i] = page.index;
+ slot += width;
+ });
+ });
+ }
+
+ /** Total number of slots (spread mode). Always even. */
+ get slotCount(): number {
+ return this.slotToItemArr.length;
+ }
+
+ /** Count of pages that take a double-width slot in spread mode */
+ get nDouble(): number {
+ return this.pages.reduce((n, p) => n + (p.double ? 1 : 0), 0);
+ }
+
+ slotOfItem(itemIndex: number): number {
+ return this.itemToSlotArr[Math.max(0, Math.min(itemIndex, this.pages.length - 1))] ?? 0;
+ }
+
+ itemOfSlot(slot: number): number {
+ return this.slotToItemArr[Math.max(0, Math.min(slot, this.slotToItemArr.length - 1))] ?? 0;
+ }
+
+ spreadIndexOfItem(itemIndex: number): number {
+ return this.spreadOfItemArr[Math.max(0, Math.min(itemIndex, this.pages.length - 1))] ?? 0;
+ }
+
+ spreadOfItem(itemIndex: number): DivinaPage[] {
+ return this.spreads[this.spreadIndexOfItem(itemIndex)];
+ }
+
+ /**
+ * Whether the page occupies a full spread on its own in double-page mode
+ * (and therefore takes a double-width slot). This mirrors buildSlots()
+ * exactly: any page alone in its spread is double.
+ */
+ isDouble(itemIndex: number): boolean {
+ return this.spreadOfItem(itemIndex)?.length === 1;
+ }
+
+ findByHref(href: string): DivinaPage | undefined {
+ return this.pages.find(p => p.link.href === href);
+ }
+
+ /**
+ * The position of a page within a spread, viewport-wise:
+ * left/right of the gutter, or centered when displayed alone.
+ */
+ spreadPosition(spread: DivinaPage[], target: DivinaPage, perPage: number): Page {
+ if(perPage < 2 || spread.length < 2) return Page.center;
+ return target === spread[0] ?
+ (this.rtl ? Page.right : Page.left) :
+ (this.rtl ? Page.left : Page.right);
+ }
+}
diff --git a/navigator/src/divina/DivinaVariantSelector.ts b/navigator/src/divina/DivinaVariantSelector.ts
new file mode 100644
index 00000000..8e10925e
--- /dev/null
+++ b/navigator/src/divina/DivinaVariantSelector.ts
@@ -0,0 +1,77 @@
+import { Link } from "@readium/shared";
+import { sML } from "@readium/navigator-html-injectables";
+
+/**
+ * User-selectable image quality for publications that provide alternate
+ * resolutions of their pages (RWPM `alternate` links).
+ */
+export enum DivinaQuality {
+ auto = "auto", // Match the display resolution
+ low = "low", // Smallest available variant
+ high = "high", // One step above the display resolution
+ max = "max", // Largest available variant
+}
+
+// Display caps for mobile devices: beyond this, decode lag, memory pressure
+// and canvas limits outweigh the quality gain (from xbreader)
+export const MOBILE_MAX_HEIGHT = 2560;
+export const MOBILE_MAX_WIDTH = 1800;
+
+// Accounts for rounding and slight aspect-ratio variance between variants
+const DIMENSION_TOLERANCE = 16;
+
+/** The link itself and its (bitmap) alternates, if any */
+export function gatherVariants(item: Link): Link[] {
+ const variants = item.alternates?.items ? [...item.alternates.items] : [];
+ variants.unshift(item);
+ return variants.filter(l => !l.type || l.type.startsWith("image/"));
+}
+
+export interface VariantSelectionOptions {
+ /** Display size of the page in device pixels (CSS box × devicePixelRatio) */
+ targetWidth: number;
+ targetHeight: number;
+ /** The axis that constrains the display: height in paged mode, width in scrolled mode */
+ axis: "width" | "height";
+ quality: DivinaQuality;
+}
+
+/**
+ * Selects the most appropriate variant of a page image for the current
+ * display, based purely on the choices the manifest offers: the smallest
+ * variant that covers the display resolution (in device pixels), biased by
+ * the quality preference, and capped on mobile devices.
+ */
+export function selectVariant(item: Link, opts: VariantSelectionOptions): Link {
+ const dim = (l: Link) => (opts.axis === "width" ? l.width : l.height) || 0;
+ // Variants without dimensions can't be compared, leave them out
+ const candidates = gatherVariants(item).filter(l => dim(l) > 0);
+ if (candidates.length <= 1) return candidates[0] || item;
+
+ const sorted = candidates.sort((a, b) => dim(a) - dim(b));
+ const mobile = sML.OS.iOS || sML.OS.Android;
+ const cap = mobile ? (opts.axis === "width" ? MOBILE_MAX_WIDTH : MOBILE_MAX_HEIGHT) : Infinity;
+ /** The largest variant that doesn't exceed the mobile cap (or the smallest overall) */
+ const capped = (pick: Link): Link => {
+ if (dim(pick) <= cap) return pick;
+ const fitting = sorted.filter(l => l === pick || dim(l) <= cap);
+ return fitting.length > 1 ? fitting[fitting.length - 2] : sorted[0];
+ };
+
+ switch (opts.quality) {
+ case DivinaQuality.low:
+ return sorted[0];
+ case DivinaQuality.max:
+ return capped(sorted[sorted.length - 1]);
+ default: {
+ const target = Math.min(opts.axis === "width" ? opts.targetWidth : opts.targetHeight, cap);
+ // Smallest variant that covers the display resolution
+ let pick = sorted.find(l => dim(l) >= target - DIMENSION_TOLERANCE) || sorted[sorted.length - 1];
+ if (opts.quality === DivinaQuality.high) {
+ const next = sorted[sorted.indexOf(pick) + 1];
+ if (next) pick = next;
+ }
+ return capped(pick);
+ }
+ }
+}
diff --git a/navigator/src/divina/index.ts b/navigator/src/divina/index.ts
new file mode 100644
index 00000000..60a3e740
--- /dev/null
+++ b/navigator/src/divina/index.ts
@@ -0,0 +1,8 @@
+export * from "./DivinaNavigator.ts";
+export * from "./DivinaSpreader.ts";
+export * from "./DivinaPageManager.ts";
+export * from "./DivinaPagedPresenter.ts";
+export * from "./DivinaScrolledPresenter.ts";
+export * from "./DivinaPeripherals.ts";
+export * from "./DivinaVariantSelector.ts";
+export * from "./preferences/index.ts";
diff --git a/navigator/src/divina/preferences/DivinaDefaults.ts b/navigator/src/divina/preferences/DivinaDefaults.ts
new file mode 100644
index 00000000..fdb2341e
--- /dev/null
+++ b/navigator/src/divina/preferences/DivinaDefaults.ts
@@ -0,0 +1,37 @@
+import {
+ ensureBoolean,
+ ensureEnumValue,
+ ensureNonNegative,
+ ensureString,
+ ensureValueInRange
+} from "../../preferences/guards.ts";
+
+import { DivinaQuality } from "../DivinaVariantSelector.ts";
+import { stripWidthRangeConfig } from "./DivinaPreferences.ts";
+
+export interface IDivinaDefaults {
+ backgroundColor?: string | null,
+ constraint?: number | null,
+ quality?: DivinaQuality | null,
+ scrolled?: boolean | null,
+ spreads?: boolean | null,
+ stripWidth?: number | null
+}
+
+export class DivinaDefaults {
+ backgroundColor: string | null;
+ constraint: number;
+ quality: DivinaQuality;
+ scrolled: boolean | null;
+ spreads: boolean;
+ stripWidth: number;
+
+ constructor(defaults: IDivinaDefaults) {
+ this.backgroundColor = ensureString(defaults.backgroundColor) || null;
+ this.constraint = ensureNonNegative(defaults.constraint) || 0;
+ this.quality = ensureEnumValue(defaults.quality, DivinaQuality) || DivinaQuality.auto;
+ this.scrolled = ensureBoolean(defaults.scrolled) ?? null;
+ this.spreads = ensureBoolean(defaults.spreads) ?? true;
+ this.stripWidth = ensureValueInRange(defaults.stripWidth, stripWidthRangeConfig.range) || 720;
+ }
+}
diff --git a/navigator/src/divina/preferences/DivinaPreferences.ts b/navigator/src/divina/preferences/DivinaPreferences.ts
new file mode 100644
index 00000000..3cd4f840
--- /dev/null
+++ b/navigator/src/divina/preferences/DivinaPreferences.ts
@@ -0,0 +1,77 @@
+import { ConfigurablePreferences } from "../../preferences/Configurable.ts";
+import { RangeConfig } from "../../preferences/Types.ts";
+import { DivinaQuality } from "../DivinaVariantSelector.ts";
+
+import {
+ ensureBoolean,
+ ensureEnumValue,
+ ensureNonNegative,
+ ensureString,
+ ensureValueInRange
+} from "../../preferences/guards.ts";
+
+/**
+ * Maximum width in pixels of the vertical strip in scrolled mode.
+ */
+export const stripWidthRangeConfig: RangeConfig = {
+ range: [480, 2400],
+ step: 20
+}
+
+export interface IDivinaPreferences {
+ /** Background color behind the pages (equivalent of the EPUB page background theme) */
+ backgroundColor?: string | null,
+ /** Number of pixels to constrain the container width by (e.g. for docked panels) */
+ constraint?: number | null,
+ /** Image quality when the publication offers alternate resolutions */
+ quality?: DivinaQuality | null,
+ /** Display the publication as a vertical scroll instead of horizontal pages */
+ scrolled?: boolean | null,
+ /** Display two pages side-by-side on landscape viewports (paged mode only) */
+ spreads?: boolean | null,
+ /** Maximum width in pixels of the vertical strip in scrolled mode */
+ stripWidth?: number | null
+}
+
+export class DivinaPreferences implements ConfigurablePreferences {
+ backgroundColor?: string | null;
+ constraint?: number | null;
+ quality?: DivinaQuality | null;
+ scrolled?: boolean | null;
+ spreads?: boolean | null;
+ stripWidth?: number | null;
+
+ constructor(preferences: IDivinaPreferences = {}) {
+ this.backgroundColor = ensureString(preferences.backgroundColor);
+ this.constraint = ensureNonNegative(preferences.constraint);
+ this.quality = ensureEnumValue(preferences.quality, DivinaQuality);
+ this.scrolled = ensureBoolean(preferences.scrolled);
+ this.spreads = ensureBoolean(preferences.spreads);
+ this.stripWidth = ensureValueInRange(preferences.stripWidth, stripWidthRangeConfig.range);
+ }
+
+ static serialize(preferences: DivinaPreferences): string {
+ const { ...properties } = preferences;
+ return JSON.stringify(properties);
+ }
+
+ static deserialize(preferences: string): DivinaPreferences | null {
+ try {
+ const parsedPreferences = JSON.parse(preferences);
+ return new DivinaPreferences(parsedPreferences);
+ } catch (error) {
+ console.error("Failed to deserialize preferences:", error);
+ return null;
+ }
+ }
+
+ merging(other: DivinaPreferences): DivinaPreferences {
+ const merged: IDivinaPreferences = { ...this };
+ for (const key of Object.keys(other) as (keyof IDivinaPreferences)[]) {
+ if (other[key] !== undefined) {
+ (merged as Record)[key] = other[key];
+ }
+ }
+ return new DivinaPreferences(merged);
+ }
+}
diff --git a/navigator/src/divina/preferences/DivinaPreferencesEditor.ts b/navigator/src/divina/preferences/DivinaPreferencesEditor.ts
new file mode 100644
index 00000000..9ac6abd8
--- /dev/null
+++ b/navigator/src/divina/preferences/DivinaPreferencesEditor.ts
@@ -0,0 +1,89 @@
+import { Layout, Metadata } from "@readium/shared";
+
+import { IPreferencesEditor } from "../../preferences/PreferencesEditor.ts";
+import { BooleanPreference, EnumPreference, Preference, RangePreference } from "../../preferences/Preference.ts";
+import { DivinaQuality } from "../DivinaVariantSelector.ts";
+import { DivinaPreferences, stripWidthRangeConfig } from "./DivinaPreferences.ts";
+import { DivinaSettings } from "./DivinaSettings.ts";
+
+export class DivinaPreferencesEditor implements IPreferencesEditor {
+ preferences: DivinaPreferences;
+ private settings: DivinaSettings;
+ private metadata: Metadata;
+
+ constructor(initialPreferences: DivinaPreferences, settings: DivinaSettings, metadata: Metadata) {
+ this.preferences = initialPreferences;
+ this.settings = settings;
+ this.metadata = metadata;
+ }
+
+ clear(): void {
+ this.preferences = new DivinaPreferences({});
+ }
+
+ private updatePreference(key: K, value: DivinaPreferences[K]) {
+ this.preferences[key] = value;
+ }
+
+ private get manifestScrolled(): boolean {
+ return this.metadata.effectiveLayout === Layout.scrolled;
+ }
+
+ get backgroundColor(): Preference {
+ return new Preference({
+ initialValue: this.preferences.backgroundColor,
+ effectiveValue: this.settings.backgroundColor,
+ isEffective: true,
+ onChange: (newValue) => {
+ this.updatePreference("backgroundColor", newValue ?? null);
+ }
+ });
+ }
+
+ get quality(): EnumPreference {
+ return new EnumPreference({
+ initialValue: this.preferences.quality,
+ effectiveValue: this.settings.quality,
+ isEffective: true,
+ onChange: (newValue) => {
+ this.updatePreference("quality", newValue ?? null);
+ },
+ supportedValues: [DivinaQuality.auto, DivinaQuality.low, DivinaQuality.high, DivinaQuality.max]
+ });
+ }
+
+ get scrolled(): BooleanPreference {
+ return new BooleanPreference({
+ initialValue: this.preferences.scrolled,
+ effectiveValue: this.settings.scrolled,
+ isEffective: !this.manifestScrolled,
+ onChange: (newValue) => {
+ this.updatePreference("scrolled", newValue ?? null);
+ }
+ });
+ }
+
+ get spreads(): BooleanPreference {
+ return new BooleanPreference({
+ initialValue: this.preferences.spreads,
+ effectiveValue: this.settings.spreads,
+ isEffective: !this.settings.scrolled,
+ onChange: (newValue) => {
+ this.updatePreference("spreads", newValue ?? null);
+ }
+ });
+ }
+
+ get stripWidth(): RangePreference {
+ return new RangePreference({
+ initialValue: this.preferences.stripWidth,
+ effectiveValue: this.settings.stripWidth,
+ isEffective: this.settings.scrolled,
+ onChange: (newValue) => {
+ this.updatePreference("stripWidth", newValue ?? null);
+ },
+ supportedRange: stripWidthRangeConfig.range,
+ step: stripWidthRangeConfig.step
+ });
+ }
+}
diff --git a/navigator/src/divina/preferences/DivinaSettings.ts b/navigator/src/divina/preferences/DivinaSettings.ts
new file mode 100644
index 00000000..735497f9
--- /dev/null
+++ b/navigator/src/divina/preferences/DivinaSettings.ts
@@ -0,0 +1,38 @@
+import { ConfigurableSettings } from "../../preferences/Configurable.ts";
+import { DivinaQuality } from "../DivinaVariantSelector.ts";
+import { DivinaDefaults } from "./DivinaDefaults.ts";
+import { DivinaPreferences } from "./DivinaPreferences.ts";
+
+export interface IDivinaSettings {
+ backgroundColor?: string | null,
+ constraint?: number | null,
+ quality?: DivinaQuality | null,
+ scrolled?: boolean | null,
+ spreads?: boolean | null,
+ stripWidth?: number | null
+}
+
+export class DivinaSettings implements ConfigurableSettings {
+ backgroundColor: string | null;
+ constraint: number;
+ quality: DivinaQuality;
+ scrolled: boolean;
+ spreads: boolean;
+ stripWidth: number;
+
+ /**
+ * @param manifestScrolled Whether the publication itself declares `layout: scrolled`.
+ * A natively scrolled publication (e.g. webtoon) cannot be switched to paged mode,
+ * while a fixed publication can be switched to scrolled by the user.
+ */
+ constructor(preferences: DivinaPreferences, defaults: DivinaDefaults, manifestScrolled: boolean) {
+ this.backgroundColor = preferences.backgroundColor ?? defaults.backgroundColor;
+ this.constraint = preferences.constraint ?? defaults.constraint;
+ this.quality = preferences.quality ?? defaults.quality;
+ this.scrolled = manifestScrolled
+ ? true
+ : (preferences.scrolled ?? defaults.scrolled ?? false);
+ this.spreads = preferences.spreads ?? defaults.spreads;
+ this.stripWidth = preferences.stripWidth ?? defaults.stripWidth;
+ }
+}
diff --git a/navigator/src/divina/preferences/index.ts b/navigator/src/divina/preferences/index.ts
new file mode 100644
index 00000000..ae534608
--- /dev/null
+++ b/navigator/src/divina/preferences/index.ts
@@ -0,0 +1,4 @@
+export * from "./DivinaDefaults.ts";
+export * from "./DivinaPreferences.ts";
+export * from "./DivinaPreferencesEditor.ts";
+export * from "./DivinaSettings.ts";
diff --git a/navigator/src/divina/protection/DivinaNavigatorProtector.ts b/navigator/src/divina/protection/DivinaNavigatorProtector.ts
new file mode 100644
index 00000000..31ed3bb7
--- /dev/null
+++ b/navigator/src/divina/protection/DivinaNavigatorProtector.ts
@@ -0,0 +1,44 @@
+import { NavigatorProtector } from "../../protection/NavigatorProtector.ts";
+import { DragAndDropProtector } from "../../protection/DragAndDropProtector.ts";
+import { CopyProtector } from "../../protection/CopyProtector.ts";
+import { IContentProtectionConfig } from "../../Navigator.ts";
+
+/**
+ * Content protection for the Divina navigator. Because Divina renders images
+ * directly in the host DOM (no iframes), the in-frame halves of the EPUB
+ * protection (copy and drag-and-drop blocking) must run host-side instead,
+ * like the audio navigator does.
+ */
+export class DivinaNavigatorProtector extends NavigatorProtector {
+ private dragAndDropProtector?: DragAndDropProtector;
+ private copyProtector?: CopyProtector;
+
+ constructor(config: IContentProtectionConfig = {}) {
+ super(config);
+
+ if (config.disableDragAndDrop) {
+ this.dragAndDropProtector = new DragAndDropProtector({
+ onDragDetected: (dataTransferTypes) => {
+ this.dispatchSuspiciousActivity("drag_detected", { dataTransferTypes, targetFrameSrc: "" });
+ },
+ onDropDetected: (dataTransferTypes, fileCount) => {
+ this.dispatchSuspiciousActivity("drop_detected", { dataTransferTypes, fileCount, targetFrameSrc: "" });
+ }
+ });
+ }
+
+ if (config.protectCopy) {
+ this.copyProtector = new CopyProtector({
+ onCopyBlocked: () => {
+ this.dispatchSuspiciousActivity("bulk_copy", { targetFrameSrc: "" });
+ }
+ });
+ }
+ }
+
+ public override destroy() {
+ super.destroy();
+ this.dragAndDropProtector?.destroy();
+ this.copyProtector?.destroy();
+ }
+}
diff --git a/navigator/src/epub/EpubNavigator.ts b/navigator/src/epub/EpubNavigator.ts
index 3ed2cf3a..a36dc560 100644
--- a/navigator/src/epub/EpubNavigator.ts
+++ b/navigator/src/epub/EpubNavigator.ts
@@ -6,6 +6,7 @@ import { CommsEventKey, ContextMenuEvent, DecorationActivatedEvent, FXLModules,
import { Decoration, DecorationActivationEvent, DecorationObserver, DecorableNavigator, DecoratorConfig, decorationsEqual, resolveDecorationForWire, BUILTIN_DECORATION_TYPES } from "../decorations/index.ts";
import * as path from "path-browserify";
import { FXLFrameManager } from "./fxl/FXLFrameManager.ts";
+import { isTypedOMSupported } from "./fxl/FXLPeripherals.ts";
import { FrameManager } from "./frame/FrameManager.ts";
import { IEpubPreferences, EpubPreferences } from "./preferences/EpubPreferences.ts";
import { IEpubDefaults, EpubDefaults } from "./preferences/EpubDefaults.ts";
@@ -71,6 +72,7 @@ export class EpubNavigator extends VisualNavigator implements Configurable {
+ if (this._destroyed) return res(false);
+ await this.go(this.currentLocation, false, (s) => {
+ res(s);
+ });
+ });
}
public get settings(): Readonly {
@@ -383,7 +397,12 @@ export class EpubNavigator extends VisualNavigator implements Configurable 0;
if(hasProgression)
done = await new Promise((res, _) => {
@@ -1089,7 +1110,42 @@ export class EpubNavigator extends VisualNavigator implements Configurable p.locations.position === locator.locations.position);
+ if (match) {
+ locator = match.copyWithLocations(locator.locations);
+ fellback = true;
+ }
+ }
+ if(!fellback && typeof locator.locations?.totalProgression === "number") {
+ // If locator has no href, but it does have a totalProgression,
+ // we can attempt to find the right resource from the positions list.
+ // This is here to help with conversion from OPDS locators which only
+ // require the total progression in the publication.
+ const targetProgression = locator.locations.totalProgression;
+ let closestIdx = 0;
+ let closestDist = Infinity;
+ for (let i = 0; i < this.positions.length; i++) {
+ const pos = this.positions[i];
+ // Use totalProgression if available, otherwise estimate from index
+ const posProg = pos.locations.totalProgression ?? (i / this.positions.length);
+ const dist = Math.abs(posProg - targetProgression);
+ if (dist < closestDist) {
+ closestDist = dist;
+ closestIdx = i;
+ }
+ }
+ locator = this.positions[closestIdx].copyWithLocations(locator.locations);
+ }
+ }
+ return locator;
+ }
+
public go(locator: Locator, _: boolean, cb: (ok: boolean) => void): void {
+ locator = this.completeLocator(locator);
const href = locator.href.split("#")[0];
let link = this.pub.readingOrder.findWithHref(href);
if(!link) {
diff --git a/navigator/src/epub/frame/FrameBlobBuilder.ts b/navigator/src/epub/frame/FrameBlobBuilder.ts
index e05b49cd..704ada34 100644
--- a/navigator/src/epub/frame/FrameBlobBuilder.ts
+++ b/navigator/src/epub/frame/FrameBlobBuilder.ts
@@ -1,4 +1,4 @@
-import { Link, MediaType, Publication, ReadingProgression } from "@readium/shared";
+import { Link, MediaType, Publication, ReadingProgression, Resource } from "@readium/shared";
import { Injector } from "../../injection/Injector.ts";
import { getScriptMode } from "../../helpers/scriptMode.ts";
@@ -20,73 +20,121 @@ const csp = (domains: string[]) => {
].join("; ");
};
-export default class FrameBlobBuider {
- private readonly item: Link;
- private readonly burl: string;
- private readonly pub: Publication;
+export default class FrameBlobBuilder {
private readonly cssProperties?: { [key: string]: string };
private readonly injector: Injector | null = null;
+ private currentUrl?: string;
+ private currentResource?: Resource;
+
constructor(
- pub: Publication,
- baseURL: string,
- item: Link,
+ private readonly pub: Publication,
+ private readonly baseURL: string,
+ private readonly item: Link,
options: {
cssProperties?: { [key: string]: string };
injector?: Injector | null;
}
) {
- this.pub = pub;
this.item = item;
- this.burl = item.toURL(baseURL) || "";
this.cssProperties = options.cssProperties;
this.injector = options.injector ?? null;
}
+ public reset() {
+ this.currentUrl && URL.revokeObjectURL(this.currentUrl);
+ this.currentUrl = undefined;
+ this.currentResource?.close();
+ this.currentResource = undefined;
+ }
+
public async build(fxl = false): Promise {
- if(!this.item.mediaType.isHTML) {
- if(this.item.mediaType.isBitmap || this.item.mediaType.equals(MediaType.SVG)) {
- return this.buildImageFrame();
+ if(this.currentUrl) return this.currentUrl;
+
+ this.currentResource = this.pub.get(this.item);
+ const link = await this.currentResource.link();
+ if(!this.currentResource) {
+ // Reset has occured in the meantime
+ return "about:blank";
+ }
+ if(!link.mediaType.isHTML) {
+ if(link.mediaType.isBitmap || link.mediaType.equals(MediaType.SVG)) {
+ const blobUrl = await this.buildImageFrame();
+ this.currentUrl = blobUrl;
+ return blobUrl;
} else
- throw Error("Unsupported frame mediatype " + this.item.mediaType.string);
+ throw Error("Unsupported frame mediatype " + link.mediaType.string);
} else {
- return await this.buildHtmlFrame(fxl);
+ const blobUrl = await this.buildHtmlFrame(fxl);
+ this.currentUrl = blobUrl;
+ return blobUrl;
}
}
private async buildHtmlFrame(fxl = false): Promise {
- // Load the HTML resource
- const txt = await this.pub.get(this.item).readAsString();
- if(!txt) throw new Error(`Failed reading item ${this.item.href}`);
+ if(!this.currentResource) throw new Error("No resource loaded");
- const doc = new DOMParser().parseFromString(
- txt,
- this.item.mediaType.string as DOMParserSupportedType
- );
+ // Load the HTML resource
+ const link = await this.currentResource.link();
+ const doc = await this.currentResource.readAsXML() as HTMLDocument;
+ if(!doc) throw new Error(`Failed reading item ${link.href}`);
const perror = doc.querySelector("parsererror");
if (perror) {
const details = perror.querySelector("div");
- throw new Error(`Failed parsing item ${this.item.href}: ${details?.textContent || perror.textContent}`);
+ throw new Error(`Failed parsing item ${link.href}: ${details?.textContent || perror.textContent}`);
}
// Apply resource injections if injection service is provided
if (this.injector) {
- await this.injector.injectForDocument(doc, this.item);
+ await this.injector.injectForDocument(doc, link);
}
- return this.finalizeDOM(doc, this.pub.baseURL, this.burl, this.item.mediaType, fxl, this.cssProperties);
+ return this.finalizeDOM(doc, this.pub.baseURL, link.toURL(this.baseURL) || "", link.mediaType, fxl, this.cssProperties);
}
- private buildImageFrame(): string {
- // Rudimentary image display
- const doc = document.implementation.createHTMLDocument(this.item.title || this.item.href);
+ private async buildImageFrame(): Promise {
+ if(!this.currentResource) throw new Error("No resource loaded");
+ const link = await this.currentResource.link();
+ const burl = link.toURL(this.baseURL) || ""
+
+ // Rudimentary image display in an HTML doc
+ const doc = document.implementation.createHTMLDocument(link.title || link.href);
+
+ // Add viewport if available
+ if((link?.height || 0) > 0 && (link?.width || 0) > 0) {
+ const viewportMeta = doc.createElement("meta");
+ viewportMeta.name = "viewport";
+ viewportMeta.content = `width=${link.width}, height=${link.height}`;
+ viewportMeta.dataset.readium = "true";
+ doc.head.appendChild(viewportMeta);
+ }
+
const simg = document.createElement("img");
- simg.src = this.burl || "";
- simg.alt = this.item.title || "";
- simg.decoding = "async";
+ simg.src = burl || "";
+ simg.alt = link.title || "";
+ await simg.decode(); // Reduce repaints
doc.body.appendChild(simg);
- return this.finalizeDOM(doc, this.pub.baseURL, this.burl, this.item.mediaType, true);
+
+ // Apply resource injections if injection service is provided
+ if (this.injector) {
+ await this.injector.injectForDocument(doc, new Link({
+ // Temporary solution to address injector only expecting (X)HTML
+ // documents for injection, which we are technically providing
+ href: "readium-image-frame.xhtml",
+ type: MediaType.XHTML.string
+ }));
+ }
+
+ // Add image style
+ const sstyle = doc.createElement("style");
+ sstyle.dataset.readium = "true";
+ sstyle.textContent = `
+ html, body { width: 100%; height: 100%; margin: 0; padding: 0; font-size: 0; }
+ img { margin: 0; padding: 0; border: 0; }`;
+ doc.head.appendChild(sstyle);
+
+ return this.finalizeDOM(doc, this.pub.baseURL, burl, link.mediaType, true);
}
private setProperties(cssProperties: { [key: string]: string }, doc: Document) {
@@ -102,6 +150,10 @@ export default class FrameBlobBuider {
// Get allowed domains from injector if it exists
const allowedDomains = this.injector?.getAllowedDomains?.() || [];
+ // Remove query from root if present, as CSP doesn't allow them
+ root = root?.split("?")[0];
+
+
// Always include the root domain if provided
const domains = [...new Set([
...(root ? [root] : []),
@@ -124,6 +176,7 @@ export default class FrameBlobBuider {
// loaded in parallel, greatly increasing overall speed.
doc.body.querySelectorAll("img").forEach((img) => {
img.setAttribute("fetchpriority", "high");
+ img.setAttribute("referrerpolicy", "origin");
});
// We need to ensure that lang is set on the root element
diff --git a/navigator/src/epub/frame/FrameManager.ts b/navigator/src/epub/frame/FrameManager.ts
index 507f98be..c49f0f45 100644
--- a/navigator/src/epub/frame/FrameManager.ts
+++ b/navigator/src/epub/frame/FrameManager.ts
@@ -27,7 +27,7 @@ export class FrameManager {
this.frame.sandbox.value = "allow-same-origin allow-scripts";
this.frame.classList.add("readium-navigator-iframe");
this.frame.style.visibility = "hidden";
- this.frame.style.setProperty("aria-hidden", "true");
+ this.frame.ariaHidden = "true";
this.frame.style.opacity = "0";
this.frame.style.position = "absolute";
this.frame.style.pointerEvents = "none";
@@ -111,7 +111,7 @@ export class FrameManager {
async hide(): Promise {
if(this.destroyed) return;
this.frame.style.visibility = "hidden";
- this.frame.style.setProperty("aria-hidden", "true");
+ this.frame.ariaHidden = "true";
this.frame.style.opacity = "0";
this.frame.style.pointerEvents = "none";
this.hidden = true;
@@ -143,9 +143,9 @@ export class FrameManager {
const remove = () => {
this.frame.style.removeProperty("visibility");
- this.frame.style.removeProperty("aria-hidden");
this.frame.style.removeProperty("opacity");
this.frame.style.removeProperty("pointer-events");
+ this.frame.ariaHidden = null;
this.hidden = false;
if (sML.UA.WebKit) {
diff --git a/navigator/src/epub/frame/FramePoolManager.ts b/navigator/src/epub/frame/FramePoolManager.ts
index 0363641b..7bf97f74 100644
--- a/navigator/src/epub/frame/FramePoolManager.ts
+++ b/navigator/src/epub/frame/FramePoolManager.ts
@@ -1,12 +1,12 @@
import { ModuleName } from "@readium/navigator-html-injectables";
import { Locator, Publication } from "@readium/shared";
-import FrameBlobBuider from "./FrameBlobBuilder.ts";
+import FrameBlobBuilder from "./FrameBlobBuilder.ts";
import { FrameManager } from "./FrameManager.ts";
import { Injector } from "../../injection/Injector.ts";
import { IContentProtectionConfig, IKeyboardPeripheralsConfig } from "../../Navigator.ts";
-const UPPER_BOUNDARY = 5;
-const LOWER_BOUNDARY = 3;
+const UPPER_BOUNDARY = 10;
+const LOWER_BOUNDARY = 5;
export class FramePoolManager {
private readonly container: HTMLElement;
@@ -14,13 +14,14 @@ export class FramePoolManager {
private _currentFrame: FrameManager | undefined;
private currentCssProperties: { [key: string]: string } | undefined;
private readonly pool: Map = new Map();
- private readonly blobs: Map = new Map();
- private readonly inprogress: Map> = new Map();
+ private readonly blobs: Map = new Map();
+ private readonly inprogress: Map> = new Map();
private pendingUpdates: Map = new Map();
private currentBaseURL: string | undefined;
private readonly injector: Injector | null = null;
private readonly contentProtectionConfig: IContentProtectionConfig;
private readonly keyboardPeripheralsConfig: IKeyboardPeripheralsConfig;
+ private updateSequence = 0;
constructor(
container: HTMLElement,
@@ -42,7 +43,7 @@ export class FramePoolManager {
// Wait for all in-progress loads to complete
let iit = this.inprogress.values();
let inp = iit.next();
- const inprogressPromises: Promise[] = [];
+ const inprogressPromises: Promise[] = [];
while(inp.value) {
inprogressPromises.push(inp.value);
inp = iit.next();
@@ -62,10 +63,8 @@ export class FramePoolManager {
this.pool.clear();
// Revoke all blobs
- this.blobs.forEach(v => {
- this.injector?.releaseBlobUrl?.(v);
- URL.revokeObjectURL(v);
- });
+ this.blobs.forEach(v => v.reset());
+ this.blobs.clear();
// Clean up injector if it exists
this.injector?.dispose();
@@ -77,6 +76,7 @@ export class FramePoolManager {
}
async update(pub: Publication, locator: Locator, modules: ModuleName[], force=false) {
+ const updateSequence = ++this.updateSequence;
let i = this.positions.findIndex(l => l.locations.position === locator.locations.position);
if(i < 0) throw Error(`Locator not found in position list: ${locator.locations.position} > ${this.positions.reduce((acc, l) => l.locations.position || 0 > acc ? l.locations.position || 0 : acc, 0) }`);
const newHref = this.positions[i].href;
@@ -106,39 +106,33 @@ export class FramePoolManager {
this.pool.delete(href);
if(this.pendingUpdates.has(href))
this.pendingUpdates.set(href, { inPool: false });
+ // Note that we don't reset the blob here, unlike in the FXL pool.
+ // This is because FXL tends to have a ton more blobs. Maybe we'll adjust
+ // this at a later point with a much larger boundary for resets to deal
+ // with extremely long/large reflowable publications.
+ // Reflowable publication resources also tend to be much larger documents,
+ // so they're more expensive to preprocess with the FrameBlobBuilder.
});
// Check if base URL of publication has changed
if(this.currentBaseURL !== undefined && pub.baseURL !== this.currentBaseURL) {
// Revoke all blobs
- this.blobs.forEach(v => {
- this.injector?.releaseBlobUrl?.(v);
- URL.revokeObjectURL(v);
- });
+ this.blobs.forEach(v => v.reset());
this.blobs.clear();
}
this.currentBaseURL = pub.baseURL;
+ if(force) {
+ this.blobs.forEach(v => v.reset());
+ this.blobs.clear();
+ this.pendingUpdates.clear();
+ }
+
const creator = async (href: string) => {
- if(force) {
- // Revoke all blobs so that CSSProperties are not stale
- // When using force, we switch scroll/paginated
- // If this property is not up to date, it creates issues
- // when navigating backwards, where paginated will go the
- // start of the resource instead of the end due to the
- // corrupted width ColumnSnapper (injectables) gets on init
- this.blobs.forEach(v => {
- this.injector?.releaseBlobUrl?.(v);
- URL.revokeObjectURL(v);
- });
- this.blobs.clear();
- this.pendingUpdates.clear();
- }
if(this.pendingUpdates.has(href) && this.pendingUpdates.get(href)?.inPool === false) {
- const url = this.blobs.get(href);
- if(url) {
- this.injector?.releaseBlobUrl?.(url);
- URL.revokeObjectURL(url);
+ const v = this.blobs.get(href);
+ if(v) {
+ v.reset();
this.blobs.delete(href);
this.pendingUpdates.delete(href);
}
@@ -157,7 +151,7 @@ export class FramePoolManager {
const itm = pub.readingOrder.findWithHref(href);
if(!itm) return; // TODO throw?
if(!this.blobs.has(href)) {
- const blobBuilder = new FrameBlobBuider(
+ this.blobs.set(href, new FrameBlobBuilder(
pub,
this.currentBaseURL || "",
itm,
@@ -165,36 +159,80 @@ export class FramePoolManager {
cssProperties: this.currentCssProperties,
injector: this.injector
}
- );
- const blobURL = await blobBuilder.build();
- this.blobs.set(href, blobURL);
+ ));
}
// Create