@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.
Files changed (46) hide show
  1. package/README.md +57 -23
  2. package/dist/index.d.ts +131 -26
  3. package/dist/index.js +1475 -755
  4. package/dist/index.js.map +1 -1
  5. package/dist/squisq-player.css +1 -1
  6. package/dist/squisq-player.css.map +1 -1
  7. package/dist/squisq-player.global.js +49 -13
  8. package/dist/squisq-player.global.js.map +1 -1
  9. package/dist/standalone-source.js +1 -1
  10. package/dist/styles/index.css +2263 -0
  11. package/package.json +9 -5
  12. package/src/BlockRenderer.tsx +15 -7
  13. package/src/DocPlayer.tsx +222 -55
  14. package/src/DocPlayerWithSidebar.tsx +21 -9
  15. package/src/DocProgressBar.tsx +21 -3
  16. package/src/LinearDocView.tsx +69 -206
  17. package/src/MarkdownRenderer.tsx +182 -41
  18. package/src/MediaClipLayer.tsx +135 -0
  19. package/src/__tests__/DocPlayer.test.tsx +81 -0
  20. package/src/__tests__/DocPlayerStylesSentinel.test.tsx +41 -0
  21. package/src/__tests__/DocProgressBar.test.tsx +76 -0
  22. package/src/__tests__/LinearDocView.test.tsx +53 -1
  23. package/src/__tests__/MarkdownRenderer.test.tsx +113 -1
  24. package/src/__tests__/PathLayer.test.tsx +73 -0
  25. package/src/__tests__/fillStyle.test.tsx +112 -0
  26. package/src/__tests__/transitionStyles.test.ts +125 -0
  27. package/src/__tests__/useDocPlayback.transition.test.ts +70 -0
  28. package/src/__tests__/useJsonViewTokens.test.ts +41 -0
  29. package/src/__tests__/useSlideSwipe.test.ts +81 -0
  30. package/src/hooks/{AudioProvider.ts → AudioController.ts} +3 -3
  31. package/src/hooks/index.ts +7 -2
  32. package/src/hooks/useAudioSync.ts +19 -5
  33. package/src/hooks/useDocPlayback.ts +81 -100
  34. package/src/hooks/useMediaSchedule.ts +39 -0
  35. package/src/hooks/useSlideSwipe.ts +265 -0
  36. package/src/index.ts +8 -1
  37. package/src/jsonView/useJsonViewTokens.ts +6 -31
  38. package/src/layers/ImageLayer.tsx +11 -1
  39. package/src/layers/PathLayer.tsx +146 -0
  40. package/src/layers/ShapeLayer.tsx +27 -5
  41. package/src/layers/TextLayer.tsx +395 -22
  42. package/src/layers/VideoLayer.tsx +16 -9
  43. package/src/layers/index.ts +1 -0
  44. package/src/standalone-entry.tsx +1 -1
  45. package/src/styles/doc-animations.css +1936 -35
  46. package/src/utils/fillStyle.tsx +148 -0
@@ -15,14 +15,17 @@
15
15
  */
16
16
 
17
17
  import { Fragment } from 'react';
18
- import type {
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(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactNode[] {
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={node.url}
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 (!containsMediaTag(node.htmlChildren)) {
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(node: MarkdownBlockNode, key: string): React.ReactNode {
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 (!containsMediaTag(node.htmlChildren)) {
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(item: MarkdownListItem, key: string): React.ReactNode {
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(nodes: MarkdownBlockNode[], keyPrefix = ''): React.ReactNode[] {
395
- return nodes.map((node, i) => renderBlock(node, `${keyPrefix}b${i}`));
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 resolved = useMediaUrl(src, '.');
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
- if (node.tagName === 'video' || node.tagName === 'audio') return true;
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
- if (el.tagName === 'video') {
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 (el.tagName === 'audio') {
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 = el.tagName as keyof JSX.IntrinsicElements;
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({ nodes, className }: MarkdownRendererProps) {
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 <div className={`squisq-md ${className || ''}`}>{renderBlocks(nodes)}</div>;
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
+ }