@internetarchive/bookreader
Version:
The Internet Archive BookReader.
1,651 lines (1,437 loc) • 56.6 kB
JavaScript
/*
Copyright(c)2008-2019 Internet Archive. Software license AGPL version 3.
This file is part of BookReader.
BookReader is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
BookReader is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with BookReader. If not, see <http://www.gnu.org/licenses/>.
The BookReader source is hosted at http://github.com/internetarchive/bookreader/
*/
// Needed by touch-punch
import 'jquery-ui/ui/widget.js';
import 'jquery-ui/ui/widgets/mouse.js';
import 'jquery-ui-touch-punch';
import PACKAGE_JSON from '../package.json';
import * as utils from './BookReader/utils.js';
import { exposeOverrideable } from './BookReader/utils/classes.js';
import { Navbar } from './BookReader/Navbar/Navbar.js';
import { DEFAULT_OPTIONS, OptionsParseError } from './BookReader/options.js';
/** @typedef {import('./BookReader/options.js').BookReaderOptions} BookReaderOptions */
/** @typedef {import('./BookReader/options.js').ReductionFactor} ReductionFactor */
/** @typedef {import('./BookReader/BookModel.js').PageIndex} PageIndex */
import { EVENTS } from './BookReader/events.js';
import { Toolbar } from './BookReader/Toolbar/Toolbar.js';
import { BookModel } from './BookReader/BookModel.js';
import { Mode1Up } from './BookReader/Mode1Up.js';
import { Mode2Up } from './BookReader/Mode2Up.js';
import { ModeThumb } from './BookReader/ModeThumb';
import { ImageCache } from './BookReader/ImageCache.js';
import { PageContainer } from './BookReader/PageContainer.js';
import { NAMED_REDUCE_SETS } from './BookReader/ReduceSet';
/**
* BookReader
* @param {BookReaderOptions} options
* TODO document all options properties
* @constructor
*/
export default function BookReader(overrides = {}) {
const options = jQuery.extend(true, {}, BookReader.defaultOptions, overrides, BookReader.optionOverrides);
this.setup(options);
}
BookReader.version = PACKAGE_JSON.version;
// Mode constants
/** 1 page view */
BookReader.constMode1up = 1;
/** 2 pages view */
BookReader.constMode2up = 2;
/** thumbnails view */
BookReader.constModeThumb = 3;
/** image cache */
BookReader.imageCache = null;
// Animation constants
BookReader.constNavAnimationDuration = 300;
BookReader.constResizeAnimationDuration = 100;
// Names of events that can be triggered via BookReader.prototype.trigger()
BookReader.eventNames = EVENTS;
BookReader.defaultOptions = DEFAULT_OPTIONS;
/**
* @type {BookReaderOptions}
* This is here, just in case you need to absolutely override an option.
*/
BookReader.optionOverrides = {};
/**
* Setup
* It is separate from the constructor, so plugins can extend.
* @param {BookReaderOptions} options
*/
BookReader.prototype.setup = function(options) {
// Store the options used to setup bookreader
this.options = options;
/** @type {number} @deprecated some past iterations set this */
this.numLeafs = undefined;
/** Overridden by plugin.search.js */
this.enableSearch = false;
/**
* Store viewModeOrder states
* @var {boolean}
*/
this.viewModeOrder = [];
/**
* Used to suppress fragment change for init with canonical URLs
* @var {boolean}
*/
this.suppressFragmentChange = false;
/** @type {function(): void} */
this.animationFinishedCallback = null;
// @deprecated: Instance constants. Use Class constants instead
/** 1 page view */
this.constMode1up = BookReader.constMode1up;
/** 2 pages view */
this.constMode2up = BookReader.constMode2up;
/** thumbnails view */
this.constModeThumb = BookReader.constModeThumb;
// Private properties below. Configuration should be done with options.
/** @type {number} TODO: Make private */
this.reduce = 8; /* start very small */
this.defaults = options.defaults;
this.padding = options.padding;
this.reduceSet = NAMED_REDUCE_SETS[options.reduceSet];
if (!this.reduceSet) {
console.warn(`Invalid reduceSet ${options.reduceSet}. Ignoring.`);
this.reduceSet = NAMED_REDUCE_SETS[DEFAULT_OPTIONS.reduceSet];
}
/** @type {number}
* can be 1 or 2 or 3 based on the display mode const value
*/
this.mode = null;
this.prevReadMode = null;
this.ui = options.ui;
this.uiAutoHide = options.uiAutoHide;
this.thumbWidth = 100; // will be overridden during this._modes.modeThumb.prepare();
this.thumbRowBuffer = options.thumbRowBuffer;
this.thumbColumns = options.thumbColumns;
this.thumbMaxLoading = options.thumbMaxLoading;
this.thumbPadding = options.thumbPadding;
this.displayedRows = [];
this.displayedIndices = [];
this.animating = false;
this.flipSpeed = typeof options.flipSpeed === 'number' ? options.flipSpeed : {
'fast': 200,
'slow': 600,
}[options.flipSpeed] || 400;
this.flipDelay = options.flipDelay;
/**
* Represents the first displayed index
* In 2up mode it will be the left page
* In 1 up mode it is the highest page
* @property {number|null} firstIndex
*/
this.firstIndex = null;
this.isFullscreenActive = options.startFullscreen || false;
this.lastScroll = null;
this.showLogo = options.showLogo;
this.logoURL = options.logoURL;
this.imagesBaseURL = options.imagesBaseURL;
this.reductionFactors = options.reductionFactors;
this.onePage = options.onePage;
/** @type {import('./BookReader/Mode2Up').TwoPageState} */
this.twoPage = options.twoPage;
this.onePageMinBreakpoint = options.onePageMinBreakpoint;
this.bookTitle = options.bookTitle;
this.bookUrl = options.bookUrl;
this.bookUrlText = options.bookUrlText;
this.bookUrlTitle = options.bookUrlTitle;
this.titleLeaf = options.titleLeaf;
this.metadata = options.metadata;
this.thumbnail = options.thumbnail;
this.bookUrlMoreInfo = options.bookUrlMoreInfo;
this.enableExperimentalControls = options.enableExperimentalControls;
this.el = options.el;
this.pageProgression = options.pageProgression;
this.protected = options.protected;
this.getEmbedCode = options.getEmbedCode;
this.popup = null;
// Assign the data methods
this.data = options.data;
/** @type {{[name: string]: JQuery}} */
this.refs = {};
/** The book being displayed in BookReader*/
this.book = new BookModel(this);
if (options.getNumLeafs) this.book.getNumLeafs = options.getNumLeafs.bind(this);
if (options.getPageWidth) this.book.getPageWidth = options.getPageWidth.bind(this);
if (options.getPageHeight) this.book.getPageHeight = options.getPageHeight.bind(this);
if (options.getPageURI) this.book.getPageURI = options.getPageURI.bind(this);
if (options.getPageSide) this.book.getPageSide = options.getPageSide.bind(this);
if (options.getPageNum) this.book.getPageNum = options.getPageNum.bind(this);
if (options.getPageProp) this.book.getPageProp = options.getPageProp.bind(this);
if (options.getSpreadIndices) this.book.getSpreadIndices = options.getSpreadIndices.bind(this);
if (options.leafNumToIndex) this.book.leafNumToIndex = options.leafNumToIndex.bind(this);
/**
* @private Components are 'subchunks' of bookreader functionality, usually UI related
* They should be relatively decoupled from each other/bookreader.
* Note there are no hooks right now; components just provide methods that bookreader
* calls at the correct moments.
**/
this._components = {
navbar: new Navbar(this),
toolbar: new Toolbar(this),
};
this._modes = {
mode1Up: new Mode1Up(this, this.book),
mode2Up: new Mode2Up(this, this.book),
modeThumb: new ModeThumb(this, this.book),
};
/** Stores classes which we want to expose (selectively) some methods as overridable */
this._overrideable = {
'book': this.book,
'_components.navbar': this._components.navbar,
'_components.toolbar': this._components.toolbar,
'_modes.mode1Up': this._modes.mode1Up,
'_modes.mode2Up': this._modes.mode2Up,
'_modes.modeThumb': this._modes.modeThumb,
};
/** Image cache for general image fetching */
this.imageCache = new ImageCache(this.book, {
useSrcSet: this.options.useSrcSet,
reduceSet: this.reduceSet,
});
/**
* Flag if BookReader has "focus" for keyboard shortcuts
* Initially true, set to false when:
* - BookReader scrolled out of view
* Set to true when:
* - BookReader scrolled into view
*/
this.hasKeyFocus = true;
};
/**
* Get all the HTML Elements that are being/can be rendered.
* Includes cached elements which might be rendered again.
*/
BookReader.prototype.getActivePageContainerElements = function() {
let containerEls = Object.values(this._modes.mode2Up.mode2UpLit.pageContainerCache).map(pc => pc.$container[0])
.concat(Object.values(this._modes.mode1Up.mode1UpLit.pageContainerCache).map(pc => pc.$container[0]));
if (this.mode == this.constModeThumb) {
containerEls = containerEls.concat(this.$('.BRpagecontainer').toArray());
}
return containerEls;
};
/**
* Get the HTML Elements for the rendered page. Note there can be more than one, since
* (at least as of writing) different modes can maintain different caches.
* @param {PageIndex} pageIndex
*/
BookReader.prototype.getActivePageContainerElementsForIndex = function(pageIndex) {
return [
this._modes.mode2Up.mode2UpLit.pageContainerCache[pageIndex]?.$container?.[0],
this._modes.mode1Up.mode1UpLit.pageContainerCache[pageIndex]?.$container?.[0],
...(this.mode == this.constModeThumb ? this.$(`.pagediv${pageIndex}`).toArray() : []),
].filter(x => x);
};
Object.defineProperty(BookReader.prototype, 'activeMode', {
/** @return {Mode1Up | Mode2Up | ModeThumb} */
get() { return {
1: this._modes.mode1Up,
2: this._modes.mode2Up,
3: this._modes.modeThumb,
}[this.mode]; },
});
/**
* BookReader.util are static library functions
* At top of file so they can be used below
*/
BookReader.util = utils;
/**
* Helper to merge in params in to a params object.
* It normalizes "page" into the "index" field to disambiguate and prevent concflicts
* @private
*/
BookReader.prototype.extendParams = function(params, newParams) {
const modifiedNewParams = $.extend({}, newParams);
if ('undefined' != typeof(modifiedNewParams.page)) {
const pageIndex = this.book.parsePageString(modifiedNewParams.page);
if (!isNaN(pageIndex))
modifiedNewParams.index = pageIndex;
delete modifiedNewParams.page;
}
$.extend(params, modifiedNewParams);
};
/**
* Parses params from from various initialization contexts (url, cookie, options)
* @private
* @return {object} the parsed params
*/
BookReader.prototype.initParams = function() {
const params = {};
// Flag initializing for updateFromParams()
params.init = true;
// Flag if page given in defaults or URL (not cookie)
// Used for overriding goToFirstResult in plugin.search.js
// Note: extendParams() converts params.page to index and gets rid of page
// so check and set before extendParams()
params.pageFound = false;
// True if changing the URL
params.fragmentChange = false;
// This is ordered from lowest to highest priority
// If we have a title leaf, use that as the default instead of index 0,
// but only use as default if book has a few pages
if ('undefined' != typeof(this.titleLeaf) && this.book.getNumLeafs() > 2) {
params.index = this.book.leafNumToIndex(this.titleLeaf);
} else {
params.index = 0;
}
// this.defaults is a string passed in the url format. eg "page/1/mode/1up"
if (this.defaults) {
const defaultParams = this.paramsFromFragment(this.defaults);
if ('undefined' != typeof(defaultParams.page)) {
params.pageFound = true;
}
this.extendParams(params, defaultParams);
}
// Check for Resume plugin
if (this.options.enablePageResume) {
// Check cookies
const val = this.getResumeValue();
if (val !== null) {
// If page index different from default
if (params.index !== val) {
// Show in URL
params.fragmentChange = true;
}
params.index = val;
}
}
// Check for URL plugin
if (this.options.enableUrlPlugin) {
// Params explicitly set in URL take precedence over all other methods
let urlParams = this.paramsFromFragment(this.urlReadFragment());
// Get params if hash fragment available with 'history' urlMode
const hasHashURL = !Object.keys(urlParams).length && this.urlReadHashFragment();
if (hasHashURL && (this.options.urlMode === 'history')) {
urlParams = this.paramsFromFragment(this.urlReadHashFragment());
}
// If there were any parameters
if (Object.keys(urlParams).length) {
if ('undefined' != typeof(urlParams.page)) {
params.pageFound = true;
}
this.extendParams(params, urlParams);
// Show in URL
params.fragmentChange = true;
}
}
// Check for Search plugin
if (this.options.enableSearch) {
// Go to first result only if no default or URL page
this.options.goToFirstResult = !params.pageFound;
// If initialSearchTerm not set
if (!this.options.initialSearchTerm) {
// Look for any term in URL
if (params.search) {
// Old style: /search/[term]
this.options.initialSearchTerm = params.search;
this.searchTerm = params.search;
} else {
// If we have a query string: q=[term]
const searchParams = new URLSearchParams(this.readQueryString());
const searchTerm = searchParams.get('q');
if (searchTerm) {
this.options.initialSearchTerm = utils.decodeURIComponentPlus(searchTerm);
}
}
}
}
// Set for init process, return to false at end of init()
this.suppressFragmentChange = !params.fragmentChange;
return params;
};
/**
* Allow mocking of window.location.search
*/
BookReader.prototype.getLocationSearch = function () {
return window.location.search;
};
/**
* Allow mocking of window.location.hash
*/
BookReader.prototype.getLocationHash = function () {
return window.location.hash;
};
/**
* Return URL or fragment querystring
*/
BookReader.prototype.readQueryString = function() {
const queryString = this.getLocationSearch();
if (queryString) {
return queryString;
}
const hash = this.getLocationHash();
const found = hash.search(/\?\w+=/);
return found > -1 ? hash.slice(found) : '';
};
/**
* Determines the initial mode for starting if a mode is not already
* present in the params argument
* @param {object} params
* @return {1 | 2 | 3} the initial mode
*/
BookReader.prototype.getInitialMode = function(params) {
// if mobile breakpoint, we always show this.constMode1up mode
const windowWidth = $(window).width();
const isMobile = windowWidth && windowWidth <= this.onePageMinBreakpoint;
let initialMode;
if (params.mode) {
initialMode = params.mode;
} else if (isMobile) {
initialMode = this.constMode1up;
} else {
initialMode = this.constMode2up;
}
if (!this.canSwitchToMode(initialMode)) {
initialMode = this.constMode1up;
}
// override defaults mode via `options.defaults` metadata
if (this.options.defaults) {
try {
initialMode = _modeStringToNumber(this.options.defaults);
} catch (e) {
// Can ignore this error
}
}
return initialMode;
};
/**
* Converts a mode string to a the mode numeric constant
* @param {'mode/1up'|'mode/2up'|'mode/thumb'} modeString
* @return {1 | 2 | 3}
*/
export function _modeStringToNumber(modeString) {
const MAPPING = {
'mode/1up': 1,
'mode/2up': 2,
'mode/thumb': 3,
};
if (!(modeString in MAPPING)) {
throw new OptionsParseError(`Invalid mode string: ${modeString}`);
}
return MAPPING[modeString];
}
/**
* This is called by the client to initialize BookReader.
* It renders onto the DOM. It should only be called once.
*/
BookReader.prototype.init = function() {
this.init.initComplete = false;
this.pageScale = this.reduce; // preserve current reduce
const params = this.initParams();
this.firstIndex = params.index ? params.index : 0;
// Setup Navbars and other UI
this.isTouchDevice = !!('ontouchstart' in window) || !!('msmaxtouchpoints' in window.navigator);
this.refs.$br = $(this.el)
.empty()
.removeClass()
.addClass("ui-" + this.ui)
.addClass("br-ui-" + this.ui)
.addClass('BookReader');
// Add a class if this is a touch enabled device
if (this.isTouchDevice) {
this.refs.$br.addClass("touch");
} else {
this.refs.$br.addClass("no-touch");
}
this.refs.$brContainer = $("<div class='BRcontainer' dir='ltr'></div>");
this.refs.$br.append(this.refs.$brContainer);
// Explicitly ensure params.mode exists for updateFromParams() below
params.mode = this.getInitialMode(params);
// Explicitly ensure this.mode exists for initNavbar() below
this.mode = params.mode;
// Display Navigation
if (this.options.showToolbar) {
this.initToolbar(this.mode, this.ui); // Build inside of toolbar div
}
if (this.options.showNavbar) { // default navigation
this.initNavbar();
}
// Switch navbar controls on mobile/desktop
this._components.navbar.switchNavbarControls();
this.resizeBRcontainer();
this.updateFromParams(params);
this.initUIStrings();
// Bind to events
this.bindNavigationHandlers();
this.setupKeyListeners();
this.lastScroll = (new Date().getTime());
this.refs.$brContainer.on('scroll', this, function(e) {
// Note, this scroll event fires for both user, and js generated calls
// It is functioning in some cases as the primary triggerer for rendering
e.data.lastScroll = (new Date().getTime());
if (e.data.constModeThumb == e.data.mode) {
e.data.drawLeafsThrottled();
}
});
if (this.options.autoResize) {
$(window).on('resize', this, function(e) {
e.data.resize();
});
$(window).on("orientationchange", this, function(e) {
e.data.resize();
}.bind(this));
}
if (this.protected) {
this.$('.BRicon.share').hide();
}
// If not searching, set to allow on-going fragment changes
if (!this.options.initialSearchTerm) {
this.suppressFragmentChange = false;
}
if (this.options.startFullscreen) {
this.enterFullscreen(true);
}
this.init.initComplete = true;
this.trigger(BookReader.eventNames.PostInit);
// Must be called after this.init.initComplete set to true to allow
// BookReader.prototype.resize to run.
};
/**
* @param {EVENTS} name
* @param {array | object} [props]
*/
BookReader.prototype.trigger = function(name, props = this) {
const eventName = 'BookReader:' + name;
utils.polyfillCustomEvent(window);
window.dispatchEvent(new CustomEvent(eventName, {
bubbles: true,
composed: true,
detail: { props },
}));
$(document).trigger(eventName, props);
};
BookReader.prototype.bind = function(name, callback) {
$(document).on('BookReader:' + name, callback);
};
BookReader.prototype.unbind = function(name, callback) {
$(document).off('BookReader:' + name, callback);
};
/**
* Resizes based on the container width and height
*/
BookReader.prototype.resize = function() {
if (!this.init.initComplete) return;
this.resizeBRcontainer();
// Switch navbar controls on mobile/desktop
this._components.navbar.switchNavbarControls();
if (this.constMode1up == this.mode) {
if (this.onePage.autofit != 'none') {
this._modes.mode1Up.resizePageView();
this.centerPageView();
} else {
this.centerPageView();
this.displayedIndices = [];
this.drawLeafsThrottled();
}
} else if (this.constModeThumb == this.mode) {
this._modes.modeThumb.prepare();
} else {
this._modes.mode2Up.resizePageView();
}
this.trigger(BookReader.eventNames.resize);
};
/**
* Binds keyboard and keyboard focus event listeners
*/
BookReader.prototype.setupKeyListeners = function () {
// Keyboard focus by BookReader in viewport
//
// Intersection observer and callback sets BookReader keyboard
// "focus" flag off when the BookReader is not in the viewport.
if (window.IntersectionObserver) {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.intersectionRatio === 0) {
this.hasKeyFocus = false;
} else {
this.hasKeyFocus = true;
}
});
}, {
root: null,
rootMargin: '0px',
threshold: [0, 0.05, 1],
});
observer.observe(this.refs.$br[0]);
}
// Keyboard listeners
document.addEventListener('keydown', (e) => {
// Ignore if BookReader "focus" flag not set
if (!this.hasKeyFocus) {
return;
}
// Ignore if modifiers are active.
if (e.getModifierState('Control') ||
e.getModifierState('Alt') ||
e.getModifierState('Meta') ||
e.getModifierState('Win') /* hack for IE */) {
return;
}
// Ignore in input elements
if (utils.isInputActive()) {
return;
}
// KeyboardEvent code values:
// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code/code_values
switch (e.key) {
// Page navigation
case "Home":
e.preventDefault();
this.first();
break;
case "End":
e.preventDefault();
this.last();
break;
case "ArrowDown":
case "PageDown":
case "Down": // hack for IE and old Gecko
// In 1up and thumb mode page scrolling handled by browser
if (this.constMode2up === this.mode) {
e.preventDefault();
this.next();
}
break;
case "ArrowUp":
case "PageUp":
case "Up": // hack for IE and old Gecko
// In 1up and thumb mode page scrolling handled by browser
if (this.constMode2up === this.mode) {
e.preventDefault();
this.prev();
}
break;
case "ArrowLeft":
case "Left": // hack for IE and old Gecko
// No y-scrolling in thumb mode
if (this.constModeThumb != this.mode) {
e.preventDefault();
this.left();
}
break;
case "ArrowRight":
case "Right": // hack for IE and old Gecko
// No y-scrolling in thumb mode
if (this.constModeThumb != this.mode) {
e.preventDefault();
this.right();
}
break;
// Zoom
case '-':
case 'Subtract':
e.preventDefault();
this.zoom(-1);
break;
case '+':
case '=':
case 'Add':
e.preventDefault();
this.zoom(1);
break;
// Fullscreen
case 'F':
case 'f':
e.preventDefault();
this.toggleFullscreen();
break;
}
});
};
BookReader.prototype.drawLeafs = function() {
if (this.constMode1up == this.mode) {
// Not needed for Mode1Up anymore
return;
} else {
this.activeMode.drawLeafs();
}
};
/**
* @protected
* @param {PageIndex} index
*/
BookReader.prototype._createPageContainer = function(index) {
return new PageContainer(this.book.getPage(index, false), {
isProtected: this.protected,
imageCache: this.imageCache,
loadingImage: this.imagesBaseURL + 'loading.gif',
});
};
BookReader.prototype.bindGestures = function(jElement) {
// TODO support gesture change is only iOS. Support android.
// HACK(2017-01-20) - Momentum scrolling is causing the scroll position
// to jump after zooming in on mobile device. I am able to reproduce
// when you move the book with one finger and then add another
// finger to pinch. Gestures are aware of scroll state.
const self = this;
let numTouches = 1;
jElement.unbind('touchmove').bind('touchmove', function(e) {
if (e.originalEvent.cancelable) numTouches = e.originalEvent.touches.length;
e.stopPropagation();
});
jElement.unbind('gesturechange').bind('gesturechange', function(e) {
e.preventDefault();
// These are two very important fixes to adjust for the scroll position
// issues described below
if (!(numTouches !== 2 || (new Date().getTime()) - self.lastScroll < 500)) {
if (e.originalEvent.scale > 1.5) {
self.zoom(1);
} else if (e.originalEvent.scale < 0.6) {
self.zoom(-1);
}
}
});
};
/**
* A throttled version of drawLeafs
*/
BookReader.prototype.drawLeafsThrottled = utils.throttle(
BookReader.prototype.drawLeafs,
250, // 250 ms gives quick feedback, but doesn't eat cpu
);
/**
* @param {number} direction Pass 1 to zoom in, anything else to zoom out
*/
BookReader.prototype.zoom = function(direction) {
if (direction == 1) {
this.activeMode.zoom('in');
} else {
this.activeMode.zoom('out');
}
this.textSelectionPlugin?.stopPageFlip(this.refs.$brContainer);
return;
};
/**
* Resizes the inner container to fit within the visible space to prevent
* the top toolbar and bottom navbar from clipping the visible book
*
* @param { boolean } animate - optional
* When used, BookReader will fill the main container with the book's content.
* This is primarily for 1up view - a follow up animation to the nav animation
* So resize isn't perceived sharp/jerky
*/
BookReader.prototype.resizeBRcontainer = function(animate) {
if (animate) {
this.refs.$brContainer.animate({
top: this.getToolBarHeight(),
bottom: this.getFooterHeight(),
}, this.constResizeAnimationDuration, 'linear');
} else {
this.refs.$brContainer.css({
top: this.getToolBarHeight(),
bottom: this.getFooterHeight(),
});
}
};
BookReader.prototype.centerPageView = function() {
const scrollWidth = this.refs.$brContainer.prop('scrollWidth');
const clientWidth = this.refs.$brContainer.prop('clientWidth');
if (scrollWidth > clientWidth) {
this.refs.$brContainer.prop('scrollLeft', (scrollWidth - clientWidth) / 2);
}
};
/**
* Quantizes the given reduction factor to closest power of two from set from 12.5% to 200%
* @param {number} reduce
* @param {ReductionFactor[]} reductionFactors
* @return {number}
*/
BookReader.prototype.quantizeReduce = function(reduce, reductionFactors) {
let quantized = reductionFactors[0].reduce;
let distance = Math.abs(reduce - quantized);
for (let i = 1; i < reductionFactors.length; i++) {
const newDistance = Math.abs(reduce - reductionFactors[i].reduce);
if (newDistance < distance) {
distance = newDistance;
quantized = reductionFactors[i].reduce;
}
}
return quantized;
};
/**
* @param {number} currentReduce
* @param {'in' | 'out' | 'auto' | 'height' | 'width'} direction
* @param {ReductionFactor[]} reductionFactors Must be sorted
* @returns {ReductionFactor}
*/
BookReader.prototype.nextReduce = function(currentReduce, direction, reductionFactors) {
// XXX add 'closest', to replace quantize function
if (direction === 'in') {
let newReduceIndex = 0;
for (let i = 1; i < reductionFactors.length; i++) {
if (reductionFactors[i].reduce < currentReduce) {
newReduceIndex = i;
}
}
return reductionFactors[newReduceIndex];
} else if (direction === 'out') {
const lastIndex = reductionFactors.length - 1;
let newReduceIndex = lastIndex;
for (let i = lastIndex; i >= 0; i--) {
if (reductionFactors[i].reduce > currentReduce) {
newReduceIndex = i;
}
}
return reductionFactors[newReduceIndex];
} else if (direction === 'auto') {
// If an 'auto' is specified, use that
const autoMatch = reductionFactors.find(rf => rf.autofit == 'auto');
if (autoMatch) return autoMatch;
// Otherwise, choose the least reduction from height/width
const candidates = reductionFactors.filter(({autofit}) => autofit == 'height' || autofit == 'width');
let choice = null;
for (let i = 0; i < candidates.length; i++) {
if (choice === null || choice.reduce < candidates[i].reduce) {
choice = candidates[i];
}
}
if (choice) return choice;
} else if (direction === 'height' || direction === 'width') {
// Asked for specific autofit mode
const match = reductionFactors.find(rf => rf.autofit == direction);
if (match) return match;
}
return reductionFactors[0];
};
/**
* @param {ReductionFactor} a
* @param {ReductionFactor} b
*/
BookReader.prototype._reduceSort = (a, b) => a.reduce - b.reduce;
/**
* Attempts to jump to page
* @param {string}
* @return {boolean} Returns true if page could be found, false otherwise.
*/
BookReader.prototype.jumpToPage = function(pageNum) {
const pageIndex = this.book.parsePageString(pageNum);
if ('undefined' != typeof(pageIndex)) {
this.jumpToIndex(pageIndex);
return true;
}
// Page not found
return false;
};
/**
* Check whether the specified index is currently displayed
* @param {PageIndex} index
*/
BookReader.prototype._isIndexDisplayed = function(index) {
return this.displayedIndices ? this.displayedIndices.includes(index) :
this.currentIndex() == index;
};
/**
* Changes the current page
* @param {PageIndex} index
* @param {number} [pageX]
* @param {number} [pageY]
* @param {boolean} [noAnimate]
*/
BookReader.prototype.jumpToIndex = function(index, pageX, pageY, noAnimate) {
// Don't jump into specific unviewable page
const page = this.book.getPage(index);
if (!page.isViewable && page.unviewablesStart != page.index) {
// If already in unviewable range, jump to end of that range
const alreadyInPreview = this._isIndexDisplayed(page.unviewablesStart);
const newIndex = alreadyInPreview ? page.findNext({ combineConsecutiveUnviewables: true }).index : page.unviewablesStart;
return this.jumpToIndex(newIndex, pageX, pageY, noAnimate);
}
this.trigger(BookReader.eventNames.stop);
this.activeMode.jumpToIndex(index, pageX, pageY, noAnimate);
};
/**
* Return mode or 1up if initial thumb
* @param {number}
*/
BookReader.prototype.getPrevReadMode = function(mode) {
if (mode === BookReader.constMode1up || mode === BookReader.constMode2up) {
return mode;
} else if (this.prevReadMode === null) {
// Initial thumb, return 1up
return BookReader.constMode1up;
}
};
/**
* Switches the mode (eg 1up 2up thumb)
* @param {number}
* @param {object} [options]
* @param {boolean} [options.suppressFragmentChange = false]
* @param {boolean} [options.onInit = false] - this
*/
BookReader.prototype.switchMode = function(
mode,
{
suppressFragmentChange = false,
init = false,
pageFound = false,
} = {},
) {
// Skip checks before init() complete
if (this.init.initComplete) {
if (mode === this.mode) {
return;
}
if (!this.canSwitchToMode(mode)) {
return;
}
}
this.trigger(BookReader.eventNames.stop);
this.prevReadMode = this.getPrevReadMode(this.mode);
if (this.mode != mode) {
this.activeMode.unprepare?.();
}
this.mode = mode;
// reinstate scale if moving from thumbnail view
if (this.pageScale !== this.reduce) {
this.reduce = this.pageScale;
}
// $$$ TODO preserve center of view when switching between mode
// See https://bugs.edge.launchpad.net/gnubook/+bug/416682
// XXX maybe better to preserve zoom in each mode
if (this.constMode1up == mode) {
this._modes.mode1Up.prepare();
} else if (this.constModeThumb == mode) {
this.reduce = this.quantizeReduce(this.reduce, this.reductionFactors);
this._modes.modeThumb.prepare();
} else {
this._modes.mode2Up.prepare();
}
if (!(this.suppressFragmentChange || suppressFragmentChange)) {
this.trigger(BookReader.eventNames.fragmentChange);
}
const eventName = mode + 'PageViewSelected';
this.trigger(BookReader.eventNames[eventName]);
this.textSelectionPlugin?.stopPageFlip(this.refs.$brContainer);
};
BookReader.prototype.updateBrClasses = function() {
const modeToClass = {};
modeToClass[this.constMode1up] = 'BRmode1up';
modeToClass[this.constMode2up] = 'BRmode2up';
modeToClass[this.constModeThumb] = 'BRmodeThumb';
this.refs.$br
.removeClass('BRmode1up BRmode2up BRmodeThumb')
.addClass(modeToClass[this.mode]);
if (this.isFullscreen()) {
this.refs.$br.addClass('fullscreenActive');
$(document.body).addClass('BRfullscreenActive');
} else {
this.refs.$br.removeClass('fullscreenActive');
$(document.body).removeClass('BRfullscreenActive');
}
};
BookReader.prototype.isFullscreen = function() {
return this.isFullscreenActive;
};
/**
* Toggles fullscreen
* @param { boolean } bindKeyboardControls
*/
BookReader.prototype.toggleFullscreen = async function(bindKeyboardControls = true) {
if (this.isFullscreen()) {
await this.exitFullScreen();
} else {
await this.enterFullscreen(bindKeyboardControls);
}
};
/**
* Enters fullscreen
* including:
* - binds keyboard controls
* - fires custom event
* @param { boolean } bindKeyboardControls
*/
BookReader.prototype.enterFullscreen = async function(bindKeyboardControls = true) {
this.refs.$br.addClass('BRfullscreenAnimation');
const currentIndex = this.currentIndex();
if (bindKeyboardControls) {
this._fullscreenCloseHandler = (e) => {
if (e.keyCode === 27) this.toggleFullscreen();
};
$(document).on("keyup", this._fullscreenCloseHandler);
}
const windowWidth = $(window).width();
if (windowWidth <= this.onePageMinBreakpoint) {
this.switchMode(this.constMode1up);
}
this.isFullscreenActive = true;
// prioritize class updates so CSS can propagate
this.updateBrClasses();
if (this.activeMode instanceof Mode1Up) {
this.activeMode.mode1UpLit.scale = this.activeMode.mode1UpLit.computeDefaultScale(this.book.getPage(currentIndex));
// Need the new scale to be applied before calling jumpToIndex
this.activeMode.mode1UpLit.requestUpdate();
await this.activeMode.mode1UpLit.updateComplete;
}
this.jumpToIndex(currentIndex);
this.textSelectionPlugin?.stopPageFlip(this.refs.$brContainer);
// Add "?view=theater"
this.trigger(BookReader.eventNames.fragmentChange);
// trigger event here, so that animations,
// class updates happen before book-nav relays to web components
this.trigger(BookReader.eventNames.fullscreenToggled);
// resize book after all events & css updates
await new Promise(resolve => setTimeout(resolve, 0));
this.resize();
this.refs.$br.removeClass('BRfullscreenAnimation');
};
/**
* Exits fullscreen
* - toggles fullscreen
* - binds keyboard controls
* - fires custom event
* @param { boolean } bindKeyboardControls
*/
BookReader.prototype.exitFullScreen = async function () {
this.refs.$br.addClass('BRfullscreenAnimation');
$(document).off('keyup', this._fullscreenCloseHandler);
const windowWidth = $(window).width();
const canShow2up = this.options.controls.twoPage.visible;
if (canShow2up && (windowWidth <= this.onePageMinBreakpoint)) {
this.switchMode(this.constMode2up);
}
this.isFullscreenActive = false;
// Trigger fullscreen event immediately
// so that book-nav can relay to web components
this.trigger(BookReader.eventNames.fullscreenToggled);
this.updateBrClasses();
await new Promise(resolve => setTimeout(resolve, 0));
this.resize();
if (this.activeMode instanceof Mode1Up) {
this.activeMode.mode1UpLit.scale = this.activeMode.mode1UpLit.computeDefaultScale(this.book.getPage(this.currentIndex()));
this.activeMode.mode1UpLit.requestUpdate();
await this.activeMode.mode1UpLit.updateComplete;
}
this.textSelectionPlugin?.stopPageFlip(this.refs.$brContainer);
// Remove "?view=theater"
this.trigger(BookReader.eventNames.fragmentChange);
this.refs.$br.removeClass('BRfullscreenAnimation');
};
/**
* Returns the currently active index
* @return {number}
* @throws
*/
BookReader.prototype.currentIndex = function() {
// $$$ we should be cleaner with our idea of which index is active in 1up/2up
if (this.mode == this.constMode1up || this.mode == this.constModeThumb) {
return this.firstIndex; // $$$ TODO page in center of view would be better
} else if (this.mode == this.constMode2up) {
// Only allow indices that are actually present in book
return utils.clamp(this.firstIndex, 0, this.book.getNumLeafs() - 1);
} else {
throw 'currentIndex called for unimplemented mode ' + this.mode;
}
};
/**
* Setter for this.firstIndex
* Also triggers an event and updates the navbar slider position
* @param {number} index
* @param {object} [options]
* @param {boolean} [options.suppressFragmentChange = false]
*/
BookReader.prototype.updateFirstIndex = function(
index,
{ suppressFragmentChange = false } = {},
) {
// If there's no change, do nothing
if (this.firstIndex === index) return;
this.firstIndex = index;
if (!(this.suppressFragmentChange || suppressFragmentChange)) {
this.trigger(BookReader.eventNames.fragmentChange);
}
// If there's an initial search we stop suppressing global URL changes
// when local suppression ends
// This seems to correctly handle multiple calls during mode/1up
if (this.options.initialSearchTerm && !suppressFragmentChange) {
this.suppressFragmentChange = false;
}
this.trigger(BookReader.eventNames.pageChanged);
// event to know if user is actively reading
this.trigger(BookReader.eventNames.userAction);
this._components.navbar.updateNavIndexThrottled(index);
};
/**
* Flip the right page over onto the left
*/
BookReader.prototype.right = function() {
if ('rl' != this.pageProgression) {
this.next();
} else {
this.prev();
}
};
/**
* Flip to the rightmost page
*/
BookReader.prototype.rightmost = function() {
if ('rl' != this.pageProgression) {
this.last();
} else {
this.first();
}
};
/**
* Flip the left page over onto the right
*/
BookReader.prototype.left = function() {
if ('rl' != this.pageProgression) {
this.prev();
} else {
this.next();
}
};
/**
* Flip to the leftmost page
*/
BookReader.prototype.leftmost = function() {
if ('rl' != this.pageProgression) {
this.first();
} else {
this.last();
}
};
BookReader.prototype.next = function({triggerStop = true} = {}) {
if (this.constMode2up == this.mode) {
if (triggerStop) this.trigger(BookReader.eventNames.stop);
this._modes.mode2Up.mode2UpLit.flipAnimation('next');
} else {
if (this.firstIndex < this.book.getNumLeafs() - 1) {
this.jumpToIndex(this.firstIndex + 1);
}
}
};
BookReader.prototype.prev = function({triggerStop = true} = {}) {
const isOnFrontPage = this.firstIndex < 1;
if (isOnFrontPage) return;
if (this.constMode2up == this.mode) {
if (triggerStop) this.trigger(BookReader.eventNames.stop);
this._modes.mode2Up.mode2UpLit.flipAnimation('prev');
} else {
if (this.firstIndex >= 1) {
this.jumpToIndex(this.firstIndex - 1);
}
}
};
BookReader.prototype.first = function() {
this.jumpToIndex(0);
};
BookReader.prototype.last = function() {
this.jumpToIndex(this.book.getNumLeafs() - 1);
};
/**
* @template TClass extends { br: BookReader }
* Helper method to expose a method onto BookReader from a composed class.
* Only composed classes in BookReader._overridable can be exposed in this
* way.
* @param {new () => TClass} Class
* @param {keyof BookReader['_overrideable']} classKey
* @param {keyof TClass} method
* @param {string} [brMethod]
*/
function exposeOverrideableMethod(Class, classKey, method, brMethod = method) {
/** @type {function(TClass): BookReader} */
const classToBr = cls => cls.br;
/** @type {function(BookReader): TClass} */
const brToClass = br => br._overrideable[classKey];
exposeOverrideable(Class, method, classToBr, BookReader, brMethod, brToClass);
}
/***********************/
/** Navbar extensions **/
/***********************/
/** This cannot be removed yet because plugin.tts.js overrides it */
BookReader.prototype.initNavbar = Navbar.prototype.init;
exposeOverrideableMethod(Navbar, '_components.navbar', 'init', 'initNavbar');
/************************/
/** Toolbar extensions **/
/************************/
BookReader.prototype.buildToolbarElement = Toolbar.prototype.buildToolbarElement;
exposeOverrideableMethod(Toolbar, '_components.toolbar', 'buildToolbarElement');
BookReader.prototype.initToolbar = Toolbar.prototype.initToolbar;
exposeOverrideableMethod(Toolbar, '_components.toolbar', 'initToolbar');
BookReader.prototype.buildShareDiv = Toolbar.prototype.buildShareDiv;
exposeOverrideableMethod(Toolbar, '_components.toolbar', 'buildShareDiv');
BookReader.prototype.buildInfoDiv = Toolbar.prototype.buildInfoDiv;
exposeOverrideableMethod(Toolbar, '_components.toolbar', 'buildInfoDiv');
BookReader.prototype.getToolBarHeight = Toolbar.prototype.getToolBarHeight;
exposeOverrideableMethod(Toolbar, '_components.toolbar', 'getToolBarHeight');
/**
* Bind navigation handlers
*/
BookReader.prototype.bindNavigationHandlers = function() {
const self = this;
const jIcons = this.$('.BRicon');
// Map of jIcon class -> click handler
const navigationControls = {
book_left: () => {
this.trigger(BookReader.eventNames.stop);
this.left();
},
book_right: () => {
this.trigger(BookReader.eventNames.stop);
this.right();
},
book_top: this.first.bind(this),
book_bottom: this.last.bind(this),
book_leftmost: this.leftmost.bind(this),
book_rightmost: this.rightmost.bind(this),
onepg: () => {
this.switchMode(self.constMode1up);
},
thumb: () => {
this.switchMode(self.constModeThumb);
},
twopg: () => {
this.switchMode(self.constMode2up);
},
zoom_in: () => {
this.trigger(BookReader.eventNames.stop);
this.zoom(1);
this.trigger(BookReader.eventNames.zoomIn);
},
zoom_out: () => {
this.trigger(BookReader.eventNames.stop);
this.zoom(-1);
this.trigger(BookReader.eventNames.zoomOut);
},
full: () => {
if (this.ui == 'embed') {
const url = this.$('.BRembedreturn a').attr('href');
window.open(url);
} else {
this.toggleFullscreen();
}
},
};
// custom event for auto-loan-renew in ia-book-actions
// - to know if user is actively reading
this.$('nav.BRcontrols li button').on('click', () => {
this.trigger(BookReader.eventNames.userAction);
});
for (const control in navigationControls) {
jIcons.filter(`.${control}`).on('click.bindNavigationHandlers', () => {
navigationControls[control]();
return false;
});
}
const $brNavCntlBtmEl = this.$('.BRnavCntlBtm');
const $brNavCntlTopEl = this.$('.BRnavCntlTop');
this.$('.BRnavCntl').click(
function() {
const promises = [];
// TODO don't use magic constants
// TODO move this to a function
if ($brNavCntlBtmEl.hasClass('BRdn')) {
if (self.refs.$BRtoolbar)
promises.push(self.refs.$BRtoolbar.animate(
{top: self.getToolBarHeight() * -1},
).promise());
promises.push(self.$('.BRfooter').animate({bottom: self.getFooterHeight() * -1}).promise());
$brNavCntlBtmEl.addClass('BRup').removeClass('BRdn');
$brNavCntlTopEl.addClass('BRdn').removeClass('BRup');
self.$('.BRnavCntlBtm.BRnavCntl').animate({height:'45px'});
self.$('.BRnavCntl').delay(1000).animate({opacity:.75}, 1000);
} else {
if (self.refs.$BRtoolbar)
promises.push(self.refs.$BRtoolbar.animate({top:0}).promise());
promises.push(self.$('.BRfooter').animate({bottom:0}).promise());
$brNavCntlBtmEl.addClass('BRdn').removeClass('BRup');
$brNavCntlTopEl.addClass('BRup').removeClass('BRdn');
self.$('.BRnavCntlBtm.BRnavCntl').animate({height:'30px'});
self.$('.BRvavCntl').animate({opacity:1});
}
$.when.apply($, promises).done(function() {
// Only do full resize in auto mode and need to recalc. size
if (self.mode == self.constMode2up && self.twoPage.autofit != null
&& self.twoPage.autofit != 'none'
) {
self.resize();
} else if (self.mode == self.constMode1up && self.onePage.autofit != null
&& self.onePage.autofit != 'none') {
self.resize();
} else {
// Don't do a full resize to avoid redrawing images
self.resizeBRcontainer();
}
});
},
);
$brNavCntlBtmEl
.on("mouseover", function() {
if ($(this).hasClass('BRup')) {
self.$('.BRnavCntl').animate({opacity:1},250);
}
})
.on("mouseleave", function() {
if ($(this).hasClass('BRup')) {
self.$('.BRnavCntl').animate({opacity:.75},250);
}
});
$brNavCntlTopEl
.on("mouseover", function() {
if ($(this).hasClass('BRdn')) {
self.$('.BRnavCntl').animate({opacity:1},250);
}
})
.on("mouseleave", function() {
if ($(this).hasClass('BRdn')) {
self.$('.BRnavCntl').animate({opacity:.75},250);
}
});
};
/**************************/
/** BookModel extensions **/
/**************************/
// Must modify petabox extension, which expects this on the prototype
// before removing.
BookReader.prototype.getPageURI = BookModel.prototype.getPageURI;
exposeOverrideableMethod(BookModel, 'book', 'getPageURI');
// Parameter related functions
/**
* Update from the params object
* @param {Object}
*/
BookReader.prototype.updateFromParams = function(params) {
// Set init, fragment change options for switchMode()
const {
mode = 0,
init = false,
fragmentChange = false,
} = params;
if (mode) {
this.switchMode(
mode,
{ init: init, suppressFragmentChange: !fragmentChange },
);
}
// $$$ process /zoom
// We only respect page if index is not set
if ('undefined' != typeof(params.index)) {
if (params.index != this.currentIndex()) {
this.jumpToIndex(params.index);
}
} else if ('undefined' != typeof(params.page)) {
// $$$ this assumes page numbers are unique
if (params.page != this.book.getPageNum(this.currentIndex())) {
this.jumpToPage(params.page);
}
}
// process /search
// @deprecated for urlMode 'history'
// Continues to work for urlMode 'hash'
if (this.enableSearch && 'undefined' != typeof(params.search)) {
if (this.searchTerm !== params.search) {
this.$('.BRsearchInput').val(params.search);
}
}
// $$$ process /region
// $$$ process /highlight
// $$$ process /theme
if (this.enableThemesPlugin && 'undefined' != typeof(params.theme)) {
this.updateTheme(params.theme);
}
};
/**
* Returns true if we can switch to the requested mode
* @param {number} mode
* @return {boolean}
*/
BookReader.prototype.canSwitchToMode = function(mode) {
if (mode == this.constMode2up || mode == this.constModeThumb) {
// check there are enough pages to display
// $$$ this is a workaround for the mis-feature that we can't display
// short books in 2up mode
if (this.book.getNumLeafs() < 2) {
return false;
}
}
return true;
};
/**
* Returns the page URI or transparent image if out of range
* Also makes the reduce argument optional
* @param {number} index
* @param {number} [reduce]
* @param {number} [rotate]
* @return {string}
*/
BookReader.prototype._getPageURI = function(index, reduce, rotate) {
const page = this.book.getPage(index, false);
// Synthesize page
if (!page) return this.imagesBaseURL + "transparent.png";
if ('undefined' == typeof(reduce)) {
// reduce not passed in
// $$$ this probably won't work for thumbnail mode
reduce = page.height / this.twoPage.height;
}
return page.getURI(reduce, rotate);
};
/**
* @param {string} msg
* @param {function|undefined} onCloseCallback
*/
BookReader.prototype.showProgressPopup = function(msg, onCloseCallback) {
if (this.popup) return;
this.popup = document.createElement("div");
$(this.popup).prop('className', 'BRprogresspopup');
if (typeof(onCloseCallback) === 'function') {
const closeButton = document.createElement('button');
closeButton.setAttribute('title', 'close');
closeButton.setAttribute('class', 'close-popup');
const icon = document.createElement('span');
icon.setAttribute('class', 'icon icon-close-dark');
$(closeButton).append(icon);
closeButton.addEventListener('click', () => {
onCloseCallback();
this.removeProgressPopup();
});
$(this.popup).append(closeButton);
}
const bar = document.createElement("div");
$(bar).css({
height: '20px',
}).prop('className', 'BRprogressbar');
$(this.popup).append(bar);
if (msg) {
const msgdiv = document.createElement("div");
msgdiv.innerHTML = msg;
$(this.popup).append(msgdiv);
}
$(this.popup).appendTo(this.refs.$br);
};
BookReader.prototype.removeProgressPopup = function() {
$(this.popup).remove();
this.$('.BRprogresspopup').remove();
this.popup = null;
};
/**
* Can be overridden
*/
BookReader.prototype.initUIStrings = function() {
// Navigation handlers will be bound after all UI is in place -- makes moving icons between
// the toolbar and nav bar easier
// Setup tooltips -- later we could load these from a file for i18n
const titles = {
'.logo': 'Go to Archive.org', // $$$ update after getting OL record
'.zoom_in': 'Zoom in',
'.zoom_out': 'Zoom out',
'.onepg': 'One-page view',
'.twopg': 'Two-page view',
'.thumb': 'Thumbnail view',
'.print': 'Print this page',
'.embed': 'Embed BookReader',
'.link': 'Link to this book (and page)',
'.bookmark': 'Bookmark this page',
'.share': 'Share this book',
'.info': 'About this book',