Skip to content

Commit 9475b98

Browse files
authored
fix(web): make deck thumbnails drive deck-stage previews (#6950)
* fix(web): sync deck-stage thumbnail navigation * fix(web): keep prototype annotations out of deck navigation * fix(web): distinguish legacy deck slides from annotations
1 parent 5a20c5a commit 9475b98

15 files changed

Lines changed: 828 additions & 34 deletions

apps/web/src/components/FileViewer.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ import {
154154
planDeckImageCapture,
155155
requestPreviewSnapshot,
156156
sourceLooksLikeExportableDeck,
157+
sourceLooksLikeNavigableDeck,
157158
type ExportProgress,
158159
type ImageExportFormat,
159160
} from '../runtime/exports';
@@ -3242,7 +3243,7 @@ function sourceLooksLikeDeckPreview(source: string | null | undefined): boolean
32423243
if (!source) return false;
32433244
return (
32443245
/class\s*=\s*['"](?:[^'"]*\s)?slide(?:\s|['"])/i.test(source) ||
3245-
sourceLooksLikeExportableDeck(source)
3246+
sourceLooksLikeNavigableDeck(source)
32463247
);
32473248
}
32483249

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import {
2+
DECK_LEGACY_SCREEN_SLIDE_SELECTOR,
3+
legacyDeckScreenNumber,
4+
} from '@open-design/contracts/runtime/deck-stage-fallback';
5+
6+
const VOID_ELEMENTS = new Set([
7+
'area',
8+
'base',
9+
'br',
10+
'col',
11+
'embed',
12+
'hr',
13+
'img',
14+
'input',
15+
'link',
16+
'meta',
17+
'param',
18+
'source',
19+
'track',
20+
'wbr',
21+
]);
22+
23+
function hasDistinctScreenNumbers(elements: Element[]): boolean {
24+
const numbers = new Set<number>();
25+
for (const element of elements) {
26+
const number = legacyDeckScreenNumber(element.getAttribute('data-screen-label'));
27+
if (number !== null) numbers.add(number);
28+
}
29+
return numbers.size > 1;
30+
}
31+
32+
/**
33+
* Find the compatibility-only shape used by older containerless decks. A
34+
* collection is valid only when numbered, page-like sections are direct
35+
* siblings; arbitrary annotation nodes elsewhere in a prototype never join it.
36+
*/
37+
export function collectLegacyDeckScreenSlides(root: ParentNode): Element[] {
38+
const groups = new Map<ParentNode, Element[]>();
39+
for (const element of root.querySelectorAll(DECK_LEGACY_SCREEN_SLIDE_SELECTOR)) {
40+
if (legacyDeckScreenNumber(element.getAttribute('data-screen-label')) === null) continue;
41+
const parent = element.parentNode;
42+
if (!parent) continue;
43+
const group = groups.get(parent);
44+
if (group) group.push(element);
45+
else groups.set(parent, [element]);
46+
}
47+
48+
let best: Element[] = [];
49+
for (const group of groups.values()) {
50+
if (group.length > best.length && hasDistinctScreenNumbers(group)) best = group;
51+
}
52+
return best;
53+
}
54+
55+
function screenLabelFromTag(tag: string): string | null {
56+
const match = /\bdata-screen-label\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/i.exec(tag);
57+
return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
58+
}
59+
60+
/**
61+
* SSR-safe source equivalent of collectLegacyDeckScreenSlides. This deliberately
62+
* tokenizes only enough HTML to preserve direct-parent identity; scripts,
63+
* styles, and comments are removed first so markup-looking strings cannot
64+
* manufacture slides.
65+
*/
66+
export function sourceHasLegacyDeckScreenSlides(source: string): boolean {
67+
const sanitized = source
68+
.replace(/<!--[\s\S]*?-->/g, '')
69+
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, '');
70+
const tagPattern = /<\s*(\/?)\s*([a-z][\w:-]*)\b[^>]*>/gi;
71+
const stack: Array<{ id: number; tag: string }> = [];
72+
const groups = new Map<number, Set<number>>();
73+
let nextId = 1;
74+
let match: RegExpExecArray | null;
75+
76+
while ((match = tagPattern.exec(sanitized))) {
77+
const closing = match[1] === '/';
78+
const tagName = match[2]!.toLowerCase();
79+
const tag = match[0];
80+
if (closing) {
81+
for (let index = stack.length - 1; index >= 0; index -= 1) {
82+
if (stack[index]!.tag !== tagName) continue;
83+
stack.length = index;
84+
break;
85+
}
86+
continue;
87+
}
88+
89+
const parentId = stack.at(-1)?.id ?? 0;
90+
if (tagName === 'section') {
91+
const number = legacyDeckScreenNumber(screenLabelFromTag(tag));
92+
if (number !== null) {
93+
const numbers = groups.get(parentId) ?? new Set<number>();
94+
numbers.add(number);
95+
groups.set(parentId, numbers);
96+
if (numbers.size > 1) return true;
97+
}
98+
}
99+
100+
if (!VOID_ELEMENTS.has(tagName) && !/\/\s*>$/.test(tag)) {
101+
stack.push({ id: nextId, tag: tagName });
102+
nextId += 1;
103+
}
104+
}
105+
106+
return false;
107+
}

apps/web/src/runtime/deck-thumbnail-parser.ts

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@
1919

2020
import DOMPurify from 'dompurify';
2121

22-
import { DECK_SLIDE_SELECTOR } from '@open-design/contracts/runtime/deck-stage-fallback';
22+
import {
23+
DECK_EXPLICIT_SLIDE_SELECTOR,
24+
DECK_SLIDE_SELECTOR,
25+
DECK_STRUCTURED_SLIDE_SELECTOR,
26+
} from '@open-design/contracts/runtime/deck-stage-fallback';
27+
import { collectLegacyDeckScreenSlides } from './deck-slide-structure';
2328

2429
export type DeckThumbnailFallbackReason =
2530
| 'no-dom-parser'
@@ -59,16 +64,6 @@ const DEFAULT_DESIGN_WIDTH = 1920;
5964
const DEFAULT_DESIGN_HEIGHT = 1080;
6065
const MAX_SLIDES = 200;
6166

62-
// Structured-first slide detection, mirroring the deck bridge's `slides()` in
63-
// srcdoc.ts: prefer slides that are direct children of a recognized stage so
64-
// decorative `.slide` markup elsewhere isn't miscounted, then fall back to the
65-
// shared selector.
66-
const STRUCTURED_SLIDE_SELECTOR =
67-
'deck-stage > .slide, .deck > .slide, .deck-stage > .slide, .deck-shell > .slide, ' +
68-
'#deck > .slide, body > .slide, ' +
69-
'deck-stage > [data-screen-label], .deck-stage > [data-screen-label], ' +
70-
'#deck > [data-screen-label], body > [data-screen-label]';
71-
7267
const FONT_HOSTS = new Set([
7368
'fonts.googleapis.com',
7469
'fonts.gstatic.com',
@@ -205,9 +200,18 @@ function rewriteViewportUnits(css: string, width: number, height: number): strin
205200
}
206201

207202
function collectSlideElements(doc: Document): Element[] {
208-
const structured = Array.from(doc.querySelectorAll(STRUCTURED_SLIDE_SELECTOR));
203+
const deckStage = doc.querySelector('deck-stage');
204+
if (deckStage) {
205+
const nested = Array.from(deckStage.querySelectorAll(DECK_SLIDE_SELECTOR));
206+
const direct = nested.filter((slide) => slide.parentElement === deckStage);
207+
if (direct.length > 0) return direct;
208+
if (nested.length > 0) return nested;
209+
}
210+
const structured = Array.from(doc.querySelectorAll(DECK_STRUCTURED_SLIDE_SELECTOR));
209211
if (structured.length > 0) return structured;
210-
return Array.from(doc.querySelectorAll(DECK_SLIDE_SELECTOR));
212+
const explicit = Array.from(doc.querySelectorAll(DECK_EXPLICIT_SLIDE_SELECTOR));
213+
if (explicit.length > 0) return explicit;
214+
return collectLegacyDeckScreenSlides(doc);
211215
}
212216

213217
// Walk from the slide's parent up to (but excluding) <body>/<html>, so

apps/web/src/runtime/exports.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
workspaceProjectHeaders,
2525
workspaceResourceUrl,
2626
} from '../collab/workspace-identity';
27+
import { sourceHasLegacyDeckScreenSlides } from './deck-slide-structure';
2728

2829
// Re-exported so app components can gate desktop-only export paths without
2930
// importing the host package directly.
@@ -1010,10 +1011,9 @@ export async function exportProjectAsPptx(opts: {
10101011
// structure such as `data-title` or a `.deck` wrapper. Deliberately DO NOT treat
10111012
// a plain `.slide` class as proof of a deck: ordinary pages often use that token
10121013
// for carousels/testimonials and still need full-page/scroll-stitch capture.
1013-
export function sourceLooksLikeExportableDeck(source: string | null | undefined): boolean {
1014-
if (!source) return false;
1014+
function sourceLooksLikeStructuredDeck(source: string): boolean {
10151015
return (
1016-
/<deck-stage[\s/>]|\bdata-screen-label\s*=|class\s*=\s*['"](?:[^'"]*\s)?(?:deck-slide|ppt-slide)(?:\s|['"])/i.test(
1016+
/<deck-stage[\s/>]|class\s*=\s*['"](?:[^'"]*\s)?(?:deck-slide|ppt-slide)(?:\s|['"])/i.test(
10171017
source,
10181018
) ||
10191019
/<[^>]*\bclass\s*=\s*['"](?:[^'"]*\s)?slide(?:\s|['"])[^>]*\bdata-title\s*=|<[^>]*\bdata-title\s*=[^>]*\bclass\s*=\s*['"](?:[^'"]*\s)?slide(?:\s|['"])/i.test(
@@ -1025,6 +1025,23 @@ export function sourceLooksLikeExportableDeck(source: string | null | undefined)
10251025
);
10261026
}
10271027

1028+
export function sourceLooksLikeExportableDeck(source: string | null | undefined): boolean {
1029+
if (!source) return false;
1030+
return sourceLooksLikeStructuredDeck(source) || /\bdata-screen-label\s*=/i.test(source);
1031+
}
1032+
1033+
/**
1034+
* Viewer navigation needs stronger evidence than export. `data-screen-label`
1035+
* is shared with ordinary prototype annotations, so only explicit deck
1036+
* structure or a numbered sibling collection of legacy slide sections may
1037+
* turn the live preview into deck mode.
1038+
*/
1039+
export function sourceLooksLikeNavigableDeck(source: string | null | undefined): boolean {
1040+
if (!source) return false;
1041+
if (sourceLooksLikeStructuredDeck(source)) return true;
1042+
return sourceHasLegacyDeckScreenSlides(source);
1043+
}
1044+
10281045
// Decides how a current-slide / whole-deck / page image capture should run.
10291046
// The off-screen renderer needs a concrete slide `index` for a CURRENT-slide
10301047
// capture (Copy screenshot / annotation), but we only know the active slide when

apps/web/src/runtime/srcdoc.ts

Lines changed: 112 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,14 @@
1414
* { type: 'od:slide-state', active: number, count: number }
1515
* after every navigation so the host can render its own counter / dots.
1616
*/
17-
import { injectDeckStageFallback } from '@open-design/contracts/runtime/deck-stage-fallback';
17+
import {
18+
DECK_EXPLICIT_SLIDE_SELECTOR,
19+
DECK_LEGACY_SCREEN_LABEL_RE_SOURCE,
20+
DECK_LEGACY_SCREEN_SLIDE_SELECTOR,
21+
DECK_SLIDE_SELECTOR,
22+
DECK_STRUCTURED_SLIDE_SELECTOR,
23+
injectDeckStageFallback,
24+
} from '@open-design/contracts/runtime/deck-stage-fallback';
1825
import { buildPreviewObservabilityBridge } from '@open-design/contracts/runtime/preview-observability';
1926

2027
import {
@@ -2882,15 +2889,97 @@ function injectDeckBridge(
28822889
}, true);
28832890
}
28842891
window.__odDeckBridgeOwnListenerInstall = false;
2892+
function deckStageSlides(stage){
2893+
if (!stage) return [];
2894+
var nested = stage.querySelectorAll(${JSON.stringify(DECK_SLIDE_SELECTOR)});
2895+
var direct = [];
2896+
for (var i = 0; i < nested.length; i++) {
2897+
if (nested[i].parentElement === stage) direct.push(nested[i]);
2898+
}
2899+
return direct.length ? direct : nested;
2900+
}
2901+
function legacyScreenSlides(){
2902+
var candidates = document.querySelectorAll(${JSON.stringify(DECK_LEGACY_SCREEN_SLIDE_SELECTOR)});
2903+
var labelRe = new RegExp(${JSON.stringify(DECK_LEGACY_SCREEN_LABEL_RE_SOURCE)}, 'i');
2904+
var groups = [];
2905+
for (var i = 0; i < candidates.length; i++) {
2906+
var match = labelRe.exec(candidates[i].getAttribute('data-screen-label') || '');
2907+
if (!match || !candidates[i].parentNode) continue;
2908+
var group = null;
2909+
for (var j = 0; j < groups.length; j++) {
2910+
if (groups[j].parent === candidates[i].parentNode) { group = groups[j]; break; }
2911+
}
2912+
if (!group) {
2913+
group = { parent: candidates[i].parentNode, slides: [], numbers: {} };
2914+
groups.push(group);
2915+
}
2916+
group.slides.push(candidates[i]);
2917+
group.numbers[String(Number(match[1]))] = true;
2918+
}
2919+
var best = [];
2920+
for (var k = 0; k < groups.length; k++) {
2921+
if (Object.keys(groups[k].numbers).length > 1 && groups[k].slides.length > best.length) {
2922+
best = groups[k].slides;
2923+
}
2924+
}
2925+
return best;
2926+
}
28852927
function slides(){
2886-
// Structured selectors first so decorative .slide markup in non-deck
2887-
// pages (icons, badges, code samples) is not counted as deck slides;
2888-
// fall back to all .slide only when nothing structured matched, so
2889-
// freeform decks that nest slides under an extra wrapper still report
2890-
// the real count instead of leaving the host counter at 1 / 0.
2891-
var structured = document.querySelectorAll('deck-stage > .slide, .deck > .slide, .deck-stage > .slide, .deck-shell > .slide, body > .slide');
2928+
// An explicit deck-stage owns its descendants, so unrelated annotated
2929+
// screens elsewhere in the document cannot leak into its count. Otherwise
2930+
// prefer direct children of recognized legacy containers before falling
2931+
// back to every supported persisted slide marker.
2932+
var stageSlides = deckStageSlides(document.querySelector('deck-stage'));
2933+
if (stageSlides.length) return stageSlides;
2934+
var structured = document.querySelectorAll(${JSON.stringify(DECK_STRUCTURED_SLIDE_SELECTOR)});
28922935
if (structured.length) return structured;
2893-
return document.querySelectorAll('.slide');
2936+
var explicit = document.querySelectorAll(${JSON.stringify(DECK_EXPLICIT_SLIDE_SELECTOR)});
2937+
if (explicit.length) return explicit;
2938+
return legacyScreenSlides();
2939+
}
2940+
function deckStageForSlides(list){
2941+
var stage = document.querySelector('deck-stage');
2942+
if (!stage) return null;
2943+
if (list && list.length && !stage.contains(list[0])) return null;
2944+
return stage;
2945+
}
2946+
function activeIndexFromDeckStage(list){
2947+
var stage = deckStageForSlides(list);
2948+
if (!stage) return -1;
2949+
var index = Number(stage.index);
2950+
if (!Number.isFinite(index)) return -1;
2951+
index = Math.floor(index);
2952+
return index >= 0 && index < list.length ? index : -1;
2953+
}
2954+
function navigateViaDeckStage(list, action, index){
2955+
if (!list.length) return false;
2956+
var stage = deckStageForSlides(list);
2957+
if (!stage) return false;
2958+
try {
2959+
if (action === 'go' && typeof index === 'number' && typeof stage.goTo === 'function') {
2960+
stage.goTo(index);
2961+
} else if (action === 'next' && typeof stage.next === 'function') {
2962+
stage.next();
2963+
} else if (action === 'prev' && typeof stage.prev === 'function') {
2964+
stage.prev();
2965+
} else if (action === 'first' && typeof stage.reset === 'function') {
2966+
stage.reset();
2967+
} else if (action === 'first' && typeof stage.goTo === 'function') {
2968+
stage.goTo(0);
2969+
} else if (action === 'last' && typeof stage.goTo === 'function') {
2970+
stage.goTo(list.length - 1);
2971+
} else {
2972+
return false;
2973+
}
2974+
// Public deck-stage methods are synchronous in the authored runtime.
2975+
// Report here for older implementations that do not emit slidechange;
2976+
// modern implementations also emit the event and are harmlessly
2977+
// coalesced by React state equality in the host.
2978+
report();
2979+
return true;
2980+
} catch (_) {
2981+
return false;
2982+
}
28942983
}
28952984
function scrollOverflow(el){
28962985
if (!el) return 0;
@@ -2989,6 +3078,8 @@ function injectDeckBridge(
29893078
var w = Math.max(1, window.innerWidth);
29903079
return Math.max(0, Math.min(list.length - 1, Math.round(maxScrollLeft() / w)));
29913080
}
3081+
var byDeckStage = activeIndexFromDeckStage(list);
3082+
if (byDeckStage >= 0) return byDeckStage;
29923083
var byTransform = activeIndexFromTransform(list);
29933084
if (byTransform >= 0) return byTransform;
29943085
var byClass = findActiveByClass(list);
@@ -3287,6 +3378,7 @@ function injectDeckBridge(
32873378
function go(action){
32883379
var list = slides();
32893380
if (!list.length) return;
3381+
if (navigateViaDeckStage(list, action)) return;
32903382
if (isScrollDeck()) {
32913383
scrollGo(Math.max(0, Math.min(list.length - 1, targetFor(action, list))));
32923384
return;
@@ -3307,6 +3399,7 @@ function injectDeckBridge(
33073399
var list = slides();
33083400
if (!list.length) return;
33093401
var target = Math.max(0, Math.min(list.length - 1, i));
3402+
if (navigateViaDeckStage(list, 'go', target)) return;
33103403
if (isScrollDeck()) { scrollGo(target); return; }
33113404
if (activeIndex(list) === target) { report(); return; }
33123405
stepToIndexViaKeys(target, function(stepped){
@@ -3390,6 +3483,17 @@ function injectDeckBridge(
33903483
}
33913484
} catch (e) {}
33923485
}
3486+
// Runtime-managed decks can also move themselves through their own tap
3487+
// zones, keyboard handler, or public API. Normalize both generations of
3488+
// deck-stage state notification back into the host-owned slide protocol.
3489+
document.addEventListener('slidechange', function(ev){
3490+
var stage = document.querySelector('deck-stage');
3491+
if (stage && ev.target === stage) report();
3492+
});
3493+
window.addEventListener('message', function(ev){
3494+
var data = ev && ev.data;
3495+
if (data && typeof data.slideIndexChanged === 'number') report();
3496+
});
33933497
window.__odDeckSlideState = function(){
33943498
var list = slides();
33953499
return { active: activeIndex(list), count: list.length };

apps/web/tests/components/FileViewer.test.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6003,6 +6003,12 @@ describe('FileViewer SVG artifacts', () => {
60036003
'slides.html',
60046004
'<section class="slide">A</section><section class="slide">B</section>',
60056005
).deck).toBe(true);
6006+
6007+
expect(fileVersionPreviewOptions(
6008+
'project-1',
6009+
'prototype.html',
6010+
'<main><h1 data-screen-label="Hero title">Prototype</h1></main>',
6011+
).deck).toBe(false);
60066012
});
60076013

60086014
it('routes history deck arrow keys to the preview unless a text input is focused', async () => {

0 commit comments

Comments
 (0)