返回 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 function ready(fn){ if(document.readyState!='loading')fn(); else document.addEventListener('DOMContentLoaded',fn);}
27
28 /* ========== Parse URL for preview-only mode ==========
29 * When loaded as iframe.src = "index.html?preview=3", runtime enters a
30 * locked single-slide mode: only slide N is visible, no chrome, no keys,
31 * no hash updates. This is how the presenter window shows pixel-perfect
32 * previews — by loading the actual deck file in an iframe and telling it
33 * to display only a specific slide.
34 */
35 function getPreviewIdx() {
36 const m = /[?&]preview=(\d+)/.exec(location.search || '');
37 return m ? parseInt(m[1], 10) - 1 : -1;
38 }
39
40 ready(function () {
41 const deck = document.querySelector('.deck');
42 if (!deck) return;
43 const slides = Array.from(deck.querySelectorAll('.slide'));
44 if (!slides.length) return;
45
46 const previewOnlyIdx = getPreviewIdx();
47 const isPreviewMode = previewOnlyIdx >= 0 && previewOnlyIdx < slides.length;
48
49 /* ===== Preview-only mode: show one slide, hide everything else ===== */
50 if (isPreviewMode) {
51 function showSlide(i) {
52 slides.forEach((s, j) => {
53 const active = (j === i);
54 s.classList.toggle('is-active', active);
55 s.style.display = active ? '' : 'none';
56 if (active) {
57 s.style.opacity = '1';
58 s.style.transform = 'none';
59 s.style.pointerEvents = 'auto';
60 }
61 });
62 }
63 showSlide(previewOnlyIdx);
64 /* Hide chrome that the presenter shouldn't see in preview */
65 const hideSel = '.progress-bar, .notes-overlay, .overview, .notes, aside.notes, .speaker-notes';
66 document.querySelectorAll(hideSel).forEach(el => { el.style.display = 'none'; });
67 document.documentElement.setAttribute('data-preview', '1');
68 document.body.setAttribute('data-preview', '1');
69 /* Auto-detect theme base path for theme switching in preview mode */
70 function getPreviewThemeBase() {
71 const base = document.documentElement.getAttribute('data-theme-base');
72 if (base) return base;
73 const tl = document.getElementById('theme-link');
74 if (tl) {
75 const raw = tl.getAttribute('href') || '';
76 const ls = raw.lastIndexOf('/');
77 if (ls >= 0) return raw.substring(0, ls + 1);
78 }
79 return 'assets/themes/';
80 }
81 const previewThemeBase = getPreviewThemeBase();
82
83 /* Listen for postMessage from parent presenter window:
84 * - preview-goto: switch visible slide WITHOUT reloading
85 * - preview-theme: switch theme CSS link to match audience window */
86 window.addEventListener('message', function(e) {
87 if (!e.data) return;
88 if (e.data.type === 'preview-goto') {
89 const n = parseInt(e.data.idx, 10);
90 if (n >= 0 && n < slides.length) showSlide(n);
91 } else if (e.data.type === 'preview-theme' && e.data.name) {
92 let link = document.getElementById('theme-link');
93 if (!link) {
94 link = document.createElement('link');
95 link.rel = 'stylesheet';
96 link.id = 'theme-link';
97 document.head.appendChild(link);
98 }
99 link.href = previewThemeBase + e.data.name + '.css';
100 document.documentElement.setAttribute('data-theme', e.data.name);
101 }
102 });
103 /* Signal to parent that preview iframe is ready */
104 try { window.parent && window.parent.postMessage({ type: 'preview-ready' }, '*'); } catch(e) {}
105 return;
106 }
107
108 let idx = 0;
109 const total = slides.length;
110
111 /* ===== BroadcastChannel for presenter sync ===== */
112 const CHANNEL_NAME = 'html-ppt-presenter-' + location.pathname;
113 let bc;
114 try { bc = new BroadcastChannel(CHANNEL_NAME); } catch(e) { bc = null; }
115
116 // Are we running inside the presenter popup? (legacy flag, now unused)
117 const isPresenterWindow = false;
118
119 /* ===== progress bar ===== */
120 let bar = document.querySelector('.progress-bar');
121 if (!bar) {
122 bar = document.createElement('div');
123 bar.className = 'progress-bar';
124 bar.innerHTML = '<span></span>';
125 document.body.appendChild(bar);
126 }
127 const barFill = bar.querySelector('span');
128
129 /* ===== notes overlay (N key) ===== */
130 let notes = document.querySelector('.notes-overlay');
131 if (!notes) {
132 notes = document.createElement('div');
133 notes.className = 'notes-overlay';
134 document.body.appendChild(notes);
135 }
136
137 /* ===== overview grid (O key) ===== */
138 let overview = document.querySelector('.overview');
139 if (!overview) {
140 overview = document.createElement('div');
141 overview.className = 'overview';
142 slides.forEach((s, i) => {
143 const t = document.createElement('div');
144 t.className = 'thumb';
145 // Force 16:9 aspect ratio robustly
146 t.style.padding = '0 0 56.25% 0';
147 t.style.height = '0';
148 t.style.position = 'relative';
149 t.style.overflow = 'hidden';
150
151 const title = s.getAttribute('data-title') ||
152 (s.querySelector('h1,h2,h3')||{}).textContent || ('Slide '+(i+1));
153
154 // Create a container for the mini-slide
155 const mini = document.createElement('div');
156 mini.className = 'mini-slide';
157 mini.style.position = 'absolute';
158 mini.style.top = '0';
159 mini.style.left = '0';
160 mini.style.width = '1920px';
161 mini.style.height = '1080px';
162 mini.style.transformOrigin = 'top left';
163 mini.style.pointerEvents = 'none';
164 mini.style.background = 'var(--bg)';
165
166 // Clone the slide content
167 const clone = s.cloneNode(true);
168 clone.className = 'slide is-active'; // force active styles
169 clone.style.position = 'absolute';
170 clone.style.inset = '0';
171 clone.style.transform = 'none';
172 clone.style.opacity = '1';
173 clone.style.padding = '72px 96px'; // ensure padding is kept
174
175 mini.appendChild(clone);
176 t.appendChild(mini);
177
178 // Add the number and title overlay
179 const overlay = document.createElement('div');
180 overlay.style.position = 'absolute';
181 overlay.style.inset = '0';
182 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%)';
183 overlay.style.color = '#fff';
184 overlay.style.zIndex = '10';
185 overlay.style.pointerEvents = 'none';
186
187 const n = document.createElement('div');
188 n.className = 'n';
189 n.textContent = i + 1;
190 n.style.position = 'absolute';
191 n.style.top = '12px';
192 n.style.left = '16px';
193 n.style.fontWeight = '700';
194 n.style.fontSize = '16px';
195 n.style.color = '#fff';
196 n.style.textShadow = '0 1px 4px rgba(0,0,0,0.8)';
197
198 const text = document.createElement('div');
199 text.className = 't';
200 text.textContent = title.trim().slice(0,80);
201 text.style.position = 'absolute';
202 text.style.bottom = '12px';
203 text.style.left = '16px';
204 text.style.right = '16px';
205 text.style.fontWeight = '600';
206 text.style.fontSize = '14px';
207 text.style.color = '#fff';
208 text.style.textShadow = '0 1px 4px rgba(0,0,0,0.8)';
209
210 overlay.appendChild(n);
211 overlay.appendChild(text);
212 t.appendChild(overlay);
213
214 t.addEventListener('click', () => { go(i); toggleOverview(false); });
215 overview.appendChild(t);
216 });
217 document.body.appendChild(overview);
218 }
219
220 /* ===== navigation ===== */
221 function go(n, fromRemote){
222 n = Math.max(0, Math.min(total-1, n));
223 slides.forEach((s,i) => {
224 s.classList.toggle('is-active', i===n);
225 s.classList.toggle('is-prev', i<n);
226 });
227 idx = n;
228 barFill.style.width = ((n+1)/total*100)+'%';
229 const numEl = document.querySelector('.slide-number');
230 if (numEl) { numEl.setAttribute('data-current', n+1); numEl.setAttribute('data-total', total); }
231
232 // notes (bottom overlay)
233 const note = slides[n].querySelector('.notes, aside.notes, .speaker-notes');
234 notes.innerHTML = note ? note.innerHTML : '';
235
236 // hash
237 const hashTarget = '#/'+(n+1);
238 if (location.hash !== hashTarget && !isPresenterWindow) {
239 history.replaceState(null,'', hashTarget);
240 }
241
242 // re-trigger entry animations
243 slides[n].querySelectorAll('[data-anim]').forEach(el => {
244 const a = el.getAttribute('data-anim');
245 el.classList.remove('anim-'+a);
246 void el.offsetWidth;
247 el.classList.add('anim-'+a);
248 });
249
250 // counter-up
251 slides[n].querySelectorAll('.counter').forEach(el => {
252 const target = parseFloat(el.getAttribute('data-to')||el.textContent);
253 const dur = parseInt(el.getAttribute('data-dur')||'1200',10);
254 const start = performance.now();
255 const from = 0;
256 function tick(now){
257 const t = Math.min(1,(now-start)/dur);
258 const v = from + (target-from)*(1-Math.pow(1-t,3));
259 el.textContent = (target % 1 === 0) ? Math.round(v) : v.toFixed(1);
260 if (t<1) requestAnimationFrame(tick);
261 }
262 requestAnimationFrame(tick);
263 });
264
265 // Broadcast to other window (audience ↔ presenter)
266 if (!fromRemote && bc) {
267 bc.postMessage({ type: 'go', idx: n });
268 }
269 }
270
271 /* ===== listen for remote navigation / theme changes ===== */
272 if (bc) {
273 bc.onmessage = function(e) {
274 if (!e.data) return;
275 if (e.data.type === 'go' && typeof e.data.idx === 'number') {
276 go(e.data.idx, true);
277 } else if (e.data.type === 'theme' && e.data.name) {
278 /* Sync theme across windows */
279 const i = themes.indexOf(e.data.name);
280 if (i >= 0) themeIdx = i;
281 applyTheme(e.data.name);
282 }
283 };
284 }
285
286 function toggleNotes(force){ notes.classList.toggle('open', force!==undefined?force:!notes.classList.contains('open')); }
287 function toggleOverview(force){
288 const isOpen = force!==undefined ? force : !overview.classList.contains('open');
289 overview.classList.toggle('open', isOpen);
290 if (isOpen) {
291 requestAnimationFrame(() => {
292 const thumbs = overview.querySelectorAll('.thumb');
293 if (thumbs.length) {
294 const scale = thumbs[0].clientWidth / 1920;
295 overview.querySelectorAll('.mini-slide').forEach(m => {
296 m.style.transform = 'scale(' + scale + ')';
297 });
298 }
299 });
300 }
301 }
302
303 /* ========== PRESENTER MODE — Magnetic-card popup window ========== */
304 /* Opens a new window with 4 draggable, resizable cards:
305 * CURRENT — iframe(?preview=N) pixel-perfect preview of current slide
306 * NEXT — iframe(?preview=N+1) pixel-perfect preview of next slide
307 * SCRIPT — large speaker notes (逐字稿)
308 * TIMER — elapsed timer + page counter + controls
309 * Cards remember position/size in localStorage.
310 * Two windows sync via BroadcastChannel.
311 */
312 let presenterWin = null;
313
314 function openPresenterWindow() {
315 if (presenterWin && !presenterWin.closed) {
316 presenterWin.focus();
317 return;
318 }
319
320 // Build absolute URL of THIS deck file (without hash/query)
321 const deckUrl = location.protocol + '//' + location.host + location.pathname;
322
323 // Collect slide titles + notes (HTML strings)
324 const slideMeta = slides.map((s, i) => {
325 const note = s.querySelector('.notes, aside.notes, .speaker-notes');
326 return {
327 title: s.getAttribute('data-title') ||
328 (s.querySelector('h1,h2,h3')||{}).textContent || ('Slide '+(i+1)),
329 notes: note ? note.innerHTML : ''
330 };
331 });
332
333 /* Capture current theme so presenter previews match the audience */
334 const currentTheme = root.getAttribute('data-theme') || (themes[themeIdx] || '');
335 const presenterHTML = buildPresenterHTML(deckUrl, slideMeta, total, idx, CHANNEL_NAME, currentTheme);
336
337 presenterWin = window.open('', 'html-ppt-presenter', 'width=1280,height=820,menubar=no,toolbar=no');
338 if (!presenterWin) {
339 alert('请允许弹出窗口以使用演讲者视图');
340 return;
341 }
342 presenterWin.document.open();
343 presenterWin.document.write(presenterHTML);
344 presenterWin.document.close();
345 }
346
347 function buildPresenterHTML(deckUrl, slideMeta, total, startIdx, channelName, currentTheme) {
348 const metaJSON = JSON.stringify(slideMeta);
349 const deckUrlJSON = JSON.stringify(deckUrl);
350 const channelJSON = JSON.stringify(channelName);
351 const themeJSON = JSON.stringify(currentTheme || '');
352 const storageKey = 'html-ppt-presenter:' + location.pathname;
353
354 // Build the document as a single template string for clarity
355 return `<!DOCTYPE html>
356 <html lang="zh-CN">
357 <head>
358 <meta charset="utf-8">
359 <title>Presenter View</title>
360 <style>
361 * { margin: 0; padding: 0; box-sizing: border-box; }
362 html, body {
363 width: 100%; height: 100%; overflow: hidden;
364 background: #1a1d24;
365 background-image:
366 radial-gradient(circle at 20% 30%, rgba(88,166,255,.04), transparent 50%),
367 radial-gradient(circle at 80% 70%, rgba(188,140,255,.04), transparent 50%);
368 color: #e6edf3;
369 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", sans-serif;
370 }
371 /* Stage: positioned area where cards live */
372 #stage { position: absolute; inset: 0; overflow: hidden; }
373
374 /* Magnetic card */
375 .pcard {
376 position: absolute;
377 background: #0d1117;
378 border: 1px solid rgba(255,255,255,.1);
379 border-radius: 12px;
380 box-shadow: 0 8px 32px rgba(0,0,0,.45), 0 0 0 1px rgba(255,255,255,.02);
381 display: flex; flex-direction: column;
382 overflow: hidden;
383 min-width: 180px; min-height: 100px;
384 transition: box-shadow .2s, border-color .2s;
385 }
386 .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; }
387 .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; }
388 .pcard:hover { border-color: rgba(88,166,255,.3); }
389
390 /* Card header (drag handle) */
391 .pcard-head {
392 display: flex; align-items: center; gap: 10px;
393 padding: 8px 12px;
394 background: rgba(255,255,255,.04);
395 border-bottom: 1px solid rgba(255,255,255,.06);
396 cursor: move;
397 user-select: none;
398 flex-shrink: 0;
399 }
400 .pcard-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dot-color, #58a6ff); flex-shrink: 0; }
401 .pcard-title {
402 font-size: 11px; letter-spacing: .15em; text-transform: uppercase;
403 font-weight: 700; color: #8b949e; flex: 1;
404 }
405 .pcard-meta { font-size: 11px; color: #6e7681; }
406
407 /* Card body */
408 .pcard-body { flex: 1; position: relative; overflow: hidden; min-height: 0; }
409
410 /* Preview cards (CURRENT/NEXT) — iframe-based pixel-perfect render */
411 .pcard-preview .pcard-body { background: #000; }
412 .pcard-preview iframe {
413 position: absolute; top: 0; left: 0;
414 width: 1920px; height: 1080px;
415 border: none;
416 transform-origin: top left;
417 pointer-events: none;
418 background: transparent;
419 }
420 .pcard-preview .preview-end {
421 position: absolute; inset: 0;
422 display: flex; align-items: center; justify-content: center;
423 color: #484f58; font-size: 14px; letter-spacing: .12em;
424 }
425
426 /* Notes card */
427 .pcard-notes .pcard-body {
428 padding: 14px 18px;
429 overflow-y: auto;
430 font-size: 18px; line-height: 1.75;
431 color: #d0d7de;
432 font-family: "Noto Sans SC", -apple-system, sans-serif;
433 }
434 .pcard-notes .pcard-body p { margin: 0 0 .7em 0; }
435 .pcard-notes .pcard-body strong { color: #f0883e; }
436 .pcard-notes .pcard-body em { color: #58a6ff; font-style: normal; }
437 .pcard-notes .pcard-body code {
438 font-family: "SF Mono", monospace; font-size: .9em;
439 background: rgba(255,255,255,.08); padding: 1px 6px; border-radius: 4px;
440 }
441 .pcard-notes .empty { color: #484f58; font-style: italic; }
442
443 /* Timer card */
444 .pcard-timer .pcard-body {
445 display: flex; flex-direction: column; gap: 14px;
446 padding: 18px 20px; justify-content: center;
447 }
448 .timer-display {
449 font-family: "SF Mono", "JetBrains Mono", monospace;
450 font-size: 42px; font-weight: 700;
451 color: #3fb950;
452 letter-spacing: .04em;
453 line-height: 1;
454 }
455 .timer-row {
456 display: flex; align-items: center; gap: 12px;
457 font-size: 14px; color: #8b949e;
458 }
459 .timer-row .label { font-size: 10px; letter-spacing: .15em; text-transform: uppercase; color: #6e7681; }
460 .timer-row .val { color: #e6edf3; font-weight: 600; font-family: "SF Mono", monospace; }
461 .timer-controls { display: flex; gap: 8px; flex-wrap: wrap; }
462 .timer-btn {
463 background: rgba(255,255,255,.06);
464 border: 1px solid rgba(255,255,255,.1);
465 color: #e6edf3;
466 padding: 6px 12px;
467 border-radius: 6px;
468 font-size: 12px;
469 cursor: pointer;
470 font-family: inherit;
471 }
472 .timer-btn:hover { background: rgba(88,166,255,.15); border-color: #58a6ff; }
473 .timer-btn:active { transform: translateY(1px); }
474
475 /* Resize handle */
476 .pcard-resize {
477 position: absolute; right: 0; bottom: 0;
478 width: 18px; height: 18px;
479 cursor: nwse-resize;
480 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%);
481 z-index: 5;
482 }
483 .pcard-resize:hover { background: linear-gradient(135deg, transparent 50%, #58a6ff 50%, #58a6ff 60%, transparent 60%, transparent 70%, #58a6ff 70%, #58a6ff 80%, transparent 80%); }
484
485 /* Bottom hint bar */
486 .hint-bar {
487 position: fixed; bottom: 0; left: 0; right: 0;
488 background: rgba(0,0,0,.6);
489 backdrop-filter: blur(10px);
490 border-top: 1px solid rgba(255,255,255,.08);
491 padding: 6px 16px;
492 font-size: 11px; color: #8b949e;
493 display: flex; gap: 18px; align-items: center;
494 z-index: 1000;
495 }
496 .hint-bar kbd {
497 background: rgba(255,255,255,.08);
498 padding: 1px 6px; border-radius: 3px;
499 font-family: "SF Mono", monospace;
500 font-size: 10px;
501 border: 1px solid rgba(255,255,255,.1);
502 color: #e6edf3;
503 }
504 .hint-bar .reset-layout {
505 margin-left: auto;
506 background: transparent; border: 1px solid rgba(255,255,255,.15);
507 color: #8b949e; padding: 3px 10px; border-radius: 4px;
508 font-size: 11px; cursor: pointer; font-family: inherit;
509 }
510 .hint-bar .reset-layout:hover { background: rgba(248,81,73,.15); border-color: #f85149; color: #f85149; }
511
512 body.is-dragging-card * { user-select: none !important; }
513 body.is-dragging-card iframe { pointer-events: none !important; }
514 </style>
515 </head>
516 <body>
517
518 <div id="stage">
519 <div class="pcard pcard-preview" id="card-cur" style="--dot-color:#58a6ff">
520 <div class="pcard-head" data-drag>
521 <span class="pcard-dot"></span>
522 <span class="pcard-title">CURRENT</span>
523 <span class="pcard-meta" id="cur-meta">—</span>
524 </div>
525 <div class="pcard-body"><iframe id="iframe-cur"></iframe></div>
526 <div class="pcard-resize" data-resize></div>
527 </div>
528
529 <div class="pcard pcard-preview" id="card-nxt" style="--dot-color:#bc8cff">
530 <div class="pcard-head" data-drag>
531 <span class="pcard-dot"></span>
532 <span class="pcard-title">NEXT</span>
533 <span class="pcard-meta" id="nxt-meta">—</span>
534 </div>
535 <div class="pcard-body"><iframe id="iframe-nxt"></iframe></div>
536 <div class="pcard-resize" data-resize></div>
537 </div>
538
539 <div class="pcard pcard-notes" id="card-notes" style="--dot-color:#f0883e">
540 <div class="pcard-head" data-drag>
541 <span class="pcard-dot"></span>
542 <span class="pcard-title">SPEAKER SCRIPT · 逐字稿</span>
543 </div>
544 <div class="pcard-body" id="notes-body"></div>
545 <div class="pcard-resize" data-resize></div>
546 </div>
547
548 <div class="pcard pcard-timer" id="card-timer" style="--dot-color:#3fb950">
549 <div class="pcard-head" data-drag>
550 <span class="pcard-dot"></span>
551 <span class="pcard-title">TIMER</span>
552 </div>
553 <div class="pcard-body">
554 <div class="timer-display" id="timer-display">00:00</div>
555 <div class="timer-row">
556 <span class="label">Slide</span>
557 <span class="val" id="timer-count">1 / ${total}</span>
558 </div>
559 <div class="timer-controls">
560 <button class="timer-btn" id="btn-prev">← Prev</button>
561 <button class="timer-btn" id="btn-next">Next →</button>
562 <button class="timer-btn" id="btn-reset">⏱ Reset</button>
563 </div>
564 </div>
565 <div class="pcard-resize" data-resize></div>
566 </div>
567 </div>
568
569 <div class="hint-bar">
570 <span><kbd>← →</kbd> 翻页</span>
571 <span><kbd>R</kbd> 重置计时</span>
572 <span><kbd>Esc</kbd> 关闭</span>
573 <span style="color:#6e7681">拖动卡片头部移动 · 拖动右下角调整大小</span>
574 <button class="reset-layout" id="reset-layout">重置布局</button>
575 </div>
576
577 <script>
578 (function(){
579 var slideMeta = ${metaJSON};
580 var total = ${total};
581 var idx = ${startIdx};
582 var deckUrl = ${deckUrlJSON};
583 var STORAGE_KEY = ${JSON.stringify(storageKey)};
584 var bc;
585 try { bc = new BroadcastChannel(${channelJSON}); } catch(e) {}
586
587 var iframeCur = document.getElementById('iframe-cur');
588 var iframeNxt = document.getElementById('iframe-nxt');
589 var notesBody = document.getElementById('notes-body');
590 var curMeta = document.getElementById('cur-meta');
591 var nxtMeta = document.getElementById('nxt-meta');
592 var timerDisplay = document.getElementById('timer-display');
593 var timerCount = document.getElementById('timer-count');
594
595 /* ===== Default card layout ===== */
596 function defaultLayout() {
597 var w = window.innerWidth;
598 var h = window.innerHeight - 36; /* leave room for hint bar */
599 return {
600 'card-cur': { x: 16, y: 16, w: Math.round(w*0.55) - 24, h: Math.round(h*0.62) - 16 },
601 '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 },
602 '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 },
603 '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 }
604 };
605 }
606
607 /* ===== Apply / save / restore layout ===== */
608 function applyLayout(layout) {
609 Object.keys(layout).forEach(function(id){
610 var el = document.getElementById(id);
611 var l = layout[id];
612 if (el && l) {
613 el.style.left = l.x + 'px';
614 el.style.top = l.y + 'px';
615 el.style.width = l.w + 'px';
616 el.style.height = l.h + 'px';
617 }
618 });
619 rescaleAll();
620 }
621 function readLayout() {
622 try {
623 var saved = localStorage.getItem(STORAGE_KEY);
624 if (saved) return JSON.parse(saved);
625 } catch(e) {}
626 return defaultLayout();
627 }
628 function saveLayout() {
629 var layout = {};
630 ['card-cur','card-nxt','card-notes','card-timer'].forEach(function(id){
631 var el = document.getElementById(id);
632 if (el) {
633 layout[id] = {
634 x: parseInt(el.style.left,10) || 0,
635 y: parseInt(el.style.top,10) || 0,
636 w: parseInt(el.style.width,10) || 300,
637 h: parseInt(el.style.height,10) || 200
638 };
639 }
640 });
641 try { localStorage.setItem(STORAGE_KEY, JSON.stringify(layout)); } catch(e) {}
642 }
643
644 /* ===== iframe rescale to fit card body ===== */
645 function rescaleIframe(iframe) {
646 if (!iframe || iframe.style.display === 'none') return;
647 var body = iframe.parentElement;
648 var cw = body.clientWidth, ch = body.clientHeight;
649 if (!cw || !ch) return;
650 var s = Math.min(cw / 1920, ch / 1080);
651 iframe.style.transform = 'scale(' + s + ')';
652 /* Center the scaled iframe in the body */
653 var sw = 1920 * s, sh = 1080 * s;
654 iframe.style.left = Math.max(0, (cw - sw) / 2) + 'px';
655 iframe.style.top = Math.max(0, (ch - sh) / 2) + 'px';
656 }
657 function rescaleAll() {
658 rescaleIframe(iframeCur);
659 rescaleIframe(iframeNxt);
660 }
661 window.addEventListener('resize', rescaleAll);
662
663 /* ===== Drag (move card by header) ===== */
664 document.querySelectorAll('[data-drag]').forEach(function(handle){
665 handle.addEventListener('mousedown', function(e){
666 if (e.button !== 0) return;
667 var card = handle.closest('.pcard');
668 if (!card) return;
669 e.preventDefault();
670 card.classList.add('dragging');
671 document.body.classList.add('is-dragging-card');
672 var startX = e.clientX, startY = e.clientY;
673 var startL = parseInt(card.style.left,10) || 0;
674 var startT = parseInt(card.style.top,10) || 0;
675 function onMove(ev){
676 var nx = Math.max(0, Math.min(window.innerWidth - 100, startL + ev.clientX - startX));
677 var ny = Math.max(0, Math.min(window.innerHeight - 50, startT + ev.clientY - startY));
678 card.style.left = nx + 'px';
679 card.style.top = ny + 'px';
680 }
681 function onUp(){
682 card.classList.remove('dragging');
683 document.body.classList.remove('is-dragging-card');
684 document.removeEventListener('mousemove', onMove);
685 document.removeEventListener('mouseup', onUp);
686 saveLayout();
687 }
688 document.addEventListener('mousemove', onMove);
689 document.addEventListener('mouseup', onUp);
690 });
691 });
692
693 /* ===== Resize (drag bottom-right corner) ===== */
694 document.querySelectorAll('[data-resize]').forEach(function(handle){
695 handle.addEventListener('mousedown', function(e){
696 if (e.button !== 0) return;
697 var card = handle.closest('.pcard');
698 if (!card) return;
699 e.preventDefault(); e.stopPropagation();
700 card.classList.add('resizing');
701 document.body.classList.add('is-dragging-card');
702 var startX = e.clientX, startY = e.clientY;
703 var startW = parseInt(card.style.width,10) || card.offsetWidth;
704 var startH = parseInt(card.style.height,10) || card.offsetHeight;
705 function onMove(ev){
706 var nw = Math.max(180, startW + ev.clientX - startX);
707 var nh = Math.max(100, startH + ev.clientY - startY);
708 card.style.width = nw + 'px';
709 card.style.height = nh + 'px';
710 if (card.querySelector('iframe')) rescaleIframe(card.querySelector('iframe'));
711 }
712 function onUp(){
713 card.classList.remove('resizing');
714 document.body.classList.remove('is-dragging-card');
715 document.removeEventListener('mousemove', onMove);
716 document.removeEventListener('mouseup', onUp);
717 rescaleAll();
718 saveLayout();
719 }
720 document.addEventListener('mousemove', onMove);
721 document.addEventListener('mouseup', onUp);
722 });
723 });
724
725 /* ===== Preview iframe ready tracking =====
726 * Each iframe loads the deck ONCE with ?preview=1 on init. Subsequent
727 * slide changes are sent via postMessage('preview-goto') so the iframe
728 * just toggles visibility of a different .slide — no reload, no flicker.
729 */
730 var iframeReady = { cur: false, nxt: false };
731 var currentTheme = ${themeJSON};
732 window.addEventListener('message', function(e) {
733 if (!e.data || e.data.type !== 'preview-ready') return;
734 var iframe = null;
735 if (e.source === iframeCur.contentWindow) {
736 iframeReady.cur = true;
737 iframe = iframeCur;
738 postPreviewGoto(iframeCur, idx);
739 } else if (e.source === iframeNxt.contentWindow) {
740 iframeReady.nxt = true;
741 iframe = iframeNxt;
742 postPreviewGoto(iframeNxt, idx + 1 < total ? idx + 1 : idx);
743 }
744 /* Sync current theme to the iframe */
745 if (iframe && currentTheme) {
746 try { iframe.contentWindow.postMessage({ type: 'preview-theme', name: currentTheme }, '*'); } catch(err) {}
747 }
748 if (iframe) rescaleIframe(iframe);
749 });
750
751 function postPreviewGoto(iframe, n) {
752 try {
753 iframe.contentWindow.postMessage({ type: 'preview-goto', idx: n }, '*');
754 } catch(e) {}
755 }
756
757 /* ===== Update content =====
758 * Smooth (no-reload) navigation: send postMessage to iframes instead of
759 * resetting src. Iframes stay loaded, just switch visible .slide.
760 */
761 function update(n) {
762 n = Math.max(0, Math.min(total - 1, n));
763 idx = n;
764
765 /* Current preview — postMessage (smooth) */
766 if (iframeReady.cur) postPreviewGoto(iframeCur, n);
767 curMeta.textContent = (n + 1) + '/' + total;
768
769 /* Next preview */
770 if (n + 1 < total) {
771 iframeNxt.style.display = '';
772 var endEl = document.querySelector('#card-nxt .preview-end');
773 if (endEl) endEl.remove();
774 if (iframeReady.nxt) postPreviewGoto(iframeNxt, n + 1);
775 nxtMeta.textContent = (n + 2) + '/' + total;
776 } else {
777 iframeNxt.style.display = 'none';
778 var body = document.querySelector('#card-nxt .pcard-body');
779 if (body && !body.querySelector('.preview-end')) {
780 var end = document.createElement('div');
781 end.className = 'preview-end';
782 end.textContent = '— END OF DECK —';
783 body.appendChild(end);
784 }
785 nxtMeta.textContent = 'END';
786 }
787
788 /* Notes */
789 var note = slideMeta[n].notes;
790 notesBody.innerHTML = note || '<span class="empty">(这一页还没有逐字稿)</span>';
791
792 /* Timer count */
793 timerCount.textContent = (n + 1) + ' / ' + total;
794 }
795
796 /* ===== Timer ===== */
797 var tStart = Date.now();
798 setInterval(function(){
799 var s = Math.floor((Date.now() - tStart) / 1000);
800 var mm = String(Math.floor(s/60)).padStart(2,'0');
801 var ss = String(s%60).padStart(2,'0');
802 timerDisplay.textContent = mm + ':' + ss;
803 }, 1000);
804 function resetTimer(){ tStart = Date.now(); timerDisplay.textContent = '00:00'; }
805
806 /* ===== BroadcastChannel sync ===== */
807 if (bc) {
808 bc.onmessage = function(e){
809 if (!e.data) return;
810 if (e.data.type === 'go') update(e.data.idx);
811 else if (e.data.type === 'theme' && e.data.name) {
812 currentTheme = e.data.name;
813 /* Forward theme change to preview iframes */
814 [iframeCur, iframeNxt].forEach(function(iframe){
815 try {
816 iframe.contentWindow.postMessage({ type: 'preview-theme', name: e.data.name }, '*');
817 } catch(err) {}
818 });
819 }
820 };
821 }
822 function go(n) {
823 update(n);
824 if (bc) bc.postMessage({ type: 'go', idx: idx });
825 }
826
827 /* ===== Buttons ===== */
828 document.getElementById('btn-prev').addEventListener('click', function(){ go(idx - 1); });
829 document.getElementById('btn-next').addEventListener('click', function(){ go(idx + 1); });
830 document.getElementById('btn-reset').addEventListener('click', resetTimer);
831 document.getElementById('reset-layout').addEventListener('click', function(){
832 if (confirm('恢复默认卡片布局?')) {
833 try { localStorage.removeItem(STORAGE_KEY); } catch(e){}
834 applyLayout(defaultLayout());
835 }
836 });
837
838 /* ===== Keyboard ===== */
839 document.addEventListener('keydown', function(e){
840 if (e.metaKey || e.ctrlKey || e.altKey) return;
841 switch(e.key) {
842 case 'ArrowRight': case ' ': case 'PageDown': go(idx + 1); e.preventDefault(); break;
843 case 'ArrowLeft': case 'PageUp': go(idx - 1); e.preventDefault(); break;
844 case 'Home': go(0); break;
845 case 'End': go(total - 1); break;
846 case 'r': case 'R': resetTimer(); break;
847 case 'Escape': window.close(); break;
848 }
849 });
850
851 /* ===== Iframe load → rescale (catches initial size) ===== */
852 iframeCur.addEventListener('load', function(){ rescaleIframe(iframeCur); });
853 iframeNxt.addEventListener('load', function(){ rescaleIframe(iframeNxt); });
854
855 /* ===== Init =====
856 * Load each iframe ONCE with the deck file. After they post
857 * 'preview-ready', all subsequent navigation is via postMessage
858 * (smooth, no reload, no flicker).
859 */
860 applyLayout(readLayout());
861 iframeCur.src = deckUrl + '?preview=' + (idx + 1);
862 if (idx + 1 < total) iframeNxt.src = deckUrl + '?preview=' + (idx + 2);
863 /* Initialize notes/timer/count without touching iframes */
864 notesBody.innerHTML = slideMeta[idx].notes || '<span class="empty">(这一页还没有逐字稿)</span>';
865 curMeta.textContent = (idx + 1) + '/' + total;
866 nxtMeta.textContent = (idx + 2) + '/' + total;
867 timerCount.textContent = (idx + 1) + ' / ' + total;
868 })();
869 </` + `script>
870 </body></html>`;
871 }
872
873 function fullscreen(){ const el=document.documentElement;
874 if (!document.fullscreenElement) el.requestFullscreen&&el.requestFullscreen();
875 else document.exitFullscreen&&document.exitFullscreen();
876 }
877
878 // theme cycling
879 const root = document.documentElement;
880 const themesAttr = root.getAttribute('data-themes') || document.body.getAttribute('data-themes');
881 const themes = themesAttr ? themesAttr.split(',').map(s=>s.trim()).filter(Boolean) : [];
882 let themeIdx = 0;
883
884 // Auto-detect theme base path from existing <link id="theme-link">
885 let themeBase = root.getAttribute('data-theme-base');
886 if (!themeBase) {
887 const existingLink = document.getElementById('theme-link');
888 if (existingLink) {
889 // el.getAttribute('href') gives the raw relative path written in HTML
890 const rawHref = existingLink.getAttribute('href') || '';
891 const lastSlash = rawHref.lastIndexOf('/');
892 themeBase = lastSlash >= 0 ? rawHref.substring(0, lastSlash + 1) : 'assets/themes/';
893 } else {
894 themeBase = 'assets/themes/';
895 }
896 }
897
898 function applyTheme(name) {
899 let link = document.getElementById('theme-link');
900 if (!link) {
901 link = document.createElement('link');
902 link.rel = 'stylesheet';
903 link.id = 'theme-link';
904 document.head.appendChild(link);
905 }
906 link.href = themeBase + name + '.css';
907 root.setAttribute('data-theme', name);
908 const ind = document.querySelector('.theme-indicator');
909 if (ind) ind.textContent = name;
910 }
911 function cycleTheme(fromRemote){
912 if (!themes.length) return;
913 themeIdx = (themeIdx+1) % themes.length;
914 const name = themes[themeIdx];
915 applyTheme(name);
916 /* Broadcast to other window (audience ↔ presenter) */
917 if (!fromRemote && bc) bc.postMessage({ type: 'theme', name: name });
918 }
919
920 // animation cycling on current slide
921 let animIdx = 0;
922 function cycleAnim(){
923 animIdx = (animIdx+1) % ANIMS.length;
924 const a = ANIMS[animIdx];
925 const target = slides[idx].querySelector('[data-anim-target]') || slides[idx];
926 ANIMS.forEach(x => target.classList.remove('anim-'+x));
927 void target.offsetWidth;
928 target.classList.add('anim-'+a);
929 target.setAttribute('data-anim', a);
930 const ind = document.querySelector('.anim-indicator');
931 if (ind) ind.textContent = a;
932 }
933
934 document.addEventListener('keydown', function (e) {
935 if (e.metaKey||e.ctrlKey||e.altKey) return;
936 switch (e.key) {
937 case 'ArrowRight': case ' ': case 'PageDown': case 'Enter': go(idx+1); e.preventDefault(); break;
938 case 'ArrowLeft': case 'PageUp': case 'Backspace': go(idx-1); e.preventDefault(); break;
939 case 'Home': go(0); break;
940 case 'End': go(total-1); break;
941 case 'f': case 'F': fullscreen(); break;
942 case 's': case 'S': openPresenterWindow(); break;
943 case 'n': case 'N': toggleNotes(); break;
944 case 'o': case 'O': toggleOverview(); break;
945 case 't': case 'T': cycleTheme(); break;
946 case 'a': case 'A': cycleAnim(); break;
947 case 'Escape': toggleOverview(false); toggleNotes(false); break;
948 }
949 });
950
951 // hash deep-link
952 function fromHash(){
953 const m = /^#\/(\d+)/.exec(location.hash||'');
954 if (m) go(Math.max(0, parseInt(m[1],10)-1));
955 }
956 window.addEventListener('hashchange', fromHash);
957 fromHash();
958 go(idx);
959 });
960 })();
961
961 lines JAVASCRIPT