Files
comicverse/.epub/example/OEBPS/scripts/ipub.js

361 lines
8.3 KiB
JavaScript
Raw Normal View History

"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<IPUBBody, Set<IPUBBackground>>} */
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 <ipub-background> 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 <img> 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 <img> 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<void>}
*/
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 <ipub-cover>");
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");
}
2025-10-17 15:35:18 -03:00
}
globalThis.addEventListener("load", () => {
console.info("IPUB: Starting IPUB elements");
console.log("IPUB: Defining custom element <ipub-body>");
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 {
2025-10-17 15:36:39 -03:00
static elementName = "ipub-image";
}
2025-10-17 18:05:04 -03:00
class IPUBInteraction extends IPUBElement {
static elementName = "ipub-interaction";
}
}
});
}
/**
*/
/**
* @param {Readonly<Element>} 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);
}