Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new renderPageURI option #1334

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/BookReader.js
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ BookReader.prototype.setup = function(options) {
this.imageCache = new ImageCache(this.book, {
useSrcSet: this.options.useSrcSet,
reduceSet: this.reduceSet,
renderPageURI: options.renderPageURI.bind(this),
});

/**
Expand Down Expand Up @@ -778,7 +779,6 @@ BookReader.prototype._createPageContainer = function(index) {
return new PageContainer(this.book.getPage(index, false), {
isProtected: this.protected,
imageCache: this.imageCache,
loadingImage: this.imagesBaseURL + 'loading.gif',
});
};

Expand Down
63 changes: 48 additions & 15 deletions src/BookReader/ImageCache.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,36 @@
/** @typedef {import("./BookModel").BookModel} BookModel */
/** @typedef {import("./BookModel").PageIndex} PageIndex */
/** @typedef {import("./ReduceSet").ReduceSet} ReduceSet */
/** @typedef {import("./options").BookReaderOptions} BookReaderOptions */

import { Pow2ReduceSet } from "./ReduceSet";
import { DEFAULT_OPTIONS } from "./options";

export class ImageCache {
/**
* @param {BookModel} book
* @param {object} opts
* @param {boolean} [opts.useSrcSet]
* @param {ReduceSet} [opts.reduceSet]
* @param {BookReaderOptions['renderPageURI']} [opts.renderPageURI]
*/
constructor(book, { useSrcSet = false, reduceSet = Pow2ReduceSet } = {}) {
constructor(
book,
{
useSrcSet = false,
reduceSet = Pow2ReduceSet,
renderPageURI = DEFAULT_OPTIONS.renderPageURI,
} = {}
) {
/** @type {BookModel} */
this.book = book;
/** @type {boolean} */
this.useSrcSet = useSrcSet;
/** @type {ReduceSet} */
this.reduceSet = reduceSet;
/** @type {BookReaderOptions['renderPageURI']} */
this.renderPageURI = renderPageURI;

/** @type {{ [index: number]: { reduce: number, loaded: boolean }[] }} */
this.cache = {};
this.defaultScale = 8;
Expand All @@ -33,19 +49,35 @@ export class ImageCache {
*
* @param {PageIndex} index
* @param {Number} reduce
* @param {HTMLImageElement?} [img]
*/
image(index, reduce) {
image(index, reduce, img = null) {
const finalReduce = this.getFinalReduce(index, reduce);
return this._serveImageElement(index, finalReduce, img);
}

/**
* Get the final reduce factor to use for the given index
*
* @param {PageIndex} index
* @param {Number} reduce
*/
getFinalReduce(index, reduce) {
const cachedImages = this.cache[index] || [];
const sufficientImages = cachedImages
.filter(x => x.loaded && x.reduce <= reduce);

if (sufficientImages.length) {
// Choose the largest reduction factor that meets our needs
const bestReduce = Math.max(...sufficientImages.map(e => e.reduce));
return this._serveImageElement(index, bestReduce);
// Don't need to floor here, since we know the image is in the cache
// and hence was already floor'd by the below `else` clause before
// it was added
return bestReduce;
} else {
// Don't use a cache entry; i.e. a fresh fetch will be made
// for this reduce
return this._serveImageElement(index, reduce);
return this.reduceSet.floor(reduce);
}
}

Expand Down Expand Up @@ -87,26 +119,27 @@ export class ImageCache {
*
* @param {PageIndex} index
* @param {number} reduce
* @param {HTMLImageElement?} [img]
* @returns {JQuery<HTMLImageElement>} with base image classes
*/
_serveImageElement(index, reduce) {
const validReduce = this.reduceSet.floor(reduce);
let cacheEntry = this.cache[index]?.find(e => e.reduce == validReduce);
_serveImageElement(index, reduce, img = null) {
let cacheEntry = this.cache[index]?.find(e => e.reduce == reduce);
if (!cacheEntry) {
cacheEntry = { reduce: validReduce, loaded: false };
cacheEntry = { reduce, loaded: false };
const entries = this.cache[index] || (this.cache[index] = []);
entries.push(cacheEntry);
}
const page = this.book.getPage(index);

const $img = $('<img />', {
'class': 'BRpageimage',
'alt': 'Book page image',
src: page.getURI(validReduce, 0),
})
.data('reduce', validReduce);
const uri = page.getURI(reduce, 0);
const $img = $(img || document.createElement('img'))
.addClass('BRpageimage')
.attr('alt', 'Book page image')
.data('reduce', reduce)
.data('src', uri);
this.renderPageURI($img[0], uri);
if (this.useSrcSet) {
$img.attr('srcset', page.getURISrcSet(validReduce));
$img.attr('srcset', page.getURISrcSet(reduce));
}
if (!cacheEntry.loaded) {
$img.one('load', () => cacheEntry.loaded = true);
Expand Down
63 changes: 41 additions & 22 deletions src/BookReader/PageContainer.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,19 @@
/** @typedef {import('./BookModel.js').PageModel} PageModel */
/** @typedef {import('./ImageCache.js').ImageCache} ImageCache */

import { sleep } from './utils.js';


export class PageContainer {
/**
* @param {PageModel} page
* @param {object} opts
* @param {boolean} opts.isProtected Whether we're in a protected book
* @param {ImageCache} opts.imageCache
* @param {string} opts.loadingImage
*/
constructor(page, {isProtected, imageCache, loadingImage}) {
constructor(page, {isProtected, imageCache}) {
this.page = page;
this.imageCache = imageCache;
this.loadingImage = loadingImage;
this.$container = $('<div />', {
'class': `BRpagecontainer ${page ? `pagediv${page.index}` : 'BRemptypage'}`,
css: { position: 'absolute' },
Expand Down Expand Up @@ -43,39 +43,58 @@ export class PageContainer {
return;
}

const alreadyLoaded = this.imageCache.imageLoaded(this.page.index, reduce);
const nextBestLoadedReduce = !alreadyLoaded && this.imageCache.getBestLoadedReduce(this.page.index, reduce);
const finalReduce = this.imageCache.getFinalReduce(this.page.index, reduce);
const newImageURI = this.page.getURI(finalReduce, 0);

// Create high res image
const $newImg = this.imageCache.image(this.page.index, reduce);
// Note: These must be computed _before_ we call .image()
const alreadyLoaded = this.imageCache.imageLoaded(this.page.index, finalReduce);
const nextBestLoadedReduce = this.imageCache.getBestLoadedReduce(this.page.index, reduce);

// Avoid removing/re-adding the image if it's already there
// This can be called quite a bit, so we need to be fast
if (this.$img?.[0].src == $newImg[0].src) {
if (this.$img?.data('src') == newImageURI) {
return this;
}

this.$img?.remove();
this.$img = $newImg.prependTo(this.$container);
let $oldImg = this.$img;
this.$img = this.imageCache.image(this.page.index, finalReduce);
if ($oldImg) {
this.$img.insertAfter($oldImg);
} else {
this.$img.prependTo(this.$container);
}

const backgroundLayers = [];
if (!alreadyLoaded) {
this.$container.addClass('BRpageloading');
backgroundLayers.push(`url("${this.loadingImage}") center/20px no-repeat`);
}
if (nextBestLoadedReduce) {
backgroundLayers.push(`url("${this.page.getURI(nextBestLoadedReduce, 0)}") center/100% 100% no-repeat`);
}

if (!alreadyLoaded) {
this.$img
.css('background', backgroundLayers.join(','))
.one('loadend', async (ev) => {
$(ev.target).css({ 'background': '' });
$(ev.target).parent().removeClass('BRpageloading');
});
if (!alreadyLoaded && nextBestLoadedReduce) {
// If we have a slightly lower quality image loaded, use that as the background
// while the higher res one loads
const nextBestUri = this.page.getURI(nextBestLoadedReduce, 0);
if ($oldImg) {
if ($oldImg.data('src') == nextBestUri) {
// Do nothing! It's already showing the right thing
} else {
// We have a different src, need to update the src
this.imageCache.image(this.page.index, nextBestLoadedReduce, $oldImg[0]);
}
} else {
// We don't have an old <img>, so we need to create a new one
$oldImg = this.imageCache.image(this.page.index, nextBestLoadedReduce);
$oldImg.prependTo(this.$container);
}
}

this.$img
.one('load', async (ev) => {
this.$container.removeClass('BRpageloading');
// `load` can fire a little early, so wait a spell before removing the old image
// to avoid flicker
await sleep(100);
$oldImg?.remove();
});

return this;
}
}
Expand Down
17 changes: 15 additions & 2 deletions src/BookReader/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,16 +183,29 @@ export const DEFAULT_OPTIONS = {
/** @type {import('../plugins/plugin.chapters.js').TocEntry[]} */
table_of_contents: null,

/** Advanced methods for page rendering */
/**
* Advanced methods for page rendering.
* All option functions have their `this` object set to the BookReader instance.
**/

/** @type {() => number} */
getNumLeafs: null,
/** @type {(index: number) => number} */
getPageWidth: null,
/** @type {(index: number) => number} */
getPageHeight: null,
/** @type {(index: number, reduce: number, rotate: number) => *} */
/** @type {(index: number, reduce: number, rotate: number) => string} */
getPageURI: null,

/**
* @type {(img: HTMLImageElement, uri: string) => Promise<void>}
* Render the page URI into the image element. Perform any necessary preloading,
* authentication, etc.
*/
renderPageURI(img, uri) {
img.src = uri;
},

/**
* @type {(index: number) => 'L' | 'R'}
* Return which side, left or right, that a given page should be displayed on
Expand Down
23 changes: 21 additions & 2 deletions src/css/_BRpages.scss
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,28 @@
left: 0;
z-index: 1;
}
&.BRpageloading img {
&.BRpageloading {
// Don't show the alt text while loading
color: transparent;
img {
color: transparent;
}

// src can be set async, so hide the image if it's not set
img:not([src]) {
display: none;
}

&::after {
display: block;
content: "";
width: 20px;
height: 20px;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
background: url("images/loading.gif") center/20px no-repeat;
}
}
&.BRemptypage {
background: transparent;
Expand Down
Loading
Loading