Files
ThothII/presentation/presenter/presenter.js
T

278 lines
14 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=18&view=${role}&session=${encodeURIComponent(session)}`;
const pad = value => String(value).padStart(2, '0');
function showNoteLens(event) {
const cue = event.target.closest('[data-note-lens]');
if (!cue || !slides[current?.index]?.popups.some(popup => popup.kind === 'lens' && popup.key === cue.dataset.noteLens)) return;
if (current?.popup?.key !== cue.dataset.noteLens) command('popup', { popup: { kind: 'lens', key: cue.dataset.noteLens } });
}
$('notes').addEventListener('click', showNoteLens);
$('notes').addEventListener('click', event => {
const cue = event.target.closest('[data-note-screenshot]');
if (cue && slides[current?.index]?.popups.some(popup => popup.kind === 'screenshot' && popup.key === cue.dataset.noteScreenshot)) {
command('popup', { popup: { kind: 'screenshot', key: cue.dataset.noteScreenshot } });
}
});
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) {
$('notes-number').textContent = `SLIDE ${pad(state.index + 1)}`;
$('notes-title').textContent = slide.title;
// 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;
$('notes-title').textContent = note.title;
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=18&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);
})();