644 lines
15 KiB
JavaScript
644 lines
15 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 IPUBAudio extends IPUBElement {
|
|
static elementName = "ipub-audio";
|
|
|
|
/**
|
|
* @param {boolean} [forced=false]
|
|
* @throws {Error}
|
|
* @returns {Promise<void>}
|
|
*/
|
|
play(forced = false) {
|
|
if (!this.#audioElement.readyState > HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
throw new Error("IPUBAudio: audio is not ready");
|
|
}
|
|
if (forced) {
|
|
this.setAttribute("forced", "true");
|
|
}
|
|
return this.#audioElement.play();
|
|
}
|
|
|
|
/**
|
|
* @param {boolean} [forced=false]
|
|
*/
|
|
pause(forced = false) {
|
|
if (forced) {
|
|
this.setAttribute("forced", "true");
|
|
}
|
|
this.#audioElement.pause();
|
|
}
|
|
|
|
/**
|
|
* @param {boolean} state
|
|
*/
|
|
setLoop(state) {
|
|
this.#audioElement.loop = state;
|
|
}
|
|
|
|
/**
|
|
* @returns {boolean}
|
|
*/
|
|
getLoop() {
|
|
return this.#audioElement.loop;
|
|
}
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
|
|
const audio = this.querySelector("audio");
|
|
if (!audio) {
|
|
console.error("IPUBAudio: Missing child <audio> element");
|
|
return;
|
|
}
|
|
|
|
this.#audioElement = audio;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @type {HTMLAudioElement}
|
|
*/
|
|
#audioElement;
|
|
|
|
/**
|
|
* @param {number} volume
|
|
* @param {Object} options
|
|
* @param {number} [options.fadetime=0]
|
|
* @param {() => void} [options.onfinish=() => {}]
|
|
*/
|
|
setVolume(volume, { fadetimeMS = 0, onFinishFade = () => {} } = {}) {
|
|
if (fadetimeMS === 0) {
|
|
this.#audioElement.volume = volume / 100;
|
|
return;
|
|
}
|
|
|
|
if (this.#isFading) {
|
|
return;
|
|
}
|
|
|
|
this.#onFinishFade = onFinishFade;
|
|
|
|
const diff = volume - this.#audioElement.volume * 100;
|
|
const ticks = diff < 0 ? Math.abs(diff) : diff;
|
|
let tick = 0;
|
|
|
|
const interval = fadetimeMS / ticks;
|
|
|
|
this.#isFading = true;
|
|
this.#fadeTask = setInterval(() => {
|
|
tick++;
|
|
|
|
const cancel = () => {
|
|
this.#isFading = false;
|
|
if (onFinishFade) {
|
|
onFinishFade();
|
|
}
|
|
clearInterval(this.#fadeTask);
|
|
this.#onFinishFade = null;
|
|
};
|
|
|
|
if (!this.#audioElement) {
|
|
cancel();
|
|
console.error("IPUBAudio: Missing child <audio> element");
|
|
return;
|
|
}
|
|
|
|
if (ticks < tick) {
|
|
cancel();
|
|
return;
|
|
}
|
|
|
|
if (volume === this.getVolume()) {
|
|
cancel();
|
|
return;
|
|
}
|
|
|
|
if (diff === 0) {
|
|
cancel();
|
|
return;
|
|
}
|
|
|
|
const nvol =
|
|
(diff > 0
|
|
? Math.ceil(this.#audioElement.volume * 100 + 1)
|
|
: Math.floor(this.#audioElement.volume * 100 - 1)) / 100;
|
|
|
|
if (nvol > 1 || nvol < 0) {
|
|
cancel();
|
|
return;
|
|
}
|
|
|
|
this.#audioElement.volume = nvol;
|
|
}, interval);
|
|
}
|
|
|
|
/**
|
|
* @returns {number}
|
|
*/
|
|
getVolume() {
|
|
return Math.floor((this.#audioElement?.volume ?? 0) * 100);
|
|
}
|
|
|
|
#isFading = false;
|
|
#fadeTask = 0;
|
|
/** @type {() => void | null} */
|
|
#onFinishFade = null;
|
|
}
|
|
|
|
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 [
|
|
IPUBAudio,
|
|
IPUBBackground,
|
|
IPUBImage,
|
|
IPUBInteraction,
|
|
IPUBSoundtrack,
|
|
]) {
|
|
console.info(`IPUBBody: Defining custom element <${e.elementName}>`);
|
|
globalThis.customElements.define(e.elementName, e);
|
|
}
|
|
}
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
|
|
this.setAttribute("aria-busy", "true");
|
|
|
|
// TODO?: Move IPUBCover's "can-play" logic to here
|
|
|
|
console.log("IPUBBody: Defining custom element <ipub-cover>");
|
|
globalThis.customElements.define(IPUBCover.elementName, IPUBCover);
|
|
|
|
/** @type {IPUBCover} */
|
|
const cover = this.querySelector("ipub-cover");
|
|
if (!cover) {
|
|
// TODO: automatically create IPUBCover element if it doesn't exists
|
|
console.error("IPUBBody: Document doesn't has <ipub-cover> element");
|
|
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");
|
|
|
|
// HACK: Test if we can autoplay interactions, soundtracks, etc
|
|
|
|
/** @type {HTMLMediaElement | null} */
|
|
const media =
|
|
this.parentElement.querySelector("audio") ??
|
|
this.parentElement.querySelector("video");
|
|
|
|
if (!media) {
|
|
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");
|
|
dialog.close();
|
|
this.onclose();
|
|
})
|
|
.catch(() => {
|
|
console.debug(
|
|
"IPUBCover: Cannot autoplay interactions, covering content",
|
|
);
|
|
|
|
dialog.show();
|
|
|
|
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";
|
|
}
|
|
|
|
class IPUBSoundtrack extends IPUBElement {
|
|
static elementName = "ipub-soundtrack";
|
|
|
|
// TODO: Toggle automatic soundtrack playing
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
static #player = setInterval(() => {
|
|
const last = Array.from(this.#onScreenStack).pop();
|
|
if (!last) {
|
|
// TODO: Fallback to previous soundtrack if there's no audio
|
|
return;
|
|
}
|
|
|
|
// TODO: Get siblings based by group OR parent
|
|
/** @type {NodeListOf<IPUBSoundtrack> | undefined} */
|
|
const siblings = last.parentElement?.querySelectorAll(
|
|
IPUBSoundtrack.elementName,
|
|
);
|
|
|
|
try {
|
|
if (siblings) {
|
|
siblings.forEach((el) => {
|
|
if (el !== last) {
|
|
el.fadeOut();
|
|
}
|
|
});
|
|
}
|
|
last.fadeIn();
|
|
} catch (e) {
|
|
// TODO: Fallback to previous soundtrack on error
|
|
console.error(
|
|
`IPUBSoundtrack: error while trying to play audio, error: ${e}`,
|
|
{
|
|
error: e,
|
|
audio: audio,
|
|
},
|
|
);
|
|
}
|
|
}, 1000);
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
static #observer = (() => {
|
|
return new IntersectionObserver((entries) => {
|
|
for (const { intersectionRatio, target, time } of entries) {
|
|
/** @type {IPUBSoundtrack} */
|
|
const soundtrack = target;
|
|
|
|
if (intersectionRatio > 0) {
|
|
console.debug(`${soundtrack.id} is on screen at ${time}`, soundtrack);
|
|
this.#onScreenStack.add(soundtrack);
|
|
} else {
|
|
console.debug(
|
|
`${soundtrack.id} is not on screen ${time}`,
|
|
soundtrack,
|
|
);
|
|
this.#onScreenStack.delete(soundtrack);
|
|
}
|
|
}
|
|
});
|
|
})();
|
|
|
|
/**
|
|
* @private
|
|
* @type {Set<IPUBSoundtrack>}
|
|
*/
|
|
static #onScreenStack = new Set();
|
|
|
|
/**
|
|
* @throws {Error}
|
|
*/
|
|
fadeIn() {
|
|
/** @type {IPUBAudio | undefined} */
|
|
const audio = this.querySelector(IPUBAudio.elementName);
|
|
if (!audio) {
|
|
throw new Error("IPUBSoundtrack.fadeIn: missing audio element");
|
|
}
|
|
|
|
// TODO: Global volume settings
|
|
|
|
audio.play();
|
|
audio.setVolume(10, { fadetimeMS: IPUBSoundtrack.FADE_TIME_MS });
|
|
}
|
|
|
|
/**
|
|
* @throws {Error}
|
|
*/
|
|
fadeOut() {
|
|
/** @type {IPUBAudio | undefined} */
|
|
const audio = this.querySelector(IPUBAudio.elementName);
|
|
if (!audio) {
|
|
throw new Error("IPUBSoundtrack.fadeIn: missing audio element");
|
|
}
|
|
|
|
audio.setVolume(0, {
|
|
fadetimeMS: IPUBSoundtrack.FADE_TIME_MS,
|
|
onFinishFade: () => {
|
|
audio.pause();
|
|
},
|
|
});
|
|
}
|
|
|
|
/** @type {Readonly<number>} */
|
|
static FADE_TIME_MS = 1000 * 3;
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
|
|
/** @type {IPUBAudio | undefined} */
|
|
const audio = this.querySelector(IPUBAudio.elementName);
|
|
if (audio) {
|
|
audio.setVolume(0);
|
|
} else {
|
|
console.error("IPUBSoundtrack: missing audio element");
|
|
return;
|
|
}
|
|
|
|
IPUBSoundtrack.#observer.observe(this);
|
|
}
|
|
|
|
// TODO(guz013): Handle if element is moved, it's group should be updated
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLElement} el
|
|
* @param {string} tagName
|
|
* @returns {HTMLElement | undefined}
|
|
*/
|
|
function getAncestor(el, tagName) {
|
|
if (!el.parentElement) {
|
|
return undefined;
|
|
}
|
|
if (el.parentElement.tagName.toLowerCase() === tagName.toLowerCase()) {
|
|
return el.parentElement;
|
|
}
|
|
return getAncestor(el.parentElement, tagName);
|
|
}
|
|
|
|
/**
|
|
* @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);
|
|
}
|
|
|