返回 html-ppt-skill
runtime.js
根目录 / assets / runtime.js
1 /* html-ppt :: runtime.js
2 * Keyboard-driven deck runtime. Zero dependencies.
3 *
4 * Features:
5 * ← → / space / PgUp PgDn / Home End navigation
6 * F fullscreen
7 * S presenter mode (opens a NEW WINDOW with current/next slide preview + notes + timer)
8 * The original window stays as audience view, synced via BroadcastChannel.
9 * Slide previews use CSS transform:scale() at design resolution for pixel-perfect layout.
10 * N quick notes overlay (bottom drawer)
11 * O slide overview grid
12 * T cycle themes (reads data-themes on <html> or <body>)
13 * A cycle demo animation on current slide
14 * URL hash #/N deep-link to slide N (1-based)
15 * Progress bar auto-managed
16 */
17 (function () {
18 'use strict';
19
20 const ANIMS = ['fade-up','fade-down','fade-left','fade-right','rise-in','drop-in',
21 'zoom-pop','blur-in','glitch-in','typewriter','neon-glow','shimmer-sweep',
22 'gradient-flow','stagger-list','counter-up','path-draw','parallax-tilt',
23 'card-flip-3d','cube-rotate-3d','page-turn-3d','perspective-zoom',
24 'marquee-scroll','kenburns','confetti-burst','spotlight','morph-shape','ripple-reveal'];
25
26 /* Every class the runtime treats as speaker notes. base.css hides the same
27 * set from the audience — if you add one here, add it there too. */
28 const NOTE_SEL = '.notes, aside.notes, .speaker-notes';
29
30 function ready(fn){ if(document.readyState!='loading')fn(); else document.addEventListener('DOMContentLoaded',fn);}
31
32 /* ========== Parse URL for preview-only mode ==========
33 * When loaded as iframe.src = "index.html?preview=3", runtime enters a
34 * locked single-slide mode: only slide N is visible, no chrome, no keys,
35 * no hash updates. This is how the presenter window shows pixel-perfect
36 * previews — by loading the actual deck file in an iframe and telling it
37 * to display only a specific slide.
38 */
39 function getPreviewIdx() {
40 const m = /[?&]preview=(\d+)/.exec(location.search || '');
41 return m ? parseInt(m[1], 10) - 1 : -1;
42 }
43
44 /* ========== Design-canvas fit (issue #20) ==========
45 * Slides are authored against a fixed canvas (1920x1080 by default). The
46 * canvas is scaled — never reflowed — to fit whatever viewport it lands in,
47 * so the browser view, the presenter preview, the overview thumbnail and a
48 * headless PNG render are all the same picture.
49 *
50 * Opt out with <body data-fit="fluid">. Override the canvas per deck with
51 * <div class="deck" data-w="1080" data-h="1440"> (e.g. a 3:4 小红书 post).
52 */
53 function initCanvasFit(deck) {
54 if (document.body.getAttribute('data-fit') === 'fluid') return;
55
56 const w = parseInt(deck.getAttribute('data-w'), 10) || 1920;
57 const h = parseInt(deck.getAttribute('data-h'), 10) || 1080;
58 deck.style.setProperty('--deck-w', w + 'px');
59 deck.style.setProperty('--deck-h', h + 'px');
60
61 function fit() {
62 const scale = Math.min(window.innerWidth / w, window.innerHeight / h);
63 deck.style.setProperty('--deck-scale', String(scale));
64 }
65 fit();
66 window.addEventListener('resize', fit);
67 if (window.visualViewport) window.visualViewport.addEventListener('resize', fit);
68 /* Webfonts can land after first paint; re-fit once they do. */
69 if (document.fonts && document.fonts.ready) document.fonts.ready.then(fit).catch(function(){});
70 }
71
72 ready(function () {
73 const deck = document.querySelector('.deck');
74 if (!deck) return;
75 const slides = Array.from(deck.querySelectorAll('.slide'));
76 if (!slides.length) return;
77
78 initCanvasFit(deck);
79
80 /* ===== custom logo (issue #11) =====
81 * Deliberately initialised BEFORE the preview-mode branch below: the
82 * presenter's "pixel-perfect" preview is the audience view, so it has to
83 * carry the logo too. A hand-authored <img class="deck-logo"> is left
84 * exactly where it is — base.css styles both paths identically, so a deck
85 * can have a logo with runtime.js absent entirely.
86 */
87 const logoEl = (function initLogo(){
88 const attr = (n) => document.body.getAttribute(n) || document.documentElement.getAttribute(n);
89 let el = deck.querySelector(':scope > .deck-logo');
90 if (!el) {
91 const src = attr('data-logo');
92 if (!src) return null;
93 el = document.createElement('img');
94 el.className = 'deck-logo';
95 el.src = src;
96 el.alt = attr('data-logo-alt') || '';
97 deck.appendChild(el);
98 }
99 if (!el.hasAttribute('data-pos')) {
100 el.setAttribute('data-pos', attr('data-logo-position') || 'top-right');
101 }
102 /* Custom props go on .deck, not on the element: the element inherits
103 them, and the print rules (which paint the logo per page on
104 .slide::after) can read them too. An inline --logo-* on a
105 hand-authored element wins for the element, so mirror it up. */
106 const mirror = (attrName, prop) => {
107 const v = (attrName && attr(attrName)) || el.style.getPropertyValue(prop);
108 if (v) deck.style.setProperty(prop, v.trim());
109 };
110 mirror('data-logo-size', '--logo-size');
111 mirror('data-logo-opacity', '--logo-opacity');
112 mirror(null, '--logo-inset-x');
113 mirror(null, '--logo-inset-y');
114
115 /* Print can't use the element itself — see the @media print note in
116 base.css. Hand the URL and the corner to the per-page painter.
117 Use el.src, not getAttribute('src'): a relative url() inside a custom
118 property is resolved against the stylesheet that *uses* the var()
119 (assets/base.css), not against the deck, so it must be absolute. */
120 const src = el.src;
121 if (src) {
122 deck.style.setProperty('--logo-print', 'url("' + src.replace(/["\\]/g, '\\$&') + '")');
123 deck.setAttribute('data-logo-print', el.getAttribute('data-pos'));
124 }
125 return el;
126 })();
127
128 /* Per-slide opt-out: <section class="slide" data-no-logo> — covers and
129 full-bleed image slides usually carry their own branding. */
130 function syncLogo(slide){
131 if (!logoEl) return;
132 logoEl.style.display = (slide && slide.hasAttribute('data-no-logo')) ? 'none' : '';
133 }
134
135 const previewOnlyIdx = getPreviewIdx();
136 const isPreviewMode = previewOnlyIdx >= 0 && previewOnlyIdx < slides.length;
137
138 /* ===== Preview-only mode: show one slide, hide everything else ===== */
139 if (isPreviewMode) {
140 function showSlide(i) {
141 slides.forEach((s, j) => {
142 const active = (j === i);
143 s.classList.toggle('is-active', active);
144 s.style.display = active ? '' : 'none';
145 if (active) {
146 s.style.opacity = '1';
147 s.style.transform = 'none';
148 s.style.pointerEvents = 'auto';
149 }
150 });
151 }
152 showSlide(previewOnlyIdx);
153 syncLogo(slides[previewOnlyIdx]);
154 /* Hide chrome that the presenter shouldn't see in preview */
155 const hideSel = '.progress-bar, .notes-overlay, .overview, ' + NOTE_SEL;
156 document.querySelectorAll(hideSel).forEach(el => { el.style.display = 'none'; });
157 document.documentElement.setAttribute('data-preview', '1');
158 document.body.setAttribute('data-preview', '1');
159 /* Auto-detect theme base path for theme switching in preview mode */
160 function getPreviewThemeBase() {
161 const base = document.documentElement.getAttribute('data-theme-base');
162 if (base) return base;
163 const tl = document.getElementById('theme-link');
164 if (tl) {
165 const raw = tl.getAttribute('href') || '';
166 const ls = raw.lastIndexOf('/');
167 if (ls >= 0) return raw.substring(0, ls + 1);
168 }
169 return 'assets/themes/';
170 }
171 const previewThemeBase = getPreviewThemeBase();
172
173 /* Listen for postMessage from parent presenter window:
174 * - preview-goto: switch visible slide WITHOUT reloading
175 * - preview-theme: switch theme CSS link to match audience window */
176 window.addEventListener('message', function(e) {
177 if (!e.data) return;
178 if (e.data.type === 'preview-goto') {
179 const n = parseInt(e.data.idx, 10);
180 if (n >= 0 && n < slides.length) { showSlide(n); syncLogo(slides[n]); }
181 } else if (e.data.type === 'preview-theme' && e.data.name) {
182 let link = document.getElementById('theme-link');
183 if (!link) {
184 link = document.createElement('link');
185 link.rel = 'stylesheet';
186 link.id = 'theme-link';
187 document.head.appendChild(link);
188 }
189 link.href = previewThemeBase + e.data.name + '.css';
190 document.documentElement.setAttribute('data-theme', e.data.name);
191 }
192 });
193 /* Signal to parent that preview iframe is ready */
194 try { window.parent && window.parent.postMessage({ type: 'preview-ready' }, '*'); } catch(e) {}
195 return;
196 }
197
198 let idx = 0;
199 const total = slides.length;
200
201 /* ===== BroadcastChannel for presenter sync ===== */
202 const CHANNEL_NAME = 'html-ppt-presenter-' + location.pathname;
203 let bc;
204 try { bc = new BroadcastChannel(CHANNEL_NAME); } catch(e) { bc = null; }
205
206 // Are we running inside the presenter popup? (legacy flag, now unused)
207 const isPresenterWindow = false;
208
209 /* ===== progress bar ===== */
210 let bar = document.querySelector('.progress-bar');
211 if (!bar) {
212 bar = document.createElement('div');
213 bar.className = 'progress-bar';
214 bar.innerHTML = '<span></span>';
215 document.body.appendChild(bar);
216 }
217 const barFill = bar.querySelector('span');
218
219 /* ===== notes overlay (N key) ===== */
220 let notes = document.querySelector('.notes-overlay');
221 if (!notes) {
222 notes = document.createElement('div');
223 notes.className = 'notes-overlay';
224 document.body.appendChild(notes);
225 }
226
227 /* ===== overview grid (O key) ===== */
228 let overview = document.querySelector('.overview');
229 if (!overview) {
230 overview = document.createElement('div');
231 overview.className = 'overview';
232 slides.forEach((s, i) => {
233 const t = document.createElement('div');
234 t.className = 'thumb';
235 // Force 16:9 aspect ratio robustly
236 t.style.padding = '0 0 56.25% 0';
237 t.style.height = '0';
238 t.style.position = 'relative';
239 t.style.overflow = 'hidden';
240
241 const title = s.getAttribute('data-title') ||
242 (s.querySelector('h1,h2,h3')||{}).textContent || ('Slide '+(i+1));
243
244 // Create a container for the mini-slide
245 const mini = document.createElement('div');
246 mini.className = 'mini-slide';
247 mini.style.position = 'absolute';
248 mini.style.top = '0';
249 mini.style.left = '0';
250 mini.style.width = '1920px';
251 mini.style.height = '1080px';
252 mini.style.transformOrigin = 'top left';
253 mini.style.pointerEvents = 'none';
254 mini.style.background = 'var(--bg)';
255
256 // Clone the slide content
257 const clone = s.cloneNode(true);
258 clone.className = 'slide is-active'; // force active styles
259 clone.style.position = 'absolute';
260 clone.style.inset = '0';
261 clone.style.transform = 'none';
262 clone.style.opacity = '1';
263 clone.style.padding = '72px 96px'; // ensure padding is kept
264
265 mini.appendChild(clone);
266 t.appendChild(mini);
267
268 // Add the number and title overlay
269 const overlay = document.createElement('div');
270 overlay.style.position = 'absolute';
271 overlay.style.inset = '0';
272 overlay.style.background = 'linear-gradient(to bottom, rgba(0,0,0,0.2) 0%, transparent 40%, transparent 60%, rgba(0,0,0,0.8) 100%)';
273 overlay.style.color = '#fff';
274 overlay.style.zIndex = '10';
275 overlay.style.pointerEvents = 'none';
276
277 const n = document.createElement('div');
278 n.className = 'n';
279 n.textContent = i + 1;
280 n.style.position = 'absolute';
281 n.style.top = '12px';
282 n.style.left = '16px';
283 n.style.fontWeight = '700';
284 n.style.fontSize = '16px';
285 n.style.color = '#fff';
286 n.style.textShadow = '0 1px 4px rgba(0,0,0,0.8)';
287
288 const text = document.createElement('div');
289 text.className = 't';
290 text.textContent = title.trim().slice(0,80);
291 text.style.position = 'absolute';
292 text.style.bottom = '12px';
293 text.style.left = '16px';
294 text.style.right = '16px';
295 text.style.fontWeight = '600';
296 text.style.fontSize = '14px';
297 text.style.color = '#fff';
298 text.style.textShadow = '0 1px 4px rgba(0,0,0,0.8)';
299
300 overlay.appendChild(n);
301 overlay.appendChild(text);
302 t.appendChild(overlay);
303
304 t.addEventListener('click', () => { go(i); toggleOverview(false); });
305 overview.appendChild(t);
306 });
307 document.body.appendChild(overview);
308 }
309
310 /* ===== navigation ===== */
311 function go(n, fromRemote){
312 n = Math.max(0, Math.min(total-1, n));
313 slides.forEach((s,i) => {
314 s.classList.toggle('is-active', i===n);
315 s.classList.toggle('is-prev', i<n);
316 });
317 idx = n;
318 syncLogo(slides[n]);
319 barFill.style.width = ((n+1)/total*100)+'%';
320 const numEl = document.querySelector('.slide-number');
321 if (numEl) { numEl.setAttribute('data-current', n+1); numEl.setAttribute('data-total', total); }
322
323 // notes (bottom overlay)
324 const note = slides[n].querySelector(NOTE_SEL);
325 notes.innerHTML = note ? note.innerHTML : '';
326
327 // hash
328 const hashTarget = '#/'+(n+1);
329 if (location.hash !== hashTarget && !isPresenterWindow) {
330 history.replaceState(null,'', hashTarget);
331 }
332
333 // re-trigger entry animations
334 slides[n].querySelectorAll('[data-anim]').forEach(el => {
335 const a = el.getAttribute('data-anim');
336 el.classList.remove('anim-'+a);
337 void el.offsetWidth;
338 el.classList.add('anim-'+a);
339 });
340
341 // counter-up
342 slides[n].querySelectorAll('.counter').forEach(el => {
343 const target = parseFloat(el.getAttribute('data-to')||el.textContent);
344 const dur = parseInt(el.getAttribute('data-dur')||'1200',10);
345 const start = performance.now();
346 const from = 0;
347 function tick(now){
348 const t = Math.min(1,(now-start)/dur);
349 const v = from + (target-from)*(1-Math.pow(1-t,3));
350 el.textContent = (target % 1 === 0) ? Math.round(v) : v.toFixed(1);
351 if (t<1) requestAnimationFrame(tick);
352 }
353 requestAnimationFrame(tick);
354 });
355
356 // Broadcast to other window (audience ↔ presenter)
357 if (!fromRemote && bc) {
358 bc.postMessage({ type: 'go', idx: n });
359 }
360 }
361
362 /* ===== listen for remote navigation / theme changes ===== */
363 if (bc) {
364 bc.onmessage = function(e) {
365 if (!e.data) return;
366 if (e.data.type === 'go' && typeof e.data.idx === 'number') {
367 go(e.data.idx, true);
368 } else if (e.data.type === 'theme' && e.data.name) {
369 /* Sync theme across windows */
370 const i = themes.indexOf(e.data.name);
371 if (i >= 0) themeIdx = i;
372 applyTheme(e.data.name);
373 }
374 };
375 }
376
377 function toggleNotes(force){ notes.classList.toggle('open', force!==undefined?force:!notes.classList.contains('open')); }
378 function toggleOverview(force){
379 const isOpen = force!==undefined ? force : !overview.classList.contains('open');
380 overview.classList.toggle('open', isOpen);
381 if (isOpen) {
382 requestAnimationFrame(() => {
383 const thumbs = overview.querySelectorAll('.thumb');
384 if (thumbs.length) {
385 const scale = thumbs[0].clientWidth / 1920;
386 overview.querySelectorAll('.mini-slide').forEach(m => {
387 m.style.transform = 'scale(' + scale + ')';
388 });
389 }
390 });
391 }
392 }
393
394 /* ========== PRESENTER MODE — Magnetic-card popup window ========== */
395 /* Opens a new window with 4 draggable, resizable cards:
396 * CURRENT — iframe(?preview=N) pixel-perfect preview of current slide
397 * NEXT — iframe(?preview=N+1) pixel-perfect preview of next slide
398 * SCRIPT — large speaker notes (逐字稿)
399 * TIMER — elapsed timer + page counter + controls
400 * Cards remember position/size in localStorage.
401 * Two windows sync via BroadcastChannel.
402 */
403 let presenterWin = null;
404
405 function openPresenterWindow() {
406 if (presenterWin && !presenterWin.closed) {
407 presenterWin.focus();
408 return;
409 }
410
411 // Build absolute URL of THIS deck file (without hash/query)
412 const deckUrl = location.protocol + '//' + location.host + location.pathname;
413
414 // Collect slide titles + notes (HTML strings)
415 const slideMeta = slides.map((s, i) => {
416 const note = s.querySelector(NOTE_SEL);
417 return {
418 title: s.getAttribute('data-title') ||
419 (s.querySelector('h1,h2,h3')||{}).textContent || ('Slide '+(i+1)),
420 notes: note ? note.innerHTML : ''
421 };
422 });
423
424 /* Capture current theme so presenter previews match the audience */
425 const currentTheme = root.getAttribute('data-theme') || (themes[themeIdx] || '');
426 const presenterHTML = buildPresenterHTML(deckUrl, slideMeta, total, idx, CHANNEL_NAME, currentTheme);
427
428 presenterWin = window.open('', 'html-ppt-presenter', 'width=1280,height=820,menubar=no,toolbar=no');
429 if (!presenterWin) {
430 alert('请允许弹出窗口以使用演讲者视图');
431 return;
432 }
433 presenterWin.document.open();
434 presenterWin.document.write(presenterHTML);
435 presenterWin.document.close();
436 }
437
438 function buildPresenterHTML(deckUrl, slideMeta, total, startIdx, channelName, currentTheme) {
439 /* Notes are authored HTML. Escaping "<" keeps a literal </script> in a
440 slide's notes from closing the inline script that carries this JSON —
441 which would kill the whole presenter init. */
442 const embed = (v) => JSON.stringify(v).replace(/</g, '\\u003c');
443 const metaJSON = embed(slideMeta);
444 const deckUrlJSON = embed(deckUrl);
445 const channelJSON = embed(channelName);
446 const themeJSON = embed(currentTheme || '');
447 const storageKey = 'html-ppt-presenter:' + location.pathname;
448
449 // Build the document as a single template string for clarity
450 return `<!DOCTYPE html>
451 <html lang="zh-CN">
452 <head>
453 <meta charset="utf-8">
454 <title>Presenter View</title>
455 <style>
456 * { margin: 0; padding: 0; box-sizing: border-box; }
457 html, body {
458 width: 100%; height: 100%; overflow: hidden;
459 background: #1a1d24;
460 background-image:
461 radial-gradient(circle at 20% 30%, rgba(88,166,255,.04), transparent 50%),
462 radial-gradient(circle at 80% 70%, rgba(188,140,255,.04), transparent 50%);
463 color: #e6edf3;
464 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", sans-serif;
465 }
466 /* Stage: positioned area where cards live */
467 #stage { position: absolute; inset: 0; overflow: hidden; }
468
469 /* Magnetic card */
470 .pcard {
471 position: absolute;
472 background: #0d1117;
473 border: 1px solid rgba(255,255,255,.1);
474 border-radius: 12px;
475 box-shadow: 0 8px 32px rgba(0,0,0,.45), 0 0 0 1px rgba(255,255,255,.02);
476 display: flex; flex-direction: column;
477 overflow: hidden;
478 min-width: 180px; min-height: 100px;
479 transition: box-shadow .2s, border-color .2s;
480 }
481
482 /* Default geometry. The cards used to get position and size ONLY from
483 applyLayout(), which runs at the very end of the init — so anything that
484 stopped that script (a blocked inline script under CSP, a stale layout in
485 localStorage, any throw in the wiring above it) left four cards collapsed
486 on top of each other and just the hint bar visible (#14). These rules make
487 the presenter usable with zero JS; applyLayout() overrides them with px. */
488 #card-cur { left: 16px; top: 16px;
489 width: calc(55% - 24px); height: calc((100% - 36px) * 0.62 - 16px); }
490 #card-nxt { left: calc(55% + 8px); top: 16px;
491 width: calc(45% - 24px); height: calc((100% - 36px) * 0.42 - 16px); }
492 #card-notes { left: calc(55% + 8px); top: calc((100% - 36px) * 0.42 + 8px);
493 width: calc(45% - 24px); height: calc((100% - 36px) * 0.58 - 16px); }
494 #card-timer { left: 16px; top: calc((100% - 36px) * 0.62 + 8px);
495 width: calc(55% - 24px); height: calc((100% - 36px) * 0.38 - 16px); }
496
497 .pcard.dragging { box-shadow: 0 16px 48px rgba(0,0,0,.6), 0 0 0 2px rgba(88,166,255,.5); border-color: #58a6ff; transition: none; z-index: 9999; }
498 .pcard.resizing { box-shadow: 0 16px 48px rgba(0,0,0,.6), 0 0 0 2px rgba(63,185,80,.5); border-color: #3fb950; transition: none; z-index: 9999; }
499 .pcard:hover { border-color: rgba(88,166,255,.3); }
500
501 /* Card header (drag handle) */
502 .pcard-head {
503 display: flex; align-items: center; gap: 10px;
504 padding: 8px 12px;
505 background: rgba(255,255,255,.04);
506 border-bottom: 1px solid rgba(255,255,255,.06);
507 cursor: move;
508 user-select: none;
509 flex-shrink: 0;
510 }
511 .pcard-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dot-color, #58a6ff); flex-shrink: 0; }
512 .pcard-title {
513 font-size: 11px; letter-spacing: .15em; text-transform: uppercase;
514 font-weight: 700; color: #8b949e; flex: 1;
515 }
516 .pcard-meta { font-size: 11px; color: #6e7681; }
517
518 /* Card body */
519 .pcard-body { flex: 1; position: relative; overflow: hidden; min-height: 0; }
520
521 /* Preview cards (CURRENT/NEXT) — iframe-based pixel-perfect render */
522 .pcard-preview .pcard-body { background: #000; }
523 .pcard-preview iframe {
524 position: absolute; top: 0; left: 0;
525 width: 1920px; height: 1080px;
526 border: none;
527 transform-origin: top left;
528 pointer-events: none;
529 background: transparent;
530 }
531 .pcard-preview .preview-end {
532 position: absolute; inset: 0;
533 display: flex; align-items: center; justify-content: center;
534 color: #484f58; font-size: 14px; letter-spacing: .12em;
535 }
536
537 /* Notes card */
538 .pcard-notes .pcard-body {
539 padding: 14px 18px;
540 overflow-y: auto;
541 font-size: 18px; line-height: 1.75;
542 color: #d0d7de;
543 font-family: "Noto Sans SC", -apple-system, sans-serif;
544 }
545 .pcard-notes .pcard-body p { margin: 0 0 .7em 0; }
546 .pcard-notes .pcard-body strong { color: #f0883e; }
547 .pcard-notes .pcard-body em { color: #58a6ff; font-style: normal; }
548 .pcard-notes .pcard-body code {
549 font-family: "SF Mono", monospace; font-size: .9em;
550 background: rgba(255,255,255,.08); padding: 1px 6px; border-radius: 4px;
551 }
552 .pcard-notes .empty { color: #484f58; font-style: italic; }
553
554 /* Timer card */
555 .pcard-timer .pcard-body {
556 display: flex; flex-direction: column; gap: 14px;
557 padding: 18px 20px; justify-content: center;
558 }
559 .timer-display {
560 font-family: "SF Mono", "JetBrains Mono", monospace;
561 font-size: 42px; font-weight: 700;
562 color: #3fb950;
563 letter-spacing: .04em;
564 line-height: 1;
565 }
566 .timer-row {
567 display: flex; align-items: center; gap: 12px;
568 font-size: 14px; color: #8b949e;
569 }
570 .timer-row .label { font-size: 10px; letter-spacing: .15em; text-transform: uppercase; color: #6e7681; }
571 .timer-row .val { color: #e6edf3; font-weight: 600; font-family: "SF Mono", monospace; }
572 .timer-controls { display: flex; gap: 8px; flex-wrap: wrap; }
573 .timer-btn {
574 background: rgba(255,255,255,.06);
575 border: 1px solid rgba(255,255,255,.1);
576 color: #e6edf3;
577 padding: 6px 12px;
578 border-radius: 6px;
579 font-size: 12px;
580 cursor: pointer;
581 font-family: inherit;
582 }
583 .timer-btn:hover { background: rgba(88,166,255,.15); border-color: #58a6ff; }
584 .timer-btn:active { transform: translateY(1px); }
585
586 /* Resize handle */
587 .pcard-resize {
588 position: absolute; right: 0; bottom: 0;
589 width: 18px; height: 18px;
590 cursor: nwse-resize;
591 background: linear-gradient(135deg, transparent 50%, rgba(255,255,255,.25) 50%, rgba(255,255,255,.25) 60%, transparent 60%, transparent 70%, rgba(255,255,255,.25) 70%, rgba(255,255,255,.25) 80%, transparent 80%);
592 z-index: 5;
593 }
594 .pcard-resize:hover { background: linear-gradient(135deg, transparent 50%, #58a6ff 50%, #58a6ff 60%, transparent 60%, transparent 70%, #58a6ff 70%, #58a6ff 80%, transparent 80%); }
595
596 /* Bottom hint bar */
597 .hint-bar {
598 position: fixed; bottom: 0; left: 0; right: 0;
599 background: rgba(0,0,0,.6);
600 backdrop-filter: blur(10px);
601 border-top: 1px solid rgba(255,255,255,.08);
602 padding: 6px 16px;
603 font-size: 11px; color: #8b949e;
604 display: flex; gap: 18px; align-items: center;
605 z-index: 1000;
606 }
607 .hint-bar kbd {
608 background: rgba(255,255,255,.08);
609 padding: 1px 6px; border-radius: 3px;
610 font-family: "SF Mono", monospace;
611 font-size: 10px;
612 border: 1px solid rgba(255,255,255,.1);
613 color: #e6edf3;
614 }
615 .hint-bar .reset-layout {
616 margin-left: auto;
617 background: transparent; border: 1px solid rgba(255,255,255,.15);
618 color: #8b949e; padding: 3px 10px; border-radius: 4px;
619 font-size: 11px; cursor: pointer; font-family: inherit;
620 }
621 .hint-bar .reset-layout:hover { background: rgba(248,81,73,.15); border-color: #f85149; color: #f85149; }
622
623 body.is-dragging-card * { user-select: none !important; }
624 body.is-dragging-card iframe { pointer-events: none !important; }
625 </style>
626 </head>
627 <body>
628
629 <div id="stage">
630 <div class="pcard pcard-preview" id="card-cur" style="--dot-color:#58a6ff">
631 <div class="pcard-head" data-drag>
632 <span class="pcard-dot"></span>
633 <span class="pcard-title">CURRENT</span>
634 <span class="pcard-meta" id="cur-meta">—</span>
635 </div>
636 <div class="pcard-body"><iframe id="iframe-cur"></iframe></div>
637 <div class="pcard-resize" data-resize></div>
638 </div>
639
640 <div class="pcard pcard-preview" id="card-nxt" style="--dot-color:#bc8cff">
641 <div class="pcard-head" data-drag>
642 <span class="pcard-dot"></span>
643 <span class="pcard-title">NEXT</span>
644 <span class="pcard-meta" id="nxt-meta">—</span>
645 </div>
646 <div class="pcard-body"><iframe id="iframe-nxt"></iframe></div>
647 <div class="pcard-resize" data-resize></div>
648 </div>
649
650 <div class="pcard pcard-notes" id="card-notes" style="--dot-color:#f0883e">
651 <div class="pcard-head" data-drag>
652 <span class="pcard-dot"></span>
653 <span class="pcard-title">SPEAKER SCRIPT · 逐字稿</span>
654 </div>
655 <div class="pcard-body" id="notes-body"></div>
656 <div class="pcard-resize" data-resize></div>
657 </div>
658
659 <div class="pcard pcard-timer" id="card-timer" style="--dot-color:#3fb950">
660 <div class="pcard-head" data-drag>
661 <span class="pcard-dot"></span>
662 <span class="pcard-title">TIMER</span>
663 </div>
664 <div class="pcard-body">
665 <div class="timer-display" id="timer-display">00:00</div>
666 <div class="timer-row">
667 <span class="label">Slide</span>
668 <span class="val" id="timer-count">1 / ${total}</span>
669 </div>
670 <div class="timer-controls">
671 <button class="timer-btn" id="btn-prev">← Prev</button>
672 <button class="timer-btn" id="btn-next">Next →</button>
673 <button class="timer-btn" id="btn-reset">⏱ Reset</button>
674 </div>
675 </div>
676 <div class="pcard-resize" data-resize></div>
677 </div>
678 </div>
679
680 <div class="hint-bar">
681 <span><kbd>← →</kbd> 翻页</span>
682 <span><kbd>R</kbd> 重置计时</span>
683 <span><kbd>Esc</kbd> 关闭</span>
684 <span style="color:#6e7681">拖动卡片头部移动 · 拖动右下角调整大小</span>
685 <button class="reset-layout" id="reset-layout">重置布局</button>
686 </div>
687
688 <script>
689 (function(){
690 var slideMeta = ${metaJSON};
691 var total = ${total};
692 var idx = ${startIdx};
693 var deckUrl = ${deckUrlJSON};
694 var STORAGE_KEY = ${embed(storageKey)};
695 var bc;
696 try { bc = new BroadcastChannel(${channelJSON}); } catch(e) {}
697
698 var iframeCur = document.getElementById('iframe-cur');
699 var iframeNxt = document.getElementById('iframe-nxt');
700 var notesBody = document.getElementById('notes-body');
701 var curMeta = document.getElementById('cur-meta');
702 var nxtMeta = document.getElementById('nxt-meta');
703 var timerDisplay = document.getElementById('timer-display');
704 var timerCount = document.getElementById('timer-count');
705
706 /* Lay the cards out before anything else. This used to be the LAST statement
707 of the init, so every line below it was a single point of failure for the
708 whole presenter's visibility (#14). Function declarations hoist, so this is
709 safe here. */
710 applyLayout(readLayout());
711
712 /* ===== Default card layout ===== */
713 var CARD_IDS = ['card-cur','card-nxt','card-notes','card-timer'];
714 var MIN_W = 180, MIN_H = 100;
715
716 function defaultLayout() {
717 /* A popup that opened minimised or in a background tab can report 0 here;
718 the old code turned that into negative widths, which CSS discards. */
719 var w = Math.max(640, window.innerWidth || 0);
720 var h = Math.max(400, (window.innerHeight || 0) - 36); /* room for hint bar */
721 return {
722 'card-cur': { x: 16, y: 16, w: Math.round(w*0.55) - 24, h: Math.round(h*0.62) - 16 },
723 'card-nxt': { x: Math.round(w*0.55) + 8, y: 16, w: w - Math.round(w*0.55) - 24, h: Math.round(h*0.42) - 16 },
724 'card-notes': { x: Math.round(w*0.55) + 8, y: Math.round(h*0.42) + 8, w: w - Math.round(w*0.55) - 24, h: h - Math.round(h*0.42) - 16 },
725 'card-timer': { x: 16, y: Math.round(h*0.62) + 8, w: Math.round(w*0.55) - 24, h: h - Math.round(h*0.62) - 16 }
726 };
727 }
728
729 /* ===== Apply / save / restore layout ===== */
730 function applyLayout(layout) {
731 Object.keys(layout).forEach(function(id){
732 var el = document.getElementById(id);
733 var l = layout[id];
734 if (el && l) {
735 el.style.left = l.x + 'px';
736 el.style.top = l.y + 'px';
737 el.style.width = l.w + 'px';
738 el.style.height = l.h + 'px';
739 }
740 });
741 rescaleAll();
742 }
743 /* A layout restored from localStorage was written against whatever window
744 size the deck was last presented at. Replayed in a smaller window it puts
745 every card off-screen, and there is no way back because the layout is
746 sticky — so clamp it into view and reject anything malformed. */
747 function sanitizeLayout(layout) {
748 if (!layout || typeof layout !== 'object') return null;
749 var vw = Math.max(MIN_W, window.innerWidth || 0);
750 var vh = Math.max(MIN_H, window.innerHeight || 0);
751 var out = {};
752 for (var i = 0; i < CARD_IDS.length; i++) {
753 var l = layout[CARD_IDS[i]];
754 if (!l) return null;
755 var cw = Math.min(Math.max(+l.w || 0, MIN_W), vw);
756 var ch = Math.min(Math.max(+l.h || 0, MIN_H), vh);
757 var cx = Math.min(Math.max(+l.x || 0, 0), Math.max(0, vw - MIN_W));
758 var cy = Math.min(Math.max(+l.y || 0, 0), Math.max(0, vh - 40));
759 if (!isFinite(cw) || !isFinite(ch) || !isFinite(cx) || !isFinite(cy)) return null;
760 out[CARD_IDS[i]] = { x: cx, y: cy, w: cw, h: ch };
761 }
762 return out;
763 }
764 function readLayout() {
765 var saved = null;
766 try { saved = JSON.parse(localStorage.getItem(STORAGE_KEY)); } catch(e) {}
767 return sanitizeLayout(saved) || defaultLayout();
768 }
769 function saveLayout() {
770 var layout = {};
771 CARD_IDS.forEach(function(id){
772 var el = document.getElementById(id);
773 if (el) {
774 layout[id] = {
775 x: parseInt(el.style.left,10) || 0,
776 y: parseInt(el.style.top,10) || 0,
777 w: parseInt(el.style.width,10) || 300,
778 h: parseInt(el.style.height,10) || 200
779 };
780 }
781 });
782 try { localStorage.setItem(STORAGE_KEY, JSON.stringify(layout)); } catch(e) {}
783 }
784
785 /* ===== iframe rescale to fit card body ===== */
786 function rescaleIframe(iframe) {
787 if (!iframe || iframe.style.display === 'none') return;
788 var body = iframe.parentElement;
789 var cw = body.clientWidth, ch = body.clientHeight;
790 if (!cw || !ch) return;
791 var s = Math.min(cw / 1920, ch / 1080);
792 iframe.style.transform = 'scale(' + s + ')';
793 /* Center the scaled iframe in the body */
794 var sw = 1920 * s, sh = 1080 * s;
795 iframe.style.left = Math.max(0, (cw - sw) / 2) + 'px';
796 iframe.style.top = Math.max(0, (ch - sh) / 2) + 'px';
797 }
798 function rescaleAll() {
799 rescaleIframe(iframeCur);
800 rescaleIframe(iframeNxt);
801 }
802 window.addEventListener('resize', rescaleAll);
803
804 /* ===== Drag (move card by header) ===== */
805 document.querySelectorAll('[data-drag]').forEach(function(handle){
806 handle.addEventListener('mousedown', function(e){
807 if (e.button !== 0) return;
808 var card = handle.closest('.pcard');
809 if (!card) return;
810 e.preventDefault();
811 card.classList.add('dragging');
812 document.body.classList.add('is-dragging-card');
813 var startX = e.clientX, startY = e.clientY;
814 var startL = parseInt(card.style.left,10) || 0;
815 var startT = parseInt(card.style.top,10) || 0;
816 function onMove(ev){
817 var nx = Math.max(0, Math.min(window.innerWidth - 100, startL + ev.clientX - startX));
818 var ny = Math.max(0, Math.min(window.innerHeight - 50, startT + ev.clientY - startY));
819 card.style.left = nx + 'px';
820 card.style.top = ny + 'px';
821 }
822 function onUp(){
823 card.classList.remove('dragging');
824 document.body.classList.remove('is-dragging-card');
825 document.removeEventListener('mousemove', onMove);
826 document.removeEventListener('mouseup', onUp);
827 saveLayout();
828 }
829 document.addEventListener('mousemove', onMove);
830 document.addEventListener('mouseup', onUp);
831 });
832 });
833
834 /* ===== Resize (drag bottom-right corner) ===== */
835 document.querySelectorAll('[data-resize]').forEach(function(handle){
836 handle.addEventListener('mousedown', function(e){
837 if (e.button !== 0) return;
838 var card = handle.closest('.pcard');
839 if (!card) return;
840 e.preventDefault(); e.stopPropagation();
841 card.classList.add('resizing');
842 document.body.classList.add('is-dragging-card');
843 var startX = e.clientX, startY = e.clientY;
844 var startW = parseInt(card.style.width,10) || card.offsetWidth;
845 var startH = parseInt(card.style.height,10) || card.offsetHeight;
846 function onMove(ev){
847 var nw = Math.max(180, startW + ev.clientX - startX);
848 var nh = Math.max(100, startH + ev.clientY - startY);
849 card.style.width = nw + 'px';
850 card.style.height = nh + 'px';
851 if (card.querySelector('iframe')) rescaleIframe(card.querySelector('iframe'));
852 }
853 function onUp(){
854 card.classList.remove('resizing');
855 document.body.classList.remove('is-dragging-card');
856 document.removeEventListener('mousemove', onMove);
857 document.removeEventListener('mouseup', onUp);
858 rescaleAll();
859 saveLayout();
860 }
861 document.addEventListener('mousemove', onMove);
862 document.addEventListener('mouseup', onUp);
863 });
864 });
865
866 /* ===== Preview iframe ready tracking =====
867 * Each iframe loads the deck ONCE with ?preview=1 on init. Subsequent
868 * slide changes are sent via postMessage('preview-goto') so the iframe
869 * just toggles visibility of a different .slide — no reload, no flicker.
870 */
871 var iframeReady = { cur: false, nxt: false };
872 var currentTheme = ${themeJSON};
873 window.addEventListener('message', function(e) {
874 if (!e.data || e.data.type !== 'preview-ready') return;
875 var iframe = null;
876 if (e.source === iframeCur.contentWindow) {
877 iframeReady.cur = true;
878 iframe = iframeCur;
879 postPreviewGoto(iframeCur, idx);
880 } else if (e.source === iframeNxt.contentWindow) {
881 iframeReady.nxt = true;
882 iframe = iframeNxt;
883 postPreviewGoto(iframeNxt, idx + 1 < total ? idx + 1 : idx);
884 }
885 /* Sync current theme to the iframe */
886 if (iframe && currentTheme) {
887 try { iframe.contentWindow.postMessage({ type: 'preview-theme', name: currentTheme }, '*'); } catch(err) {}
888 }
889 if (iframe) rescaleIframe(iframe);
890 });
891
892 function postPreviewGoto(iframe, n) {
893 try {
894 iframe.contentWindow.postMessage({ type: 'preview-goto', idx: n }, '*');
895 } catch(e) {}
896 }
897
898 /* ===== Update content =====
899 * Smooth (no-reload) navigation: send postMessage to iframes instead of
900 * resetting src. Iframes stay loaded, just switch visible .slide.
901 */
902 function update(n) {
903 n = Math.max(0, Math.min(total - 1, n));
904 idx = n;
905
906 /* Current preview — postMessage (smooth) */
907 if (iframeReady.cur) postPreviewGoto(iframeCur, n);
908 curMeta.textContent = (n + 1) + '/' + total;
909
910 /* Next preview */
911 if (n + 1 < total) {
912 iframeNxt.style.display = '';
913 var endEl = document.querySelector('#card-nxt .preview-end');
914 if (endEl) endEl.remove();
915 if (iframeReady.nxt) postPreviewGoto(iframeNxt, n + 1);
916 nxtMeta.textContent = (n + 2) + '/' + total;
917 } else {
918 iframeNxt.style.display = 'none';
919 var body = document.querySelector('#card-nxt .pcard-body');
920 if (body && !body.querySelector('.preview-end')) {
921 var end = document.createElement('div');
922 end.className = 'preview-end';
923 end.textContent = '— END OF DECK —';
924 body.appendChild(end);
925 }
926 nxtMeta.textContent = 'END';
927 }
928
929 /* Notes */
930 var note = slideMeta[n].notes;
931 notesBody.innerHTML = note || '<span class="empty">(这一页还没有逐字稿)</span>';
932
933 /* Timer count */
934 timerCount.textContent = (n + 1) + ' / ' + total;
935 }
936
937 /* ===== Timer ===== */
938 var tStart = Date.now();
939 setInterval(function(){
940 var s = Math.floor((Date.now() - tStart) / 1000);
941 var mm = String(Math.floor(s/60)).padStart(2,'0');
942 var ss = String(s%60).padStart(2,'0');
943 timerDisplay.textContent = mm + ':' + ss;
944 }, 1000);
945 function resetTimer(){ tStart = Date.now(); timerDisplay.textContent = '00:00'; }
946
947 /* ===== BroadcastChannel sync ===== */
948 if (bc) {
949 bc.onmessage = function(e){
950 if (!e.data) return;
951 if (e.data.type === 'go') update(e.data.idx);
952 else if (e.data.type === 'theme' && e.data.name) {
953 currentTheme = e.data.name;
954 /* Forward theme change to preview iframes */
955 [iframeCur, iframeNxt].forEach(function(iframe){
956 try {
957 iframe.contentWindow.postMessage({ type: 'preview-theme', name: e.data.name }, '*');
958 } catch(err) {}
959 });
960 }
961 };
962 }
963 function go(n) {
964 update(n);
965 if (bc) bc.postMessage({ type: 'go', idx: idx });
966 }
967
968 /* ===== Buttons ===== */
969 document.getElementById('btn-prev').addEventListener('click', function(){ go(idx - 1); });
970 document.getElementById('btn-next').addEventListener('click', function(){ go(idx + 1); });
971 document.getElementById('btn-reset').addEventListener('click', resetTimer);
972 document.getElementById('reset-layout').addEventListener('click', function(){
973 if (confirm('恢复默认卡片布局?')) {
974 try { localStorage.removeItem(STORAGE_KEY); } catch(e){}
975 applyLayout(defaultLayout());
976 }
977 });
978
979 /* ===== Keyboard ===== */
980 document.addEventListener('keydown', function(e){
981 if (e.metaKey || e.ctrlKey || e.altKey) return;
982 switch(e.key) {
983 case 'ArrowRight': case ' ': case 'PageDown': go(idx + 1); e.preventDefault(); break;
984 case 'ArrowLeft': case 'PageUp': go(idx - 1); e.preventDefault(); break;
985 case 'Home': go(0); break;
986 case 'End': go(total - 1); break;
987 case 'r': case 'R': resetTimer(); break;
988 case 'Escape': window.close(); break;
989 }
990 });
991
992 /* ===== Iframe load → rescale (catches initial size) ===== */
993 iframeCur.addEventListener('load', function(){ rescaleIframe(iframeCur); });
994 iframeNxt.addEventListener('load', function(){ rescaleIframe(iframeNxt); });
995
996 /* ===== Init =====
997 * Load each iframe ONCE with the deck file. After they post
998 * 'preview-ready', all subsequent navigation is via postMessage
999 * (smooth, no reload, no flicker).
1000 */
1001 try {
1002 iframeCur.src = deckUrl + '?preview=' + (idx + 1);
1003 if (idx + 1 < total) iframeNxt.src = deckUrl + '?preview=' + (idx + 2);
1004 /* Initialize notes/timer/count without touching iframes */
1005 var m = slideMeta[idx] || {};
1006 notesBody.innerHTML = m.notes || '<span class="empty">(这一页还没有逐字稿)</span>';
1007 curMeta.textContent = (idx + 1) + '/' + total;
1008 nxtMeta.textContent = (idx + 2) + '/' + total;
1009 timerCount.textContent = (idx + 1) + ' / ' + total;
1010 } catch (e) {
1011 /* Report it, but never let it blank the cards. */
1012 if (window.console && console.error) console.error('[html-ppt] presenter init:', e);
1013 }
1014 })();
1015 </` + `script>
1016 </body></html>`;
1017 }
1018
1019 function fullscreen(){ const el=document.documentElement;
1020 if (!document.fullscreenElement) el.requestFullscreen&&el.requestFullscreen();
1021 else document.exitFullscreen&&document.exitFullscreen();
1022 }
1023
1024 // theme cycling
1025 const root = document.documentElement;
1026 const themesAttr = root.getAttribute('data-themes') || document.body.getAttribute('data-themes');
1027 const themes = themesAttr ? themesAttr.split(',').map(s=>s.trim()).filter(Boolean) : [];
1028 let themeIdx = 0;
1029
1030 // Auto-detect theme base path from existing <link id="theme-link">
1031 let themeBase = root.getAttribute('data-theme-base');
1032 if (!themeBase) {
1033 const existingLink = document.getElementById('theme-link');
1034 if (existingLink) {
1035 // el.getAttribute('href') gives the raw relative path written in HTML
1036 const rawHref = existingLink.getAttribute('href') || '';
1037 const lastSlash = rawHref.lastIndexOf('/');
1038 themeBase = lastSlash >= 0 ? rawHref.substring(0, lastSlash + 1) : 'assets/themes/';
1039 } else {
1040 themeBase = 'assets/themes/';
1041 }
1042 }
1043
1044 function applyTheme(name) {
1045 let link = document.getElementById('theme-link');
1046 if (!link) {
1047 link = document.createElement('link');
1048 link.rel = 'stylesheet';
1049 link.id = 'theme-link';
1050 document.head.appendChild(link);
1051 }
1052 link.href = themeBase + name + '.css';
1053 root.setAttribute('data-theme', name);
1054 const ind = document.querySelector('.theme-indicator');
1055 if (ind) ind.textContent = name;
1056 }
1057 function cycleTheme(fromRemote){
1058 if (!themes.length) return;
1059 themeIdx = (themeIdx+1) % themes.length;
1060 const name = themes[themeIdx];
1061 applyTheme(name);
1062 /* Broadcast to other window (audience ↔ presenter) */
1063 if (!fromRemote && bc) bc.postMessage({ type: 'theme', name: name });
1064 }
1065
1066 // animation cycling on current slide
1067 let animIdx = 0;
1068 function cycleAnim(){
1069 animIdx = (animIdx+1) % ANIMS.length;
1070 const a = ANIMS[animIdx];
1071 const target = slides[idx].querySelector('[data-anim-target]') || slides[idx];
1072 ANIMS.forEach(x => target.classList.remove('anim-'+x));
1073 void target.offsetWidth;
1074 target.classList.add('anim-'+a);
1075 target.setAttribute('data-anim', a);
1076 const ind = document.querySelector('.anim-indicator');
1077 if (ind) ind.textContent = a;
1078 }
1079
1080 document.addEventListener('keydown', function (e) {
1081 if (e.metaKey||e.ctrlKey||e.altKey) return;
1082 switch (e.key) {
1083 case 'ArrowRight': case ' ': case 'PageDown': case 'Enter': go(idx+1); e.preventDefault(); break;
1084 case 'ArrowLeft': case 'PageUp': case 'Backspace': go(idx-1); e.preventDefault(); break;
1085 case 'Home': go(0); break;
1086 case 'End': go(total-1); break;
1087 case 'f': case 'F': fullscreen(); break;
1088 case 's': case 'S': openPresenterWindow(); break;
1089 case 'n': case 'N': toggleNotes(); break;
1090 case 'o': case 'O': toggleOverview(); break;
1091 case 't': case 'T': cycleTheme(); break;
1092 case 'a': case 'A': cycleAnim(); break;
1093 case 'Escape': toggleOverview(false); toggleNotes(false); break;
1094 }
1095 });
1096
1097 /* ===== Touch navigation =====
1098 * Phones have no arrow keys (#15). Swipe left for next, right for prev.
1099 *
1100 * Deliberately passive: we never call preventDefault, so pinch-zoom and
1101 * any native scrolling keep working and the browser is free to scroll
1102 * while we are still deciding. A gesture only counts as a swipe if it is
1103 * single-finger, clearly horizontal, long enough and quick enough —
1104 * otherwise it falls through untouched.
1105 */
1106 (function initTouchNav(){
1107 /* No capability sniffing on purpose. Touch listeners cost nothing on a
1108 device that never fires them, and `ontouchstart in window` /
1109 maxTouchPoints both misreport on touchscreen laptops and some
1110 tablets — a guard here would silently remove the feature on exactly
1111 the devices that need it. */
1112 var SWIPE_MIN_PX = 50; // shorter than this is a tap or a wobble
1113 var SWIPE_RATIO = 1.5; // must be this much more horizontal than vertical
1114 var SWIPE_MAX_MS = 800; // slower than this is a drag, not a swipe
1115
1116 var x0 = 0, y0 = 0, t0 = 0, tracking = false;
1117
1118 function interactive(target) {
1119 if (!target || !target.closest) return false;
1120 /* Don't steal the gesture from the overview grid, the notes drawer,
1121 or anything the author made scrollable or tappable. */
1122 return !!target.closest('.overview, .notes-overlay, a, button, input, textarea, select, [data-no-swipe]');
1123 }
1124
1125 document.addEventListener('touchstart', function(e){
1126 if (e.touches.length !== 1 || interactive(e.target)) { tracking = false; return; }
1127 x0 = e.touches[0].clientX;
1128 y0 = e.touches[0].clientY;
1129 t0 = Date.now();
1130 tracking = true;
1131 }, { passive: true });
1132
1133 document.addEventListener('touchmove', function(e){
1134 /* A second finger means pinch-zoom — abandon the swipe. */
1135 if (e.touches.length > 1) tracking = false;
1136 }, { passive: true });
1137
1138 document.addEventListener('touchend', function(e){
1139 if (!tracking) return;
1140 tracking = false;
1141 var t = e.changedTouches && e.changedTouches[0];
1142 if (!t) return;
1143 if (Date.now() - t0 > SWIPE_MAX_MS) return;
1144 var dx = t.clientX - x0, dy = t.clientY - y0;
1145 if (Math.abs(dx) < SWIPE_MIN_PX) return;
1146 if (Math.abs(dx) < Math.abs(dy) * SWIPE_RATIO) return;
1147 go(dx < 0 ? idx + 1 : idx - 1);
1148 }, { passive: true });
1149
1150 document.addEventListener('touchcancel', function(){ tracking = false; }, { passive: true });
1151 })();
1152
1153 // hash deep-link
1154 function fromHash(){
1155 const m = /^#\/(\d+)/.exec(location.hash||'');
1156 if (m) go(Math.max(0, parseInt(m[1],10)-1));
1157 }
1158 window.addEventListener('hashchange', fromHash);
1159 fromHash();
1160 go(idx);
1161 });
1162 })();
1163
1163 lines JAVASCRIPT