@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.
- package/dist/index.d.ts +563 -0
- package/dist/index.js +3180 -0
- package/dist/index.js.map +1 -0
- package/dist/squisq-player.css +2 -0
- package/dist/squisq-player.css.map +1 -0
- package/dist/squisq-player.global.js +6 -0
- package/dist/squisq-player.global.js.map +1 -0
- package/dist/standalone-source.d.ts +2 -0
- package/dist/standalone-source.js +2 -0
- package/package.json +69 -0
- package/src/BlockRenderer.tsx +146 -0
- package/src/CaptionOverlay.tsx +86 -0
- package/src/DocControlsBottom.tsx +103 -0
- package/src/DocControlsOverlay.tsx +178 -0
- package/src/DocControlsSidebar.tsx +107 -0
- package/src/DocControlsSlideshow.tsx +132 -0
- package/src/DocPlayer.tsx +1005 -0
- package/src/DocPlayerWithSidebar.tsx +138 -0
- package/src/DocProgressBar.tsx +200 -0
- package/src/LinearDocView.tsx +313 -0
- package/src/MarkdownRenderer.tsx +360 -0
- package/src/__tests__/BlockRenderer.test.tsx +105 -0
- package/src/__tests__/DocControlsSlideshow.test.tsx +127 -0
- package/src/__tests__/LinearDocView.test.tsx +180 -0
- package/src/__tests__/MarkdownRenderer.test.tsx +234 -0
- package/src/__tests__/exports.test.ts +55 -0
- package/src/hooks/AudioProvider.ts +114 -0
- package/src/hooks/MediaContext.tsx +81 -0
- package/src/hooks/index.ts +6 -0
- package/src/hooks/useAudioSync.ts +390 -0
- package/src/hooks/useDocPlayback.ts +251 -0
- package/src/hooks/useViewportOrientation.ts +117 -0
- package/src/index.ts +46 -0
- package/src/layers/ImageLayer.tsx +182 -0
- package/src/layers/MapLayer.tsx +184 -0
- package/src/layers/ShapeLayer.tsx +107 -0
- package/src/layers/TextLayer.tsx +197 -0
- package/src/layers/VideoLayer.tsx +150 -0
- package/src/layers/index.ts +5 -0
- package/src/standalone-entry.tsx +228 -0
- package/src/styles/doc-animations.css +458 -0
- package/src/types.ts +152 -0
- package/src/utils/animationUtils.ts +13 -0
- package/src/utils/layerUtils.ts +42 -0
- 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
|
+
});
|