Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/src/components/DeckSlideThumbnail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffec
// - `.overlay/.tapzones`: template decks (`deck-stage.js`) name their nav this
// way — belt-and-suspenders on top of DECK_CHROME_HIDE_CSS.
const THUMB_OVERRIDE_CSS = `[data-od-thumb-wrap]{display:block!important;position:absolute!important;inset:0!important;width:100%!important;height:100%!important;margin:0!important;padding:0!important;transform:none!important;box-shadow:none!important;visibility:visible!important;opacity:1!important;}
[data-od-thumb-slide]{display:block!important;position:absolute!important;inset:0!important;margin:0!important;visibility:visible!important;opacity:1!important;pointer-events:none!important;}
[data-od-thumb-slide]{position:absolute!important;inset:0!important;margin:0!important;visibility:visible!important;opacity:1!important;pointer-events:none!important;}
.overlay,.tapzones{display:none!important;visibility:hidden!important;pointer-events:none!important;}`;

interface DeckCssEntry {
Expand Down
100 changes: 93 additions & 7 deletions apps/web/src/components/DeckThumbnailRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,16 @@
// deck animations settle at their final frame instead of keeping N
// compositor layers rasterizing forever.

import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type RefObject,
} from 'react';
import { useT } from '../i18n';
import { useInView } from './plugins-home/useInView';
import { DeckSlideThumbnail } from './DeckSlideThumbnail';
Expand All @@ -35,6 +44,71 @@ import type { ParsedDeckThumbnails } from '../runtime/deck-thumbnail-parser';
*/
export const MOUNTED_THUMBNAIL_CAP = 16;

const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;

export interface DeckThumbnailViewport {
width: number;
height: number;
}

interface DeckIframeThumbnailProps {
label: string;
srcDoc: string;
previewViewport?: DeckThumbnailViewport | null;
onReady: () => void;
}

function DeckIframeThumbnail({
label,
srcDoc,
previewViewport,
onReady,
}: DeckIframeThumbnailProps) {
const hostRef = useRef<HTMLDivElement | null>(null);
const frameRef = useRef<HTMLIFrameElement | null>(null);

useIsomorphicLayoutEffect(() => {
const host = hostRef.current;
const frame = frameRef.current;
if (!host || !frame) return;
const apply = () => {
const hostWidth = host.clientWidth;
const hostHeight = host.clientHeight;
if (!hostWidth || !hostHeight) return;
const hasPreviewViewport = !!previewViewport
&& Number.isFinite(previewViewport.width)
&& previewViewport.width > 0
&& Number.isFinite(previewViewport.height)
&& previewViewport.height > 0;
const viewportWidth = hasPreviewViewport ? previewViewport.width : hostWidth * 2;
const viewportHeight = hasPreviewViewport ? previewViewport.height : hostHeight * 2;
const scale = Math.min(hostWidth / viewportWidth, hostHeight / viewportHeight);
frame.style.width = `${viewportWidth}px`;
frame.style.height = `${viewportHeight}px`;
frame.style.transform = `scale(${scale})`;
frame.style.left = `${(hostWidth - viewportWidth * scale) / 2}px`;
frame.style.top = `${(hostHeight - viewportHeight * scale) / 2}px`;
};
apply();
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(apply) : null;
observer?.observe(host);
return () => observer?.disconnect();
}, [previewViewport?.height, previewViewport?.width]);

return (
<div ref={hostRef} className="deck-thumbnail-iframe-host">
<iframe
ref={frameRef}
title={label}
sandbox="allow-scripts allow-downloads"
srcDoc={srcDoc}
tabIndex={-1}
onLoad={onReady}
/>
</div>
);
}

/**
* Pure LRU step for the set of thumbnail indices that keep a live iframe.
*
Expand Down Expand Up @@ -84,6 +158,7 @@ interface DeckThumbnailItemProps {
* full-deck iframe built by `getSrcDoc`.
*/
parsedDeck: ParsedDeckThumbnails | null;
previewViewport?: DeckThumbnailViewport | null;
getSrcDoc: (index: number) => string;
onSelect: (index: number) => void;
onVisibilityChange: (index: number, inView: boolean) => void;
Expand All @@ -96,6 +171,7 @@ const DeckThumbnailItem = memo(function DeckThumbnailItem({
label,
listRef,
parsedDeck,
previewViewport,
getSrcDoc,
onSelect,
onVisibilityChange,
Expand Down Expand Up @@ -133,7 +209,13 @@ const DeckThumbnailItem = memo(function DeckThumbnailItem({
useEffect(() => {
if (!mounted) setReadySource(null);
}, [mounted]);
const handleThumbnailReady = useCallback(() => setReadySource(thumbnailSource), [thumbnailSource]);
// The iframe fallback uses `getSrcDoc` (a function) as its source identity.
// Wrap it so React stores that function instead of invoking it as a state
// updater and leaving the loading cover permanently visible.
const handleThumbnailReady = useCallback(
() => setReadySource(() => thumbnailSource),
[thumbnailSource],
);
const handleShadowError = useCallback(() => {
setReadySource(null);
setShadowFailed(true);
Expand All @@ -159,12 +241,11 @@ const DeckThumbnailItem = memo(function DeckThumbnailItem({
onReady={handleThumbnailReady}
/>
) : (
<iframe
title={label}
sandbox="allow-scripts allow-downloads"
<DeckIframeThumbnail
label={label}
srcDoc={getSrcDoc(index)}
tabIndex={-1}
onLoad={handleThumbnailReady}
previewViewport={previewViewport}
onReady={handleThumbnailReady}
/>
)
) : null}
Expand Down Expand Up @@ -194,6 +275,9 @@ export interface DeckThumbnailRailProps {
* thumbnail uses the iframe fallback (decks we can't statically render).
*/
parsedDeck?: ParsedDeckThumbnails | null;
/** Live preview iframe viewport. Used to make responsive fallback thumbnails
* take the same media-query branch and line-wrap identically. */
previewViewport?: DeckThumbnailViewport | null;
onSelect: (index: number) => void;
}

Expand All @@ -203,6 +287,7 @@ export const DeckThumbnailRail = memo(function DeckThumbnailRail({
labelTotal,
buildThumbSrcDoc,
parsedDeck = null,
previewViewport = null,
onSelect,
}: DeckThumbnailRailProps) {
const t = useT();
Expand Down Expand Up @@ -254,6 +339,7 @@ export const DeckThumbnailRail = memo(function DeckThumbnailRail({
})}
listRef={listRef}
parsedDeck={parsedDeck}
previewViewport={previewViewport}
getSrcDoc={getSrcDoc}
onSelect={onSelect}
onVisibilityChange={onVisibilityChange}
Expand Down
38 changes: 37 additions & 1 deletion apps/web/src/components/FileViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ import {
htmlHasAuthoredBase,
PREVIEW_REDIRECT_LOOP_MESSAGE,
} from '../runtime/srcdoc';
import { DeckThumbnailRail } from './DeckThumbnailRail';
import { DeckThumbnailRail, type DeckThumbnailViewport } from './DeckThumbnailRail';
import { parseDeckThumbnails } from '../runtime/deck-thumbnail-parser';
import {
buildSpeakerNotesPresenterHtml,
Expand Down Expand Up @@ -8241,6 +8241,8 @@ function HtmlViewer({
const [previewBodyRef, previewBodySize] = usePreviewCanvasSize<HTMLDivElement>();
const [commentComposerHost, setCommentComposerHost] = useState<HTMLDivElement | null>(null);
const [commentPreviewCanvasNode, setCommentPreviewCanvasNode] = useState<HTMLDivElement | null>(null);
const [deckThumbnailCanvasSize, setDeckThumbnailCanvasSize] =
useState<DeckThumbnailViewport | null>(null);
// Seed from the cache instead of a cold `null` — see htmlPreviewContentWidthState
// above. A stale seed still self-corrects once a fresh measurement lands.
const previewMeasurementInteractionActive =
Expand Down Expand Up @@ -8441,6 +8443,27 @@ function HtmlViewer({
const setCommentPreviewCanvasRef = useCallback((node: HTMLDivElement | null) => {
setCommentPreviewCanvasNode((current) => (current === node ? current : node));
}, []);
useEffect(() => {
const canvas = commentPreviewCanvasNode;
if (!canvas) {
setDeckThumbnailCanvasSize(null);
return;
}
const measure = () => {
const width = canvas.clientWidth;
const height = canvas.clientHeight;
if (!width || !height) return;
setDeckThumbnailCanvasSize((current) => (
current?.width === width && current.height === height
? current
: { width, height }
));
};
measure();
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measure) : null;
observer?.observe(canvas);
return () => observer?.disconnect();
}, [commentPreviewCanvasNode]);
const requestDesktopPreviewContentMeasure = useCallback((target: HTMLIFrameElement | null = iframeRef.current) => {
if (!workspaceActive) return;
const source = target?.contentWindow;
Expand Down Expand Up @@ -9465,6 +9488,18 @@ function HtmlViewer({
: null,
});
const previewScale = previewZoomPercent / 100;
const deckThumbnailRenderViewport = useMemo(() => {
const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === previewViewport);
if (preset?.width && preset.height) {
return { width: preset.width, height: preset.height };
}
if (!deckThumbnailCanvasSize) return null;
const scale = Number.isFinite(previewScale) && previewScale > 0 ? previewScale : 1;
return {
width: Math.max(1, Math.round(deckThumbnailCanvasSize.width / scale)),
height: Math.max(1, Math.round(deckThumbnailCanvasSize.height / scale)),
};
}, [deckThumbnailCanvasSize, previewScale, previewViewport]);
previewContentMeasurementContextRef.current = {
canvasWidth: boardPreviewCanvasSize?.width ?? 0,
previewScale,
Expand Down Expand Up @@ -15930,6 +15965,7 @@ function HtmlViewer({
labelTotal={deckNavTotal}
buildThumbSrcDoc={buildDeckThumbnailSrcDoc}
parsedDeck={parsedDeckThumbnails}
previewViewport={deckThumbnailRenderViewport}
onSelect={(index) => {
fireDeckViewerClick('thumbnail_select', {
slide_index: index,
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/runtime/deck-thumbnail-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type DeckThumbnailFallbackReason =
| 'no-dom-parser'
| 'no-slides'
| 'no-styles'
| 'viewport-media-query'
| 'external-stylesheet';

/** One reconstructed wrapper element between the shadow root and the slide. */
Expand Down Expand Up @@ -163,6 +164,12 @@ export function parseDeckThumbnails(html: string, baseHref?: string): ParsedDeck
}
const rawStyle = stripCssComments(importedBlocks.map((imported) => imported.css).join('\n'));
if (!rawStyle.trim()) return unrenderable('no-styles');
// A shadow-root thumbnail's @media rules evaluate against the Open Design
// host window, not the preview iframe. A deck can therefore take its desktop
// branch in the rail while the visible preview takes its mobile branch. Keep
// these decks on the isolated iframe fallback, whose viewport is explicitly
// matched to the live preview by DeckThumbnailRail.
if (hasViewportMediaQuery(rawStyle)) return unrenderable('viewport-media-query');

const designSize = resolveDesignSize(doc, rawStyle);

Expand Down Expand Up @@ -195,6 +202,12 @@ export function parseDeckThumbnails(html: string, baseHref?: string): ParsedDeck
}

const VIEWPORT_UNIT_TOKEN_RE = /(-?\d*\.?\d+)\s*(vw|vh|vmin|vmax|svw|svh|lvw|lvh|dvw|dvh)\b/gi;
const VIEWPORT_MEDIA_QUERY_RE =
/@media\s+[^{}]*(?:\b(?:min|max)-(?:width|height)\b|\b(?:width|height|orientation|aspect-ratio)\s*:)[^{}]*\{/i;
Comment thread
lefarcen marked this conversation as resolved.
Outdated
Comment thread
lefarcen marked this conversation as resolved.
Outdated

function hasViewportMediaQuery(css: string): boolean {
return VIEWPORT_MEDIA_QUERY_RE.test(css);
}

// Replace each `<n><viewport-unit>` with `calc(<n> * <k>px)` where `k` is the
// design canvas dimension / 100. Works inside `clamp()`/`min()`/`max()` and
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/styles/viewer/theater.css
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,14 @@
pointer-events: none;
background: #000;
}
.deck-thumbnail-iframe-host {
position: absolute;
inset: 0;
overflow: hidden;
}
.deck-thumbnail-iframe-host iframe {
position: absolute;
}
/* Shadow-root thumbnail host (the default path): the single cloned slide lives
in a shadow root whose `.od-thumb-canvas` is scaled + centered inline by
DeckSlideThumbnail. `contain: strict` isolates each thumbnail's layout/paint
Expand Down
10 changes: 10 additions & 0 deletions apps/web/tests/components/deck-slide-thumbnail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@ describe('DeckSlideThumbnail', () => {
expect(root.querySelector('.deck-counter')).toBeNull();
});

it('does not override the deck slide display layout with block', () => {
const { container } = render(<DeckSlideThumbnail parsed={parsed()} index={0} />);
const root = (container.querySelector('.deck-thumbnail-shadow-host') as HTMLElement).shadowRoot!;
const styleText = Array.from(root.querySelectorAll('style'))
.map((style) => style.textContent ?? '')
.join('\n');

expect(styleText).not.toContain('[data-od-thumb-slide]{display:block!important');
});

it('reconstructs the wrapper chain with data-od-thumb-wrap markers', () => {
const { container } = render(<DeckSlideThumbnail parsed={parsed()} index={0} />);
const root = (container.querySelector('.deck-thumbnail-shadow-host') as HTMLElement).shadowRoot!;
Expand Down
33 changes: 33 additions & 0 deletions apps/web/tests/components/deck-thumbnail-rail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,39 @@ describe('DeckThumbnailRail', () => {
expect(rebuilt).toHaveBeenCalledTimes(3);
});

it('renders iframe fallbacks at the live preview viewport before scaling them down', () => {
const width = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(145);
const height = vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(82);
try {
const { container } = render(
<DeckThumbnailRail
{...railProps({ count: 1, labelTotal: 1 })}
previewViewport={{ width: 716, height: 429 }}
/>,
);

const iframe = container.querySelector('.deck-thumbnail-frame iframe') as HTMLIFrameElement;
expect(iframe.style.width).toBe('716px');
expect(iframe.style.height).toBe('429px');
expect(iframe.style.transform).toMatch(/^scale\(0\.19/);
} finally {
width.mockRestore();
height.mockRestore();
}
});

it('removes the loading cover after an iframe fallback loads', () => {
const { container } = render(
<DeckThumbnailRail {...railProps({ count: 1, labelTotal: 1 })} />,
);
const iframe = container.querySelector('.deck-thumbnail-frame iframe') as HTMLIFrameElement;
expect(container.querySelector('.deck-thumbnail-loading')).toBeTruthy();

fireEvent.load(iframe);

expect(container.querySelector('.deck-thumbnail-loading')).toBeNull();
});

it('reports the clicked slide index and marks the active thumbnail', () => {
const onSelect = vi.fn();
const { container } = render(
Expand Down
31 changes: 31 additions & 0 deletions apps/web/tests/runtime/deck-thumbnail-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,37 @@ describe('parseDeckThumbnails', () => {
expect(parsed.styleText).toContain('width: 100%');
});

it('falls back when viewport media queries would diverge from the preview iframe', () => {
const html = `<!doctype html><html><head><style>
.slide { width: 100vw; height: 100vh; display: flex; }
@media (max-width: 768px) {
.slide { padding: 24px; display: grid; }
}
</style></head><body>
<section class="slide">A</section>
<section class="slide">B</section>
</body></html>`;

const parsed = parseDeckThumbnails(html);

expect(parsed.renderable).toBe(false);
expect(parsed.reason).toBe('viewport-media-query');
});

it('keeps non-viewport media queries on the static thumbnail path', () => {
const html = `<!doctype html><html><head><style>
.slide { width: 1920px; height: 1080px; display: flex; }
@media (prefers-reduced-motion: reduce) {
.slide { animation: none; }
}
</style></head><body>
<section class="slide">A</section>
<section class="slide">B</section>
</body></html>`;

expect(parseDeckThumbnails(html).renderable).toBe(true);
});

it('does not mistake a slide descendant decoration for the design canvas', () => {
const html = `<!doctype html><html><head><style>
body { display: flex; width: 200vw; height: 100vh; }
Expand Down
Loading