Files
ThothII/presentation/deck/slide-lens.js
T

122 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Reuse with a source button carrying its id and data-lens="source-id".
Content is cloned from the source, with optional explanatory content in a template. */
(() => {
let current = null;
const changed = () => window.dispatchEvent(new Event('presentationchange'));
const reducedMotion = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
function layout() {
if (!current) return;
const { source, slide, frame, copy, guides, caption, detailed } = current;
const sr = slide.getBoundingClientRect();
const rect = source.getBoundingClientRect();
const scale = sr.width / slide.offsetWidth;
const area = (slide.querySelector('.flow') || slide.querySelector('.sbody')).getBoundingClientRect();
const centered = source.dataset.lensPlacement === 'center';
const x = (rect.left - sr.left) / scale, y = (rect.top - sr.top) / scale;
const w = rect.width / scale, h = rect.height / scale;
const isGroup = source.classList.contains('zone');
const areaTop = isGroup || detailed ? 160 : (area.top - sr.top) / scale;
const areaHeight = isGroup || detailed ? slide.offsetHeight - areaTop - 50 : area.height / scale;
// Choose the roomier side so the original stays visible, including middle/right nodes.
const margin = 65, gap = 38;
const roomLeft = x - gap - margin;
const roomRight = slide.offsetWidth - margin - (x + w + gap);
const onRight = roomRight >= roomLeft;
const maxWidth = centered ? Math.min(760, slide.offsetWidth - 2 * margin)
: Math.min(slide.offsetWidth * .58, Math.max(roomLeft, roomRight));
const zoom = Math.min(4, (maxWidth - 24) / w, (areaHeight - 40) / h);
let width = w * zoom + 24, height = h * zoom + 24;
if (detailed) {
width = maxWidth;
Object.assign(copy.style, { width: `${width - 24}px`, height: 'auto', transform: 'none' });
height = Math.min(copy.scrollHeight + 24, areaHeight - 16);
}
const left = centered ? (slide.offsetWidth - width) / 2 : onRight
? Math.min(slide.offsetWidth - width - margin, Math.max(slide.offsetWidth * .36, x + w + gap))
: Math.max(margin, Math.min(slide.offsetWidth * .36, x - gap - width));
const top = areaTop + (areaHeight - height) / 2 + 10;
Object.assign(frame.style, { left: `${left}px`, top: `${top}px`, width: `${width}px`, height: `${height}px` });
if (!detailed) Object.assign(copy.style, { width: `${w}px`, height: `${h}px`, transform: `scale(${zoom})` });
const zoneTitle = source.dataset.lensCaption || source.closest('.zone')?.querySelector('.zlabel')?.textContent || 'Detail';
caption.textContent = detailed ? zoneTitle : `${zoneTitle} · ${zoom.toFixed(1)}×`;
guides.setAttribute('viewBox', `0 0 ${slide.offsetWidth} ${slide.offsetHeight}`);
const sourceEdge = onRight ? x + w : x;
const lensEdge = onRight ? left : left + width;
guides.querySelector('path').setAttribute('d', `M ${sourceEdge} ${y} L ${lensEdge} ${top} M ${sourceEdge} ${y + h} L ${lensEdge} ${top + height}`);
current.origin = `translate(${x - left}px, ${y - top}px) scale(${w / width}, ${h / height})`;
}
function close() {
if (!current) return;
const { slide, source, trigger, frame, guides, animation } = current;
animation?.cancel();
const restoreFocus = frame.contains(document.activeElement);
frame.remove(); guides.remove();
slide.classList.remove('lens-active'); source.classList.remove('lens-source');
trigger.setAttribute('aria-expanded', 'false');
current = null;
if (restoreFocus && slide.classList.contains('present')) trigger.focus({ preventScroll: true });
changed();
}
function open(trigger) {
const source = document.getElementById(trigger.dataset.lens);
const slide = source?.closest('section');
if (!source || !slide?.classList.contains('present')) return;
close();
const details = source.querySelector('template.lens-details');
const title = source.dataset.lensTitle || source.querySelector('b').textContent;
const copy = document.createElement('div');
if (details) {
copy.className = 'slide-lens-detail';
const heading = document.createElement('h3');
heading.textContent = title;
copy.append(heading, details.content.cloneNode(true));
} else {
copy.className = source.className;
copy.classList.remove('lens-target', 'lens-source');
copy.innerHTML = source.innerHTML;
}
copy.querySelectorAll('[id]').forEach(el => el.removeAttribute('id'));
copy.querySelectorAll('button').forEach(el => el.remove());
copy.classList.add('slide-lens-copy');
const frame = document.createElement('div');
frame.id = 'slide-lens'; frame.className = 'slide-lens';
frame.classList.toggle('has-details', Boolean(details));
frame.setAttribute('role', 'region');
frame.setAttribute('aria-label', details ? `${title} explained` : `${title} enlarged`);
const caption = document.createElement('div'); caption.className = 'slide-lens-caption';
const content = document.createElement('div'); content.className = 'slide-lens-content';
content.append(copy);
const closeButton = document.createElement('button');
closeButton.className = 'slide-lens-close'; closeButton.textContent = '×';
closeButton.setAttribute('aria-label', 'Close detail');
frame.append(caption, content, closeButton);
const guides = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
guides.classList.add('slide-lens-guides'); guides.setAttribute('aria-hidden', 'true');
guides.append(document.createElementNS(guides.namespaceURI, 'path'));
slide.append(guides, frame);
current = { source, slide, trigger, frame, copy, guides, caption, detailed: Boolean(details) };
layout();
source.classList.add('lens-source'); slide.classList.add('lens-active');
trigger.setAttribute('aria-expanded', 'true');
if (!reducedMotion()) {
current.animation = frame.animate([
{ transform: current.origin, opacity: .5 },
{ transform: 'none', opacity: 1 },
], { duration: 480, easing: 'cubic-bezier(.16, 1, .3, 1)' });
guides.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 480 });
}
changed();
}
window.SlideLens = {
open, close, layout,
get active() { return current ? { kind: 'lens', key: current.source.id } : null; },
};
addEventListener('resize', layout);
document.addEventListener('transitionend', event => {
if (event.target === current?.source && event.propertyName === 'transform') layout();
});
})();