Update interactive presentation and presenter console
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
# Presenter console
|
||||
|
||||
Serve `presentation/` using the same local server as the deck:
|
||||
|
||||
```sh
|
||||
python3 -m http.server 8000 --bind 127.0.0.1 --directory presentation
|
||||
```
|
||||
|
||||
Open <http://localhost:8000/presenter/> in Chrome or another modern desktop browser.
|
||||
Select **Open audience window**, move the new window to the HDMI display, then click
|
||||
**Full screen** there. Keep the console on the Mac display. Use extended displays,
|
||||
not mirroring. Both windows must use the same browser profile and origin.
|
||||
|
||||
The console provides interactive slide preview, notes with adjustable type size,
|
||||
slide navigation, per-slide popup buttons, a manually started timer, and a choice
|
||||
between notes beside or below the preview. Notes fill the available horizontal space.
|
||||
Up next includes a live, non-interactive thumbnail of the following slide in both
|
||||
layouts; at the last slide it is replaced by the end-of-presentation message.
|
||||
With notes beside the preview, the current preview keeps the height calculated as
|
||||
65% of the space available without popup controls. It resizes with the window,
|
||||
not with the number of popups. The next preview takes the space left after the
|
||||
current preview, navigation, popup buttons and labels, and never renders larger
|
||||
than the current one.
|
||||
Popup controls contain only numbered
|
||||
buttons and disappear entirely on slides without popups. The current preview omits
|
||||
the 34px slide footer; the audience slide remains unchanged.
|
||||
Arrow keys change slides; Escape closes
|
||||
the popup. Changing slide closes any open popup. Notes scroll independently and
|
||||
return to the beginning when the slide changes.
|
||||
|
||||
Popup controls, clickable images and speaker-note headings open only on click
|
||||
or explicit keyboard activation (Enter, Space or the numbered shortcuts).
|
||||
Hover and keyboard focus may highlight a control but never open or switch a popup.
|
||||
On slide 08,
|
||||
the five numbered headings in the speaker notes also open the corresponding
|
||||
Validation detail, centered on the slide with the shared lens effect. The fifth
|
||||
detail explains all three advantages; on the slide, the advantages panel highlights
|
||||
on hover and opens on click. A detail
|
||||
stays open when the pointer leaves, until another detail is selected, Escape or
|
||||
Close is used, or the slide changes. The preview and audience show the same detail.
|
||||
|
||||
Slide 09 has seven numbered screenshot controls, in the supplied tour order.
|
||||
Click a thumbnail, a console button or a numbered heading in its notes to open
|
||||
the original screenshot almost full screen. Keys 1–7 also select screenshots;
|
||||
Escape or Close returns to the slide. Preview and audience share the selection.
|
||||
The seven English descriptions form one short script (about 90 seconds including
|
||||
brief pauses to change screens).
|
||||
|
||||
The preview and audience communicate over a session-specific `BroadcastChannel`.
|
||||
The session is in the console URL; the latest slide and popup are retained in local
|
||||
storage for refresh recovery. A fresh `/presenter/` URL starts a separate session.
|
||||
The normal `/deck/index.html` page does not join any session. This console is for
|
||||
windows in one browser on one computer, not remote tablet synchronization.
|
||||
|
||||
If the audience window is blocked, allow popups for localhost and click the button
|
||||
again. Closing that window leaves the console usable for rehearsal; reopen it to
|
||||
resume at the current slide. The audience also remains navigable if the console
|
||||
is closed. Keep the local server running throughout the presentation.
|
||||
|
||||
Source files:
|
||||
|
||||
- `index.html`, `presenter.css`, `presenter.js`: presenter interface.
|
||||
- `../deck/presenter-bridge.js`: optional synchronization bridge.
|
||||
- `PresentationControls` in `../deck/index.html`: slide notes and popup interface.
|
||||
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AritmoLab | Presenter console</title>
|
||||
<link rel="stylesheet" href="../deck/fonts.css">
|
||||
<link rel="stylesheet" href="presenter.css?v=5">
|
||||
<script src="presenter.js?v=5" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="toolbar">
|
||||
<div class="identity"><strong>AritmoLab</strong><span>Presenter console</span></div>
|
||||
<div class="timer"><output id="elapsed" aria-label="Elapsed time">00:00</output><button id="timer-toggle">Start timer</button><button id="timer-reset" aria-label="Reset timer">Reset</button></div>
|
||||
<button id="open-audience" class="primary">Open audience window ↗</button>
|
||||
</header>
|
||||
<div class="connection"><span id="status" role="status">Loading presentation…</span><span>Keep this window on your Mac. Move the audience window to HDMI, then choose Full screen.</span></div>
|
||||
<main>
|
||||
<section class="stage" aria-label="Slide preview and controls">
|
||||
<div class="section-heading"><h2>On screen <span id="counter">01 / 14</span></h2><label class="sr-only" for="slide-picker">Jump to slide</label><select id="slide-picker" disabled><option>Loading slides…</option></select></div>
|
||||
<div class="preview-shell"><iframe id="preview" title="Interactive slide preview" allow="fullscreen"></iframe></div>
|
||||
<div class="slide-controls">
|
||||
<div class="navigation"><button id="previous" disabled>← Previous</button><button id="next" class="primary" disabled>Next slide →</button><button id="close-popup" disabled hidden>Close popup <kbd>Esc</kbd></button></div>
|
||||
<div id="popup-controls" aria-label="Slide popups" hidden></div>
|
||||
</div>
|
||||
<div class="up-next">
|
||||
<div class="next-preview" id="next-preview-shell" inert aria-hidden="true">
|
||||
<iframe id="next-preview" title="Next slide thumbnail" tabindex="-1"></iframe>
|
||||
</div>
|
||||
<div><span>UP NEXT</span><p id="next-title">Loading…</p></div>
|
||||
</div>
|
||||
<p id="error" role="alert" hidden></p>
|
||||
</section>
|
||||
<section class="script" aria-labelledby="notes-heading">
|
||||
<div class="section-heading"><h2 id="notes-heading">Speaker notes</h2><div class="reading-controls"><button id="smaller" aria-label="Smaller notes">A−</button><button id="larger" aria-label="Larger notes">A+</button><label class="sr-only" for="layout">Notes layout</label><select id="layout"><option value="side">Notes beside preview</option><option value="below">Notes below preview</option></select></div></div>
|
||||
<div id="reading" tabindex="0" aria-label="Scrollable speaker notes"><div class="slide-label" id="notes-number">SLIDE 01</div><h1 id="notes-title">Preparing your presentation</h1><article id="notes">The slide and its speaker notes will appear here.</article></div>
|
||||
<footer><span>Notes are visible only in this window.</span><span><kbd>←</kbd> <kbd>→</kbd> Change slide · <kbd>Esc</kbd> Close popup</span></footer>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,86 @@
|
||||
:root { color-scheme: light; --bg: oklch(96.9% .006 20); --paper: oklch(99.1% .003 20); --ink: oklch(28% .012 10); --muted: oklch(49% .015 10); --line: oklch(86% .009 10); --accent: oklch(43% .105 12); --tint: oklch(93% .025 12); --notes-size: 25px; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; width: 100%; background: var(--bg); color: var(--ink); font: 16px/1.45 'Source Sans 3', system-ui, sans-serif; height: 100dvh; display: flex; flex-direction: column; }
|
||||
button, select { font: inherit; color: inherit; background: var(--paper); border: 1px solid var(--line); border-radius: 6px; min-height: 38px; padding: 7px 12px; }
|
||||
button { cursor: pointer; } button:hover:not(:disabled), select:hover { background: var(--tint); } button:disabled { opacity: .45; cursor: default; }
|
||||
button.primary { background: var(--accent); color: var(--paper); border-color: var(--accent); font-weight: 600; } button.primary:hover { background: oklch(36% .10 12); }
|
||||
:focus-visible { outline: 3px solid oklch(62% .14 12); outline-offset: 3px; }
|
||||
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 16px 24px; background: var(--paper); border-bottom: 1px solid var(--line); }
|
||||
.identity { display: flex; gap: 16px; align-items: baseline; } .identity strong { font-size: 23px; letter-spacing: -.6px; } .identity span { color: var(--muted); }
|
||||
.timer { display: flex; align-items: center; gap: 8px; } .timer output { font-variant-numeric: tabular-nums; font-size: 24px; margin-right: 8px; } .timer button { border: 0; background: transparent; padding-inline: 7px; }
|
||||
.connection { padding: 9px 24px; display: flex; flex-wrap: wrap; gap: 6px 24px; font-size: 13px; color: var(--muted); border-bottom: 1px solid var(--line); }
|
||||
#status { font-weight: 600; color: var(--accent); } #status.connected { color: oklch(40% .075 160); }
|
||||
main { display: grid; grid-template-columns: minmax(340px, 44%) minmax(0, 1fr); flex: 1; min-height: 0; }
|
||||
.stage { padding: 16px 20px; overflow-y: auto; min-width: 0; display: grid; grid-template-rows: auto var(--current-preview-height, 0px) auto minmax(0, 1fr) auto; gap: 10px; } .section-heading { display: flex; gap: 12px; align-items: center; justify-content: space-between; margin-bottom: 0; }
|
||||
h2 { margin: 0; font-size: 16px; font-weight: 600; white-space: nowrap; } #counter { color: var(--muted); font-weight: 400; font-variant-numeric: tabular-nums; margin-left: 8px; }
|
||||
#slide-picker { min-width: 0; width: 52%; text-overflow: ellipsis; font-size: 14px; }
|
||||
.preview-shell, .next-preview { position: relative; width: 100%; aspect-ratio: 16/9; overflow: hidden; background: var(--paper); border: 1px solid var(--line); }
|
||||
.stage > .preview-shell { width: 100%; height: 100%; min-height: 0; aspect-ratio: auto; justify-self: center; border: 0; background: transparent; }
|
||||
#preview, #next-preview { position: absolute; left: 50%; top: 50%; width: 1280px; height: 720px; transform-origin: center; border: 0; display: block; }
|
||||
#next-preview { pointer-events: none; }
|
||||
#preview { height: 686px; }
|
||||
.next-preview[hidden] { display: none; }
|
||||
.navigation { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .navigation button { min-height: 36px; }
|
||||
.navigation:has(#close-popup:not([hidden])) { grid-template-columns: 1fr 1fr auto; }
|
||||
#close-popup { font-size: 12px; padding-inline: 8px; }
|
||||
#popup-controls { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
#popup-controls:not([hidden]) { margin-top: 8px; }
|
||||
#popup-controls[hidden] { display: none; }
|
||||
#popup-controls button { display: flex; align-items: center; gap: 3px; text-align: left; font-size: 15px; font-weight: 700; line-height: 1.2; min-height: 38px; padding: 4px 6px; width: 100%; overflow-wrap: anywhere; }
|
||||
#popup-controls button > span:last-child { min-width: 0; }
|
||||
.popup-number { font-size: 23px; font-weight: 800; line-height: 1; flex: none; }
|
||||
#popup-controls button[aria-pressed="true"] { background: var(--tint); border-color: var(--accent); color: var(--accent); }
|
||||
.popup-group { min-width: 0; }
|
||||
.popup-buttons { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 5px; }
|
||||
.popup-group-lens { grid-column: 1 / -1; }
|
||||
.popup-group-lens .popup-buttons { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
.popup-group-lens .popup-buttons:has(> button:nth-child(4):last-child) { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.popup-group-screenshot { grid-column: 1 / -1; }
|
||||
.popup-group-screenshot .popup-buttons { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||
#popup-controls .popup-group-screenshot button { justify-content: center; }
|
||||
.popup-group-screenshot button > span { display: none; }
|
||||
#popup-controls .popup-group-lens button { flex-direction: column; justify-content: center; text-align: center; }
|
||||
.popup-group-lens button > span:nth-child(2) { display: none; }
|
||||
.popup-group-ai { grid-column: 1 / -1; } .popup-group-ai .popup-buttons { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.control-ai-icon { width: 27px; height: 27px; flex: 0 0 27px; margin-right: 3px; }
|
||||
.up-next { border-top: 1px solid var(--line); padding-top: 10px; display: grid; grid-template-rows: minmax(0, 1fr) auto; gap: 6px; min-height: 0; }
|
||||
.up-next .next-preview { height: 100%; min-height: 0; aspect-ratio: auto; border: 0; background: transparent; }
|
||||
.up-next > div:last-child { display: flex; align-items: baseline; gap: 12px; min-width: 0; }
|
||||
.up-next span, .slide-label { color: var(--muted); font-size: 12px; letter-spacing: .09em; }
|
||||
.up-next span { flex: none; } .up-next p { margin: 0; font-size: 16px; overflow-wrap: anywhere; }
|
||||
.up-next:has(.next-preview[hidden]) { grid-template-rows: auto; align-content: start; }
|
||||
@media (min-width: 621px) {
|
||||
/* The current preview keeps its no-popup height; the next takes the remainder. */
|
||||
body:not([data-layout="below"]) .up-next { display: contents; }
|
||||
body:not([data-layout="below"]) .next-preview { grid-row: 4; }
|
||||
body:not([data-layout="below"]) .up-next > div:last-child { grid-row: 5; }
|
||||
body:not([data-layout="below"]) #next-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
}
|
||||
.script { background: var(--paper); border-left: 1px solid var(--line); display: flex; flex-direction: column; min-height: 0; min-width: 0; }
|
||||
.script > .section-heading { margin: 0; padding: 20px 28px 16px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
|
||||
.reading-controls { display: flex; align-items: center; gap: 6px; } .reading-controls select { font-size: 13px; max-width: 185px; } .reading-controls button { font-size: 17px; }
|
||||
#reading { overflow: auto; padding: 22px 28px 40px; flex: 1; min-width: 0; scroll-behavior: auto; } h1 { font-size: 27px; line-height: 1.2; letter-spacing: -.3px; margin: 8px 0 24px; overflow-wrap: anywhere; }
|
||||
#notes { font-size: var(--notes-size); line-height: 1.48; white-space: normal; width: 100%; overflow-wrap: anywhere; } #notes p { margin: 0 0 .85em; } #notes strong { font-weight: 600; } #notes .notes-authored { white-space: pre-line; } .notes-draft { font-size: 13px; color: var(--muted); margin-bottom: 16px; }
|
||||
#notes [data-note-lens], #notes [data-note-screenshot] {
|
||||
min-height: 0; padding: 0; border: 0; border-bottom: 1px dotted currentColor; border-radius: 0;
|
||||
background: transparent; color: var(--accent); font: inherit; font-weight: 600;
|
||||
text-align: left; cursor: pointer;
|
||||
}
|
||||
#notes [data-note-lens]:hover, #notes [data-note-screenshot]:hover { background: var(--tint); }
|
||||
#notes [data-note-lens]:focus-visible, #notes [data-note-screenshot]:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
|
||||
footer { border-top: 1px solid var(--line); padding: 12px 28px; font-size: 12px; color: var(--muted); display: flex; flex-wrap: wrap; gap: 6px 20px; justify-content: space-between; }
|
||||
kbd { font: 12px system-ui; background: var(--bg); border: 1px solid var(--line); border-radius: 3px; padding: 1px 4px; } #error { color: var(--accent); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
||||
body[data-layout="below"] main { grid-template-columns: 1fr; grid-template-rows: minmax(240px, 42%) minmax(0, 1fr); }
|
||||
body[data-layout="below"] .stage { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr) minmax(150px, .65fr); grid-template-rows: auto minmax(0, 1fr); column-gap: 24px; padding: 14px 24px; }
|
||||
body[data-layout="below"] .stage > .section-heading { grid-column: 1; }
|
||||
body[data-layout="below"] .preview-shell { grid-column: 1; grid-row: 2; width: 100%; height: 100%; aspect-ratio: auto; min-height: 100px; }
|
||||
body[data-layout="below"] .slide-controls { grid-column: 2; grid-row: 1 / 3; overflow: auto; min-height: 0; }
|
||||
body[data-layout="below"] .navigation { margin: 0 0 16px; }
|
||||
body[data-layout="below"] .up-next { grid-column: 3; grid-row: 1 / 3; border-top: 0; padding-top: 0; overflow: auto; }
|
||||
body[data-layout="below"] .up-next > div:last-child { display: block; }
|
||||
body[data-layout="below"] .stage .hint { display: none; } body[data-layout="below"] .script { border-left: 0; border-top: 1px solid var(--line); }
|
||||
@media (max-width: 850px) { .identity span, .timer #timer-reset { display: none; } .toolbar { gap: 10px; padding: 12px; } main { grid-template-columns: minmax(280px, 42%) minmax(0,1fr); } .stage { padding: 16px; } #reading { padding: 22px; } .script > .section-heading { padding: 16px; } }
|
||||
@media (max-width: 1000px) { body[data-layout="below"] .stage { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-rows: auto auto auto; overflow: auto; } body[data-layout="below"] .preview-shell { height: auto; aspect-ratio: 16/9; } body[data-layout="below"] .up-next { grid-column: 1; grid-row: 3; display: flex; padding-top: 14px; } body[data-layout="below"] .up-next .next-preview { margin: 0; } body[data-layout="below"] .slide-controls { grid-row: 1 / 4; overflow: visible; } }
|
||||
@media (max-width: 620px) { body { height: auto; min-height: 100dvh; } .toolbar { flex-wrap: wrap; } main, body[data-layout="below"] main { display: block; } body[data-layout="below"] .stage { display: block; } body[data-layout="below"] .navigation { margin-top: 14px; } .script { min-height: 65vh; } .connection { padding: 10px 12px; } #reading { max-height: 70vh; } .timer output { font-size: 20px; } .section-heading { flex-wrap: wrap; } .reading-controls { flex-wrap: wrap; } #slide-picker { width: 100%; } }
|
||||
@media (max-width: 620px) { .stage { display: block; } .stage > .preview-shell, body[data-layout="below"] .stage > .preview-shell { width: 100%; aspect-ratio: 1280/686; height: auto; margin: 12px 0; } .up-next, body[data-layout="below"] .up-next { display: grid; grid-template-rows: auto auto; margin-top: 12px; } .up-next .next-preview { aspect-ratio: 16/9; height: auto; } }
|
||||
@@ -0,0 +1,263 @@
|
||||
(() => {
|
||||
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 audience = null;
|
||||
let lastAudienceSeen = 0;
|
||||
let lastPreviewSeen = 0;
|
||||
let notesSize = 25;
|
||||
let blocked = false;
|
||||
const urlFor = role => `../deck/index.html?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.
|
||||
renderNotes(slide.notes);
|
||||
$('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);
|
||||
}
|
||||
}
|
||||
}
|
||||
$('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?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);
|
||||
})();
|
||||
Reference in New Issue
Block a user