@bendyline/squisq-react 2.0.1 → 2.2.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 (87) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +20 -0
  3. package/README.md +6 -0
  4. package/dist/index.d.ts +101 -96
  5. package/dist/index.js +1483 -782
  6. package/dist/squisq-player.full.global.js +4165 -0
  7. package/dist/squisq-player.global.js +561 -168
  8. package/dist/standalone-source.d.ts +1 -0
  9. package/dist/standalone-source.js +1 -1
  10. package/dist/styles/index.css +64 -0
  11. package/package.json +8 -4
  12. package/dist/index.js.map +0 -1
  13. package/dist/squisq-player.css +0 -2
  14. package/dist/squisq-player.css.map +0 -1
  15. package/dist/squisq-player.global.js.map +0 -1
  16. package/src/BlockRenderer.tsx +0 -194
  17. package/src/CaptionOverlay.tsx +0 -111
  18. package/src/DocControlsBottom.tsx +0 -109
  19. package/src/DocControlsOverlay.tsx +0 -190
  20. package/src/DocControlsSidebar.tsx +0 -113
  21. package/src/DocControlsSlideshow.tsx +0 -360
  22. package/src/DocPlayer.tsx +0 -1528
  23. package/src/DocPlayerWithSidebar.tsx +0 -155
  24. package/src/DocProgressBar.tsx +0 -257
  25. package/src/InlineAudioPlayer.tsx +0 -46
  26. package/src/InlineVideoPlayer.tsx +0 -70
  27. package/src/LinearDocView.tsx +0 -569
  28. package/src/MarkdownRenderer.tsx +0 -624
  29. package/src/MediaClipLayer.tsx +0 -145
  30. package/src/SocialCaptionOverlay.tsx +0 -255
  31. package/src/__tests__/BlockRenderer.test.tsx +0 -235
  32. package/src/__tests__/DocControlsSlideshow.test.tsx +0 -221
  33. package/src/__tests__/DocPlayer.test.tsx +0 -635
  34. package/src/__tests__/DocPlayerStylesSentinel.test.tsx +0 -41
  35. package/src/__tests__/DocProgressBar.test.tsx +0 -102
  36. package/src/__tests__/JsonView.test.tsx +0 -111
  37. package/src/__tests__/LinearDocView.test.tsx +0 -426
  38. package/src/__tests__/MapLayer.test.tsx +0 -63
  39. package/src/__tests__/MarkdownRenderer.test.tsx +0 -357
  40. package/src/__tests__/MediaClipLayer.test.tsx +0 -70
  41. package/src/__tests__/MediaContext.test.tsx +0 -51
  42. package/src/__tests__/PathLayer.test.tsx +0 -84
  43. package/src/__tests__/TableLayer.test.tsx +0 -142
  44. package/src/__tests__/VideoLayer.test.tsx +0 -94
  45. package/src/__tests__/exports.test.ts +0 -55
  46. package/src/__tests__/fillStyle.test.tsx +0 -160
  47. package/src/__tests__/standaloneEntry.test.tsx +0 -103
  48. package/src/__tests__/transitionStyles.test.ts +0 -125
  49. package/src/__tests__/useAudioSync.test.ts +0 -49
  50. package/src/__tests__/useDocPlayback.transition.test.ts +0 -113
  51. package/src/__tests__/useJsonViewTokens.test.ts +0 -41
  52. package/src/__tests__/useSlideSwipe.test.ts +0 -81
  53. package/src/__tests__/useViewportOrientation.test.ts +0 -22
  54. package/src/hooks/AudioController.ts +0 -114
  55. package/src/hooks/MediaContext.tsx +0 -97
  56. package/src/hooks/index.ts +0 -11
  57. package/src/hooks/useAudioSync.ts +0 -456
  58. package/src/hooks/useAutoSurface.ts +0 -33
  59. package/src/hooks/useDocPlayback.ts +0 -265
  60. package/src/hooks/useMediaSchedule.ts +0 -39
  61. package/src/hooks/useSlideSwipe.ts +0 -265
  62. package/src/hooks/useViewportOrientation.ts +0 -115
  63. package/src/index.ts +0 -70
  64. package/src/jsonView/JsonView.tsx +0 -51
  65. package/src/jsonView/RenderNode.tsx +0 -51
  66. package/src/jsonView/index.ts +0 -2
  67. package/src/jsonView/json-view.css +0 -206
  68. package/src/jsonView/useJsonViewTokens.ts +0 -32
  69. package/src/jsonView/viewers.tsx +0 -343
  70. package/src/layers/ImageLayer.tsx +0 -297
  71. package/src/layers/MapLayer.tsx +0 -185
  72. package/src/layers/PathLayer.tsx +0 -155
  73. package/src/layers/ShapeLayer.tsx +0 -158
  74. package/src/layers/TableLayer.tsx +0 -129
  75. package/src/layers/TextLayer.tsx +0 -571
  76. package/src/layers/TreeLayer.tsx +0 -167
  77. package/src/layers/VideoLayer.tsx +0 -171
  78. package/src/layers/index.ts +0 -6
  79. package/src/standalone-entry.tsx +0 -337
  80. package/src/standalone-source.d.ts +0 -10
  81. package/src/styles/doc-animations.css +0 -2431
  82. package/src/styles/index.css +0 -7
  83. package/src/types.ts +0 -183
  84. package/src/utils/animationUtils.ts +0 -13
  85. package/src/utils/fillStyle.tsx +0 -148
  86. package/src/utils/layerUtils.ts +0 -42
  87. package/src/utils/mapTileUtils.ts +0 -375
@@ -1,167 +0,0 @@
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
- }
@@ -1,171 +0,0 @@
1
- /**
2
- * VideoLayer Component
3
- *
4
- * Renders a video clip layer within an SVG block. Uses an HTML5 <video> element
5
- * inside a <foreignObject> (same pattern as ImageLayer for cover-mode images).
6
- * Videos are always muted — narration audio is the only sound track.
7
- *
8
- * Two modes of operation:
9
- * 1. Normal playback: Video auto-plays from clipStart to clipEnd on mount,
10
- * pausing when the clip ends or when the block is no longer active.
11
- * 2. Render/seekTo mode (Playwright frame capture): Video is paused and seeked
12
- * programmatically via data attributes read by DocPlayer's seekTo handler.
13
- *
14
- * The <video> element carries data-clip-start and data-clip-end attributes so
15
- * the seekTo handler can calculate the correct video time for any doc time.
16
- *
17
- * Related Files:
18
- * - schemas/Doc.ts — VideoLayer type definition
19
- * - shared/doc/templates/videoWithCaption.ts — template producing VideoLayers
20
- * - site/src/components/doc/DocPlayer.tsx — seekTo handler for video sync
21
- * - site/src/components/doc/layers/ImageLayer.tsx — similar foreignObject pattern
22
- */
23
-
24
- import { useRef, useEffect } from 'react';
25
- import type { VideoLayer as VideoLayerType } from '@bendyline/squisq/schemas';
26
- import { useMediaUrl } from '../hooks/MediaContext';
27
- import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
28
-
29
- const VIDEO_SYNC_DRIFT_SECONDS = 0.2;
30
-
31
- interface VideoLayerProps {
32
- layer: VideoLayerType;
33
- /** Base path for resolving relative video URLs */
34
- basePath: string;
35
- /** Viewport dimensions for percentage calculations */
36
- viewport: { width: number; height: number };
37
- /** Current time relative to block start (for playback sync) */
38
- blockTime: number;
39
- /** Whether the doc is currently playing */
40
- isPlaying?: boolean;
41
- }
42
-
43
- export function VideoLayer({ layer, basePath, viewport, blockTime, isPlaying }: VideoLayerProps) {
44
- const { content, position } = layer;
45
- const videoRef = useRef<HTMLVideoElement>(null);
46
- const hasStartedRef = useRef(false);
47
-
48
- // Seconds into the block before this clip begins. Until then the video is
49
- // held (paused) at its in-point so a `startAt` offset reads as a delay.
50
- const startAt = content.startAt ?? 0;
51
- const gated = blockTime < startAt;
52
-
53
- // Resolve position values to pixels
54
- const x = resolveValue(position.x, viewport.width);
55
- const y = resolveValue(position.y, viewport.height);
56
- const width = position.width ? resolveValue(position.width, viewport.width) : viewport.width;
57
- const height = position.height ? resolveValue(position.height, viewport.height) : viewport.height;
58
-
59
- // Apply anchor offset
60
- const offset = getAnchorOffset(position.anchor, width, height);
61
- const finalX = x + offset.x;
62
- const finalY = y + offset.y;
63
-
64
- // Resolve video URL via MediaProvider (if available), falling back to basePath
65
- const src = useMediaUrl(content.src, basePath);
66
-
67
- // Always call the hook (Rules of Hooks), but pass empty string when no poster
68
- const resolvedPoster = useMediaUrl(content.posterSrc || '', basePath);
69
- const posterSrc = content.posterSrc ? resolvedPoster : undefined;
70
-
71
- // On mount: seek to clipStart and set up clipEnd boundary.
72
- // The video will be muted and play silently alongside the narration.
73
- useEffect(() => {
74
- const video = videoRef.current;
75
- if (!video) return;
76
-
77
- // Set initial time to clipStart
78
- video.currentTime = content.clipStart;
79
- hasStartedRef.current = true;
80
-
81
- // Start playing if doc is already playing
82
- if (isPlaying) {
83
- const playPromise = video.play();
84
- if (playPromise) {
85
- playPromise.catch(() => {
86
- // Autoplay blocked — fine for Playwright seekTo mode
87
- });
88
- }
89
- }
90
-
91
- // Monitor timeupdate to pause at clipEnd
92
- const handleTimeUpdate = () => {
93
- if (video.currentTime >= content.clipEnd) {
94
- video.pause();
95
- video.currentTime = content.clipEnd;
96
- }
97
- };
98
-
99
- video.addEventListener('timeupdate', handleTimeUpdate);
100
- return () => {
101
- video.removeEventListener('timeupdate', handleTimeUpdate);
102
- video.pause();
103
- };
104
- // eslint-disable-next-line react-hooks/exhaustive-deps -- isPlaying is handled by the separate sync effect below
105
- }, [src, content.clipStart, content.clipEnd]);
106
-
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.
111
- useEffect(() => {
112
- const video = videoRef.current;
113
- if (!video || !hasStartedRef.current) return;
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
-
122
- // Before the clip's startAt offset, hold at the in-point.
123
- if (gated) {
124
- video.pause();
125
- return;
126
- }
127
-
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
- }
133
-
134
- if (isPlaying) {
135
- const playPromise = video.play();
136
- if (playPromise) {
137
- playPromise.catch(() => {});
138
- }
139
- } else {
140
- video.pause();
141
- }
142
- }, [isPlaying, gated, blockTime, startAt, src, content.clipStart, content.clipEnd]);
143
-
144
- return (
145
- <g className="block-layer block-layer--video" data-layer-id={layer.id}>
146
- <foreignObject x={finalX} y={finalY} width={width} height={height}>
147
- <video
148
- ref={videoRef}
149
- src={src}
150
- poster={posterSrc}
151
- muted
152
- playsInline
153
- preload="auto"
154
- data-clip-start={content.clipStart}
155
- data-clip-end={content.clipEnd}
156
- data-start-at={startAt}
157
- style={{
158
- width: `${width}px`,
159
- height: `${height}px`,
160
- objectFit: content.fit || 'cover',
161
- objectPosition: 'center',
162
- display: 'block',
163
- pointerEvents: 'none',
164
- }}
165
- />
166
- </foreignObject>
167
- </g>
168
- );
169
- }
170
-
171
- export default VideoLayer;
@@ -1,6 +0,0 @@
1
- export { ImageLayer } from './ImageLayer';
2
- export { TextLayer } from './TextLayer';
3
- export { ShapeLayer } from './ShapeLayer';
4
- export { PathLayer } from './PathLayer';
5
- export { VideoLayer } from './VideoLayer';
6
- export { MapLayer } from './MapLayer';
@@ -1,337 +0,0 @@
1
- /**
2
- * Standalone Entry Point — IIFE bundle for self-contained HTML rendering.
3
- *
4
- * This file is the entry point for the standalone `squisq-player.iife.js` bundle.
5
- * It bundles Preact (via preact/compat), squisq core, and all rendering components
6
- * into a single self-contained script that can be loaded in any HTML page.
7
- *
8
- * The bundle exposes a global `SquisqPlayer` object with methods to mount
9
- * interactive or static document views into any DOM element.
10
- *
11
- * Usage (in HTML):
12
- * <script src="squisq-player.iife.js"></script>
13
- * <div id="root"></div>
14
- * <script>
15
- * const root = document.getElementById('root');
16
- * const handle = SquisqPlayer.mount(root, docJson, {
17
- * mode: 'slideshow',
18
- * images: { 'hero.jpg': 'data:image/jpeg;base64,...' }
19
- * });
20
- * // In render mode: const api = await handle.renderAPI;
21
- * </script>
22
- */
23
-
24
- import { createElement } from 'react';
25
- import { createRoot, type Root } from 'react-dom/client';
26
- import type { Doc, MediaProvider } from '@bendyline/squisq/schemas';
27
- import type { Theme } from '@bendyline/squisq/schemas';
28
- import type { SquisqRenderAPI } from './types';
29
- import { DocPlayer } from './DocPlayer';
30
- import { LinearDocView } from './LinearDocView';
31
- import { MediaContext } from './hooks/MediaContext';
32
-
33
- // CSS is loaded as text via esbuild's text loader (configured in tsup.standalone.config.ts)
34
- // @ts-expect-error — .css import returns a string when esbuild uses 'text' loader (standalone build only)
35
- import animationCss from './styles/doc-animations.css';
36
-
37
- // ── Types ──────────────────────────────────────────────────────────
38
-
39
- export interface MountOptions {
40
- /** Rendering mode: 'slideshow' (interactive, default) or 'static' (scrollable) */
41
- mode?: 'slideshow' | 'static';
42
- /** Base path for resolving relative media URLs */
43
- basePath?: string;
44
- /**
45
- * Map of relative image paths to data URIs or blob URLs.
46
- * Used in single-HTML exports where images are inlined as base64.
47
- * Example: { 'hero.jpg': 'data:image/jpeg;base64,...' }
48
- */
49
- images?: Record<string, string>;
50
- /**
51
- * Map of audio segment names/paths to URLs (data URIs, blob URLs, or relative paths).
52
- * Used in ZIP exports where audio files are included alongside the HTML.
53
- */
54
- audio?: Record<string, string>;
55
- /** Optional theme override */
56
- theme?: Theme;
57
- /** Auto-play on mount (only for slideshow mode, default: false) */
58
- autoPlay?: boolean;
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.
68
- */
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;
75
- /** Caption style: 'standard' or 'social'. Omit or set to undefined for no captions. */
76
- captionStyle?: 'standard' | 'social';
77
- }
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
-
91
- // ── CSS Injection ──────────────────────────────────────────────────
92
-
93
- let cssInjected = false;
94
-
95
- function injectCss(): void {
96
- if (cssInjected || typeof document === 'undefined') return;
97
- const style = document.createElement('style');
98
- style.setAttribute('data-squisq-player', 'animations');
99
- style.textContent = animationCss;
100
- document.head.appendChild(style);
101
- cssInjected = true;
102
- }
103
-
104
- // ── Inline Media Provider ──────────────────────────────────────────
105
-
106
- /**
107
- * Creates a MediaProvider that resolves URLs from an inline image map.
108
- * Falls back to basePath-based resolution for unknown paths.
109
- */
110
- function createInlineMediaProvider(
111
- images: Record<string, string>,
112
- basePath: string,
113
- ): MediaProvider {
114
- // Doc image references may not exactly match image map keys
115
- // (e.g., "images/hero.jpg" vs "hero.jpg"). Build a filename-keyed
116
- // lookup so resolution can fall back to basename matching.
117
- const byFilename: Record<string, string> = {};
118
- for (const key of Object.keys(images)) {
119
- const filename = key.split('/').pop()!;
120
- byFilename[filename] = images[key];
121
- }
122
-
123
- return {
124
- async resolveUrl(relativePath: string): Promise<string> {
125
- if (relativePath in images) return images[relativePath];
126
- const stripped = relativePath.replace(/^\.\//, '');
127
- if (stripped !== relativePath && stripped in images) return images[stripped];
128
- const filename = relativePath.split('/').pop()!;
129
- if (filename in byFilename) return byFilename[filename];
130
- if (
131
- relativePath.startsWith('http') ||
132
- relativePath.startsWith('data:') ||
133
- relativePath.startsWith('blob:')
134
- ) {
135
- return relativePath;
136
- }
137
- return `${basePath}/${relativePath}`;
138
- },
139
- async listMedia() {
140
- return Object.keys(images).map((name) => ({
141
- name,
142
- mimeType: inferMimeType(name),
143
- size: 0,
144
- }));
145
- },
146
- async addMedia() {
147
- throw new Error('Standalone player is read-only');
148
- },
149
- async removeMedia() {
150
- throw new Error('Standalone player is read-only');
151
- },
152
- dispose() {
153
- // no-op
154
- },
155
- };
156
- }
157
-
158
- function inferMimeType(filename: string): string {
159
- const ext = filename.split('.').pop()?.toLowerCase() ?? '';
160
- const map: Record<string, string> = {
161
- jpg: 'image/jpeg',
162
- jpeg: 'image/jpeg',
163
- png: 'image/png',
164
- gif: 'image/gif',
165
- webp: 'image/webp',
166
- svg: 'image/svg+xml',
167
- mp3: 'audio/mpeg',
168
- mp4: 'video/mp4',
169
- webm: 'video/webm',
170
- };
171
- return map[ext] ?? 'application/octet-stream';
172
- }
173
-
174
- // ── Audio Rewriting ────────────────────────────────────────────────
175
-
176
- /**
177
- * Rewrite audio segment URLs in a Doc if an audio map is provided.
178
- * Returns a shallow-modified copy — does not mutate the original.
179
- */
180
- function rewriteAudioUrls(doc: Doc, audioMap: Record<string, string>): Doc {
181
- if (!doc.audio?.segments?.length) return doc;
182
-
183
- const segments = doc.audio.segments.map((seg) => {
184
- const resolved = audioMap[seg.name] ?? audioMap[seg.src] ?? seg.src;
185
- return { ...seg, src: resolved };
186
- });
187
-
188
- return { ...doc, audio: { ...doc.audio, segments } };
189
- }
190
-
191
- // ── Root Tracking ──────────────────────────────────────────────────
192
-
193
- const roots = new WeakMap<Element, Root>();
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
-
239
- // ── Public API ─────────────────────────────────────────────────────
240
-
241
- /**
242
- * Mount a SquisqPlayer into a DOM element.
243
- *
244
- * @param element - The DOM element to render into
245
- * @param doc - A Doc object (parsed JSON)
246
- * @param options - Rendering options
247
- */
248
- export function mount(element: Element, doc: Doc, options: MountOptions = {}): SquisqPlayerHandle {
249
- injectCss();
250
-
251
- const {
252
- mode = 'slideshow',
253
- basePath = '.',
254
- images,
255
- audio,
256
- autoPlay = false,
257
- theme,
258
- renderMode = false,
259
- animationsEnabled = true,
260
- captionStyle,
261
- globalKeyboardShortcuts = true,
262
- } = options;
263
-
264
- // Rewrite audio URLs if map provided
265
- const finalDoc = audio ? rewriteAudioUrls(doc, audio) : doc;
266
-
267
- // Build the media provider if images are provided
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);
272
-
273
- let content: ReturnType<typeof createElement>;
274
-
275
- if (mode === 'static') {
276
- content = createElement(LinearDocView, {
277
- doc: finalDoc,
278
- basePath,
279
- theme,
280
- animationsEnabled,
281
- globalKeyboardShortcuts,
282
- });
283
- } else {
284
- content = createElement(DocPlayer, {
285
- doc: finalDoc,
286
- basePath,
287
- displayMode: 'slideshow',
288
- autoPlay: renderMode ? false : autoPlay,
289
- showControls: !renderMode,
290
- renderMode,
291
- animationsEnabled,
292
- theme,
293
- captionsEnabled: !!captionStyle,
294
- captionStyle: captionStyle ?? 'standard',
295
- globalKeyboardShortcuts,
296
- onRenderAPIReady: (api: SquisqRenderAPI | null) => handle.setRenderAPI(api),
297
- });
298
- }
299
-
300
- // Wrap in MediaContext if provider is available
301
- if (mediaProvider) {
302
- content = createElement(MediaContext.Provider, { value: mediaProvider }, content);
303
- }
304
-
305
- // Create or reuse React root
306
- let root = roots.get(element);
307
- if (!root) {
308
- root = createRoot(element);
309
- roots.set(element, root);
310
- }
311
- root.render(content);
312
- return handle;
313
- }
314
-
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);
318
- }
319
-
320
- /**
321
- * Unmount a previously mounted SquisqPlayer from an element.
322
- */
323
- export function unmount(element: Element): void {
324
- const handle = handles.get(element);
325
- handle?.cancel();
326
- handles.delete(element);
327
- const root = roots.get(element);
328
- if (root) {
329
- root.unmount();
330
- roots.delete(element);
331
- }
332
- }
333
-
334
- /** Package version — injected at build time via esbuild define */
335
- declare const __SQUISQ_VERSION__: string;
336
- export const version: string =
337
- typeof __SQUISQ_VERSION__ !== 'undefined' ? __SQUISQ_VERSION__ : '0.0.0';
@@ -1,10 +0,0 @@
1
- /**
2
- * Type declaration for the `@bendyline/squisq-react/standalone-source`
3
- * subpath export. The runtime JS file is generated by
4
- * `scripts/generate-standalone-source.mjs` after the IIFE build emits
5
- * `dist/squisq-player.global.js`, but the type surface is constant —
6
- * it always exposes the same single `string` constant — so the
7
- * declaration lives in source so consumers can typecheck without
8
- * having had to build this package first.
9
- */
10
- export declare const PLAYER_BUNDLE: string;