@xiee/utils
Version:
Miscellaneous tools and utilities to manipulate HTML pages
194 lines (192 loc) • 7.3 kB
JavaScript
(function(d) {
let p = d.body; // container of slides; assume <body> for now
const s1 = ':scope > hr:not([class])', s2 = ':scope > h2';
// find a container that has at least n "slides"
function findContainer(s, n = 1) {
if (p.querySelectorAll(s).length >= n) return true;
// if body doesn't contain headings or <hr>s, look into children
for (let i = 0; i < p.children.length; i++) {
if (p.children[i].querySelectorAll(s).length >= n) {
p = p.children[i]; break;
}
}
return false;
}
function newEl(tag, cls) {
const el = d.createElement(tag);
if (cls) el.className = cls;
return el;
}
if (!findContainer(s1, 3)) {
// if not enough <hr>s found in children; look for <h2> instead
if (p.tagName === 'BODY') {
// not enough h2 found, this page is not appropriate for slides
if (!findContainer(s2) && p.tagName === 'BODY') return;
p.querySelectorAll(s2).forEach(h2 => h2.before(newEl('hr')));
}
}
p.classList.add('slide-container');
// add 'slide' class to the frontmatter/abstract divs and toc
['.frontmatter', '.abstract', '#TOC'].forEach(sel => {
const el = d.body.querySelector(sel);
if (!el) return;
if (sel === '.frontmatter') {
el.classList.add('slide');
} else {
const s = newSlide(); el.before(s); s.append(el);
}
});
function newSlide(s) {
return (s?.innerHTML === '') ? s : newEl('div', 'slide');
}
function isSep(el) {
return el.tagName === 'HR' && el.attributes.length === 0;
}
let el = p.firstElementChild; isSep(el) && el.remove();
el = p.firstElementChild; if (!el) return;
let s = newSlide(); el.before(s);
while (true) {
let el = s.nextSibling;
if (!el) break;
// remove slide separators (<hr>) and create new slide
if (isSep(el)) {
s = newSlide(s);
el.before(s); el.remove();
} else if (el.classList?.contains('slide')) {
s = newSlide(s);
el.after(s);
} else {
s.append(el);
}
}
function setAttr(el, attr) {
const m = newEl('div');
m.innerHTML = `<div ${attr}></div>`;
const attrs = m.firstElementChild.attributes;
for (const a of attrs) {
el.setAttribute(a.name, a.value);
}
m.remove();
}
function reveal(el) {
setTimeout(() => el?.scrollIntoView(), 100);
}
const dE = d.documentElement, dC = d.body.classList;
const slides = d.querySelectorAll('div.slide'), N = slides.length,
tm = d.querySelector('span.timer'), fn = d.querySelector('.footnotes');
slides.forEach((s, i) => {
// slide header, main body, and footer
const header = newEl('div', 'header'), main = newEl('div', 'main'), footer = newEl('div', 'footer');
main.append(...s.childNodes);
s.append(main);
s.insertAdjacentElement('afterbegin', header);
s.insertAdjacentElement('beforeend', footer);
// append footnotes
if (fn) s.querySelectorAll('.footnote-ref > a[href^="#fn"]').forEach(a => {
const li = fn.querySelector('li' + a.getAttribute('href'));
if (!li) return;
let f = s.querySelector('section.footnotes');
if (!f || f.contains(li)) {
f = newEl('section', 'footnotes'); footer.before(f);
}
f.append(li);
li.firstElementChild?.insertAdjacentHTML('afterbegin', `[${a.innerHTML}] `);
li.outerHTML = li.innerHTML;
});
// add a timer
footer.append(tm ? tm.cloneNode() : newEl('span', 'timer'));
// add page numbers
const n = newEl('span', 'page-number');
n.innerText = i + 1 + '/' + N;
n.onclick = e => location.hash = i + 1;
footer.append(n);
// apply slide attributes in <!--# -->
for (const node of main.childNodes) {
if (node.nodeType !== Node.COMMENT_NODE) continue;
let t = node.textContent;
if (!/^#/.test(t)) continue;
t = t.replace(/^#/, '');
const r = /[\s\n]class="([^"]+)"/, m = t.match(r);
if (m) {
t = t.replace(r, '').trim();
s.className += ' ' + m[1];
}
if (t) setAttr(s, t);
break;
}
s.addEventListener('click', e => {
(e.altKey || e.ctrlKey) && (toggleView(e), reveal(e.target));
});
});
[...d.querySelectorAll('a.footnote-backref'), fn, tm].forEach(el => el?.remove());
const tms = d.querySelectorAll('span.timer'), t1 = 1000 * tms[0].dataset.total;
let t0;
function startTimers() {
t0 = new Date();
setInterval(setTimers, 1000);
}
function setTimers() {
if (!dC.contains('slide-mode')) return; // set timer only in slide mode
let t = (new Date() - t0);
if (t1) t = t1 - t;
const t2 = new Date(Math.abs(t)).toISOString().substr(11, 8).replace(/^00:/, '');
tms.forEach(el => {
el.innerText = t2;
if (t < 0) el.style.opacity = Math.ceiling(t/1000) % 2;
});
}
function toggleView(e) {
(dC.toggle('overview') ? dC.remove('slide-mode') : setScale(e));
}
// press f for fullscreen mode
d.addEventListener('keyup', e => {
if (e.target !== d.body) return;
e.key === 'f' && dE.requestFullscreen();
e.key === 'o' && toggleView(e);
e.key === 'm' && dC.toggle('mirrored');
sessionStorage.setItem('body-class', d.body.className);
});
// start timer and set scale on fullscreen
d.onfullscreenchange = e => {
d.fullscreenElement && (!t0 && startTimers(), setScale(e));
}
tms.forEach(el => el.addEventListener('click', e => startTimers()));
// measure the height of an empty slide
let H = -1;
function slideHeight() {
if (H >= 0) return H;
const s = newEl('div', 'slide');
p.querySelector('.slide:last-of-type').after(s);
H = s.offsetHeight;
s.remove()
return H;
}
// scale slides according to window height (baseline: 900px)
const sty = newEl('style'); sty.setAttribute('type', 'text/css');
d.head.append(sty);
// default height/width ratio from screen size
sty.innerHTML = `:root{--slide-ratio:${screen.height / screen.width}}`;
// read --slide-ratio in case users have set it in their CSS
const ratio = +getComputedStyle(dE).getPropertyValue('--slide-ratio');
function setScale(e) {
// navigate to a slide indicated by the hash if provided
e.type === 'load' && reveal(slides[location.hash.replace(/^#/, '') - 1]);
if (dC.contains('overview')) return;
let h = window.innerHeight, p = h / 900, r2 = h / window.innerWidth, full = d.fullscreenElement;
// add slide mode if there's enough window width, and remove it in case of scrollbar
dC.toggle('slide-mode', full || r2 <= ratio) &&
(!full && (dE.scrollWidth > dE.offsetWidth)) && dC.remove('slide-mode');
sty.innerHTML = `:root{--slide-ratio:${ratio};--slide-scale:${p};--slide-top:${(p - 1)/2 * d.body.scrollHeight + 'px'};}`;
// add spacers with enough height on load
!d.querySelector('.spacer.fade') && dC.contains('slide-mode') && slides.forEach(s => {
const sp = newEl('div', 'spacer fade'), h = s.offsetHeight, h2 = slideHeight();
s.append(sp);
if (h <= h2) return;
sp.style.height = (h2 - h % h2) * p + 'px';
});
}
['load', 'resize'].forEach(evt => window.addEventListener(evt, setScale));
// restore previously saved body class
const bc = sessionStorage.getItem('body-class');
if (bc) d.body.className += ' ' + bc;
})(document);