@bendyline/squisq-react 1.3.1 → 1.4.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/index.d.ts +766 -35
- package/dist/index.js +4793 -33
- package/dist/index.js.map +1 -1
- package/dist/squisq-player.css +1 -1
- package/dist/squisq-player.css.map +1 -1
- package/dist/squisq-player.global.js +17 -13
- package/dist/squisq-player.global.js.map +1 -1
- package/dist/standalone-source.js +1 -1
- package/package.json +3 -2
- package/src/BlockRenderer.tsx +15 -7
- package/src/DocPlayer.tsx +65 -12
- package/src/DocProgressBar.tsx +21 -3
- package/src/LinearDocView.tsx +11 -197
- package/src/MarkdownRenderer.tsx +165 -41
- package/src/MediaClipLayer.tsx +135 -0
- package/src/__tests__/DocPlayer.test.tsx +51 -0
- package/src/__tests__/DocProgressBar.test.tsx +76 -0
- package/src/__tests__/MarkdownRenderer.test.tsx +95 -1
- package/src/__tests__/PathLayer.test.tsx +73 -0
- package/src/__tests__/fillStyle.test.tsx +112 -0
- package/src/__tests__/transitionStyles.test.ts +125 -0
- package/src/__tests__/useDocPlayback.transition.test.ts +70 -0
- package/src/hooks/useAudioSync.ts +14 -1
- package/src/hooks/useDocPlayback.ts +81 -100
- package/src/hooks/useMediaSchedule.ts +39 -0
- package/src/index.ts +7 -0
- package/src/layers/ImageLayer.tsx +11 -1
- package/src/layers/PathLayer.tsx +146 -0
- package/src/layers/ShapeLayer.tsx +27 -5
- package/src/layers/TextLayer.tsx +395 -22
- package/src/layers/VideoLayer.tsx +16 -9
- package/src/layers/index.ts +1 -0
- package/src/styles/doc-animations.css +1857 -2
- package/src/utils/fillStyle.tsx +148 -0
- package/dist/BlockRenderer.d.ts +0 -37
- package/dist/BlockRenderer.d.ts.map +0 -1
- package/dist/BlockRenderer.js +0 -54
- package/dist/BlockRenderer.js.map +0 -1
- package/dist/CaptionOverlay.d.ts +0 -33
- package/dist/CaptionOverlay.d.ts.map +0 -1
- package/dist/CaptionOverlay.js +0 -44
- package/dist/CaptionOverlay.js.map +0 -1
- package/dist/DocControlsBottom.d.ts +0 -28
- package/dist/DocControlsBottom.d.ts.map +0 -1
- package/dist/DocControlsBottom.js +0 -27
- package/dist/DocControlsBottom.js.map +0 -1
- package/dist/DocControlsOverlay.d.ts +0 -28
- package/dist/DocControlsOverlay.d.ts.map +0 -1
- package/dist/DocControlsOverlay.js +0 -80
- package/dist/DocControlsOverlay.js.map +0 -1
- package/dist/DocControlsSidebar.d.ts +0 -27
- package/dist/DocControlsSidebar.d.ts.map +0 -1
- package/dist/DocControlsSidebar.js +0 -10
- package/dist/DocControlsSidebar.js.map +0 -1
- package/dist/DocControlsSlideshow.d.ts +0 -20
- package/dist/DocControlsSlideshow.d.ts.map +0 -1
- package/dist/DocControlsSlideshow.js +0 -69
- package/dist/DocControlsSlideshow.js.map +0 -1
- package/dist/DocPlayer.d.ts +0 -97
- package/dist/DocPlayer.d.ts.map +0 -1
- package/dist/DocPlayer.js +0 -657
- package/dist/DocPlayer.js.map +0 -1
- package/dist/DocPlayerWithSidebar.d.ts +0 -41
- package/dist/DocPlayerWithSidebar.d.ts.map +0 -1
- package/dist/DocPlayerWithSidebar.js +0 -69
- package/dist/DocPlayerWithSidebar.js.map +0 -1
- package/dist/DocProgressBar.d.ts +0 -31
- package/dist/DocProgressBar.d.ts.map +0 -1
- package/dist/DocProgressBar.js +0 -122
- package/dist/DocProgressBar.js.map +0 -1
- package/dist/InlineAudioPlayer.d.ts +0 -15
- package/dist/InlineAudioPlayer.d.ts.map +0 -1
- package/dist/InlineAudioPlayer.js +0 -22
- package/dist/InlineAudioPlayer.js.map +0 -1
- package/dist/InlineVideoPlayer.d.ts +0 -21
- package/dist/InlineVideoPlayer.d.ts.map +0 -1
- package/dist/InlineVideoPlayer.js +0 -29
- package/dist/InlineVideoPlayer.js.map +0 -1
- package/dist/LinearDocView.d.ts +0 -73
- package/dist/LinearDocView.d.ts.map +0 -1
- package/dist/LinearDocView.js +0 -451
- package/dist/LinearDocView.js.map +0 -1
- package/dist/MarkdownRenderer.d.ts +0 -32
- package/dist/MarkdownRenderer.d.ts.map +0 -1
- package/dist/MarkdownRenderer.js +0 -259
- package/dist/MarkdownRenderer.js.map +0 -1
- package/dist/SocialCaptionOverlay.d.ts +0 -27
- package/dist/SocialCaptionOverlay.d.ts.map +0 -1
- package/dist/SocialCaptionOverlay.js +0 -185
- package/dist/SocialCaptionOverlay.js.map +0 -1
- package/dist/__tests__/BlockRenderer.test.d.ts +0 -2
- package/dist/__tests__/BlockRenderer.test.d.ts.map +0 -1
- package/dist/__tests__/BlockRenderer.test.js +0 -82
- package/dist/__tests__/BlockRenderer.test.js.map +0 -1
- package/dist/__tests__/DocControlsSlideshow.test.d.ts +0 -2
- package/dist/__tests__/DocControlsSlideshow.test.d.ts.map +0 -1
- package/dist/__tests__/DocControlsSlideshow.test.js +0 -89
- package/dist/__tests__/DocControlsSlideshow.test.js.map +0 -1
- package/dist/__tests__/JsonView.test.d.ts +0 -2
- package/dist/__tests__/JsonView.test.d.ts.map +0 -1
- package/dist/__tests__/JsonView.test.js +0 -93
- package/dist/__tests__/JsonView.test.js.map +0 -1
- package/dist/__tests__/LinearDocView.test.d.ts +0 -2
- package/dist/__tests__/LinearDocView.test.d.ts.map +0 -1
- package/dist/__tests__/LinearDocView.test.js +0 -250
- package/dist/__tests__/LinearDocView.test.js.map +0 -1
- package/dist/__tests__/MarkdownRenderer.test.d.ts +0 -2
- package/dist/__tests__/MarkdownRenderer.test.d.ts.map +0 -1
- package/dist/__tests__/MarkdownRenderer.test.js +0 -208
- package/dist/__tests__/MarkdownRenderer.test.js.map +0 -1
- package/dist/__tests__/TableLayer.test.d.ts +0 -2
- package/dist/__tests__/TableLayer.test.d.ts.map +0 -1
- package/dist/__tests__/TableLayer.test.js +0 -121
- package/dist/__tests__/TableLayer.test.js.map +0 -1
- package/dist/__tests__/exports.test.d.ts +0 -2
- package/dist/__tests__/exports.test.d.ts.map +0 -1
- package/dist/__tests__/exports.test.js +0 -48
- package/dist/__tests__/exports.test.js.map +0 -1
- package/dist/hooks/AudioProvider.d.ts +0 -71
- package/dist/hooks/AudioProvider.d.ts.map +0 -1
- package/dist/hooks/AudioProvider.js +0 -52
- package/dist/hooks/AudioProvider.js.map +0 -1
- package/dist/hooks/MediaContext.d.ts +0 -35
- package/dist/hooks/MediaContext.d.ts.map +0 -1
- package/dist/hooks/MediaContext.js +0 -72
- package/dist/hooks/MediaContext.js.map +0 -1
- package/dist/hooks/index.d.ts +0 -6
- package/dist/hooks/index.d.ts.map +0 -1
- package/dist/hooks/index.js +0 -5
- package/dist/hooks/index.js.map +0 -1
- package/dist/hooks/useAudioSync.d.ts +0 -18
- package/dist/hooks/useAudioSync.d.ts.map +0 -1
- package/dist/hooks/useAudioSync.js +0 -340
- package/dist/hooks/useAudioSync.js.map +0 -1
- package/dist/hooks/useAutoSurface.d.ts +0 -11
- package/dist/hooks/useAutoSurface.d.ts.map +0 -1
- package/dist/hooks/useAutoSurface.js +0 -24
- package/dist/hooks/useAutoSurface.js.map +0 -1
- package/dist/hooks/useDocPlayback.d.ts +0 -47
- package/dist/hooks/useDocPlayback.d.ts.map +0 -1
- package/dist/hooks/useDocPlayback.js +0 -179
- package/dist/hooks/useDocPlayback.js.map +0 -1
- package/dist/hooks/useViewportOrientation.d.ts +0 -31
- package/dist/hooks/useViewportOrientation.d.ts.map +0 -1
- package/dist/hooks/useViewportOrientation.js +0 -92
- package/dist/hooks/useViewportOrientation.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/jsonView/JsonView.d.ts +0 -26
- package/dist/jsonView/JsonView.d.ts.map +0 -1
- package/dist/jsonView/JsonView.js +0 -12
- package/dist/jsonView/JsonView.js.map +0 -1
- package/dist/jsonView/RenderNode.d.ts +0 -22
- package/dist/jsonView/RenderNode.d.ts.map +0 -1
- package/dist/jsonView/RenderNode.js +0 -30
- package/dist/jsonView/RenderNode.js.map +0 -1
- package/dist/jsonView/index.d.ts +0 -3
- package/dist/jsonView/index.d.ts.map +0 -1
- package/dist/jsonView/index.js +0 -2
- package/dist/jsonView/index.js.map +0 -1
- package/dist/jsonView/useJsonViewTokens.d.ts +0 -14
- package/dist/jsonView/useJsonViewTokens.d.ts.map +0 -1
- package/dist/jsonView/useJsonViewTokens.js +0 -34
- package/dist/jsonView/useJsonViewTokens.js.map +0 -1
- package/dist/jsonView/viewers.d.ts +0 -30
- package/dist/jsonView/viewers.d.ts.map +0 -1
- package/dist/jsonView/viewers.js +0 -226
- package/dist/jsonView/viewers.js.map +0 -1
- package/dist/layers/ImageLayer.d.ts +0 -23
- package/dist/layers/ImageLayer.d.ts.map +0 -1
- package/dist/layers/ImageLayer.js +0 -104
- package/dist/layers/ImageLayer.js.map +0 -1
- package/dist/layers/MapLayer.d.ts +0 -29
- package/dist/layers/MapLayer.d.ts.map +0 -1
- package/dist/layers/MapLayer.js +0 -97
- package/dist/layers/MapLayer.js.map +0 -1
- package/dist/layers/ShapeLayer.d.ts +0 -20
- package/dist/layers/ShapeLayer.d.ts.map +0 -1
- package/dist/layers/ShapeLayer.js +0 -39
- package/dist/layers/ShapeLayer.js.map +0 -1
- package/dist/layers/TableLayer.d.ts +0 -23
- package/dist/layers/TableLayer.d.ts.map +0 -1
- package/dist/layers/TableLayer.js +0 -58
- package/dist/layers/TableLayer.js.map +0 -1
- package/dist/layers/TextLayer.d.ts +0 -23
- package/dist/layers/TextLayer.d.ts.map +0 -1
- package/dist/layers/TextLayer.js +0 -126
- package/dist/layers/TextLayer.js.map +0 -1
- package/dist/layers/VideoLayer.d.ts +0 -40
- package/dist/layers/VideoLayer.d.ts.map +0 -1
- package/dist/layers/VideoLayer.js +0 -105
- package/dist/layers/VideoLayer.js.map +0 -1
- package/dist/layers/index.d.ts +0 -6
- package/dist/layers/index.d.ts.map +0 -1
- package/dist/layers/index.js +0 -6
- package/dist/layers/index.js.map +0 -1
- package/dist/standalone-entry.d.ts +0 -69
- package/dist/standalone-entry.d.ts.map +0 -1
- package/dist/standalone-entry.js +0 -185
- package/dist/standalone-entry.js.map +0 -1
- package/dist/types.d.ts +0 -154
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -21
- package/dist/types.js.map +0 -1
- package/dist/utils/animationUtils.d.ts +0 -8
- package/dist/utils/animationUtils.d.ts.map +0 -1
- package/dist/utils/animationUtils.js +0 -8
- package/dist/utils/animationUtils.js.map +0 -1
- package/dist/utils/layerUtils.d.ts +0 -17
- package/dist/utils/layerUtils.d.ts.map +0 -1
- package/dist/utils/layerUtils.js +0 -37
- package/dist/utils/layerUtils.js.map +0 -1
- package/dist/utils/mapTileUtils.d.ts +0 -105
- package/dist/utils/mapTileUtils.d.ts.map +0 -1
- package/dist/utils/mapTileUtils.js +0 -268
- package/dist/utils/mapTileUtils.js.map +0 -1
package/src/MarkdownRenderer.tsx
CHANGED
|
@@ -15,14 +15,17 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { Fragment } from 'react';
|
|
18
|
-
import
|
|
18
|
+
import {
|
|
19
|
+
sanitizeHtmlNodes,
|
|
20
|
+
sanitizeUrl,
|
|
21
|
+
type HtmlPolicy,
|
|
22
|
+
type HtmlElement,
|
|
23
|
+
type HtmlNode,
|
|
19
24
|
MarkdownBlockNode,
|
|
20
25
|
MarkdownInlineNode,
|
|
21
26
|
MarkdownListItem,
|
|
22
27
|
MarkdownTableRow,
|
|
23
28
|
MarkdownTableCell,
|
|
24
|
-
HtmlNode,
|
|
25
|
-
HtmlElement,
|
|
26
29
|
} from '@bendyline/squisq/markdown';
|
|
27
30
|
import { useMediaUrl } from './hooks/MediaContext';
|
|
28
31
|
import { InlineVideoPlayer } from './InlineVideoPlayer.js';
|
|
@@ -35,12 +38,21 @@ export interface MarkdownRendererProps {
|
|
|
35
38
|
nodes: MarkdownBlockNode[];
|
|
36
39
|
/** Optional CSS class for the wrapper element */
|
|
37
40
|
className?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Raw HTML policy. Defaults to `sanitize`, which removes unsafe tags,
|
|
43
|
+
* event handlers, and executable URL schemes before rendering.
|
|
44
|
+
*/
|
|
45
|
+
htmlPolicy?: HtmlPolicy;
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
// ── Inline Renderer ────────────────────────────────────────────────
|
|
41
49
|
|
|
42
50
|
/** Render an array of inline nodes into React elements. */
|
|
43
|
-
function renderInline(
|
|
51
|
+
function renderInline(
|
|
52
|
+
nodes: MarkdownInlineNode[],
|
|
53
|
+
keyPrefix = '',
|
|
54
|
+
htmlPolicy: HtmlPolicy = 'sanitize',
|
|
55
|
+
): React.ReactNode[] {
|
|
44
56
|
return nodes.map((node, i) => {
|
|
45
57
|
const key = `${keyPrefix}i${i}`;
|
|
46
58
|
switch (node.type) {
|
|
@@ -67,21 +79,21 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
|
|
|
67
79
|
case 'emphasis':
|
|
68
80
|
return (
|
|
69
81
|
<em key={key} className="squisq-md-em">
|
|
70
|
-
{renderInline(node.children, key)}
|
|
82
|
+
{renderInline(node.children, key, htmlPolicy)}
|
|
71
83
|
</em>
|
|
72
84
|
);
|
|
73
85
|
|
|
74
86
|
case 'strong':
|
|
75
87
|
return (
|
|
76
88
|
<strong key={key} className="squisq-md-strong">
|
|
77
|
-
{renderInline(node.children, key)}
|
|
89
|
+
{renderInline(node.children, key, htmlPolicy)}
|
|
78
90
|
</strong>
|
|
79
91
|
);
|
|
80
92
|
|
|
81
93
|
case 'delete':
|
|
82
94
|
return (
|
|
83
95
|
<del key={key} className="squisq-md-del">
|
|
84
|
-
{renderInline(node.children, key)}
|
|
96
|
+
{renderInline(node.children, key, htmlPolicy)}
|
|
85
97
|
</del>
|
|
86
98
|
);
|
|
87
99
|
|
|
@@ -92,19 +104,28 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
|
|
|
92
104
|
</code>
|
|
93
105
|
);
|
|
94
106
|
|
|
95
|
-
case 'link':
|
|
107
|
+
case 'link': {
|
|
108
|
+
const href = sanitizeUrl(node.url, 'link');
|
|
109
|
+
if (!href) {
|
|
110
|
+
return (
|
|
111
|
+
<span key={key} className="squisq-md-link squisq-md-link--blocked">
|
|
112
|
+
{renderInline(node.children, key, htmlPolicy)}
|
|
113
|
+
</span>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
96
116
|
return (
|
|
97
117
|
<a
|
|
98
118
|
key={key}
|
|
99
119
|
className="squisq-md-link"
|
|
100
|
-
href={
|
|
120
|
+
href={href}
|
|
101
121
|
title={node.title ?? undefined}
|
|
102
122
|
target="_blank"
|
|
103
123
|
rel="noopener noreferrer"
|
|
104
124
|
>
|
|
105
|
-
{renderInline(node.children, key)}
|
|
125
|
+
{renderInline(node.children, key, htmlPolicy)}
|
|
106
126
|
</a>
|
|
107
127
|
);
|
|
128
|
+
}
|
|
108
129
|
|
|
109
130
|
case 'image':
|
|
110
131
|
return (
|
|
@@ -122,9 +143,15 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
|
|
|
122
143
|
);
|
|
123
144
|
|
|
124
145
|
case 'htmlInline':
|
|
146
|
+
if (htmlPolicy === 'strip') return null;
|
|
125
147
|
// Fast path: no <video>/<audio> in the subtree → use the original
|
|
126
148
|
// rawHtml passthrough (preserves arbitrary HTML for custom embeds).
|
|
127
|
-
if (
|
|
149
|
+
if (
|
|
150
|
+
htmlPolicy === 'trusted' &&
|
|
151
|
+
!containsMediaTag(node.htmlChildren) &&
|
|
152
|
+
!containsDangerousTag(node.htmlChildren) &&
|
|
153
|
+
!hasDangerousRawHtml(node.rawHtml)
|
|
154
|
+
) {
|
|
128
155
|
return (
|
|
129
156
|
<span
|
|
130
157
|
key={key}
|
|
@@ -137,7 +164,7 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
|
|
|
137
164
|
// go through MediaContext-aware player components.
|
|
138
165
|
return (
|
|
139
166
|
<span key={key} className="squisq-md-html-inline">
|
|
140
|
-
{renderHtmlNodes(node.htmlChildren, `${key}h`)}
|
|
167
|
+
{renderHtmlNodes(resolveHtmlNodes(node.htmlChildren, htmlPolicy), `${key}h`)}
|
|
141
168
|
</span>
|
|
142
169
|
);
|
|
143
170
|
|
|
@@ -152,7 +179,7 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
|
|
|
152
179
|
// Render as plain text (definition targets not available at render time)
|
|
153
180
|
return (
|
|
154
181
|
<span key={key} className="squisq-md-link-ref">
|
|
155
|
-
{renderInline(node.children, key)}
|
|
182
|
+
{renderInline(node.children, key, htmlPolicy)}
|
|
156
183
|
</span>
|
|
157
184
|
);
|
|
158
185
|
|
|
@@ -166,7 +193,7 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
|
|
|
166
193
|
case 'textDirective':
|
|
167
194
|
return (
|
|
168
195
|
<span key={key} className="squisq-md-text-directive" data-directive={node.name}>
|
|
169
|
-
{renderInline(node.children, key)}
|
|
196
|
+
{renderInline(node.children, key, htmlPolicy)}
|
|
170
197
|
</span>
|
|
171
198
|
);
|
|
172
199
|
|
|
@@ -193,12 +220,16 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
|
|
|
193
220
|
// ── Block Renderer ─────────────────────────────────────────────────
|
|
194
221
|
|
|
195
222
|
/** Render a single block-level node into a React element. */
|
|
196
|
-
function renderBlock(
|
|
223
|
+
function renderBlock(
|
|
224
|
+
node: MarkdownBlockNode,
|
|
225
|
+
key: string,
|
|
226
|
+
htmlPolicy: HtmlPolicy = 'sanitize',
|
|
227
|
+
): React.ReactNode {
|
|
197
228
|
switch (node.type) {
|
|
198
229
|
case 'paragraph':
|
|
199
230
|
return (
|
|
200
231
|
<p key={key} className="squisq-md-p">
|
|
201
|
-
{renderInline(node.children, key)}
|
|
232
|
+
{renderInline(node.children, key, htmlPolicy)}
|
|
202
233
|
</p>
|
|
203
234
|
);
|
|
204
235
|
|
|
@@ -206,7 +237,7 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
|
|
|
206
237
|
const Tag = `h${node.depth}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
|
207
238
|
return (
|
|
208
239
|
<Tag key={key} className={`squisq-md-heading squisq-md-h${node.depth}`}>
|
|
209
|
-
{renderInline(node.children, key)}
|
|
240
|
+
{renderInline(node.children, key, htmlPolicy)}
|
|
210
241
|
</Tag>
|
|
211
242
|
);
|
|
212
243
|
}
|
|
@@ -214,7 +245,7 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
|
|
|
214
245
|
case 'blockquote':
|
|
215
246
|
return (
|
|
216
247
|
<blockquote key={key} className="squisq-md-blockquote">
|
|
217
|
-
{renderBlocks(node.children, key)}
|
|
248
|
+
{renderBlocks(node.children, key, htmlPolicy)}
|
|
218
249
|
</blockquote>
|
|
219
250
|
);
|
|
220
251
|
|
|
@@ -222,13 +253,13 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
|
|
|
222
253
|
if (node.ordered) {
|
|
223
254
|
return (
|
|
224
255
|
<ol key={key} className="squisq-md-list squisq-md-ol" start={node.start ?? undefined}>
|
|
225
|
-
{node.children.map((item, i) => renderListItem(item, `${key}li${i}
|
|
256
|
+
{node.children.map((item, i) => renderListItem(item, `${key}li${i}`, htmlPolicy))}
|
|
226
257
|
</ol>
|
|
227
258
|
);
|
|
228
259
|
}
|
|
229
260
|
return (
|
|
230
261
|
<ul key={key} className="squisq-md-list squisq-md-ul">
|
|
231
|
-
{node.children.map((item, i) => renderListItem(item, `${key}li${i}
|
|
262
|
+
{node.children.map((item, i) => renderListItem(item, `${key}li${i}`, htmlPolicy))}
|
|
232
263
|
</ul>
|
|
233
264
|
);
|
|
234
265
|
|
|
@@ -243,12 +274,18 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
|
|
|
243
274
|
return <hr key={key} className="squisq-md-hr" />;
|
|
244
275
|
|
|
245
276
|
case 'table':
|
|
246
|
-
return renderTable(node.children, node.align, key);
|
|
277
|
+
return renderTable(node.children, node.align, key, htmlPolicy);
|
|
247
278
|
|
|
248
279
|
case 'htmlBlock':
|
|
280
|
+
if (htmlPolicy === 'strip') return null;
|
|
249
281
|
// Fast path: no <video>/<audio> → preserve the existing rawHtml
|
|
250
282
|
// passthrough so arbitrary HTML embeds still survive verbatim.
|
|
251
|
-
if (
|
|
283
|
+
if (
|
|
284
|
+
htmlPolicy === 'trusted' &&
|
|
285
|
+
!containsMediaTag(node.htmlChildren) &&
|
|
286
|
+
!containsDangerousTag(node.htmlChildren) &&
|
|
287
|
+
!hasDangerousRawHtml(node.rawHtml)
|
|
288
|
+
) {
|
|
252
289
|
return (
|
|
253
290
|
<div
|
|
254
291
|
key={key}
|
|
@@ -261,7 +298,7 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
|
|
|
261
298
|
// route through the player components and resolve via MediaContext.
|
|
262
299
|
return (
|
|
263
300
|
<div key={key} className="squisq-md-html-block">
|
|
264
|
-
{renderHtmlNodes(node.htmlChildren, `${key}h`)}
|
|
301
|
+
{renderHtmlNodes(resolveHtmlNodes(node.htmlChildren, htmlPolicy), `${key}h`)}
|
|
265
302
|
</div>
|
|
266
303
|
);
|
|
267
304
|
|
|
@@ -280,7 +317,7 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
|
|
|
280
317
|
return (
|
|
281
318
|
<div key={key} className="squisq-md-footnote-def" id={`fn-${node.identifier}`}>
|
|
282
319
|
<sup>{node.label ?? node.identifier}</sup>
|
|
283
|
-
{renderBlocks(node.children, key)}
|
|
320
|
+
{renderBlocks(node.children, key, htmlPolicy)}
|
|
284
321
|
</div>
|
|
285
322
|
);
|
|
286
323
|
|
|
@@ -292,7 +329,7 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
|
|
|
292
329
|
data-directive={node.name}
|
|
293
330
|
>
|
|
294
331
|
{node.label && <div className="squisq-md-directive-label">{node.label}</div>}
|
|
295
|
-
{renderBlocks(node.children, key)}
|
|
332
|
+
{renderBlocks(node.children, key, htmlPolicy)}
|
|
296
333
|
</div>
|
|
297
334
|
);
|
|
298
335
|
|
|
@@ -303,7 +340,7 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
|
|
|
303
340
|
className={`squisq-md-directive squisq-md-directive-${node.name}`}
|
|
304
341
|
data-directive={node.name}
|
|
305
342
|
>
|
|
306
|
-
{renderInline(node.children, key)}
|
|
343
|
+
{renderInline(node.children, key, htmlPolicy)}
|
|
307
344
|
</div>
|
|
308
345
|
);
|
|
309
346
|
|
|
@@ -314,13 +351,13 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
|
|
|
314
351
|
if (child.type === 'definitionTerm') {
|
|
315
352
|
return (
|
|
316
353
|
<dt key={`${key}dt${i}`} className="squisq-md-dt">
|
|
317
|
-
{renderInline(child.children, `${key}dt${i}
|
|
354
|
+
{renderInline(child.children, `${key}dt${i}`, htmlPolicy)}
|
|
318
355
|
</dt>
|
|
319
356
|
);
|
|
320
357
|
}
|
|
321
358
|
return (
|
|
322
359
|
<dd key={`${key}dd${i}`} className="squisq-md-dd">
|
|
323
|
-
{renderBlocks(child.children, `${key}dd${i}
|
|
360
|
+
{renderBlocks(child.children, `${key}dd${i}`, htmlPolicy)}
|
|
324
361
|
</dd>
|
|
325
362
|
);
|
|
326
363
|
})}
|
|
@@ -333,14 +370,18 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
|
|
|
333
370
|
}
|
|
334
371
|
|
|
335
372
|
/** Render a list item, including task-list checkbox support. */
|
|
336
|
-
function renderListItem(
|
|
373
|
+
function renderListItem(
|
|
374
|
+
item: MarkdownListItem,
|
|
375
|
+
key: string,
|
|
376
|
+
htmlPolicy: HtmlPolicy = 'sanitize',
|
|
377
|
+
): React.ReactNode {
|
|
337
378
|
const isTask = item.checked !== null && item.checked !== undefined;
|
|
338
379
|
return (
|
|
339
380
|
<li key={key} className={`squisq-md-li${isTask ? ' squisq-md-task' : ''}`}>
|
|
340
381
|
{isTask && (
|
|
341
382
|
<input type="checkbox" checked={!!item.checked} readOnly className="squisq-md-checkbox" />
|
|
342
383
|
)}
|
|
343
|
-
{renderBlocks(item.children, key)}
|
|
384
|
+
{renderBlocks(item.children, key, htmlPolicy)}
|
|
344
385
|
</li>
|
|
345
386
|
);
|
|
346
387
|
}
|
|
@@ -350,6 +391,7 @@ function renderTable(
|
|
|
350
391
|
rows: MarkdownTableRow[],
|
|
351
392
|
align: (('left' | 'right' | 'center') | null)[] | undefined,
|
|
352
393
|
key: string,
|
|
394
|
+
htmlPolicy: HtmlPolicy = 'sanitize',
|
|
353
395
|
): React.ReactNode {
|
|
354
396
|
const [headerRow, ...bodyRows] = rows;
|
|
355
397
|
return (
|
|
@@ -363,7 +405,7 @@ function renderTable(
|
|
|
363
405
|
className="squisq-md-th"
|
|
364
406
|
style={align?.[ci] ? { textAlign: align[ci]! } : undefined}
|
|
365
407
|
>
|
|
366
|
-
{renderInline(cell.children, `${key}th${ci}
|
|
408
|
+
{renderInline(cell.children, `${key}th${ci}`, htmlPolicy)}
|
|
367
409
|
</th>
|
|
368
410
|
))}
|
|
369
411
|
</tr>
|
|
@@ -379,7 +421,7 @@ function renderTable(
|
|
|
379
421
|
className="squisq-md-td"
|
|
380
422
|
style={align?.[ci] ? { textAlign: align[ci]! } : undefined}
|
|
381
423
|
>
|
|
382
|
-
{renderInline(cell.children, `${key}td${ri}-${ci}
|
|
424
|
+
{renderInline(cell.children, `${key}td${ri}-${ci}`, htmlPolicy)}
|
|
383
425
|
</td>
|
|
384
426
|
))}
|
|
385
427
|
</tr>
|
|
@@ -391,15 +433,21 @@ function renderTable(
|
|
|
391
433
|
}
|
|
392
434
|
|
|
393
435
|
/** Render an array of block-level nodes. */
|
|
394
|
-
function renderBlocks(
|
|
395
|
-
|
|
436
|
+
function renderBlocks(
|
|
437
|
+
nodes: MarkdownBlockNode[],
|
|
438
|
+
keyPrefix = '',
|
|
439
|
+
htmlPolicy: HtmlPolicy = 'sanitize',
|
|
440
|
+
): React.ReactNode[] {
|
|
441
|
+
return nodes.map((node, i) => renderBlock(node, `${keyPrefix}b${i}`, htmlPolicy));
|
|
396
442
|
}
|
|
397
443
|
|
|
398
444
|
// ── Image with MediaProvider resolution ───────────────────────────
|
|
399
445
|
|
|
400
446
|
/** Renders an <img> that resolves its src through the MediaProvider when available. */
|
|
401
447
|
function MdImage({ src, alt, title }: { src: string; alt: string; title?: string }) {
|
|
402
|
-
const
|
|
448
|
+
const safeSrc = sanitizeUrl(src, 'media');
|
|
449
|
+
const resolved = useMediaUrl(safeSrc ?? '', '.');
|
|
450
|
+
if (!safeSrc) return null;
|
|
403
451
|
return <img className="squisq-md-image" src={resolved} alt={alt} title={title} />;
|
|
404
452
|
}
|
|
405
453
|
|
|
@@ -408,15 +456,75 @@ function MdImage({ src, alt, title }: { src: string; alt: string; title?: string
|
|
|
408
456
|
/** True when the htmlElement subtree contains a tag we want to swap
|
|
409
457
|
* for a React component. Cheap recursive scan — lets us keep the
|
|
410
458
|
* `dangerouslySetInnerHTML` fast path for everything else. */
|
|
459
|
+
function resolveHtmlNodes(nodes: HtmlNode[], htmlPolicy: HtmlPolicy): HtmlNode[] {
|
|
460
|
+
if (htmlPolicy === 'strip') return [];
|
|
461
|
+
if (htmlPolicy === 'trusted') return nodes;
|
|
462
|
+
return sanitizeHtmlNodes(nodes);
|
|
463
|
+
}
|
|
464
|
+
|
|
411
465
|
function containsMediaTag(nodes: HtmlNode[]): boolean {
|
|
412
466
|
for (const node of nodes) {
|
|
413
467
|
if (node.type !== 'htmlElement') continue;
|
|
414
|
-
|
|
468
|
+
const tagName = node.tagName.toLowerCase();
|
|
469
|
+
if (tagName === 'video' || tagName === 'audio') return true;
|
|
415
470
|
if (containsMediaTag(node.children)) return true;
|
|
416
471
|
}
|
|
417
472
|
return false;
|
|
418
473
|
}
|
|
419
474
|
|
|
475
|
+
/**
|
|
476
|
+
* Tags that can escape their container and affect the whole host
|
|
477
|
+
* document — global styling, script execution, external/resource loads,
|
|
478
|
+
* or framing. Markdown content is frequently untrusted (LLM output,
|
|
479
|
+
* pasted snippets), and these tags have no business mutating the page
|
|
480
|
+
* they're embedded in, so they are *always* dropped — even under the
|
|
481
|
+
* `'trusted'` policy. "Trusted" means "render this HTML's structure
|
|
482
|
+
* verbatim," not "let it restyle or script the host." A stray `<style>`
|
|
483
|
+
* applies document-wide (CSS has no per-element scoping outside shadow
|
|
484
|
+
* DOM / iframes), which is exactly how an embedded game's `<style>`
|
|
485
|
+
* leaked onto the surrounding app chrome.
|
|
486
|
+
*/
|
|
487
|
+
const DANGEROUS_HTML_TAGS = new Set([
|
|
488
|
+
'base',
|
|
489
|
+
'embed',
|
|
490
|
+
'iframe',
|
|
491
|
+
'link',
|
|
492
|
+
'meta',
|
|
493
|
+
'object',
|
|
494
|
+
'script',
|
|
495
|
+
'style',
|
|
496
|
+
'title',
|
|
497
|
+
]);
|
|
498
|
+
|
|
499
|
+
/** True when the subtree contains any host-affecting tag (see
|
|
500
|
+
* {@link DANGEROUS_HTML_TAGS}). Mirrors {@link containsMediaTag}: keeps
|
|
501
|
+
* such content off the verbatim `dangerouslySetInnerHTML` fast path so
|
|
502
|
+
* it routes through the React reconstruction, which drops the tag. */
|
|
503
|
+
function containsDangerousTag(nodes: HtmlNode[]): boolean {
|
|
504
|
+
for (const node of nodes) {
|
|
505
|
+
if (node.type !== 'htmlElement') continue;
|
|
506
|
+
if (DANGEROUS_HTML_TAGS.has(node.tagName.toLowerCase())) return true;
|
|
507
|
+
if (containsDangerousTag(node.children)) return true;
|
|
508
|
+
}
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Raw-string backstop for {@link DANGEROUS_HTML_TAGS}. The structural
|
|
514
|
+
* {@link containsDangerousTag} check covers the normal case, but a block
|
|
515
|
+
* parsed with `parseHtml: false` carries an empty `htmlChildren` while
|
|
516
|
+
* `rawHtml` still holds the markup — so the verbatim fast path scans the
|
|
517
|
+
* raw string too, guaranteeing a `<style>`/`<script>` can never be
|
|
518
|
+
* injected into the host document by that path no matter how the node
|
|
519
|
+
* was produced. The `\b` keeps `<styled-thing>` from matching `<style>`.
|
|
520
|
+
*/
|
|
521
|
+
const DANGEROUS_RAW_HTML_RE =
|
|
522
|
+
/<\s*\/?\s*(?:base|embed|iframe|link|meta|object|script|style|title)\b/i;
|
|
523
|
+
|
|
524
|
+
function hasDangerousRawHtml(rawHtml: string): boolean {
|
|
525
|
+
return DANGEROUS_RAW_HTML_RE.test(rawHtml);
|
|
526
|
+
}
|
|
527
|
+
|
|
420
528
|
/** A pragmatic shortlist of HTML attributes the raw-HTML walker
|
|
421
529
|
* passes through to React when reconstructing a non-media element.
|
|
422
530
|
* Anything outside this list is silently dropped — the media-tag
|
|
@@ -432,6 +540,10 @@ const PASSTHROUGH_ATTRS: Record<string, string> = {
|
|
|
432
540
|
// media-adjacent (used when video/audio appear inside other wrappers)
|
|
433
541
|
width: 'width',
|
|
434
542
|
height: 'height',
|
|
543
|
+
src: 'src',
|
|
544
|
+
alt: 'alt',
|
|
545
|
+
loading: 'loading',
|
|
546
|
+
decoding: 'decoding',
|
|
435
547
|
// anchor
|
|
436
548
|
href: 'href',
|
|
437
549
|
target: 'target',
|
|
@@ -456,7 +568,13 @@ function reactPropsFromAttrs(attrs: Record<string, string>): Record<string, unkn
|
|
|
456
568
|
}
|
|
457
569
|
|
|
458
570
|
function renderHtmlElement(el: HtmlElement, key: string): React.ReactNode {
|
|
459
|
-
|
|
571
|
+
const tagName = el.tagName.toLowerCase();
|
|
572
|
+
// Final safety net: never reconstruct a host-affecting element (e.g. a
|
|
573
|
+
// <style> that would leak globally), whatever the policy. The fast path
|
|
574
|
+
// is gated by containsDangerousTag, so trusted content carrying these
|
|
575
|
+
// tags lands here — drop the tag and keep the rest of the subtree.
|
|
576
|
+
if (DANGEROUS_HTML_TAGS.has(tagName)) return null;
|
|
577
|
+
if (tagName === 'video') {
|
|
460
578
|
return (
|
|
461
579
|
<InlineVideoPlayer
|
|
462
580
|
key={key}
|
|
@@ -477,7 +595,7 @@ function renderHtmlElement(el: HtmlElement, key: string): React.ReactNode {
|
|
|
477
595
|
/>
|
|
478
596
|
);
|
|
479
597
|
}
|
|
480
|
-
if (
|
|
598
|
+
if (tagName === 'audio') {
|
|
481
599
|
return (
|
|
482
600
|
<InlineAudioPlayer
|
|
483
601
|
key={key}
|
|
@@ -494,7 +612,7 @@ function renderHtmlElement(el: HtmlElement, key: string): React.ReactNode {
|
|
|
494
612
|
);
|
|
495
613
|
}
|
|
496
614
|
|
|
497
|
-
const Tag =
|
|
615
|
+
const Tag = tagName as keyof JSX.IntrinsicElements;
|
|
498
616
|
const props = reactPropsFromAttrs(el.attributes);
|
|
499
617
|
if (el.selfClosing) {
|
|
500
618
|
return <Tag key={key} {...props} />;
|
|
@@ -533,8 +651,14 @@ function renderHtmlNodes(nodes: HtmlNode[], keyPrefix: string): React.ReactNode[
|
|
|
533
651
|
* <MarkdownRenderer nodes={block.contents} />
|
|
534
652
|
* ```
|
|
535
653
|
*/
|
|
536
|
-
export function MarkdownRenderer({
|
|
654
|
+
export function MarkdownRenderer({
|
|
655
|
+
nodes,
|
|
656
|
+
className,
|
|
657
|
+
htmlPolicy = 'sanitize',
|
|
658
|
+
}: MarkdownRendererProps) {
|
|
537
659
|
if (!nodes || nodes.length === 0) return null;
|
|
538
660
|
|
|
539
|
-
return
|
|
661
|
+
return (
|
|
662
|
+
<div className={`squisq-md ${className || ''}`}>{renderBlocks(nodes, '', htmlPolicy)}</div>
|
|
663
|
+
);
|
|
540
664
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MediaClipLayer
|
|
3
|
+
*
|
|
4
|
+
* Player-level audio/video elements for the media-clip schedule. Renders one
|
|
5
|
+
* element per scheduled clip (audio, or document-spanning video) and keeps each
|
|
6
|
+
* mounted so re-entry doesn't reload; the drive effect seeks/plays only the
|
|
7
|
+
* clips active at the current time. Multiple concurrent clips (e.g. a
|
|
8
|
+
* document-spanning narration plus a block clip) are independent elements and
|
|
9
|
+
* the browser mixes their output.
|
|
10
|
+
*
|
|
11
|
+
* Audio clips play unmuted during live playback (silent in render mode, where
|
|
12
|
+
* frames are captured without sound and audio is muxed offline). Video clips
|
|
13
|
+
* play muted — their audio, if any, is reproduced by the export mux.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useEffect, useRef } from 'react';
|
|
17
|
+
import type { ScheduledClip } from '@bendyline/squisq/schemas';
|
|
18
|
+
import { useMediaUrl } from './hooks/MediaContext';
|
|
19
|
+
import { useMediaSchedule } from './hooks/useMediaSchedule';
|
|
20
|
+
|
|
21
|
+
/** Re-seek an element only when it drifts this far from its target (seconds). */
|
|
22
|
+
const DRIFT = 0.25;
|
|
23
|
+
|
|
24
|
+
export interface MediaClipLayerProps {
|
|
25
|
+
schedule: ScheduledClip[];
|
|
26
|
+
currentTime: number;
|
|
27
|
+
isPlaying: boolean;
|
|
28
|
+
basePath: string;
|
|
29
|
+
renderMode?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function MediaClipLayer({
|
|
33
|
+
schedule,
|
|
34
|
+
currentTime,
|
|
35
|
+
isPlaying,
|
|
36
|
+
basePath,
|
|
37
|
+
renderMode = false,
|
|
38
|
+
}: MediaClipLayerProps) {
|
|
39
|
+
const { renderClips, activeIds } = useMediaSchedule(schedule, currentTime);
|
|
40
|
+
if (renderClips.length === 0) return null;
|
|
41
|
+
return (
|
|
42
|
+
<div className="doc-player__media-clips" aria-hidden>
|
|
43
|
+
{renderClips.map((clip) => (
|
|
44
|
+
<MediaClipElement
|
|
45
|
+
key={clip.id}
|
|
46
|
+
clip={clip}
|
|
47
|
+
active={activeIds.has(clip.id)}
|
|
48
|
+
currentTime={currentTime}
|
|
49
|
+
isPlaying={isPlaying}
|
|
50
|
+
basePath={basePath}
|
|
51
|
+
renderMode={renderMode}
|
|
52
|
+
/>
|
|
53
|
+
))}
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface MediaClipElementProps {
|
|
59
|
+
clip: ScheduledClip;
|
|
60
|
+
active: boolean;
|
|
61
|
+
currentTime: number;
|
|
62
|
+
isPlaying: boolean;
|
|
63
|
+
basePath: string;
|
|
64
|
+
renderMode: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function MediaClipElement({
|
|
68
|
+
clip,
|
|
69
|
+
active,
|
|
70
|
+
currentTime,
|
|
71
|
+
isPlaying,
|
|
72
|
+
basePath,
|
|
73
|
+
renderMode,
|
|
74
|
+
}: MediaClipElementProps) {
|
|
75
|
+
const ref = useRef<HTMLMediaElement | null>(null);
|
|
76
|
+
const src = useMediaUrl(clip.src, basePath);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
const el = ref.current;
|
|
80
|
+
if (!el) return;
|
|
81
|
+
if (!active) {
|
|
82
|
+
if (!el.paused) el.pause();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const target = Math.max(0, clip.sourceIn + (currentTime - clip.absoluteStart));
|
|
86
|
+
if (renderMode || Math.abs(el.currentTime - target) > DRIFT) {
|
|
87
|
+
try {
|
|
88
|
+
el.currentTime = target;
|
|
89
|
+
} catch {
|
|
90
|
+
// Seeking before metadata loads throws; the next tick retries.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (isPlaying && !renderMode) {
|
|
94
|
+
const p = el.play();
|
|
95
|
+
if (p) p.catch(() => {});
|
|
96
|
+
} else {
|
|
97
|
+
el.pause();
|
|
98
|
+
}
|
|
99
|
+
}, [active, currentTime, isPlaying, renderMode, clip.sourceIn, clip.absoluteStart]);
|
|
100
|
+
|
|
101
|
+
const isVideo = clip.kind === 'video';
|
|
102
|
+
const common = {
|
|
103
|
+
ref: ref as React.RefObject<never>,
|
|
104
|
+
src,
|
|
105
|
+
preload: 'auto' as const,
|
|
106
|
+
'data-clip-id': clip.id,
|
|
107
|
+
'data-abs-start': clip.absoluteStart,
|
|
108
|
+
'data-abs-end': clip.absoluteEnd,
|
|
109
|
+
'data-source-in': clip.sourceIn,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
if (isVideo) {
|
|
113
|
+
// Document-spanning video renders full-bleed behind the blocks.
|
|
114
|
+
return (
|
|
115
|
+
<video
|
|
116
|
+
{...common}
|
|
117
|
+
muted
|
|
118
|
+
playsInline
|
|
119
|
+
style={{
|
|
120
|
+
position: 'absolute',
|
|
121
|
+
inset: 0,
|
|
122
|
+
width: '100%',
|
|
123
|
+
height: '100%',
|
|
124
|
+
objectFit: 'cover',
|
|
125
|
+
zIndex: 0,
|
|
126
|
+
pointerEvents: 'none',
|
|
127
|
+
}}
|
|
128
|
+
/>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<audio {...common} muted={renderMode} style={{ position: 'absolute', width: 0, height: 0 }} />
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
import { DocPlayer } from '../DocPlayer';
|
|
4
|
+
import type { Doc } from '@bendyline/squisq/schemas';
|
|
5
|
+
|
|
6
|
+
function minimalDoc(): Doc {
|
|
7
|
+
return {
|
|
8
|
+
articleId: 'smoke',
|
|
9
|
+
duration: 5,
|
|
10
|
+
blocks: [{ id: 'b1', startTime: 0, duration: 5, audioSegment: 0, layers: [] }],
|
|
11
|
+
audio: { segments: [] },
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('DocPlayer smoke test', () => {
|
|
16
|
+
it('renders without crashing in video mode (default)', () => {
|
|
17
|
+
const { container } = render(<DocPlayer script={minimalDoc()} basePath="/test" />);
|
|
18
|
+
expect(container.firstChild).toBeTruthy();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('renders without crashing in slideshow mode', () => {
|
|
22
|
+
const { container } = render(
|
|
23
|
+
<DocPlayer script={minimalDoc()} basePath="/test" displayMode="slideshow" />,
|
|
24
|
+
);
|
|
25
|
+
expect(container.firstChild).toBeTruthy();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('renders without crashing in linear mode', () => {
|
|
29
|
+
const { container } = render(
|
|
30
|
+
<DocPlayer script={minimalDoc()} basePath="/test" displayMode="linear" />,
|
|
31
|
+
);
|
|
32
|
+
expect(container.firstChild).toBeTruthy();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('exposes playback controls via onControlsReady', () => {
|
|
36
|
+
let controls: { play: () => void; pause: () => void } | null = null;
|
|
37
|
+
render(
|
|
38
|
+
<DocPlayer
|
|
39
|
+
script={minimalDoc()}
|
|
40
|
+
basePath="/test"
|
|
41
|
+
showControls={false}
|
|
42
|
+
onControlsReady={(c) => {
|
|
43
|
+
controls = c;
|
|
44
|
+
}}
|
|
45
|
+
/>,
|
|
46
|
+
);
|
|
47
|
+
expect(controls).not.toBeNull();
|
|
48
|
+
expect(typeof controls!.play).toBe('function');
|
|
49
|
+
expect(typeof controls!.pause).toBe('function');
|
|
50
|
+
});
|
|
51
|
+
});
|