UNPKG

@readium/navigator

Version:

Next generation SDK for publications in Web Apps

237 lines (213 loc) 10.2 kB
import { ModuleName } from "@readium/navigator-html-injectables"; import { Locator, Publication } from "@readium/shared"; import FrameBlobBuider from "./FrameBlobBuilder"; import { FrameManager } from "./FrameManager"; const UPPER_BOUNDARY = 5; const LOWER_BOUNDARY = 3; export class FramePoolManager { private readonly container: HTMLElement; private readonly positions: Locator[]; private _currentFrame: FrameManager | undefined; private currentCssProperties: { [key: string]: string } | undefined; private readonly pool: Map<string, FrameManager> = new Map(); private readonly blobs: Map<string, string> = new Map(); private readonly inprogress: Map<string, Promise<void>> = new Map(); private pendingUpdates: Map<string, { inPool: boolean }> = new Map(); private currentBaseURL: string | undefined; constructor(container: HTMLElement, positions: Locator[], cssProperties?: { [key: string]: string }) { this.container = container; this.positions = positions; this.currentCssProperties = cssProperties; } 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 FrameManager).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(); }) } async update(pub: Publication, locator: Locator, modules: ModuleName[], force=false) { 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<number>((acc, l) => l.locations.position || 0 > acc ? l.locations.position || 0 : acc, 0) }`); const newHref = this.positions[i].href; if(this.inprogress.has(newHref)) // 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(newHref); // 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; await this.pool.get(href)?.destroy(); this.pool.delete(href); if(this.pendingUpdates.has(href)) this.pendingUpdates.set(href, { inPool: false }); }); // 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) => { 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 => 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) { URL.revokeObjectURL(url); this.blobs.delete(href); this.pendingUpdates.delete(href); } } if(this.pool.has(href)) { const fm = this.pool.get(href)!; if(!this.blobs.has(href)) { await fm.destroy(); this.pool.delete(href); this.pendingUpdates.delete(href); } else { await fm.load(modules); return; } } const itm = pub.readingOrder.findWithHref(href); if(!itm) return; // TODO throw? if(!this.blobs.has(href)) { const blobBuilder = new FrameBlobBuider(pub, this.currentBaseURL || "", itm, this.currentCssProperties); const blobURL = await blobBuilder.build(); this.blobs.set(href, blobURL); } // Create <iframe> const fm = new FrameManager(this.blobs.get(href)!); if(href !== newHref) await fm.hide(); // Avoid unecessary hide this.container.appendChild(fm.iframe); await fm.load(modules); this.pool.set(href, fm); } try { await Promise.all(creation.map(href => creator(href))); } catch (error) { reject(error); } // Update current frame const newFrame = this.pool.get(newHref)!; if(newFrame?.source !== this._currentFrame?.source || force) { await this._currentFrame?.hide(); // Hide current frame. It's possible it no longer even exists in the DOM at this point if(newFrame) // If user is speeding through the publication, this can get destroyed await newFrame.load(modules); // In order to ensure modules match the latest configuration // Update progression if necessary and show the new frame if(newFrame) // If user is speeding through the publication, this can get destroyed await newFrame.show(locator.locations.progression); // Show/activate new frame this._currentFrame = newFrame; } resolve(); }); this.inprogress.set(newHref, progressPromise); // Add the job to the in progress map await progressPromise; // Wait on the job to finish... this.inprogress.delete(newHref); // Delete it from the in progress map! } setCSSProperties(properties: { [key: string]: string }) { const deepCompare = (obj1: { [key: string]: string }, obj2: { [key: string]: string }) => { const keys1 = Object.keys(obj1); const keys2 = Object.keys(obj2); if (keys1.length !== keys2.length) { return false; } for (const key of keys1) { if (obj1[key] !== obj2[key]) { return false; } } return true; }; // If CSSProperties have changed, we update the currentCssProperties, // and set the CSS Properties to all frames already in the pool // We also need to invalidate the blobs and recreate them with the new properties. // We do that in update, by updating them when needed (they are added into the pool) // so that we do not invalidate and recreate blobs over and over again. if(!deepCompare(this.currentCssProperties || {}, properties)) { this.currentCssProperties = properties; this.pool.forEach((frame) => { frame.setCSSProperties(properties); }); for (const href of this.blobs.keys()) { this.pendingUpdates.set(href, { inPool: this.pool.has(href) }); } } } get currentFrames(): (FrameManager | undefined)[] { return [this._currentFrame]; } 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; } }