fix(presenter): use iframe srcdoc for pixel-perfect slide preview
Root cause: CSS viewport units (vw/vh), clamp(), and percentage-based layouts resolve against the actual viewport, not a fixed-size div. DOM clones in a 1920x1080 container still render differently from the audience view because the iframe-less context has the wrong viewport. Fix: replace DOM clone approach with <iframe srcdoc='...'> for both current and next slide previews. Each iframe: - Is sized 1920x1080 (matching the design resolution) - Creates its own viewport context (100vw=1920px, 100vh=1080px) - Loads the full page CSS (absolute URLs) + body classes + html attrs - Wraps the single slide in a .deck container - CSS transform:scale() shrinks it to fit the preview area - Result: pixel-perfect match with audience view Also: srcdoc template carries the host page's body classes (e.g. .tpl-presenter-mode-reveal) and html attributes (data-themes, lang) so scoped CSS and theme variables work correctly inside the iframe.
This commit is contained in:
parent
0ce226f87c
commit
06d6283ff7
|
|
@ -186,35 +186,45 @@
|
|||
function buildPresenterHTML(slideData, styleSheets, total, startIdx, bodyClasses, htmlAttrs) {
|
||||
const slidesJSON = JSON.stringify(slideData);
|
||||
|
||||
// Build the srcdoc template for slide iframes.
|
||||
// Each iframe gets its own viewport (1920x1080) so all CSS
|
||||
// (vw/vh/clamp/percentage) resolves identically to the audience view.
|
||||
// We use a placeholder %%SLIDE_HTML%% that gets replaced per-slide.
|
||||
const iframeSrcdocTemplate = '<!DOCTYPE html>'
|
||||
+ '<html ' + htmlAttrs.replace(/"/g, '"') + '>'
|
||||
+ '<head><meta charset="utf-8">'
|
||||
+ styleSheets.replace(/"/g, '"')
|
||||
+ '<style>'
|
||||
+ 'html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden}'
|
||||
+ '.deck{position:relative;width:100vw;height:100vh;overflow:hidden}'
|
||||
+ '.slide{position:absolute!important;inset:0!important;width:100vw!important;height:100vh!important;opacity:1!important;transform:none!important;display:block!important;overflow:hidden!important}'
|
||||
+ '.notes,aside.notes,.speaker-notes{display:none!important}'
|
||||
+ '.progress-bar,.notes-overlay,.overview,.deck-header,.deck-footer,.slide-number{display:none!important}'
|
||||
+ '</style></head>'
|
||||
+ '<body class="' + bodyClasses.replace(/"/g, '"') + '">'
|
||||
+ '<div class="deck">%%SLIDE_HTML%%</div>'
|
||||
+ '</body></html>';
|
||||
|
||||
return '<!DOCTYPE html>\n'
|
||||
+ '<html ' + htmlAttrs + '>\n<head>\n<meta charset="utf-8">\n'
|
||||
+ '<html lang="zh-CN">\n<head>\n<meta charset="utf-8">\n'
|
||||
+ '<title>Presenter View</title>\n'
|
||||
+ styleSheets + '\n'
|
||||
+ '<style>\n'
|
||||
/* ===== Presenter-only layout (overrides everything from host CSS) ===== */
|
||||
+ ' .pv-grid, .pv-grid * { box-sizing: border-box; }\n'
|
||||
+ ' body.pv-body { width: 100% !important; height: 100% !important; overflow: hidden !important; background: #0d0d0d !important; color: #e6edf3 !important; margin: 0 !important; padding: 0 !important; }\n'
|
||||
+ ' .pv-grid { display: grid; grid-template-columns: 1.4fr 1fr; grid-template-rows: 1fr auto; height: 100vh; gap: 12px; padding: 12px; position: relative; z-index: 1; }\n'
|
||||
+ ' * { margin: 0; padding: 0; box-sizing: border-box; }\n'
|
||||
+ ' html, body { width: 100%; height: 100%; overflow: hidden; background: #0d0d0d; color: #e6edf3; font-family: "Noto Sans SC", -apple-system, sans-serif; }\n'
|
||||
+ ' .pv-grid { display: grid; grid-template-columns: 1.4fr 1fr; grid-template-rows: 1fr auto; height: 100vh; gap: 12px; padding: 12px; }\n'
|
||||
+ ' .pv-current-wrap { grid-row: 1; grid-column: 1; display: flex; flex-direction: column; min-height: 0; }\n'
|
||||
+ ' .pv-right { grid-row: 1; grid-column: 2; display: flex; flex-direction: column; gap: 10px; min-height: 0; }\n'
|
||||
+ ' .pv-bar { grid-row: 2; grid-column: 1 / -1; display: flex; align-items: center; gap: 16px; padding: 8px 16px; background: rgba(255,255,255,.04); border-radius: 8px; font-size: 13px; }\n'
|
||||
+ ' .pv-label { font-size: 10px; letter-spacing: .18em; text-transform: uppercase; color: #6e7681; font-weight: 700; margin-bottom: 6px; padding-left: 2px; flex-shrink: 0; }\n'
|
||||
+ '\n'
|
||||
/* ===== Slide stage: 16:9 container at design res, then CSS scale ===== */
|
||||
+ ' .pv-stage { flex: 1; position: relative; border: 1px solid rgba(255,255,255,.08); border-radius: 10px; overflow: hidden; background: var(--bg, #0d1117); min-height: 0; }\n'
|
||||
+ ' .pv-stage-inner { position: absolute; top: 0; left: 0; width: 1920px; height: 1080px; transform-origin: top left; pointer-events: none; overflow: hidden; }\n'
|
||||
/* The .deck wrapper gives slides the same context as the audience view */
|
||||
+ ' .pv-stage-inner .deck { position: absolute; top: 0; left: 0; width: 1920px; height: 1080px; overflow: hidden; }\n'
|
||||
+ ' .pv-stage-inner .deck .slide { position: absolute !important; inset: 0 !important; width: 1920px !important; height: 1080px !important; opacity: 1 !important; transform: none !important; display: block !important; overflow: hidden !important; }\n'
|
||||
+ ' .pv-stage-inner .deck .slide .notes, .pv-stage-inner .deck .slide aside.notes, .pv-stage-inner .deck .slide .speaker-notes { display: none !important; }\n'
|
||||
/* Hide runtime chrome inside preview clones */
|
||||
+ ' .pv-stage-inner .deck .progress-bar, .pv-stage-inner .deck .notes-overlay, .pv-stage-inner .deck .overview, .pv-stage-inner .deck .deck-header, .pv-stage-inner .deck .deck-footer { display: none !important; }\n'
|
||||
+ ' /* Slide preview: iframe at 1920x1080 scaled to fit container */\n'
|
||||
+ ' .pv-stage { flex: 1; position: relative; border: 1px solid rgba(255,255,255,.08); border-radius: 10px; overflow: hidden; background: #0d1117; min-height: 0; }\n'
|
||||
+ ' .pv-stage iframe { position: absolute; top: 0; left: 0; width: 1920px; height: 1080px; transform-origin: top left; border: none; pointer-events: none; }\n'
|
||||
+ '\n'
|
||||
+ ' .pv-next-wrap { flex: 0 0 35%; display: flex; flex-direction: column; min-height: 0; }\n'
|
||||
+ ' .pv-next-stage { opacity: .85; }\n'
|
||||
+ ' .pv-next-end { display: flex; align-items: center; justify-content: center; height: 100%; font-size: 16px; color: #484f58; letter-spacing: .1em; position: absolute; inset: 0; }\n'
|
||||
+ ' .pv-next-end { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 16px; color: #484f58; letter-spacing: .1em; }\n'
|
||||
+ '\n'
|
||||
/* ===== Notes panel ===== */
|
||||
+ ' .pv-notes { flex: 1; display: flex; flex-direction: column; min-height: 0; background: rgba(255,255,255,.02); border: 1px solid rgba(255,255,255,.06); border-radius: 10px; padding: 12px 16px; }\n'
|
||||
+ ' .pv-notes-body { flex: 1; overflow-y: auto; font-size: 18px; line-height: 1.75; color: #d0d7de; font-family: "Noto Sans SC", -apple-system, sans-serif; }\n'
|
||||
+ ' .pv-notes-body p { margin: 0 0 .7em 0; }\n'
|
||||
|
|
@ -228,16 +238,16 @@
|
|||
+ ' .pv-title { color: #8b949e; font-size: 13px; flex: 1; text-align: right; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }\n'
|
||||
+ ' .pv-hint { font-size: 11px; color: #484f58; margin-left: auto; }\n'
|
||||
+ '</style>\n'
|
||||
+ '</head>\n<body class="pv-body ' + bodyClasses + '">\n'
|
||||
+ '</head>\n<body>\n'
|
||||
+ '<div class="pv-grid">\n'
|
||||
+ ' <div class="pv-current-wrap">\n'
|
||||
+ ' <div class="pv-label">CURRENT</div>\n'
|
||||
+ ' <div class="pv-stage" id="pv-current"><div class="pv-stage-inner" id="pv-cur-inner"></div></div>\n'
|
||||
+ ' <div class="pv-stage" id="pv-current"><iframe id="iframe-cur"></iframe></div>\n'
|
||||
+ ' </div>\n'
|
||||
+ ' <div class="pv-right">\n'
|
||||
+ ' <div class="pv-next-wrap">\n'
|
||||
+ ' <div class="pv-label">NEXT</div>\n'
|
||||
+ ' <div class="pv-stage pv-next-stage" id="pv-next"><div class="pv-stage-inner" id="pv-nxt-inner"></div></div>\n'
|
||||
+ ' <div class="pv-stage pv-next-stage" id="pv-next"><iframe id="iframe-nxt"></iframe></div>\n'
|
||||
+ ' </div>\n'
|
||||
+ ' <div class="pv-notes">\n'
|
||||
+ ' <div class="pv-label">SPEAKER SCRIPT · 逐字稿</div>\n'
|
||||
|
|
@ -257,10 +267,11 @@
|
|||
+ ' var total = ' + total + ';\n'
|
||||
+ ' var idx = ' + startIdx + ';\n'
|
||||
+ ' var CHANNEL_NAME = ' + JSON.stringify(CHANNEL_NAME) + ';\n'
|
||||
+ ' var SRCDOC_TPL = ' + JSON.stringify(iframeSrcdocTemplate) + ';\n'
|
||||
+ ' var bc; try { bc = new BroadcastChannel(CHANNEL_NAME); } catch(e) {}\n'
|
||||
+ '\n'
|
||||
+ ' var curInner = document.getElementById("pv-cur-inner");\n'
|
||||
+ ' var nxtInner = document.getElementById("pv-nxt-inner");\n'
|
||||
+ ' var iframeCur = document.getElementById("iframe-cur");\n'
|
||||
+ ' var iframeNxt = document.getElementById("iframe-nxt");\n'
|
||||
+ ' var pvNotes = document.getElementById("pv-notes");\n'
|
||||
+ ' var pvCount = document.getElementById("pv-count");\n'
|
||||
+ ' var pvTitle = document.getElementById("pv-title");\n'
|
||||
|
|
@ -273,63 +284,58 @@
|
|||
+ ' pvTimer.textContent = String(Math.floor(s/60)).padStart(2,"0") + ":" + String(s%60).padStart(2,"0");\n'
|
||||
+ ' }, 1000);\n'
|
||||
+ '\n'
|
||||
+ ' /* Compute scale to fit 1920x1080 into actual container size */\n'
|
||||
+ ' /* Scale iframe (1920x1080) to fit its container */\n'
|
||||
+ ' function fitScale(container) {\n'
|
||||
+ ' var cw = container.clientWidth, ch = container.clientHeight;\n'
|
||||
+ ' if (!cw || !ch) return 0.3;\n'
|
||||
+ ' return Math.min(cw / 1920, ch / 1080);\n'
|
||||
+ ' }\n'
|
||||
+ '\n'
|
||||
+ ' /* Render a slide inside a proper .deck wrapper at 1920x1080 */\n'
|
||||
+ ' function renderSlide(container, html) {\n'
|
||||
+ ' container.innerHTML = "<div class=\\"deck\\">" + html + "</div>";\n'
|
||||
+ ' var sl = container.querySelector(".slide");\n'
|
||||
+ ' if (sl) { sl.classList.add("is-active"); }\n'
|
||||
+ ' function setIframeSrcdoc(iframe, slideHTML) {\n'
|
||||
+ ' var doc = SRCDOC_TPL.replace("%%SLIDE_HTML%%", slideHTML);\n'
|
||||
+ ' iframe.srcdoc = doc;\n'
|
||||
+ ' }\n'
|
||||
+ '\n'
|
||||
+ ' var endEl = null;\n'
|
||||
+ ' function update(n) {\n'
|
||||
+ ' n = Math.max(0, Math.min(total - 1, n));\n'
|
||||
+ ' idx = n;\n'
|
||||
+ ' /* Current slide */\n'
|
||||
+ ' renderSlide(curInner, slideData[n].html);\n'
|
||||
+ ' /* Current slide — render in iframe */\n'
|
||||
+ ' setIframeSrcdoc(iframeCur, slideData[n].html);\n'
|
||||
+ ' /* Next slide */\n'
|
||||
+ ' var existingEnd = nxtInner.parentElement.querySelector(".pv-next-end");\n'
|
||||
+ ' if (n + 1 < total) {\n'
|
||||
+ ' if (existingEnd) existingEnd.remove();\n'
|
||||
+ ' nxtInner.style.display = "";\n'
|
||||
+ ' renderSlide(nxtInner, slideData[n + 1].html);\n'
|
||||
+ ' iframeNxt.style.display = "";\n'
|
||||
+ ' if (endEl) { endEl.remove(); endEl = null; }\n'
|
||||
+ ' setIframeSrcdoc(iframeNxt, slideData[n + 1].html);\n'
|
||||
+ ' } else {\n'
|
||||
+ ' nxtInner.style.display = "none";\n'
|
||||
+ ' if (!existingEnd) {\n'
|
||||
+ ' var end = document.createElement("div");\n'
|
||||
+ ' end.className = "pv-next-end";\n'
|
||||
+ ' end.textContent = "— END —";\n'
|
||||
+ ' nxtInner.parentElement.appendChild(end);\n'
|
||||
+ ' iframeNxt.style.display = "none";\n'
|
||||
+ ' if (!endEl) {\n'
|
||||
+ ' endEl = document.createElement("div");\n'
|
||||
+ ' endEl.className = "pv-next-end";\n'
|
||||
+ ' endEl.textContent = "— END —";\n'
|
||||
+ ' document.getElementById("pv-next").appendChild(endEl);\n'
|
||||
+ ' }\n'
|
||||
+ ' }\n'
|
||||
+ ' /* Notes */\n'
|
||||
+ ' pvNotes.innerHTML = slideData[n].notes || "<span class=\\"pv-empty\\">(这一页还没有逐字稿)</span>";\n'
|
||||
+ ' pvCount.textContent = (n + 1) + " / " + total;\n'
|
||||
+ ' pvTitle.textContent = slideData[n].title;\n'
|
||||
+ ' /* Recompute scale */\n'
|
||||
+ ' reScale();\n'
|
||||
+ ' }\n'
|
||||
+ '\n'
|
||||
+ ' function reScale() {\n'
|
||||
+ ' var cs = fitScale(document.getElementById("pv-current"));\n'
|
||||
+ ' curInner.style.transform = "scale(" + cs + ")";\n'
|
||||
+ ' iframeCur.style.transform = "scale(" + cs + ")";\n'
|
||||
+ ' var ns = fitScale(document.getElementById("pv-next"));\n'
|
||||
+ ' nxtInner.style.transform = "scale(" + ns + ")";\n'
|
||||
+ ' iframeNxt.style.transform = "scale(" + ns + ")";\n'
|
||||
+ ' }\n'
|
||||
+ '\n'
|
||||
+ ' /* Sync from audience window */\n'
|
||||
+ ' if (bc) {\n'
|
||||
+ ' bc.onmessage = function(e) {\n'
|
||||
+ ' if (e.data && e.data.type === "go") update(e.data.idx);\n'
|
||||
+ ' };\n'
|
||||
+ ' }\n'
|
||||
+ '\n'
|
||||
+ ' /* Keyboard in presenter window */\n'
|
||||
+ ' function go(n) {\n'
|
||||
+ ' update(n);\n'
|
||||
+ ' if (bc) bc.postMessage({ type: "go", idx: idx });\n'
|
||||
|
|
@ -346,7 +352,6 @@
|
|||
+ ' });\n'
|
||||
+ '\n'
|
||||
+ ' window.addEventListener("resize", reScale);\n'
|
||||
+ ' /* Initial render */\n'
|
||||
+ ' setTimeout(function(){ update(idx); }, 100);\n'
|
||||
+ '})();\n'
|
||||
+ '</' + 'script>\n'
|
||||
|
|
|
|||
Loading…
Reference in New Issue