@bendyline/squisq-react 1.2.0 → 1.3.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.
Files changed (77) hide show
  1. package/dist/InlineAudioPlayer.d.ts +15 -0
  2. package/dist/InlineAudioPlayer.d.ts.map +1 -0
  3. package/dist/InlineAudioPlayer.js +22 -0
  4. package/dist/InlineAudioPlayer.js.map +1 -0
  5. package/dist/InlineVideoPlayer.d.ts +21 -0
  6. package/dist/InlineVideoPlayer.d.ts.map +1 -0
  7. package/dist/InlineVideoPlayer.js +29 -0
  8. package/dist/InlineVideoPlayer.js.map +1 -0
  9. package/dist/LinearDocView.d.ts.map +1 -1
  10. package/dist/LinearDocView.js +124 -13
  11. package/dist/LinearDocView.js.map +1 -1
  12. package/dist/MarkdownRenderer.d.ts.map +1 -1
  13. package/dist/MarkdownRenderer.js +111 -2
  14. package/dist/MarkdownRenderer.js.map +1 -1
  15. package/dist/SocialCaptionOverlay.d.ts.map +1 -1
  16. package/dist/SocialCaptionOverlay.js +3 -2
  17. package/dist/SocialCaptionOverlay.js.map +1 -1
  18. package/dist/__tests__/JsonView.test.d.ts +2 -0
  19. package/dist/__tests__/JsonView.test.d.ts.map +1 -0
  20. package/dist/__tests__/JsonView.test.js +93 -0
  21. package/dist/__tests__/JsonView.test.js.map +1 -0
  22. package/dist/__tests__/LinearDocView.test.js +62 -0
  23. package/dist/__tests__/LinearDocView.test.js.map +1 -1
  24. package/dist/hooks/MediaContext.d.ts.map +1 -1
  25. package/dist/hooks/MediaContext.js +13 -7
  26. package/dist/hooks/MediaContext.js.map +1 -1
  27. package/dist/index.d.ts +7 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +5 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/jsonView/JsonView.d.ts +26 -0
  32. package/dist/jsonView/JsonView.d.ts.map +1 -0
  33. package/dist/jsonView/JsonView.js +12 -0
  34. package/dist/jsonView/JsonView.js.map +1 -0
  35. package/dist/jsonView/RenderNode.d.ts +22 -0
  36. package/dist/jsonView/RenderNode.d.ts.map +1 -0
  37. package/dist/jsonView/RenderNode.js +30 -0
  38. package/dist/jsonView/RenderNode.js.map +1 -0
  39. package/dist/jsonView/index.d.ts +3 -0
  40. package/dist/jsonView/index.d.ts.map +1 -0
  41. package/dist/jsonView/index.js +2 -0
  42. package/dist/jsonView/index.js.map +1 -0
  43. package/dist/jsonView/useJsonViewTokens.d.ts +14 -0
  44. package/dist/jsonView/useJsonViewTokens.d.ts.map +1 -0
  45. package/dist/jsonView/useJsonViewTokens.js +34 -0
  46. package/dist/jsonView/useJsonViewTokens.js.map +1 -0
  47. package/dist/jsonView/viewers.d.ts +30 -0
  48. package/dist/jsonView/viewers.d.ts.map +1 -0
  49. package/dist/jsonView/viewers.js +226 -0
  50. package/dist/jsonView/viewers.js.map +1 -0
  51. package/dist/squisq-player.css +1 -1
  52. package/dist/squisq-player.css.map +1 -1
  53. package/dist/squisq-player.global.js +24 -10
  54. package/dist/squisq-player.global.js.map +1 -1
  55. package/dist/standalone-source.js +1 -1
  56. package/dist/types.d.ts +5 -1
  57. package/dist/types.d.ts.map +1 -1
  58. package/dist/types.js.map +1 -1
  59. package/package.json +4 -3
  60. package/src/InlineAudioPlayer.tsx +46 -0
  61. package/src/InlineVideoPlayer.tsx +70 -0
  62. package/src/LinearDocView.tsx +136 -14
  63. package/src/MarkdownRenderer.tsx +156 -10
  64. package/src/SocialCaptionOverlay.tsx +3 -2
  65. package/src/__tests__/JsonView.test.tsx +111 -0
  66. package/src/__tests__/LinearDocView.test.tsx +66 -0
  67. package/src/hooks/MediaContext.tsx +15 -8
  68. package/src/index.ts +9 -0
  69. package/src/jsonView/JsonView.tsx +51 -0
  70. package/src/jsonView/RenderNode.tsx +51 -0
  71. package/src/jsonView/index.ts +2 -0
  72. package/src/jsonView/json-view.css +206 -0
  73. package/src/jsonView/useJsonViewTokens.ts +57 -0
  74. package/src/jsonView/viewers.tsx +343 -0
  75. package/src/styles/doc-animations.css +36 -0
  76. package/src/styles/index.css +7 -0
  77. package/src/types.ts +5 -1
package/dist/types.d.ts CHANGED
@@ -26,8 +26,12 @@ export type ControlsLayout = 'overlay' | 'sidebar' | 'bottom';
26
26
  * - `'linear'` — Long-scrolling document view. Renders the full markdown as
27
27
  * readable HTML with template-annotated sections shown as inline SVG cards.
28
28
  * No audio, no timeline, no controls — just a scrollable page.
29
+ * - `'page'` — Plain semantic HTML preview matching what the
30
+ * `markdownDocToPlainHtml` export produces. No SquisqPlayer, no SVG
31
+ * cards — just `<h1>`/`<p>`/`<ul>` etc. inside a sandboxed iframe.
32
+ * Use when you want a WYSIWYG view of the simple HTML export.
29
33
  */
30
- export type DisplayMode = 'video' | 'slideshow' | 'linear';
34
+ export type DisplayMode = 'video' | 'slideshow' | 'linear' | 'page';
31
35
  /**
32
36
  * Caption display style.
33
37
  *
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AA0JH,6CAA6C;AAC7C,MAAM,UAAU,UAAU,CAAC,OAAe;IACxC,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,GAAG,EAAE,CAAC,CAAC;IACtC,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,GAAG,EAAE,CAAC,CAAC;IACtC,OAAO,GAAG,IAAI,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC;AACvD,CAAC"}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AA8JH,6CAA6C;AAC7C,MAAM,UAAU,UAAU,CAAC,OAAe;IACxC,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,GAAG,EAAE,CAAC,CAAC;IACtC,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,GAAG,EAAE,CAAC,CAAC;IACtC,OAAO,GAAG,IAAI,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC;AACvD,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bendyline/squisq-react",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "React component library for doc playback, block rendering, and media layers",
5
5
  "license": "MIT",
6
6
  "author": "Bendyline",
@@ -34,7 +34,7 @@
34
34
  "types": "./dist/index.d.ts",
35
35
  "import": "./dist/index.js"
36
36
  },
37
- "./styles": "./src/styles/doc-animations.css",
37
+ "./styles": "./src/styles/index.css",
38
38
  "./standalone": "./dist/squisq-player.global.js",
39
39
  "./standalone-source": {
40
40
  "types": "./dist/standalone-source.d.ts",
@@ -45,6 +45,7 @@
45
45
  "build": "tsup && tsup --config tsup.standalone.config.ts && node scripts/generate-standalone-source.mjs",
46
46
  "build:esm": "tsup",
47
47
  "build:standalone": "tsup --config tsup.standalone.config.ts && node scripts/generate-standalone-source.mjs",
48
+ "dev": "npm run build:standalone && concurrently -n js,dts -c blue,gray -r \"tsup --watch --no-dts --no-clean\" \"tsup --watch --dts-only --no-clean\"",
48
49
  "typecheck": "tsc --noEmit"
49
50
  },
50
51
  "peerDependencies": {
@@ -52,7 +53,7 @@
52
53
  "react-dom": "^18.0.0 || ^19.0.0"
53
54
  },
54
55
  "dependencies": {
55
- "@bendyline/squisq": "1.3.0"
56
+ "@bendyline/squisq": "1.4.0"
56
57
  },
57
58
  "devDependencies": {
58
59
  "@types/react": "18.3.28",
@@ -0,0 +1,46 @@
1
+ /**
2
+ * InlineAudioPlayer
3
+ *
4
+ * Small inline HTML5 audio player for `<audio>` tags embedded directly in
5
+ * a markdown document — e.g. a narration recording the user wants to
6
+ * audition in the editor. Resolves the `src` through `MediaContext` so a
7
+ * container-backed `audio/foo.webm` path returns a playable blob URL.
8
+ *
9
+ * Deliberately thin: native browser controls, no autoplay. The
10
+ * MarkdownRenderer swaps this in for raw `<audio>` htmlElements; the
11
+ * editor-react Tiptap audio node reuses it for its NodeView.
12
+ */
13
+ import { useMediaUrl } from './hooks/MediaContext.js';
14
+
15
+ export interface InlineAudioPlayerProps {
16
+ /** Source path — resolved through MediaContext when relative. */
17
+ src: string;
18
+ /** Base path used when no MediaProvider is in context. */
19
+ basePath?: string;
20
+ /** Whether to show native controls. Defaults to true. */
21
+ controls?: boolean;
22
+ /** `preload` attribute passthrough. Defaults to `'metadata'`. */
23
+ preload?: 'none' | 'metadata' | 'auto';
24
+ /** Extra className on the wrapper. */
25
+ className?: string;
26
+ }
27
+
28
+ export function InlineAudioPlayer({
29
+ src,
30
+ basePath = '',
31
+ controls = true,
32
+ preload = 'metadata',
33
+ className,
34
+ }: InlineAudioPlayerProps) {
35
+ const resolvedSrc = useMediaUrl(src, basePath);
36
+
37
+ if (!resolvedSrc) return null;
38
+
39
+ return (
40
+ <span className={`squisq-inline-audio-player ${className ?? ''}`.trim()}>
41
+ <audio src={resolvedSrc} controls={controls} preload={preload} />
42
+ </span>
43
+ );
44
+ }
45
+
46
+ export default InlineAudioPlayer;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * InlineVideoPlayer
3
+ *
4
+ * Small inline HTML5 video player for `<video>` tags embedded directly in
5
+ * a markdown document — e.g. a recording dropped into the doc by the
6
+ * recorder. Resolves the `src` through `MediaContext` (same path
7
+ * `VideoLayer` uses) so a container-backed `audio/foo.webm` or
8
+ * `video/foo.mp4` resolves to a blob URL instead of 404-ing on the
9
+ * filesystem path.
10
+ *
11
+ * Deliberately thin: native browser controls, intrinsic aspect ratio,
12
+ * no autoplay. The MarkdownRenderer swaps this in for a raw `<video>`
13
+ * htmlElement; the editor-react Tiptap video node uses the same
14
+ * component for its NodeView.
15
+ */
16
+ import { useMediaUrl } from './hooks/MediaContext.js';
17
+
18
+ export interface InlineVideoPlayerProps {
19
+ /** Source path — resolved through MediaContext when relative. */
20
+ src: string;
21
+ /** Base path used when no MediaProvider is in context. */
22
+ basePath?: string;
23
+ /** Optional explicit width (pixels or CSS length). */
24
+ width?: number | string;
25
+ /** Optional explicit height (pixels or CSS length). */
26
+ height?: number | string;
27
+ /** Optional poster image src — also resolved through MediaContext. */
28
+ poster?: string;
29
+ /** Whether to show native controls. Defaults to true. */
30
+ controls?: boolean;
31
+ /** `preload` attribute passthrough. Defaults to `'metadata'`. */
32
+ preload?: 'none' | 'metadata' | 'auto';
33
+ /** Extra className on the wrapper. */
34
+ className?: string;
35
+ }
36
+
37
+ export function InlineVideoPlayer({
38
+ src,
39
+ basePath = '',
40
+ width,
41
+ height,
42
+ poster,
43
+ controls = true,
44
+ preload = 'metadata',
45
+ className,
46
+ }: InlineVideoPlayerProps) {
47
+ const resolvedSrc = useMediaUrl(src, basePath);
48
+ // Always call the hook (Rules of Hooks); only use the result if a poster
49
+ // was actually requested. Empty input → fallback path which we discard.
50
+ const resolvedPoster = useMediaUrl(poster ?? '', basePath);
51
+ const posterUrl = poster ? resolvedPoster : undefined;
52
+
53
+ if (!resolvedSrc) return null;
54
+
55
+ return (
56
+ <span className={`squisq-inline-video-player ${className ?? ''}`.trim()}>
57
+ <video
58
+ src={resolvedSrc}
59
+ poster={posterUrl}
60
+ controls={controls}
61
+ preload={preload}
62
+ width={width}
63
+ height={height}
64
+ playsInline
65
+ />
66
+ </span>
67
+ );
68
+ }
69
+
70
+ export default InlineVideoPlayer;
@@ -21,7 +21,12 @@ import { useMemo } from 'react';
21
21
  import { useAutoSurface } from './hooks/useAutoSurface';
22
22
  import type { Doc, Block, DocBlock } from '@bendyline/squisq/schemas';
23
23
  import type { ViewportConfig } from '@bendyline/squisq/schemas';
24
- import { applySurface, type SurfaceScheme, type Theme } from '@bendyline/squisq/schemas';
24
+ import {
25
+ applySurface,
26
+ resolveFontFamily,
27
+ type SurfaceScheme,
28
+ type Theme,
29
+ } from '@bendyline/squisq/schemas';
25
30
  import { VIEWPORT_PRESETS } from '@bendyline/squisq/schemas';
26
31
  import { getLayers, hasTemplate, DEFAULT_THEME } from '@bendyline/squisq/doc';
27
32
  import type { RenderContext } from '@bendyline/squisq/doc';
@@ -164,23 +169,21 @@ function BlockSection({ block, basePath, viewport, renderContext, blockIndex }:
164
169
  {/* Render the heading (if present — preamble has no sourceHeading) */}
165
170
  {block.sourceHeading && !isAnnotated && <MarkdownRenderer nodes={[block.sourceHeading]} />}
166
171
 
167
- {/* Annotated block: render SVG card */}
172
+ {/* Annotated block: render SVG card.
173
+ The heading is intentionally *not* duplicated above the card —
174
+ every template card renders its own title layer internally, so
175
+ a separate `squisq-linear-card-label` only made the heading
176
+ appear twice in the linear view. The card also drops its
177
+ rounded border + drop shadow so it reads as a continuation of
178
+ the surrounding page rather than a chrome'd preview. */}
168
179
  {isAnnotated && visualBlock && (
169
180
  <div className="squisq-linear-card">
170
- {/* Optional heading label above the card */}
171
- {block.sourceHeading && (
172
- <div className="squisq-linear-card-label squisq-md">
173
- <MarkdownRenderer nodes={[block.sourceHeading]} />
174
- </div>
175
- )}
176
181
  <div
177
182
  className="squisq-linear-card-svg"
178
183
  style={{
179
184
  width: '100%',
180
185
  aspectRatio: `${viewport.width} / ${viewport.height}`,
181
186
  overflow: 'hidden',
182
- borderRadius: '8px',
183
- boxShadow: '0 2px 8px rgba(0, 0, 0, 0.12)',
184
187
  marginBottom: '1em',
185
188
  }}
186
189
  >
@@ -244,6 +247,83 @@ function extractListItems(contents?: MarkdownBlockNode[]): string[] {
244
247
  return items;
245
248
  }
246
249
 
250
+ /** First image discovered in a block's body, with any explicit
251
+ * dimensions captured (set by `<img width>` / `<img height>` in raw
252
+ * HTML — markdown shorthand has no syntax for dimensions). */
253
+ interface FirstImage {
254
+ src: string;
255
+ alt: string;
256
+ width?: number;
257
+ height?: number;
258
+ }
259
+
260
+ /**
261
+ * Find the first image referenced anywhere in block contents — both
262
+ * markdown shorthand `![alt](url)` (type `image`) and raw HTML `<img>`
263
+ * tags (type `htmlBlock`/`htmlInline`). The WYSIWYG editor emits the
264
+ * HTML form whenever a user resizes an image (markdown shorthand has no
265
+ * width syntax), so missing that path silently breaks every resized
266
+ * image in the linear view.
267
+ */
268
+ function extractFirstImage(contents: MarkdownBlockNode[] | undefined): FirstImage | null {
269
+ if (!contents || contents.length === 0) return null;
270
+
271
+ function fromHtml(nodes: unknown[]): FirstImage | null {
272
+ for (const node of nodes) {
273
+ if (!node || typeof node !== 'object') continue;
274
+ const n = node as Record<string, unknown>;
275
+ if (n.type === 'htmlElement' && n.tagName === 'img') {
276
+ const attrs = n.attributes as Record<string, string> | undefined;
277
+ if (attrs && typeof attrs.src === 'string' && attrs.src) {
278
+ return {
279
+ src: attrs.src,
280
+ alt: typeof attrs.alt === 'string' ? attrs.alt : '',
281
+ width: parseDim(attrs.width),
282
+ height: parseDim(attrs.height),
283
+ };
284
+ }
285
+ }
286
+ if (Array.isArray(n.children)) {
287
+ const found = fromHtml(n.children);
288
+ if (found) return found;
289
+ }
290
+ }
291
+ return null;
292
+ }
293
+
294
+ function walk(node: unknown): FirstImage | null {
295
+ if (!node || typeof node !== 'object') return null;
296
+ const n = node as Record<string, unknown>;
297
+ if (n.type === 'image' && typeof n.url === 'string' && n.url) {
298
+ return { src: n.url, alt: typeof n.alt === 'string' ? n.alt : '' };
299
+ }
300
+ if ((n.type === 'htmlBlock' || n.type === 'htmlInline') && Array.isArray(n.htmlChildren)) {
301
+ const found = fromHtml(n.htmlChildren);
302
+ if (found) return found;
303
+ }
304
+ if (Array.isArray(n.children)) {
305
+ for (const child of n.children) {
306
+ const found = walk(child);
307
+ if (found) return found;
308
+ }
309
+ }
310
+ return null;
311
+ }
312
+
313
+ for (const node of contents) {
314
+ const found = walk(node);
315
+ if (found) return found;
316
+ }
317
+ return null;
318
+ }
319
+
320
+ /** Parse a `width`/`height` HTML attribute to a positive number. */
321
+ function parseDim(raw: string | undefined): number | undefined {
322
+ if (raw === undefined) return undefined;
323
+ const n = parseFloat(raw);
324
+ return Number.isFinite(n) && n > 0 ? n : undefined;
325
+ }
326
+
247
327
  /** Extract table data (headers, rows, alignment) from block contents. */
248
328
  function extractTableData(contents?: MarkdownBlockNode[]): {
249
329
  headers: string[];
@@ -277,7 +357,7 @@ function getTemplateDefaults(
277
357
  switch (templateName) {
278
358
  case 'statHighlight':
279
359
  return { stat: headingText, description: bodyText || headingText };
280
- case 'quoteBlock':
360
+ case 'quote':
281
361
  case 'fullBleedQuote':
282
362
  case 'pullQuote':
283
363
  return { quote: bodyText || headingText };
@@ -285,7 +365,7 @@ function getTemplateDefaults(
285
365
  return { fact: headingText, explanation: bodyText || headingText };
286
366
  case 'comparisonBar':
287
367
  return { leftLabel: 'A', leftValue: 60, rightLabel: 'B', rightValue: 40 };
288
- case 'listBlock': {
368
+ case 'list': {
289
369
  const items = extractListItems(contents);
290
370
  return { items: items.length > 0 ? items : ['Item 1', 'Item 2', 'Item 3'] };
291
371
  }
@@ -297,6 +377,37 @@ function getTemplateDefaults(
297
377
  const tableData = extractTableData(contents);
298
378
  return tableData ?? { headers: ['Column'], rows: [['Data']] };
299
379
  }
380
+ case 'imageWithCaption': {
381
+ // The template requires `imageSrc` — without it the image layer
382
+ // renders a broken `<img src=undefined>`. Pull the first image
383
+ // out of the block's body (markdown shorthand or HTML <img>) and
384
+ // use the heading as a caption fallback so a bare `# Title
385
+ // [Image with Caption]` block renders something sensible.
386
+ const img = extractFirstImage(contents);
387
+ if (!img) return { caption: headingText };
388
+ return {
389
+ imageSrc: img.src,
390
+ imageAlt: img.alt || headingText,
391
+ caption: headingText,
392
+ };
393
+ }
394
+ case 'leftFeature':
395
+ case 'rightFeature': {
396
+ // Feature blocks pair an image with the heading + the first
397
+ // paragraph of body text. Both inputs come from the section's
398
+ // contents — the image via the same scan used by imageWithCaption,
399
+ // the body text via the plain-text extractor that's already in
400
+ // scope here.
401
+ const img = extractFirstImage(contents);
402
+ return {
403
+ imageSrc: img?.src ?? '',
404
+ imageAlt: img?.alt || headingText,
405
+ imageWidth: img?.width,
406
+ imageHeight: img?.height,
407
+ title: headingText,
408
+ body: bodyText,
409
+ };
410
+ }
300
411
  default:
301
412
  return {};
302
413
  }
@@ -345,8 +456,8 @@ export function LinearDocView({
345
456
  const textColor = activeTheme.colors.text;
346
457
  const mutedColor = activeTheme.colors.textMuted;
347
458
  const primaryColor = activeTheme.colors.primary;
348
- const bodyFont = activeTheme.typography.bodyFontFamily;
349
- const titleFont = activeTheme.typography.titleFontFamily;
459
+ const bodyFont = resolveFontFamily(activeTheme.typography.bodyFont, 'system-ui, sans-serif');
460
+ const titleFont = resolveFontFamily(activeTheme.typography.titleFont, 'Georgia, serif');
350
461
  const lineHt = activeTheme.typography.lineHeight ?? 1.7;
351
462
 
352
463
  return (
@@ -418,6 +529,17 @@ export function LinearDocView({
418
529
  margin-bottom: 0.3em;
419
530
  }
420
531
  .squisq-linear-content a {
532
+ /* Blend the theme's primary toward the body text color so
533
+ links stay theme-flavored but always have enough contrast
534
+ against the background. Some themes (e.g. Gezellig) pick a
535
+ mid-tone primary that's nearly invisible on a dark page
536
+ without this lift. */
537
+ color: color-mix(in srgb, var(--squisq-linear-primary) 65%, var(--squisq-linear-text));
538
+ text-decoration: underline;
539
+ text-decoration-thickness: 1px;
540
+ text-underline-offset: 2px;
541
+ }
542
+ .squisq-linear-content a:hover {
421
543
  color: var(--squisq-linear-primary);
422
544
  }
423
545
  .squisq-linear-content code {
@@ -21,8 +21,12 @@ import type {
21
21
  MarkdownListItem,
22
22
  MarkdownTableRow,
23
23
  MarkdownTableCell,
24
+ HtmlNode,
25
+ HtmlElement,
24
26
  } from '@bendyline/squisq/markdown';
25
27
  import { useMediaUrl } from './hooks/MediaContext';
28
+ import { InlineVideoPlayer } from './InlineVideoPlayer.js';
29
+ import { InlineAudioPlayer } from './InlineAudioPlayer.js';
26
30
 
27
31
  // ── Props ──────────────────────────────────────────────────────────
28
32
 
@@ -118,12 +122,23 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
118
122
  );
119
123
 
120
124
  case 'htmlInline':
125
+ // Fast path: no <video>/<audio> in the subtree → use the original
126
+ // rawHtml passthrough (preserves arbitrary HTML for custom embeds).
127
+ if (!containsMediaTag(node.htmlChildren)) {
128
+ return (
129
+ <span
130
+ key={key}
131
+ className="squisq-md-html-inline"
132
+ dangerouslySetInnerHTML={{ __html: node.rawHtml }}
133
+ />
134
+ );
135
+ }
136
+ // Otherwise reconstruct the subtree as React so <video>/<audio>
137
+ // go through MediaContext-aware player components.
121
138
  return (
122
- <span
123
- key={key}
124
- className="squisq-md-html-inline"
125
- dangerouslySetInnerHTML={{ __html: node.rawHtml }}
126
- />
139
+ <span key={key} className="squisq-md-html-inline">
140
+ {renderHtmlNodes(node.htmlChildren, `${key}h`)}
141
+ </span>
127
142
  );
128
143
 
129
144
  case 'footnoteReference':
@@ -231,12 +246,23 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
231
246
  return renderTable(node.children, node.align, key);
232
247
 
233
248
  case 'htmlBlock':
249
+ // Fast path: no <video>/<audio> → preserve the existing rawHtml
250
+ // passthrough so arbitrary HTML embeds still survive verbatim.
251
+ if (!containsMediaTag(node.htmlChildren)) {
252
+ return (
253
+ <div
254
+ key={key}
255
+ className="squisq-md-html-block"
256
+ dangerouslySetInnerHTML={{ __html: node.rawHtml }}
257
+ />
258
+ );
259
+ }
260
+ // Otherwise reconstruct subtree as React so <video>/<audio>
261
+ // route through the player components and resolve via MediaContext.
234
262
  return (
235
- <div
236
- key={key}
237
- className="squisq-md-html-block"
238
- dangerouslySetInnerHTML={{ __html: node.rawHtml }}
239
- />
263
+ <div key={key} className="squisq-md-html-block">
264
+ {renderHtmlNodes(node.htmlChildren, `${key}h`)}
265
+ </div>
240
266
  );
241
267
 
242
268
  case 'math':
@@ -377,6 +403,126 @@ function MdImage({ src, alt, title }: { src: string; alt: string; title?: string
377
403
  return <img className="squisq-md-image" src={resolved} alt={alt} title={title} />;
378
404
  }
379
405
 
406
+ // ── Raw-HTML walker (intercepts <video>/<audio>) ─────────────────
407
+
408
+ /** True when the htmlElement subtree contains a tag we want to swap
409
+ * for a React component. Cheap recursive scan — lets us keep the
410
+ * `dangerouslySetInnerHTML` fast path for everything else. */
411
+ function containsMediaTag(nodes: HtmlNode[]): boolean {
412
+ for (const node of nodes) {
413
+ if (node.type !== 'htmlElement') continue;
414
+ if (node.tagName === 'video' || node.tagName === 'audio') return true;
415
+ if (containsMediaTag(node.children)) return true;
416
+ }
417
+ return false;
418
+ }
419
+
420
+ /** A pragmatic shortlist of HTML attributes the raw-HTML walker
421
+ * passes through to React when reconstructing a non-media element.
422
+ * Anything outside this list is silently dropped — the media-tag
423
+ * fast path means most authors will never hit this code, so we
424
+ * keep the surface narrow to avoid React warnings about unknown
425
+ * attributes. */
426
+ const PASSTHROUGH_ATTRS: Record<string, string> = {
427
+ // common
428
+ class: 'className',
429
+ id: 'id',
430
+ title: 'title',
431
+ style: 'style',
432
+ // media-adjacent (used when video/audio appear inside other wrappers)
433
+ width: 'width',
434
+ height: 'height',
435
+ // anchor
436
+ href: 'href',
437
+ target: 'target',
438
+ rel: 'rel',
439
+ };
440
+
441
+ function reactPropsFromAttrs(attrs: Record<string, string>): Record<string, unknown> {
442
+ const out: Record<string, unknown> = {};
443
+ for (const [name, value] of Object.entries(attrs)) {
444
+ const propName = PASSTHROUGH_ATTRS[name];
445
+ if (!propName) continue;
446
+ if (propName === 'style') {
447
+ // We can't safely parse arbitrary `style="..."` strings without
448
+ // an HTML parser; preserve as a data attribute so authors can
449
+ // see it survived round-trip without crashing React.
450
+ out['data-style'] = value;
451
+ continue;
452
+ }
453
+ out[propName] = value;
454
+ }
455
+ return out;
456
+ }
457
+
458
+ function renderHtmlElement(el: HtmlElement, key: string): React.ReactNode {
459
+ if (el.tagName === 'video') {
460
+ return (
461
+ <InlineVideoPlayer
462
+ key={key}
463
+ src={el.attributes.src ?? ''}
464
+ width={el.attributes.width}
465
+ height={el.attributes.height}
466
+ poster={el.attributes.poster}
467
+ // The `controls` attribute is a boolean — present means true,
468
+ // even if its value is an empty string.
469
+ controls={'controls' in el.attributes}
470
+ preload={
471
+ el.attributes.preload === 'none' ||
472
+ el.attributes.preload === 'metadata' ||
473
+ el.attributes.preload === 'auto'
474
+ ? el.attributes.preload
475
+ : undefined
476
+ }
477
+ />
478
+ );
479
+ }
480
+ if (el.tagName === 'audio') {
481
+ return (
482
+ <InlineAudioPlayer
483
+ key={key}
484
+ src={el.attributes.src ?? ''}
485
+ controls={'controls' in el.attributes}
486
+ preload={
487
+ el.attributes.preload === 'none' ||
488
+ el.attributes.preload === 'metadata' ||
489
+ el.attributes.preload === 'auto'
490
+ ? el.attributes.preload
491
+ : undefined
492
+ }
493
+ />
494
+ );
495
+ }
496
+
497
+ const Tag = el.tagName as keyof JSX.IntrinsicElements;
498
+ const props = reactPropsFromAttrs(el.attributes);
499
+ if (el.selfClosing) {
500
+ return <Tag key={key} {...props} />;
501
+ }
502
+ return (
503
+ <Tag key={key} {...props}>
504
+ {renderHtmlNodes(el.children, `${key}c`)}
505
+ </Tag>
506
+ );
507
+ }
508
+
509
+ function renderHtmlNodes(nodes: HtmlNode[], keyPrefix: string): React.ReactNode[] {
510
+ return nodes.map((node, i) => {
511
+ const key = `${keyPrefix}${i}`;
512
+ switch (node.type) {
513
+ case 'htmlElement':
514
+ return renderHtmlElement(node, key);
515
+ case 'htmlText':
516
+ return <Fragment key={key}>{node.value}</Fragment>;
517
+ case 'htmlComment':
518
+ // Comments don't render in React; ignore.
519
+ return null;
520
+ default:
521
+ return null;
522
+ }
523
+ });
524
+ }
525
+
380
526
  // ── Main Component ─────────────────────────────────────────────────
381
527
 
382
528
  /**
@@ -17,6 +17,7 @@
17
17
  import { useMemo } from 'react';
18
18
  import type { CaptionTrack, CaptionPhrase, ViewportConfig } from '@bendyline/squisq/schemas';
19
19
  import type { Theme } from '@bendyline/squisq/schemas';
20
+ import { resolveFontFamily } from '@bendyline/squisq/schemas';
20
21
 
21
22
  /** Target words per visible chunk. */
22
23
  const TARGET_CHUNK_SIZE = 4;
@@ -196,8 +197,8 @@ export function SocialCaptionOverlay({
196
197
 
197
198
  // Theme-derived styling
198
199
  const primaryColor = theme?.colors?.primary ?? '#5b9bd5';
199
- const fontFamily = theme?.typography?.titleFontFamily
200
- ? `"${theme.typography.titleFontFamily}", system-ui, sans-serif`
200
+ const fontFamily = theme?.typography?.titleFont
201
+ ? resolveFontFamily(theme.typography.titleFont, '"PT Serif", Georgia, serif')
201
202
  : '"PT Serif", Georgia, serif';
202
203
 
203
204
  // Scale font to viewport — aim for ~5.5% of viewport height