UNPKG

@readium/navigator-html-injectables

Version:

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

505 lines (449 loc) 21.1 kB
import { Comms } from "../../comms"; import { Snapper } from "./Snapper"; import { getColumnCountPerScreen, isRTL, appendVirtualColumnIfNeeded } from "../../helpers/document"; import { easeInOutQuad } from "../../helpers/animation"; import { ModuleName } from "../ModuleLibrary"; import { Locator, LocatorLocations, LocatorText } from "@readium/shared"; import { rangeFromLocator } from "../../helpers/locator"; import { ReadiumWindow, deselect, findFirstVisibleLocator } from "../../helpers/dom"; const COLUMN_SNAPPER_STYLE_ID = "readium-column-snapper-style"; const SNAP_DURATION = 200; // Milliseconds enum ScrollTouchState { END = 0, START = 1, MOVE = 2 } /** * A {Snapper} for reflowable resources using a column-based layout */ export class ColumnSnapper extends Snapper { static readonly moduleName: ModuleName = "column_snapper"; private resizeObserver!: ResizeObserver; private mutationObserver!: MutationObserver; private wnd!: ReadiumWindow; private comms!: Comms; private doc() { return this.wnd.document.scrollingElement as HTMLElement; } private scrollOffset() { // The reason we do this is because when the document is transformed (translate3d), // the scrollLeft value is 0 because... reasons. So we have to use the cached value // from this.alreadyScrollLeft instead. return (this.doc().scrollLeft > 0) ? this.doc().scrollLeft : this.alreadyScrollLeft; } snapOffset(offset: number) { const value = offset + (isRTL(this.wnd) ? -1 : 1); return value - (value % this.wnd.innerWidth); } reportProgress() { // Contrary to ScrollTop, Android slightly adds to scrollX // So we do not need to round it up const scrollX = this.wnd.scrollX; const scrollWidth = this.cachedScrollWidth; const progress = Math.max(0, Math.min(1, scrollX / scrollWidth)); const viewportEnd = Math.max(0, Math.min(1, (scrollX + this.wnd.innerWidth) / scrollWidth)); this.comms.send("progress", { start: progress, end: viewportEnd }); } private shakeTimeout = 0; shake() { // - If already overscrolling (touchscreen), then shaking on top of it looks ugly // - If already shaking, wait until it's finished before allowing another shake if(this.overscroll !== 0 || this.shakeTimeout !== 0) return; const doc = this.doc(); doc.classList.add((isRTL(this.wnd) ? "readium-bounce-l" : "readium-bounce-r")); const curScrollLeft = this.scrollOffset(); this.shakeTimeout = this.wnd.setTimeout(() => { doc.classList.remove("readium-bounce-l"); doc.classList.remove("readium-bounce-r"); this.shakeTimeout = 0; this.doc().scrollLeft = curScrollLeft; }, 150); } private snappingCancelled = false; private alreadyScrollLeft = 0; private overscroll = 0; private cachedScrollWidth = 0; // We have to cache this because during overscroll (transform, or left) the width is incorrect due to browser private takeOverSnap() { this.snappingCancelled = true; this.clearTouches(); const doc = this.doc(); // translate3d(XXXpx, 0px, 0px) -> slice 12 -> XXXpx, 0px, 0px) -> split "px" [0] -> XXX this.overscroll = doc.style.transform?.length > 12 ? parseFloat(doc.style.transform.slice(12).split("px")[0]) : 0; } // Snaps the current offset to the page width. snapCurrentOffset(smooth=false, noprogress=false) { const startX = this.wnd.scrollX > 0 ? this.wnd.scrollX : this.alreadyScrollLeft; const doc = this.doc(); const cdo = this.dragOffset(); const columnCount = getColumnCountPerScreen(this.wnd); const currentOffset = Math.min(Math.max(0, startX), this.cachedScrollWidth); const factor = isRTL(this.wnd) ? -1 : 1; const hurdle = // The hurdle to overcome in order to change pages factor * (this.wnd.innerWidth / 3) * ((factor * cdo) > 0 ? 2 : 1); const so = this.snapOffset(currentOffset + hurdle); if(smooth && so !== this.scrollOffset()) { // Smooth snapping this.snappingCancelled = false; const position = (start: number, end: number, elapsed: number, period: number) => { if (elapsed > period) { return end; } return start + (end - start) * easeInOutQuad(elapsed / period); } const period = /*Math.abs(startX - (this.useTransform ? currentOffset : 0)) < 10 ? 1 : */SNAP_DURATION * columnCount; // TODO revamp let startTime: number; const step = (timestamp: number) => { if(this.snappingCancelled) return; if (!startTime) startTime = timestamp; const elapsed = timestamp - startTime; const lpos = position(this.overscroll, 0, elapsed, period); const spos = position(startX, so, elapsed, period); doc.scrollLeft = spos; if(this.overscroll !== 0) doc.style.transform = `translate3d(${-lpos}px, 0px, 0px)`; if (elapsed < period) this.wnd.requestAnimationFrame(step); else { this.clearTouches(); doc.style.removeProperty("transform"); doc.scrollLeft = so; if(!noprogress) this.reportProgress(); } } this.wnd.requestAnimationFrame(step); } else { // Instant snapping doc.style.removeProperty("transform"); this.wnd.requestAnimationFrame(() => { doc.scrollLeft = so; this.clearTouches(); if(!noprogress) this.reportProgress(); }); } } // Current touch state cycler, to assist with swipe detection etc. private touchState: ScrollTouchState = ScrollTouchState.END; private startingX: number | undefined = undefined; private endingX: number | undefined = undefined; private dragOffset() { return (this.startingX ?? 0) - (this.endingX ?? 0); } private clearTouches() { this.startingX = undefined; this.endingX = undefined; this.overscroll = 0; // this.doc().style.removeProperty("will-change"); } onTouchStart(e: TouchEvent) { e.stopPropagation(); this.takeOverSnap(); switch (e.touches.length) { case 1: // Single finger - handle it break; case 2: // Pinch - abort this.onTouchEnd(e); return; default: { // More fingers - abort, notify this.onTouchEnd(e); this.comms.send("tap_more", e.touches.length); return; } } // this.doc().style.willChange = "transform, scroll-position"; this.startingX = e.touches[0].clientX; this.alreadyScrollLeft = this.doc().scrollLeft; this.touchState = ScrollTouchState.START; } private readonly onTouchStarter = this.onTouchStart.bind(this); onTouchEnd(_: TouchEvent) { if(this.touchState === ScrollTouchState.MOVE) { // Get the horizontal drag distance const dragOffset = this.dragOffset(); const scrollOffset = this.scrollOffset(); // this.cachedScrollWidth = this.doc().scrollWidth!; if(this.cachedScrollWidth <= this.wnd.innerWidth) { // Only a single page, meaning any swipe triggers next/prev this.reportProgress(); if(dragOffset > 5) this.comms.send("no_more", undefined); if(dragOffset < -5) this.comms.send("no_less", undefined); } else if(scrollOffset < 5 && dragOffset < 5) { this.alreadyScrollLeft = 0; this.comms.send("no_less", undefined); } else if((this.cachedScrollWidth - scrollOffset - this.wnd.innerWidth) < 5 && dragOffset > 5) { this.alreadyScrollLeft = this.cachedScrollWidth; this.comms.send("no_more", undefined); } this.snapCurrentOffset(true); this.comms.send("swipe", dragOffset); } this.touchState = ScrollTouchState.END; } private readonly onTouchEnder = this.onTouchEnd.bind(this); private onWidthChange() { this.cachedScrollWidth = this.doc().scrollWidth!; if(this.comms.ready) // This function can be called while the frame is still hidden // so it should only be snapped if it's actually active because // it sends a comms message to update progress. this.snapCurrentOffset(); } private readonly onWidthChanger = this.onWidthChange.bind(this); onTouchMove(e: TouchEvent) { if(this.touchState === ScrollTouchState.END) return; if(this.touchState === ScrollTouchState.START) { this.touchState = ScrollTouchState.MOVE; deselect(this.wnd); } this.endingX = e.touches[0].clientX; const dro = this.dragOffset(); const newpos = this.alreadyScrollLeft + dro; if(newpos < 0) { this.overscroll = newpos; this.doc().style.transform = `translate3d(${-this.overscroll}px, 0px, 0px)`; } else if((newpos + this.wnd.innerWidth) > this.cachedScrollWidth) { this.overscroll = newpos; this.doc().style.transform = `translate3d(${-newpos}px, 0px, 0px)`; } else { this.overscroll = 0; this.doc().style.removeProperty("transform"); this.doc().scrollLeft = this.alreadyScrollLeft + dro; } } private readonly onTouchMover = this.onTouchMove.bind(this); mount(wnd: ReadiumWindow, comms: Comms): boolean { this.wnd = wnd; this.comms = comms; if(!super.mount(wnd, comms)) return false; wnd.navigator.epubReadingSystem.layoutStyle = "paginated"; // Add styling to hide the scrollbar const d = wnd.document.createElement("style"); d.dataset.readium = "true"; d.id = COLUMN_SNAPPER_STYLE_ID; d.textContent = ` @keyframes readium-bounce-l-animation { 0%, 100% {transform: translate3d(0, 0, 0);} 50% {transform: translate3d(-50px, 0, 0);} } @keyframes readium-bounce-r-animation { 0%, 100% {transform: translate3d(0, 0, 0);} 50% {transform: translate3d(50px, 0, 0);} } .readium-bounce-l { animation: readium-bounce-l-animation 150ms ease-out 1; } .readium-bounce-r { animation: readium-bounce-r-animation 150ms ease-out 1; } html { overflow: hidden; } body { -ms-overflow-style: none; /* for Internet Explorer, Edge */ } * { scrollbar-width: none; /* for Firefox */ } body::-webkit-scrollbar { display: none; /* for Chrome, Safari, and Opera */ } `; wnd.document.head.appendChild(d); this.resizeObserver = new ResizeObserver(() => { wnd.requestAnimationFrame(() => { wnd && appendVirtualColumnIfNeeded(wnd); }); this.onWidthChange(); }); this.resizeObserver.observe(wnd.document.body); this.mutationObserver = new MutationObserver((mutations) => { for (const mutation of mutations) { // We have to check it’s not onTouchMove + snapOffset setting transforms if (mutation.target === this.wnd.document.documentElement) { const oldValue = mutation.oldValue as string; const newValue = (mutation.target as HTMLElement).getAttribute("style") as string; const transformRegex = /transform\s*:\s*([^;]+)/; const oldValueTransform = oldValue?.match(transformRegex); const newValueTransform = newValue?.match(transformRegex); if ( (!oldValueTransform && !newValueTransform) || (oldValueTransform && !newValueTransform) || (oldValueTransform && newValueTransform && oldValueTransform[1] !== newValueTransform[1]) ) { wnd.requestAnimationFrame(() => { wnd && appendVirtualColumnIfNeeded(wnd); }); this.onWidthChange(); } } else { wnd.requestAnimationFrame(() => this.cachedScrollWidth = this.doc().scrollWidth!); } } }); wnd.frameElement && this.mutationObserver.observe(wnd.frameElement, {attributes: true, attributeFilter: ["style"]}); this.mutationObserver.observe(wnd.document, {attributes: true, attributeFilter: ["style"]}); // For cases the resizeObserver is not able to detect cos body is not resizing despite colCount, // we need to check the syle attribute on the documentElement (ReadiumCSS props) this.mutationObserver.observe(wnd.document.documentElement, {attributes: true, attributeFilter: ["style"]}); const scrollToOffset = (offset: number): boolean => { const oldScrollLeft = this.doc().scrollLeft; this.doc().scrollLeft = this.snapOffset(offset); // TODO assert if never undefined (same for rest of !) return oldScrollLeft !== this.doc().scrollLeft; } wnd.addEventListener("orientationchange", this.onWidthChanger); wnd.addEventListener("resize", this.onWidthChanger); wnd.requestAnimationFrame(() => this.cachedScrollWidth = this.doc().scrollWidth!); comms.register("go_progression", ColumnSnapper.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.cachedScrollWidth = this.doc().scrollWidth!; const documentWidth = this.cachedScrollWidth; const factor = isRTL(wnd) ? -1 : 1; const offset = documentWidth * position * factor; this.doc().scrollLeft = this.snapOffset(offset); this.reportProgress(); deselect(this.wnd); ack(true); }); }) comms.register("go_id", ColumnSnapper.moduleName, (data, ack) => { const element = wnd.document.getElementById(data as string); if(!element) { ack(false); return; } this.wnd.requestAnimationFrame(() => { this.doc().scrollLeft = this.snapOffset(element.getBoundingClientRect().left + wnd.scrollX); this.reportProgress(); deselect(this.wnd); ack(true); }); }); comms.register("go_text", ColumnSnapper.moduleName, (data: unknown | unknown[], 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().scrollLeft = this.snapOffset(r.getBoundingClientRect().left + wnd.scrollX); this.reportProgress(); deselect(this.wnd); ack(true); }); }); comms.register("go_end", ColumnSnapper.moduleName, (_, ack) => { const factor = isRTL(wnd) ? -1 : 1; this.wnd.requestAnimationFrame(() => { this.cachedScrollWidth = this.doc().scrollWidth!; const final = this.cachedScrollWidth * factor; if(this.doc().scrollLeft === final) return ack(false); this.doc().scrollLeft = this.snapOffset(final); this.reportProgress(); deselect(this.wnd); ack(true); }); }) comms.register("go_start", ColumnSnapper.moduleName, (_, ack) => { this.wnd.requestAnimationFrame(() => { if(this.doc().scrollLeft === 0) return ack(false); this.doc().scrollLeft = 0; this.reportProgress(); deselect(this.wnd); ack(true); }); }) comms.register("go_prev", ColumnSnapper.moduleName, (_, ack) => { this.wnd.requestAnimationFrame(() => { this.cachedScrollWidth = this.doc().scrollWidth!; const offset = wnd.scrollX - wnd.innerWidth; const minOffset = isRTL(wnd) ? - (this.cachedScrollWidth - wnd.innerWidth) : 0; const change = scrollToOffset(Math.max(offset, minOffset)); if(change) { this.reportProgress(); deselect(this.wnd); } ack(change); }); }); comms.register("go_next", ColumnSnapper.moduleName, (_, ack) => { this.wnd.requestAnimationFrame(() => { this.cachedScrollWidth = this.doc().scrollWidth!; const offset = wnd.scrollX + wnd.innerWidth; const maxOffset = isRTL(wnd) ? 0 : this.cachedScrollWidth - wnd.innerWidth; const change = scrollToOffset(Math.min(offset, maxOffset)); if(change) { this.reportProgress(); deselect(this.wnd); } ack(change); }); }); comms.register("unfocus", ColumnSnapper.moduleName, (_, ack) => { this.snappingCancelled = true; deselect(this.wnd); ack(true); }); comms.register("shake", ColumnSnapper.moduleName, (_, ack) => { this.shake(); ack(true); }); comms.register("focus", ColumnSnapper.moduleName, (_, ack) => { this.wnd.requestAnimationFrame(() => { this.cachedScrollWidth = this.doc().scrollWidth!; this.snapCurrentOffset(false, true); this.reportProgress(); ack(true); }); }); comms.register("first_visible_locator", ColumnSnapper.moduleName, (_, ack) => { const locator = findFirstVisibleLocator(wnd as ReadiumWindow, false); this.comms.send("first_visible_locator", locator.serialize()); ack(true); }); // Add interaction listeners wnd.addEventListener("touchstart", this.onTouchStarter, { passive: true }); wnd.addEventListener("touchend", this.onTouchEnder, { passive: true }); wnd.addEventListener("touchmove", this.onTouchMover, { passive: true }); // Safari hack, otherwise other events won't register wnd.document.addEventListener('touchstart', () => {}); comms.log("ColumnSnapper Mounted"); return true; } unmount(wnd: ReadiumWindow, comms: Comms): boolean { this.snappingCancelled = true; comms.unregisterAll(ColumnSnapper.moduleName); this.resizeObserver.disconnect(); this.mutationObserver.disconnect(); wnd.removeEventListener("touchstart", this.onTouchStarter); wnd.removeEventListener("touchend", this.onTouchEnder); wnd.removeEventListener("touchmove", this.onTouchMover); wnd.removeEventListener("orientationchange", this.onWidthChanger); wnd.removeEventListener("resize", this.onWidthChanger); wnd.document.getElementById(COLUMN_SNAPPER_STYLE_ID)?.remove(); comms.log("ColumnSnapper Unmounted"); return super.unmount(wnd, comms); } }