@bendyline/squisq-react 1.4.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +30 -3
  2. package/dist/index.d.ts +177 -28
  3. package/dist/index.js +1330 -611
  4. package/dist/index.js.map +1 -1
  5. package/dist/squisq-player.css +1 -1
  6. package/dist/squisq-player.css.map +1 -1
  7. package/dist/squisq-player.global.js +57 -37
  8. package/dist/squisq-player.global.js.map +1 -1
  9. package/dist/standalone-source.js +1 -1
  10. package/dist/styles/index.css +28 -0
  11. package/package.json +2 -2
  12. package/src/BlockRenderer.tsx +54 -17
  13. package/src/DocControlsSlideshow.tsx +222 -5
  14. package/src/DocPlayer.tsx +367 -183
  15. package/src/DocPlayerWithSidebar.tsx +4 -0
  16. package/src/DocProgressBar.tsx +40 -1
  17. package/src/LinearDocView.tsx +138 -62
  18. package/src/MarkdownRenderer.tsx +40 -97
  19. package/src/MediaClipLayer.tsx +12 -2
  20. package/src/__tests__/BlockRenderer.test.tsx +138 -8
  21. package/src/__tests__/DocControlsSlideshow.test.tsx +94 -1
  22. package/src/__tests__/DocPlayer.test.tsx +505 -0
  23. package/src/__tests__/DocProgressBar.test.tsx +28 -2
  24. package/src/__tests__/LinearDocView.test.tsx +104 -11
  25. package/src/__tests__/MapLayer.test.tsx +63 -0
  26. package/src/__tests__/MarkdownRenderer.test.tsx +16 -5
  27. package/src/__tests__/MediaClipLayer.test.tsx +70 -0
  28. package/src/__tests__/MediaContext.test.tsx +51 -0
  29. package/src/__tests__/PathLayer.test.tsx +12 -1
  30. package/src/__tests__/VideoLayer.test.tsx +94 -0
  31. package/src/__tests__/fillStyle.test.tsx +50 -2
  32. package/src/__tests__/standaloneEntry.test.tsx +103 -0
  33. package/src/__tests__/useAudioSync.test.ts +49 -0
  34. package/src/__tests__/useDocPlayback.transition.test.ts +48 -5
  35. package/src/__tests__/useViewportOrientation.test.ts +22 -0
  36. package/src/hooks/MediaContext.tsx +12 -3
  37. package/src/hooks/useAudioSync.ts +61 -12
  38. package/src/hooks/useDocPlayback.ts +40 -12
  39. package/src/hooks/useViewportOrientation.ts +2 -4
  40. package/src/index.ts +5 -2
  41. package/src/layers/ImageLayer.tsx +106 -1
  42. package/src/layers/MapLayer.tsx +7 -6
  43. package/src/layers/PathLayer.tsx +20 -11
  44. package/src/layers/ShapeLayer.tsx +33 -9
  45. package/src/layers/TextLayer.tsx +4 -3
  46. package/src/layers/TreeLayer.tsx +167 -0
  47. package/src/layers/VideoLayer.tsx +20 -6
  48. package/src/standalone-entry.tsx +91 -14
  49. package/src/styles/doc-animations.css +36 -0
  50. package/src/types.ts +13 -13
@@ -158,27 +158,12 @@ function renderInline(
158
158
 
159
159
  case 'htmlInline':
160
160
  if (ctx.htmlPolicy === 'strip') return null;
161
- // Fast path: no <video>/<audio> in the subtree use the original
162
- // rawHtml passthrough (preserves arbitrary HTML for custom embeds).
163
- if (
164
- ctx.htmlPolicy === 'trusted' &&
165
- !containsMediaTag(node.htmlChildren) &&
166
- !containsDangerousTag(node.htmlChildren) &&
167
- !hasDangerousRawHtml(node.rawHtml)
168
- ) {
169
- return (
170
- <span
171
- key={key}
172
- className="squisq-md-html-inline"
173
- dangerouslySetInnerHTML={{ __html: node.rawHtml }}
174
- />
175
- );
176
- }
177
- // Otherwise reconstruct the subtree as React so <video>/<audio>
178
- // go through MediaContext-aware player components.
161
+ // Reconstruct every subtree as React. Besides routing media through
162
+ // MediaContext, this keeps event-handler attributes and unsafe URLs
163
+ // out of the DOM even when the caller selected `trusted` structure.
179
164
  return (
180
165
  <span key={key} className="squisq-md-html-inline">
181
- {renderHtmlNodes(resolveHtmlNodes(node.htmlChildren, ctx.htmlPolicy), `${key}h`)}
166
+ {renderHtmlNodes(resolveHtmlNodes(node.htmlChildren, ctx.htmlPolicy), `${key}h`, ctx)}
182
167
  </span>
183
168
  );
184
169
 
@@ -292,27 +277,11 @@ function renderBlock(
292
277
 
293
278
  case 'htmlBlock':
294
279
  if (ctx.htmlPolicy === 'strip') return null;
295
- // Fast path: no <video>/<audio> preserve the existing rawHtml
296
- // passthrough so arbitrary HTML embeds still survive verbatim.
297
- if (
298
- ctx.htmlPolicy === 'trusted' &&
299
- !containsMediaTag(node.htmlChildren) &&
300
- !containsDangerousTag(node.htmlChildren) &&
301
- !hasDangerousRawHtml(node.rawHtml)
302
- ) {
303
- return (
304
- <div
305
- key={key}
306
- className="squisq-md-html-block"
307
- dangerouslySetInnerHTML={{ __html: node.rawHtml }}
308
- />
309
- );
310
- }
311
- // Otherwise reconstruct subtree as React so <video>/<audio>
312
- // route through the player components and resolve via MediaContext.
280
+ // The structural walker is deliberately the only rendering path: raw
281
+ // HTML strings never bypass the tag, attribute, and URL policy below.
313
282
  return (
314
283
  <div key={key} className="squisq-md-html-block">
315
- {renderHtmlNodes(resolveHtmlNodes(node.htmlChildren, ctx.htmlPolicy), `${key}h`)}
284
+ {renderHtmlNodes(resolveHtmlNodes(node.htmlChildren, ctx.htmlPolicy), `${key}h`, ctx)}
316
285
  </div>
317
286
  );
318
287
 
@@ -467,25 +436,13 @@ function MdImage({ src, alt, title }: { src: string; alt: string; title?: string
467
436
 
468
437
  // ── Raw-HTML walker (intercepts <video>/<audio>) ─────────────────
469
438
 
470
- /** True when the htmlElement subtree contains a tag we want to swap
471
- * for a React component. Cheap recursive scan — lets us keep the
472
- * `dangerouslySetInnerHTML` fast path for everything else. */
439
+ /** Apply the caller's structural HTML policy before React reconstruction. */
473
440
  function resolveHtmlNodes(nodes: HtmlNode[], htmlPolicy: HtmlPolicy): HtmlNode[] {
474
441
  if (htmlPolicy === 'strip') return [];
475
442
  if (htmlPolicy === 'trusted') return nodes;
476
443
  return sanitizeHtmlNodes(nodes);
477
444
  }
478
445
 
479
- function containsMediaTag(nodes: HtmlNode[]): boolean {
480
- for (const node of nodes) {
481
- if (node.type !== 'htmlElement') continue;
482
- const tagName = node.tagName.toLowerCase();
483
- if (tagName === 'video' || tagName === 'audio') return true;
484
- if (containsMediaTag(node.children)) return true;
485
- }
486
- return false;
487
- }
488
-
489
446
  /**
490
447
  * Tags that can escape their container and affect the whole host
491
448
  * document — global styling, script execution, external/resource loads,
@@ -510,41 +467,9 @@ const DANGEROUS_HTML_TAGS = new Set([
510
467
  'title',
511
468
  ]);
512
469
 
513
- /** True when the subtree contains any host-affecting tag (see
514
- * {@link DANGEROUS_HTML_TAGS}). Mirrors {@link containsMediaTag}: keeps
515
- * such content off the verbatim `dangerouslySetInnerHTML` fast path so
516
- * it routes through the React reconstruction, which drops the tag. */
517
- function containsDangerousTag(nodes: HtmlNode[]): boolean {
518
- for (const node of nodes) {
519
- if (node.type !== 'htmlElement') continue;
520
- if (DANGEROUS_HTML_TAGS.has(node.tagName.toLowerCase())) return true;
521
- if (containsDangerousTag(node.children)) return true;
522
- }
523
- return false;
524
- }
525
-
526
- /**
527
- * Raw-string backstop for {@link DANGEROUS_HTML_TAGS}. The structural
528
- * {@link containsDangerousTag} check covers the normal case, but a block
529
- * parsed with `parseHtml: false` carries an empty `htmlChildren` while
530
- * `rawHtml` still holds the markup — so the verbatim fast path scans the
531
- * raw string too, guaranteeing a `<style>`/`<script>` can never be
532
- * injected into the host document by that path no matter how the node
533
- * was produced. The `\b` keeps `<styled-thing>` from matching `<style>`.
534
- */
535
- const DANGEROUS_RAW_HTML_RE =
536
- /<\s*\/?\s*(?:base|embed|iframe|link|meta|object|script|style|title)\b/i;
537
-
538
- function hasDangerousRawHtml(rawHtml: string): boolean {
539
- return DANGEROUS_RAW_HTML_RE.test(rawHtml);
540
- }
541
-
542
470
  /** A pragmatic shortlist of HTML attributes the raw-HTML walker
543
471
  * passes through to React when reconstructing a non-media element.
544
- * Anything outside this list is silently dropped — the media-tag
545
- * fast path means most authors will never hit this code, so we
546
- * keep the surface narrow to avoid React warnings about unknown
547
- * attributes. */
472
+ * Anything outside this list is silently dropped. */
548
473
  const PASSTHROUGH_ATTRS: Record<string, string> = {
549
474
  // common
550
475
  class: 'className',
@@ -564,7 +489,10 @@ const PASSTHROUGH_ATTRS: Record<string, string> = {
564
489
  rel: 'rel',
565
490
  };
566
491
 
567
- function reactPropsFromAttrs(attrs: Record<string, string>): Record<string, unknown> {
492
+ function reactPropsFromAttrs(
493
+ attrs: Record<string, string>,
494
+ ctx: RenderCtx,
495
+ ): Record<string, unknown> {
568
496
  const out: Record<string, unknown> = {};
569
497
  for (const [name, value] of Object.entries(attrs)) {
570
498
  const propName = PASSTHROUGH_ATTRS[name];
@@ -576,26 +504,36 @@ function reactPropsFromAttrs(attrs: Record<string, string>): Record<string, unkn
576
504
  out['data-style'] = value;
577
505
  continue;
578
506
  }
507
+ if (propName === 'href') {
508
+ const href = sanitizeUrl(value, 'link', { extraLinkSchemes: ctx.linkSchemes });
509
+ if (href) out.href = href;
510
+ continue;
511
+ }
512
+ if (propName === 'src') {
513
+ const src = sanitizeUrl(value, 'media');
514
+ if (src) out.src = src;
515
+ continue;
516
+ }
579
517
  out[propName] = value;
580
518
  }
581
519
  return out;
582
520
  }
583
521
 
584
- function renderHtmlElement(el: HtmlElement, key: string): React.ReactNode {
522
+ function renderHtmlElement(el: HtmlElement, key: string, ctx: RenderCtx): React.ReactNode {
585
523
  const tagName = el.tagName.toLowerCase();
586
- // Final safety net: never reconstruct a host-affecting element (e.g. a
587
- // <style> that would leak globally), whatever the policy. The fast path
588
- // is gated by containsDangerousTag, so trusted content carrying these
589
- // tags lands here — drop the tag and keep the rest of the subtree.
524
+ // Never reconstruct a host-affecting element (e.g. a <style> that would
525
+ // leak globally), whatever the policy.
590
526
  if (DANGEROUS_HTML_TAGS.has(tagName)) return null;
591
527
  if (tagName === 'video') {
528
+ const src = sanitizeUrl(el.attributes.src ?? '', 'media') ?? '';
529
+ const poster = sanitizeUrl(el.attributes.poster ?? '', 'media') ?? undefined;
592
530
  return (
593
531
  <InlineVideoPlayer
594
532
  key={key}
595
- src={el.attributes.src ?? ''}
533
+ src={src}
596
534
  width={el.attributes.width}
597
535
  height={el.attributes.height}
598
- poster={el.attributes.poster}
536
+ poster={poster}
599
537
  // The `controls` attribute is a boolean — present means true,
600
538
  // even if its value is an empty string.
601
539
  controls={'controls' in el.attributes}
@@ -610,10 +548,11 @@ function renderHtmlElement(el: HtmlElement, key: string): React.ReactNode {
610
548
  );
611
549
  }
612
550
  if (tagName === 'audio') {
551
+ const src = sanitizeUrl(el.attributes.src ?? '', 'media') ?? '';
613
552
  return (
614
553
  <InlineAudioPlayer
615
554
  key={key}
616
- src={el.attributes.src ?? ''}
555
+ src={src}
617
556
  controls={'controls' in el.attributes}
618
557
  preload={
619
558
  el.attributes.preload === 'none' ||
@@ -627,23 +566,27 @@ function renderHtmlElement(el: HtmlElement, key: string): React.ReactNode {
627
566
  }
628
567
 
629
568
  const Tag = tagName as keyof JSX.IntrinsicElements;
630
- const props = reactPropsFromAttrs(el.attributes);
569
+ const props = reactPropsFromAttrs(el.attributes, ctx);
631
570
  if (el.selfClosing) {
632
571
  return <Tag key={key} {...props} />;
633
572
  }
634
573
  return (
635
574
  <Tag key={key} {...props}>
636
- {renderHtmlNodes(el.children, `${key}c`)}
575
+ {renderHtmlNodes(el.children, `${key}c`, ctx)}
637
576
  </Tag>
638
577
  );
639
578
  }
640
579
 
641
- function renderHtmlNodes(nodes: HtmlNode[], keyPrefix: string): React.ReactNode[] {
580
+ function renderHtmlNodes(
581
+ nodes: HtmlNode[],
582
+ keyPrefix: string,
583
+ ctx: RenderCtx = DEFAULT_CTX,
584
+ ): React.ReactNode[] {
642
585
  return nodes.map((node, i) => {
643
586
  const key = `${keyPrefix}${i}`;
644
587
  switch (node.type) {
645
588
  case 'htmlElement':
646
- return renderHtmlElement(node, key);
589
+ return renderHtmlElement(node, key, ctx);
647
590
  case 'htmlText':
648
591
  return <Fragment key={key}>{node.value}</Fragment>;
649
592
  case 'htmlComment':
@@ -27,6 +27,8 @@ export interface MediaClipLayerProps {
27
27
  isPlaying: boolean;
28
28
  basePath: string;
29
29
  renderMode?: boolean;
30
+ /** Silence every scheduled clip during live playback. */
31
+ muted?: boolean;
30
32
  }
31
33
 
32
34
  export function MediaClipLayer({
@@ -35,6 +37,7 @@ export function MediaClipLayer({
35
37
  isPlaying,
36
38
  basePath,
37
39
  renderMode = false,
40
+ muted = false,
38
41
  }: MediaClipLayerProps) {
39
42
  const { renderClips, activeIds } = useMediaSchedule(schedule, currentTime);
40
43
  if (renderClips.length === 0) return null;
@@ -49,6 +52,7 @@ export function MediaClipLayer({
49
52
  isPlaying={isPlaying}
50
53
  basePath={basePath}
51
54
  renderMode={renderMode}
55
+ muted={muted}
52
56
  />
53
57
  ))}
54
58
  </div>
@@ -62,6 +66,7 @@ interface MediaClipElementProps {
62
66
  isPlaying: boolean;
63
67
  basePath: string;
64
68
  renderMode: boolean;
69
+ muted: boolean;
65
70
  }
66
71
 
67
72
  function MediaClipElement({
@@ -71,6 +76,7 @@ function MediaClipElement({
71
76
  isPlaying,
72
77
  basePath,
73
78
  renderMode,
79
+ muted,
74
80
  }: MediaClipElementProps) {
75
81
  const ref = useRef<HTMLMediaElement | null>(null);
76
82
  const src = useMediaUrl(clip.src, basePath);
@@ -96,7 +102,7 @@ function MediaClipElement({
96
102
  } else {
97
103
  el.pause();
98
104
  }
99
- }, [active, currentTime, isPlaying, renderMode, clip.sourceIn, clip.absoluteStart]);
105
+ }, [active, currentTime, isPlaying, renderMode, clip.sourceIn, clip.absoluteStart, src]);
100
106
 
101
107
  const isVideo = clip.kind === 'video';
102
108
  const common = {
@@ -130,6 +136,10 @@ function MediaClipElement({
130
136
  }
131
137
 
132
138
  return (
133
- <audio {...common} muted={renderMode} style={{ position: 'absolute', width: 0, height: 0 }} />
139
+ <audio
140
+ {...common}
141
+ muted={renderMode || muted}
142
+ style={{ position: 'absolute', width: 0, height: 0 }}
143
+ />
134
144
  );
135
145
  }
@@ -1,15 +1,8 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { render } from '@testing-library/react';
3
- import { BlockRenderer, VIEWPORT } from '../BlockRenderer';
3
+ import { BlockRenderer } from '../BlockRenderer';
4
4
  import type { Block } from '@bendyline/squisq/schemas';
5
5
 
6
- describe('VIEWPORT constant', () => {
7
- it('has correct 1080p dimensions', () => {
8
- expect(VIEWPORT.width).toBe(1920);
9
- expect(VIEWPORT.height).toBe(1080);
10
- });
11
- });
12
-
13
6
  describe('BlockRenderer', () => {
14
7
  const minimalBlock: Block = {
15
8
  id: 'test-block',
@@ -48,6 +41,63 @@ describe('BlockRenderer', () => {
48
41
  expect(svg?.getAttribute('viewBox')).toBe('0 0 1080 1920');
49
42
  });
50
43
 
44
+ it('pans dominant cover images across portrait frames without adding zoom', () => {
45
+ const portraitHero: Block = {
46
+ ...minimalBlock,
47
+ layers: [
48
+ {
49
+ type: 'image',
50
+ id: 'portrait-hero',
51
+ content: { src: 'wide-hero.jpg', alt: 'Wide hero', fit: 'cover' },
52
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
53
+ animation: { type: 'slowZoom', duration: 9, direction: 'in' },
54
+ },
55
+ ],
56
+ };
57
+
58
+ const { container } = render(
59
+ <BlockRenderer
60
+ block={portraitHero}
61
+ blockTime={0}
62
+ basePath="/test"
63
+ viewport={{ width: 1080, height: 1920 }}
64
+ />,
65
+ );
66
+
67
+ const layer = container.querySelector('[data-layer-id="portrait-hero"]');
68
+ const image = layer?.querySelector('img');
69
+ expect(layer?.getAttribute('data-image-framing')).toBe('portrait-pan');
70
+ expect(image?.classList.contains('squisq-image--portrait-pan-right')).toBe(true);
71
+ expect(layer?.classList.contains('anim-slowZoom-in')).toBe(false);
72
+ expect(image?.style.objectFit).toBe('cover');
73
+ expect(image?.style.getPropertyValue('--portrait-pan-duration')).toBe('9s');
74
+ });
75
+
76
+ it('keeps smaller portrait cover tiles static', () => {
77
+ const portraitTile: Block = {
78
+ ...minimalBlock,
79
+ layers: [
80
+ {
81
+ type: 'image',
82
+ id: 'portrait-tile',
83
+ content: { src: 'tile.jpg', alt: 'Tile', fit: 'cover' },
84
+ position: { x: '5%', y: '5%', width: '42%', height: '42%' },
85
+ },
86
+ ],
87
+ };
88
+
89
+ const { container } = render(
90
+ <BlockRenderer
91
+ block={portraitTile}
92
+ blockTime={0}
93
+ basePath="/test"
94
+ viewport={{ width: 1080, height: 1920 }}
95
+ />,
96
+ );
97
+
98
+ expect(container.querySelector('[data-image-framing="portrait-pan"]')).toBeNull();
99
+ });
100
+
51
101
  it('renders text layers', () => {
52
102
  const blockWithText: Block = {
53
103
  id: 'text-block',
@@ -102,4 +152,84 @@ describe('BlockRenderer', () => {
102
152
  const rect = container.querySelector('rect');
103
153
  expect(rect).toBeTruthy();
104
154
  });
155
+
156
+ it('namespaces SVG definitions per renderer instance', () => {
157
+ const { container } = render(
158
+ <>
159
+ <BlockRenderer block={minimalBlock} blockTime={0} basePath="/test" />
160
+ <BlockRenderer block={minimalBlock} blockTime={0} basePath="/test" />
161
+ </>,
162
+ );
163
+ const ids = Array.from(container.querySelectorAll('clipPath')).map((node) => node.id);
164
+ expect(ids).toHaveLength(2);
165
+ expect(new Set(ids).size).toBe(2);
166
+ });
167
+
168
+ it('renders transitions and layer animations by default', () => {
169
+ const animatedBlock: Block = {
170
+ ...minimalBlock,
171
+ transition: { type: 'fade', duration: 0.5 },
172
+ layers: [
173
+ {
174
+ type: 'text',
175
+ id: 'animated-title',
176
+ content: { text: 'Animated', style: { fontSize: 48, color: '#fff' } },
177
+ position: { x: 100, y: 100 },
178
+ animation: { type: 'fadeIn', duration: 1 },
179
+ },
180
+ ],
181
+ };
182
+
183
+ const { container } = render(
184
+ <BlockRenderer block={animatedBlock} blockTime={0} basePath="/test" isEntering />,
185
+ );
186
+
187
+ expect(container.querySelector('svg')?.classList.contains('transition-fade-enter')).toBe(true);
188
+ expect(
189
+ container
190
+ .querySelector('[data-layer-id="animated-title"]')
191
+ ?.classList.contains('anim-fadeIn'),
192
+ ).toBe(true);
193
+ });
194
+
195
+ it('suppresses transitions and every layer animation without mutating the block', () => {
196
+ const animatedBlock: Block = {
197
+ ...minimalBlock,
198
+ transition: { type: 'fade', duration: 0.5 },
199
+ layers: [
200
+ {
201
+ type: 'text',
202
+ id: 'animated-title',
203
+ content: { text: 'Static', style: { fontSize: 48, color: '#fff' } },
204
+ position: { x: 100, y: 100 },
205
+ animation: { type: 'fadeIn', duration: 1 },
206
+ },
207
+ {
208
+ type: 'image',
209
+ id: 'animated-hero',
210
+ content: { src: 'hero.jpg', alt: 'Hero', fit: 'cover' },
211
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
212
+ animation: { type: 'slowZoom', duration: 5 },
213
+ },
214
+ ],
215
+ };
216
+ const originalAnimations = animatedBlock.layers?.map((layer) => layer.animation);
217
+
218
+ const { container } = render(
219
+ <BlockRenderer
220
+ block={animatedBlock}
221
+ blockTime={0}
222
+ basePath="/test"
223
+ isEntering
224
+ animationsEnabled={false}
225
+ viewport={{ width: 1080, height: 1920 }}
226
+ />,
227
+ );
228
+
229
+ expect(container.querySelector('svg')?.className.baseVal).not.toContain('transition-');
230
+ expect(container.querySelector('[class*="anim-"]')).toBeNull();
231
+ expect(container.querySelector('[data-image-framing="portrait-pan"]')).toBeNull();
232
+ expect(animatedBlock.transition).toEqual({ type: 'fade', duration: 0.5 });
233
+ expect(animatedBlock.layers?.map((layer) => layer.animation)).toEqual(originalAnimations);
234
+ });
105
235
  });
@@ -1,8 +1,14 @@
1
- import { describe, it, expect } from 'vitest';
1
+ import { describe, it, expect, vi } from 'vitest';
2
2
  import { render, fireEvent } from '@testing-library/react';
3
3
  import { DocControlsSlideshow } from '../DocControlsSlideshow';
4
4
  import type { PlaybackState, SlideNavActions } from '../types';
5
5
 
6
+ const slides = [
7
+ { id: 'intro', label: '1', summary: 'Introduction' },
8
+ { id: 'results', label: '2', summary: 'Key results' },
9
+ { id: 'next', label: '3', summary: 'What comes next' },
10
+ ];
11
+
6
12
  function makeState(overrides: Partial<PlaybackState> = {}): PlaybackState {
7
13
  return {
8
14
  isPlaying: false,
@@ -49,6 +55,93 @@ describe('DocControlsSlideshow', () => {
49
55
  expect(getByTestId('slide-counter').textContent).toBe('5 / 12');
50
56
  });
51
57
 
58
+ it('opens an upward slide picker with block summaries', () => {
59
+ const { getByTestId, getByRole } = render(
60
+ <DocControlsSlideshow
61
+ state={makeState({ currentBlockIndex: 1, totalBlocks: slides.length })}
62
+ slideNav={makeSlideNav()}
63
+ slides={slides}
64
+ />,
65
+ );
66
+
67
+ fireEvent.click(getByTestId('slide-counter'));
68
+
69
+ const picker = getByRole('menu', { name: 'Choose a slide' });
70
+ expect(picker.style.bottom).toBe('calc(100% + 8px)');
71
+ expect(getByTestId('slide-picker-item-0').textContent).toContain('1Introduction');
72
+ expect(getByTestId('slide-picker-item-1').textContent).toContain('2Key results');
73
+ expect(getByTestId('slide-picker-item-1').getAttribute('aria-current')).toBe('true');
74
+ });
75
+
76
+ it('uses the available player height above the toolbar', () => {
77
+ const { getByTestId } = render(
78
+ <div className="doc-player">
79
+ <DocControlsSlideshow
80
+ state={makeState({ currentBlockIndex: 1, totalBlocks: slides.length })}
81
+ slideNav={makeSlideNav()}
82
+ slides={slides}
83
+ />
84
+ </div>,
85
+ );
86
+ const controls = getByTestId('slideshow-controls');
87
+ const player = controls.parentElement as HTMLElement;
88
+ const rect = (top: number, height: number) =>
89
+ ({
90
+ x: 0,
91
+ y: top,
92
+ top,
93
+ right: 640,
94
+ bottom: top + height,
95
+ left: 0,
96
+ width: 640,
97
+ height,
98
+ toJSON: () => ({}),
99
+ }) as DOMRect;
100
+ const playerBounds = vi.spyOn(player, 'getBoundingClientRect').mockReturnValue(rect(0, 480));
101
+ const controlsBounds = vi
102
+ .spyOn(controls, 'getBoundingClientRect')
103
+ .mockReturnValue(rect(420, 40));
104
+
105
+ fireEvent.click(getByTestId('slide-counter'));
106
+
107
+ expect(getByTestId('slide-picker').style.maxHeight).toBe('396px');
108
+ playerBounds.mockRestore();
109
+ controlsBounds.mockRestore();
110
+ });
111
+
112
+ it('navigates directly to a selected slide and closes the picker', () => {
113
+ let selectedIndex = -1;
114
+ const { getByTestId, queryByTestId } = render(
115
+ <DocControlsSlideshow
116
+ state={makeState({ currentBlockIndex: 0, totalBlocks: slides.length })}
117
+ slideNav={makeSlideNav({ goToSlide: (index) => (selectedIndex = index) })}
118
+ slides={slides}
119
+ />,
120
+ );
121
+
122
+ fireEvent.click(getByTestId('slide-counter'));
123
+ fireEvent.click(getByTestId('slide-picker-item-2'));
124
+
125
+ expect(selectedIndex).toBe(2);
126
+ expect(queryByTestId('slide-picker')).toBeNull();
127
+ });
128
+
129
+ it('closes the slide picker with Escape', () => {
130
+ const { getByTestId, queryByTestId } = render(
131
+ <DocControlsSlideshow
132
+ state={makeState({ currentBlockIndex: 0, totalBlocks: slides.length })}
133
+ slideNav={makeSlideNav()}
134
+ slides={slides}
135
+ />,
136
+ );
137
+
138
+ fireEvent.click(getByTestId('slide-counter'));
139
+ fireEvent.keyDown(document, { key: 'Escape' });
140
+
141
+ expect(queryByTestId('slide-picker')).toBeNull();
142
+ expect(document.activeElement).toBe(getByTestId('slide-counter'));
143
+ });
144
+
52
145
  it('disables prev button on first slide', () => {
53
146
  const { getByTestId } = render(
54
147
  <DocControlsSlideshow