7 Commits

3 changed files with 548 additions and 199 deletions

View File

@@ -4,36 +4,166 @@ class IPUBElement extends HTMLElement {
static observedAttributes = ["id"];
connectedCallback() {
this.ensureID();
this.#ensureID();
}
attributeChangedCallback(_name, _oldValue, _newValue) {
this.ensureID();
this.#ensureID();
}
ensureID() {
if (this.id) {
/**
* @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;
}
// INFO: Hash algorithm by Joe Freeman & Cristian Sanchez at StackOverflow:
// https://stackoverflow.com/a/16348977
// https://stackoverflow.com/a/3426956
//
// Licensed under CC BY-SA 3.0 (https://creativecommons.org/licenses/by-sa/3.0/)
this.#audioElement = audio;
}
let hash = 0;
this.innerHTML.split("").forEach((char) => {
hash = char.charCodeAt(0) + ((hash << 5) - hash);
});
/**
* @private
* @type {HTMLAudioElement}
*/
#audioElement;
let id = "";
for (let i = 0; i < 3; i++) {
id += ((hash >> (i * 8)) & 0xff).toString(16).padStart(2, "0");
/**
* @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;
}
this.id = id;
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 {
@@ -43,49 +173,84 @@ class IPUBBackground extends IPUBElement {
/**
* @private
*/
static #observer = (() => {
/** @type {Map<string, IPUBBackground>} */
const instancesOnScreen = new Map();
document.addEventListener("scroll", () => {
for (const [_, instance] of instancesOnScreen) {
const perc = getPercentageInView(
instance.querySelector("img") || instance,
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`,
);
instance.fade(perc);
return;
}
});
return new IntersectionObserver((entries) =>
entries.forEach((e) => {
let instance = e.target.parentElement;
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;
}
if (
instance.tagName.toLowerCase() !==
IPUBBackground.elementName.toLowerCase()
) {
instance = instance.parentElement;
}
const set = this.map.get(body);
if (!set) {
return;
}
if (
instance.tagName.toLowerCase() !==
IPUBBackground.elementName.toLowerCase()
) {
console.error(
"IPUBBackground: malformed <ipub-background> element",
e.target,
);
return;
}
set.delete(background);
if (e.intersectionRatio > 0 && instance.id) {
instancesOnScreen.set(instance.id, instance);
} else if (instance.id) {
instancesOnScreen.delete(instance.id);
}
}),
);
})();
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();
@@ -141,8 +306,6 @@ class IPUBBackground extends IPUBElement {
* @returns {void | Promise<void>}
*/
fade(perc) {
console.debug(`IPUBBackground: ${this.id} is ${perc} on screen`);
if (!this.style.getPropertyValue("--ipub-fade")) {
this.style.setProperty("--ipub-fade", `${perc}%`);
return;
@@ -159,8 +322,109 @@ class IPUBBackground extends IPUBElement {
}
}
class IPUBContent extends IPUBElement {
static elementName = "ipub-content";
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 {
@@ -171,72 +435,174 @@ class IPUBInteraction extends IPUBElement {
static elementName = "ipub-interaction";
}
globalThis.addEventListener("load", () => {
console.info("IPUB: STARTING DEFINITIONS");
class IPUBSoundtrack extends IPUBElement {
static elementName = "ipub-soundtrack";
[IPUBBackground, IPUBContent, IPUBImage, IPUBInteraction].forEach((e) => {
console.info(`IPUB: Defining custom element <${e.elementName}>`);
globalThis.customElements.define(e.elementName, e);
});
// TODO: Toggle automatic soundtrack playing
console.info("IPUB: FINISHED DEFINITIONS");
/**
* @private
*/
static #player = setInterval(() => {
const last = Array.from(this.#onScreenStack).pop();
if (!last) {
// TODO: Fallback to previous soundtrack if there's no audio
return;
}
/** @type {Map<string, Element>} */
const onScreenMap = new Map();
// TODO: Get siblings based by group OR parent
/** @type {NodeListOf<IPUBSoundtrack> | undefined} */
const siblings = last.parentElement?.querySelectorAll(
IPUBSoundtrack.elementName,
);
const observer = new IntersectionObserver((entries) => {
entries.forEach((e) => {
if (e.intersectionRatio > 0) {
// console.debug(
// `IntersectionObserver: adding element #${e.target.id} to onScreenMap`,
// );
onScreenMap.set(e.target.id, e.target);
} else {
// console.debug(
// `IntersectionObserver: removing element #${e.target.id} to onScreenMap`,
// );
onScreenMap.delete(e.target.id);
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);
}
}
});
});
})();
for (const element of document.querySelectorAll(
`[data-ipub-trigger="on-screen"]`,
)) {
observer.observe(element);
/**
* @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 });
}
document.addEventListener("scroll", async () => {
for (const [id, element] of onScreenMap) {
const perc = getPercentageInView(element);
// console.debug(`Element #${id} is now ${perc}% on screen`);
const played = element.getAttribute("data-ipub-trigger-played") == "true";
if (perc >= 100 && !played) {
await playIpubElement(element);
element.setAttribute("data-ipub-trigger-played", "true");
}
/**
* @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 {Element} element
* @param {HTMLElement} el
* @param {string} tagName
* @returns {HTMLElement | undefined}
*/
async function playIpubElement(element) {
switch (element.tagName) {
case "audio": {
/** @type {HTMLAudioElement} */
const audio = element;
// await audio.play();
break;
}
default:
break;
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);
}
/**
@@ -274,3 +640,4 @@ function getPercentageInView(element) {
return Math.round((inView / globalThis.innerHeight) * 100);
}

View File

@@ -9,8 +9,19 @@
</script>
</head>
<body xmlns:epub="http://www.idpf.org/2007/ops" class="body">
<ipub-content style="--ipub-padding: 10%;">
<main>
<ipub-body style="--ipub-padding: 10%;">
<ipub-cover>
<dialog>
<header>
<h1>Test comic</h1>
<form method="dialog">
<p>Click anywhere to
<button>start Reading</button></p>
</form>
</header>
</dialog>
</ipub-cover>
<main id="content">
<ipub-background id="background0001">
<img src="../images/background0001.jpg" width="100" height="100" />
</ipub-background>
@@ -25,60 +36,35 @@
rel="external nofollow noopener noreferrer" target="_blank" />
</ipub-interaction>
</ipub-image>
<section data-ipub-element="page" id="page02">
<span data-ipub-element="image">
<img src="../images/image0002.png" />
</span>
<!--
This in the UI would be an "Area Interaction". The editor would first place
the first top-left point, and then the bottom-right one, to select an area/size
of the interaction.
The element wound not have a "action" per say, but would have a "on screen" trigger,
which in itself would have the action "play sound".
-->
<audio data-ipub-element="interaction" data-ipub-trigger="on-screen" controls="true"
volume="0" style="--ipub-x: 20%; --ipub-y: 25%; --ipub-width: 50%; --ipub-height: 50%;"
id="int-audio0001">
<source src="../audios/audio0001.wav.disable" />
</audio>
</section>
<ipub-image>
<img src="../images/image0002.png" />
</ipub-image>
<ipub-background id="background0002">
<picture>
<img src="../images/background0002.jpg" />
</picture>
</ipub-background>
<section data-ipub-element="page" id="page03">
<span data-ipub-element="image">
<img src="../images/image0003.png" />
</span>
</section>
<section data-ipub-element="page" id="page04">
<span data-ipub-element="image">
<img src="../images/image0004.png" />
</span>
</section>
<ipub-image>
<img src="../images/image0003.png" />
</ipub-image>
<ipub-image>
<img src="../images/image0004.png" />
</ipub-image>
<ipub-background id="background0003">
<picture>
<img src="../images/background0003.jpg" />
</picture>
</ipub-background>
<section data-ipub-element="page" id="page02">
<span data-ipub-element="image">
<img src="../images/image0002.png" />
</span>
</section>
<section data-ipub-element="page" id="page04">
<span data-ipub-element="image">
<img src="../images/image0003.png" />
</span>
</section>
<section data-ipub-element="page" id="page04">
<span data-ipub-element="image">
<img src="../images/image0004.png" />
</span>
</section>
<ipub-image>
<img src="../images/image0002.png" />
</ipub-image>
<ipub-image>
<img src="../images/image0003.png" />
</ipub-image>
<ipub-image>
<img src="../images/image0004.png" />
</ipub-image>
</main>
</ipub-content>
</ipub-body>
</body>
</html>

View File

@@ -8,9 +8,45 @@
margin: 0;
max-width: 100vw;
max-height: 100vh;
overflow: clip;
display: flex;
--z-cover: 9;
ipub-cover > dialog[open] {
--ipub-accent-color: #fff;
z-index: var(--z-cover);
background-color: transparent;
border: none;
display: inline-block;
position: absolute;
backdrop-filter: blur(1rem);
background-image: linear-gradient(
rgba(from var(--ipub-accent-color) r g b / 0) 0%,
rgba(from var(--ipub-accent-color) r g b / 0.5)
calc(100% + calc(var(--ipub-fade, 50%) * -1))
);
top: 0;
left: 0;
width: 100%;
height: 100%;
}
ipub-content {
ipub-body {
max-width: 100%;
max-height: 100%;
position: relative;
display: flex;
flex-direction: column;
overflow: scroll;
&:has(ipub-cover > dialog[open]) {
overflow: hidden;
}
--ipub-padding: 0%;
--ipub-gap: 0%;
--ipub-padding-x: var(--ipub-padding, 0%);
@@ -98,13 +134,14 @@ ipub-background {
ipub-image {
position: relative;
display: block;
display: inline-block;
flex-direction: column;
width: var(--ipub-width, unset);
height: var(--ipub-height, unset);
img {
display: block;
max-width: 100%;
max-height: 100%;
}
@@ -146,44 +183,3 @@ ipub-interaction {
--ipub-radius: 100%;
}
}
[data-ipub-element="image"] {
width: var(--ipub-width, unset);
height: var(--ipub-height, unset);
background-image: var(--ipub-image, unset);
background-repeat: no-repeat;
background-size: cover;
display: block;
img {
max-width: 100%;
max-height: 100%;
}
}
[data-ipub-element="interaction"] {
position: absolute;
left: var(--ipub-x, 0%);
top: var(--ipub-y, 0%);
border-radius: var(--ipub-radius, unset);
width: var(--ipub-width, unset);
height: var(--ipub-height, unset);
transform: translate(
var(--ipub-origin-offset-x, 0%),
var(--ipub-origin-offset-y, 0%)
);
aspect-ratio: var(--ipub-ratio, unset);
/*
* The opacity would be, by default, zero. Here it is 0.3 for easier debugging and
* showing of the example ebook
*/
background-color: red;
opacity: 0.3;
}
a[data-ipub-element="interaction"] {
/* The text inside the interaction anchor are for accessibility purposes */
font-size: 0px;
}
}