/* 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(); }); })();