fix(web): preserve deck preview pagination per file (#119)

Generated-By: looper 0.2.7 (runner=worker, agent=codex)
This commit is contained in:
Siri-Ray 2026-05-02 11:13:49 +08:00 committed by GitHub
parent 4e3d82cdcd
commit 85032f530c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 57 additions and 11 deletions

View file

@ -22,6 +22,9 @@ import type { DeployConfigResponse, DeployProjectFileResponse, ProjectFile } fro
import { Icon } from './Icon';
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
type SlideState = { active: number; count: number };
const htmlPreviewSlideState = new Map<string, SlideState>();
interface Props {
projectId: string;
@ -244,10 +247,13 @@ function HtmlViewer({
const [teamSlug, setTeamSlug] = useState('');
const [inTabPresent, setInTabPresent] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const previewStateKey = `${projectId}:${file.name}`;
// Slide deck nav state: the iframe posts the active index + total count
// back to the host every time a slide settles. Host renders prev/next
// controls in the toolbar and reflects the count beside them.
const [slideState, setSlideState] = useState<{ active: number; count: number } | null>(null);
const [slideState, setSlideState] = useState<SlideState | null>(
() => htmlPreviewSlideState.get(previewStateKey) ?? null,
);
const previewBodyRef = useRef<HTMLDivElement | null>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const shareRef = useRef<HTMLDivElement | null>(null);
@ -313,8 +319,9 @@ function HtmlViewer({
() => (previewSource ? buildSrcdoc(previewSource, {
deck: effectiveDeck,
baseHref: projectRawUrl(projectId, baseDirFor(file.name)),
initialSlideIndex: htmlPreviewSlideState.get(previewStateKey)?.active ?? 0,
}) : ''),
[previewSource, effectiveDeck, projectId, file.name],
[previewSource, effectiveDeck, projectId, file.name, previewStateKey],
);
useEffect(() => {
@ -322,17 +329,21 @@ function HtmlViewer({
setSlideState(null);
return;
}
setSlideState(htmlPreviewSlideState.get(previewStateKey) ?? null);
function onMessage(ev: MessageEvent) {
if (ev.source !== iframeRef.current?.contentWindow) return;
const data = ev?.data as
| { type?: string; active?: number; count?: number }
| null;
if (!data || data.type !== 'od:slide-state') return;
if (typeof data.active !== 'number' || typeof data.count !== 'number') return;
setSlideState({ active: data.active, count: data.count });
const next = { active: data.active, count: data.count };
htmlPreviewSlideState.set(previewStateKey, next);
setSlideState(next);
}
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [effectiveDeck]);
}, [effectiveDeck, previewStateKey]);
function postSlide(action: 'next' | 'prev' | 'first' | 'last') {
const win = iframeRef.current?.contentWindow;

View file

@ -1,8 +1,31 @@
import { describe, expect, it } from 'vitest';
import { buildSrcdoc } from './srcdoc';
describe('buildSrcdoc deck bridge', () => {
const deckHtml = `<!doctype html>
<html>
<head><title>Deck</title></head>
<body>
<section class="slide active">One</section>
<section class="slide">Two</section>
<section class="slide">Three</section>
</body>
</html>`;
describe('buildSrcdoc', () => {
it('injects an initial slide index for deck previews', () => {
const doc = buildSrcdoc(deckHtml, { deck: true, initialSlideIndex: 2 });
expect(doc).toContain('var initialSlideIndex = 2;');
expect(doc).toContain('setTimeout(restoreInitialSlide, 200)');
expect(doc).toContain('setTimeout(restoreInitialSlide, 100)');
});
it('clamps invalid initial slide indices before injecting deck bridge script', () => {
const doc = buildSrcdoc(deckHtml, { deck: true, initialSlideIndex: -4 });
expect(doc).toContain('var initialSlideIndex = 0;');
});
it('only uses directly mutable slide conventions for setActive support', () => {
const srcdoc = buildSrcdoc(
'<section class="slide">One</section><section class="slide">Two</section>',

View file

@ -16,7 +16,7 @@
*/
export function buildSrcdoc(
html: string,
options: { deck?: boolean; baseHref?: string } = {}
options: { deck?: boolean; baseHref?: string; initialSlideIndex?: number } = {}
): string {
const head = html.trimStart().slice(0, 64).toLowerCase();
const isFullDoc = head.startsWith("<!doctype") || head.startsWith("<html");
@ -33,7 +33,7 @@ export function buildSrcdoc(
const withBase = options.baseHref ? injectBaseHref(wrapped, options.baseHref) : wrapped;
const withShim = injectSandboxShim(withBase);
if (!options.deck) return withShim;
return injectDeckBridge(withShim);
return injectDeckBridge(withShim, options.initialSlideIndex);
}
function injectBaseHref(doc: string, baseHref: string): string {
@ -119,7 +119,10 @@ function injectSandboxShim(doc: string): string {
// the scaled canvas ends up offset toward the bottom-right of any
// preview that's smaller than 1920x1080 — exactly what users see in the
// sandbox iframe. `place-content: center` centers the track itself.
function injectDeckBridge(doc: string): string {
function injectDeckBridge(doc: string, initialSlideIndex = 0): string {
const safeInitialSlideIndex = Number.isFinite(initialSlideIndex)
? Math.max(0, Math.floor(initialSlideIndex))
: 0;
const styleFix = `<style data-od-deck-fix>
.stage, .deck-stage, .deck-shell { place-content: center !important; }
</style>`;
@ -130,6 +133,8 @@ function injectDeckBridge(doc: string): string {
: styleFix + doc;
doc = docWithStyle;
const script = `<script>(function(){
var initialSlideIndex = ${safeInitialSlideIndex};
var didRestoreInitialSlide = initialSlideIndex <= 0;
function slides(){ return document.querySelectorAll('.slide'); }
function scroller(){
if (document.body && document.body.scrollWidth > document.body.clientWidth + 1) return document.body;
@ -292,6 +297,13 @@ function injectDeckBridge(doc: string): string {
}, '*');
} catch (e) {}
}
function restoreInitialSlide(){
if (didRestoreInitialSlide) { report(); return; }
var list = slides();
if (!list.length) return;
didRestoreInitialSlide = true;
gotoIndex(initialSlideIndex);
}
window.addEventListener('message', function(ev){
var data = ev && ev.data;
if (!data || data.type !== 'od:slide') return;
@ -311,7 +323,7 @@ function injectDeckBridge(doc: string): string {
ownDeckButton('deck-prev', 'prev');
ownDeckButton('deck-next', 'next');
// Report once on load and on every scroll-end so the host stays in sync.
window.addEventListener('load', function(){ setTimeout(report, 200); });
window.addEventListener('load', function(){ setTimeout(restoreInitialSlide, 200); });
document.addEventListener('scroll', function(){
clearTimeout(window.__odReportT);
window.__odReportT = setTimeout(report, 120);
@ -365,7 +377,7 @@ function injectDeckBridge(doc: string): string {
mo.observe(list[i], { attributes: true, attributeFilter: ['class', 'style', 'hidden', 'aria-hidden'] });
}
} catch (e) {}
setTimeout(report, 100);
setTimeout(restoreInitialSlide, 100);
}
observeSlides();
})();</script>`;