Update interactive presentation and presenter console

This commit is contained in:
Codex
2026-09-20 19:43:33 +02:00
parent 348f4a9738
commit 5217c574e9
21 changed files with 1318 additions and 112 deletions
+121
View File
@@ -0,0 +1,121 @@
/* 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();
});
})();