UNPKG

@readium/navigator-html-injectables

Version:

An embeddable solution for connecting frames of HTML publications with a Readium Navigator

253 lines (217 loc) 9.21 kB
import { Locator, LocatorLocations, LocatorText } from "@readium/shared"; import { Comms } from "../../comms"; import { ReadiumWindow, deselect, findFirstVisibleLocator } from "../../helpers/dom"; import { ModuleName } from "../ModuleLibrary"; import { Snapper } from "./Snapper"; import { rangeFromLocator } from "../../helpers/locator"; import { forceWebkitRecalc } from "../../helpers/document"; const SCROLL_SNAPPER_STYLE_ID = "readium-scroll-snapper-style"; export class ScrollSnapper extends Snapper { static readonly moduleName: ModuleName = "scroll_snapper"; private wnd!: ReadiumWindow; private comms!: Comms; private resizeObserver!: ResizeObserver; private initialScrollHandled = false; private isScrolling = false; private lastScrollTop = 0; private isResizing = false; private resizeDebounce: number | null = null; private doc() { return this.wnd.document.scrollingElement as HTMLElement; } private reportProgress() { if (!this.comms.ready) return; // We have to round up the scroll position because // Android may never reach 100% of the scroll height // due to the way it rounds scrollTop… const scrollTop = Math.ceil(this.doc().scrollTop); const scrollHeight = this.doc().scrollHeight; const viewportHeight = this.wnd.innerHeight; const progress = Math.max(0, Math.min(1, scrollTop / scrollHeight)); const viewportEnd = Math.max(0, Math.min(1, (scrollTop + viewportHeight) / scrollHeight)); this.comms.send("progress", { start: progress, end: viewportEnd }); } private handleScroll = () => { if (!this.comms.ready) return; // We have to filter scroll from resize events if (this.isResizing) { return; } // We have to filter the first scroll event because // it is triggered by the progression sync’ing // on load, and is not triggered by the user if (!this.initialScrollHandled) { this.lastScrollTop = this.doc().scrollTop; this.initialScrollHandled = true; this.reportProgress(); return; } if (!this.isScrolling) { this.isScrolling = true; this.wnd.requestAnimationFrame(() => { this.reportProgress(); const currentScrollTop = this.doc().scrollTop; const deltaY = currentScrollTop - this.lastScrollTop; this.lastScrollTop = currentScrollTop; this.comms.send("scroll", deltaY); this.isScrolling = false; }); } }; mount(wnd: ReadiumWindow, comms: Comms): boolean { this.wnd = wnd; this.comms = comms; this.initialScrollHandled = false; this.lastScrollTop = 0; this.isResizing = false; if (this.resizeDebounce) { this.wnd.clearTimeout(this.resizeDebounce); this.resizeDebounce = null; } wnd.navigator.epubReadingSystem.layoutStyle = "scrolling"; // Add styling to hide the scrollbar const style = wnd.document.createElement("style"); style.dataset.readium = "true"; style.id = SCROLL_SNAPPER_STYLE_ID; style.textContent = ` * { scrollbar-width: none; /* for Firefox */ } body::-webkit-scrollbar { display: none; /* for Chrome, Safari, and Opera */ } `; wnd.document.head.appendChild(style); // We have to debounce resize events so that // we don’t send scroll events when the user // resizes the window this.resizeObserver = new ResizeObserver(() => { if (this.resizeDebounce) { this.wnd.clearTimeout(this.resizeDebounce); } this.isResizing = true; this.resizeDebounce = this.wnd.setTimeout(() => { this.isResizing = false; this.resizeDebounce = null; this.reportProgress(); }, 50); }); this.resizeObserver.observe(wnd.document.body); wnd.addEventListener("scroll", this.handleScroll, { passive: true }); comms.register("force_webkit_recalc", ScrollSnapper.moduleName, () => { forceWebkitRecalc(this.wnd); // We absolutely must do this because overflown content // won’t be rendered if we do not trigger scroll… // Only the content at the start of the document, // whose height is the viewport height, will be rendered. const currentScroll = this.doc().scrollTop; if (currentScroll > 1) { this.doc().scrollTop = currentScroll - 1; } else { this.doc().scrollTop = currentScroll + 1; } this.doc().scrollTop = currentScroll; }); comms.register("go_progression", ScrollSnapper.moduleName, (data, ack) => { const position = data as number; if (position < 0 || position > 1) { comms.send("error", { message: "go_progression must be given a position from 0.0 to 1.0" }); ack(false); return; } this.wnd.requestAnimationFrame(() => { this.doc().scrollTop = this.doc().offsetHeight * position; this.reportProgress(); deselect(this.wnd); ack(true); }); }); comms.register("go_id", ScrollSnapper.moduleName, (data, ack) => { const element = wnd.document.getElementById(data as string); if(!element) { ack(false); return; } this.wnd.requestAnimationFrame(() => { this.doc().scrollTop = element.getBoundingClientRect().top + wnd.scrollY - wnd.innerHeight / 2; this.reportProgress(); deselect(this.wnd); ack(true); }); }); comms.register("go_text", ScrollSnapper.moduleName, (data, ack) => { let cssSelector = undefined; if(Array.isArray(data)) { if(data.length > 1) // Second element is presumed to be the CSS selector cssSelector = (data as unknown[])[1] as string; data = data[0]; // First element will always be the locator text object } const text = LocatorText.deserialize(data); const r = rangeFromLocator(this.wnd.document, new Locator({ href: wnd.location.href, type: "text/html", text, locations: cssSelector ? new LocatorLocations({ otherLocations: new Map([ ["cssSelector", cssSelector] ]) }) : undefined })); if(!r) { ack(false); return; } this.wnd.requestAnimationFrame(() => { this.doc().scrollTop = r.getBoundingClientRect().top + wnd.scrollY - wnd.innerHeight / 2; this.reportProgress(); deselect(this.wnd); ack(true); }); }); comms.register("go_start", ScrollSnapper.moduleName, (_, ack) => { if (this.doc().scrollTop === 0) return ack(false); this.doc().scrollTop = 0; this.reportProgress(); ack(true); }); comms.register("go_end", ScrollSnapper.moduleName, (_, ack) => { if (this.doc().scrollTop === this.doc().scrollHeight - this.doc().offsetHeight) return ack(false); this.doc().scrollTop = this.doc().scrollHeight - this.doc().offsetHeight; this.reportProgress(); ack(true); }) comms.register("unfocus", ScrollSnapper.moduleName, (_, ack) => { deselect(this.wnd); ack(true); }); comms.register([ "go_next", "go_prev", ], ScrollSnapper.moduleName, (_, ack) => ack(false)); comms.register("focus", ScrollSnapper.moduleName, (_, ack) => { this.reportProgress(); ack(true); }); comms.register("first_visible_locator", ScrollSnapper.moduleName, (_, ack) => { const locator = findFirstVisibleLocator(wnd as ReadiumWindow, true); this.comms.send("first_visible_locator", locator.serialize()); ack(true); }); comms.log("ScrollSnapper Mounted"); return true; } unmount(wnd: ReadiumWindow, comms: Comms): boolean { comms.unregisterAll(ScrollSnapper.moduleName); this.resizeObserver.disconnect(); if (this.handleScroll) wnd.removeEventListener("scroll", this.handleScroll); wnd.document.getElementById(SCROLL_SNAPPER_STYLE_ID)?.remove(); comms.log("ScrollSnapper Unmounted"); return true; } }