@bendyline/squisq-react 0.1.2

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 (45) hide show
  1. package/dist/index.d.ts +563 -0
  2. package/dist/index.js +3180 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/squisq-player.css +2 -0
  5. package/dist/squisq-player.css.map +1 -0
  6. package/dist/squisq-player.global.js +6 -0
  7. package/dist/squisq-player.global.js.map +1 -0
  8. package/dist/standalone-source.d.ts +2 -0
  9. package/dist/standalone-source.js +2 -0
  10. package/package.json +69 -0
  11. package/src/BlockRenderer.tsx +146 -0
  12. package/src/CaptionOverlay.tsx +86 -0
  13. package/src/DocControlsBottom.tsx +103 -0
  14. package/src/DocControlsOverlay.tsx +178 -0
  15. package/src/DocControlsSidebar.tsx +107 -0
  16. package/src/DocControlsSlideshow.tsx +132 -0
  17. package/src/DocPlayer.tsx +1005 -0
  18. package/src/DocPlayerWithSidebar.tsx +138 -0
  19. package/src/DocProgressBar.tsx +200 -0
  20. package/src/LinearDocView.tsx +313 -0
  21. package/src/MarkdownRenderer.tsx +360 -0
  22. package/src/__tests__/BlockRenderer.test.tsx +105 -0
  23. package/src/__tests__/DocControlsSlideshow.test.tsx +127 -0
  24. package/src/__tests__/LinearDocView.test.tsx +180 -0
  25. package/src/__tests__/MarkdownRenderer.test.tsx +234 -0
  26. package/src/__tests__/exports.test.ts +55 -0
  27. package/src/hooks/AudioProvider.ts +114 -0
  28. package/src/hooks/MediaContext.tsx +81 -0
  29. package/src/hooks/index.ts +6 -0
  30. package/src/hooks/useAudioSync.ts +390 -0
  31. package/src/hooks/useDocPlayback.ts +251 -0
  32. package/src/hooks/useViewportOrientation.ts +117 -0
  33. package/src/index.ts +46 -0
  34. package/src/layers/ImageLayer.tsx +182 -0
  35. package/src/layers/MapLayer.tsx +184 -0
  36. package/src/layers/ShapeLayer.tsx +107 -0
  37. package/src/layers/TextLayer.tsx +197 -0
  38. package/src/layers/VideoLayer.tsx +150 -0
  39. package/src/layers/index.ts +5 -0
  40. package/src/standalone-entry.tsx +228 -0
  41. package/src/styles/doc-animations.css +458 -0
  42. package/src/types.ts +152 -0
  43. package/src/utils/animationUtils.ts +13 -0
  44. package/src/utils/layerUtils.ts +42 -0
  45. package/src/utils/mapTileUtils.ts +375 -0
@@ -0,0 +1,360 @@
1
+ /**
2
+ * MarkdownRenderer Component
3
+ *
4
+ * Converts a MarkdownBlockNode[] AST into React elements for rendering
5
+ * markdown content as readable HTML. Used by LinearDocView to display
6
+ * non-annotated document sections as flowing text.
7
+ *
8
+ * Supports all block and inline node types from the markdown DOM:
9
+ * - Block: paragraph, heading, blockquote, list, code, table,
10
+ * thematicBreak, math, htmlBlock, definitionList, directives
11
+ * - Inline: text, emphasis, strong, delete, inlineCode, link,
12
+ * image, break, inlineMath, htmlInline, footnoteReference
13
+ *
14
+ * All elements use the `squisq-md-*` CSS class prefix for styling.
15
+ */
16
+
17
+ import { Fragment } from 'react';
18
+ import type {
19
+ MarkdownBlockNode,
20
+ MarkdownInlineNode,
21
+ MarkdownListItem,
22
+ MarkdownTableRow,
23
+ MarkdownTableCell,
24
+ } from '@bendyline/squisq/markdown';
25
+
26
+ // ── Props ──────────────────────────────────────────────────────────
27
+
28
+ export interface MarkdownRendererProps {
29
+ /** Block-level AST nodes to render */
30
+ nodes: MarkdownBlockNode[];
31
+ /** Optional CSS class for the wrapper element */
32
+ className?: string;
33
+ }
34
+
35
+ // ── Inline Renderer ────────────────────────────────────────────────
36
+
37
+ /** Render an array of inline nodes into React elements. */
38
+ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactNode[] {
39
+ return nodes.map((node, i) => {
40
+ const key = `${keyPrefix}i${i}`;
41
+ switch (node.type) {
42
+ case 'text':
43
+ return <Fragment key={key}>{node.value}</Fragment>;
44
+
45
+ case 'emphasis':
46
+ return (
47
+ <em key={key} className="squisq-md-em">
48
+ {renderInline(node.children, key)}
49
+ </em>
50
+ );
51
+
52
+ case 'strong':
53
+ return (
54
+ <strong key={key} className="squisq-md-strong">
55
+ {renderInline(node.children, key)}
56
+ </strong>
57
+ );
58
+
59
+ case 'delete':
60
+ return (
61
+ <del key={key} className="squisq-md-del">
62
+ {renderInline(node.children, key)}
63
+ </del>
64
+ );
65
+
66
+ case 'inlineCode':
67
+ return (
68
+ <code key={key} className="squisq-md-inline-code">
69
+ {node.value}
70
+ </code>
71
+ );
72
+
73
+ case 'link':
74
+ return (
75
+ <a
76
+ key={key}
77
+ className="squisq-md-link"
78
+ href={node.url}
79
+ title={node.title ?? undefined}
80
+ target="_blank"
81
+ rel="noopener noreferrer"
82
+ >
83
+ {renderInline(node.children, key)}
84
+ </a>
85
+ );
86
+
87
+ case 'image':
88
+ return (
89
+ <img
90
+ key={key}
91
+ className="squisq-md-image"
92
+ src={node.url}
93
+ alt={node.alt ?? ''}
94
+ title={node.title ?? undefined}
95
+ />
96
+ );
97
+
98
+ case 'break':
99
+ return <br key={key} />;
100
+
101
+ case 'inlineMath':
102
+ return (
103
+ <code key={key} className="squisq-md-inline-math">
104
+ {node.value}
105
+ </code>
106
+ );
107
+
108
+ case 'htmlInline':
109
+ return (
110
+ <span
111
+ key={key}
112
+ className="squisq-md-html-inline"
113
+ dangerouslySetInnerHTML={{ __html: node.rawHtml }}
114
+ />
115
+ );
116
+
117
+ case 'footnoteReference':
118
+ return (
119
+ <sup key={key} className="squisq-md-footnote-ref">
120
+ <a href={`#fn-${node.identifier}`}>[{node.label ?? node.identifier}]</a>
121
+ </sup>
122
+ );
123
+
124
+ case 'linkReference':
125
+ // Render as plain text (definition targets not available at render time)
126
+ return (
127
+ <span key={key} className="squisq-md-link-ref">
128
+ {renderInline(node.children, key)}
129
+ </span>
130
+ );
131
+
132
+ case 'imageReference':
133
+ return (
134
+ <span key={key} className="squisq-md-image-ref">
135
+ [{node.alt ?? node.identifier}]
136
+ </span>
137
+ );
138
+
139
+ case 'textDirective':
140
+ return (
141
+ <span key={key} className="squisq-md-text-directive" data-directive={node.name}>
142
+ {renderInline(node.children, key)}
143
+ </span>
144
+ );
145
+
146
+ default:
147
+ return null;
148
+ }
149
+ });
150
+ }
151
+
152
+ // ── Block Renderer ─────────────────────────────────────────────────
153
+
154
+ /** Render a single block-level node into a React element. */
155
+ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
156
+ switch (node.type) {
157
+ case 'paragraph':
158
+ return (
159
+ <p key={key} className="squisq-md-p">
160
+ {renderInline(node.children, key)}
161
+ </p>
162
+ );
163
+
164
+ case 'heading': {
165
+ const Tag = `h${node.depth}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
166
+ return (
167
+ <Tag key={key} className={`squisq-md-heading squisq-md-h${node.depth}`}>
168
+ {renderInline(node.children, key)}
169
+ </Tag>
170
+ );
171
+ }
172
+
173
+ case 'blockquote':
174
+ return (
175
+ <blockquote key={key} className="squisq-md-blockquote">
176
+ {renderBlocks(node.children, key)}
177
+ </blockquote>
178
+ );
179
+
180
+ case 'list':
181
+ if (node.ordered) {
182
+ return (
183
+ <ol key={key} className="squisq-md-list squisq-md-ol" start={node.start ?? undefined}>
184
+ {node.children.map((item, i) => renderListItem(item, `${key}li${i}`))}
185
+ </ol>
186
+ );
187
+ }
188
+ return (
189
+ <ul key={key} className="squisq-md-list squisq-md-ul">
190
+ {node.children.map((item, i) => renderListItem(item, `${key}li${i}`))}
191
+ </ul>
192
+ );
193
+
194
+ case 'code':
195
+ return (
196
+ <pre key={key} className="squisq-md-code-block">
197
+ <code className={node.lang ? `language-${node.lang}` : undefined}>{node.value}</code>
198
+ </pre>
199
+ );
200
+
201
+ case 'thematicBreak':
202
+ return <hr key={key} className="squisq-md-hr" />;
203
+
204
+ case 'table':
205
+ return renderTable(node.children, node.align, key);
206
+
207
+ case 'htmlBlock':
208
+ return (
209
+ <div
210
+ key={key}
211
+ className="squisq-md-html-block"
212
+ dangerouslySetInnerHTML={{ __html: node.rawHtml }}
213
+ />
214
+ );
215
+
216
+ case 'math':
217
+ return (
218
+ <pre key={key} className="squisq-md-math-block">
219
+ <code>{node.value}</code>
220
+ </pre>
221
+ );
222
+
223
+ case 'definition':
224
+ // Link definitions aren't rendered visually
225
+ return null;
226
+
227
+ case 'footnoteDefinition':
228
+ return (
229
+ <div key={key} className="squisq-md-footnote-def" id={`fn-${node.identifier}`}>
230
+ <sup>{node.label ?? node.identifier}</sup>
231
+ {renderBlocks(node.children, key)}
232
+ </div>
233
+ );
234
+
235
+ case 'containerDirective':
236
+ return (
237
+ <div
238
+ key={key}
239
+ className={`squisq-md-directive squisq-md-directive-${node.name}`}
240
+ data-directive={node.name}
241
+ >
242
+ {node.label && <div className="squisq-md-directive-label">{node.label}</div>}
243
+ {renderBlocks(node.children, key)}
244
+ </div>
245
+ );
246
+
247
+ case 'leafDirective':
248
+ return (
249
+ <div
250
+ key={key}
251
+ className={`squisq-md-directive squisq-md-directive-${node.name}`}
252
+ data-directive={node.name}
253
+ >
254
+ {renderInline(node.children, key)}
255
+ </div>
256
+ );
257
+
258
+ case 'definitionList':
259
+ return (
260
+ <dl key={key} className="squisq-md-dl">
261
+ {node.children.map((child, i) => {
262
+ if (child.type === 'definitionTerm') {
263
+ return (
264
+ <dt key={`${key}dt${i}`} className="squisq-md-dt">
265
+ {renderInline(child.children, `${key}dt${i}`)}
266
+ </dt>
267
+ );
268
+ }
269
+ return (
270
+ <dd key={`${key}dd${i}`} className="squisq-md-dd">
271
+ {renderBlocks(child.children, `${key}dd${i}`)}
272
+ </dd>
273
+ );
274
+ })}
275
+ </dl>
276
+ );
277
+
278
+ default:
279
+ return null;
280
+ }
281
+ }
282
+
283
+ /** Render a list item, including task-list checkbox support. */
284
+ function renderListItem(item: MarkdownListItem, key: string): React.ReactNode {
285
+ const isTask = item.checked !== null && item.checked !== undefined;
286
+ return (
287
+ <li key={key} className={`squisq-md-li${isTask ? ' squisq-md-task' : ''}`}>
288
+ {isTask && (
289
+ <input type="checkbox" checked={!!item.checked} readOnly className="squisq-md-checkbox" />
290
+ )}
291
+ {renderBlocks(item.children, key)}
292
+ </li>
293
+ );
294
+ }
295
+
296
+ /** Render a table from rows and alignment data. */
297
+ function renderTable(
298
+ rows: MarkdownTableRow[],
299
+ align: (('left' | 'right' | 'center') | null)[] | undefined,
300
+ key: string,
301
+ ): React.ReactNode {
302
+ const [headerRow, ...bodyRows] = rows;
303
+ return (
304
+ <table key={key} className="squisq-md-table">
305
+ {headerRow && (
306
+ <thead>
307
+ <tr>
308
+ {headerRow.children.map((cell: MarkdownTableCell, ci: number) => (
309
+ <th
310
+ key={`${key}th${ci}`}
311
+ className="squisq-md-th"
312
+ style={align?.[ci] ? { textAlign: align[ci]! } : undefined}
313
+ >
314
+ {renderInline(cell.children, `${key}th${ci}`)}
315
+ </th>
316
+ ))}
317
+ </tr>
318
+ </thead>
319
+ )}
320
+ {bodyRows.length > 0 && (
321
+ <tbody>
322
+ {bodyRows.map((row, ri) => (
323
+ <tr key={`${key}tr${ri}`}>
324
+ {row.children.map((cell: MarkdownTableCell, ci: number) => (
325
+ <td
326
+ key={`${key}td${ri}-${ci}`}
327
+ className="squisq-md-td"
328
+ style={align?.[ci] ? { textAlign: align[ci]! } : undefined}
329
+ >
330
+ {renderInline(cell.children, `${key}td${ri}-${ci}`)}
331
+ </td>
332
+ ))}
333
+ </tr>
334
+ ))}
335
+ </tbody>
336
+ )}
337
+ </table>
338
+ );
339
+ }
340
+
341
+ /** Render an array of block-level nodes. */
342
+ function renderBlocks(nodes: MarkdownBlockNode[], keyPrefix = ''): React.ReactNode[] {
343
+ return nodes.map((node, i) => renderBlock(node, `${keyPrefix}b${i}`));
344
+ }
345
+
346
+ // ── Main Component ─────────────────────────────────────────────────
347
+
348
+ /**
349
+ * Renders MarkdownBlockNode[] AST as React HTML elements.
350
+ *
351
+ * @example
352
+ * ```tsx
353
+ * <MarkdownRenderer nodes={block.contents} />
354
+ * ```
355
+ */
356
+ export function MarkdownRenderer({ nodes, className }: MarkdownRendererProps) {
357
+ if (!nodes || nodes.length === 0) return null;
358
+
359
+ return <div className={`squisq-md ${className || ''}`}>{renderBlocks(nodes)}</div>;
360
+ }
@@ -0,0 +1,105 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render } from '@testing-library/react';
3
+ import { BlockRenderer, VIEWPORT } from '../BlockRenderer';
4
+ import type { Block } from '@bendyline/squisq/schemas';
5
+
6
+ describe('VIEWPORT constant', () => {
7
+ it('has correct 1080p dimensions', () => {
8
+ expect(VIEWPORT.width).toBe(1920);
9
+ expect(VIEWPORT.height).toBe(1080);
10
+ });
11
+ });
12
+
13
+ describe('BlockRenderer', () => {
14
+ const minimalBlock: Block = {
15
+ id: 'test-block',
16
+ startTime: 0,
17
+ duration: 5,
18
+ audioSegment: 0,
19
+ layers: [],
20
+ };
21
+
22
+ it('renders an SVG element', () => {
23
+ const { container } = render(
24
+ <BlockRenderer block={minimalBlock} blockTime={0} basePath="/test" />,
25
+ );
26
+ const svg = container.querySelector('svg');
27
+ expect(svg).toBeTruthy();
28
+ });
29
+
30
+ it('renders with correct viewBox', () => {
31
+ const { container } = render(
32
+ <BlockRenderer block={minimalBlock} blockTime={0} basePath="/test" />,
33
+ );
34
+ const svg = container.querySelector('svg');
35
+ expect(svg?.getAttribute('viewBox')).toBe('0 0 1920 1080');
36
+ });
37
+
38
+ it('renders with custom viewport', () => {
39
+ const { container } = render(
40
+ <BlockRenderer
41
+ block={minimalBlock}
42
+ blockTime={0}
43
+ basePath="/test"
44
+ viewport={{ width: 1080, height: 1920 }}
45
+ />,
46
+ );
47
+ const svg = container.querySelector('svg');
48
+ expect(svg?.getAttribute('viewBox')).toBe('0 0 1080 1920');
49
+ });
50
+
51
+ it('renders text layers', () => {
52
+ const blockWithText: Block = {
53
+ id: 'text-block',
54
+ startTime: 0,
55
+ duration: 5,
56
+ audioSegment: 0,
57
+ layers: [
58
+ {
59
+ type: 'text',
60
+ id: 'title',
61
+ content: {
62
+ text: 'Hello World',
63
+ style: {
64
+ fontSize: 48,
65
+ color: '#ffffff',
66
+ },
67
+ },
68
+ position: { x: 100, y: 100 },
69
+ },
70
+ ],
71
+ };
72
+
73
+ const { container } = render(
74
+ <BlockRenderer block={blockWithText} blockTime={0} basePath="/test" />,
75
+ );
76
+ // Text should be rendered somewhere in the SVG
77
+ expect(container.textContent).toContain('Hello World');
78
+ });
79
+
80
+ it('renders shape layers', () => {
81
+ const blockWithShape: Block = {
82
+ id: 'shape-block',
83
+ startTime: 0,
84
+ duration: 5,
85
+ audioSegment: 0,
86
+ layers: [
87
+ {
88
+ type: 'shape',
89
+ id: 'bg',
90
+ content: {
91
+ shape: 'rect',
92
+ fill: '#000000',
93
+ },
94
+ position: { x: 0, y: 0, width: 1920, height: 1080 },
95
+ },
96
+ ],
97
+ };
98
+
99
+ const { container } = render(
100
+ <BlockRenderer block={blockWithShape} blockTime={0} basePath="/test" />,
101
+ );
102
+ const rect = container.querySelector('rect');
103
+ expect(rect).toBeTruthy();
104
+ });
105
+ });
@@ -0,0 +1,127 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, fireEvent } from '@testing-library/react';
3
+ import { DocControlsSlideshow } from '../DocControlsSlideshow';
4
+ import type { PlaybackState, SlideNavActions } from '../types';
5
+
6
+ function makeState(overrides: Partial<PlaybackState> = {}): PlaybackState {
7
+ return {
8
+ isPlaying: false,
9
+ currentTime: 0,
10
+ totalDuration: 60,
11
+ currentBlockIndex: 2,
12
+ totalBlocks: 10,
13
+ docProgress: 0.2,
14
+ hasCaptions: false,
15
+ captionsEnabled: false,
16
+ currentSegmentIndex: 0,
17
+ currentSegmentName: null,
18
+ currentBlock: null,
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ function makeSlideNav(overrides: Partial<SlideNavActions> = {}): SlideNavActions {
24
+ return {
25
+ nextSlide: overrides.nextSlide ?? (() => {}),
26
+ prevSlide: overrides.prevSlide ?? (() => {}),
27
+ goToSlide: overrides.goToSlide ?? (() => {}),
28
+ };
29
+ }
30
+
31
+ describe('DocControlsSlideshow', () => {
32
+ it('renders prev, next buttons and slide counter', () => {
33
+ const { getByTestId } = render(
34
+ <DocControlsSlideshow state={makeState()} slideNav={makeSlideNav()} />,
35
+ );
36
+ expect(getByTestId('slide-prev')).toBeTruthy();
37
+ expect(getByTestId('slide-next')).toBeTruthy();
38
+ expect(getByTestId('slide-counter')).toBeTruthy();
39
+ });
40
+
41
+ it('shows correct slide counter text', () => {
42
+ const { getByTestId } = render(
43
+ <DocControlsSlideshow
44
+ state={makeState({ currentBlockIndex: 4, totalBlocks: 12 })}
45
+ slideNav={makeSlideNav()}
46
+ />,
47
+ );
48
+ expect(getByTestId('slide-counter').textContent).toBe('5 / 12');
49
+ });
50
+
51
+ it('disables prev button on first slide', () => {
52
+ const { getByTestId } = render(
53
+ <DocControlsSlideshow
54
+ state={makeState({ currentBlockIndex: 0 })}
55
+ slideNav={makeSlideNav()}
56
+ />,
57
+ );
58
+ const prevBtn = getByTestId('slide-prev') as HTMLButtonElement;
59
+ expect(prevBtn.disabled).toBe(true);
60
+ });
61
+
62
+ it('disables next button on last slide', () => {
63
+ const { getByTestId } = render(
64
+ <DocControlsSlideshow
65
+ state={makeState({ currentBlockIndex: 9, totalBlocks: 10 })}
66
+ slideNav={makeSlideNav()}
67
+ />,
68
+ );
69
+ const nextBtn = getByTestId('slide-next') as HTMLButtonElement;
70
+ expect(nextBtn.disabled).toBe(true);
71
+ });
72
+
73
+ it('enables both buttons on a middle slide', () => {
74
+ const { getByTestId } = render(
75
+ <DocControlsSlideshow
76
+ state={makeState({ currentBlockIndex: 3, totalBlocks: 10 })}
77
+ slideNav={makeSlideNav()}
78
+ />,
79
+ );
80
+ const prevBtn = getByTestId('slide-prev') as HTMLButtonElement;
81
+ const nextBtn = getByTestId('slide-next') as HTMLButtonElement;
82
+ expect(prevBtn.disabled).toBe(false);
83
+ expect(nextBtn.disabled).toBe(false);
84
+ });
85
+
86
+ it('calls nextSlide when next button is clicked', () => {
87
+ let called = false;
88
+ const nav = makeSlideNav({
89
+ nextSlide: () => {
90
+ called = true;
91
+ },
92
+ });
93
+ const { getByTestId } = render(<DocControlsSlideshow state={makeState()} slideNav={nav} />);
94
+ fireEvent.click(getByTestId('slide-next'));
95
+ expect(called).toBe(true);
96
+ });
97
+
98
+ it('calls prevSlide when prev button is clicked', () => {
99
+ let called = false;
100
+ const nav = makeSlideNav({
101
+ prevSlide: () => {
102
+ called = true;
103
+ },
104
+ });
105
+ const { getByTestId } = render(<DocControlsSlideshow state={makeState()} slideNav={nav} />);
106
+ fireEvent.click(getByTestId('slide-prev'));
107
+ expect(called).toBe(true);
108
+ });
109
+
110
+ it('shows dash for empty doc', () => {
111
+ const { getByTestId } = render(
112
+ <DocControlsSlideshow
113
+ state={makeState({ currentBlockIndex: -1, totalBlocks: 0 })}
114
+ slideNav={makeSlideNav()}
115
+ />,
116
+ );
117
+ expect(getByTestId('slide-counter').textContent).toBe('—');
118
+ });
119
+
120
+ it('has correct aria-labels', () => {
121
+ const { getByTestId } = render(
122
+ <DocControlsSlideshow state={makeState()} slideNav={makeSlideNav()} />,
123
+ );
124
+ expect(getByTestId('slide-prev').getAttribute('aria-label')).toBe('Previous slide');
125
+ expect(getByTestId('slide-next').getAttribute('aria-label')).toBe('Next slide');
126
+ });
127
+ });