(() => { const $ = id => document.getElementById(id); const params = new URLSearchParams(location.search); const session = params.get('session') || crypto.randomUUID(); params.set('session', session); history.replaceState(null, '', `${location.pathname}?${params}`); const channel = new BroadcastChannel(`aritmolab-presenter:${session}`); const sender = crypto.randomUUID(); const send = (type, extra = {}) => channel.postMessage({ type, role: 'console', sender, ...extra }); const command = (action, extra = {}) => send('command', { action, ...extra }); const preview = $('preview'); const nextPreview = $('next-preview'); let nextPreviewReady = false; let slides = []; let current = null; let currentNoteKey = null; let audience = null; let lastAudienceSeen = 0; let lastPreviewSeen = 0; let notesSize = 25; let blocked = false; const urlFor = role => `../deck/index.html?v=19&view=${role}&session=${encodeURIComponent(session)}`; const pad = value => String(value).padStart(2, '0'); function renderNotes(html) { const template = document.createElement('template'); template.innerHTML = html; const target = $('notes'); target.replaceChildren(); // Preserve authored rich text when available; plain scripts get reading paragraphs. if (template.content.querySelector('p, ul, ol, h3, blockquote')) { target.append(template.content.cloneNode(true)); return; } if (template.content.querySelector('*')) { const script = document.createElement('div'); script.className = 'notes-authored'; script.append(template.content.cloneNode(true)); target.append(script); return; } let text = template.content.textContent.trim(); const draft = text.match(/^\[DRAFT[^\]]*\]/); if (draft) { const label = document.createElement('div'); label.className = 'notes-draft'; label.textContent = draft[0]; target.append(label, document.createTextNode('\n')); text = text.slice(draft[0].length).trim(); } const segmenter = new Intl.Segmenter('en', { granularity: 'sentence' }); const paragraphs = []; for (const line of text.split(/\n+/).map(line => line.trim()).filter(Boolean)) { let paragraph = ''; for (const { segment } of segmenter.segment(line)) { if (paragraph.length >= 240 && paragraph.length + segment.length > 390) { paragraphs.push(paragraph.trim()); paragraph = ''; } paragraph += segment; } if (paragraph.trim()) paragraphs.push(paragraph.trim()); } for (const text of paragraphs.length ? paragraphs : ['No speaker notes for this slide.']) { const paragraph = document.createElement('p'); for (const part of text.split(/(\b(?:Cardioref|Omics Portal|AritmoLab|ThothII|Tier 1|Tier 2)\b)/g)) { if (/^(Cardioref|Omics Portal|AritmoLab|ThothII|Tier 1|Tier 2)$/.test(part)) { const strong = document.createElement('strong'); strong.textContent = part; paragraph.append(strong); } else paragraph.append(document.createTextNode(part)); } target.append(paragraph, document.createTextNode('\n')); } } function updateNextPreview() { const index = (current?.index ?? 0) + 1; const hasNext = index < slides.length; $('next-preview-shell').hidden = !hasNext; if (hasNext && nextPreviewReady) nextPreview.contentWindow.Reveal.slide(index); } function render(state) { if (!state || !slides[state.index]) return; const changed = !current || current.index !== state.index; current = state; const slide = slides[state.index]; $('counter').textContent = `${pad(state.index + 1)} / ${pad(slides.length)}`; $('slide-picker').value = state.index; $('previous').disabled = state.index === 0; $('next').disabled = state.index === slides.length - 1; $('close-popup').disabled = !state.popup; if (changed) { // Trusted, same-origin authored notes, never channel-provided HTML. if (!slide.popupNotes?.length) renderNotes(slide.notes); currentNoteKey = null; $('reading').scrollTop = 0; $('next-title').textContent = slides[state.index + 1]?.title || 'End of presentation'; updateNextPreview(); $('popup-controls').replaceChildren(); $('popup-controls').hidden = !slide.popups.length; $('close-popup').hidden = !slide.popups.length; let popupNumber = 0; const groups = ['lens', 'screenshot', 'start', 'missing', 'ai'].flatMap(kind => { const popups = slide.popups.filter(popup => popup.kind === kind); return popups.length ? [{ kind, popups }] : []; }); for (const { kind, popups } of groups) { const group = document.createElement('section'); group.className = `popup-group popup-group-${kind}`; group.setAttribute('aria-label', { lens: 'Enlargements', screenshot: 'Screenshots', start: 'Source systems', missing: 'Missing pieces', ai: 'AI contributions' }[kind]); const buttons = document.createElement('div'); buttons.className = 'popup-buttons'; group.append(buttons); $('popup-controls').append(group); for (const popup of popups) { const button = document.createElement('button'); const number = document.createElement('strong'); number.className = 'popup-number'; number.textContent = ++popupNumber; const separator = document.createElement('span'); separator.textContent = ' - '; const label = document.createElement('span'); label.textContent = popup.title.split(' — ')[0]; button.append(number, separator, label); if (kind === 'ai') { const icon = document.createElement('img'); icon.src = '../deck/artificial-brain.svg'; icon.alt = ''; icon.className = 'control-ai-icon'; button.insertBefore(icon, number); } button.title = popup.title; button.dataset.kind = popup.kind; button.dataset.key = popup.key; button.onclick = () => { if (kind === 'lens') { command('popup', { popup: { kind: popup.kind, key: popup.key } }); return; } if (current?.popup?.kind === popup.kind && current.popup.key === popup.key) command('close'); else command('popup', { popup: { kind: popup.kind, key: popup.key } }); }; buttons.append(button); } } } if (slide.popupNotes?.length) { // Keep the last selected notes readable when its screenshot is closed. const note = slide.popupNotes.find(note => state.popup?.kind === 'screenshot' && note.key === state.popup.key) || slide.popupNotes.find(note => note.key === currentNoteKey) || slide.popupNotes[0]; if (note.key !== currentNoteKey) { currentNoteKey = note.key; renderNotes(note.html); $('reading').scrollTop = 0; } } $('popup-controls').querySelectorAll('button').forEach(button => { button.setAttribute('aria-pressed', String(state.popup?.kind === button.dataset.kind && state.popup?.key === button.dataset.key)); }); } channel.onmessage = ({ data }) => { if (!data || data.sender === sender) return; if (data.role === 'audience') lastAudienceSeen = data.type === 'departed' ? 0 : Date.now(); if (data.role === 'preview') lastPreviewSeen = data.type === 'departed' ? 0 : Date.now(); if (data.type === 'state') render(data.state); updateStatus(); }; function updateStatus() { const connected = Date.now() - lastAudienceSeen < 4500; const previewReady = slides.length > 0 && Date.now() - lastPreviewSeen < 4500; $('status').textContent = !previewReady ? 'Connecting to slide preview…' : connected ? 'Audience connected' : blocked ? 'Window blocked. Allow popups and click Open audience window again.' : 'Preview only. Open the audience window to present.'; $('status').classList.toggle('connected', connected); } $('open-audience').onclick = () => { if (audience && !audience.closed) { audience.focus(); return; } audience = window.open(urlFor('audience'), `aritmolab-audience-${session}`, 'popup=yes,width=1280,height=760'); blocked = !audience; updateStatus(); }; preview.addEventListener('load', async () => { try { const win = preview.contentWindow; if (!win.Reveal) throw new Error('Slide preview could not load. Check that the local server is running.'); if (!win.Reveal.isReady()) await new Promise(resolve => win.Reveal.on('ready', resolve)); slides = win.PresentationControls.slides(); $('slide-picker').replaceChildren(...slides.map((slide, index) => new Option(`${pad(index + 1)} · ${slide.title}`, index))); $('slide-picker').disabled = false; current = null; render(win.PresentationControls.snapshot()); send('request'); } catch (error) { $('error').hidden = false; $('error').textContent = error.message; } }); preview.src = urlFor('preview'); nextPreview.addEventListener('load', async () => { const reveal = nextPreview.contentWindow.Reveal; if (!reveal) return; if (!reveal.isReady()) await new Promise(resolve => reveal.on('ready', resolve)); reveal.configure({ keyboard: false, touch: false, overview: false, transition: 'none' }); nextPreviewReady = true; updateNextPreview(); }); // No session token: the thumbnail must never navigate or publish to the audience. nextPreview.src = '../deck/index.html?v=19&view=thumbnail#/1'; const stage = document.querySelector('.stage'); function sizeCurrentPreview() { const style = getComputedStyle(stage); const navigation = getComputedStyle($('previous')); // Use the one-line navigation height, never the popup-dependent controls height. const navigationHeight = Math.max(parseFloat(navigation.minHeight), parseFloat(navigation.lineHeight) + parseFloat(navigation.paddingTop) + parseFloat(navigation.paddingBottom) + parseFloat(navigation.borderTopWidth) + parseFloat(navigation.borderBottomWidth)); const available = stage.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom) - stage.querySelector('.section-heading').getBoundingClientRect().height - navigationHeight - parseFloat(getComputedStyle($('next-title')).lineHeight) - 4 * parseFloat(style.rowGap); // Do not reserve empty bands when the slide is already limited by column width. const widthLimitedHeight = (stage.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight)) * 686 / 1280; stage.style.setProperty('--current-preview-height', `${Math.max(0, Math.min(available * .65, widthLimitedHeight))}px`); } const stageSizes = new ResizeObserver(sizeCurrentPreview); stageSizes.observe(stage); stageSizes.observe(stage.querySelector('.section-heading')); stageSizes.observe($('previous')); sizeCurrentPreview(); function fitPreviews() { const currentBox = preview.parentElement; const nextBox = nextPreview.parentElement; const currentScale = Math.min(currentBox.clientWidth / 1280, currentBox.clientHeight / 686); // The next slide includes its footer: cap its rendered height as well as its width. const nextScale = Math.min(nextBox.clientWidth / 1280, nextBox.clientHeight / 720, currentScale * 686 / 720); preview.style.transform = `translate(-50%, -50%) scale(${currentScale})`; nextPreview.style.transform = `translate(-50%, -50%) scale(${nextScale})`; } const previewSizes = new ResizeObserver(fitPreviews); for (const frame of [preview, nextPreview]) previewSizes.observe(frame.parentElement); $('previous').onclick = () => command('previous'); $('next').onclick = () => command('next'); $('close-popup').onclick = () => command('close'); $('slide-picker').onchange = event => command('slide', { index: Number(event.target.value) }); $('layout').onchange = event => { document.body.dataset.layout = event.target.value; }; function fontSize(delta) { notesSize = Math.min(40, Math.max(18, notesSize + delta)); document.documentElement.style.setProperty('--notes-size', `${notesSize}px`); $('smaller').disabled = notesSize === 18; $('larger').disabled = notesSize === 40; } $('smaller').onclick = () => fontSize(-2); $('larger').onclick = () => fontSize(2); document.addEventListener('keydown', event => { if (event.target.closest('input, select, textarea, [contenteditable="true"]')) return; if (/^[1-9]$/.test(event.key) && !event.repeat && !event.altKey && !event.ctrlKey && !event.metaKey) { const lens = [...$('popup-controls').querySelectorAll('[data-kind="lens"], [data-kind="screenshot"]')] .find(button => button.querySelector('.popup-number').textContent === event.key); if (lens) { event.preventDefault(); lens.click(); return; } } const action = { ArrowRight: 'next', ArrowLeft: 'previous', Escape: 'close' }[event.key]; if (action) { event.preventDefault(); command(action); } }); let elapsed = 0, started = null; $('timer-toggle').onclick = () => { if (started === null) { started = Date.now(); $('timer-toggle').textContent = 'Pause timer'; } else { elapsed += Date.now() - started; started = null; $('timer-toggle').textContent = 'Start timer'; } }; $('timer-reset').onclick = () => { elapsed = 0; if (started !== null) started = Date.now(); }; setInterval(() => { const seconds = Math.floor((elapsed + (started === null ? 0 : Date.now() - started)) / 1000); $('elapsed').textContent = `${pad(Math.floor(seconds / 60))}:${pad(seconds % 60)}`; updateStatus(); }, 500); })();