diff --git a/src/BookReader.js b/src/BookReader.js index c63d0835a..67eb62db8 100644 --- a/src/BookReader.js +++ b/src/BookReader.js @@ -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), }); /** @@ -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', }); }; diff --git a/src/BookReader/ImageCache.js b/src/BookReader/ImageCache.js index 6dc7e0e02..d9ed402e8 100644 --- a/src/BookReader/ImageCache.js +++ b/src/BookReader/ImageCache.js @@ -7,8 +7,10 @@ /** @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 { /** @@ -16,11 +18,25 @@ export class ImageCache { * @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; @@ -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); } } @@ -87,26 +119,27 @@ export class ImageCache { * * @param {PageIndex} index * @param {number} reduce + * @param {HTMLImageElement?} [img] * @returns {JQuery} 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 = $('', { - '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); diff --git a/src/BookReader/PageContainer.js b/src/BookReader/PageContainer.js index 0293b2711..bec7d714e 100644 --- a/src/BookReader/PageContainer.js +++ b/src/BookReader/PageContainer.js @@ -2,6 +2,8 @@ /** @typedef {import('./BookModel.js').PageModel} PageModel */ /** @typedef {import('./ImageCache.js').ImageCache} ImageCache */ +import { sleep } from './utils.js'; + export class PageContainer { /** @@ -9,12 +11,10 @@ export class PageContainer { * @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 = $('
', { 'class': `BRpagecontainer ${page ? `pagediv${page.index}` : 'BRemptypage'}`, css: { position: 'absolute' }, @@ -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 , 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; } } diff --git a/src/BookReader/options.js b/src/BookReader/options.js index 4d56b0009..8222ca42f 100644 --- a/src/BookReader/options.js +++ b/src/BookReader/options.js @@ -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} + * 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 diff --git a/src/css/_BRpages.scss b/src/css/_BRpages.scss index c0b8abbd0..b16ef8927 100644 --- a/src/css/_BRpages.scss +++ b/src/css/_BRpages.scss @@ -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; diff --git a/tests/jest/BookReader/PageContainer.test.js b/tests/jest/BookReader/PageContainer.test.js index c95e656e8..e1466d904 100644 --- a/tests/jest/BookReader/PageContainer.test.js +++ b/tests/jest/BookReader/PageContainer.test.js @@ -1,4 +1,37 @@ import {PageContainer, boxToSVGRect, createSVGPageLayer, renderBoxesInPageContainerLayer} from '@/src/BookReader/PageContainer.js'; +import {ImageCache} from '@/src/BookReader/ImageCache.js'; +import {BookModel} from '@/src/BookReader/BookModel.js'; +import { afterEventLoop } from '../utils.js'; +import sinon from 'sinon'; +/** @typedef {import('@/src/BookReader/options.js').BookReaderOptions} BookReaderOptions */ + +const SAMPLE_BOOK = new BookModel( + { + data: [ + [ + { width: 123, height: 123, uri: 'https://archive.org/image0.jpg', pageNum: '1' }, + ], + [ + { width: 123, height: 123, uri: 'https://archive.org/image1.jpg', pageNum: '2' }, + { width: 123, height: 123, uri: 'https://archive.org/image2.jpg', pageNum: '3' }, + ], + [ + { width: 123, height: 123, uri: 'https://archive.org/image3.jpg', pageNum: '4' }, + { width: 123, height: 123, uri: 'https://archive.org/image4.jpg', pageNum: '5' }, + ], + [ + { width: 123, height: 123, uri: 'https://archive.org/image5.jpg', pageNum: '6' }, + ], + ] + } +); + +const realGetPageURI = SAMPLE_BOOK.getPageURI; +SAMPLE_BOOK.getPageURI = function (index, reduce, rotate) { + // Need to add a reduce url parameter, since the src is used + // for caching + return realGetPageURI.call(SAMPLE_BOOK, index, reduce, rotate) + `?reduce=${reduce}`; +}; describe('constructor', () => { test('protected books', () => { @@ -33,13 +66,15 @@ describe('constructor', () => { describe('update', () => { test('dimensions sets CSS', () => { - const pc = new PageContainer(null, {}); + const imageCache = new ImageCache(SAMPLE_BOOK); + const pc = new PageContainer(null, {imageCache}); pc.update({ dimensions: { left: 20 } }); expect(pc.$container[0].style.left).toBe('20px'); }); test('does not create image if empty page', () => { - const pc = new PageContainer(null, {}); + const imageCache = new ImageCache(SAMPLE_BOOK); + const pc = new PageContainer(null, {imageCache}); pc.update({ reduce: null }); expect(pc.$img).toBeNull(); pc.update({ reduce: 7 }); @@ -47,71 +82,77 @@ describe('update', () => { }); test('does not create image if no reduce', () => { - const pc = new PageContainer({index: 17}, {}); + const imageCache = new ImageCache(SAMPLE_BOOK); + const pc = new PageContainer(SAMPLE_BOOK.getPage(3), {imageCache}); pc.update({ reduce: null }); expect(pc.$img).toBeNull(); }); - test('does not set background image if already loaded', () => { - const fakeImageCache = { - imageLoaded: () => true, - image: () => $(''), - }; - const pc = new PageContainer({index: 12}, {imageCache: fakeImageCache}); - pc.update({ reduce: 7 }); - expect(pc.$img[0].style.background).toBe(''); + test('loads image on initial load', () => { + const imageCache = new ImageCache(SAMPLE_BOOK); + const pc = new PageContainer(SAMPLE_BOOK.getPage(3), {imageCache}); + pc.update({ reduce: 7 }); // This will load reduce=8 into memory + expect(pc.$container.hasClass('BRpageloading')).toBe(true); + expect(pc.$container.children('.BRpageimage').length).toBe(1); + pc.$img.trigger('load'); + expect(pc.$container.hasClass('BRpageloading')).toBe(false); + expect(pc.$container.children('.BRpageimage').length).toBe(1); }); - test('removes image between updates only if changed', () => { - const fakeImageCache = { - imageLoaded: () => true, - image: (index, reduce) => $(``), - }; - const pc = new PageContainer({index: 12}, {imageCache: fakeImageCache}); - pc.update({ reduce: 7 }); - const $im1 = pc.$img; - pc.update({ reduce: 7 }); - expect(pc.$img).toBe($im1); - pc.update({ reduce: 16 }); - expect(pc.$img).not.toBe($im1); - expect($im1.parent().length).toBe(0); - }); - - test('adds/removes loading indicators while loading', () => { - const fakeImageCache = { - imageLoaded: () => false, - image: () => $(''), - getBestLoadedReduce: () => undefined, - }; - const pc = new PageContainer({index: 12}, {imageCache: fakeImageCache, loadingImage: 'loading.gif'}); - pc.update({ reduce: 7 }); - expect(pc.$container.hasClass('BRpageloading')).toBe(true); - // See https://github.com/jsdom/jsdom/issues/3169 - // expect(pc.$img.css('background')).toBeTruthy(); - // expect(pc.$img.css('background').includes('loading.gif')).toBe(true); - // expect(pc.$img.css('background').includes(',')).toBe(false); + test('does not set loading class if already loaded', () => { + const imageCache = new ImageCache(SAMPLE_BOOK); + const pc = new PageContainer(SAMPLE_BOOK.getPage(3), {imageCache}); + pc.update({ reduce: 7 }); // This will load reduce=8 into memory + pc.$img.trigger('load'); + pc.update({ reduce: 6 }); // This will still load reduce=8 + expect(pc.$container.hasClass('BRpageloading')).toBe(false); + expect(pc.$container.children('.BRpageimage').length).toBe(1); + }); + + test('removes image between updates only if changed', async () => { + const clock = sinon.useFakeTimers(); + const imageCache = new ImageCache(SAMPLE_BOOK); + const pc = new PageContainer(SAMPLE_BOOK.getPage(3), {imageCache}); - pc.$img.trigger('loadend'); + // load reduce=8 + pc.update({ reduce: 7 }); + pc.$img.trigger('load'); + const img1 = pc.$img[0]; + + // Should not create a new image; same final reduce + pc.update({ reduce: 6 }); + expect(pc.$img[0]).toBe(img1); + expect(pc.$container.children('.BRpageimage').length).toBe(1); + + // Should create a new image; different reduce + pc.update({ reduce: 3 }); + expect(pc.$img[0]).not.toBe(img1); + expect(pc.$container.children('.BRpageimage').length).toBe(2); + + pc.$img.trigger('load'); + // After loading we remove the old image; but not immediately! + expect(pc.$container.children('.BRpageimage').length).toBe(2); expect(pc.$container.hasClass('BRpageloading')).toBe(false); - expect(pc.$img.css('background')).toBeFalsy(); + // increment time clock 100ms + clock.tick(100); + // wait for promises to resolve + clock.restore(); + await afterEventLoop(); + // NOW we remove the old image + expect(pc.$container.children('.BRpageimage').length).toBe(1); }); test('shows lower res image while loading if one available', () => { - const fakeImageCache = { - imageLoaded: () => false, - image: () => $(''), - getBestLoadedReduce: () => 3, - }; - const fakePage = { - index: 12, - getURI: () => 'page12.jpg', - }; - const pc = new PageContainer(fakePage, {imageCache: fakeImageCache, loadingImage: 'loading.gif'}); + const imageCache = new ImageCache(SAMPLE_BOOK); + const pc = new PageContainer(SAMPLE_BOOK.getPage(3), {imageCache}); pc.update({ reduce: 7 }); - // See https://github.com/jsdom/jsdom/issues/3169 - // expect(pc.$img.css('background').includes('page12.jpg')).toBe(true); - pc.$img.trigger('loadend'); - expect(pc.$img.css('background')).toBeFalsy(); + pc.$img.trigger('load'); + + pc.update({reduce: 2}); + expect(pc.$container.hasClass('BRpageloading')).toBe(true); + expect(pc.$container.children('.BRpageimage').length).toBe(2); + expect(pc.$container.children('.BRpageimage')[0].src).toContain('reduce=4'); + expect(pc.$img[0].src).toContain('reduce=2'); }); });