@bendyline/squisq-react 1.0.2 → 1.1.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/dist/BlockRenderer.d.ts.map +1 -1
- package/dist/BlockRenderer.js +3 -0
- package/dist/BlockRenderer.js.map +1 -1
- package/dist/CaptionOverlay.d.ts +16 -7
- package/dist/CaptionOverlay.d.ts.map +1 -1
- package/dist/CaptionOverlay.js +7 -3
- package/dist/CaptionOverlay.js.map +1 -1
- package/dist/DocControlsBottom.d.ts.map +1 -1
- package/dist/DocControlsBottom.js +5 -1
- package/dist/DocControlsBottom.js.map +1 -1
- package/dist/DocControlsOverlay.d.ts.map +1 -1
- package/dist/DocControlsOverlay.js +9 -4
- package/dist/DocControlsOverlay.js.map +1 -1
- package/dist/DocControlsSidebar.d.ts.map +1 -1
- package/dist/DocControlsSidebar.js +5 -1
- package/dist/DocControlsSidebar.js.map +1 -1
- package/dist/DocPlayer.d.ts +5 -2
- package/dist/DocPlayer.d.ts.map +1 -1
- package/dist/DocPlayer.js +36 -7
- package/dist/DocPlayer.js.map +1 -1
- package/dist/DocPlayerWithSidebar.d.ts.map +1 -1
- package/dist/DocPlayerWithSidebar.js +1 -0
- package/dist/DocPlayerWithSidebar.js.map +1 -1
- package/dist/DocProgressBar.js +2 -2
- package/dist/LinearDocView.d.ts.map +1 -1
- package/dist/LinearDocView.js +130 -6
- package/dist/LinearDocView.js.map +1 -1
- package/dist/SocialCaptionOverlay.d.ts +27 -0
- package/dist/SocialCaptionOverlay.d.ts.map +1 -0
- package/dist/SocialCaptionOverlay.js +184 -0
- package/dist/SocialCaptionOverlay.js.map +1 -0
- package/dist/__tests__/DocControlsSlideshow.test.js +1 -0
- package/dist/__tests__/DocControlsSlideshow.test.js.map +1 -1
- package/dist/__tests__/TableLayer.test.d.ts +2 -0
- package/dist/__tests__/TableLayer.test.d.ts.map +1 -0
- package/dist/__tests__/TableLayer.test.js +121 -0
- package/dist/__tests__/TableLayer.test.js.map +1 -0
- package/dist/hooks/useAudioSync.d.ts.map +1 -1
- package/dist/hooks/useAudioSync.js +4 -2
- package/dist/hooks/useAudioSync.js.map +1 -1
- package/dist/hooks/useDocPlayback.d.ts.map +1 -1
- package/dist/hooks/useDocPlayback.js +7 -4
- package/dist/hooks/useDocPlayback.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/layers/ShapeLayer.d.ts.map +1 -1
- package/dist/layers/ShapeLayer.js +7 -3
- package/dist/layers/ShapeLayer.js.map +1 -1
- package/dist/layers/TableLayer.d.ts +23 -0
- package/dist/layers/TableLayer.d.ts.map +1 -0
- package/dist/layers/TableLayer.js +58 -0
- package/dist/layers/TableLayer.js.map +1 -0
- package/dist/layers/TextLayer.js +1 -1
- package/dist/layers/TextLayer.js.map +1 -1
- package/dist/squisq-player.global.js +91 -5
- package/dist/squisq-player.global.js.map +1 -1
- package/dist/standalone-entry.d.ts +8 -0
- package/dist/standalone-entry.d.ts.map +1 -1
- package/dist/standalone-entry.js +6 -3
- package/dist/standalone-entry.js.map +1 -1
- package/dist/standalone-source.js +1 -1
- package/dist/types.d.ts +19 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
- package/src/BlockRenderer.tsx +3 -0
- package/src/CaptionOverlay.tsx +33 -8
- package/src/DocControlsBottom.tsx +11 -5
- package/src/DocControlsOverlay.tsx +18 -6
- package/src/DocControlsSidebar.tsx +11 -5
- package/src/DocPlayer.tsx +56 -9
- package/src/DocPlayerWithSidebar.tsx +1 -0
- package/src/DocProgressBar.tsx +2 -2
- package/src/LinearDocView.tsx +149 -12
- package/src/SocialCaptionOverlay.tsx +254 -0
- package/src/__tests__/DocControlsSlideshow.test.tsx +1 -0
- package/src/__tests__/TableLayer.test.tsx +142 -0
- package/src/hooks/useAudioSync.ts +5 -2
- package/src/hooks/useDocPlayback.ts +8 -3
- package/src/index.ts +4 -0
- package/src/layers/ShapeLayer.tsx +8 -3
- package/src/layers/TableLayer.tsx +129 -0
- package/src/layers/TextLayer.tsx +1 -1
- package/src/standalone-entry.tsx +23 -3
- package/src/types.ts +21 -0
|
@@ -0,0 +1,254 @@
|
|
|
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
|
+
|
|
21
|
+
/** Target words per visible chunk. */
|
|
22
|
+
const TARGET_CHUNK_SIZE = 4;
|
|
23
|
+
const MIN_CHUNK_SIZE = 2;
|
|
24
|
+
const MAX_CHUNK_SIZE = 6;
|
|
25
|
+
|
|
26
|
+
/** A timed word derived from phrase data. */
|
|
27
|
+
interface TimedWord {
|
|
28
|
+
text: string;
|
|
29
|
+
startTime: number;
|
|
30
|
+
endTime: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A chunk of words displayed together. */
|
|
34
|
+
interface WordChunk {
|
|
35
|
+
words: TimedWord[];
|
|
36
|
+
startTime: number;
|
|
37
|
+
endTime: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve per-word timing for a single phrase.
|
|
42
|
+
* Uses precise word timestamps when available, otherwise interpolates.
|
|
43
|
+
*/
|
|
44
|
+
function resolvePhraseTiming(phrase: CaptionPhrase): TimedWord[] {
|
|
45
|
+
// Use precise timing when available
|
|
46
|
+
if (phrase.words && phrase.words.length > 0) {
|
|
47
|
+
return phrase.words.map((w) => ({
|
|
48
|
+
text: w.text,
|
|
49
|
+
startTime: w.startTime,
|
|
50
|
+
endTime: w.endTime,
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Interpolate: split text into words, distribute evenly
|
|
55
|
+
const rawWords = phrase.text.split(/\s+/).filter((w) => w.length > 0);
|
|
56
|
+
if (rawWords.length === 0) return [];
|
|
57
|
+
|
|
58
|
+
const duration = phrase.endTime - phrase.startTime;
|
|
59
|
+
const wordDuration = duration / rawWords.length;
|
|
60
|
+
|
|
61
|
+
return rawWords.map((text, i) => ({
|
|
62
|
+
text,
|
|
63
|
+
startTime: phrase.startTime + i * wordDuration,
|
|
64
|
+
endTime: phrase.startTime + (i + 1) * wordDuration,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Build a continuous stream of timed words from ALL caption phrases,
|
|
70
|
+
* then group into uniform display chunks. This eliminates the "pulsy"
|
|
71
|
+
* feeling caused by per-phrase chunking with variable phrase lengths.
|
|
72
|
+
*/
|
|
73
|
+
function buildWordStream(captions: CaptionTrack): { words: TimedWord[]; chunks: WordChunk[] } {
|
|
74
|
+
// Gather all words across all phrases
|
|
75
|
+
const allWords: TimedWord[] = [];
|
|
76
|
+
for (const phrase of captions.phrases) {
|
|
77
|
+
allWords.push(...resolvePhraseTiming(phrase));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (allWords.length === 0) return { words: [], chunks: [] };
|
|
81
|
+
|
|
82
|
+
// Determine uniform chunk size
|
|
83
|
+
let chunkSize = TARGET_CHUNK_SIZE;
|
|
84
|
+
if (allWords.length > chunkSize) {
|
|
85
|
+
const numChunks = Math.ceil(allWords.length / chunkSize);
|
|
86
|
+
chunkSize = Math.ceil(allWords.length / numChunks);
|
|
87
|
+
chunkSize = Math.min(MAX_CHUNK_SIZE, Math.max(MIN_CHUNK_SIZE, chunkSize));
|
|
88
|
+
} else {
|
|
89
|
+
chunkSize = Math.max(MIN_CHUNK_SIZE, allWords.length);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Build chunks
|
|
93
|
+
const chunks: WordChunk[] = [];
|
|
94
|
+
for (let i = 0; i < allWords.length; i += chunkSize) {
|
|
95
|
+
const chunkWords = allWords.slice(i, i + chunkSize);
|
|
96
|
+
chunks.push({
|
|
97
|
+
words: chunkWords,
|
|
98
|
+
startTime: chunkWords[0].startTime,
|
|
99
|
+
endTime: chunkWords[chunkWords.length - 1].endTime,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { words: allWords, chunks };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface SocialCaptionOverlayProps {
|
|
107
|
+
captions: CaptionTrack | undefined;
|
|
108
|
+
currentTime: number;
|
|
109
|
+
enabled?: boolean;
|
|
110
|
+
theme?: Theme;
|
|
111
|
+
viewport?: ViewportConfig;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function SocialCaptionOverlay({
|
|
115
|
+
captions,
|
|
116
|
+
currentTime,
|
|
117
|
+
enabled = true,
|
|
118
|
+
theme,
|
|
119
|
+
viewport,
|
|
120
|
+
}: SocialCaptionOverlayProps) {
|
|
121
|
+
// Build the word stream once when captions change (memoized)
|
|
122
|
+
const { chunks } = useMemo(
|
|
123
|
+
() => (captions ? buildWordStream(captions) : { words: [], chunks: [] }),
|
|
124
|
+
[captions],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
if (!enabled || chunks.length === 0) {
|
|
128
|
+
return (
|
|
129
|
+
<div
|
|
130
|
+
className="social-caption-overlay"
|
|
131
|
+
style={{
|
|
132
|
+
position: 'absolute',
|
|
133
|
+
bottom: '18%',
|
|
134
|
+
left: 0,
|
|
135
|
+
right: 0,
|
|
136
|
+
zIndex: 50,
|
|
137
|
+
pointerEvents: 'none',
|
|
138
|
+
opacity: 0,
|
|
139
|
+
transition: 'opacity 0.15s ease-in-out',
|
|
140
|
+
}}
|
|
141
|
+
/>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Find the active chunk and word using binary-style search
|
|
146
|
+
let activeChunk: WordChunk | null = null;
|
|
147
|
+
let activeWordIndex = -1;
|
|
148
|
+
|
|
149
|
+
for (const chunk of chunks) {
|
|
150
|
+
if (currentTime >= chunk.startTime && currentTime < chunk.endTime) {
|
|
151
|
+
activeChunk = chunk;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// If between chunks (gap), show the nearest chunk
|
|
157
|
+
if (!activeChunk) {
|
|
158
|
+
for (let i = 0; i < chunks.length - 1; i++) {
|
|
159
|
+
if (currentTime >= chunks[i].endTime && currentTime < chunks[i + 1].startTime) {
|
|
160
|
+
// In a gap — show the chunk we just left (feels more natural)
|
|
161
|
+
activeChunk = chunks[i];
|
|
162
|
+
activeWordIndex = activeChunk.words.length - 1;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Past all chunks
|
|
167
|
+
if (!activeChunk && chunks.length > 0 && currentTime >= chunks[chunks.length - 1].startTime) {
|
|
168
|
+
activeChunk = chunks[chunks.length - 1];
|
|
169
|
+
activeWordIndex = activeChunk.words.length - 1;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!activeChunk) return null;
|
|
174
|
+
|
|
175
|
+
// Find active word within chunk (if not already set from gap handling)
|
|
176
|
+
if (activeWordIndex === -1) {
|
|
177
|
+
for (let i = 0; i < activeChunk.words.length; i++) {
|
|
178
|
+
const word = activeChunk.words[i];
|
|
179
|
+
if (currentTime >= word.startTime && currentTime < word.endTime) {
|
|
180
|
+
activeWordIndex = i;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Fallback: last word before currentTime
|
|
185
|
+
if (activeWordIndex === -1) {
|
|
186
|
+
for (let i = activeChunk.words.length - 1; i >= 0; i--) {
|
|
187
|
+
if (currentTime >= activeChunk.words[i].startTime) {
|
|
188
|
+
activeWordIndex = i;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Final fallback
|
|
194
|
+
if (activeWordIndex === -1) activeWordIndex = 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Theme-derived styling
|
|
198
|
+
const primaryColor = theme?.colors?.primary ?? '#5b9bd5';
|
|
199
|
+
const fontFamily = theme?.typography?.titleFontFamily
|
|
200
|
+
? `"${theme.typography.titleFontFamily}", system-ui, sans-serif`
|
|
201
|
+
: '"PT Serif", Georgia, serif';
|
|
202
|
+
|
|
203
|
+
// Scale font to viewport — aim for ~5.5% of viewport height
|
|
204
|
+
const viewportHeight = viewport?.height ?? 720;
|
|
205
|
+
const baseFontSize = Math.round(viewportHeight * 0.055);
|
|
206
|
+
const fontSize = Math.max(24, Math.min(72, baseFontSize));
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<div
|
|
210
|
+
className="social-caption-overlay"
|
|
211
|
+
style={{
|
|
212
|
+
position: 'absolute',
|
|
213
|
+
bottom: '18%',
|
|
214
|
+
left: 0,
|
|
215
|
+
right: 0,
|
|
216
|
+
zIndex: 50,
|
|
217
|
+
pointerEvents: 'none',
|
|
218
|
+
textAlign: 'center',
|
|
219
|
+
padding: '0 8%',
|
|
220
|
+
boxSizing: 'border-box',
|
|
221
|
+
opacity: 1,
|
|
222
|
+
transition: 'opacity 0.15s ease-in-out',
|
|
223
|
+
}}
|
|
224
|
+
>
|
|
225
|
+
<div
|
|
226
|
+
style={{
|
|
227
|
+
display: 'inline-block',
|
|
228
|
+
lineHeight: 1.3,
|
|
229
|
+
}}
|
|
230
|
+
>
|
|
231
|
+
{activeChunk.words.map((word, i) => {
|
|
232
|
+
const isActive = i === activeWordIndex;
|
|
233
|
+
return (
|
|
234
|
+
<span
|
|
235
|
+
key={`${word.startTime}-${i}`}
|
|
236
|
+
style={{
|
|
237
|
+
fontFamily,
|
|
238
|
+
fontSize: `${fontSize}px`,
|
|
239
|
+
fontWeight: isActive ? 800 : 600,
|
|
240
|
+
color: isActive ? primaryColor : 'rgba(255, 255, 255, 0.9)',
|
|
241
|
+
textShadow: '0 2px 8px rgba(0,0,0,0.7), 0 0 20px rgba(0,0,0,0.4)',
|
|
242
|
+
marginRight: i < activeChunk!.words.length - 1 ? '0.3em' : undefined,
|
|
243
|
+
transition: 'color 0.1s ease, font-weight 0.1s ease',
|
|
244
|
+
textTransform: 'uppercase',
|
|
245
|
+
}}
|
|
246
|
+
>
|
|
247
|
+
{word.text}
|
|
248
|
+
</span>
|
|
249
|
+
);
|
|
250
|
+
})}
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
import { TableLayer } from '../layers/TableLayer';
|
|
4
|
+
import type { TableLayer as TableLayerType } from '@bendyline/squisq/schemas';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
function makeTableLayer(overrides: Partial<TableLayerType> = {}): TableLayerType {
|
|
11
|
+
return {
|
|
12
|
+
type: 'table',
|
|
13
|
+
id: 'test-table',
|
|
14
|
+
content: {
|
|
15
|
+
headers: ['Name', 'Age', 'City'],
|
|
16
|
+
rows: [
|
|
17
|
+
['Alice', '30', 'Seattle'],
|
|
18
|
+
['Bob', '25', 'Portland'],
|
|
19
|
+
],
|
|
20
|
+
style: {
|
|
21
|
+
headerBackground: '#1e3a5f',
|
|
22
|
+
headerColor: '#ffffff',
|
|
23
|
+
cellBackground: 'rgba(255,255,255,0.05)',
|
|
24
|
+
cellColor: '#e0e0e0',
|
|
25
|
+
borderColor: 'rgba(255,255,255,0.12)',
|
|
26
|
+
fontSize: 28,
|
|
27
|
+
fontFamily: 'system-ui',
|
|
28
|
+
headerFontFamily: 'Georgia',
|
|
29
|
+
borderRadius: 8,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
position: { x: '10%', y: '10%', width: '80%', height: '80%' },
|
|
33
|
+
...overrides,
|
|
34
|
+
} as TableLayerType;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const viewport = { width: 1920, height: 1080 };
|
|
38
|
+
|
|
39
|
+
function renderTableLayer(layer?: TableLayerType) {
|
|
40
|
+
return render(
|
|
41
|
+
<svg>
|
|
42
|
+
<TableLayer layer={layer ?? makeTableLayer()} viewport={viewport} blockTime={0} />
|
|
43
|
+
</svg>,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Tests
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
describe('TableLayer', () => {
|
|
52
|
+
it('renders a foreignObject element', () => {
|
|
53
|
+
const { container } = renderTableLayer();
|
|
54
|
+
const fo = container.querySelector('foreignObject');
|
|
55
|
+
expect(fo).toBeTruthy();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('renders a table element', () => {
|
|
59
|
+
const { container } = renderTableLayer();
|
|
60
|
+
const table = container.querySelector('table');
|
|
61
|
+
expect(table).toBeTruthy();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('renders the correct number of header cells', () => {
|
|
65
|
+
const { container } = renderTableLayer();
|
|
66
|
+
const headers = container.querySelectorAll('th');
|
|
67
|
+
expect(headers.length).toBe(3);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('renders header text content', () => {
|
|
71
|
+
const { container } = renderTableLayer();
|
|
72
|
+
const headers = container.querySelectorAll('th');
|
|
73
|
+
expect(headers[0].textContent).toBe('Name');
|
|
74
|
+
expect(headers[1].textContent).toBe('Age');
|
|
75
|
+
expect(headers[2].textContent).toBe('City');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('renders the correct number of data rows', () => {
|
|
79
|
+
const { container } = renderTableLayer();
|
|
80
|
+
const rows = container.querySelectorAll('tbody tr');
|
|
81
|
+
expect(rows.length).toBe(2);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('renders cell text content', () => {
|
|
85
|
+
const { container } = renderTableLayer();
|
|
86
|
+
const cells = container.querySelectorAll('td');
|
|
87
|
+
expect(cells[0].textContent).toBe('Alice');
|
|
88
|
+
expect(cells[1].textContent).toBe('30');
|
|
89
|
+
expect(cells[2].textContent).toBe('Seattle');
|
|
90
|
+
expect(cells[3].textContent).toBe('Bob');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('applies header background color', () => {
|
|
94
|
+
const { container } = renderTableLayer();
|
|
95
|
+
const th = container.querySelector('th');
|
|
96
|
+
expect(th?.style.background).toBe('rgb(30, 58, 95)');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('applies font size from style', () => {
|
|
100
|
+
const { container } = renderTableLayer();
|
|
101
|
+
const table = container.querySelector('table');
|
|
102
|
+
expect(table?.style.fontSize).toBe('28px');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('applies column alignment when provided', () => {
|
|
106
|
+
const layer = makeTableLayer();
|
|
107
|
+
layer.content.align = ['left', 'center', 'right'];
|
|
108
|
+
const { container } = renderTableLayer(layer);
|
|
109
|
+
|
|
110
|
+
const headers = container.querySelectorAll('th');
|
|
111
|
+
expect(headers[0].style.textAlign).toBe('left');
|
|
112
|
+
expect(headers[1].style.textAlign).toBe('center');
|
|
113
|
+
expect(headers[2].style.textAlign).toBe('right');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('handles empty headers gracefully', () => {
|
|
117
|
+
const layer = makeTableLayer();
|
|
118
|
+
layer.content.headers = [];
|
|
119
|
+
const { container } = renderTableLayer(layer);
|
|
120
|
+
const thead = container.querySelector('thead');
|
|
121
|
+
// No thead or empty thead
|
|
122
|
+
expect(thead).toBeFalsy();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('handles empty rows gracefully', () => {
|
|
126
|
+
const layer = makeTableLayer();
|
|
127
|
+
layer.content.rows = [];
|
|
128
|
+
const { container } = renderTableLayer(layer);
|
|
129
|
+
const tbody = container.querySelector('tbody');
|
|
130
|
+
// No tbody or empty tbody
|
|
131
|
+
expect(tbody).toBeFalsy();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('sets foreignObject dimensions based on viewport percentage', () => {
|
|
135
|
+
const { container } = renderTableLayer();
|
|
136
|
+
const fo = container.querySelector('foreignObject');
|
|
137
|
+
// 80% of 1920 = 1536
|
|
138
|
+
expect(fo?.getAttribute('width')).toBe('1536');
|
|
139
|
+
// 80% of 1080 = 864
|
|
140
|
+
expect(fo?.getAttribute('height')).toBe('864');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -277,8 +277,11 @@ export function useAudioSync(
|
|
|
277
277
|
const audio = audioRef.current;
|
|
278
278
|
if (!audio || !audioTrack?.segments) return;
|
|
279
279
|
|
|
280
|
-
// Clamp time to valid range
|
|
281
|
-
|
|
280
|
+
// Clamp time to valid range.
|
|
281
|
+
// When totalDuration is 0 (no audio segments), don't clamp — allow
|
|
282
|
+
// seeking by block timing alone (used in render mode / preview).
|
|
283
|
+
const clampedTime =
|
|
284
|
+
totalDuration > 0 ? Math.max(0, Math.min(time, totalDuration)) : Math.max(0, time);
|
|
282
285
|
|
|
283
286
|
// Find which segment this time falls into
|
|
284
287
|
let segmentIndex = 0;
|
|
@@ -18,6 +18,7 @@ import type { Theme } from '@bendyline/squisq/schemas';
|
|
|
18
18
|
import { getBlockAtTime } from '@bendyline/squisq/schemas';
|
|
19
19
|
import {
|
|
20
20
|
expandDocBlocks,
|
|
21
|
+
flattenBlocks,
|
|
21
22
|
isTemplateBlock,
|
|
22
23
|
VIEWPORT_PRESETS,
|
|
23
24
|
type ViewportConfig,
|
|
@@ -76,8 +77,12 @@ export function useDocPlayback(
|
|
|
76
77
|
return [];
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
// Flatten nested block hierarchy (markdown-derived docs have children)
|
|
81
|
+
const hasChildren = script.blocks.some((b) => b.children && b.children.length > 0);
|
|
82
|
+
const flatBlocks = hasChildren ? flattenBlocks(script.blocks) : script.blocks;
|
|
83
|
+
|
|
79
84
|
// Check if any blocks are templates
|
|
80
|
-
const hasTemplates =
|
|
85
|
+
const hasTemplates = flatBlocks.some(isTemplateBlock);
|
|
81
86
|
|
|
82
87
|
if (hasTemplates) {
|
|
83
88
|
// Extract audio segment timing for proper block synchronization
|
|
@@ -87,7 +92,7 @@ export function useDocPlayback(
|
|
|
87
92
|
}));
|
|
88
93
|
|
|
89
94
|
// Expand template blocks with audio segment timing, viewport, and persistent layers
|
|
90
|
-
const expanded = expandDocBlocks(
|
|
95
|
+
const expanded = expandDocBlocks(flatBlocks as DocBlock[], {
|
|
91
96
|
audioSegments,
|
|
92
97
|
viewport,
|
|
93
98
|
persistentLayers: script.persistentLayers,
|
|
@@ -97,7 +102,7 @@ export function useDocPlayback(
|
|
|
97
102
|
}
|
|
98
103
|
|
|
99
104
|
// All raw blocks, use as-is
|
|
100
|
-
return
|
|
105
|
+
return flatBlocks;
|
|
101
106
|
}, [script?.blocks, script?.audio?.segments, script?.persistentLayers, viewport, theme]);
|
|
102
107
|
|
|
103
108
|
// Find current block based on time
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
export { DocPlayer } from './DocPlayer.js';
|
|
3
3
|
export { BlockRenderer, VIEWPORT } from './BlockRenderer.js';
|
|
4
4
|
export { CaptionOverlay } from './CaptionOverlay.js';
|
|
5
|
+
export { SocialCaptionOverlay } from './SocialCaptionOverlay.js';
|
|
5
6
|
export { DocControlsOverlay } from './DocControlsOverlay.js';
|
|
6
7
|
export { DocControlsBottom } from './DocControlsBottom.js';
|
|
7
8
|
export { DocControlsSidebar } from './DocControlsSidebar.js';
|
|
@@ -16,6 +17,7 @@ export { ImageLayer } from './layers/ImageLayer.js';
|
|
|
16
17
|
export { TextLayer } from './layers/TextLayer.js';
|
|
17
18
|
export { ShapeLayer } from './layers/ShapeLayer.js';
|
|
18
19
|
export { VideoLayer } from './layers/VideoLayer.js';
|
|
20
|
+
export { TableLayer } from './layers/TableLayer.js';
|
|
19
21
|
export { MapLayer } from './layers/MapLayer.js';
|
|
20
22
|
|
|
21
23
|
// Hooks
|
|
@@ -32,6 +34,8 @@ export type {
|
|
|
32
34
|
BlockMarker,
|
|
33
35
|
ControlsLayout,
|
|
34
36
|
DisplayMode,
|
|
37
|
+
CaptionStyle,
|
|
38
|
+
CaptionMode,
|
|
35
39
|
SlideNavActions,
|
|
36
40
|
SquisqRenderAPI,
|
|
37
41
|
SquisqWindow,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { ShapeLayer as ShapeLayerType } from '@bendyline/squisq/schemas';
|
|
9
9
|
import { getAnimationStyle } from '../utils/animationUtils';
|
|
10
|
-
import { resolveValue } from '../utils/layerUtils';
|
|
10
|
+
import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
|
|
11
11
|
|
|
12
12
|
interface ShapeLayerProps {
|
|
13
13
|
layer: ShapeLayerType;
|
|
@@ -21,11 +21,16 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
|
|
|
21
21
|
const { content, position, animation } = layer;
|
|
22
22
|
|
|
23
23
|
// Resolve position values to pixels
|
|
24
|
-
const
|
|
25
|
-
const
|
|
24
|
+
const rawX = resolveValue(position.x, viewport.width);
|
|
25
|
+
const rawY = resolveValue(position.y, viewport.height);
|
|
26
26
|
const width = position.width ? resolveValue(position.width, viewport.width) : 100;
|
|
27
27
|
const height = position.height ? resolveValue(position.height, viewport.height) : 100;
|
|
28
28
|
|
|
29
|
+
// Apply anchor offset (e.g., 'center' shifts x by -width/2 and y by -height/2)
|
|
30
|
+
const anchorOffset = getAnchorOffset(position.anchor, width, height);
|
|
31
|
+
const x = rawX + anchorOffset.x;
|
|
32
|
+
const y = rawY + anchorOffset.y;
|
|
33
|
+
|
|
29
34
|
// Get animation styles
|
|
30
35
|
const animStyle = getAnimationStyle(animation, blockTime);
|
|
31
36
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TableLayer Component
|
|
3
|
+
*
|
|
4
|
+
* Renders a data table within an SVG block using a <foreignObject> to embed
|
|
5
|
+
* an HTML table. This gives us native table layout inside SVG viewports.
|
|
6
|
+
*
|
|
7
|
+
* The table is styled inline using the TableLayerStyle properties, which are
|
|
8
|
+
* typically derived from the active theme by the dataTable template.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { TableLayer as TableLayerType } from '@bendyline/squisq/schemas';
|
|
12
|
+
import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
|
|
13
|
+
import { getAnimationStyle } from '../utils/animationUtils';
|
|
14
|
+
|
|
15
|
+
interface TableLayerProps {
|
|
16
|
+
layer: TableLayerType;
|
|
17
|
+
/** Viewport dimensions for percentage calculations */
|
|
18
|
+
viewport: { width: number; height: number };
|
|
19
|
+
/** Current time relative to block start (for animation) */
|
|
20
|
+
blockTime: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function TableLayer({ layer, viewport, blockTime }: TableLayerProps) {
|
|
24
|
+
const { content, position, animation } = layer;
|
|
25
|
+
const { headers, rows, align, style } = content;
|
|
26
|
+
|
|
27
|
+
// Resolve position values to pixels
|
|
28
|
+
const x = resolveValue(position.x, viewport.width);
|
|
29
|
+
const y = resolveValue(position.y, viewport.height);
|
|
30
|
+
const width = position.width ? resolveValue(position.width, viewport.width) : viewport.width;
|
|
31
|
+
const height = position.height ? resolveValue(position.height, viewport.height) : viewport.height;
|
|
32
|
+
|
|
33
|
+
// Apply anchor offset
|
|
34
|
+
const offset = getAnchorOffset(position.anchor, width, height);
|
|
35
|
+
const finalX = x + offset.x;
|
|
36
|
+
const finalY = y + offset.y;
|
|
37
|
+
|
|
38
|
+
// Build animation style
|
|
39
|
+
const animStyle = animation ? getAnimationStyle(animation, blockTime) : {};
|
|
40
|
+
|
|
41
|
+
const cellAlign = (ci: number): React.CSSProperties | undefined => {
|
|
42
|
+
const a = align?.[ci];
|
|
43
|
+
return a ? { textAlign: a } : undefined;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const borderRadius = style.borderRadius ?? 8;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<foreignObject x={finalX} y={finalY} width={width} height={height} style={animStyle}>
|
|
50
|
+
<div
|
|
51
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
52
|
+
{...({ xmlns: 'http://www.w3.org/1999/xhtml' } as any)}
|
|
53
|
+
style={{
|
|
54
|
+
width: '100%',
|
|
55
|
+
height: '100%',
|
|
56
|
+
display: 'flex',
|
|
57
|
+
alignItems: 'center',
|
|
58
|
+
justifyContent: 'center',
|
|
59
|
+
padding: '16px',
|
|
60
|
+
boxSizing: 'border-box',
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
<table
|
|
64
|
+
style={{
|
|
65
|
+
width: '100%',
|
|
66
|
+
borderCollapse: 'separate',
|
|
67
|
+
borderSpacing: 0,
|
|
68
|
+
fontSize: `${style.fontSize}px`,
|
|
69
|
+
fontFamily: style.fontFamily ?? 'system-ui, sans-serif',
|
|
70
|
+
overflow: 'hidden',
|
|
71
|
+
borderRadius: `${borderRadius}px`,
|
|
72
|
+
border: `1px solid ${style.borderColor}`,
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
{headers.length > 0 && (
|
|
76
|
+
<thead>
|
|
77
|
+
<tr>
|
|
78
|
+
{headers.map((header, ci) => (
|
|
79
|
+
<th
|
|
80
|
+
key={ci}
|
|
81
|
+
style={{
|
|
82
|
+
background: style.headerBackground,
|
|
83
|
+
color: style.headerColor,
|
|
84
|
+
fontFamily:
|
|
85
|
+
style.headerFontFamily ?? style.fontFamily ?? 'system-ui, sans-serif',
|
|
86
|
+
fontWeight: 600,
|
|
87
|
+
padding: '12px 16px',
|
|
88
|
+
borderBottom: `2px solid ${style.borderColor}`,
|
|
89
|
+
borderRight:
|
|
90
|
+
ci < headers.length - 1 ? `1px solid ${style.borderColor}` : undefined,
|
|
91
|
+
...cellAlign(ci),
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
{header}
|
|
95
|
+
</th>
|
|
96
|
+
))}
|
|
97
|
+
</tr>
|
|
98
|
+
</thead>
|
|
99
|
+
)}
|
|
100
|
+
{rows.length > 0 && (
|
|
101
|
+
<tbody>
|
|
102
|
+
{rows.map((row, ri) => (
|
|
103
|
+
<tr key={ri}>
|
|
104
|
+
{row.map((cell, ci) => (
|
|
105
|
+
<td
|
|
106
|
+
key={ci}
|
|
107
|
+
style={{
|
|
108
|
+
background: style.cellBackground,
|
|
109
|
+
color: style.cellColor,
|
|
110
|
+
padding: '10px 16px',
|
|
111
|
+
borderBottom:
|
|
112
|
+
ri < rows.length - 1 ? `1px solid ${style.borderColor}` : undefined,
|
|
113
|
+
borderRight:
|
|
114
|
+
ci < row.length - 1 ? `1px solid ${style.borderColor}` : undefined,
|
|
115
|
+
...cellAlign(ci),
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
{cell}
|
|
119
|
+
</td>
|
|
120
|
+
))}
|
|
121
|
+
</tr>
|
|
122
|
+
))}
|
|
123
|
+
</tbody>
|
|
124
|
+
)}
|
|
125
|
+
</table>
|
|
126
|
+
</div>
|
|
127
|
+
</foreignObject>
|
|
128
|
+
);
|
|
129
|
+
}
|
package/src/layers/TextLayer.tsx
CHANGED
|
@@ -38,7 +38,7 @@ export function TextLayer({ layer, viewport, blockTime }: TextLayerProps) {
|
|
|
38
38
|
const animStyle = getAnimationStyle(animation, blockTime);
|
|
39
39
|
|
|
40
40
|
// Split text into lines, and wrap if maxWidth is specified
|
|
41
|
-
const rawLines = text.split('\n');
|
|
41
|
+
const rawLines = (text ?? '').split('\n');
|
|
42
42
|
let lines = maxWidth
|
|
43
43
|
? rawLines.reduce<string[]>(
|
|
44
44
|
(acc, line) => acc.concat(wrapText(line, style.fontSize, maxWidth)),
|