@bendyline/squisq-react 1.4.2 → 2.0.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 (50) hide show
  1. package/README.md +30 -3
  2. package/dist/index.d.ts +177 -28
  3. package/dist/index.js +1330 -611
  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 +57 -37
  8. package/dist/squisq-player.global.js.map +1 -1
  9. package/dist/standalone-source.js +1 -1
  10. package/dist/styles/index.css +28 -0
  11. package/package.json +2 -2
  12. package/src/BlockRenderer.tsx +54 -17
  13. package/src/DocControlsSlideshow.tsx +222 -5
  14. package/src/DocPlayer.tsx +367 -183
  15. package/src/DocPlayerWithSidebar.tsx +4 -0
  16. package/src/DocProgressBar.tsx +40 -1
  17. package/src/LinearDocView.tsx +138 -62
  18. package/src/MarkdownRenderer.tsx +40 -97
  19. package/src/MediaClipLayer.tsx +12 -2
  20. package/src/__tests__/BlockRenderer.test.tsx +138 -8
  21. package/src/__tests__/DocControlsSlideshow.test.tsx +94 -1
  22. package/src/__tests__/DocPlayer.test.tsx +505 -0
  23. package/src/__tests__/DocProgressBar.test.tsx +28 -2
  24. package/src/__tests__/LinearDocView.test.tsx +104 -11
  25. package/src/__tests__/MapLayer.test.tsx +63 -0
  26. package/src/__tests__/MarkdownRenderer.test.tsx +16 -5
  27. package/src/__tests__/MediaClipLayer.test.tsx +70 -0
  28. package/src/__tests__/MediaContext.test.tsx +51 -0
  29. package/src/__tests__/PathLayer.test.tsx +12 -1
  30. package/src/__tests__/VideoLayer.test.tsx +94 -0
  31. package/src/__tests__/fillStyle.test.tsx +50 -2
  32. package/src/__tests__/standaloneEntry.test.tsx +103 -0
  33. package/src/__tests__/useAudioSync.test.ts +49 -0
  34. package/src/__tests__/useDocPlayback.transition.test.ts +48 -5
  35. package/src/__tests__/useViewportOrientation.test.ts +22 -0
  36. package/src/hooks/MediaContext.tsx +12 -3
  37. package/src/hooks/useAudioSync.ts +61 -12
  38. package/src/hooks/useDocPlayback.ts +40 -12
  39. package/src/hooks/useViewportOrientation.ts +2 -4
  40. package/src/index.ts +5 -2
  41. package/src/layers/ImageLayer.tsx +106 -1
  42. package/src/layers/MapLayer.tsx +7 -6
  43. package/src/layers/PathLayer.tsx +20 -11
  44. package/src/layers/ShapeLayer.tsx +33 -9
  45. package/src/layers/TextLayer.tsx +4 -3
  46. package/src/layers/TreeLayer.tsx +167 -0
  47. package/src/layers/VideoLayer.tsx +20 -6
  48. package/src/standalone-entry.tsx +91 -14
  49. package/src/styles/doc-animations.css +36 -0
  50. package/src/types.ts +13 -13
@@ -12,7 +12,7 @@
12
12
  * Playwright screenshot contexts.
13
13
  */
14
14
 
15
- import { useState, useEffect } from 'react';
15
+ import { useId, useState, useEffect } from 'react';
16
16
  import type { MapLayer as MapLayerType } from '@bendyline/squisq/schemas';
17
17
  import { getAnimationStyle } from '../utils/animationUtils';
18
18
  import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
@@ -30,6 +30,7 @@ interface MapLayerProps {
30
30
 
31
31
  export function MapLayer({ layer, basePath, viewport, blockTime }: MapLayerProps) {
32
32
  const { content, position, animation } = layer;
33
+ const clipId = `map-clip-${useId().replace(/:/g, '')}-${layer.id}`;
33
34
  const [mapImageUrl, setMapImageUrl] = useState<string | null>(null);
34
35
  const [isLoading, setIsLoading] = useState(true);
35
36
  const [error, setError] = useState<string | null>(null);
@@ -89,13 +90,13 @@ export function MapLayer({ layer, basePath, viewport, blockTime }: MapLayerProps
89
90
  return () => {
90
91
  cancelled = true;
91
92
  };
92
- // eslint-disable-next-line react-hooks/exhaustive-deps -- content properties are destructured below; center/markers/showAttribution are stable per-render
93
93
  }, [
94
- content.center.lat,
95
- content.center.lng,
94
+ content.center,
96
95
  content.zoom,
97
96
  content.style,
98
97
  content.staticSrc,
98
+ content.markers,
99
+ content.showAttribution,
99
100
  width,
100
101
  height,
101
102
  basePath,
@@ -160,13 +161,13 @@ export function MapLayer({ layer, basePath, viewport, blockTime }: MapLayerProps
160
161
  >
161
162
  {/* Clip path for overflow handling */}
162
163
  <defs>
163
- <clipPath id={`clip-${layer.id}`}>
164
+ <clipPath id={clipId}>
164
165
  <rect x={finalX} y={finalY} width={width} height={height} />
165
166
  </clipPath>
166
167
  </defs>
167
168
 
168
169
  {/* Map image */}
169
- <g clipPath={`url(#clip-${layer.id})`}>
170
+ <g clipPath={`url(#${clipId})`}>
170
171
  <image
171
172
  href={mapImageUrl}
172
173
  x={finalX}
@@ -17,11 +17,11 @@
17
17
  * rect/circle/line `ShapeLayer` behaves — rather than pinned to a baked
18
18
  * absolute path.
19
19
  *
20
- * End markers are configured via `startMarker`/`endMarker` (with the legacy
21
- * `arrow` flag mapping to a filled triangle). Marker geometry comes from
22
- * `markerPath` in core so the SSR renderer and the editor agree.
20
+ * End markers are configured via `startMarker`/`endMarker`. Marker geometry
21
+ * comes from `markerPath` in core so the SSR renderer and the editor agree.
23
22
  */
24
23
 
24
+ import { useId } from 'react';
25
25
  import type { PathLayer as PathLayerType, MarkerStyle } from '@bendyline/squisq/schemas';
26
26
  import { markerPath, shapePath } from '@bendyline/squisq/doc';
27
27
  import { getAnimationStyle } from '../utils/animationUtils';
@@ -54,33 +54,42 @@ function effectivePath(layer: PathLayerType, viewport: { width: number; height:
54
54
  return derived ?? content.d;
55
55
  }
56
56
 
57
- /** Resolve the effective marker for an endpoint (explicit field, else `arrow`). */
57
+ type LegacyArrow = 'none' | 'end' | 'start' | 'both';
58
+
59
+ /** Tolerant-reader support for documents authored before endpoint markers. */
60
+ function readLegacyArrow(content: PathLayerType['content']): LegacyArrow | undefined {
61
+ return (content as PathLayerType['content'] & { arrow?: LegacyArrow }).arrow;
62
+ }
63
+
64
+ /** Resolve the effective marker for an endpoint. */
58
65
  function effectiveMarker(
59
66
  explicit: MarkerStyle | undefined,
60
- arrow: PathLayerType['content']['arrow'],
67
+ legacyArrow: LegacyArrow | undefined,
61
68
  end: 'start' | 'end',
62
69
  ): MarkerStyle {
63
70
  if (explicit) return explicit;
64
- const wants = arrow === 'both' || arrow === end;
71
+ const wants = legacyArrow === 'both' || legacyArrow === end;
65
72
  return wants ? 'arrow' : 'none';
66
73
  }
67
74
 
68
75
  export function PathLayer({ layer, viewport, blockTime }: PathLayerProps) {
69
76
  const { content, animation, id } = layer;
77
+ const defsId = `${useId().replace(/:/g, '')}-${id}`;
70
78
  const d = effectivePath(layer, viewport);
71
79
  const stroke = content.stroke ?? '#1e293b';
72
80
  const strokeWidth = content.strokeWidth ?? 2;
73
- const { fill, def: fillDef } = resolveFill(id, content.fill ?? 'none', content.gradient);
81
+ const { fill, def: fillDef } = resolveFill(defsId, content.fill ?? 'none', content.gradient);
74
82
  // `borderStyle` (named shapes) takes precedence over a raw `dasharray`.
75
83
  const dash = content.borderStyle
76
84
  ? borderDashArray(content.borderStyle, strokeWidth)
77
85
  : content.dasharray;
78
86
  const animStyle = getAnimationStyle(animation, blockTime);
79
87
 
80
- const startId = `marker-start-${id}`;
81
- const endId = `marker-end-${id}`;
82
- const start = markerPath(effectiveMarker(content.startMarker, content.arrow, 'start'), 'start');
83
- const end = markerPath(effectiveMarker(content.endMarker, content.arrow, 'end'), 'end');
88
+ const startId = `marker-start-${defsId}`;
89
+ const endId = `marker-end-${defsId}`;
90
+ const legacyArrow = readLegacyArrow(content);
91
+ const start = markerPath(effectiveMarker(content.startMarker, legacyArrow, 'start'), 'start');
92
+ const end = markerPath(effectiveMarker(content.endMarker, legacyArrow, 'end'), 'end');
84
93
 
85
94
  return (
86
95
  <g
@@ -5,6 +5,7 @@
5
5
  * Useful for visual accents, dividers, and background elements.
6
6
  */
7
7
 
8
+ import { useId } from 'react';
8
9
  import type { ShapeLayer as ShapeLayerType } from '@bendyline/squisq/schemas';
9
10
  import { getAnimationStyle } from '../utils/animationUtils';
10
11
  import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
@@ -18,8 +19,17 @@ interface ShapeLayerProps {
18
19
  blockTime: number;
19
20
  }
20
21
 
22
+ /**
23
+ * Full-bleed HTML and SVG layers can be rasterized on slightly different
24
+ * subpixel boundaries. Extend unbordered background rectangles beyond the
25
+ * viewport so an image cannot peek through at the outermost pixel; the block
26
+ * renderer clips the combined layer stack back to the viewBox.
27
+ */
28
+ const FULL_BLEED_OVERSCAN = 1;
29
+
21
30
  export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
22
31
  const { content, position, animation } = layer;
32
+ const defsId = `${useId().replace(/:/g, '')}-${layer.id}`;
23
33
 
24
34
  // Resolve position values to pixels
25
35
  const rawX = resolveValue(position.x, viewport.width);
@@ -32,6 +42,20 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
32
42
  const x = rawX + anchorOffset.x;
33
43
  const y = rawY + anchorOffset.y;
34
44
 
45
+ const isUnborderedFullBleedRect =
46
+ content.shape === 'rect' &&
47
+ x === 0 &&
48
+ y === 0 &&
49
+ width === viewport.width &&
50
+ height === viewport.height &&
51
+ !content.stroke &&
52
+ !content.borderRadius;
53
+ const overscan = isUnborderedFullBleedRect ? FULL_BLEED_OVERSCAN : 0;
54
+ const paintX = x - overscan;
55
+ const paintY = y - overscan;
56
+ const paintWidth = width + overscan * 2;
57
+ const paintHeight = height + overscan * 2;
58
+
35
59
  // Get animation styles
36
60
  const animStyle = getAnimationStyle(animation, blockTime);
37
61
 
@@ -49,11 +73,11 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
49
73
  style={animStyle.style}
50
74
  data-layer-id={layer.id}
51
75
  >
52
- <foreignObject x={x} y={y} width={width} height={height}>
76
+ <foreignObject x={paintX} y={paintY} width={paintWidth} height={paintHeight}>
53
77
  <div
54
78
  style={{
55
- width: `${width}px`,
56
- height: `${height}px`,
79
+ width: `${paintWidth}px`,
80
+ height: `${paintHeight}px`,
57
81
  background: fill,
58
82
  borderRadius: content.borderRadius ? `${content.borderRadius}px` : undefined,
59
83
  pointerEvents: 'none',
@@ -65,12 +89,12 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
65
89
  }
66
90
 
67
91
  const { fill: fillValue, def: fillDef } = resolveFill(
68
- layer.id,
92
+ defsId,
69
93
  fill,
70
94
  content.gradient,
71
95
  content.pattern,
72
96
  );
73
- const { filterAttr, def: filterDef } = resolveShapeFilter(layer.id, content.filter);
97
+ const { filterAttr, def: filterDef } = resolveShapeFilter(defsId, content.filter);
74
98
  const dash = borderDashArray(content.borderStyle, content.strokeWidth);
75
99
 
76
100
  // Common style props for native SVG shapes. `line` is stroke-only.
@@ -97,10 +121,10 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
97
121
  )}
98
122
  {content.shape === 'rect' && (
99
123
  <rect
100
- x={x}
101
- y={y}
102
- width={width}
103
- height={height}
124
+ x={paintX}
125
+ y={paintY}
126
+ width={paintWidth}
127
+ height={paintHeight}
104
128
  rx={content.borderRadius}
105
129
  ry={content.borderRadius}
106
130
  {...shapeProps}
@@ -16,7 +16,7 @@
16
16
  * PDF bypasses SVG) and already used by Video/Table layers.
17
17
  */
18
18
 
19
- import { useMemo, type CSSProperties } from 'react';
19
+ import { useId, useMemo, type CSSProperties } from 'react';
20
20
  import type { TextLayer as TextLayerType } from '@bendyline/squisq/schemas';
21
21
  import { DEFAULT_DOC_FONT } from '@bendyline/squisq/schemas';
22
22
  import {
@@ -165,6 +165,7 @@ function IconTextLayer({ layer, viewport, blockTime }: TextLayerProps) {
165
165
  }
166
166
 
167
167
  function PlainTextLayer({ layer, viewport, blockTime }: TextLayerProps) {
168
+ const defsId = `${useId().replace(/:/g, '')}-${layer.id}`;
168
169
  const { content, position, animation } = layer;
169
170
  const { text, style } = content;
170
171
 
@@ -225,7 +226,7 @@ function PlainTextLayer({ layer, viewport, blockTime }: TextLayerProps) {
225
226
  };
226
227
 
227
228
  // Add shadow filter if requested
228
- const filterId = style.shadow ? `shadow-${layer.id}` : undefined;
229
+ const filterId = style.shadow ? `shadow-${defsId}` : undefined;
229
230
 
230
231
  return (
231
232
  <g className={`block-layer block-layer--text ${animStyle.className}`} data-layer-id={layer.id}>
@@ -244,7 +245,7 @@ function PlainTextLayer({ layer, viewport, blockTime }: TextLayerProps) {
244
245
  rectangle. Without a box we fall back to a snug rect hugging the
245
246
  text (legacy behavior for point-anchored text). */}
246
247
  <TextBox
247
- layerId={layer.id}
248
+ layerId={defsId}
248
249
  style={style}
249
250
  box={
250
251
  boxWidth != null && boxHeight != null
@@ -0,0 +1,167 @@
1
+ /**
2
+ * TreeLayer Component
3
+ *
4
+ * Renders a hierarchical treeview inside an SVG block via a <foreignObject>
5
+ * (same technique as TableLayer) — a filesystem-style outline with
6
+ * folder/file icons, indentation guide rails, and collapse chevrons.
7
+ *
8
+ * Interactive in the live React player: clicking a folder chevron
9
+ * collapses/expands it (local component state, default fully expanded).
10
+ * Headless frame / PDF capture renders the default expanded DOM statically,
11
+ * so exports are deterministic.
12
+ */
13
+
14
+ import { useState } from 'react';
15
+ import type { TreeLayer as TreeLayerType, TreeLayerItem } from '@bendyline/squisq/schemas';
16
+ import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
17
+ import { getAnimationStyle } from '../utils/animationUtils';
18
+
19
+ interface TreeLayerProps {
20
+ layer: TreeLayerType;
21
+ viewport: { width: number; height: number };
22
+ blockTime: number;
23
+ }
24
+
25
+ /** FontAwesome class from a bare name (`folder`) or qualified (`fa-solid:folder`). */
26
+ function faClass(token: string | undefined, fallback: string): string {
27
+ const name = token && token.trim() ? token.trim() : fallback;
28
+ const colon = name.indexOf(':');
29
+ if (colon > 0) {
30
+ const family = name.slice(0, colon).replace(/^fa-/, '');
31
+ return `fa-${family} fa-${name.slice(colon + 1)}`;
32
+ }
33
+ return `fa-solid fa-${name}`;
34
+ }
35
+
36
+ export function TreeLayer({ layer, viewport, blockTime }: TreeLayerProps) {
37
+ const { content, position, animation } = layer;
38
+ const { items, style } = content;
39
+
40
+ const x = resolveValue(position.x, viewport.width);
41
+ const y = resolveValue(position.y, viewport.height);
42
+ const width = position.width ? resolveValue(position.width, viewport.width) : viewport.width;
43
+ const height = position.height ? resolveValue(position.height, viewport.height) : viewport.height;
44
+ const offset = getAnchorOffset(position.anchor, width, height);
45
+ const animStyle = animation ? getAnimationStyle(animation, blockTime) : {};
46
+
47
+ return (
48
+ <foreignObject
49
+ x={x + offset.x}
50
+ y={y + offset.y}
51
+ width={width}
52
+ height={height}
53
+ style={animStyle}
54
+ >
55
+ <div
56
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
57
+ {...({ xmlns: 'http://www.w3.org/1999/xhtml' } as any)}
58
+ className="squisq-treelayer"
59
+ style={{
60
+ width: `${width}px`,
61
+ height: `${height}px`,
62
+ display: 'flex',
63
+ flexDirection: 'column',
64
+ justifyContent: 'center',
65
+ padding: '24px 32px',
66
+ boxSizing: 'border-box',
67
+ fontFamily: style.fontFamily ?? 'system-ui, sans-serif',
68
+ fontSize: `${style.fontSize}px`,
69
+ lineHeight: 1.7,
70
+ overflow: 'hidden',
71
+ }}
72
+ >
73
+ <TreeList items={items} depth={0} style={style} />
74
+ </div>
75
+ </foreignObject>
76
+ );
77
+ }
78
+
79
+ function TreeList({
80
+ items,
81
+ depth,
82
+ style,
83
+ }: {
84
+ items: TreeLayerItem[];
85
+ depth: number;
86
+ style: TreeLayerType['content']['style'];
87
+ }) {
88
+ return (
89
+ <ul
90
+ style={{
91
+ listStyle: 'none',
92
+ margin: 0,
93
+ padding: 0,
94
+ paddingLeft: depth === 0 ? 0 : `${style.indentPx}px`,
95
+ borderLeft: depth === 0 ? 'none' : `1px solid ${style.connectorColor}`,
96
+ }}
97
+ >
98
+ {items.map((item) => (
99
+ <TreeRow key={item.id} item={item} style={style} />
100
+ ))}
101
+ </ul>
102
+ );
103
+ }
104
+
105
+ function TreeRow({
106
+ item,
107
+ style,
108
+ }: {
109
+ item: TreeLayerItem;
110
+ style: TreeLayerType['content']['style'];
111
+ }) {
112
+ const hasChildren = item.children.length > 0;
113
+ const [collapsed, setCollapsed] = useState(false);
114
+ const isDir = item.isDir || hasChildren;
115
+ const iconCls = isDir
116
+ ? faClass(style.folderIcon, collapsed ? 'folder' : 'folder-open')
117
+ : faClass(style.fileIcon, 'file');
118
+
119
+ return (
120
+ <li style={{ position: 'relative' }}>
121
+ <div style={{ display: 'flex', alignItems: 'baseline', gap: '8px', padding: '1px 0' }}>
122
+ {hasChildren ? (
123
+ <button
124
+ type="button"
125
+ aria-label={collapsed ? 'Expand' : 'Collapse'}
126
+ onClick={() => setCollapsed((c) => !c)}
127
+ style={{
128
+ flex: '0 0 auto',
129
+ width: '1em',
130
+ border: 'none',
131
+ background: 'transparent',
132
+ cursor: 'pointer',
133
+ color: style.connectorColor,
134
+ padding: 0,
135
+ fontSize: '0.8em',
136
+ }}
137
+ >
138
+ <i
139
+ className={`fa-solid ${collapsed ? 'fa-chevron-right' : 'fa-chevron-down'}`}
140
+ aria-hidden="true"
141
+ />
142
+ </button>
143
+ ) : (
144
+ <span style={{ flex: '0 0 auto', width: '1em' }} />
145
+ )}
146
+ <i
147
+ className={iconCls}
148
+ aria-hidden="true"
149
+ style={{ flex: '0 0 auto', color: style.iconColor, width: '1.2em', textAlign: 'center' }}
150
+ />
151
+ <span
152
+ style={{ color: isDir ? style.dirColor : style.rowColor, fontWeight: isDir ? 600 : 400 }}
153
+ >
154
+ {item.label}
155
+ </span>
156
+ {item.comment ? (
157
+ <span style={{ color: style.commentColor, fontSize: '0.85em', fontStyle: 'italic' }}>
158
+ {item.comment}
159
+ </span>
160
+ ) : null}
161
+ </div>
162
+ {hasChildren && !collapsed ? (
163
+ <TreeList items={item.children} depth={1} style={style} />
164
+ ) : null}
165
+ </li>
166
+ );
167
+ }
@@ -26,6 +26,8 @@ import type { VideoLayer as VideoLayerType } from '@bendyline/squisq/schemas';
26
26
  import { useMediaUrl } from '../hooks/MediaContext';
27
27
  import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
28
28
 
29
+ const VIDEO_SYNC_DRIFT_SECONDS = 0.2;
30
+
29
31
  interface VideoLayerProps {
30
32
  layer: VideoLayerType;
31
33
  /** Base path for resolving relative video URLs */
@@ -100,22 +102,34 @@ export function VideoLayer({ layer, basePath, viewport, blockTime, isPlaying }:
100
102
  video.pause();
101
103
  };
102
104
  // eslint-disable-next-line react-hooks/exhaustive-deps -- isPlaying is handled by the separate sync effect below
103
- }, [content.src, content.clipStart, content.clipEnd]);
105
+ }, [src, content.clipStart, content.clipEnd]);
104
106
 
105
- // Sync video play/pause with doc playback state, honoring the startAt gate.
107
+ // Sync video time + play/pause with the doc clock, honoring the startAt
108
+ // gate. The time correction matters when a synchronized audience player is
109
+ // opened partway through a block: its video must join at the main player's
110
+ // current frame rather than restarting from clipStart.
106
111
  useEffect(() => {
107
112
  const video = videoRef.current;
108
113
  if (!video || !hasStartedRef.current) return;
109
114
 
115
+ const targetTime = gated
116
+ ? content.clipStart
117
+ : Math.min(content.clipEnd, content.clipStart + Math.max(0, blockTime - startAt));
118
+ if (Math.abs(video.currentTime - targetTime) > VIDEO_SYNC_DRIFT_SECONDS) {
119
+ video.currentTime = targetTime;
120
+ }
121
+
110
122
  // Before the clip's startAt offset, hold at the in-point.
111
123
  if (gated) {
112
124
  video.pause();
113
- video.currentTime = content.clipStart;
114
125
  return;
115
126
  }
116
127
 
117
- // Don't resume if clip has already reached its end
118
- if (video.currentTime >= content.clipEnd) return;
128
+ // Don't resume if the document clock has already reached the clip end.
129
+ if (targetTime >= content.clipEnd) {
130
+ video.pause();
131
+ return;
132
+ }
119
133
 
120
134
  if (isPlaying) {
121
135
  const playPromise = video.play();
@@ -125,7 +139,7 @@ export function VideoLayer({ layer, basePath, viewport, blockTime, isPlaying }:
125
139
  } else {
126
140
  video.pause();
127
141
  }
128
- }, [isPlaying, gated, content.clipStart, content.clipEnd]);
142
+ }, [isPlaying, gated, blockTime, startAt, src, content.clipStart, content.clipEnd]);
129
143
 
130
144
  return (
131
145
  <g className="block-layer block-layer--video" data-layer-id={layer.id}>
@@ -12,10 +12,12 @@
12
12
  * <script src="squisq-player.iife.js"></script>
13
13
  * <div id="root"></div>
14
14
  * <script>
15
- * SquisqPlayer.mount(document.getElementById('root'), docJson, {
15
+ * const root = document.getElementById('root');
16
+ * const handle = SquisqPlayer.mount(root, docJson, {
16
17
  * mode: 'slideshow',
17
18
  * images: { 'hero.jpg': 'data:image/jpeg;base64,...' }
18
19
  * });
20
+ * // In render mode: const api = await handle.renderAPI;
19
21
  * </script>
20
22
  */
21
23
 
@@ -23,6 +25,7 @@ import { createElement } from 'react';
23
25
  import { createRoot, type Root } from 'react-dom/client';
24
26
  import type { Doc, MediaProvider } from '@bendyline/squisq/schemas';
25
27
  import type { Theme } from '@bendyline/squisq/schemas';
28
+ import type { SquisqRenderAPI } from './types';
26
29
  import { DocPlayer } from './DocPlayer';
27
30
  import { LinearDocView } from './LinearDocView';
28
31
  import { MediaContext } from './hooks/MediaContext';
@@ -54,15 +57,37 @@ export interface MountOptions {
54
57
  /** Auto-play on mount (only for slideshow mode, default: false) */
55
58
  autoPlay?: boolean;
56
59
  /**
57
- * Enable render mode for headless frame capture.
58
- * Exposes window.seekTo(), getDuration(), getCaptions(), etc.
59
- * Disables controls and auto-play. Used by Playwright video export.
60
+ * Capture presentation arrow keys without requiring focus (default: true).
61
+ * Disable when mounting multiple interactive players on the same page.
62
+ */
63
+ globalKeyboardShortcuts?: boolean;
64
+ /**
65
+ * Enable render mode for headless frame capture. The instance API is
66
+ * available through the returned mount handle. Disables controls and
67
+ * auto-play.
60
68
  */
61
69
  renderMode?: boolean;
70
+ /**
71
+ * Whether to render slide transitions and per-layer animations (default: true).
72
+ * Timed media continues to play when disabled.
73
+ */
74
+ animationsEnabled?: boolean;
62
75
  /** Caption style: 'standard' or 'social'. Omit or set to undefined for no captions. */
63
76
  captionStyle?: 'standard' | 'social';
64
77
  }
65
78
 
79
+ /** Instance handle returned by {@link mount}. */
80
+ export interface SquisqPlayerHandle {
81
+ /** DOM element that owns this player instance. */
82
+ readonly element: Element;
83
+ /** Resolves to this instance's render API, or null when render mode is off. */
84
+ readonly renderAPI: Promise<SquisqRenderAPI | null>;
85
+ /** Current render API without waiting for effects to run. */
86
+ getRenderAPI(): SquisqRenderAPI | null;
87
+ /** Unmount this exact player instance. */
88
+ unmount(): void;
89
+ }
90
+
66
91
  // ── CSS Injection ──────────────────────────────────────────────────
67
92
 
68
93
  let cssInjected = false;
@@ -167,6 +192,50 @@ function rewriteAudioUrls(doc: Doc, audioMap: Record<string, string>): Doc {
167
192
 
168
193
  const roots = new WeakMap<Element, Root>();
169
194
 
195
+ interface InternalPlayerHandle extends SquisqPlayerHandle {
196
+ setRenderAPI(api: SquisqRenderAPI | null): void;
197
+ cancel(): void;
198
+ }
199
+
200
+ const handles = new WeakMap<Element, InternalPlayerHandle>();
201
+
202
+ function createPlayerHandle(element: Element, expectsRenderAPI: boolean): InternalPlayerHandle {
203
+ let currentAPI: SquisqRenderAPI | null = null;
204
+ let active = true;
205
+ let settled = false;
206
+ let resolveRenderAPI!: (api: SquisqRenderAPI | null) => void;
207
+ const renderAPI = new Promise<SquisqRenderAPI | null>((resolve) => {
208
+ resolveRenderAPI = resolve;
209
+ });
210
+
211
+ const settle = (api: SquisqRenderAPI | null) => {
212
+ if (settled) return;
213
+ settled = true;
214
+ resolveRenderAPI(api);
215
+ };
216
+ if (!expectsRenderAPI) settle(null);
217
+
218
+ const handle: InternalPlayerHandle = {
219
+ element,
220
+ renderAPI,
221
+ getRenderAPI: () => currentAPI,
222
+ unmount: () => {
223
+ if (handles.get(element) === handle) unmount(element);
224
+ },
225
+ setRenderAPI(api) {
226
+ if (!active) return;
227
+ currentAPI = api;
228
+ if (api) settle(api);
229
+ },
230
+ cancel() {
231
+ active = false;
232
+ currentAPI = null;
233
+ settle(null);
234
+ },
235
+ };
236
+ return handle;
237
+ }
238
+
170
239
  // ── Public API ─────────────────────────────────────────────────────
171
240
 
172
241
  /**
@@ -176,7 +245,7 @@ const roots = new WeakMap<Element, Root>();
176
245
  * @param doc - A Doc object (parsed JSON)
177
246
  * @param options - Rendering options
178
247
  */
179
- export function mount(element: Element, doc: Doc, options: MountOptions = {}): void {
248
+ export function mount(element: Element, doc: Doc, options: MountOptions = {}): SquisqPlayerHandle {
180
249
  injectCss();
181
250
 
182
251
  const {
@@ -187,7 +256,9 @@ export function mount(element: Element, doc: Doc, options: MountOptions = {}): v
187
256
  autoPlay = false,
188
257
  theme,
189
258
  renderMode = false,
259
+ animationsEnabled = true,
190
260
  captionStyle,
261
+ globalKeyboardShortcuts = true,
191
262
  } = options;
192
263
 
193
264
  // Rewrite audio URLs if map provided
@@ -195,6 +266,9 @@ export function mount(element: Element, doc: Doc, options: MountOptions = {}): v
195
266
 
196
267
  // Build the media provider if images are provided
197
268
  const mediaProvider = images ? createInlineMediaProvider(images, basePath) : null;
269
+ handles.get(element)?.cancel();
270
+ const handle = createPlayerHandle(element, mode === 'slideshow' && renderMode);
271
+ handles.set(element, handle);
198
272
 
199
273
  let content: ReturnType<typeof createElement>;
200
274
 
@@ -203,6 +277,8 @@ export function mount(element: Element, doc: Doc, options: MountOptions = {}): v
203
277
  doc: finalDoc,
204
278
  basePath,
205
279
  theme,
280
+ animationsEnabled,
281
+ globalKeyboardShortcuts,
206
282
  });
207
283
  } else {
208
284
  content = createElement(DocPlayer, {
@@ -212,9 +288,12 @@ export function mount(element: Element, doc: Doc, options: MountOptions = {}): v
212
288
  autoPlay: renderMode ? false : autoPlay,
213
289
  showControls: !renderMode,
214
290
  renderMode,
291
+ animationsEnabled,
215
292
  theme,
216
293
  captionsEnabled: !!captionStyle,
217
294
  captionStyle: captionStyle ?? 'standard',
295
+ globalKeyboardShortcuts,
296
+ onRenderAPIReady: (api: SquisqRenderAPI | null) => handle.setRenderAPI(api),
218
297
  });
219
298
  }
220
299
 
@@ -230,23 +309,21 @@ export function mount(element: Element, doc: Doc, options: MountOptions = {}): v
230
309
  roots.set(element, root);
231
310
  }
232
311
  root.render(content);
312
+ return handle;
233
313
  }
234
314
 
235
- /**
236
- * Mount a static scrollable document view (alias for mount with mode='static').
237
- */
238
- export function mountStatic(
239
- element: Element,
240
- doc: Doc,
241
- options: Omit<MountOptions, 'mode'> = {},
242
- ): void {
243
- mount(element, doc, { ...options, mode: 'static' });
315
+ /** Return the handle for the player mounted into `element`, if any. */
316
+ export function getHandle(element: Element): SquisqPlayerHandle | undefined {
317
+ return handles.get(element);
244
318
  }
245
319
 
246
320
  /**
247
321
  * Unmount a previously mounted SquisqPlayer from an element.
248
322
  */
249
323
  export function unmount(element: Element): void {
324
+ const handle = handles.get(element);
325
+ handle?.cancel();
326
+ handles.delete(element);
250
327
  const root = roots.get(element);
251
328
  if (root) {
252
329
  root.unmount();