"use strict"; class IPUBElement extends HTMLElement { static observedAttributes = ["id"]; connectedCallback() { this.#ensureID(); } attributeChangedCallback(_name, _oldValue, _newValue) { this.#ensureID(); } /** * @private */ #ensureID() { if (!this.id) { this.id = hashFromHTML(this); } } } class IPUBBackground extends IPUBElement { static elementName = "ipub-background"; static observedAttributes = ["nofade"].concat(super.observedAttributes); /** * @private */ static #instancesOnScreen = { /** @type {Map>} */ map: new Map(), /** * @param {IPUBBackground} background */ add(background) { const body = getAncestor(background, IPUBBody.elementName); if (!body) { console.error( `IPUBBackground: ${background.id} does not have a valid ipub-body ancestor`, ); return; } let set = this.map.get(body); if (!set) { set = new Set(); body.addEventListener("scroll", () => { for (const instance of set.values()) { const perc = getPercentageInView( instance.querySelector("img") || instance, ); instance.fade(perc); } }); } set.add(background); this.map.set(body, set); }, /** * @param {IPUBBackground} background */ remove(background) { const body = getAncestor(background, IPUBBody.elementName); if (!body) { console.error( `IPUBBackground: ${background.id} does not have a valid ipub-body ancestor`, ); return; } const set = this.map.get(body); if (!set) { return; } set.delete(background); if (set.size === 0) { this.map.delete(body); } }, }; static addToScreen() {} /** * @private */ static #observer = new IntersectionObserver((entries) => { for (const { intersectionRatio, target: image } of entries) { const instance = getAncestor(image, IPUBBackground.elementName); if (!instance) { console.error( "IPUBBackground: malformed element", image, ); return; } if (intersectionRatio > 0 && instance.id) { IPUBBackground.#instancesOnScreen.add(instance); } else if (instance.id) { IPUBBackground.#instancesOnScreen.remove(instance); } } }); connectedCallback() { super.connectedCallback(); const image = this.querySelector("img"); if (!image) { console.error("IPUBBackground: missing element inside background"); return; } // INFO: We don't need to fade the first background if (this.matches(":first-of-type") || this.hasAttribute("nofade")) { IPUBBackground.#observer.unobserve(image); return; } IPUBBackground.#observer.observe(image); const perc = getPercentageInView(image); if (perc > 0) { this.fade(perc); } } attributeChangedCallback(name, _oldValue, _newValue) { super.attributeChangedCallback(); if (name !== "nofade") { return; } const image = this.querySelector("img"); if (!image) { console.error("IPUBBackground: missing element inside background"); return; } // INFO: We don't need to fade the first background if (this.matches(":first-of-type") || this.hasAttribute("nofade")) { IPUBBackground.#observer.unobserve(image); return; } IPUBBackground.#observer.observe(image); const perc = getPercentageInView(image); if (perc > 0) { this.fade(perc); } } /** * @param {number} perc * @throws {Error} * @returns {void | Promise} */ fade(perc) { if (!this.style.getPropertyValue("--ipub-fade")) { this.style.setProperty("--ipub-fade", `${perc}%`); return; } const currentPerc = this.style.getPropertyValue("--ipub-fade"); if (currentPerc === "100%" && perc >= 100) { return; } if (perc % 10 === 0) { this.style.setProperty("--ipub-fade", `${perc}%`); } } } class IPUBBody extends IPUBElement { static elementName = "ipub-body"; static defineContentElements() { for (const e of [ IPUBBackground, IPUBImage, IPUBInteraction, ]) { console.info(`IPUBBody: Defining custom element <${e.elementName}>`); globalThis.customElements.define(e.elementName, e); } } connectedCallback() { super.connectedCallback(); this.setAttribute("aria-busy", "true"); console.log("IPUBBody: defining custom element "); globalThis.customElements.define(IPUBCover.elementName, IPUBCover); /** @type {IPUBCover} */ const cover = this.querySelector("ipub-cover"); if (!cover) { IPUBBody.defineContentElements(); } cover.onclose = IPUBBody.defineContentElements; this.setAttribute("aria-busy", "false"); } } globalThis.addEventListener("load", () => { console.info("IPUB: Starting IPUB elements"); console.log("IPUB: Defining custom element "); globalThis.customElements.define(IPUBBody.elementName, IPUBBody); }); class IPUBCover extends IPUBElement { static elementName = "ipub-cover"; /** * @type {() => void} callback */ onclose = () => {}; connectedCallback() { super.connectedCallback(); console.debug("IPUBCover: Setting up cover"); this.setAttribute("aria-busy", "true"); const dialog = this.querySelector("dialog"); dialog.show(); // HACK: Test if we can autoplay interactions, soundtracks, etc /** @type {HTMLMediaElement | null} */ const media = this.parentElement.querySelector("audio") ?? this.parentElement.querySelector("video"); if (!media) { dialog.close(); return; } const pastVolume = media.volume; media.volume = 0.1; // don't let the user hear the test audio media .play() .then(() => { media.pause(); media.volume = pastVolume; media.currentTime = 0; console.debug("IPUBCover: Can autoplay interactions, removing cover"); this.onclose(); }) .catch(() => { console.debug( "IPUBCover: Cannot autoplay interactions, covering content", ); dialog.parentElement.addEventListener("click", () => { dialog.close(); this.onclose(); }); this.setAttribute("aria-busy", "false"); return; }); } } class IPUBImage extends IPUBElement { static elementName = "ipub-image"; } class IPUBInteraction extends IPUBElement { static elementName = "ipub-interaction"; } } }); } /** */ /** * @param {Readonly} el * @param {number} [length=6] * @returns {string} * * @copyright This function contains code from a hash algorithm by * {@link https://stackoverflow.com/a/16348977|Joe Freeman} and * {@link https://stackoverflow.com/a/3426956|Cristian Sanchez} at * StackOverflow, licensed under {@link https://creativecommons.org/licenses/by-sa/3.0/|CC BY-SA 3.0}. */ function hashFromHTML(el, length = 6) { const hexLength = length / 2; if (hexLength % 2 !== 0) { hexLength + 1; } let hash = 0; for (const char of el.innerHTML) { hash = char.charCodeAt(0) + ((hash << 5) - hash); } let hex = ""; for (let i = 0; i < hexLength; i++) { hex += ((hash >> (i * 8)) & 0xff).toString(16).padStart(2, "0"); } return hex.substring(0, length); } /** * @param {Element} element * @returns {number} */ function getPercentageInView(element) { const viewTop = globalThis.pageYOffset; const viewBottom = viewTop + globalThis.innerHeight; const rect = element.getBoundingClientRect(); const elementTop = rect.y + viewTop; const elementBottom = rect.y + rect.height + viewTop; if (viewTop > elementBottom || viewBottom < elementTop) { return 0; } if ( (viewTop < elementTop && viewBottom > elementBottom) || (elementTop < viewTop && elementBottom > viewBottom) ) { return 100; } let inView = rect.height; if (elementTop < viewTop) { inView = rect.height - (globalThis.pageYOffset - elementTop); } if (elementBottom > viewBottom) { inView = inView - (elementBottom - viewBottom); } return Math.round((inView / globalThis.innerHeight) * 100); }