@bendyline/squisq-react 1.3.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bendyline/squisq-react",
3
- "version": "1.3.2",
3
+ "version": "1.4.0",
4
4
  "description": "React component library for doc playback, block rendering, and media layers",
5
5
  "license": "MIT",
6
6
  "author": "Bendyline",
@@ -53,13 +53,14 @@
53
53
  "react-dom": "^18.0.0 || ^19.0.0"
54
54
  },
55
55
  "dependencies": {
56
- "@bendyline/squisq": "1.4.1"
56
+ "@bendyline/squisq": "1.5.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@types/react": "18.3.28",
60
60
  "preact": "10.29.0",
61
61
  "react": "18.3.1",
62
62
  "react-dom": "18.3.1",
63
+ "@testing-library/dom": "10.4.1",
63
64
  "@testing-library/react": "16.3.2",
64
65
  "@testing-library/jest-dom": "6.9.1",
65
66
  "jsdom": "25.0.1",
@@ -6,10 +6,12 @@
6
6
  * Handles positioning, animations, and transitions.
7
7
  */
8
8
 
9
- import type { Block, Layer } from '@bendyline/squisq/schemas';
9
+ import type { Block, Layer, Transition } from '@bendyline/squisq/schemas';
10
+ import { resolveTransitionDuration } from '@bendyline/squisq/schemas';
10
11
  import { ImageLayer } from './layers/ImageLayer';
11
12
  import { TextLayer } from './layers/TextLayer';
12
13
  import { ShapeLayer } from './layers/ShapeLayer';
14
+ import { PathLayer } from './layers/PathLayer';
13
15
  import { MapLayer } from './layers/MapLayer';
14
16
  import { VideoLayer } from './layers/VideoLayer';
15
17
  import { TableLayer } from './layers/TableLayer';
@@ -39,6 +41,8 @@ interface BlockRendererProps {
39
41
  isEntering?: boolean;
40
42
  /** Whether this block is exiting (for transition) */
41
43
  isExiting?: boolean;
44
+ /** Transition to apply. Defaults to block.transition. */
45
+ transition?: Transition;
42
46
  /** Viewport dimensions (defaults to 1920x1080 landscape) */
43
47
  viewport?: ViewportDimensions;
44
48
  /** Whether the doc is currently playing (controls video playback) */
@@ -51,18 +55,20 @@ export function BlockRenderer({
51
55
  basePath,
52
56
  isEntering = false,
53
57
  isExiting = false,
58
+ transition,
54
59
  viewport = VIEWPORT,
55
60
  isPlaying,
56
61
  }: BlockRendererProps) {
57
62
  // Build transition class and inline style for dynamic duration
58
63
  let transitionClass = '';
59
64
  const transitionStyle: Record<string, string> = {};
60
- if (block.transition && isEntering) {
61
- transitionClass = getTransitionClass(block.transition.type, true);
62
- transitionStyle['--transition-duration'] = `${block.transition.duration}s`;
63
- } else if (block.transition && isExiting) {
64
- transitionClass = getTransitionClass(block.transition.type, false);
65
- transitionStyle['--transition-duration'] = `${block.transition.duration}s`;
65
+ const activeTransition = transition ?? block.transition;
66
+ if (activeTransition && isEntering) {
67
+ transitionClass = getTransitionClass(activeTransition.type, true, activeTransition.direction);
68
+ transitionStyle['--transition-duration'] = `${resolveTransitionDuration(activeTransition)}s`;
69
+ } else if (activeTransition && isExiting) {
70
+ transitionClass = getTransitionClass(activeTransition.type, false, activeTransition.direction);
71
+ transitionStyle['--transition-duration'] = `${resolveTransitionDuration(activeTransition)}s`;
66
72
  }
67
73
 
68
74
  // Unique clip path ID per block to avoid conflicts when multiple blocks render simultaneously
@@ -124,6 +130,8 @@ function LayerRenderer({ layer, basePath, viewport, blockTime, isPlaying }: Laye
124
130
  return <TextLayer layer={layer} viewport={viewport} blockTime={blockTime} />;
125
131
  case 'shape':
126
132
  return <ShapeLayer layer={layer} viewport={viewport} blockTime={blockTime} />;
133
+ case 'path':
134
+ return <PathLayer layer={layer} viewport={viewport} blockTime={blockTime} />;
127
135
  case 'map':
128
136
  return (
129
137
  <MapLayer layer={layer} basePath={basePath} viewport={viewport} blockTime={blockTime} />
package/src/DocPlayer.tsx CHANGED
@@ -24,7 +24,13 @@
24
24
 
25
25
  import { Fragment, useRef, useState, useEffect, useCallback, useMemo } from 'react';
26
26
  import type { Doc, Block, TextLayer, StartBlockConfig, DocBlock } from '@bendyline/squisq/schemas';
27
- import { isTemplateBlock, getCaptionAtTime } from '@bendyline/squisq/schemas';
27
+ import {
28
+ isTemplateBlock,
29
+ getCaptionAtTime,
30
+ resolveMediaSchedule,
31
+ getDocPlaybackDuration,
32
+ } from '@bendyline/squisq/schemas';
33
+ import { MediaClipLayer } from './MediaClipLayer';
28
34
  import type { SurfaceScheme, Theme } from '@bendyline/squisq/schemas';
29
35
  import { applySurface } from '@bendyline/squisq/schemas';
30
36
  import { BlockRenderer } from './BlockRenderer';
@@ -255,6 +261,11 @@ export function DocPlayer({
255
261
  restart,
256
262
  } = audio;
257
263
 
264
+ // Timed media clips (block.media + doc.documentMedia) resolved to absolute
265
+ // doc-timeline coordinates. Empty for documents without the media model, so
266
+ // <MediaClipLayer> renders nothing and the legacy audio path is unaffected.
267
+ const mediaSchedule = useMemo(() => resolveMediaSchedule(script), [script]);
268
+
258
269
  // Refs for frequently-changing values used in the keyboard handler,
259
270
  // so the handler callback doesn't need to be recreated every frame.
260
271
  const currentTimeRef = useRef(currentTime);
@@ -450,7 +461,13 @@ export function DocPlayer({
450
461
  const video = el as HTMLVideoElement;
451
462
  const clipStart = parseFloat(video.dataset.clipStart || '0');
452
463
  const clipEnd = parseFloat(video.dataset.clipEnd || '0');
453
- const targetTime = Math.min(clipStart + Math.max(0, blockElapsed), clipEnd);
464
+ // Honor the per-clip startAt offset: before it, hold at the
465
+ // in-point; after, advance by (blockElapsed - startAt).
466
+ const startAt = parseFloat(video.dataset.startAt || '0');
467
+ const targetTime = Math.min(
468
+ clipStart + Math.max(0, blockElapsed - startAt),
469
+ clipEnd,
470
+ );
454
471
 
455
472
  video.pause();
456
473
  video.currentTime = targetTime;
@@ -468,6 +485,30 @@ export function DocPlayer({
468
485
  });
469
486
  }
470
487
 
488
+ // Seek player-level scheduled videos (document-spanning clips
489
+ // rendered by MediaClipLayer, outside any single block). Each
490
+ // carries data-abs-start/data-abs-end/data-source-in.
491
+ document.querySelectorAll('video[data-clip-id]').forEach((el) => {
492
+ const video = el as HTMLVideoElement;
493
+ const absStart = parseFloat(video.dataset.absStart || '0');
494
+ const absEnd = parseFloat(video.dataset.absEnd || '0');
495
+ const sourceIn = parseFloat(video.dataset.sourceIn || '0');
496
+ video.pause();
497
+ if (time < absStart || time >= absEnd) return;
498
+ const targetTime = sourceIn + (time - absStart);
499
+ video.currentTime = targetTime;
500
+ videoSeekPromises.push(
501
+ new Promise<void>((r) => {
502
+ if (Math.abs(video.currentTime - targetTime) < 0.1) {
503
+ r();
504
+ } else {
505
+ video.addEventListener('seeked', () => r(), { once: true });
506
+ setTimeout(r, 200);
507
+ }
508
+ }),
509
+ );
510
+ });
511
+
471
512
  // Wait for video seeks + one more frame for the browser to render
472
513
  Promise.all(videoSeekPromises).then(() => {
473
514
  requestAnimationFrame(() => resolve());
@@ -476,14 +517,12 @@ export function DocPlayer({
476
517
  });
477
518
  };
478
519
  w.getDuration = () => {
479
- // When audio is present totalDuration comes from audio segments.
480
- // For audio-less docs, compute from block timings instead.
481
- if (totalDuration > 0) return totalDuration;
482
- if (expandedBlocks.length > 0) {
483
- const last = expandedBlocks[expandedBlocks.length - 1];
484
- return last.startTime + last.duration;
485
- }
486
- return 0;
520
+ // The larger of the audio/block timeline and any media that spills
521
+ // past the last block (block-clip spillover or document-spanning
522
+ // media), so frame capture covers the full tail.
523
+ const mediaDuration = getDocPlaybackDuration(script);
524
+ if (totalDuration > 0) return Math.max(totalDuration, mediaDuration);
525
+ return mediaDuration;
487
526
  };
488
527
  // Expose block metadata for testing -- allows tests to find specific templates
489
528
  w.getBlocks = () =>
@@ -843,6 +882,15 @@ export function DocPlayer({
843
882
  {/* Hidden audio element */}
844
883
  <audio ref={audioRef} preload="auto" muted={muted} />
845
884
 
885
+ {/* Timed media clips (per-block + document-spanning audio/video). */}
886
+ <MediaClipLayer
887
+ schedule={mediaSchedule}
888
+ currentTime={currentTime}
889
+ isPlaying={isPlaying}
890
+ basePath={basePath}
891
+ renderMode={renderMode}
892
+ />
893
+
846
894
  {/* Block viewport */}
847
895
  <div className="doc-player__viewport">
848
896
  {/* Cover block (shown at rest before playback) */}
@@ -860,12 +908,17 @@ export function DocPlayer({
860
908
 
861
909
  {/* Previous block (during transition) */}
862
910
  {!showCoverBlock && previousBlock && isExiting && (
863
- <div className="doc-player__block doc-player__block--previous">
911
+ // Keyed by block id so each block is its own DOM subtree: React never
912
+ // reconciles one block's layers onto another's (templates reuse layer
913
+ // ids like `title`/`background`), which would otherwise reuse stale
914
+ // DOM / skip entrance animations mid-transition.
915
+ <div key={previousBlock.id} className="doc-player__block doc-player__block--previous">
864
916
  <BlockRenderer
865
917
  block={previousBlock}
866
918
  blockTime={blockTime}
867
919
  basePath={basePath}
868
920
  isExiting={true}
921
+ transition={currentBlock?.transition}
869
922
  viewport={activeViewport}
870
923
  />
871
924
  </div>
@@ -873,7 +926,7 @@ export function DocPlayer({
873
926
 
874
927
  {/* Current block */}
875
928
  {!showCoverBlock && currentBlock && (
876
- <div className="doc-player__block doc-player__block--active">
929
+ <div key={currentBlock.id} className="doc-player__block doc-player__block--active">
877
930
  <BlockRenderer
878
931
  block={currentBlock}
879
932
  blockTime={blockTime}
@@ -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,10 +28,9 @@ 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 { getLayers, hasTemplate, DEFAULT_THEME, deriveTemplateInputs } from '@bendyline/squisq/doc';
32
32
  import type { RenderContext } from '@bendyline/squisq/doc';
33
33
  import { extractPlainText } from '@bendyline/squisq/markdown';
34
- import type { MarkdownBlockNode, MarkdownList, MarkdownTable } from '@bendyline/squisq/markdown';
35
34
  import { BlockRenderer } from './BlockRenderer';
36
35
  import { MarkdownRenderer } from './MarkdownRenderer';
37
36
 
@@ -126,7 +125,6 @@ function BlockSection({ block, basePath, viewport, renderContext, blockIndex }:
126
125
 
127
126
  const annotation = block.sourceHeading!.templateAnnotation!;
128
127
  const headingText = extractPlainText(block.sourceHeading!);
129
- const bodyText = extractBodyPlainText(block.contents);
130
128
 
131
129
  // Build a TemplateBlock-compatible object
132
130
  const templateBlock: Record<string, unknown> = {
@@ -136,12 +134,14 @@ function BlockSection({ block, basePath, viewport, renderContext, blockIndex }:
136
134
  duration: 1,
137
135
  audioSegment: 0,
138
136
  title: headingText,
139
- ...getTemplateDefaults(
137
+ ...(deriveTemplateInputs(
140
138
  annotation.template ?? 'sectionHeader',
141
139
  headingText,
142
- bodyText,
143
140
  block.contents,
144
- ),
141
+ {
142
+ placeholders: true,
143
+ },
144
+ ) ?? {}),
145
145
  ...annotation.params,
146
146
  ...block.templateOverrides,
147
147
  };
@@ -223,196 +223,6 @@ function BlockSection({ block, basePath, viewport, renderContext, blockIndex }:
223
223
 
224
224
  // ── Template Defaults (mirrored from PreviewPanel) ─────────────────
225
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
226
  // ── Main Component ─────────────────────────────────────────────────
417
227
 
418
228
  /**
@@ -444,10 +254,14 @@ export function LinearDocView({
444
254
 
445
255
  const renderContext: RenderContext = useMemo(() => {
446
256
  const baseTheme = theme ?? DEFAULT_THEME;
257
+ const effectiveTheme = resolvedSurface ? applySurface(baseTheme, resolvedSurface) : baseTheme;
447
258
  return {
448
- theme: resolvedSurface ? applySurface(baseTheme, resolvedSurface) : baseTheme,
259
+ theme: effectiveTheme,
449
260
  viewport: activeViewport,
450
261
  totalBlocks,
262
+ // Theme atmosphere (vignette/grain/gradient persistent layers) shows
263
+ // on the inline template cards so they match the player's look.
264
+ persistentLayers: effectiveTheme.persistentLayers,
451
265
  };
452
266
  }, [activeViewport, totalBlocks, theme, resolvedSurface]);
453
267