263 lines
13 KiB
JavaScript
263 lines
13 KiB
JavaScript
(() => {
|
|
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);
|
|
})();
|