122 lines
6.4 KiB
JavaScript
122 lines
6.4 KiB
JavaScript
/* 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();
|
||
});
|
||
})();
|