@bendyline/squisq-react 1.4.2 → 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.
- package/README.md +30 -3
- package/dist/index.d.ts +174 -27
- package/dist/index.js +1244 -603
- package/dist/index.js.map +1 -1
- package/dist/squisq-player.global.js +54 -37
- package/dist/squisq-player.global.js.map +1 -1
- package/dist/standalone-source.js +1 -1
- package/package.json +2 -2
- package/src/BlockRenderer.tsx +53 -17
- package/src/DocControlsSlideshow.tsx +222 -5
- package/src/DocPlayer.tsx +367 -183
- package/src/DocPlayerWithSidebar.tsx +4 -0
- package/src/DocProgressBar.tsx +40 -1
- package/src/LinearDocView.tsx +135 -62
- package/src/MarkdownRenderer.tsx +40 -97
- package/src/MediaClipLayer.tsx +12 -2
- package/src/__tests__/BlockRenderer.test.tsx +79 -8
- package/src/__tests__/DocControlsSlideshow.test.tsx +94 -1
- package/src/__tests__/DocPlayer.test.tsx +505 -0
- package/src/__tests__/DocProgressBar.test.tsx +28 -2
- package/src/__tests__/LinearDocView.test.tsx +91 -11
- package/src/__tests__/MapLayer.test.tsx +63 -0
- package/src/__tests__/MarkdownRenderer.test.tsx +13 -2
- package/src/__tests__/MediaClipLayer.test.tsx +70 -0
- package/src/__tests__/MediaContext.test.tsx +51 -0
- package/src/__tests__/PathLayer.test.tsx +12 -1
- package/src/__tests__/VideoLayer.test.tsx +94 -0
- package/src/__tests__/fillStyle.test.tsx +3 -2
- package/src/__tests__/standaloneEntry.test.tsx +103 -0
- package/src/__tests__/useAudioSync.test.ts +49 -0
- package/src/__tests__/useDocPlayback.transition.test.ts +48 -5
- package/src/__tests__/useViewportOrientation.test.ts +22 -0
- package/src/hooks/MediaContext.tsx +12 -3
- package/src/hooks/useAudioSync.ts +61 -12
- package/src/hooks/useDocPlayback.ts +40 -12
- package/src/hooks/useViewportOrientation.ts +2 -4
- package/src/index.ts +5 -2
- package/src/layers/MapLayer.tsx +7 -6
- package/src/layers/PathLayer.tsx +20 -11
- package/src/layers/ShapeLayer.tsx +4 -2
- package/src/layers/TextLayer.tsx +4 -3
- package/src/layers/TreeLayer.tsx +167 -0
- package/src/layers/VideoLayer.tsx +20 -6
- package/src/standalone-entry.tsx +91 -14
- package/src/types.ts +13 -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
|
-
}, [
|
|
105
|
+
}, [src, content.clipStart, content.clipEnd]);
|
|
104
106
|
|
|
105
|
-
// Sync video play/pause with doc
|
|
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
|
|
118
|
-
if (
|
|
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}>
|
package/src/standalone-entry.tsx
CHANGED
|
@@ -12,10 +12,12 @@
|
|
|
12
12
|
* <script src="squisq-player.iife.js"></script>
|
|
13
13
|
* <div id="root"></div>
|
|
14
14
|
* <script>
|
|
15
|
-
*
|
|
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
|
-
*
|
|
58
|
-
*
|
|
59
|
-
|
|
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 = {}):
|
|
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
|
-
|
|
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;
|
|
@@ -146,15 +152,15 @@ export interface RenderChapterInfo {
|
|
|
146
152
|
}
|
|
147
153
|
|
|
148
154
|
/**
|
|
149
|
-
* API
|
|
150
|
-
*
|
|
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.
|
|
151
158
|
*
|
|
152
159
|
* @example
|
|
153
160
|
* ```ts
|
|
154
|
-
*
|
|
155
|
-
* const
|
|
156
|
-
* await
|
|
157
|
-
* const blocks = w.getBlocks!();
|
|
161
|
+
* const handle = SquisqPlayer.getHandle(rootElement);
|
|
162
|
+
* const api = await handle?.renderAPI;
|
|
163
|
+
* await api?.seekTo(5.0);
|
|
158
164
|
* ```
|
|
159
165
|
*/
|
|
160
166
|
export interface SquisqRenderAPI {
|
|
@@ -169,12 +175,6 @@ export interface SquisqRenderAPI {
|
|
|
169
175
|
hasCoverBlock: () => boolean;
|
|
170
176
|
}
|
|
171
177
|
|
|
172
|
-
/**
|
|
173
|
-
* Window augmented with optional SquisqRenderAPI properties.
|
|
174
|
-
* Each property is optional because they're only present in render/debug mode.
|
|
175
|
-
*/
|
|
176
|
-
export type SquisqWindow = Window & typeof globalThis & Partial<SquisqRenderAPI>;
|
|
177
|
-
|
|
178
178
|
/** Format time in seconds to MM:SS string */
|
|
179
179
|
export function formatTime(seconds: number): string {
|
|
180
180
|
const mins = Math.floor(seconds / 60);
|