@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
@@ -20,19 +20,22 @@
20
20
 
21
21
  import { useRef, useState, useCallback, useEffect } from 'react';
22
22
  import type { Doc } from '@bendyline/squisq/schemas';
23
- import type { ViewportConfig } from '@bendyline/squisq/schemas';
24
- import type { AudioProvider } from './hooks/AudioProvider';
23
+ import type { ViewportConfig, Theme } from '@bendyline/squisq/schemas';
24
+ import type { AudioController } from './hooks/AudioController';
25
25
  import { DocPlayer } from './DocPlayer';
26
26
  import { DocControlsSidebar } from './DocControlsSidebar';
27
27
  import type { PlaybackState, PlaybackActions } from './types';
28
28
 
29
29
  interface DocPlayerWithSidebarProps {
30
- script: Doc;
31
- basePath: string;
30
+ /** The Doc to play */
31
+ doc: Doc;
32
+ /** Base path for resolving media URLs (default: `'.'`) */
33
+ basePath?: string;
32
34
  autoPlay?: boolean;
33
35
  onEnded?: () => void;
34
36
  onTimeUpdate?: (time: number) => void;
35
- audioProvider?: AudioProvider;
37
+ /** Optional audio controller (if not provided, uses default HTML5 audio) */
38
+ audioController?: AudioController;
36
39
  muted?: boolean;
37
40
  captionsEnabled?: boolean;
38
41
  isFullscreen?: boolean;
@@ -41,6 +44,13 @@ interface DocPlayerWithSidebarProps {
41
44
  forceViewport?: ViewportConfig;
42
45
  /** Called when playing state changes */
43
46
  onPlayingChange?: (isPlaying: boolean) => void;
47
+ /**
48
+ * Theme for rendering. Forwarded to the inner DocPlayer so the sidebar
49
+ * (portrait) layout matches the default (landscape) layout — without it the
50
+ * inner player falls back to DEFAULT_THEME, whose dark text is unreadable
51
+ * over a hero cover image.
52
+ */
53
+ theme?: Theme;
44
54
  }
45
55
 
46
56
  const DEFAULT_STATE: PlaybackState = {
@@ -59,18 +69,19 @@ const DEFAULT_STATE: PlaybackState = {
59
69
  };
60
70
 
61
71
  export function DocPlayerWithSidebar({
62
- script,
72
+ doc,
63
73
  basePath,
64
74
  autoPlay = false,
65
75
  onEnded,
66
76
  onTimeUpdate,
67
- audioProvider,
77
+ audioController,
68
78
  muted,
69
79
  captionsEnabled,
70
80
  isFullscreen,
71
81
  onFullscreenToggle,
72
82
  forceViewport,
73
83
  onPlayingChange,
84
+ theme,
74
85
  }: DocPlayerWithSidebarProps) {
75
86
  // Store playback state in a ref to avoid triggering re-renders from DocPlayer callbacks
76
87
  const stateRef = useRef<PlaybackState>(DEFAULT_STATE);
@@ -114,12 +125,13 @@ export function DocPlayerWithSidebar({
114
125
  <div className="doc-player-sidebar-layout">
115
126
  <div className="doc-player-sidebar-layout__video">
116
127
  <DocPlayer
117
- script={script}
128
+ doc={doc}
129
+ theme={theme}
118
130
  basePath={basePath}
119
131
  autoPlay={autoPlay}
120
132
  onEnded={onEnded}
121
133
  onTimeUpdate={onTimeUpdate}
122
- audioProvider={audioProvider}
134
+ audioController={audioController}
123
135
  muted={muted}
124
136
  captionsEnabled={captionsEnabled}
125
137
  showControls={isFullscreen}
@@ -41,6 +41,15 @@ export function DocProgressBar({
41
41
  const progressBarRef = useRef<HTMLDivElement>(null);
42
42
  const [hoverPosition, setHoverPosition] = useState<number | null>(null);
43
43
 
44
+ // Fill position tracks the same timeline as the clock readout and the block
45
+ // marker dots — elapsed playback time over the audio `totalDuration`. (We
46
+ // deliberately don't use `state.docProgress`, which is keyed to the doc's
47
+ // estimated `script.duration`; when that diverges from the actual playback
48
+ // length — e.g. the editor preview's reading-time estimate — the fill would
49
+ // desync from the clock and the dots.)
50
+ const playProgress =
51
+ state.totalDuration > 0 ? Math.max(0, Math.min(1, state.currentTime / state.totalDuration)) : 0;
52
+
44
53
  const handleProgressHover = useCallback((e: React.MouseEvent) => {
45
54
  const bar = progressBarRef.current;
46
55
  if (!bar) return;
@@ -99,16 +108,25 @@ export function DocProgressBar({
99
108
  }}
100
109
  />
101
110
 
102
- {/* Progress fill */}
111
+ {/* Progress fill.
112
+ No CSS `width` transition: the fill is driven by frequent JS updates
113
+ (the synthetic fallback timer runs ~60fps; real audio fires
114
+ `timeupdate` several times/sec), so it's already smooth. A CSS
115
+ transition here actively breaks playback — after a discontinuous
116
+ backward seek, the per-frame inline-width updates on resume keep
117
+ interrupting the in-flight transition before it advances, so the
118
+ *rendered* width latches at the seek position even though the inline
119
+ style (and the clock) keep climbing. The result is a frozen-looking
120
+ bar while time advances. Update width instantly instead. */}
103
121
  <div
122
+ data-testid="doc-progress-fill"
104
123
  style={{
105
124
  position: 'absolute',
106
125
  left: 0,
107
- width: `${state.docProgress * 100}%`,
126
+ width: `${playProgress * 100}%`,
108
127
  height: '6px',
109
128
  background: '#5b9bd5',
110
129
  borderRadius: '3px',
111
- transition: 'width 0.1s',
112
130
  }}
113
131
  />
114
132
 
@@ -28,18 +28,32 @@ import {
28
28
  type Theme,
29
29
  } from '@bendyline/squisq/schemas';
30
30
  import { VIEWPORT_PRESETS } from '@bendyline/squisq/schemas';
31
- import { getLayers, hasTemplate, DEFAULT_THEME } from '@bendyline/squisq/doc';
31
+ import {
32
+ getLayers,
33
+ hasTemplate,
34
+ markdownToDoc,
35
+ DEFAULT_THEME,
36
+ deriveTemplateInputs,
37
+ } from '@bendyline/squisq/doc';
32
38
  import type { RenderContext } from '@bendyline/squisq/doc';
33
- import { extractPlainText } from '@bendyline/squisq/markdown';
34
- import type { MarkdownBlockNode, MarkdownList, MarkdownTable } from '@bendyline/squisq/markdown';
39
+ import { extractPlainText, parseMarkdown } from '@bendyline/squisq/markdown';
35
40
  import { BlockRenderer } from './BlockRenderer';
36
41
  import { MarkdownRenderer } from './MarkdownRenderer';
37
42
 
38
43
  // ── Props ──────────────────────────────────────────────────────────
39
44
 
40
45
  export interface LinearDocViewProps {
41
- /** The Doc to render */
42
- doc: Doc;
46
+ /**
47
+ * The Doc to render. Wins over `markdown` when both are provided.
48
+ * When neither `doc` nor `markdown` is given, an empty container renders.
49
+ */
50
+ doc?: Doc;
51
+ /**
52
+ * Markdown source to render. When `doc` is absent, the markdown is parsed
53
+ * and converted to a Doc via `markdownToDoc(parseMarkdown(markdown))`.
54
+ * Ignored when `doc` is provided.
55
+ */
56
+ markdown?: string;
43
57
  /** Base path for resolving media URLs (images, etc.) */
44
58
  basePath?: string;
45
59
  /** Viewport config for SVG card rendering (default: landscape) */
@@ -79,16 +93,33 @@ export type ImageDisplayMode = 'inline' | 'thumbnail';
79
93
 
80
94
  // ── Helpers ────────────────────────────────────────────────────────
81
95
 
96
+ // Unknown template names we've already warned about (module-level so each
97
+ // name warns at most once per page, not once per render).
98
+ const warnedUnknownTemplates = new Set<string>();
99
+
82
100
  /**
83
101
  * Determine whether a block has a template annotation that should be
84
102
  * rendered as a visual SVG card. A block is "annotated" when:
85
103
  * 1. Its sourceHeading has a templateAnnotation, AND
86
104
  * 2. The annotated template exists in the registry
105
+ *
106
+ * Blocks annotated with a template that is NOT in the registry fall back
107
+ * to plain markdown rendering, with a one-shot dev-visible warning per
108
+ * unknown template name.
87
109
  */
88
110
  function isAnnotatedBlock(block: Block): boolean {
89
111
  const annotation = block.sourceHeading?.templateAnnotation;
90
- if (!annotation) return false;
91
- return !!annotation.template && hasTemplate(annotation.template);
112
+ if (!annotation?.template) return false;
113
+ if (!hasTemplate(annotation.template)) {
114
+ if (!warnedUnknownTemplates.has(annotation.template)) {
115
+ warnedUnknownTemplates.add(annotation.template);
116
+ console.warn(
117
+ `[squisq] Unknown template "${annotation.template}" — rendering the block as plain markdown.`,
118
+ );
119
+ }
120
+ return false;
121
+ }
122
+ return true;
92
123
  }
93
124
 
94
125
  /**
@@ -126,7 +157,6 @@ function BlockSection({ block, basePath, viewport, renderContext, blockIndex }:
126
157
 
127
158
  const annotation = block.sourceHeading!.templateAnnotation!;
128
159
  const headingText = extractPlainText(block.sourceHeading!);
129
- const bodyText = extractBodyPlainText(block.contents);
130
160
 
131
161
  // Build a TemplateBlock-compatible object
132
162
  const templateBlock: Record<string, unknown> = {
@@ -136,12 +166,14 @@ function BlockSection({ block, basePath, viewport, renderContext, blockIndex }:
136
166
  duration: 1,
137
167
  audioSegment: 0,
138
168
  title: headingText,
139
- ...getTemplateDefaults(
169
+ ...(deriveTemplateInputs(
140
170
  annotation.template ?? 'sectionHeader',
141
171
  headingText,
142
- bodyText,
143
172
  block.contents,
144
- ),
173
+ {
174
+ placeholders: true,
175
+ },
176
+ ) ?? {}),
145
177
  ...annotation.params,
146
178
  ...block.templateOverrides,
147
179
  };
@@ -221,198 +253,6 @@ function BlockSection({ block, basePath, viewport, renderContext, blockIndex }:
221
253
  );
222
254
  }
223
255
 
224
- // ── Template Defaults (mirrored from PreviewPanel) ─────────────────
225
-
226
- /** Extract plain text from block contents. */
227
- function extractBodyPlainText(contents?: MarkdownBlockNode[]): string {
228
- if (!contents || contents.length === 0) return '';
229
- return contents
230
- .map((n) => extractPlainText(n))
231
- .join('\n')
232
- .trim();
233
- }
234
-
235
- /** Extract list items as plain text. */
236
- function extractListItems(contents?: MarkdownBlockNode[]): string[] {
237
- if (!contents) return [];
238
- const items: string[] = [];
239
- for (const node of contents) {
240
- if (node.type === 'list') {
241
- for (const item of (node as MarkdownList).children) {
242
- const text = extractPlainText(item).trim();
243
- if (text) items.push(text);
244
- }
245
- }
246
- }
247
- return items;
248
- }
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
-
327
- /** Extract table data (headers, rows, alignment) from block contents. */
328
- function extractTableData(contents?: MarkdownBlockNode[]): {
329
- headers: string[];
330
- rows: string[][];
331
- align?: (('left' | 'right' | 'center') | null)[];
332
- } | null {
333
- if (!contents) return null;
334
- for (const node of contents) {
335
- if (node.type === 'table') {
336
- const table = node as MarkdownTable;
337
- const [headerRow, ...bodyRows] = table.children;
338
- if (!headerRow) return null;
339
- const headers = headerRow.children.map((cell) => extractPlainText(cell).trim());
340
- const rows = bodyRows.map((row) => row.children.map((cell) => extractPlainText(cell).trim()));
341
- return { headers, rows, align: table.align };
342
- }
343
- }
344
- return null;
345
- }
346
-
347
- /**
348
- * Provide sensible default fields for templates that require more than
349
- * just a `title`. Prevents crashes from undefined required fields.
350
- */
351
- function getTemplateDefaults(
352
- templateName: string,
353
- headingText: string,
354
- bodyText: string,
355
- contents?: MarkdownBlockNode[],
356
- ): Record<string, unknown> {
357
- switch (templateName) {
358
- case 'statHighlight':
359
- return { stat: headingText, description: bodyText || headingText };
360
- case 'quote':
361
- case 'fullBleedQuote':
362
- case 'pullQuote':
363
- return { quote: bodyText || headingText };
364
- case 'factCard':
365
- return { fact: headingText, explanation: bodyText || headingText };
366
- case 'comparisonBar':
367
- return { leftLabel: 'A', leftValue: 60, rightLabel: 'B', rightValue: 40 };
368
- case 'list': {
369
- const items = extractListItems(contents);
370
- return { items: items.length > 0 ? items : ['Item 1', 'Item 2', 'Item 3'] };
371
- }
372
- case 'definitionCard':
373
- return { term: headingText, definition: bodyText || headingText };
374
- case 'dateEvent':
375
- return { date: headingText, description: bodyText || headingText };
376
- case 'dataTable': {
377
- const tableData = extractTableData(contents);
378
- return tableData ?? { headers: ['Column'], rows: [['Data']] };
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
- }
411
- default:
412
- return {};
413
- }
414
- }
415
-
416
256
  // ── Main Component ─────────────────────────────────────────────────
417
257
 
418
258
  /**
@@ -429,6 +269,7 @@ function getTemplateDefaults(
429
269
  */
430
270
  export function LinearDocView({
431
271
  doc,
272
+ markdown,
432
273
  basePath = '/',
433
274
  viewport,
434
275
  className,
@@ -438,20 +279,42 @@ export function LinearDocView({
438
279
  imageDisplayMode = 'inline',
439
280
  }: LinearDocViewProps) {
440
281
  const activeViewport = viewport ?? VIEWPORT_PRESETS.landscape;
441
- const totalBlocks = useMemo(() => countAll(doc.blocks), [doc.blocks]);
282
+
283
+ // Parse markdown into a Doc only when no explicit doc is supplied.
284
+ const markdownDoc = useMemo(
285
+ () => (!doc && markdown !== undefined ? markdownToDoc(parseMarkdown(markdown)) : undefined),
286
+ [doc, markdown],
287
+ );
288
+ const resolvedDoc = doc ?? markdownDoc;
289
+
290
+ const totalBlocks = useMemo(
291
+ () => (resolvedDoc ? countAll(resolvedDoc.blocks) : 0),
292
+ [resolvedDoc],
293
+ );
442
294
  const autoSurface = useAutoSurface(surface === 'auto');
443
295
  const resolvedSurface: SurfaceScheme | undefined = surface === 'auto' ? autoSurface : surface;
444
296
 
445
297
  const renderContext: RenderContext = useMemo(() => {
446
298
  const baseTheme = theme ?? DEFAULT_THEME;
299
+ const effectiveTheme = resolvedSurface ? applySurface(baseTheme, resolvedSurface) : baseTheme;
447
300
  return {
448
- theme: resolvedSurface ? applySurface(baseTheme, resolvedSurface) : baseTheme,
301
+ theme: effectiveTheme,
449
302
  viewport: activeViewport,
450
303
  totalBlocks,
304
+ // Theme atmosphere (vignette/grain/gradient persistent layers) shows
305
+ // on the inline template cards so they match the player's look.
306
+ persistentLayers: effectiveTheme.persistentLayers,
451
307
  };
452
308
  }, [activeViewport, totalBlocks, theme, resolvedSurface]);
453
309
 
454
310
  const activeTheme = renderContext.theme!;
311
+
312
+ // Nothing to render — keep an empty (but classed) container so hosts can
313
+ // still target/measure the view.
314
+ if (!resolvedDoc) {
315
+ return <div className={`squisq-linear squisq-linear--empty ${className || ''}`} />;
316
+ }
317
+
455
318
  const bgColor = activeTheme.colors.background;
456
319
  const textColor = activeTheme.colors.text;
457
320
  const mutedColor = activeTheme.colors.textMuted;
@@ -614,7 +477,7 @@ export function LinearDocView({
614
477
  background: color-mix(in srgb, var(--squisq-linear-primary) 8%, transparent);
615
478
  }
616
479
  `}</style>
617
- {doc.blocks.map((block, i) => (
480
+ {resolvedDoc.blocks.map((block, i) => (
618
481
  <BlockSection
619
482
  key={block.id}
620
483
  block={block}