@readium/navigator
Version:
Next generation SDK for publications in Web Apps
640 lines (554 loc) • 23.9 kB
text/typescript
import { ModuleName } from "@readium/navigator-html-injectables";
import { Locator, Publication, ReadingProgression, Page, Link } from "@readium/shared";
import { FrameCommsListener } from "../frame";
import FrameBlobBuider from "../frame/FrameBlobBuilder";
import { FXLFrameManager } from "./FXLFrameManager";
import { FXLPeripherals } from "./FXLPeripherals";
import { FXLSpreader, Orientation, Spread } from "./FXLSpreader";
import { VisualNavigatorViewport } from "../../Navigator";
const UPPER_BOUNDARY = 8;
const LOWER_BOUNDARY = 5;
const OFFSCREEN_LOAD_DELAY = 300;
const OFFSCREEN_LOAD_TIMEOUT = 15000;
const RESIZE_UPDATE_TIMEOUT = 250;
const SLIDE_FAST = 150;
const SLIDE_SLOW = 500;
export class FXLFramePoolManager {
private readonly container: HTMLElement;
private readonly positions: Locator[];
private readonly pool: Map<string, FXLFrameManager> = new Map();
private readonly blobs: Map<string, string> = new Map();
private readonly inprogress: Map<string, Promise<void>> = new Map();
private readonly delayedShow: Map<string, Promise<void>> = new Map();
private readonly delayedTimeout: Map<string, number> = new Map();
private currentBaseURL: string | undefined;
private previousFrames: FXLFrameManager[] = [];
// NEW
private readonly bookElement: HTMLDivElement;
public readonly spineElement: HTMLDivElement;
private readonly pub: Publication;
public width: number = 0;
public height: number = 0;
private transform: string = "";
public currentSlide: number = 0;
private spreader: FXLSpreader;
private spread = true; // TODO
private readonly spreadPresentation: Spread;
private orientationInternal = -1; // Portrait = 1, Landscape = 0, Unknown = -1
private containerHeightCached: number;
private resizeTimeout: number | undefined;
// private readonly pages: FXLFrameManager[] = [];
public readonly peripherals: FXLPeripherals;
constructor(container: HTMLElement, positions: Locator[], pub: Publication) {
this.container = container;
this.positions = positions;
this.pub = pub;
this.spreadPresentation = pub.metadata.otherMetadata?.spread || Spread.auto;
if(this.pub.metadata.effectiveReadingProgression !== ReadingProgression.rtl && this.pub.metadata.effectiveReadingProgression !== ReadingProgression.ltr)
// TODO support TTB and BTT
throw Error("Unsupported reading progression for EPUB");
// NEW
this.spreader = new FXLSpreader(this.pub);
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);
this.updateSpineStyle(true);
this.peripherals = new FXLPeripherals(this);
this.pub.readingOrder.items.forEach((link) => {
// Create <iframe>
const fm = new FXLFrameManager(this.peripherals, this.pub.metadata.effectiveReadingProgression, link.href);
this.spineElement.appendChild(fm.element);
// this.pages.push(fm);
this.pool.set(link.href, fm);
fm.width = 100 / this.length * (link.properties?.otherProperties["orientation"] === Orientation.landscape || link.properties?.otherProperties["addBlank"] ? this.perPage : 1);
fm.height = this.height;
});
}
private _listener!: FrameCommsListener;
public set listener(listener: FrameCommsListener) {
this._listener = listener;
}
public get listener() {
return this._listener;
}
public get doNotDisturb() {
// TODO other situations
return this.peripherals.pan.touchID > 0;
}
/**
* When window resizes, resize slider components as well
*/
resizeHandler(slide = true, fast = true) {
// relcalculate currentSlide
// prevent hiding items when browser width increases
if (this.currentSlide + this.perPage > this.length) {
this.currentSlide = this.length <= this.perPage ? 0 : this.length - 1;
}
this.containerHeightCached = this.container.clientHeight;
this.orientationInternal = -1;
this.updateSpineStyle(true);
if(slide/* && !sML.Mobile*/) {
this.currentSlide = this.reAlign();
this.slideToCurrent(!fast, fast);
}
clearTimeout(this.resizeTimeout);
this.resizeTimeout = window.setTimeout(() => {
// TODO optimize this expensive set of loops and operations
this.pool.forEach((frm, linkHref) => {
let i = this.pub.readingOrder.items.findIndex(l => l.href === linkHref);
const link = this.pub.readingOrder.items[i];
frm.width = 100 / this.length * (link.properties?.otherProperties["orientation"] === Orientation.landscape || link.properties?.otherProperties["addBlank"] ? this.perPage : 1);
frm.height = this.height;
if(!frm.loaded) return;
const spread = this.spreader.findByLink(link)!;
frm.update(this.spreadPosition(spread, link));
});
}, RESIZE_UPDATE_TIMEOUT);
}
/**
* It is important that these values be cached to avoid spamming them on redraws, they are expensive.
*/
private updateDimensions() {
this.width = this.bookElement.clientWidth;
this.height = this.bookElement.clientHeight;
// this.containerHeightCached = r.height;
}
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.spreadPresentation === Spread.none) return true; // No spreads
if(this.orientationInternal === -1) {
this.orientationInternal = this.containerHeightCached > this.container.clientWidth ? 1 : 0;
}
return this.orientationInternal === 1;
}
public updateSpineStyle(animate: boolean, fast = true) {
let margin = "0";
this.updateDimensions();
if(this.perPage > 1 && true) // this.shift
margin = `${this.width / 2}px`;
const spineStyle = {
transition: animate ? `all ${fast ? SLIDE_FAST : SLIDE_SLOW}ms ease-out` : "all 0ms ease-out",
marginRight: this.rtl ? margin : "0",
marginLeft: this.rtl ? "0" : margin,
width: `${(this.width / this.perPage) * this.length}px`,
transform: this.transform,
// Static (should be moved to CSS)
contain: "content"
} as CSSStyleDeclaration;
Object.assign(this.spineElement.style, spineStyle);
}
public updateBookStyle(initial=false) {
if(initial) {
const bookStyle = {
overflow: "hidden",
direction: this.pub.metadata.effectiveReadingProgression,
cursor: "",
// Static (should be moved to CSS)
// minHeight: 100%
// maxHeight: "100%",
height: "100%",
width: "100%",
position: "relative",
outline: "none",
transition: this.peripherals?.dragState ? "none" : "transform .15s ease-in-out",
touchAction: "none",
} as CSSStyleDeclaration;
Object.assign(this.bookElement.style, bookStyle);
}
this.bookElement.style.transform = `scale(${this.peripherals?.scale || 1})` + (this.peripherals ? ` translate3d(${this.peripherals.pan.translateX}px, ${this.peripherals.pan.translateY}px, 0px)` : "");
}
/**
* Go to slide with particular index
* @param {number} index - Item index to slide to.
*/
goTo(index: number) {
if (this.slength <= this.perPage)
return;
index = this.reAlign(index);
const beforeChange = this.currentSlide;
this.currentSlide = Math.min(Math.max(index, 0), this.length - 1);
if (beforeChange !== this.currentSlide) {
this.slideToCurrent(false);
// this.onChange();
}
}
onChange() {
this.peripherals.scale = 1;
this.updateBookStyle();
}
private get offset() {
return (this.rtl ? 1 : -1) * this.currentSlide * (this.width / this.perPage);
}
get length() {
if(this.single)
return this.slength;
const total = this.slength + this.nLandscape;
return (this.shift && (total % 2 === 0)) ? total + 1 : total;
}
get slength() {
return this.pub.readingOrder.items.length || 0;
}
get shift() {
return this.spreader.shift;
}
private get nLandscape() {
return this.spreader.nLandscape;
}
public setPerPage(perPage: number | null) {
if(perPage === null) {
// TODO this mode is auto
this.spread = true;
} else if(perPage === 1) {
this.spread = false;
} else {
this.spread = true;
}
requestAnimationFrame(() => this.resizeHandler(true));
}
/**
* Moves sliders frame to position of currently active slide
*/
slideToCurrent(enableTransition?: boolean, fast = true) {
this.updateDimensions();
if (enableTransition) {
// This one is tricky, I know but this is a perfect explanation:
// https://youtu.be/cCOL7MC4Pl0
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const newTransform = `translate3d(${this.offset}px, 0, 0)`;
if(this.spineElement.style.transform === newTransform) return;
this.transform = newTransform;
this.updateSpineStyle(true, fast);
this.deselect();
});
});
} else {
const newTransform = `translate3d(${this.offset}px, 0, 0)`;
if(this.spineElement.style.transform === newTransform) return;
this.transform = newTransform;
this.updateSpineStyle(false);
this.deselect();
}
}
bounce(rtl = false) {
requestAnimationFrame(() => {
this.transform = `translate3d(${this.offset+(50 * (rtl ? 1 : -1))}px, 0, 0)`;
this.updateSpineStyle(true, true);
setTimeout(() => {
this.transform = `translate3d(${this.offset}px, 0, 0)`;
this.updateSpineStyle(true, true);
}, 100);
});
}
/**
* Go to next slide.
* @param {number} [howManySlides=1] - How many items to slide forward.
* @returns {boolean} Whether or not going to next was possible
*/
next(howManySlides = 1): boolean {
// early return when there is nothing to slide
if (this.slength <= this.perPage) {
return false;
}
const beforeChange = this.currentSlide;
this.currentSlide = Math.min(this.currentSlide + howManySlides, this.length - 1);
if(this.perPage > 1 && this.currentSlide % 2)
this.currentSlide--;
if(this.currentSlide === beforeChange && this.currentSlide + 1 === this.length) {
// At end and trying to go further, means trigger "last page" callback
// this.onLastPage();
}
if (beforeChange !== this.currentSlide) {
this.slideToCurrent(true);
this.onChange();
return true;
} else {
this.bounce(this.rtl);
return false;
}
}
/**
* Go to previous slide.
* @param {number} [howManySlides=1] - How many items to slide backward.
* @returns {boolean} Whether or not going to prev was possible
*/
prev(howManySlides = 1): boolean {
// early return when there is nothing to slide
if (this.slength <= this.perPage) {
return false;
}
const beforeChange = this.currentSlide;
this.currentSlide = Math.max(this.currentSlide - howManySlides, 0);
if(this.perPage > 1 && this.currentSlide % 2)
this.currentSlide++;
if (beforeChange !== this.currentSlide) {
this.slideToCurrent(true);
this.onChange();
return true;
} else
this.bounce(!this.rtl);
return false;
}
get ownerWindow() {
return this.container.ownerDocument.defaultView || window;
}
// OLD
async destroy() {
// Wait for all in-progress loads to complete
let iit = this.inprogress.values();
let inp = iit.next();
const inprogressPromises: Promise<void>[] = [];
while(inp.value) {
inprogressPromises.push(inp.value);
inp = iit.next();
}
if(inprogressPromises.length > 0) {
await Promise.allSettled(inprogressPromises);
}
this.inprogress.clear();
// Destroy all frames
let fit = this.pool.values();
let frm = fit.next();
while(frm.value) {
await (frm.value as FXLFrameManager).destroy();
frm = fit.next();
}
this.pool.clear();
// Revoke all blobs
this.blobs.forEach(v => URL.revokeObjectURL(v));
// Empty container of elements
this.container.childNodes.forEach(v => {
if(v.nodeType === Node.ELEMENT_NODE || v.nodeType === Node.TEXT_NODE) v.remove();
})
}
makeSpread(itemIndex: number) {
return this.perPage < 2 ? [this.pub.readingOrder.items[itemIndex]] : this.spreader.currentSpread(itemIndex, this.perPage);
}
reAlign(index: number = this.currentSlide) {
if (index % 2 && !this.single) // Prevent getting out of track
index++;
return index;
}
spreadPosition(spread: Link[], target: Link) {
return this.perPage < 2 ? Page.center : (spread.length < 2 ? Page.center : (
target.href === spread[0].href ? (this.rtl ? Page.right : Page.left) : (this.rtl ? Page.left : Page.right)
));
}
async waitForItem(href: string) {
if(this.inprogress.has(href))
// If this same href is already being loaded, block until the other function
// call has finished executing so we don't end up e.g. loading the blob twice.
await this.inprogress.get(href);
if(this.delayedShow.has(href)) {
const timeoutVal = this.delayedTimeout.get(href)!;
if(timeoutVal > 0) {
// Delayed resource showing has not yet commenced, cancel it
clearTimeout(timeoutVal);
} else {
// Await a current delayed showing of the resource
await this.delayedShow.get(href);
}
this.delayedTimeout.set(href, 0);
this.delayedShow.delete(href);
}
}
async cancelShowing(href: string) {
if(this.delayedShow.has(href)) {
const timeoutVal = this.delayedTimeout.get(href)!;
if(timeoutVal > 0) {
// Delayed resource showing has not yet commenced, cancel it
clearTimeout(timeoutVal);
}
this.delayedShow.delete(href);
}
}
async update(pub: Publication, locator: Locator, modules: ModuleName[], _force=false) {
let i = this.pub.readingOrder.items.findIndex(l => l.href === locator.href);
if(i < 0) throw Error("Href not found in reading order");
if(this.currentSlide !== i) {
this.currentSlide = this.reAlign(i);
this.slideToCurrent(true);
}
const spread = this.makeSpread(this.currentSlide);
if(this.perPage > 1) i++;
for (const s of spread) {
await this.waitForItem(s.href);
}
// Create a new progress that doesn't resolve until complete
// loading of the resource and its dependencies has finished.
const progressPromise = new Promise<void>(async (resolve, reject) => {
const disposal: string[] = [];
const creation: string[] = [];
this.positions.forEach((l, j) => {
if(j > (i + UPPER_BOUNDARY) || j < (i - UPPER_BOUNDARY)) {
if(!disposal.includes(l.href)) disposal.push(l.href);
}
if(j < (i + LOWER_BOUNDARY) && j > (i - LOWER_BOUNDARY)) {
if(!creation.includes(l.href)) creation.push(l.href);
}
});
disposal.forEach(async href => {
if(creation.includes(href)) return;
if(!this.pool.has(href)) return;
this.cancelShowing(href);
await this.pool.get(href)?.unload();
// this.pool.delete(href);
});
// Check if base URL of publication has changed
if(this.currentBaseURL !== undefined && pub.baseURL !== this.currentBaseURL) {
// Revoke all blobs
this.blobs.forEach(v => URL.revokeObjectURL(v));
this.blobs.clear();
}
this.currentBaseURL = pub.baseURL;
const creator = async (href: string) => {
const index = pub.readingOrder.findIndexWithHref(href);
const itm = pub.readingOrder.items[index];
if(!itm) return; // TODO throw?
if(!this.blobs.has(href)) {
const blobBuilder = new FrameBlobBuider(pub, this.currentBaseURL || "", itm);
const blobURL = await blobBuilder.build(true);
this.blobs.set(href, blobURL);
}
// Show future offscreen frame in advance after a delay
// The added delay prevents this expensive operation from
// occuring during the sliding animation, to reduce lag
if(!this.delayedShow.has(href))
this.delayedShow.set(href, new Promise((resolve, reject) => {
let done = false;
const t = window.setTimeout(async () => {
this.delayedTimeout.set(href, 0);
const spread = this.makeSpread(this.reAlign(index));
const page = this.spreadPosition(spread, itm);
const fm = this.pool.get(href)!;
await fm.load(modules, this.blobs.get(href)!);
if(!this.peripherals.isScaled) // When scaled, positioning is screwed up, so wait to show
await fm.show(page); // Show/activate new frame
this.delayedShow.delete(href);
done = true;
resolve();
}, OFFSCREEN_LOAD_DELAY);
setTimeout(() => {
if(!done && this.delayedShow.has(href)) reject(`Offscreen load timeout: ${href}`);
}, OFFSCREEN_LOAD_TIMEOUT);
this.delayedTimeout.set(href, t);
}));
}
try {
await Promise.all(creation.map(href => creator(href)));
} catch (error) {
reject(error);
}
// Update current frame(s)
const newFrames: FXLFrameManager[] = [];
for (const s of spread) {
const newFrame = this.pool.get(s.href)!;
const source = this.blobs.get(s.href);
if(!source) continue; // This can get destroyed
this.cancelShowing(s.href);
await newFrame.load(modules, source); // In order to ensure modules match the latest configuration
await newFrame.show(this.spreadPosition(spread, s)); // Show/activate new frame
this.previousFrames.push(newFrame);
await newFrame.activate();
newFrames.push(newFrame);
}
// Unfocus previous frame(s)
while(this.previousFrames.length > 0) {
const fm = this.previousFrames.shift();
if(fm && !newFrames.includes(fm))
await fm.unfocus();
}
this.previousFrames = newFrames;
resolve();
});
for (const s of spread) {
this.inprogress.set(s.href, progressPromise); // Add the job to the in progress map
}
await progressPromise; // Wait on the job to finish...
for (const s of spread) {
this.inprogress.delete(s.href); // Delete it from the in progress map!
}
}
get currentFrames(): (FXLFrameManager | undefined)[] {
if(this.perPage < 2) {
const link = this.pub.readingOrder.items[this.currentSlide];
return [this.pool.get(link.href)];
}
const spread = this.spreader.currentSpread(this.currentSlide, this.perPage);
return spread.map(s => this.pool.get(s.href));
}
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.currentFrames.forEach(f => {
if(!f) return;
const b = f.realSize;
ret.x = Math.min(ret.x, b.x);
ret.y = Math.min(ret.y, b.y);
ret.width += b.width; // TODO different in vertical
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 currentSpread = this.spreader.currentSpread(this.currentSlide, this.perPage);
currentSpread.forEach(link => {
viewport.readingOrder.push(link.href);
viewport.progressions.set(link.href, { start: 0, end: 1 }); // FXL always uses [0,1] progression
});
// Set positions using currentNumbers
viewport.positions = this.getCurrentNumbers();
return viewport;
}
private getCurrentNumbers(): number[] {
if(this.perPage < 2) {
const link = this.pub.readingOrder.items[this.currentSlide];
return [link.properties?.otherProperties["number"]];
}
const spread = this.spreader.currentSpread(this.currentSlide, this.perPage);
return spread.length > 1 ? [
spread[0].properties?.otherProperties["number"] as number,
spread[spread.length-1].properties?.otherProperties["number"] as number
] : [spread[0].properties?.otherProperties["number"] as number];
}
deselect() {
this.currentFrames?.forEach(f => f?.deselect());
}
}