@bendyline/squisq-react 1.4.1 → 2.0.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 (45) hide show
  1. package/README.md +30 -3
  2. package/dist/index.d.ts +185 -27
  3. package/dist/index.js +1323 -614
  4. package/dist/index.js.map +1 -1
  5. package/dist/squisq-player.global.js +54 -37
  6. package/dist/squisq-player.global.js.map +1 -1
  7. package/dist/standalone-source.js +1 -1
  8. package/package.json +2 -2
  9. package/src/BlockRenderer.tsx +53 -17
  10. package/src/DocControlsSlideshow.tsx +235 -7
  11. package/src/DocPlayer.tsx +462 -195
  12. package/src/DocPlayerWithSidebar.tsx +4 -0
  13. package/src/DocProgressBar.tsx +40 -1
  14. package/src/LinearDocView.tsx +135 -62
  15. package/src/MarkdownRenderer.tsx +40 -97
  16. package/src/MediaClipLayer.tsx +12 -2
  17. package/src/__tests__/BlockRenderer.test.tsx +79 -8
  18. package/src/__tests__/DocControlsSlideshow.test.tsx +94 -1
  19. package/src/__tests__/DocPlayer.test.tsx +556 -2
  20. package/src/__tests__/DocProgressBar.test.tsx +28 -2
  21. package/src/__tests__/LinearDocView.test.tsx +91 -11
  22. package/src/__tests__/MapLayer.test.tsx +63 -0
  23. package/src/__tests__/MarkdownRenderer.test.tsx +13 -2
  24. package/src/__tests__/MediaClipLayer.test.tsx +70 -0
  25. package/src/__tests__/MediaContext.test.tsx +51 -0
  26. package/src/__tests__/PathLayer.test.tsx +12 -1
  27. package/src/__tests__/VideoLayer.test.tsx +94 -0
  28. package/src/__tests__/fillStyle.test.tsx +3 -2
  29. package/src/__tests__/standaloneEntry.test.tsx +103 -0
  30. package/src/__tests__/useAudioSync.test.ts +49 -0
  31. package/src/__tests__/useDocPlayback.transition.test.ts +48 -5
  32. package/src/__tests__/useViewportOrientation.test.ts +22 -0
  33. package/src/hooks/MediaContext.tsx +12 -3
  34. package/src/hooks/useAudioSync.ts +61 -12
  35. package/src/hooks/useDocPlayback.ts +40 -12
  36. package/src/hooks/useViewportOrientation.ts +2 -4
  37. package/src/index.ts +5 -2
  38. package/src/layers/MapLayer.tsx +7 -6
  39. package/src/layers/PathLayer.tsx +20 -11
  40. package/src/layers/ShapeLayer.tsx +4 -2
  41. package/src/layers/TextLayer.tsx +4 -3
  42. package/src/layers/TreeLayer.tsx +167 -0
  43. package/src/layers/VideoLayer.tsx +20 -6
  44. package/src/standalone-entry.tsx +91 -14
  45. package/src/types.ts +19 -13
@@ -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();
package/src/types.ts CHANGED
@@ -33,8 +33,12 @@ export type ControlsLayout = 'overlay' | 'sidebar' | 'bottom';
33
33
  * `markdownDocToPlainHtml` export produces. No SquisqPlayer, no SVG
34
34
  * cards — just `<h1>`/`<p>`/`<ul>` etc. inside a sandboxed iframe.
35
35
  * Use when you want a WYSIWYG view of the simple HTML export.
36
+ * - `'narrate'` — Teleprompter/performance surface. DocPlayer does not
37
+ * implement this mode; it is owned by the editor package
38
+ * (`@bendyline/squisq-editor-react`), which renders its own
39
+ * voice-paced teleprompter view for it.
36
40
  */
37
- export type DisplayMode = 'video' | 'slideshow' | 'linear' | 'page';
41
+ export type DisplayMode = 'video' | 'slideshow' | 'linear' | 'page' | 'narrate';
38
42
 
39
43
  /**
40
44
  * Caption display style.
@@ -68,6 +72,8 @@ export interface PlaybackState {
68
72
  isPlaying: boolean;
69
73
  currentTime: number;
70
74
  totalDuration: number;
75
+ /** Whether the managed cover is the visual currently shown. */
76
+ isCoverVisible?: boolean;
71
77
  currentBlockIndex: number;
72
78
  totalBlocks: number;
73
79
  docProgress: number;
@@ -82,6 +88,12 @@ export interface PlaybackState {
82
88
  currentSegmentName: string | null;
83
89
  /** Current block data (for extracting image info, etc.) */
84
90
  currentBlock: Block | null;
91
+ /** Optional display label for non-block slides, e.g. the managed cover. */
92
+ currentSlideLabel?: string;
93
+ /** Optional human-facing slide number, separate from internal nav index. */
94
+ currentSlideNumber?: number;
95
+ /** Optional human-facing slide total, separate from internal nav total. */
96
+ totalSlideNumber?: number;
85
97
  }
86
98
 
87
99
  /** Playback actions exposed to external control components */
@@ -140,15 +152,15 @@ export interface RenderChapterInfo {
140
152
  }
141
153
 
142
154
  /**
143
- * API surface exposed on `window` in render mode and debug mode.
144
- * Used by Playwright for video export and by ?debug=true for testing.
155
+ * Instance-scoped API created in render mode and debug mode.
156
+ * React hosts receive it via `DocPlayer.onRenderAPIReady`; standalone hosts
157
+ * receive it from their mount handle.
145
158
  *
146
159
  * @example
147
160
  * ```ts
148
- * // In Playwright:
149
- * const w = window as unknown as SquisqWindow;
150
- * await w.seekTo!(5.0);
151
- * const blocks = w.getBlocks!();
161
+ * const handle = SquisqPlayer.getHandle(rootElement);
162
+ * const api = await handle?.renderAPI;
163
+ * await api?.seekTo(5.0);
152
164
  * ```
153
165
  */
154
166
  export interface SquisqRenderAPI {
@@ -163,12 +175,6 @@ export interface SquisqRenderAPI {
163
175
  hasCoverBlock: () => boolean;
164
176
  }
165
177
 
166
- /**
167
- * Window augmented with optional SquisqRenderAPI properties.
168
- * Each property is optional because they're only present in render/debug mode.
169
- */
170
- export type SquisqWindow = Window & typeof globalThis & Partial<SquisqRenderAPI>;
171
-
172
178
  /** Format time in seconds to MM:SS string */
173
179
  export function formatTime(seconds: number): string {
174
180
  const mins = Math.floor(seconds / 60);