@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.
Files changed (87) hide show
  1. package/dist/BlockRenderer.d.ts.map +1 -1
  2. package/dist/BlockRenderer.js +3 -0
  3. package/dist/BlockRenderer.js.map +1 -1
  4. package/dist/CaptionOverlay.d.ts +16 -7
  5. package/dist/CaptionOverlay.d.ts.map +1 -1
  6. package/dist/CaptionOverlay.js +7 -3
  7. package/dist/CaptionOverlay.js.map +1 -1
  8. package/dist/DocControlsBottom.d.ts.map +1 -1
  9. package/dist/DocControlsBottom.js +5 -1
  10. package/dist/DocControlsBottom.js.map +1 -1
  11. package/dist/DocControlsOverlay.d.ts.map +1 -1
  12. package/dist/DocControlsOverlay.js +9 -4
  13. package/dist/DocControlsOverlay.js.map +1 -1
  14. package/dist/DocControlsSidebar.d.ts.map +1 -1
  15. package/dist/DocControlsSidebar.js +5 -1
  16. package/dist/DocControlsSidebar.js.map +1 -1
  17. package/dist/DocPlayer.d.ts +5 -2
  18. package/dist/DocPlayer.d.ts.map +1 -1
  19. package/dist/DocPlayer.js +36 -7
  20. package/dist/DocPlayer.js.map +1 -1
  21. package/dist/DocPlayerWithSidebar.d.ts.map +1 -1
  22. package/dist/DocPlayerWithSidebar.js +1 -0
  23. package/dist/DocPlayerWithSidebar.js.map +1 -1
  24. package/dist/DocProgressBar.js +2 -2
  25. package/dist/LinearDocView.d.ts.map +1 -1
  26. package/dist/LinearDocView.js +130 -6
  27. package/dist/LinearDocView.js.map +1 -1
  28. package/dist/SocialCaptionOverlay.d.ts +27 -0
  29. package/dist/SocialCaptionOverlay.d.ts.map +1 -0
  30. package/dist/SocialCaptionOverlay.js +184 -0
  31. package/dist/SocialCaptionOverlay.js.map +1 -0
  32. package/dist/__tests__/DocControlsSlideshow.test.js +1 -0
  33. package/dist/__tests__/DocControlsSlideshow.test.js.map +1 -1
  34. package/dist/__tests__/TableLayer.test.d.ts +2 -0
  35. package/dist/__tests__/TableLayer.test.d.ts.map +1 -0
  36. package/dist/__tests__/TableLayer.test.js +121 -0
  37. package/dist/__tests__/TableLayer.test.js.map +1 -0
  38. package/dist/hooks/useAudioSync.d.ts.map +1 -1
  39. package/dist/hooks/useAudioSync.js +4 -2
  40. package/dist/hooks/useAudioSync.js.map +1 -1
  41. package/dist/hooks/useDocPlayback.d.ts.map +1 -1
  42. package/dist/hooks/useDocPlayback.js +7 -4
  43. package/dist/hooks/useDocPlayback.js.map +1 -1
  44. package/dist/index.d.ts +3 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +2 -0
  47. package/dist/index.js.map +1 -1
  48. package/dist/layers/ShapeLayer.d.ts.map +1 -1
  49. package/dist/layers/ShapeLayer.js +7 -3
  50. package/dist/layers/ShapeLayer.js.map +1 -1
  51. package/dist/layers/TableLayer.d.ts +23 -0
  52. package/dist/layers/TableLayer.d.ts.map +1 -0
  53. package/dist/layers/TableLayer.js +58 -0
  54. package/dist/layers/TableLayer.js.map +1 -0
  55. package/dist/layers/TextLayer.js +1 -1
  56. package/dist/layers/TextLayer.js.map +1 -1
  57. package/dist/squisq-player.global.js +91 -5
  58. package/dist/squisq-player.global.js.map +1 -1
  59. package/dist/standalone-entry.d.ts +8 -0
  60. package/dist/standalone-entry.d.ts.map +1 -1
  61. package/dist/standalone-entry.js +6 -3
  62. package/dist/standalone-entry.js.map +1 -1
  63. package/dist/standalone-source.js +1 -1
  64. package/dist/types.d.ts +19 -0
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/types.js.map +1 -1
  67. package/package.json +2 -2
  68. package/src/BlockRenderer.tsx +3 -0
  69. package/src/CaptionOverlay.tsx +33 -8
  70. package/src/DocControlsBottom.tsx +11 -5
  71. package/src/DocControlsOverlay.tsx +18 -6
  72. package/src/DocControlsSidebar.tsx +11 -5
  73. package/src/DocPlayer.tsx +56 -9
  74. package/src/DocPlayerWithSidebar.tsx +1 -0
  75. package/src/DocProgressBar.tsx +2 -2
  76. package/src/LinearDocView.tsx +149 -12
  77. package/src/SocialCaptionOverlay.tsx +254 -0
  78. package/src/__tests__/DocControlsSlideshow.test.tsx +1 -0
  79. package/src/__tests__/TableLayer.test.tsx +142 -0
  80. package/src/hooks/useAudioSync.ts +5 -2
  81. package/src/hooks/useDocPlayback.ts +8 -3
  82. package/src/index.ts +4 -0
  83. package/src/layers/ShapeLayer.tsx +8 -3
  84. package/src/layers/TableLayer.tsx +129 -0
  85. package/src/layers/TextLayer.tsx +1 -1
  86. package/src/standalone-entry.tsx +23 -3
  87. 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
+ }
@@ -13,6 +13,7 @@ function makeState(overrides: Partial<PlaybackState> = {}): PlaybackState {
13
13
  docProgress: 0.2,
14
14
  hasCaptions: false,
15
15
  captionsEnabled: false,
16
+ captionMode: 'off',
16
17
  currentSegmentIndex: 0,
17
18
  currentSegmentName: null,
18
19
  currentBlock: null,
@@ -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
- const clampedTime = Math.max(0, Math.min(time, totalDuration));
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 = script.blocks.some(isTemplateBlock);
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(script.blocks as DocBlock[], {
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 script.blocks;
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 x = resolveValue(position.x, viewport.width);
25
- const y = resolveValue(position.y, viewport.height);
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
+ }
@@ -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)),