@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.
- package/LICENSE +21 -0
- package/NOTICE.md +20 -0
- package/README.md +6 -0
- package/dist/index.d.ts +101 -96
- package/dist/index.js +1483 -782
- package/dist/squisq-player.full.global.js +4165 -0
- package/dist/squisq-player.global.js +561 -168
- package/dist/standalone-source.d.ts +1 -0
- package/dist/standalone-source.js +1 -1
- package/dist/styles/index.css +64 -0
- package/package.json +8 -4
- package/dist/index.js.map +0 -1
- package/dist/squisq-player.css +0 -2
- package/dist/squisq-player.css.map +0 -1
- package/dist/squisq-player.global.js.map +0 -1
- package/src/BlockRenderer.tsx +0 -194
- package/src/CaptionOverlay.tsx +0 -111
- package/src/DocControlsBottom.tsx +0 -109
- package/src/DocControlsOverlay.tsx +0 -190
- package/src/DocControlsSidebar.tsx +0 -113
- package/src/DocControlsSlideshow.tsx +0 -360
- package/src/DocPlayer.tsx +0 -1528
- package/src/DocPlayerWithSidebar.tsx +0 -155
- package/src/DocProgressBar.tsx +0 -257
- package/src/InlineAudioPlayer.tsx +0 -46
- package/src/InlineVideoPlayer.tsx +0 -70
- package/src/LinearDocView.tsx +0 -569
- package/src/MarkdownRenderer.tsx +0 -624
- package/src/MediaClipLayer.tsx +0 -145
- package/src/SocialCaptionOverlay.tsx +0 -255
- package/src/__tests__/BlockRenderer.test.tsx +0 -235
- package/src/__tests__/DocControlsSlideshow.test.tsx +0 -221
- package/src/__tests__/DocPlayer.test.tsx +0 -635
- package/src/__tests__/DocPlayerStylesSentinel.test.tsx +0 -41
- package/src/__tests__/DocProgressBar.test.tsx +0 -102
- package/src/__tests__/JsonView.test.tsx +0 -111
- package/src/__tests__/LinearDocView.test.tsx +0 -426
- package/src/__tests__/MapLayer.test.tsx +0 -63
- package/src/__tests__/MarkdownRenderer.test.tsx +0 -357
- package/src/__tests__/MediaClipLayer.test.tsx +0 -70
- package/src/__tests__/MediaContext.test.tsx +0 -51
- package/src/__tests__/PathLayer.test.tsx +0 -84
- package/src/__tests__/TableLayer.test.tsx +0 -142
- package/src/__tests__/VideoLayer.test.tsx +0 -94
- package/src/__tests__/exports.test.ts +0 -55
- package/src/__tests__/fillStyle.test.tsx +0 -160
- package/src/__tests__/standaloneEntry.test.tsx +0 -103
- package/src/__tests__/transitionStyles.test.ts +0 -125
- package/src/__tests__/useAudioSync.test.ts +0 -49
- package/src/__tests__/useDocPlayback.transition.test.ts +0 -113
- package/src/__tests__/useJsonViewTokens.test.ts +0 -41
- package/src/__tests__/useSlideSwipe.test.ts +0 -81
- package/src/__tests__/useViewportOrientation.test.ts +0 -22
- package/src/hooks/AudioController.ts +0 -114
- package/src/hooks/MediaContext.tsx +0 -97
- package/src/hooks/index.ts +0 -11
- package/src/hooks/useAudioSync.ts +0 -456
- package/src/hooks/useAutoSurface.ts +0 -33
- package/src/hooks/useDocPlayback.ts +0 -265
- package/src/hooks/useMediaSchedule.ts +0 -39
- package/src/hooks/useSlideSwipe.ts +0 -265
- package/src/hooks/useViewportOrientation.ts +0 -115
- package/src/index.ts +0 -70
- package/src/jsonView/JsonView.tsx +0 -51
- package/src/jsonView/RenderNode.tsx +0 -51
- package/src/jsonView/index.ts +0 -2
- package/src/jsonView/json-view.css +0 -206
- package/src/jsonView/useJsonViewTokens.ts +0 -32
- package/src/jsonView/viewers.tsx +0 -343
- package/src/layers/ImageLayer.tsx +0 -297
- package/src/layers/MapLayer.tsx +0 -185
- package/src/layers/PathLayer.tsx +0 -155
- package/src/layers/ShapeLayer.tsx +0 -158
- package/src/layers/TableLayer.tsx +0 -129
- package/src/layers/TextLayer.tsx +0 -571
- package/src/layers/TreeLayer.tsx +0 -167
- package/src/layers/VideoLayer.tsx +0 -171
- package/src/layers/index.ts +0 -6
- package/src/standalone-entry.tsx +0 -337
- package/src/standalone-source.d.ts +0 -10
- package/src/styles/doc-animations.css +0 -2431
- package/src/styles/index.css +0 -7
- package/src/types.ts +0 -183
- package/src/utils/animationUtils.ts +0 -13
- package/src/utils/fillStyle.tsx +0 -148
- package/src/utils/layerUtils.ts +0 -42
- package/src/utils/mapTileUtils.ts +0 -375
package/src/MediaClipLayer.tsx
DELETED
|
@@ -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
|
-
});
|