361 lines
8.3 KiB
JavaScript
361 lines
8.3 KiB
JavaScript
"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");
|
|
}
|
|
}
|
|
|
|
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 {
|
|
static elementName = "ipub-image";
|
|
}
|
|
|
|
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);
|
|
}
|