From 832f5be2128aa901ee7d8dc54b73cfd56e7f4989 Mon Sep 17 00:00:00 2001 From: lewis Date: Fri, 17 Apr 2026 23:21:45 +0800 Subject: [PATCH] feat(presenter): magnetic-card UI with pixel-perfect iframe previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete rewrite of presenter mode. Two-part solution: 1. Single-slide preview mode in runtime.js - Detect ?preview=N query param on page load - When present: show only slide N, hide all chrome (progress-bar, notes-overlay, overview), skip key handlers, skip hash/broadcast - This lets the presenter window load the deck file in iframes with specific slides — CSS/theme/colors 100% identical to audience view 2. Magnetic-card presenter window - 4 independent cards (CURRENT / NEXT / SCRIPT / TIMER) - Each card is position:absolute, draggable by header, resizable by bottom-right corner handle - Cards have colored indicator dots matching their role - Dragging shows blue outline; resizing shows green - Layout persists to localStorage per-deck, with 'Reset layout' button - Default layout: CURRENT big top-left, NEXT top-right, SCRIPT bottom-right, TIMER bottom-left Previews use iframe.src = deckUrl + '?preview=N': - iframe loads THE SAME HTML file as the audience - runtime.js detects ?preview=N and renders only that slide - CSS transform:scale() fits 1920x1080 into card body, centered - Zero style injection / zero HTML escaping — can't break Sync: BroadcastChannel keeps audience ↔ presenter in lockstep. Verified with headless Chrome: tokyo-night theme, gradient headlines, speaker avatars, agenda rows — all render identically between windows. --- assets/runtime.js | 797 ++++++++++++++++++++++++++++++---------------- 1 file changed, 527 insertions(+), 270 deletions(-) diff --git a/assets/runtime.js b/assets/runtime.js index b691d80..2c0acdc 100644 --- a/assets/runtime.js +++ b/assets/runtime.js @@ -25,22 +25,59 @@ function ready(fn){ if(document.readyState!='loading')fn(); else document.addEventListener('DOMContentLoaded',fn);} + /* ========== Parse URL for preview-only mode ========== + * When loaded as iframe.src = "index.html?preview=3", runtime enters a + * locked single-slide mode: only slide N is visible, no chrome, no keys, + * no hash updates. This is how the presenter window shows pixel-perfect + * previews — by loading the actual deck file in an iframe and telling it + * to display only a specific slide. + */ + function getPreviewIdx() { + const m = /[?&]preview=(\d+)/.exec(location.search || ''); + return m ? parseInt(m[1], 10) - 1 : -1; + } + ready(function () { const deck = document.querySelector('.deck'); if (!deck) return; const slides = Array.from(deck.querySelectorAll('.slide')); if (!slides.length) return; + const previewOnlyIdx = getPreviewIdx(); + const isPreviewMode = previewOnlyIdx >= 0 && previewOnlyIdx < slides.length; + + /* ===== Preview-only mode: show one slide, hide everything else ===== */ + if (isPreviewMode) { + slides.forEach((s, i) => { + s.classList.toggle('is-active', i === previewOnlyIdx); + if (i !== previewOnlyIdx) { + s.style.display = 'none'; + } else { + s.style.opacity = '1'; + s.style.transform = 'none'; + s.style.pointerEvents = 'auto'; + } + }); + /* Hide chrome that the presenter shouldn't see in preview */ + const hideSel = '.progress-bar, .notes-overlay, .overview, .notes, aside.notes, .speaker-notes'; + document.querySelectorAll(hideSel).forEach(el => { el.style.display = 'none'; }); + /* Also add a data attr so templates can style preview mode if needed */ + document.documentElement.setAttribute('data-preview', '1'); + document.body.setAttribute('data-preview', '1'); + /* Don't register key handlers, don't start broadcast channel, don't auto-hash */ + return; + } + let idx = 0; const total = slides.length; /* ===== BroadcastChannel for presenter sync ===== */ - const CHANNEL_NAME = 'html-ppt-presenter-' + (location.pathname + location.search); + const CHANNEL_NAME = 'html-ppt-presenter-' + location.pathname; let bc; try { bc = new BroadcastChannel(CHANNEL_NAME); } catch(e) { bc = null; } - // Are we running inside the presenter popup? - const isPresenterWindow = location.hash.indexOf('__presenter__') !== -1; + // Are we running inside the presenter popup? (legacy flag, now unused) + const isPresenterWindow = false; /* ===== progress bar ===== */ let bar = document.querySelector('.progress-bar'); @@ -140,7 +177,15 @@ function toggleNotes(force){ notes.classList.toggle('open', force!==undefined?force:!notes.classList.contains('open')); } function toggleOverview(force){ overview.classList.toggle('open', force!==undefined?force:!overview.classList.contains('open')); } - /* ========== PRESENTER MODE (new window) ========== */ + /* ========== PRESENTER MODE — Magnetic-card popup window ========== */ + /* Opens a new window with 4 draggable, resizable cards: + * CURRENT — iframe(?preview=N) pixel-perfect preview of current slide + * NEXT — iframe(?preview=N+1) pixel-perfect preview of next slide + * SCRIPT — large speaker notes (逐字稿) + * TIMER — elapsed timer + page counter + controls + * Cards remember position/size in localStorage. + * Two windows sync via BroadcastChannel. + */ let presenterWin = null; function openPresenterWindow() { @@ -149,31 +194,22 @@ return; } - // Collect all slides' HTML and notes - const slideData = slides.map((s, i) => { + // Build absolute URL of THIS deck file (without hash/query) + const deckUrl = location.protocol + '//' + location.host + location.pathname; + + // Collect slide titles + notes (HTML strings) + const slideMeta = slides.map((s, i) => { const note = s.querySelector('.notes, aside.notes, .speaker-notes'); return { - html: s.outerHTML, - notes: note ? note.innerHTML : '', title: s.getAttribute('data-title') || - (s.querySelector('h1,h2,h3')||{}).textContent || ('Slide '+(i+1)) + (s.querySelector('h1,h2,h3')||{}).textContent || ('Slide '+(i+1)), + notes: note ? note.innerHTML : '' }; }); - // Collect all stylesheets — use absolute URLs so popup can resolve them - const styleSheets = Array.from(document.querySelectorAll('link[rel="stylesheet"], style')).map(el => { - if (el.tagName === 'LINK') return ''; - return ''; - }).join('\n'); + const presenterHTML = buildPresenterHTML(deckUrl, slideMeta, total, idx, CHANNEL_NAME); - // Collect body classes (e.g. tpl-presenter-mode-reveal) so scoped CSS works - const bodyClasses = document.body.className || ''; - // Collect attributes for theme variables - const htmlAttrs = Array.from(root.attributes).map(a => a.name+'="'+a.value+'"').join(' '); - - const presenterHTML = buildPresenterHTML(slideData, styleSheets, total, idx, bodyClasses, htmlAttrs); - - presenterWin = window.open('', 'html-ppt-presenter', 'width=1200,height=800,menubar=no,toolbar=no'); + presenterWin = window.open('', 'html-ppt-presenter', 'width=1280,height=820,menubar=no,toolbar=no'); if (!presenterWin) { alert('请允许弹出窗口以使用演讲者视图'); return; @@ -183,258 +219,479 @@ presenterWin.document.close(); } - function buildPresenterHTML(slideData, styleSheets, total, startIdx, bodyClasses, htmlAttrs) { - const slidesJSON = JSON.stringify(slideData); + function buildPresenterHTML(deckUrl, slideMeta, total, startIdx, channelName) { + const metaJSON = JSON.stringify(slideMeta); + const deckUrlJSON = JSON.stringify(deckUrl); + const channelJSON = JSON.stringify(channelName); + const storageKey = 'html-ppt-presenter:' + location.pathname; - // Build iframe document template. Each iframe gets its own viewport - // at 1920x1080 so vw/vh/clamp all resolve exactly like the audience view. - // We inject via contentDocument.write() so there's ZERO HTML escaping issues. - // Template is a JS string (not embedded HTML attribute), so quotes stay raw. - // IMPORTANT: Do NOT override .slide or .deck styling. The original - // base.css + theme + scoped CSS handles flex centering, padding, etc. - // We only: (1) reset margins, (2) force the single slide to be visible - // (.is-active), (3) hide the speaker notes and runtime chrome. - const iframeDocTemplate = '' - + '' - + '' - + styleSheets - + '' - + '' - + '
%%SLIDE_HTML%%
' - + ''; + // Build the document as a single template string for clarity + return ` + + + +Presenter View +\n' -+ '\n\n' -+ '
\n' -+ '
\n' -+ '
CURRENT
\n' -+ '
\n' -+ '
\n' -+ '
\n' -+ '
\n' -+ '
\n' -+ '
NEXT
\n' -+ '
\n' -+ '
\n' -+ '
\n' -+ '
\n' -+ '
SPEAKER SCRIPT · 逐字稿
\n' -+ '
\n' -+ '
\n' -+ '
\n' -+ '
\n' -+ '
\n' -+ '
00:00
\n' -+ '
1 / ' + total + '
\n' -+ '
\n' -+ '
← → 翻页 · R 重置计时 · 拖动分隔线调整区域 · Esc 关闭
\n' -+ '
\n' -+ '