@internetarchive/bookreader
Version:
The Internet Archive BookReader.
389 lines (323 loc) • 11.5 kB
JavaScript
// @ts-check
import { customElement, property, query } from 'lit/decorators.js';
import {LitElement, html} from 'lit';
import { styleMap } from 'lit/directives/style-map.js';
import { ModeSmoothZoom } from './ModeSmoothZoom';
import { arrChanged, genToArray, sum, throttle } from './utils';
import { HTMLDimensionsCacher } from "./utils/HTMLDimensionsCacher";
import { ScrollClassAdder } from './utils/ScrollClassAdder';
import { ModeCoordinateSpace } from './ModeCoordinateSpace';
/** @typedef {import('./BookModel').BookModel} BookModel */
/** @typedef {import('./BookModel').PageIndex} PageIndex */
/** @typedef {import('./BookModel').PageModel} PageModel */
/** @typedef {import('./ModeSmoothZoom').SmoothZoomable} SmoothZoomable */
/** @typedef {import('./PageContainer').PageContainer} PageContainer */
/** @typedef {import('../BookReader').default} BookReader */
// I _have_ to make this globally public, otherwise it won't let me call
// it's constructor :/
/** @implements {SmoothZoomable} */
export class Mode1UpLit extends LitElement {
/****************************************/
/************** PROPERTIES **************/
/****************************************/
/** @type {BookReader} */
br;
/************** BOOK-RELATED PROPERTIES **************/
/** @type {BookModel} */
book;
/** @type {PageModel[]} */
pages = [];
/** @type {Record<PageIndex, number>} in world coordinates (inches) */
pageTops = {};
/************** SCALE-RELATED PROPERTIES **************/
/** @type {ModeCoordinateSpace} Manage conversion between coordinates */
coordSpace = new ModeCoordinateSpace(this);
scale = 1;
/************** VIRTUAL-SCROLLING PROPERTIES **************/
/** in world coordinates (inches) */
visibleRegion = {
top: 0,
left: 0,
width: 100,
height: 100,
};
/** @type {PageModel[]} */
visiblePages = [];
/** @type {PageModel[]} */
renderedPages = [];
/** @type {Record<PageIndex, PageContainer>} position in inches */
pageContainerCache = {};
/************** WORLD-RELATED PROPERTIES **************/
/**
* The world is an imaginary giant document that contains all the pages.
* The "world"'s size is used to determine how long the scroll bar should
* be, for example.
*/
/** @type {HTMLElement} */
$world;
worldDimensions = { width: 100, height: 100 };
get worldStyle() {
const wToR = this.coordSpace.worldUnitsToRenderedPixels;
return {
width: wToR(this.worldDimensions.width) + "px",
height: wToR(this.worldDimensions.height) + "px",
};
}
/** @type {HTMLElement} */
get $container() { return this; }
/** @type {HTMLElement} */
$visibleWorld;
/************** DOM-RELATED PROPERTIES **************/
/** @type {HTMLDimensionsCacher} Cache things like clientWidth to reduce repaints */
htmlDimensionsCacher = new HTMLDimensionsCacher(this);
smoothZoomer = new ModeSmoothZoom(this);
scrollClassAdder = new ScrollClassAdder(this, 'BRscrolling-active');
/************** CONSTANT PROPERTIES **************/
/** Vertical space between/around the pages in inches */
SPACING_IN = 0.2;
/** How much to zoom when zoom button pressed */
ZOOM_FACTOR = 1.1;
/****************************************/
/************** PUBLIC API **************/
/****************************************/
/************** MAIN PUBLIC METHODS **************/
/**
* @param {PageIndex} index
*/
jumpToIndex(index, { smooth = false } = {}) {
if (smooth) {
this.style.scrollBehavior = 'smooth';
}
this.scrollTop = this.coordSpace.worldUnitsToVisiblePixels(this.pageTops[index] - this.SPACING_IN / 2);
// TODO: Also h center?
if (smooth) {
setTimeout(() => this.style.scrollBehavior = '', 100);
}
}
zoomIn() {
this.scale *= this.ZOOM_FACTOR;
}
zoomOut() {
this.scale *= 1 / this.ZOOM_FACTOR;
}
/********************************************/
/************** INTERNAL STUFF **************/
/********************************************/
/************** LIFE CYCLE **************/
/**
* @param {BookModel} book
* @param {BookReader} br
*/
constructor(book, br) {
super();
this.book = book;
/** @type {BookReader} */
this.br = br;
}
/** @override */
firstUpdated(changedProps) {
super.firstUpdated(changedProps);
this.htmlDimensionsCacher.updateClientSizes();
this.smoothZoomer.attach();
}
/**
* @param {PageIndex} startIndex
*/
initFirstRender(startIndex) {
const page = this.book.getPage(startIndex);
this.scale = this.computeDefaultScale(page);
}
/** @override */
updated(changedProps) {
// this.X is the new value
// changedProps.get('X') is the old value
if (changedProps.has('book')) {
this.updatePages();
}
if (changedProps.has('pages')) {
this.worldDimensions = this.computeWorldDimensions();
this.pageTops = this.computePageTops(this.pages, this.SPACING_IN);
}
if (changedProps.has('visibleRegion')) {
this.visiblePages = this.computeVisiblePages();
}
if (changedProps.has('visiblePages')) {
this.throttledUpdateRenderedPages();
if (this.visiblePages.length) {
// unclear why this is ever really happening
this.br.displayedIndices = this.visiblePages.map(p => p.index);
this.br.updateFirstIndex(this.br.displayedIndices[0]);
this.br._components.navbar.updateNavIndexThrottled();
}
}
if (changedProps.has('scale')) {
const oldVal = changedProps.get('scale');
// Need to set this scale to actually scale the pages
this.$visibleWorld.style.transform = `scale(${this.scale})`;
this.smoothZoomer.updateViewportOnZoom(this.scale, oldVal);
this.updateVisibleRegion();
// Need to set this scale to update the world size, so the scrollbar gets the correct size
this.$world.style.transform = `scale(${this.scale})`;
}
}
updatePages() {
this.pages = genToArray(this.book.pagesIterator({ combineConsecutiveUnviewables: true }));
}
/** @override */
connectedCallback() {
super.connectedCallback();
this.htmlDimensionsCacher.attachResizeListener();
this.attachScrollListeners();
this.smoothZoomer.attach();
}
/** @override */
disconnectedCallback() {
this.htmlDimensionsCacher.detachResizeListener();
this.detachScrollListeners();
this.smoothZoomer.detach();
super.disconnectedCallback();
}
/************** LIT CONFIGS **************/
/** @override */
createRenderRoot() {
// Disable shadow DOM; that would require a huge rejiggering of CSS
return this;
}
/************** RENDERING **************/
/** @override */
render() {
return html`
<div class="br-mode-1up__world" style=${styleMap(this.worldStyle)}></div>
<div class="br-mode-1up__visible-world">
${this.renderedPages.map(p => this.renderPage(p))}
</div>`;
}
/** @param {PageModel} page */
createPageContainer = (page) => {
return this.pageContainerCache[page.index] || (
this.pageContainerCache[page.index] = (
// @ts-ignore I know it's protected, TS! But Mode1Up and BookReader are friends.
this.br._createPageContainer(page.index)
)
);
}
/** @param {PageModel} page */
renderPage = (page) => {
const wToR = this.coordSpace.worldUnitsToRenderedPixels;
const wToV = this.coordSpace.worldUnitsToVisiblePixels;
const containerWidth = this.coordSpace.visiblePixelsToWorldUnits(this.htmlDimensionsCacher.clientWidth);
const width = wToR(page.widthInches);
const height = wToR(page.heightInches);
const left = Math.max(this.SPACING_IN, (containerWidth - page.widthInches) / 2);
const top = this.pageTops[page.index];
const transform = `translate(${wToR(left)}px, ${wToR(top)}px)`;
const pageContainerEl = this.createPageContainer(page)
.update({
dimensions: {
width,
height,
top: 0,
left: 0,
},
reduce: page.width / wToV(page.widthInches),
}).$container[0];
pageContainerEl.style.transform = transform;
pageContainerEl.classList.toggle('BRpage-visible', this.visiblePages.includes(page));
return pageContainerEl;
}
/************** VIRTUAL SCROLLING LOGIC **************/
updateVisibleRegion = () => {
const { scrollTop, scrollLeft } = this;
// clientHeight excludes scrollbars, which is good.
const clientWidth = this.htmlDimensionsCacher.clientWidth;
const clientHeight = this.htmlDimensionsCacher.clientHeight;
// Note: scrollTop, and clientWidth all are in visible space;
// i.e. they are affects by the CSS transforms.
const vToW = this.coordSpace.visiblePixelsToWorldUnits;
this.visibleRegion = {
top: vToW(scrollTop),
height: vToW(clientHeight),
// TODO: These are very likely wrong
left: vToW(scrollLeft),
width: vToW(clientWidth),
};
}
/**
* @returns {PageModel[]}
*/
computeRenderedPages() {
// Also render 1 page before/after
// @ts-ignore TS doesn't understand the filtering out of null values
return [
this.visiblePages[0]?.prev,
...this.visiblePages,
this.visiblePages[this.visiblePages.length - 1]?.next,
]
.filter(p => p)
// Never render more than 10 pages! Usually means something is wrong
.slice(0, 10);
}
throttledUpdateRenderedPages = throttle(() => {
this.renderedPages = this.computeRenderedPages();
this.requestUpdate();
}, 100, null)
/**
* @param {PageModel[]} pages
* @param {number} spacing
*/
computePageTops(pages, spacing) {
/** @type {{ [pageIndex: string]: number }} */
const result = {};
let top = spacing;
for (const page of pages) {
result[page.index] = top;
top += page.heightInches + spacing;
}
return result;
}
/**
* @param {PageModel} page
* @returns {number}
*/
computeDefaultScale(page) {
// Default to real size if it fits, otherwise default to full width
const containerWidthIn = this.coordSpace.renderedPixelsToWorldUnits(this.clientWidth);
return Math.min(1, containerWidthIn / (page.widthInches + 2 * this.SPACING_IN)) || 1;
}
computeWorldDimensions() {
return {
width: Math.max(...this.pages.map(p => p.widthInches)) + 2 * this.SPACING_IN,
height:
sum(this.pages.map(p => p.heightInches)) +
(this.pages.length + 1) * this.SPACING_IN,
};
}
computeVisiblePages() {
return this.pages.filter(page => {
const PT = this.pageTops[page.index];
const PB = PT + page.heightInches;
const VT = this.visibleRegion.top;
const VB = VT + this.visibleRegion.height;
return PT <= VB && PB >= VT;
});
}
/************** INPUT HANDLERS **************/
attachScrollListeners = () => {
this.addEventListener("scroll", this.updateVisibleRegion);
this.scrollClassAdder.attach();
}
detachScrollListeners = () => {
this.removeEventListener("scroll", this.updateVisibleRegion);
this.scrollClassAdder.detach();
}
}