@bendyline/squisq-react 1.3.2 → 1.4.1
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/README.md +57 -23
- package/dist/index.d.ts +131 -26
- package/dist/index.js +1475 -755
- 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 +49 -13
- package/dist/squisq-player.global.js.map +1 -1
- package/dist/standalone-source.js +1 -1
- package/dist/styles/index.css +2263 -0
- package/package.json +9 -5
- package/src/BlockRenderer.tsx +15 -7
- package/src/DocPlayer.tsx +222 -55
- package/src/DocPlayerWithSidebar.tsx +21 -9
- package/src/DocProgressBar.tsx +21 -3
- package/src/LinearDocView.tsx +69 -206
- package/src/MarkdownRenderer.tsx +182 -41
- package/src/MediaClipLayer.tsx +135 -0
- package/src/__tests__/DocPlayer.test.tsx +81 -0
- package/src/__tests__/DocPlayerStylesSentinel.test.tsx +41 -0
- package/src/__tests__/DocProgressBar.test.tsx +76 -0
- package/src/__tests__/LinearDocView.test.tsx +53 -1
- package/src/__tests__/MarkdownRenderer.test.tsx +113 -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/__tests__/useJsonViewTokens.test.ts +41 -0
- package/src/__tests__/useSlideSwipe.test.ts +81 -0
- package/src/hooks/{AudioProvider.ts → AudioController.ts} +3 -3
- package/src/hooks/index.ts +7 -2
- package/src/hooks/useAudioSync.ts +19 -5
- package/src/hooks/useDocPlayback.ts +81 -100
- package/src/hooks/useMediaSchedule.ts +39 -0
- package/src/hooks/useSlideSwipe.ts +265 -0
- package/src/index.ts +8 -1
- package/src/jsonView/useJsonViewTokens.ts +6 -31
- 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/standalone-entry.tsx +1 -1
- package/src/styles/doc-animations.css +1936 -35
- package/src/utils/fillStyle.tsx +148 -0
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,35 @@ 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;
|
|
46
|
+
/**
|
|
47
|
+
* Extra URL schemes to allow on links (e.g. a host app's internal
|
|
48
|
+
* navigation scheme it intercepts on click). Executable schemes are
|
|
49
|
+
* never allowed regardless. See {@link SanitizeUrlOptions}.
|
|
50
|
+
*/
|
|
51
|
+
linkSchemes?: readonly string[];
|
|
38
52
|
}
|
|
39
53
|
|
|
54
|
+
/** Options threaded through the recursive renderers. */
|
|
55
|
+
interface RenderCtx {
|
|
56
|
+
htmlPolicy: HtmlPolicy;
|
|
57
|
+
linkSchemes?: readonly string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const DEFAULT_CTX: RenderCtx = { htmlPolicy: 'sanitize' };
|
|
61
|
+
|
|
40
62
|
// ── Inline Renderer ────────────────────────────────────────────────
|
|
41
63
|
|
|
42
64
|
/** Render an array of inline nodes into React elements. */
|
|
43
|
-
function renderInline(
|
|
65
|
+
function renderInline(
|
|
66
|
+
nodes: MarkdownInlineNode[],
|
|
67
|
+
keyPrefix = '',
|
|
68
|
+
ctx: RenderCtx = DEFAULT_CTX,
|
|
69
|
+
): React.ReactNode[] {
|
|
44
70
|
return nodes.map((node, i) => {
|
|
45
71
|
const key = `${keyPrefix}i${i}`;
|
|
46
72
|
switch (node.type) {
|
|
@@ -67,21 +93,21 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
|
|
|
67
93
|
case 'emphasis':
|
|
68
94
|
return (
|
|
69
95
|
<em key={key} className="squisq-md-em">
|
|
70
|
-
{renderInline(node.children, key)}
|
|
96
|
+
{renderInline(node.children, key, ctx)}
|
|
71
97
|
</em>
|
|
72
98
|
);
|
|
73
99
|
|
|
74
100
|
case 'strong':
|
|
75
101
|
return (
|
|
76
102
|
<strong key={key} className="squisq-md-strong">
|
|
77
|
-
{renderInline(node.children, key)}
|
|
103
|
+
{renderInline(node.children, key, ctx)}
|
|
78
104
|
</strong>
|
|
79
105
|
);
|
|
80
106
|
|
|
81
107
|
case 'delete':
|
|
82
108
|
return (
|
|
83
109
|
<del key={key} className="squisq-md-del">
|
|
84
|
-
{renderInline(node.children, key)}
|
|
110
|
+
{renderInline(node.children, key, ctx)}
|
|
85
111
|
</del>
|
|
86
112
|
);
|
|
87
113
|
|
|
@@ -92,19 +118,28 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
|
|
|
92
118
|
</code>
|
|
93
119
|
);
|
|
94
120
|
|
|
95
|
-
case 'link':
|
|
121
|
+
case 'link': {
|
|
122
|
+
const href = sanitizeUrl(node.url, 'link', { extraLinkSchemes: ctx.linkSchemes });
|
|
123
|
+
if (!href) {
|
|
124
|
+
return (
|
|
125
|
+
<span key={key} className="squisq-md-link squisq-md-link--blocked">
|
|
126
|
+
{renderInline(node.children, key, ctx)}
|
|
127
|
+
</span>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
96
130
|
return (
|
|
97
131
|
<a
|
|
98
132
|
key={key}
|
|
99
133
|
className="squisq-md-link"
|
|
100
|
-
href={
|
|
134
|
+
href={href}
|
|
101
135
|
title={node.title ?? undefined}
|
|
102
136
|
target="_blank"
|
|
103
137
|
rel="noopener noreferrer"
|
|
104
138
|
>
|
|
105
|
-
{renderInline(node.children, key)}
|
|
139
|
+
{renderInline(node.children, key, ctx)}
|
|
106
140
|
</a>
|
|
107
141
|
);
|
|
142
|
+
}
|
|
108
143
|
|
|
109
144
|
case 'image':
|
|
110
145
|
return (
|
|
@@ -122,9 +157,15 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
|
|
|
122
157
|
);
|
|
123
158
|
|
|
124
159
|
case 'htmlInline':
|
|
160
|
+
if (ctx.htmlPolicy === 'strip') return null;
|
|
125
161
|
// Fast path: no <video>/<audio> in the subtree → use the original
|
|
126
162
|
// rawHtml passthrough (preserves arbitrary HTML for custom embeds).
|
|
127
|
-
if (
|
|
163
|
+
if (
|
|
164
|
+
ctx.htmlPolicy === 'trusted' &&
|
|
165
|
+
!containsMediaTag(node.htmlChildren) &&
|
|
166
|
+
!containsDangerousTag(node.htmlChildren) &&
|
|
167
|
+
!hasDangerousRawHtml(node.rawHtml)
|
|
168
|
+
) {
|
|
128
169
|
return (
|
|
129
170
|
<span
|
|
130
171
|
key={key}
|
|
@@ -137,7 +178,7 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
|
|
|
137
178
|
// go through MediaContext-aware player components.
|
|
138
179
|
return (
|
|
139
180
|
<span key={key} className="squisq-md-html-inline">
|
|
140
|
-
{renderHtmlNodes(node.htmlChildren, `${key}h`)}
|
|
181
|
+
{renderHtmlNodes(resolveHtmlNodes(node.htmlChildren, ctx.htmlPolicy), `${key}h`)}
|
|
141
182
|
</span>
|
|
142
183
|
);
|
|
143
184
|
|
|
@@ -152,7 +193,7 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
|
|
|
152
193
|
// Render as plain text (definition targets not available at render time)
|
|
153
194
|
return (
|
|
154
195
|
<span key={key} className="squisq-md-link-ref">
|
|
155
|
-
{renderInline(node.children, key)}
|
|
196
|
+
{renderInline(node.children, key, ctx)}
|
|
156
197
|
</span>
|
|
157
198
|
);
|
|
158
199
|
|
|
@@ -166,7 +207,7 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
|
|
|
166
207
|
case 'textDirective':
|
|
167
208
|
return (
|
|
168
209
|
<span key={key} className="squisq-md-text-directive" data-directive={node.name}>
|
|
169
|
-
{renderInline(node.children, key)}
|
|
210
|
+
{renderInline(node.children, key, ctx)}
|
|
170
211
|
</span>
|
|
171
212
|
);
|
|
172
213
|
|
|
@@ -193,12 +234,16 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
|
|
|
193
234
|
// ── Block Renderer ─────────────────────────────────────────────────
|
|
194
235
|
|
|
195
236
|
/** Render a single block-level node into a React element. */
|
|
196
|
-
function renderBlock(
|
|
237
|
+
function renderBlock(
|
|
238
|
+
node: MarkdownBlockNode,
|
|
239
|
+
key: string,
|
|
240
|
+
ctx: RenderCtx = DEFAULT_CTX,
|
|
241
|
+
): React.ReactNode {
|
|
197
242
|
switch (node.type) {
|
|
198
243
|
case 'paragraph':
|
|
199
244
|
return (
|
|
200
245
|
<p key={key} className="squisq-md-p">
|
|
201
|
-
{renderInline(node.children, key)}
|
|
246
|
+
{renderInline(node.children, key, ctx)}
|
|
202
247
|
</p>
|
|
203
248
|
);
|
|
204
249
|
|
|
@@ -206,7 +251,7 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
|
|
|
206
251
|
const Tag = `h${node.depth}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
|
207
252
|
return (
|
|
208
253
|
<Tag key={key} className={`squisq-md-heading squisq-md-h${node.depth}`}>
|
|
209
|
-
{renderInline(node.children, key)}
|
|
254
|
+
{renderInline(node.children, key, ctx)}
|
|
210
255
|
</Tag>
|
|
211
256
|
);
|
|
212
257
|
}
|
|
@@ -214,7 +259,7 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
|
|
|
214
259
|
case 'blockquote':
|
|
215
260
|
return (
|
|
216
261
|
<blockquote key={key} className="squisq-md-blockquote">
|
|
217
|
-
{renderBlocks(node.children, key)}
|
|
262
|
+
{renderBlocks(node.children, key, ctx)}
|
|
218
263
|
</blockquote>
|
|
219
264
|
);
|
|
220
265
|
|
|
@@ -222,13 +267,13 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
|
|
|
222
267
|
if (node.ordered) {
|
|
223
268
|
return (
|
|
224
269
|
<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}
|
|
270
|
+
{node.children.map((item, i) => renderListItem(item, `${key}li${i}`, ctx))}
|
|
226
271
|
</ol>
|
|
227
272
|
);
|
|
228
273
|
}
|
|
229
274
|
return (
|
|
230
275
|
<ul key={key} className="squisq-md-list squisq-md-ul">
|
|
231
|
-
{node.children.map((item, i) => renderListItem(item, `${key}li${i}
|
|
276
|
+
{node.children.map((item, i) => renderListItem(item, `${key}li${i}`, ctx))}
|
|
232
277
|
</ul>
|
|
233
278
|
);
|
|
234
279
|
|
|
@@ -243,12 +288,18 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
|
|
|
243
288
|
return <hr key={key} className="squisq-md-hr" />;
|
|
244
289
|
|
|
245
290
|
case 'table':
|
|
246
|
-
return renderTable(node.children, node.align, key);
|
|
291
|
+
return renderTable(node.children, node.align, key, ctx);
|
|
247
292
|
|
|
248
293
|
case 'htmlBlock':
|
|
294
|
+
if (ctx.htmlPolicy === 'strip') return null;
|
|
249
295
|
// Fast path: no <video>/<audio> → preserve the existing rawHtml
|
|
250
296
|
// passthrough so arbitrary HTML embeds still survive verbatim.
|
|
251
|
-
if (
|
|
297
|
+
if (
|
|
298
|
+
ctx.htmlPolicy === 'trusted' &&
|
|
299
|
+
!containsMediaTag(node.htmlChildren) &&
|
|
300
|
+
!containsDangerousTag(node.htmlChildren) &&
|
|
301
|
+
!hasDangerousRawHtml(node.rawHtml)
|
|
302
|
+
) {
|
|
252
303
|
return (
|
|
253
304
|
<div
|
|
254
305
|
key={key}
|
|
@@ -261,7 +312,7 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
|
|
|
261
312
|
// route through the player components and resolve via MediaContext.
|
|
262
313
|
return (
|
|
263
314
|
<div key={key} className="squisq-md-html-block">
|
|
264
|
-
{renderHtmlNodes(node.htmlChildren, `${key}h`)}
|
|
315
|
+
{renderHtmlNodes(resolveHtmlNodes(node.htmlChildren, ctx.htmlPolicy), `${key}h`)}
|
|
265
316
|
</div>
|
|
266
317
|
);
|
|
267
318
|
|
|
@@ -280,7 +331,7 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
|
|
|
280
331
|
return (
|
|
281
332
|
<div key={key} className="squisq-md-footnote-def" id={`fn-${node.identifier}`}>
|
|
282
333
|
<sup>{node.label ?? node.identifier}</sup>
|
|
283
|
-
{renderBlocks(node.children, key)}
|
|
334
|
+
{renderBlocks(node.children, key, ctx)}
|
|
284
335
|
</div>
|
|
285
336
|
);
|
|
286
337
|
|
|
@@ -292,7 +343,7 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
|
|
|
292
343
|
data-directive={node.name}
|
|
293
344
|
>
|
|
294
345
|
{node.label && <div className="squisq-md-directive-label">{node.label}</div>}
|
|
295
|
-
{renderBlocks(node.children, key)}
|
|
346
|
+
{renderBlocks(node.children, key, ctx)}
|
|
296
347
|
</div>
|
|
297
348
|
);
|
|
298
349
|
|
|
@@ -303,7 +354,7 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
|
|
|
303
354
|
className={`squisq-md-directive squisq-md-directive-${node.name}`}
|
|
304
355
|
data-directive={node.name}
|
|
305
356
|
>
|
|
306
|
-
{renderInline(node.children, key)}
|
|
357
|
+
{renderInline(node.children, key, ctx)}
|
|
307
358
|
</div>
|
|
308
359
|
);
|
|
309
360
|
|
|
@@ -314,13 +365,13 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
|
|
|
314
365
|
if (child.type === 'definitionTerm') {
|
|
315
366
|
return (
|
|
316
367
|
<dt key={`${key}dt${i}`} className="squisq-md-dt">
|
|
317
|
-
{renderInline(child.children, `${key}dt${i}
|
|
368
|
+
{renderInline(child.children, `${key}dt${i}`, ctx)}
|
|
318
369
|
</dt>
|
|
319
370
|
);
|
|
320
371
|
}
|
|
321
372
|
return (
|
|
322
373
|
<dd key={`${key}dd${i}`} className="squisq-md-dd">
|
|
323
|
-
{renderBlocks(child.children, `${key}dd${i}
|
|
374
|
+
{renderBlocks(child.children, `${key}dd${i}`, ctx)}
|
|
324
375
|
</dd>
|
|
325
376
|
);
|
|
326
377
|
})}
|
|
@@ -333,14 +384,18 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
|
|
|
333
384
|
}
|
|
334
385
|
|
|
335
386
|
/** Render a list item, including task-list checkbox support. */
|
|
336
|
-
function renderListItem(
|
|
387
|
+
function renderListItem(
|
|
388
|
+
item: MarkdownListItem,
|
|
389
|
+
key: string,
|
|
390
|
+
ctx: RenderCtx = DEFAULT_CTX,
|
|
391
|
+
): React.ReactNode {
|
|
337
392
|
const isTask = item.checked !== null && item.checked !== undefined;
|
|
338
393
|
return (
|
|
339
394
|
<li key={key} className={`squisq-md-li${isTask ? ' squisq-md-task' : ''}`}>
|
|
340
395
|
{isTask && (
|
|
341
396
|
<input type="checkbox" checked={!!item.checked} readOnly className="squisq-md-checkbox" />
|
|
342
397
|
)}
|
|
343
|
-
{renderBlocks(item.children, key)}
|
|
398
|
+
{renderBlocks(item.children, key, ctx)}
|
|
344
399
|
</li>
|
|
345
400
|
);
|
|
346
401
|
}
|
|
@@ -350,6 +405,7 @@ function renderTable(
|
|
|
350
405
|
rows: MarkdownTableRow[],
|
|
351
406
|
align: (('left' | 'right' | 'center') | null)[] | undefined,
|
|
352
407
|
key: string,
|
|
408
|
+
ctx: RenderCtx = DEFAULT_CTX,
|
|
353
409
|
): React.ReactNode {
|
|
354
410
|
const [headerRow, ...bodyRows] = rows;
|
|
355
411
|
return (
|
|
@@ -363,7 +419,7 @@ function renderTable(
|
|
|
363
419
|
className="squisq-md-th"
|
|
364
420
|
style={align?.[ci] ? { textAlign: align[ci]! } : undefined}
|
|
365
421
|
>
|
|
366
|
-
{renderInline(cell.children, `${key}th${ci}
|
|
422
|
+
{renderInline(cell.children, `${key}th${ci}`, ctx)}
|
|
367
423
|
</th>
|
|
368
424
|
))}
|
|
369
425
|
</tr>
|
|
@@ -379,7 +435,7 @@ function renderTable(
|
|
|
379
435
|
className="squisq-md-td"
|
|
380
436
|
style={align?.[ci] ? { textAlign: align[ci]! } : undefined}
|
|
381
437
|
>
|
|
382
|
-
{renderInline(cell.children, `${key}td${ri}-${ci}
|
|
438
|
+
{renderInline(cell.children, `${key}td${ri}-${ci}`, ctx)}
|
|
383
439
|
</td>
|
|
384
440
|
))}
|
|
385
441
|
</tr>
|
|
@@ -391,15 +447,21 @@ function renderTable(
|
|
|
391
447
|
}
|
|
392
448
|
|
|
393
449
|
/** Render an array of block-level nodes. */
|
|
394
|
-
function renderBlocks(
|
|
395
|
-
|
|
450
|
+
function renderBlocks(
|
|
451
|
+
nodes: MarkdownBlockNode[],
|
|
452
|
+
keyPrefix = '',
|
|
453
|
+
ctx: RenderCtx = DEFAULT_CTX,
|
|
454
|
+
): React.ReactNode[] {
|
|
455
|
+
return nodes.map((node, i) => renderBlock(node, `${keyPrefix}b${i}`, ctx));
|
|
396
456
|
}
|
|
397
457
|
|
|
398
458
|
// ── Image with MediaProvider resolution ───────────────────────────
|
|
399
459
|
|
|
400
460
|
/** Renders an <img> that resolves its src through the MediaProvider when available. */
|
|
401
461
|
function MdImage({ src, alt, title }: { src: string; alt: string; title?: string }) {
|
|
402
|
-
const
|
|
462
|
+
const safeSrc = sanitizeUrl(src, 'media');
|
|
463
|
+
const resolved = useMediaUrl(safeSrc ?? '', '.');
|
|
464
|
+
if (!safeSrc) return null;
|
|
403
465
|
return <img className="squisq-md-image" src={resolved} alt={alt} title={title} />;
|
|
404
466
|
}
|
|
405
467
|
|
|
@@ -408,15 +470,75 @@ function MdImage({ src, alt, title }: { src: string; alt: string; title?: string
|
|
|
408
470
|
/** True when the htmlElement subtree contains a tag we want to swap
|
|
409
471
|
* for a React component. Cheap recursive scan — lets us keep the
|
|
410
472
|
* `dangerouslySetInnerHTML` fast path for everything else. */
|
|
473
|
+
function resolveHtmlNodes(nodes: HtmlNode[], htmlPolicy: HtmlPolicy): HtmlNode[] {
|
|
474
|
+
if (htmlPolicy === 'strip') return [];
|
|
475
|
+
if (htmlPolicy === 'trusted') return nodes;
|
|
476
|
+
return sanitizeHtmlNodes(nodes);
|
|
477
|
+
}
|
|
478
|
+
|
|
411
479
|
function containsMediaTag(nodes: HtmlNode[]): boolean {
|
|
412
480
|
for (const node of nodes) {
|
|
413
481
|
if (node.type !== 'htmlElement') continue;
|
|
414
|
-
|
|
482
|
+
const tagName = node.tagName.toLowerCase();
|
|
483
|
+
if (tagName === 'video' || tagName === 'audio') return true;
|
|
415
484
|
if (containsMediaTag(node.children)) return true;
|
|
416
485
|
}
|
|
417
486
|
return false;
|
|
418
487
|
}
|
|
419
488
|
|
|
489
|
+
/**
|
|
490
|
+
* Tags that can escape their container and affect the whole host
|
|
491
|
+
* document — global styling, script execution, external/resource loads,
|
|
492
|
+
* or framing. Markdown content is frequently untrusted (LLM output,
|
|
493
|
+
* pasted snippets), and these tags have no business mutating the page
|
|
494
|
+
* they're embedded in, so they are *always* dropped — even under the
|
|
495
|
+
* `'trusted'` policy. "Trusted" means "render this HTML's structure
|
|
496
|
+
* verbatim," not "let it restyle or script the host." A stray `<style>`
|
|
497
|
+
* applies document-wide (CSS has no per-element scoping outside shadow
|
|
498
|
+
* DOM / iframes), which is exactly how an embedded game's `<style>`
|
|
499
|
+
* leaked onto the surrounding app chrome.
|
|
500
|
+
*/
|
|
501
|
+
const DANGEROUS_HTML_TAGS = new Set([
|
|
502
|
+
'base',
|
|
503
|
+
'embed',
|
|
504
|
+
'iframe',
|
|
505
|
+
'link',
|
|
506
|
+
'meta',
|
|
507
|
+
'object',
|
|
508
|
+
'script',
|
|
509
|
+
'style',
|
|
510
|
+
'title',
|
|
511
|
+
]);
|
|
512
|
+
|
|
513
|
+
/** True when the subtree contains any host-affecting tag (see
|
|
514
|
+
* {@link DANGEROUS_HTML_TAGS}). Mirrors {@link containsMediaTag}: keeps
|
|
515
|
+
* such content off the verbatim `dangerouslySetInnerHTML` fast path so
|
|
516
|
+
* it routes through the React reconstruction, which drops the tag. */
|
|
517
|
+
function containsDangerousTag(nodes: HtmlNode[]): boolean {
|
|
518
|
+
for (const node of nodes) {
|
|
519
|
+
if (node.type !== 'htmlElement') continue;
|
|
520
|
+
if (DANGEROUS_HTML_TAGS.has(node.tagName.toLowerCase())) return true;
|
|
521
|
+
if (containsDangerousTag(node.children)) return true;
|
|
522
|
+
}
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Raw-string backstop for {@link DANGEROUS_HTML_TAGS}. The structural
|
|
528
|
+
* {@link containsDangerousTag} check covers the normal case, but a block
|
|
529
|
+
* parsed with `parseHtml: false` carries an empty `htmlChildren` while
|
|
530
|
+
* `rawHtml` still holds the markup — so the verbatim fast path scans the
|
|
531
|
+
* raw string too, guaranteeing a `<style>`/`<script>` can never be
|
|
532
|
+
* injected into the host document by that path no matter how the node
|
|
533
|
+
* was produced. The `\b` keeps `<styled-thing>` from matching `<style>`.
|
|
534
|
+
*/
|
|
535
|
+
const DANGEROUS_RAW_HTML_RE =
|
|
536
|
+
/<\s*\/?\s*(?:base|embed|iframe|link|meta|object|script|style|title)\b/i;
|
|
537
|
+
|
|
538
|
+
function hasDangerousRawHtml(rawHtml: string): boolean {
|
|
539
|
+
return DANGEROUS_RAW_HTML_RE.test(rawHtml);
|
|
540
|
+
}
|
|
541
|
+
|
|
420
542
|
/** A pragmatic shortlist of HTML attributes the raw-HTML walker
|
|
421
543
|
* passes through to React when reconstructing a non-media element.
|
|
422
544
|
* Anything outside this list is silently dropped — the media-tag
|
|
@@ -432,6 +554,10 @@ const PASSTHROUGH_ATTRS: Record<string, string> = {
|
|
|
432
554
|
// media-adjacent (used when video/audio appear inside other wrappers)
|
|
433
555
|
width: 'width',
|
|
434
556
|
height: 'height',
|
|
557
|
+
src: 'src',
|
|
558
|
+
alt: 'alt',
|
|
559
|
+
loading: 'loading',
|
|
560
|
+
decoding: 'decoding',
|
|
435
561
|
// anchor
|
|
436
562
|
href: 'href',
|
|
437
563
|
target: 'target',
|
|
@@ -456,7 +582,13 @@ function reactPropsFromAttrs(attrs: Record<string, string>): Record<string, unkn
|
|
|
456
582
|
}
|
|
457
583
|
|
|
458
584
|
function renderHtmlElement(el: HtmlElement, key: string): React.ReactNode {
|
|
459
|
-
|
|
585
|
+
const tagName = el.tagName.toLowerCase();
|
|
586
|
+
// Final safety net: never reconstruct a host-affecting element (e.g. a
|
|
587
|
+
// <style> that would leak globally), whatever the policy. The fast path
|
|
588
|
+
// is gated by containsDangerousTag, so trusted content carrying these
|
|
589
|
+
// tags lands here — drop the tag and keep the rest of the subtree.
|
|
590
|
+
if (DANGEROUS_HTML_TAGS.has(tagName)) return null;
|
|
591
|
+
if (tagName === 'video') {
|
|
460
592
|
return (
|
|
461
593
|
<InlineVideoPlayer
|
|
462
594
|
key={key}
|
|
@@ -477,7 +609,7 @@ function renderHtmlElement(el: HtmlElement, key: string): React.ReactNode {
|
|
|
477
609
|
/>
|
|
478
610
|
);
|
|
479
611
|
}
|
|
480
|
-
if (
|
|
612
|
+
if (tagName === 'audio') {
|
|
481
613
|
return (
|
|
482
614
|
<InlineAudioPlayer
|
|
483
615
|
key={key}
|
|
@@ -494,7 +626,7 @@ function renderHtmlElement(el: HtmlElement, key: string): React.ReactNode {
|
|
|
494
626
|
);
|
|
495
627
|
}
|
|
496
628
|
|
|
497
|
-
const Tag =
|
|
629
|
+
const Tag = tagName as keyof JSX.IntrinsicElements;
|
|
498
630
|
const props = reactPropsFromAttrs(el.attributes);
|
|
499
631
|
if (el.selfClosing) {
|
|
500
632
|
return <Tag key={key} {...props} />;
|
|
@@ -533,8 +665,17 @@ function renderHtmlNodes(nodes: HtmlNode[], keyPrefix: string): React.ReactNode[
|
|
|
533
665
|
* <MarkdownRenderer nodes={block.contents} />
|
|
534
666
|
* ```
|
|
535
667
|
*/
|
|
536
|
-
export function MarkdownRenderer({
|
|
668
|
+
export function MarkdownRenderer({
|
|
669
|
+
nodes,
|
|
670
|
+
className,
|
|
671
|
+
htmlPolicy = 'sanitize',
|
|
672
|
+
linkSchemes,
|
|
673
|
+
}: MarkdownRendererProps) {
|
|
537
674
|
if (!nodes || nodes.length === 0) return null;
|
|
538
675
|
|
|
539
|
-
return
|
|
676
|
+
return (
|
|
677
|
+
<div className={`squisq-md ${className || ''}`}>
|
|
678
|
+
{renderBlocks(nodes, '', { htmlPolicy, linkSchemes })}
|
|
679
|
+
</div>
|
|
680
|
+
);
|
|
540
681
|
}
|
|
@@ -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
|
+
}
|