@expositio/core
Version:
Simple and lean presentation tool.
351 lines (310 loc) • 13.4 kB
JavaScript
// This file is part of Expositio.
//
// Expositio is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Expositio is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Expositio. If not, see <https://www.gnu.org/licenses/>.
import { apply, constant } from "@fpjs/overture/base";
import { patchBuiltins } from "@fpjs/overture/patches";
import { pure } from "@fpjs/overture/algebras/applicative";
import { map } from "@fpjs/overture/algebras/functor";
import { concat } from "@fpjs/overture/algebras/semigroup";
import { equals } from "@fpjs/overture/algebras/setoid";
import { App, DOM, Signal } from "@fpjs/signal";
const { filter, mergeFold } = Signal;
const { Eff, start, noEffects, onlyEffects, transition } = App;
import { bootstrap, setClasses, toggleFullscreen } from "./src/dom.js";
import { Actions } from "./src/state.js";
import { trackGestures } from "./src/gestures.js";
import { modal } from "./src/ui.js";
const run = (opts={}) => {
const root = document.getElementById("expositio") || document.body;
if (root.getAttribute("id") == null) {
console.warn("'#expositio' root element not found, falling back to 'body'. This behavior will be removed in a future release.");
root.setAttribute("id", "expositio");
}
if (document.documentElement.hasAttribute("expositio-theme")) {
console.warn(`'expositio-theme' on the html document has been replaced with 'data-theme' on the #expositio root element and 'expositio="standalone"' on the document element. Support for 'expositio-theme' will be removed in a future release.`);
root.setAttribute("data-theme", document.documentElement.getAttribute("expositio-theme"));
document.documentElement.setAttribute("expositio", "standalone");
document.documentElement.removeAttribute("expositio-theme");
}
if (root.getElementsByClassName("slides").length > 1) {
console.warn("Document includes multiples 'slides' decks; only the first one is presented but both will be visible.");
}
const slidesContainer = root.getElementsByClassName("slides")[0];
if (slidesContainer == null) {
console.error("Document has no 'slides' deck; cannot start presenting.");
return;
}
const domSlides = slidesContainer.getElementsByClassName("slide");
const initDOM = (options) => {
const progress = root.querySelector("#slide-progress");
if (progress != null) {
const bar = progress.getElementsByClassName("bar");
if (~bar.length) {
const el = document.createElement("span");
el.className = "bar";
el.style.width = "0";
progress.appendChild(el);
}
}
if (options["theme-switcher"].enable) {
const builtin = ["white", "black", "nord-bright", "nord-dark", "git"];
const themes = concat (builtin) (options["theme-switcher"]["extra-themes"]);
// Take first theme as default
if (!root.hasAttribute("data-theme")) {
root.setAttribute("data-theme", themes[0]);
}
const initTheme = root.getAttribute("data-theme");
const parent = root;
const themelist = document.createElement("ul");
themelist.className = "theme-switcher";
const tags = themes.map((theme) => {
const tag = document.createElement("li");
tag.textContent = theme;
tag.onclick = (e) => {
root.setAttribute("data-theme", theme);
tags.forEach(setClasses ({"active": false}));
tag.className = "active";
};
setClasses ({"active": theme === initTheme}) (tag);
return tag;
});
tags.forEach(tag => themelist.appendChild(tag));
parent.insertBefore(themelist, parent.firstChild);
}
};
const initAnimations = () => {
const typewriters = root.querySelectorAll("[data-animate=typewriter]");
for (const el of typewriters) {
const contentLength = el.textContent.length;
el.classList.add(`typewriter-${contentLength}`);
}
};
const playMedia = ({target}) => {
target.currentTime = 0;
target.play().catch((e) => {
const text = {
"NotAllowedError": "Cannot autoplay media. Please press Play to start.",
"NotSupportedError": "Unsupported media format.",
}[e.name] || "Cannot autoplay media. Please press play to start or try reloading.";
const overlay = modal ("hint") (text);
const parent = target.parentNode;
parent.appendChild(overlay);
const removeOverlay = () => {
parent.removeChild(overlay);
target.removeEventListener("play", removeOverlay);
};
target.addEventListener("play", removeOverlay);
});
target.removeEventListener("loadeddata", playMedia);
};
const autoplay = (target) => {
if (target.readyState > 1) {
playMedia({target});
} else {
target.addEventListener("loadeddata", playMedia);
}
};
const inView = (el) => {
const {top, bottom, left, right} = el.getBoundingClientRect();
const boundingBox = {
top: Math.max(0, el.parentElement.getBoundingClientRect().top),
bottom: Math.min(window.innerHeight, el.parentElement.getBoundingClientRect().bottom),
};
return top >= boundingBox.top && bottom <= boundingBox.bottom;
};
const view = Eff(({state, input}) => {
const {slides, index} = state;
slidesContainer.setAttribute("data-mode", state.mode);
for (let i = 0; i<domSlides.length; i++) {
const domSlide = domSlides[i];
const focusedSlide = i === index;
setClasses ({"cloak": !focusedSlide}) (domSlide);
if (focusedSlide) {
const tag = domSlide.getAttribute("data-slide-tag");
if (tag != null) {
root.setAttribute("data-slide-tag", tag);
} else {
root.removeAttribute("data-slide-tag");
}
if (!inView (domSlide)) {
domSlide.scrollIntoView(true);
}
}
const fragments = domSlide.querySelectorAll(".fragment");
const slide = slides[index];
let handledMedia = new Set();
for (let j = 0; j<fragments.length; j++) {
const fragment = fragments[j];
const focusedFragment = focusedSlide && j === slide.index;
setClasses ({
"hidden": focusedSlide && j > slide.index,
"focus": focusedSlide && focusedFragment,
"done": focusedSlide && j < slide.index,
}) (fragment);
if (focusedFragment && !inView (fragment)) {
fragment.scrollIntoView(false);
}
const media = fragment.querySelectorAll("video[data-autoplay]");
for (const target of media) {
if (focusedFragment) {
autoplay(target);
} else {
target.pause();
}
handledMedia.add(target);
}
}
const media = domSlide.querySelectorAll("*:not(.fragment) video[data-autoplay]");
for (const target of media) {
if (handledMedia.has(target)) {
next;
}
if (focusedSlide) {
autoplay(target);
} else {
target.pause();
}
}
}
const slidenum = document.getElementById("slidenum");
if (slidenum != null) {
slidenum.innerHTML = `${index+1} / ${state.slides.length}`;
}
const progress = document.getElementById("slide-progress");
if (progress != null) {
const bar = progress.getElementsByClassName("bar");
if (bar.length) {
bar[0].style.width = `${(index+1) / state.slides.length * 100}%`;
}
}
});
const hash = window.location.hash.substring(1);
const [slide,fragment] = hash && hash.split("/").map((x) => parseInt(x));
const initState = {
mode: "presenting",
slides: map ((slide) => {
const fragments = Math.max(slide.getElementsByClassName("fragment").length-1, 0);
return {
range: [0, fragments],
index: Math.max(Math.min((fragment||1)-1, fragments), 0),
"fantasy-land/equals": function ({range, index}) {
return equals (this.index) (index)
&& equals (this.range) (range);
},
};
}) ([...domSlides]),
index: Math.min((slide||1)-1, domSlides.length-1),
"fantasy-land/equals": function ({mode, slides, index}) {
return equals (this.index) (index)
&& equals (this.mode) (mode)
&& equals (this.slides) (slides);
},
};
const handleClick = (x,y) => transition((state) => {
if (state.mode !== "overview") {
return state;
}
let el = document.elementFromPoint(x, y);
while (el != null && !el.classList.contains("slide")) {
el = el.parentElement;
}
if (el != null) {
const slides = document.getElementsByClassName("slide");
for (let i = 0; i<slides.length; i++) {
if (slides[i] === el) {
return {...state, index: i, mode: "presenting"};
}
}
}
return {...state, mode: "presenting"};
});
const handleScroll = (e) => transition((state) => {
const slides = root.getElementsByClassName("slide");
for (let i = 0; i<slides.length; i++) {
if (inView (slides[i])) {
return {...state, index: i};
}
}
return state;
});
const preventDefault = (p) => (e) =>
p (e) ? (e.preventDefault(), e.stopPropagation(), true) : false;
const onKeyPress = (key, p=constant(true)) => (action) =>
map (constant(action)) (
filter ((x) => x === true) (DOM.keyPressed (key, preventDefault(p)))
);
const navigation = mergeFold ([
onKeyPress (13, (e) => !e.shiftKey) (Actions.next),
onKeyPress (13, (e) => e.shiftKey) (Actions.previous),
onKeyPress (27) (Actions.reset),
onKeyPress (32) (Actions.next),
onKeyPress (37) (Actions.previousSlide),
onKeyPress (38) (Actions.previous),
onKeyPress (39) (Actions.nextSlide),
onKeyPress (40) (Actions.next),
onKeyPress (113) (Actions.toggleOverviewMode),
onKeyPress (114) (Actions.toggleContinuousMode),
onKeyPress (122) (onlyEffects(Eff(toggleFullscreen) (root))),
]);
const mouseClicks = () => {
const sig = pure (Signal.Signal) (noEffects);
window.addEventListener("mousedown", (e) => sig.set (handleClick (e.clientX, e.clientY)));
return sig;
};
const gestureToAction = {
"swipe_to_left": Actions.nextSlideRestart,
"swipe_to_right": Actions.previousSlideRestart,
"tap": Actions.next,
};
const gestures = map ((x) => gestureToAction[x] || noEffects) (trackGestures ());
const scroll = () => {
const sig = pure (Signal.Signal) (noEffects);
root.querySelector(".slides").addEventListener("scrollend", (e) => sig.set (handleScroll (e)));
return sig;
};
const defaultOptions = {
"theme-switcher": {
enable: !root.hasAttribute("data-theme"),
"extra-themes": [],
},
"ready": () => {},
};
const options = {
...defaultOptions,
...opts,
"theme-switcher": {
...defaultOptions["theme-switcher"],
...opts["theme-switcher"],
},
};
const config = {
initState: initState,
foldp: apply,
render: view,
inputs: [navigation, mouseClicks(), scroll(), gestures]
};
initDOM(options);
initAnimations();
const app = start (config);
app.state.subscribe (({index, slides}) => {
const slide = index + 1;
const fragment = slides[index].index + 1;
history.pushState(null, null, `#${slide}/${fragment}`);
});
options["ready"](app);
};
export const main = (opts) => {
patchBuiltins ();
bootstrap(() => run(opts));
};