@internetarchive/bookreader 5.0.0-88 → 5.0.0-89

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/BookReader/BookReader.css +14 -0
  2. package/BookReader/BookReader.js +1 -1
  3. package/BookReader/BookReader.js.map +1 -1
  4. package/BookReader/plugins/plugin.archive_analytics.js +1 -1
  5. package/BookReader/plugins/plugin.archive_analytics.js.map +1 -1
  6. package/BookReader/plugins/plugin.autoplay.js +1 -1
  7. package/BookReader/plugins/plugin.autoplay.js.map +1 -1
  8. package/BookReader/plugins/plugin.iiif.js +1 -1
  9. package/BookReader/plugins/plugin.iiif.js.map +1 -1
  10. package/BookReader/plugins/plugin.resume.js +1 -1
  11. package/BookReader/plugins/plugin.resume.js.map +1 -1
  12. package/BookReader/plugins/plugin.text_selection.js +1 -1
  13. package/BookReader/plugins/plugin.text_selection.js.map +1 -1
  14. package/BookReader/plugins/plugin.tts.js +1 -1
  15. package/BookReader/plugins/plugin.tts.js.map +1 -1
  16. package/BookReader/plugins/plugin.url.js +1 -1
  17. package/BookReader/plugins/plugin.url.js.map +1 -1
  18. package/CHANGELOG.md +10 -0
  19. package/codecov.yml +1 -1
  20. package/package.json +1 -1
  21. package/src/BookReader/ImageCache.js +48 -15
  22. package/src/BookReader/Mode2UpLit.js +3 -2
  23. package/src/BookReader/PageContainer.js +41 -22
  24. package/src/BookReader/options.js +24 -3
  25. package/src/BookReader/utils.js +10 -0
  26. package/src/BookReader.js +89 -38
  27. package/src/BookReaderPlugin.js +16 -0
  28. package/src/css/_BRpages.scss +21 -2
  29. package/src/plugins/plugin.autoplay.js +98 -102
  30. package/src/plugins/plugin.iiif.js +16 -30
  31. package/src/plugins/plugin.resume.js +54 -51
  32. package/src/plugins/plugin.text_selection.js +68 -76
  33. package/src/plugins/tts/AbstractTTSEngine.js +2 -4
  34. package/src/plugins/tts/PageChunk.js +5 -9
  35. package/src/plugins/tts/PageChunkIterator.js +3 -5
  36. package/src/plugins/tts/plugin.tts.js +309 -329
  37. package/src/plugins/url/plugin.url.js +1 -1
  38. package/src/util/strings.js +1 -0
  39. package/tests/e2e/autoplay.test.js +8 -5
  40. package/tests/e2e/helpers/base.js +2 -2
  41. package/tests/e2e/helpers/mockSearch.js +6 -9
  42. package/tests/jest/BookReader/PageContainer.test.js +96 -55
  43. package/tests/jest/BookReader/utils.test.js +21 -0
  44. package/tests/jest/BookReader.test.js +13 -12
  45. package/tests/jest/plugins/plugin.autoplay.test.js +9 -22
  46. package/tests/jest/plugins/plugin.resume.test.js +19 -32
  47. package/tests/jest/plugins/plugin.text_selection.test.js +23 -24
@@ -7,8 +7,10 @@
7
7
  /** @typedef {import("./BookModel").BookModel} BookModel */
8
8
  /** @typedef {import("./BookModel").PageIndex} PageIndex */
9
9
  /** @typedef {import("./ReduceSet").ReduceSet} ReduceSet */
10
+ /** @typedef {import("./options").BookReaderOptions} BookReaderOptions */
10
11
 
11
12
  import { Pow2ReduceSet } from "./ReduceSet";
13
+ import { DEFAULT_OPTIONS } from "./options";
12
14
 
13
15
  export class ImageCache {
14
16
  /**
@@ -16,11 +18,25 @@ export class ImageCache {
16
18
  * @param {object} opts
17
19
  * @param {boolean} [opts.useSrcSet]
18
20
  * @param {ReduceSet} [opts.reduceSet]
21
+ * @param {BookReaderOptions['renderPageURI']} [opts.renderPageURI]
19
22
  */
20
- constructor(book, { useSrcSet = false, reduceSet = Pow2ReduceSet } = {}) {
23
+ constructor(
24
+ book,
25
+ {
26
+ useSrcSet = false,
27
+ reduceSet = Pow2ReduceSet,
28
+ renderPageURI = DEFAULT_OPTIONS.renderPageURI,
29
+ } = {},
30
+ ) {
31
+ /** @type {BookModel} */
21
32
  this.book = book;
33
+ /** @type {boolean} */
22
34
  this.useSrcSet = useSrcSet;
35
+ /** @type {ReduceSet} */
23
36
  this.reduceSet = reduceSet;
37
+ /** @type {BookReaderOptions['renderPageURI']} */
38
+ this.renderPageURI = renderPageURI;
39
+
24
40
  /** @type {{ [index: number]: { reduce: number, loaded: boolean }[] }} */
25
41
  this.cache = {};
26
42
  this.defaultScale = 8;
@@ -33,19 +49,35 @@ export class ImageCache {
33
49
  *
34
50
  * @param {PageIndex} index
35
51
  * @param {Number} reduce
52
+ * @param {HTMLImageElement?} [img]
36
53
  */
37
- image(index, reduce) {
54
+ image(index, reduce, img = null) {
55
+ const finalReduce = this.getFinalReduce(index, reduce);
56
+ return this._serveImageElement(index, finalReduce, img);
57
+ }
58
+
59
+ /**
60
+ * Get the final reduce factor to use for the given index
61
+ *
62
+ * @param {PageIndex} index
63
+ * @param {Number} reduce
64
+ */
65
+ getFinalReduce(index, reduce) {
38
66
  const cachedImages = this.cache[index] || [];
39
67
  const sufficientImages = cachedImages
40
68
  .filter(x => x.loaded && x.reduce <= reduce);
69
+
41
70
  if (sufficientImages.length) {
42
71
  // Choose the largest reduction factor that meets our needs
43
72
  const bestReduce = Math.max(...sufficientImages.map(e => e.reduce));
44
- return this._serveImageElement(index, bestReduce);
73
+ // Don't need to floor here, since we know the image is in the cache
74
+ // and hence was already floor'd by the below `else` clause before
75
+ // it was added
76
+ return bestReduce;
45
77
  } else {
46
78
  // Don't use a cache entry; i.e. a fresh fetch will be made
47
79
  // for this reduce
48
- return this._serveImageElement(index, reduce);
80
+ return this.reduceSet.floor(reduce);
49
81
  }
50
82
  }
51
83
 
@@ -87,26 +119,27 @@ export class ImageCache {
87
119
  *
88
120
  * @param {PageIndex} index
89
121
  * @param {number} reduce
122
+ * @param {HTMLImageElement?} [img]
90
123
  * @returns {JQuery<HTMLImageElement>} with base image classes
91
124
  */
92
- _serveImageElement(index, reduce) {
93
- const validReduce = this.reduceSet.floor(reduce);
94
- let cacheEntry = this.cache[index]?.find(e => e.reduce == validReduce);
125
+ _serveImageElement(index, reduce, img = null) {
126
+ let cacheEntry = this.cache[index]?.find(e => e.reduce == reduce);
95
127
  if (!cacheEntry) {
96
- cacheEntry = { reduce: validReduce, loaded: false };
128
+ cacheEntry = { reduce, loaded: false };
97
129
  const entries = this.cache[index] || (this.cache[index] = []);
98
130
  entries.push(cacheEntry);
99
131
  }
100
132
  const page = this.book.getPage(index);
101
133
 
102
- const $img = $('<img />', {
103
- 'class': 'BRpageimage',
104
- 'alt': 'Book page image',
105
- src: page.getURI(validReduce, 0),
106
- })
107
- .data('reduce', validReduce);
134
+ const uri = page.getURI(reduce, 0);
135
+ const $img = $(img || document.createElement('img'))
136
+ .addClass('BRpageimage')
137
+ .attr('alt', 'Book page image')
138
+ .data('reduce', reduce)
139
+ .data('src', uri);
140
+ this.renderPageURI($img[0], uri);
108
141
  if (this.useSrcSet) {
109
- $img.attr('srcset', page.getURISrcSet(validReduce));
142
+ $img.attr('srcset', page.getURISrcSet(reduce));
110
143
  }
111
144
  if (!cacheEntry.loaded) {
112
145
  $img.one('load', () => cacheEntry.loaded = true);
@@ -493,13 +493,14 @@ export class Mode2UpLit extends LitElement {
493
493
  /**
494
494
  * @param {'left' | 'right' | 'next' | 'prev' | PageIndex | PageModel | {left: PageModel | null, right: PageModel | null}} nextSpread
495
495
  */
496
- async flipAnimation(nextSpread, { animate = true } = {}) {
496
+ async flipAnimation(nextSpread, { animate = true, flipSpeed = this.flipSpeed } = {}) {
497
497
  const curSpread = (this.pageLeft || this.pageRight)?.spread;
498
498
  if (!curSpread) {
499
499
  // Nothings been actually rendered yet! Will be corrected during initFirstRender
500
500
  return;
501
501
  }
502
502
 
503
+ flipSpeed = flipSpeed || this.flipSpeed; // Handle null
503
504
  nextSpread = this.parseNextSpread(nextSpread);
504
505
  if (this.activeFlip || !nextSpread) return;
505
506
 
@@ -559,7 +560,7 @@ export class Mode2UpLit extends LitElement {
559
560
 
560
561
  /** @type {KeyframeAnimationOptions} */
561
562
  const animationStyle = {
562
- duration: this.flipSpeed + this.activeFlip.pagesFlippingCount,
563
+ duration: flipSpeed + this.activeFlip.pagesFlippingCount,
563
564
  easing: 'ease-in',
564
565
  fill: 'none',
565
566
  };
@@ -2,6 +2,8 @@
2
2
  /** @typedef {import('./BookModel.js').PageModel} PageModel */
3
3
  /** @typedef {import('./ImageCache.js').ImageCache} ImageCache */
4
4
 
5
+ import { sleep } from './utils.js';
6
+
5
7
 
6
8
  export class PageContainer {
7
9
  /**
@@ -9,12 +11,10 @@ export class PageContainer {
9
11
  * @param {object} opts
10
12
  * @param {boolean} opts.isProtected Whether we're in a protected book
11
13
  * @param {ImageCache} opts.imageCache
12
- * @param {string} opts.loadingImage
13
14
  */
14
- constructor(page, {isProtected, imageCache, loadingImage}) {
15
+ constructor(page, {isProtected, imageCache}) {
15
16
  this.page = page;
16
17
  this.imageCache = imageCache;
17
- this.loadingImage = loadingImage;
18
18
  this.$container = $('<div />', {
19
19
  'class': `BRpagecontainer ${page ? `pagediv${page.index}` : 'BRemptypage'}`,
20
20
  css: { position: 'absolute' },
@@ -43,39 +43,58 @@ export class PageContainer {
43
43
  return;
44
44
  }
45
45
 
46
- const alreadyLoaded = this.imageCache.imageLoaded(this.page.index, reduce);
47
- const nextBestLoadedReduce = !alreadyLoaded && this.imageCache.getBestLoadedReduce(this.page.index, reduce);
46
+ const finalReduce = this.imageCache.getFinalReduce(this.page.index, reduce);
47
+ const newImageURI = this.page.getURI(finalReduce, 0);
48
48
 
49
- // Create high res image
50
- const $newImg = this.imageCache.image(this.page.index, reduce);
49
+ // Note: These must be computed _before_ we call .image()
50
+ const alreadyLoaded = this.imageCache.imageLoaded(this.page.index, finalReduce);
51
+ const nextBestLoadedReduce = this.imageCache.getBestLoadedReduce(this.page.index, reduce);
51
52
 
52
53
  // Avoid removing/re-adding the image if it's already there
53
54
  // This can be called quite a bit, so we need to be fast
54
- if (this.$img?.[0].src == $newImg[0].src) {
55
+ if (this.$img?.data('src') == newImageURI) {
55
56
  return this;
56
57
  }
57
58
 
58
- this.$img?.remove();
59
- this.$img = $newImg.prependTo(this.$container);
59
+ let $oldImg = this.$img;
60
+ this.$img = this.imageCache.image(this.page.index, finalReduce);
61
+ if ($oldImg) {
62
+ this.$img.insertAfter($oldImg);
63
+ } else {
64
+ this.$img.prependTo(this.$container);
65
+ }
60
66
 
61
- const backgroundLayers = [];
62
67
  if (!alreadyLoaded) {
63
68
  this.$container.addClass('BRpageloading');
64
- backgroundLayers.push(`url("${this.loadingImage}") center/20px no-repeat`);
65
- }
66
- if (nextBestLoadedReduce) {
67
- backgroundLayers.push(`url("${this.page.getURI(nextBestLoadedReduce, 0)}") center/100% 100% no-repeat`);
68
69
  }
69
70
 
70
- if (!alreadyLoaded) {
71
- this.$img
72
- .css('background', backgroundLayers.join(','))
73
- .one('loadend', async (ev) => {
74
- $(ev.target).css({ 'background': '' });
75
- $(ev.target).parent().removeClass('BRpageloading');
76
- });
71
+ if (!alreadyLoaded && nextBestLoadedReduce) {
72
+ // If we have a slightly lower quality image loaded, use that as the background
73
+ // while the higher res one loads
74
+ const nextBestUri = this.page.getURI(nextBestLoadedReduce, 0);
75
+ if ($oldImg) {
76
+ if ($oldImg.data('src') == nextBestUri) {
77
+ // Do nothing! It's already showing the right thing
78
+ } else {
79
+ // We have a different src, need to update the src
80
+ this.imageCache.image(this.page.index, nextBestLoadedReduce, $oldImg[0]);
81
+ }
82
+ } else {
83
+ // We don't have an old <img>, so we need to create a new one
84
+ $oldImg = this.imageCache.image(this.page.index, nextBestLoadedReduce);
85
+ $oldImg.prependTo(this.$container);
86
+ }
77
87
  }
78
88
 
89
+ this.$img
90
+ .one('load', async (ev) => {
91
+ this.$container.removeClass('BRpageloading');
92
+ // `load` can fire a little early, so wait a spell before removing the old image
93
+ // to avoid flicker
94
+ await sleep(100);
95
+ $oldImg?.remove();
96
+ });
97
+
79
98
  return this;
80
99
  }
81
100
  }
@@ -143,8 +143,16 @@ export const DEFAULT_OPTIONS = {
143
143
  plugins: {
144
144
  /** @type {import('../plugins/plugin.archive_analytics.js').ArchiveAnalyticsPlugin['options']}*/
145
145
  archiveAnalytics: null,
146
- /** @type {import('../plugins/plugin.text_selection.js').TextSelectionPluginOptions} */
146
+ /** @type {import('../plugins/plugin.autoplay.js').AutoplayPlugin['options']}*/
147
+ autoplay: null,
148
+ /** @type {import('../plugins/plugin.iiif.js').IiifPlugin['options']} */
149
+ iiif: null,
150
+ /** @type {import('../plugins/plugin.resume.js').ResumePlugin['options']} */
151
+ resume: null,
152
+ /** @type {import('../plugins/plugin.text_selection.js').TextSelectionPlugin['options']} */
147
153
  textSelection: null,
154
+ /** @type {import('../plugins/tts/plugin.tts.js').TtsPlugin['options']} */
155
+ tts: null,
148
156
  },
149
157
 
150
158
  /**
@@ -186,16 +194,29 @@ export const DEFAULT_OPTIONS = {
186
194
  /** @type {import('../plugins/plugin.chapters.js').TocEntry[]} */
187
195
  table_of_contents: null,
188
196
 
189
- /** Advanced methods for page rendering */
197
+ /**
198
+ * Advanced methods for page rendering.
199
+ * All option functions have their `this` object set to the BookReader instance.
200
+ **/
201
+
190
202
  /** @type {() => number} */
191
203
  getNumLeafs: null,
192
204
  /** @type {(index: number) => number} */
193
205
  getPageWidth: null,
194
206
  /** @type {(index: number) => number} */
195
207
  getPageHeight: null,
196
- /** @type {(index: number, reduce: number, rotate: number) => *} */
208
+ /** @type {(index: number, reduce: number, rotate: number) => string} */
197
209
  getPageURI: null,
198
210
 
211
+ /**
212
+ * @type {(img: HTMLImageElement, uri: string) => Promise<void>}
213
+ * Render the page URI into the image element. Perform any necessary preloading,
214
+ * authentication, etc.
215
+ */
216
+ renderPageURI(img, uri) {
217
+ img.src = uri;
218
+ },
219
+
199
220
  /**
200
221
  * @type {(index: number) => 'L' | 'R'}
201
222
  * Return which side, left or right, that a given page should be displayed on
@@ -288,3 +288,13 @@ export function promisifyEvent(target, eventType) {
288
288
  export function escapeRegExp(string) {
289
289
  return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
290
290
  }
291
+
292
+ /**
293
+ * @param {number | 'fast' | 'slow' | string} speed
294
+ * Parsing of the jquery animation speed; see https://api.jquery.com/animate/
295
+ */
296
+ export function parseAnimationSpeed(speed) {
297
+ if (speed === 'slow') return 600;
298
+ if (speed === 'fast') return 200;
299
+ return parseInt(speed, 10);
300
+ }
package/src/BookReader.js CHANGED
@@ -68,6 +68,14 @@ BookReader.constModeThumb = 3;
68
68
  BookReader.PLUGINS = {
69
69
  /** @type {typeof import('./plugins/plugin.archive_analytics.js').ArchiveAnalyticsPlugin | null}*/
70
70
  archiveAnalytics: null,
71
+ /** @type {typeof import('./plugins/plugin.autoplay.js').AutoplayPlugin | null}*/
72
+ autoplay: null,
73
+ /** @type {typeof import('./plugins/plugin.resume.js').ResumePlugin | null}*/
74
+ resume: null,
75
+ /** @type {typeof import('./plugins/plugin.text_selection.js').TextSelectionPlugin | null}*/
76
+ textSelection: null,
77
+ /** @type {typeof import('./plugins/tts/plugin.tts.js').TtsPlugin | null}*/
78
+ tts: null,
71
79
  };
72
80
 
73
81
  /**
@@ -108,6 +116,38 @@ BookReader.prototype.setup = function(options) {
108
116
  // Store the options used to setup bookreader
109
117
  this.options = options;
110
118
 
119
+ // Construct the usual plugins first to get type hints
120
+ this._plugins = {
121
+ archiveAnalytics: BookReader.PLUGINS.archiveAnalytics ? new BookReader.PLUGINS.archiveAnalytics(this) : null,
122
+ autoplay: BookReader.PLUGINS.autoplay ? new BookReader.PLUGINS.autoplay(this) : null,
123
+ resume: BookReader.PLUGINS.resume ? new BookReader.PLUGINS.resume(this) : null,
124
+ textSelection: BookReader.PLUGINS.textSelection ? new BookReader.PLUGINS.textSelection(this) : null,
125
+ tts: BookReader.PLUGINS.tts ? new BookReader.PLUGINS.tts(this) : null,
126
+ };
127
+
128
+ // Delete anything that's null
129
+ for (const [pluginName, plugin] of Object.entries(this._plugins)) {
130
+ if (!plugin) delete this._plugins[pluginName];
131
+ }
132
+
133
+ // Now construct the rest of the plugins
134
+ for (const [pluginName, PluginClass] of Object.entries(BookReader.PLUGINS)) {
135
+ if (this._plugins[pluginName] || !PluginClass) continue;
136
+ this._plugins[pluginName] = new PluginClass(this);
137
+ }
138
+
139
+ // And call setup on them
140
+ for (const [pluginName, plugin] of Object.entries(this._plugins)) {
141
+ try {
142
+ plugin.setup(this.options.plugins?.[pluginName] ?? {});
143
+ // Write the options back; this way the plugin is the source of truth,
144
+ // and BR just contains a reference to it.
145
+ this.options.plugins[pluginName] = plugin.options;
146
+ } catch (e) {
147
+ console.error(`Error setting up plugin ${pluginName}`, e);
148
+ }
149
+ }
150
+
111
151
  /** @type {number} @deprecated some past iterations set this */
112
152
  this.numLeafs = undefined;
113
153
 
@@ -166,11 +206,7 @@ BookReader.prototype.setup = function(options) {
166
206
  this.displayedIndices = [];
167
207
 
168
208
  this.animating = false;
169
- this.flipSpeed = typeof options.flipSpeed === 'number' ? options.flipSpeed : {
170
- 'fast': 200,
171
- 'slow': 600,
172
- }[options.flipSpeed] || 400;
173
- this.flipDelay = options.flipDelay;
209
+ this.flipSpeed = utils.parseAnimationSpeed(options.flipSpeed) || 400;
174
210
 
175
211
  /**
176
212
  * Represents the first displayed index
@@ -256,30 +292,11 @@ BookReader.prototype.setup = function(options) {
256
292
  '_modes.modeThumb': this._modes.modeThumb,
257
293
  };
258
294
 
259
- // Construct the usual suspects first to get type hints
260
- this._plugins = {
261
- archiveAnalytics: BookReader.PLUGINS.archiveAnalytics ? new BookReader.PLUGINS.archiveAnalytics(this) : null,
262
- };
263
-
264
- // Now construct the rest of the plugins
265
- for (const [pluginName, PluginClass] of Object.entries(BookReader.PLUGINS)) {
266
- if (this._plugins[pluginName] || !PluginClass) continue;
267
- this._plugins[pluginName] = new PluginClass(this);
268
- }
269
-
270
- // And call setup on them
271
- for (const [pluginName, plugin] of Object.entries(this._plugins)) {
272
- try {
273
- plugin.setup(this.options.plugins?.[pluginName] ?? {});
274
- } catch (e) {
275
- console.error(`Error setting up plugin ${pluginName}`, e);
276
- }
277
- }
278
-
279
295
  /** Image cache for general image fetching */
280
296
  this.imageCache = new ImageCache(this.book, {
281
297
  useSrcSet: this.options.useSrcSet,
282
298
  reduceSet: this.reduceSet,
299
+ renderPageURI: options.renderPageURI.bind(this),
283
300
  });
284
301
 
285
302
  /**
@@ -388,9 +405,9 @@ BookReader.prototype.initParams = function() {
388
405
  }
389
406
 
390
407
  // Check for Resume plugin
391
- if (this.options.enablePageResume) {
408
+ if (this._plugins.resume?.options.enabled) {
392
409
  // Check cookies
393
- const val = this.getResumeValue();
410
+ const val = this._plugins.resume.getResumeValue();
394
411
  if (val !== null) {
395
412
  // If page index different from default
396
413
  if (params.index !== val) {
@@ -577,7 +594,12 @@ BookReader.prototype.init = function() {
577
594
  this.initToolbar(this.mode, this.ui); // Build inside of toolbar div
578
595
  }
579
596
  if (this.options.showNavbar) { // default navigation
580
- this.initNavbar();
597
+ const $navBar = this.initNavbar();
598
+
599
+ // extend navbar with plugins
600
+ for (const plugin of Object.values(this._plugins)) {
601
+ plugin.extendNavBar($navBar);
602
+ }
581
603
  }
582
604
 
583
605
  // Switch navbar controls on mobile/desktop
@@ -824,11 +846,17 @@ BookReader.prototype.drawLeafs = function() {
824
846
  * @param {PageIndex} index
825
847
  */
826
848
  BookReader.prototype._createPageContainer = function(index) {
827
- return new PageContainer(this.book.getPage(index, false), {
849
+ const pageContainer = new PageContainer(this.book.getPage(index, false), {
828
850
  isProtected: this.protected,
829
851
  imageCache: this.imageCache,
830
- loadingImage: this.imagesBaseURL + 'loading.gif',
831
852
  });
853
+
854
+ // Call plugin handlers
855
+ for (const plugin of Object.values(this._plugins)) {
856
+ plugin._configurePageContainer(pageContainer);
857
+ }
858
+
859
+ return pageContainer;
832
860
  };
833
861
 
834
862
  BookReader.prototype.bindGestures = function(jElement) {
@@ -877,7 +905,7 @@ BookReader.prototype.zoom = function(direction) {
877
905
  } else {
878
906
  this.activeMode.zoom('out');
879
907
  }
880
- this.textSelectionPlugin?.stopPageFlip(this.refs.$brContainer);
908
+ this._plugins.textSelection?.stopPageFlip(this.refs.$brContainer);
881
909
  return;
882
910
  };
883
911
 
@@ -1108,7 +1136,7 @@ BookReader.prototype.switchMode = function(
1108
1136
  const eventName = mode + 'PageViewSelected';
1109
1137
  this.trigger(BookReader.eventNames[eventName]);
1110
1138
 
1111
- this.textSelectionPlugin?.stopPageFlip(this.refs.$brContainer);
1139
+ this._plugins.textSelection?.stopPageFlip(this.refs.$brContainer);
1112
1140
  };
1113
1141
 
1114
1142
  BookReader.prototype.updateBrClasses = function() {
@@ -1180,7 +1208,7 @@ BookReader.prototype.enterFullscreen = async function(bindKeyboardControls = tru
1180
1208
  }
1181
1209
  this.jumpToIndex(currentIndex);
1182
1210
 
1183
- this.textSelectionPlugin?.stopPageFlip(this.refs.$brContainer);
1211
+ this._plugins.textSelection?.stopPageFlip(this.refs.$brContainer);
1184
1212
  // Add "?view=theater"
1185
1213
  this.trigger(BookReader.eventNames.fragmentChange);
1186
1214
  // trigger event here, so that animations,
@@ -1226,7 +1254,7 @@ BookReader.prototype.exitFullScreen = async function () {
1226
1254
  await this.activeMode.mode1UpLit.updateComplete;
1227
1255
  }
1228
1256
 
1229
- this.textSelectionPlugin?.stopPageFlip(this.refs.$brContainer);
1257
+ this._plugins.textSelection?.stopPageFlip(this.refs.$brContainer);
1230
1258
  // Remove "?view=theater"
1231
1259
  this.trigger(BookReader.eventNames.fragmentChange);
1232
1260
  this.refs.$br.removeClass('BRfullscreenAnimation');
@@ -1325,10 +1353,19 @@ BookReader.prototype.leftmost = function() {
1325
1353
  }
1326
1354
  };
1327
1355
 
1328
- BookReader.prototype.next = function({triggerStop = true} = {}) {
1356
+ /**
1357
+ * @param {object} options
1358
+ * @param {boolean} [options.triggerStop = true]
1359
+ * @param {number | 'fast' | 'slow'} [options.flipSpeed]
1360
+ */
1361
+ BookReader.prototype.next = function({
1362
+ triggerStop = true,
1363
+ flipSpeed = null,
1364
+ } = {}) {
1329
1365
  if (this.constMode2up == this.mode) {
1330
1366
  if (triggerStop) this.trigger(BookReader.eventNames.stop);
1331
- this._modes.mode2Up.mode2UpLit.flipAnimation('next');
1367
+ flipSpeed = utils.parseAnimationSpeed(flipSpeed) || this.flipSpeed;
1368
+ this._modes.mode2Up.mode2UpLit.flipAnimation('next', {flipSpeed});
1332
1369
  } else {
1333
1370
  if (this.firstIndex < this.book.getNumLeafs() - 1) {
1334
1371
  this.jumpToIndex(this.firstIndex + 1);
@@ -1336,13 +1373,22 @@ BookReader.prototype.next = function({triggerStop = true} = {}) {
1336
1373
  }
1337
1374
  };
1338
1375
 
1339
- BookReader.prototype.prev = function({triggerStop = true} = {}) {
1376
+ /**
1377
+ * @param {object} options
1378
+ * @param {boolean} [options.triggerStop = true]
1379
+ * @param {number | 'fast' | 'slow'} [options.flipSpeed]
1380
+ */
1381
+ BookReader.prototype.prev = function({
1382
+ triggerStop = true,
1383
+ flipSpeed = null,
1384
+ } = {}) {
1340
1385
  const isOnFrontPage = this.firstIndex < 1;
1341
1386
  if (isOnFrontPage) return;
1342
1387
 
1343
1388
  if (this.constMode2up == this.mode) {
1344
1389
  if (triggerStop) this.trigger(BookReader.eventNames.stop);
1345
- this._modes.mode2Up.mode2UpLit.flipAnimation('prev');
1390
+ flipSpeed = utils.parseAnimationSpeed(flipSpeed) || this.flipSpeed;
1391
+ this._modes.mode2Up.mode2UpLit.flipAnimation('prev', {flipSpeed});
1346
1392
  } else {
1347
1393
  if (this.firstIndex >= 1) {
1348
1394
  this.jumpToIndex(this.firstIndex - 1);
@@ -1527,6 +1573,11 @@ BookReader.prototype.bindNavigationHandlers = function() {
1527
1573
  self.$('.BRnavCntl').animate({opacity:.75},250);
1528
1574
  }
1529
1575
  });
1576
+
1577
+ // Call _bindNavigationHandlers on the plugins
1578
+ for (const plugin of Object.values(this._plugins)) {
1579
+ plugin._bindNavigationHandlers();
1580
+ }
1530
1581
  };
1531
1582
 
1532
1583
  /**************************/
@@ -25,4 +25,20 @@ export class BookReaderPlugin {
25
25
 
26
26
  /** @abstract */
27
27
  init() {}
28
+
29
+ /**
30
+ * @abstract
31
+ * @protected
32
+ * @param {import ("./BookReader/PageContainer.js").PageContainer} pageContainer
33
+ */
34
+ _configurePageContainer(pageContainer) {
35
+ }
36
+
37
+ /** @abstract @protected */
38
+ _bindNavigationHandlers() {}
39
+
40
+ /**
41
+ * @param {JQuery<HTMLElement>} $navBar
42
+ */
43
+ extendNavBar($navBar) {}
28
44
  }
@@ -60,9 +60,28 @@
60
60
  left: 0;
61
61
  z-index: 1;
62
62
  }
63
- &.BRpageloading img {
63
+ &.BRpageloading {
64
64
  // Don't show the alt text while loading
65
- color: transparent;
65
+ img {
66
+ color: transparent;
67
+ }
68
+
69
+ // src can be set async, so hide the image if it's not set
70
+ img:not([src]) {
71
+ display: none;
72
+ }
73
+
74
+ &::after {
75
+ display: block;
76
+ content: "";
77
+ width: 20px;
78
+ height: 20px;
79
+ position: absolute;
80
+ left: 50%;
81
+ top: 50%;
82
+ transform: translate(-50%, -50%);
83
+ background: url("images/loading.gif") center/20px no-repeat;
84
+ }
66
85
  }
67
86
  &.BRemptypage {
68
87
  background: transparent;