@bendyline/squisq-react 2.0.1 → 2.2.0

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 (87) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +20 -0
  3. package/README.md +6 -0
  4. package/dist/index.d.ts +101 -96
  5. package/dist/index.js +1483 -782
  6. package/dist/squisq-player.full.global.js +4165 -0
  7. package/dist/squisq-player.global.js +561 -168
  8. package/dist/standalone-source.d.ts +1 -0
  9. package/dist/standalone-source.js +1 -1
  10. package/dist/styles/index.css +64 -0
  11. package/package.json +8 -4
  12. package/dist/index.js.map +0 -1
  13. package/dist/squisq-player.css +0 -2
  14. package/dist/squisq-player.css.map +0 -1
  15. package/dist/squisq-player.global.js.map +0 -1
  16. package/src/BlockRenderer.tsx +0 -194
  17. package/src/CaptionOverlay.tsx +0 -111
  18. package/src/DocControlsBottom.tsx +0 -109
  19. package/src/DocControlsOverlay.tsx +0 -190
  20. package/src/DocControlsSidebar.tsx +0 -113
  21. package/src/DocControlsSlideshow.tsx +0 -360
  22. package/src/DocPlayer.tsx +0 -1528
  23. package/src/DocPlayerWithSidebar.tsx +0 -155
  24. package/src/DocProgressBar.tsx +0 -257
  25. package/src/InlineAudioPlayer.tsx +0 -46
  26. package/src/InlineVideoPlayer.tsx +0 -70
  27. package/src/LinearDocView.tsx +0 -569
  28. package/src/MarkdownRenderer.tsx +0 -624
  29. package/src/MediaClipLayer.tsx +0 -145
  30. package/src/SocialCaptionOverlay.tsx +0 -255
  31. package/src/__tests__/BlockRenderer.test.tsx +0 -235
  32. package/src/__tests__/DocControlsSlideshow.test.tsx +0 -221
  33. package/src/__tests__/DocPlayer.test.tsx +0 -635
  34. package/src/__tests__/DocPlayerStylesSentinel.test.tsx +0 -41
  35. package/src/__tests__/DocProgressBar.test.tsx +0 -102
  36. package/src/__tests__/JsonView.test.tsx +0 -111
  37. package/src/__tests__/LinearDocView.test.tsx +0 -426
  38. package/src/__tests__/MapLayer.test.tsx +0 -63
  39. package/src/__tests__/MarkdownRenderer.test.tsx +0 -357
  40. package/src/__tests__/MediaClipLayer.test.tsx +0 -70
  41. package/src/__tests__/MediaContext.test.tsx +0 -51
  42. package/src/__tests__/PathLayer.test.tsx +0 -84
  43. package/src/__tests__/TableLayer.test.tsx +0 -142
  44. package/src/__tests__/VideoLayer.test.tsx +0 -94
  45. package/src/__tests__/exports.test.ts +0 -55
  46. package/src/__tests__/fillStyle.test.tsx +0 -160
  47. package/src/__tests__/standaloneEntry.test.tsx +0 -103
  48. package/src/__tests__/transitionStyles.test.ts +0 -125
  49. package/src/__tests__/useAudioSync.test.ts +0 -49
  50. package/src/__tests__/useDocPlayback.transition.test.ts +0 -113
  51. package/src/__tests__/useJsonViewTokens.test.ts +0 -41
  52. package/src/__tests__/useSlideSwipe.test.ts +0 -81
  53. package/src/__tests__/useViewportOrientation.test.ts +0 -22
  54. package/src/hooks/AudioController.ts +0 -114
  55. package/src/hooks/MediaContext.tsx +0 -97
  56. package/src/hooks/index.ts +0 -11
  57. package/src/hooks/useAudioSync.ts +0 -456
  58. package/src/hooks/useAutoSurface.ts +0 -33
  59. package/src/hooks/useDocPlayback.ts +0 -265
  60. package/src/hooks/useMediaSchedule.ts +0 -39
  61. package/src/hooks/useSlideSwipe.ts +0 -265
  62. package/src/hooks/useViewportOrientation.ts +0 -115
  63. package/src/index.ts +0 -70
  64. package/src/jsonView/JsonView.tsx +0 -51
  65. package/src/jsonView/RenderNode.tsx +0 -51
  66. package/src/jsonView/index.ts +0 -2
  67. package/src/jsonView/json-view.css +0 -206
  68. package/src/jsonView/useJsonViewTokens.ts +0 -32
  69. package/src/jsonView/viewers.tsx +0 -343
  70. package/src/layers/ImageLayer.tsx +0 -297
  71. package/src/layers/MapLayer.tsx +0 -185
  72. package/src/layers/PathLayer.tsx +0 -155
  73. package/src/layers/ShapeLayer.tsx +0 -158
  74. package/src/layers/TableLayer.tsx +0 -129
  75. package/src/layers/TextLayer.tsx +0 -571
  76. package/src/layers/TreeLayer.tsx +0 -167
  77. package/src/layers/VideoLayer.tsx +0 -171
  78. package/src/layers/index.ts +0 -6
  79. package/src/standalone-entry.tsx +0 -337
  80. package/src/standalone-source.d.ts +0 -10
  81. package/src/styles/doc-animations.css +0 -2431
  82. package/src/styles/index.css +0 -7
  83. package/src/types.ts +0 -183
  84. package/src/utils/animationUtils.ts +0 -13
  85. package/src/utils/fillStyle.tsx +0 -148
  86. package/src/utils/layerUtils.ts +0 -42
  87. package/src/utils/mapTileUtils.ts +0 -375
@@ -1,145 +0,0 @@
1
- /**
2
- * MediaClipLayer
3
- *
4
- * Player-level audio/video elements for the media-clip schedule. Renders one
5
- * element per scheduled clip (audio, or document-spanning video) and keeps each
6
- * mounted so re-entry doesn't reload; the drive effect seeks/plays only the
7
- * clips active at the current time. Multiple concurrent clips (e.g. a
8
- * document-spanning narration plus a block clip) are independent elements and
9
- * the browser mixes their output.
10
- *
11
- * Audio clips play unmuted during live playback (silent in render mode, where
12
- * frames are captured without sound and audio is muxed offline). Video clips
13
- * play muted — their audio, if any, is reproduced by the export mux.
14
- */
15
-
16
- import { useEffect, useRef } from 'react';
17
- import type { ScheduledClip } from '@bendyline/squisq/schemas';
18
- import { useMediaUrl } from './hooks/MediaContext';
19
- import { useMediaSchedule } from './hooks/useMediaSchedule';
20
-
21
- /** Re-seek an element only when it drifts this far from its target (seconds). */
22
- const DRIFT = 0.25;
23
-
24
- export interface MediaClipLayerProps {
25
- schedule: ScheduledClip[];
26
- currentTime: number;
27
- isPlaying: boolean;
28
- basePath: string;
29
- renderMode?: boolean;
30
- /** Silence every scheduled clip during live playback. */
31
- muted?: boolean;
32
- }
33
-
34
- export function MediaClipLayer({
35
- schedule,
36
- currentTime,
37
- isPlaying,
38
- basePath,
39
- renderMode = false,
40
- muted = false,
41
- }: MediaClipLayerProps) {
42
- const { renderClips, activeIds } = useMediaSchedule(schedule, currentTime);
43
- if (renderClips.length === 0) return null;
44
- return (
45
- <div className="doc-player__media-clips" aria-hidden>
46
- {renderClips.map((clip) => (
47
- <MediaClipElement
48
- key={clip.id}
49
- clip={clip}
50
- active={activeIds.has(clip.id)}
51
- currentTime={currentTime}
52
- isPlaying={isPlaying}
53
- basePath={basePath}
54
- renderMode={renderMode}
55
- muted={muted}
56
- />
57
- ))}
58
- </div>
59
- );
60
- }
61
-
62
- interface MediaClipElementProps {
63
- clip: ScheduledClip;
64
- active: boolean;
65
- currentTime: number;
66
- isPlaying: boolean;
67
- basePath: string;
68
- renderMode: boolean;
69
- muted: boolean;
70
- }
71
-
72
- function MediaClipElement({
73
- clip,
74
- active,
75
- currentTime,
76
- isPlaying,
77
- basePath,
78
- renderMode,
79
- muted,
80
- }: MediaClipElementProps) {
81
- const ref = useRef<HTMLMediaElement | null>(null);
82
- const src = useMediaUrl(clip.src, basePath);
83
-
84
- useEffect(() => {
85
- const el = ref.current;
86
- if (!el) return;
87
- if (!active) {
88
- if (!el.paused) el.pause();
89
- return;
90
- }
91
- const target = Math.max(0, clip.sourceIn + (currentTime - clip.absoluteStart));
92
- if (renderMode || Math.abs(el.currentTime - target) > DRIFT) {
93
- try {
94
- el.currentTime = target;
95
- } catch {
96
- // Seeking before metadata loads throws; the next tick retries.
97
- }
98
- }
99
- if (isPlaying && !renderMode) {
100
- const p = el.play();
101
- if (p) p.catch(() => {});
102
- } else {
103
- el.pause();
104
- }
105
- }, [active, currentTime, isPlaying, renderMode, clip.sourceIn, clip.absoluteStart, src]);
106
-
107
- const isVideo = clip.kind === 'video';
108
- const common = {
109
- ref: ref as React.RefObject<never>,
110
- src,
111
- preload: 'auto' as const,
112
- 'data-clip-id': clip.id,
113
- 'data-abs-start': clip.absoluteStart,
114
- 'data-abs-end': clip.absoluteEnd,
115
- 'data-source-in': clip.sourceIn,
116
- };
117
-
118
- if (isVideo) {
119
- // Document-spanning video renders full-bleed behind the blocks.
120
- return (
121
- <video
122
- {...common}
123
- muted
124
- playsInline
125
- style={{
126
- position: 'absolute',
127
- inset: 0,
128
- width: '100%',
129
- height: '100%',
130
- objectFit: 'cover',
131
- zIndex: 0,
132
- pointerEvents: 'none',
133
- }}
134
- />
135
- );
136
- }
137
-
138
- return (
139
- <audio
140
- {...common}
141
- muted={renderMode || muted}
142
- style={{ position: 'absolute', width: 0, height: 0 }}
143
- />
144
- );
145
- }
@@ -1,255 +0,0 @@
1
- /**
2
- * SocialCaptionOverlay Component
3
- *
4
- * Social media-style captions (Instagram/TikTok): large centered words
5
- * showing 3-5 words at a time with the currently-spoken word highlighted
6
- * in the theme's primary color. Font and colors are pulled from the
7
- * active theme.
8
- *
9
- * Words are gathered across all caption phrases into a continuous stream,
10
- * then chunked into uniform groups for smooth, consistent pacing.
11
- *
12
- * Supports two timing modes:
13
- * 1. Precise: uses per-word timestamps from CaptionPhrase.words
14
- * 2. Interpolated: distributes timing evenly within each phrase
15
- */
16
-
17
- import { useMemo } from 'react';
18
- import type { CaptionTrack, CaptionPhrase, ViewportConfig } from '@bendyline/squisq/schemas';
19
- import type { Theme } from '@bendyline/squisq/schemas';
20
- import { resolveFontFamily } from '@bendyline/squisq/schemas';
21
-
22
- /** Target words per visible chunk. */
23
- const TARGET_CHUNK_SIZE = 4;
24
- const MIN_CHUNK_SIZE = 2;
25
- const MAX_CHUNK_SIZE = 6;
26
-
27
- /** A timed word derived from phrase data. */
28
- interface TimedWord {
29
- text: string;
30
- startTime: number;
31
- endTime: number;
32
- }
33
-
34
- /** A chunk of words displayed together. */
35
- interface WordChunk {
36
- words: TimedWord[];
37
- startTime: number;
38
- endTime: number;
39
- }
40
-
41
- /**
42
- * Resolve per-word timing for a single phrase.
43
- * Uses precise word timestamps when available, otherwise interpolates.
44
- */
45
- function resolvePhraseTiming(phrase: CaptionPhrase): TimedWord[] {
46
- // Use precise timing when available
47
- if (phrase.words && phrase.words.length > 0) {
48
- return phrase.words.map((w) => ({
49
- text: w.text,
50
- startTime: w.startTime,
51
- endTime: w.endTime,
52
- }));
53
- }
54
-
55
- // Interpolate: split text into words, distribute evenly
56
- const rawWords = phrase.text.split(/\s+/).filter((w) => w.length > 0);
57
- if (rawWords.length === 0) return [];
58
-
59
- const duration = phrase.endTime - phrase.startTime;
60
- const wordDuration = duration / rawWords.length;
61
-
62
- return rawWords.map((text, i) => ({
63
- text,
64
- startTime: phrase.startTime + i * wordDuration,
65
- endTime: phrase.startTime + (i + 1) * wordDuration,
66
- }));
67
- }
68
-
69
- /**
70
- * Build a continuous stream of timed words from ALL caption phrases,
71
- * then group into uniform display chunks. This eliminates the "pulsy"
72
- * feeling caused by per-phrase chunking with variable phrase lengths.
73
- */
74
- function buildWordStream(captions: CaptionTrack): { words: TimedWord[]; chunks: WordChunk[] } {
75
- // Gather all words across all phrases
76
- const allWords: TimedWord[] = [];
77
- for (const phrase of captions.phrases) {
78
- allWords.push(...resolvePhraseTiming(phrase));
79
- }
80
-
81
- if (allWords.length === 0) return { words: [], chunks: [] };
82
-
83
- // Determine uniform chunk size
84
- let chunkSize = TARGET_CHUNK_SIZE;
85
- if (allWords.length > chunkSize) {
86
- const numChunks = Math.ceil(allWords.length / chunkSize);
87
- chunkSize = Math.ceil(allWords.length / numChunks);
88
- chunkSize = Math.min(MAX_CHUNK_SIZE, Math.max(MIN_CHUNK_SIZE, chunkSize));
89
- } else {
90
- chunkSize = Math.max(MIN_CHUNK_SIZE, allWords.length);
91
- }
92
-
93
- // Build chunks
94
- const chunks: WordChunk[] = [];
95
- for (let i = 0; i < allWords.length; i += chunkSize) {
96
- const chunkWords = allWords.slice(i, i + chunkSize);
97
- chunks.push({
98
- words: chunkWords,
99
- startTime: chunkWords[0].startTime,
100
- endTime: chunkWords[chunkWords.length - 1].endTime,
101
- });
102
- }
103
-
104
- return { words: allWords, chunks };
105
- }
106
-
107
- interface SocialCaptionOverlayProps {
108
- captions: CaptionTrack | undefined;
109
- currentTime: number;
110
- enabled?: boolean;
111
- theme?: Theme;
112
- viewport?: ViewportConfig;
113
- }
114
-
115
- export function SocialCaptionOverlay({
116
- captions,
117
- currentTime,
118
- enabled = true,
119
- theme,
120
- viewport,
121
- }: SocialCaptionOverlayProps) {
122
- // Build the word stream once when captions change (memoized)
123
- const { chunks } = useMemo(
124
- () => (captions ? buildWordStream(captions) : { words: [], chunks: [] }),
125
- [captions],
126
- );
127
-
128
- if (!enabled || chunks.length === 0) {
129
- return (
130
- <div
131
- className="social-caption-overlay"
132
- style={{
133
- position: 'absolute',
134
- bottom: '18%',
135
- left: 0,
136
- right: 0,
137
- zIndex: 50,
138
- pointerEvents: 'none',
139
- opacity: 0,
140
- transition: 'opacity 0.15s ease-in-out',
141
- }}
142
- />
143
- );
144
- }
145
-
146
- // Find the active chunk and word using binary-style search
147
- let activeChunk: WordChunk | null = null;
148
- let activeWordIndex = -1;
149
-
150
- for (const chunk of chunks) {
151
- if (currentTime >= chunk.startTime && currentTime < chunk.endTime) {
152
- activeChunk = chunk;
153
- break;
154
- }
155
- }
156
-
157
- // If between chunks (gap), show the nearest chunk
158
- if (!activeChunk) {
159
- for (let i = 0; i < chunks.length - 1; i++) {
160
- if (currentTime >= chunks[i].endTime && currentTime < chunks[i + 1].startTime) {
161
- // In a gap — show the chunk we just left (feels more natural)
162
- activeChunk = chunks[i];
163
- activeWordIndex = activeChunk.words.length - 1;
164
- break;
165
- }
166
- }
167
- // Past all chunks
168
- if (!activeChunk && chunks.length > 0 && currentTime >= chunks[chunks.length - 1].startTime) {
169
- activeChunk = chunks[chunks.length - 1];
170
- activeWordIndex = activeChunk.words.length - 1;
171
- }
172
- }
173
-
174
- if (!activeChunk) return null;
175
-
176
- // Find active word within chunk (if not already set from gap handling)
177
- if (activeWordIndex === -1) {
178
- for (let i = 0; i < activeChunk.words.length; i++) {
179
- const word = activeChunk.words[i];
180
- if (currentTime >= word.startTime && currentTime < word.endTime) {
181
- activeWordIndex = i;
182
- break;
183
- }
184
- }
185
- // Fallback: last word before currentTime
186
- if (activeWordIndex === -1) {
187
- for (let i = activeChunk.words.length - 1; i >= 0; i--) {
188
- if (currentTime >= activeChunk.words[i].startTime) {
189
- activeWordIndex = i;
190
- break;
191
- }
192
- }
193
- }
194
- // Final fallback
195
- if (activeWordIndex === -1) activeWordIndex = 0;
196
- }
197
-
198
- // Theme-derived styling
199
- const primaryColor = theme?.colors?.primary ?? '#5b9bd5';
200
- const fontFamily = theme?.typography?.titleFont
201
- ? resolveFontFamily(theme.typography.titleFont, '"PT Serif", Georgia, serif')
202
- : '"PT Serif", Georgia, serif';
203
-
204
- // Scale font to viewport — aim for ~5.5% of viewport height
205
- const viewportHeight = viewport?.height ?? 720;
206
- const baseFontSize = Math.round(viewportHeight * 0.055);
207
- const fontSize = Math.max(24, Math.min(72, baseFontSize));
208
-
209
- return (
210
- <div
211
- className="social-caption-overlay"
212
- style={{
213
- position: 'absolute',
214
- bottom: '18%',
215
- left: 0,
216
- right: 0,
217
- zIndex: 50,
218
- pointerEvents: 'none',
219
- textAlign: 'center',
220
- padding: '0 8%',
221
- boxSizing: 'border-box',
222
- opacity: 1,
223
- transition: 'opacity 0.15s ease-in-out',
224
- }}
225
- >
226
- <div
227
- style={{
228
- display: 'inline-block',
229
- lineHeight: 1.3,
230
- }}
231
- >
232
- {activeChunk.words.map((word, i) => {
233
- const isActive = i === activeWordIndex;
234
- return (
235
- <span
236
- key={`${word.startTime}-${i}`}
237
- style={{
238
- fontFamily,
239
- fontSize: `${fontSize}px`,
240
- fontWeight: isActive ? 800 : 600,
241
- color: isActive ? primaryColor : 'rgba(255, 255, 255, 0.9)',
242
- textShadow: '0 2px 8px rgba(0,0,0,0.7), 0 0 20px rgba(0,0,0,0.4)',
243
- marginRight: i < activeChunk!.words.length - 1 ? '0.3em' : undefined,
244
- transition: 'color 0.1s ease, font-weight 0.1s ease',
245
- textTransform: 'uppercase',
246
- }}
247
- >
248
- {word.text}
249
- </span>
250
- );
251
- })}
252
- </div>
253
- </div>
254
- );
255
- }
@@ -1,235 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { render } from '@testing-library/react';
3
- import { BlockRenderer } from '../BlockRenderer';
4
- import type { Block } from '@bendyline/squisq/schemas';
5
-
6
- describe('BlockRenderer', () => {
7
- const minimalBlock: Block = {
8
- id: 'test-block',
9
- startTime: 0,
10
- duration: 5,
11
- audioSegment: 0,
12
- layers: [],
13
- };
14
-
15
- it('renders an SVG element', () => {
16
- const { container } = render(
17
- <BlockRenderer block={minimalBlock} blockTime={0} basePath="/test" />,
18
- );
19
- const svg = container.querySelector('svg');
20
- expect(svg).toBeTruthy();
21
- });
22
-
23
- it('renders with correct viewBox', () => {
24
- const { container } = render(
25
- <BlockRenderer block={minimalBlock} blockTime={0} basePath="/test" />,
26
- );
27
- const svg = container.querySelector('svg');
28
- expect(svg?.getAttribute('viewBox')).toBe('0 0 1920 1080');
29
- });
30
-
31
- it('renders with custom viewport', () => {
32
- const { container } = render(
33
- <BlockRenderer
34
- block={minimalBlock}
35
- blockTime={0}
36
- basePath="/test"
37
- viewport={{ width: 1080, height: 1920 }}
38
- />,
39
- );
40
- const svg = container.querySelector('svg');
41
- expect(svg?.getAttribute('viewBox')).toBe('0 0 1080 1920');
42
- });
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
-
101
- it('renders text layers', () => {
102
- const blockWithText: Block = {
103
- id: 'text-block',
104
- startTime: 0,
105
- duration: 5,
106
- audioSegment: 0,
107
- layers: [
108
- {
109
- type: 'text',
110
- id: 'title',
111
- content: {
112
- text: 'Hello World',
113
- style: {
114
- fontSize: 48,
115
- color: '#ffffff',
116
- },
117
- },
118
- position: { x: 100, y: 100 },
119
- },
120
- ],
121
- };
122
-
123
- const { container } = render(
124
- <BlockRenderer block={blockWithText} blockTime={0} basePath="/test" />,
125
- );
126
- // Text should be rendered somewhere in the SVG
127
- expect(container.textContent).toContain('Hello World');
128
- });
129
-
130
- it('renders shape layers', () => {
131
- const blockWithShape: Block = {
132
- id: 'shape-block',
133
- startTime: 0,
134
- duration: 5,
135
- audioSegment: 0,
136
- layers: [
137
- {
138
- type: 'shape',
139
- id: 'bg',
140
- content: {
141
- shape: 'rect',
142
- fill: '#000000',
143
- },
144
- position: { x: 0, y: 0, width: 1920, height: 1080 },
145
- },
146
- ],
147
- };
148
-
149
- const { container } = render(
150
- <BlockRenderer block={blockWithShape} blockTime={0} basePath="/test" />,
151
- );
152
- const rect = container.querySelector('rect');
153
- expect(rect).toBeTruthy();
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
- });
235
- });