UNPKG

@expositio/core

Version:

Simple and lean presentation tool.

351 lines (310 loc) 13.4 kB
// 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)); };