@internetarchive/bookreader
Version:
The Internet Archive BookReader.
591 lines (532 loc) • 17.1 kB
JavaScript
// eslint-disable-next-line no-unused-vars
import { SharedResizeObserver } from '@internetarchive/shared-resize-observer';
// eslint-disable-next-line no-unused-vars
import { ModalManager } from '@internetarchive/modal-manager';
import { css, html, LitElement, nothing } from 'lit';
import SearchProvider from './search/search-provider.js';
import DownloadProvider from './downloads/downloads-provider.js';
import VisualAdjustmentProvider from './visual-adjustments/visual-adjustments-provider.js';
import BookmarksProvider from './bookmarks/bookmarks-provider.js';
import SharingProvider from './sharing.js';
import ViewableFilesProvider from './viewable-files.js';
import iaLogo from './assets/ia-logo.js';
const events = {
menuUpdated: 'menuUpdated',
updateSideMenu: 'updateSideMenu',
PostInit: 'PostInit',
ViewportInFullScreen: 'ViewportInFullScreen',
};
export class BookNavigator extends LitElement {
static get properties() {
return {
itemMD: { type: Object },
bookReaderLoaded: { type: Boolean },
bookreader: { type: Object },
bookIsRestricted: { type: Boolean },
downloadableTypes: { type: Array },
isAdmin: { type: Boolean },
lendingInitialized: { type: Boolean },
lendingStatus: { type: Object },
menuProviders: { type: Object },
menuShortcuts: { type: Array },
signedIn: { type: Boolean },
loaded: { type: Boolean },
sharedObserver: { type: Object, attribute: false },
modal: { type: Object, attribute: false },
fullscreenBranding: { type: Object },
};
}
constructor() {
super();
this.itemMD = undefined;
this.loaded = false;
this.bookReaderCannotLoad = false;
this.bookReaderLoaded = false;
this.bookreader = null;
this.bookIsRestricted = false;
this.downloadableTypes = [];
this.isAdmin = false;
this.lendingInitialized = false;
this.lendingStatus = {};
this.menuProviders = {};
this.menuShortcuts = [];
this.signedIn = false;
/** @type {ModalManager} */
this.modal = undefined;
/** @type {SharedResizeObserver} */
this.sharedObserver = undefined;
this.fullscreenBranding = iaLogo;
// Untracked properties
this.sharedObserverHandler = undefined;
this.brWidth = 0;
this.brHeight = 0;
this.shortcutOrder = [
/**
* sets exit FS button (`this.fullscreenBranding1)
* when `br.options.enableFSLogoShortcut`
*/
'fullscreen',
'volumes',
'chapters',
'search',
'bookmarks',
];
}
disconnectedCallback() {
this.sharedObserver.removeObserver({
target: this.mainBRContainer,
handler: this.sharedObserverHandler,
});
}
firstUpdated() {
this.bindEventListeners();
this.emitPostInit();
this.loaded = true;
}
updated(changed) {
if (!this.bookreader || !this.itemMD || !this.bookReaderLoaded) {
return;
}
const reload = changed.has('loaded') && this.loaded;
if (reload
|| changed.has('itemMD')
|| changed.has('bookreader')
|| changed.has('signedIn')
|| changed.has('isAdmin')
|| changed.has('modal')) {
this.initializeBookSubmenus();
}
if (changed.has('sharedObserver') && this.bookreader) {
this.loadSharedObserver();
this.initializeBookSubmenus();
}
if (changed.has('downloadableTypes')) {
this.initializeBookSubmenus();
}
}
/**
* Global event emitter for when Book Navigator loads
*/
emitPostInit() {
// emit global event when book nav has loaded with current bookreader selector
this.dispatchEvent(new CustomEvent(`BrBookNav:${events.PostInit}`, {
detail: { brSelector: this.bookreader?.el },
bubbles: true,
composed: true,
}));
}
/**
* @typedef {{
* baseHost: string,
* modal: ModalManager,
* sharedObserver: SharedResizeObserver,
* bookreader: BookReader,
* item: Item,
* signedIn: boolean,
* isAdmin: boolean,
* onProviderChange: (BookReader, object) => void,
* }} baseProviderConfig
*
* @return {baseProviderConfig}
*/
get baseProviderConfig() {
return {
baseHost: this.baseHost,
modal: this.modal,
sharedObserver: this.sharedObserver,
bookreader: this.bookreader,
item: this.itemMD,
signedIn: this.signedIn,
isAdmin: this.isAdmin,
onProviderChange: () => {},
};
}
get isWideEnoughToOpenMenu() {
return this.brWidth >= 640;
}
/**
* Instantiates books submenus & their update callbacks
*
* NOTE: we are doing our best to scope bookreader's instance.
* If your submenu provider uses a bookreader instance to read, manually
* manipulate BookReader, please update the navigator's instance of it
* to keep it in sync.
*/
initializeBookSubmenus() {
const providers = {
visualAdjustments: new VisualAdjustmentProvider({
...this.baseProviderConfig,
/** Update menu contents */
onProviderChange: () => {
this.updateMenuContents();
},
}),
};
if (this.baseProviderConfig.item) {
// Share options currently rely on IA item metadata
providers.share = new SharingProvider(this.baseProviderConfig);
}
if (this.shouldShowDownloadsMenu()) {
providers.downloads = new DownloadProvider(this.baseProviderConfig);
}
if (this.bookreader.options.enableSearch) {
providers.search = new SearchProvider({
...this.baseProviderConfig,
/**
* Search specific menu updates
* @param {BookReader} brInstance
* @param {{ searchCanceled: boolean }} searchUpdates
*/
onProviderChange: (brInstance = null, searchUpdates = {}) => {
if (brInstance) {
/* refresh br instance reference */
this.bookreader = brInstance;
}
this.updateMenuContents();
if (searchUpdates.openMenu === false) {
return;
}
if (this.isWideEnoughToOpenMenu && !searchUpdates?.searchCanceled) {
/* open side search menu */
setTimeout(() => {
this.updateSideMenu('search', 'open');
}, 0);
}
},
});
}
if (this.bookreader.options.enableBookmarks) {
providers.bookmarks = new BookmarksProvider({
...this.baseProviderConfig,
onProviderChange: (bookmarks) => {
const method = Object.keys(bookmarks).length ? 'add' : 'remove';
this[`${method}MenuShortcut`]('bookmarks');
this.updateMenuContents();
},
});
}
// add shortcut for volumes if multipleBooksList exists
if (this.bookreader.options.enableMultipleBooks) {
providers.volumes = new ViewableFilesProvider({
...this.baseProviderConfig,
onProviderChange: (brInstance = null, volumesUpdates = {}) => {
if (brInstance) {
/* refresh br instance reference */
this.bookreader = brInstance;
}
this.updateMenuContents();
if (this.isWideEnoughToOpenMenu) {
/* open side search menu */
setTimeout(() => {
this.updateSideMenu('volumes', 'open');
});
}
},
});
}
Object.assign(this.menuProviders, providers);
this.addMenuShortcut('search');
this.addMenuShortcut('volumes');
this.updateMenuContents();
}
/** gets element that houses the bookreader in light dom */
get mainBRContainer() {
return document.querySelector(this.bookreader?.el);
}
/** Fullscreen Shortcut */
addFullscreenShortcut() {
const closeFS = {
icon: this.fullscreenShortcut,
id: 'fullscreen',
};
this.menuShortcuts.push(closeFS);
this.sortMenuShortcuts();
this.emitMenuShortcutsUpdated();
}
deleteFullscreenShortcut() {
const updatedShortcuts = this.menuShortcuts.filter(({ id }) => {
return id !== 'fullscreen';
});
this.menuShortcuts = updatedShortcuts;
this.sortMenuShortcuts();
this.emitMenuShortcutsUpdated();
}
closeFullscreen() {
this.bookreader.exitFullScreen();
}
get fullscreenShortcut() {
return html`
<button
=${() => this.closeFullscreen()}
title="Exit fullscreen view"
>${this.fullscreenBranding}</button>
`;
}
/** End Fullscreen Shortcut */
/**
* Open side menu
* @param {string} menuId
* @param {('open'|'close'|'toggle')} action
*/
updateSideMenu(menuId = '', action = 'open') {
if (!menuId) {
return;
}
const event = new CustomEvent(
events.updateSideMenu, {
detail: { menuId, action },
},
);
this.dispatchEvent(event);
}
/**
* Sets order of menu and emits custom event when done
*/
updateMenuContents() {
const {
search, downloads, visualAdjustments, share, bookmarks, volumes, chapters,
} = this.menuProviders;
const availableMenus = [volumes, chapters, search, bookmarks, visualAdjustments, share].filter((menu) => !!menu);
if (this.shouldShowDownloadsMenu()) {
downloads?.update(this.downloadableTypes);
availableMenus.splice(-2, 0, downloads);
}
const event = new CustomEvent(
events.menuUpdated, {
detail: availableMenus,
},
);
this.dispatchEvent(event);
}
/**
* Confirms if we should show the downloads menu
* @returns {bool}
*/
shouldShowDownloadsMenu() {
if (!this.downloadableTypes.length) { return false; }
if (this.bookIsRestricted === false) { return true; }
if (this.isAdmin) { return true; }
const { user_loan_record = {} } = this.lendingStatus;
const hasNoLoanRecord = Array.isArray(user_loan_record); /* (bc PHP assoc. arrays) */
if (hasNoLoanRecord) { return false; }
const hasValidLoan = user_loan_record.type && (user_loan_record.type !== 'SESSION_LOAN');
return hasValidLoan;
}
/**
* Adds a provider object to the menuShortcuts array property if it isn't
* already added. menuShortcuts are then sorted by shortcutOrder and
* a menuShortcutsUpdated event is emitted.
*
* @param {string} menuId - a string matching the id property of a provider
*/
addMenuShortcut(menuId) {
if (this.menuShortcuts.find((m) => m.id === menuId)) {
// menu is already there
return;
}
if (!this.menuProviders[menuId]) {
// no provider for this menu
return;
}
this.menuShortcuts.push(this.menuProviders[menuId]);
this.sortMenuShortcuts();
this.emitMenuShortcutsUpdated();
}
/**
* Removes a provider object from the menuShortcuts array and emits a
* menuShortcutsUpdated event.
*
* @param {string} menuId - a string matching the id property of a provider
*/
removeMenuShortcut(menuId) {
this.menuShortcuts = this.menuShortcuts.filter((m) => m.id !== menuId);
this.emitMenuShortcutsUpdated();
}
/**
* Sorts the menuShortcuts property by comparing each provider's id to
* the id in each iteration over the shortcutOrder array.
*/
sortMenuShortcuts() {
this.menuShortcuts = this.shortcutOrder.reduce((shortcuts, id) => {
const menu = this.menuShortcuts.find((m) => m.id === id);
if (menu) { shortcuts.push(menu); }
return shortcuts;
}, []);
}
emitMenuShortcutsUpdated() {
const event = new CustomEvent('menuShortcutsUpdated', {
detail: this.menuShortcuts,
});
this.dispatchEvent(event);
}
emitLoadingStatusUpdate(loaded) {
const event = new CustomEvent('loadingStateUpdated', {
detail: { loaded },
});
this.dispatchEvent(event);
}
/**
* Core bookreader event handler registry
*
* NOTE: we are trying to keep bookreader's instance in scope
* Please update Book Navigator's instance reference of it to keep it current
*/
bindEventListeners() {
window.addEventListener('BookReader:PostInit', (e) => {
this.bookreader = e.detail.props;
this.bookreader.shell = this;
this.bookReaderLoaded = true;
this.bookReaderCannotLoad = false;
this.emitLoadingStatusUpdate(true);
this.loadSharedObserver();
setTimeout(() => {
this.bookreader.resize();
}, 0);
});
window.addEventListener('BookReader:fullscreenToggled', (event) => {
const { detail: { props: brInstance = null } } = event;
if (brInstance) {
this.bookreader = brInstance;
}
this.manageFullScreenBehavior();
}, { passive: true });
window.addEventListener('BookReader:ToggleSearchMenu', (event) => {
this.dispatchEvent(new CustomEvent(events.updateSideMenu, {
detail: { menuId: 'search', action: 'toggle' },
}));
});
window.addEventListener('LendingFlow:PostInit', ({ detail }) => {
const {
downloadTypesAvailable, lendingStatus, isAdmin, previewType,
} = detail;
this.lendingInitialized = true;
this.downloadableTypes = downloadTypesAvailable;
this.lendingStatus = lendingStatus;
this.isAdmin = isAdmin;
this.bookReaderCannotLoad = previewType === 'singlePagePreview';
this.emitLoadingStatusUpdate(true);
});
window.addEventListener('BRJSIA:PostInit', ({ detail }) => {
const { isRestricted, downloadURLs } = detail;
this.bookReaderLoaded = true;
this.downloadableTypes = downloadURLs;
this.bookIsRestricted = isRestricted;
});
window.addEventListener('contextmenu', (e) => this.manageContextMenuVisibility(e), { capture: true });
}
/** Display an element's context menu */
manageContextMenuVisibility(e) {
window.archive_analytics?.send_event(
'BookReader',
`contextmenu-${this.bookIsRestricted ? 'restricted' : 'unrestricted'}`,
e.target?.classList?.value,
);
if (!this.bookIsRestricted) {
return;
}
const imagePane = e.target.classList.value.match(/BRscreen|BRpageimage/g);
if (!imagePane) {
return;
}
e.preventDefault();
return false;
}
loadSharedObserver() {
this.sharedObserverHandler = { handleResize: this.handleResize.bind(this) };
this.sharedObserver?.addObserver({
target: this.mainBRContainer,
handler: this.sharedObserverHandler,
});
}
/**
* Uses resize observer to fire BookReader's `resize` functionality
* We do not want to trigger resize IF:
* - book animation is happening
* - book is in fullscreen (fullscreen is handled separately)
*
* @param { target: HTMLElement, contentRect: DOMRectReadOnly } entry
*/
handleResize({ contentRect, target }) {
const startBrWidth = this.brWidth;
const startBrHeight = this.brHeight;
const { animating } = this.bookreader;
if (target === this.mainBRContainer) {
this.brWidth = contentRect.width;
this.brHeight = contentRect.height;
}
if (!startBrWidth && this.brWidth) {
// loading up, let's update side menus
this.initializeBookSubmenus();
}
const widthChange = startBrWidth !== this.brWidth;
const heightChange = startBrHeight !== this.brHeight;
if (!animating && (widthChange || heightChange)) {
this.bookreader?.resize();
}
}
/**
* Manages Fullscreen behavior
* This makes sure that controls are _always_ in view
* We need this to accommodate LOAN BAR during fullscreen
*/
manageFullScreenBehavior() {
this.emitFullScreenState();
if (!this.bookreader.options.enableFSLogoShortcut) {
return;
}
const isFullScreen = this.bookreader.isFullscreen();
if (isFullScreen) {
this.addFullscreenShortcut();
} else {
this.deleteFullscreenShortcut();
}
}
/**
* Relays fullscreen toggle events
*/
emitFullScreenState() {
const isFullScreen = this.bookreader.isFullscreen();
const event = new CustomEvent('ViewportInFullScreen', {
detail: { isFullScreen },
});
this.dispatchEvent(event);
}
get itemImage() {
const identifier = this.itemMD?.metadata.identifier;
const url = `https://${this.baseHost}/services/img/${identifier}`;
return html`<img class="cover-img" src=${url} alt="cover image for ${identifier}">`;
}
get placeholder() {
return html`<div class="placeholder">${this.itemImage}</div>`;
}
render() {
return html`<div id="book-navigator__root">
${this.bookReaderCannotLoad ? this.placeholder : nothing}
${!this.bookReaderCannotLoad ? html`<slot name="main"></slot>` : nothing}
</div>
`;
}
static get styles() {
return css`
:host,
#book-navigator__root,
slot,
slot > * {
display: block;
height: inherit;
width: inherit;
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
margin: 5%;
}
.cover-img {
max-height: 300px;
}
`;
}
}
customElements.define('book-navigator', BookNavigator);