@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.
- package/README.md +30 -3
- package/dist/index.d.ts +177 -28
- package/dist/index.js +1330 -611
- package/dist/index.js.map +1 -1
- package/dist/squisq-player.css +1 -1
- package/dist/squisq-player.css.map +1 -1
- package/dist/squisq-player.global.js +57 -37
- package/dist/squisq-player.global.js.map +1 -1
- package/dist/standalone-source.js +1 -1
- package/dist/styles/index.css +28 -0
- package/package.json +2 -2
- package/src/BlockRenderer.tsx +54 -17
- package/src/DocControlsSlideshow.tsx +222 -5
- package/src/DocPlayer.tsx +367 -183
- package/src/DocPlayerWithSidebar.tsx +4 -0
- package/src/DocProgressBar.tsx +40 -1
- package/src/LinearDocView.tsx +138 -62
- package/src/MarkdownRenderer.tsx +40 -97
- package/src/MediaClipLayer.tsx +12 -2
- package/src/__tests__/BlockRenderer.test.tsx +138 -8
- package/src/__tests__/DocControlsSlideshow.test.tsx +94 -1
- package/src/__tests__/DocPlayer.test.tsx +505 -0
- package/src/__tests__/DocProgressBar.test.tsx +28 -2
- package/src/__tests__/LinearDocView.test.tsx +104 -11
- package/src/__tests__/MapLayer.test.tsx +63 -0
- package/src/__tests__/MarkdownRenderer.test.tsx +16 -5
- package/src/__tests__/MediaClipLayer.test.tsx +70 -0
- package/src/__tests__/MediaContext.test.tsx +51 -0
- package/src/__tests__/PathLayer.test.tsx +12 -1
- package/src/__tests__/VideoLayer.test.tsx +94 -0
- package/src/__tests__/fillStyle.test.tsx +50 -2
- package/src/__tests__/standaloneEntry.test.tsx +103 -0
- package/src/__tests__/useAudioSync.test.ts +49 -0
- package/src/__tests__/useDocPlayback.transition.test.ts +48 -5
- package/src/__tests__/useViewportOrientation.test.ts +22 -0
- package/src/hooks/MediaContext.tsx +12 -3
- package/src/hooks/useAudioSync.ts +61 -12
- package/src/hooks/useDocPlayback.ts +40 -12
- package/src/hooks/useViewportOrientation.ts +2 -4
- package/src/index.ts +5 -2
- package/src/layers/ImageLayer.tsx +106 -1
- package/src/layers/MapLayer.tsx +7 -6
- package/src/layers/PathLayer.tsx +20 -11
- package/src/layers/ShapeLayer.tsx +33 -9
- package/src/layers/TextLayer.tsx +4 -3
- package/src/layers/TreeLayer.tsx +167 -0
- package/src/layers/VideoLayer.tsx +20 -6
- package/src/standalone-entry.tsx +91 -14
- package/src/styles/doc-animations.css +36 -0
- package/src/types.ts +13 -13
package/src/MarkdownRenderer.tsx
CHANGED
|
@@ -158,27 +158,12 @@ function renderInline(
|
|
|
158
158
|
|
|
159
159
|
case 'htmlInline':
|
|
160
160
|
if (ctx.htmlPolicy === 'strip') return null;
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
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
|
-
//
|
|
296
|
-
//
|
|
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
|
-
/**
|
|
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
|
|
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(
|
|
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
|
-
//
|
|
587
|
-
//
|
|
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={
|
|
533
|
+
src={src}
|
|
596
534
|
width={el.attributes.width}
|
|
597
535
|
height={el.attributes.height}
|
|
598
|
-
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={
|
|
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(
|
|
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':
|
package/src/MediaClipLayer.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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
|