@bendyline/squisq-react 0.1.2

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/dist/index.d.ts +563 -0
  2. package/dist/index.js +3180 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/squisq-player.css +2 -0
  5. package/dist/squisq-player.css.map +1 -0
  6. package/dist/squisq-player.global.js +6 -0
  7. package/dist/squisq-player.global.js.map +1 -0
  8. package/dist/standalone-source.d.ts +2 -0
  9. package/dist/standalone-source.js +2 -0
  10. package/package.json +69 -0
  11. package/src/BlockRenderer.tsx +146 -0
  12. package/src/CaptionOverlay.tsx +86 -0
  13. package/src/DocControlsBottom.tsx +103 -0
  14. package/src/DocControlsOverlay.tsx +178 -0
  15. package/src/DocControlsSidebar.tsx +107 -0
  16. package/src/DocControlsSlideshow.tsx +132 -0
  17. package/src/DocPlayer.tsx +1005 -0
  18. package/src/DocPlayerWithSidebar.tsx +138 -0
  19. package/src/DocProgressBar.tsx +200 -0
  20. package/src/LinearDocView.tsx +313 -0
  21. package/src/MarkdownRenderer.tsx +360 -0
  22. package/src/__tests__/BlockRenderer.test.tsx +105 -0
  23. package/src/__tests__/DocControlsSlideshow.test.tsx +127 -0
  24. package/src/__tests__/LinearDocView.test.tsx +180 -0
  25. package/src/__tests__/MarkdownRenderer.test.tsx +234 -0
  26. package/src/__tests__/exports.test.ts +55 -0
  27. package/src/hooks/AudioProvider.ts +114 -0
  28. package/src/hooks/MediaContext.tsx +81 -0
  29. package/src/hooks/index.ts +6 -0
  30. package/src/hooks/useAudioSync.ts +390 -0
  31. package/src/hooks/useDocPlayback.ts +251 -0
  32. package/src/hooks/useViewportOrientation.ts +117 -0
  33. package/src/index.ts +46 -0
  34. package/src/layers/ImageLayer.tsx +182 -0
  35. package/src/layers/MapLayer.tsx +184 -0
  36. package/src/layers/ShapeLayer.tsx +107 -0
  37. package/src/layers/TextLayer.tsx +197 -0
  38. package/src/layers/VideoLayer.tsx +150 -0
  39. package/src/layers/index.ts +5 -0
  40. package/src/standalone-entry.tsx +228 -0
  41. package/src/styles/doc-animations.css +458 -0
  42. package/src/types.ts +152 -0
  43. package/src/utils/animationUtils.ts +13 -0
  44. package/src/utils/layerUtils.ts +42 -0
  45. package/src/utils/mapTileUtils.ts +375 -0
@@ -0,0 +1,197 @@
1
+ /**
2
+ * TextLayer Component
3
+ *
4
+ * Renders a text layer within an SVG block. Supports multi-line text,
5
+ * styling options (font, color, shadow), and animations like fadeIn
6
+ * and typewriter effects.
7
+ *
8
+ * Text is rendered using SVG <text> elements with <tspan> for line breaks.
9
+ */
10
+
11
+ import type { TextLayer as TextLayerType } from '@bendyline/squisq/schemas';
12
+ import { DEFAULT_DOC_FONT } from '@bendyline/squisq/schemas';
13
+ import { getAnimationStyle } from '../utils/animationUtils';
14
+ import { resolveValue } from '../utils/layerUtils';
15
+
16
+ interface TextLayerProps {
17
+ layer: TextLayerType;
18
+ /** Viewport dimensions for percentage calculations */
19
+ viewport: { width: number; height: number };
20
+ /** Current time relative to block start */
21
+ blockTime: number;
22
+ }
23
+
24
+ export function TextLayer({ layer, viewport, blockTime }: TextLayerProps) {
25
+ const { content, position, animation } = layer;
26
+ const { text, style } = content;
27
+
28
+ // Resolve position values to pixels
29
+ const x = resolveValue(position.x, viewport.width);
30
+ const y = resolveValue(position.y, viewport.height);
31
+ const maxWidth = position.width ? resolveValue(position.width, viewport.width) : undefined;
32
+
33
+ // Apply anchor offset for text alignment
34
+ const textAnchor = getTextAnchor(style.textAlign, position.anchor);
35
+ const dominantBaseline = getDominantBaseline(position.anchor);
36
+
37
+ // Get animation styles
38
+ const animStyle = getAnimationStyle(animation, blockTime);
39
+
40
+ // Split text into lines, and wrap if maxWidth is specified
41
+ const rawLines = text.split('\n');
42
+ let lines = maxWidth
43
+ ? rawLines.reduce<string[]>(
44
+ (acc, line) => acc.concat(wrapText(line, style.fontSize, maxWidth)),
45
+ [],
46
+ )
47
+ : rawLines;
48
+
49
+ // Truncate to maxLines if specified
50
+ if (style.maxLines && lines.length > style.maxLines) {
51
+ lines = lines.slice(0, style.maxLines);
52
+ // Add ellipsis to last visible line
53
+ const last = lines[lines.length - 1];
54
+ lines[lines.length - 1] = last.replace(/\s*$/, '') + '...';
55
+ }
56
+ const lineHeight = style.lineHeight || 1.4;
57
+ const lineHeightPx = style.fontSize * lineHeight;
58
+
59
+ // Build text styles
60
+ const textStyles: Record<string, string | number> = {
61
+ fontSize: `${style.fontSize}px`,
62
+ fontFamily: style.fontFamily || DEFAULT_DOC_FONT,
63
+ fontWeight: style.fontWeight || 'normal',
64
+ fill: style.color,
65
+ ...animStyle.style,
66
+ };
67
+
68
+ // Add shadow filter if requested
69
+ const filterId = style.shadow ? `shadow-${layer.id}` : undefined;
70
+
71
+ return (
72
+ <g className={`block-layer block-layer--text ${animStyle.className}`} data-layer-id={layer.id}>
73
+ {/* Shadow filter definition */}
74
+ {style.shadow && (
75
+ <defs>
76
+ <filter id={filterId} x="-20%" y="-20%" width="140%" height="140%">
77
+ <feDropShadow dx="0" dy="2" stdDeviation="3" floodColor="rgba(0,0,0,0.7)" />
78
+ </filter>
79
+ </defs>
80
+ )}
81
+
82
+ {/* Background box if specified */}
83
+ {style.background && (
84
+ <rect
85
+ x={x - (style.padding || 16)}
86
+ y={y - style.fontSize - (style.padding || 16)}
87
+ width={getTextBoxWidth(lines, style) + (style.padding || 16) * 2}
88
+ height={lines.length * lineHeightPx + (style.padding || 16) * 2}
89
+ fill={style.background}
90
+ rx={4}
91
+ ry={4}
92
+ />
93
+ )}
94
+
95
+ {/* Text element with tspans for each line */}
96
+ <text
97
+ x={x}
98
+ y={y}
99
+ textAnchor={textAnchor as 'start' | 'middle' | 'end'}
100
+ dominantBaseline={dominantBaseline as 'text-before-edge' | 'middle' | 'text-after-edge'}
101
+ style={textStyles}
102
+ filter={filterId ? `url(#${filterId})` : undefined}
103
+ >
104
+ {lines.map((line, i) => (
105
+ <tspan key={i} x={x} dy={i === 0 ? 0 : lineHeightPx}>
106
+ {line || '\u00A0'} {/* Non-breaking space for empty lines */}
107
+ </tspan>
108
+ ))}
109
+ </text>
110
+ </g>
111
+ );
112
+ }
113
+
114
+ /**
115
+ * Map text alignment to SVG text-anchor.
116
+ */
117
+ function getTextAnchor(align?: 'left' | 'center' | 'right', anchor?: string): string {
118
+ // Explicit alignment takes precedence
119
+ if (align === 'center') return 'middle';
120
+ if (align === 'right') return 'end';
121
+ if (align === 'left') return 'start';
122
+
123
+ // Otherwise infer from position anchor
124
+ if (anchor?.includes('right')) return 'end';
125
+ if (anchor === 'center') return 'middle';
126
+ return 'start';
127
+ }
128
+
129
+ /**
130
+ * Map position anchor to SVG dominant-baseline.
131
+ */
132
+ function getDominantBaseline(anchor?: string): string {
133
+ if (anchor?.includes('bottom')) return 'text-after-edge';
134
+ if (anchor === 'center') return 'middle';
135
+ return 'text-before-edge';
136
+ }
137
+
138
+ /**
139
+ * Estimate text box width based on content (rough approximation).
140
+ */
141
+ function getTextBoxWidth(lines: string[], style: { fontSize: number }): number {
142
+ const maxLineLength = Math.max(...lines.map((l) => l.length));
143
+ // Rough estimate: average character width is ~0.5 * fontSize
144
+ return maxLineLength * style.fontSize * 0.55;
145
+ }
146
+
147
+ /**
148
+ * Wrap text to fit within a maximum width.
149
+ * Uses character-based estimation for line breaking.
150
+ */
151
+ function wrapText(text: string, fontSize: number, maxWidth: number): string[] {
152
+ if (!text.trim()) return [''];
153
+
154
+ // Estimate characters per line (average char width ~0.5 * fontSize for most fonts)
155
+ const avgCharWidth = fontSize * 0.5;
156
+ const charsPerLine = Math.floor(maxWidth / avgCharWidth);
157
+
158
+ if (charsPerLine <= 0) return [text];
159
+
160
+ const words = text.split(/\s+/);
161
+ const lines: string[] = [];
162
+ let currentLine = '';
163
+
164
+ for (const word of words) {
165
+ const testLine = currentLine ? `${currentLine} ${word}` : word;
166
+
167
+ if (testLine.length <= charsPerLine) {
168
+ currentLine = testLine;
169
+ } else {
170
+ // Current line is full, start new line
171
+ if (currentLine) {
172
+ lines.push(currentLine);
173
+ }
174
+ // Handle words longer than a line
175
+ if (word.length > charsPerLine) {
176
+ // Break long word
177
+ let remaining = word;
178
+ while (remaining.length > charsPerLine) {
179
+ lines.push(remaining.slice(0, charsPerLine));
180
+ remaining = remaining.slice(charsPerLine);
181
+ }
182
+ currentLine = remaining;
183
+ } else {
184
+ currentLine = word;
185
+ }
186
+ }
187
+ }
188
+
189
+ // Add remaining text
190
+ if (currentLine) {
191
+ lines.push(currentLine);
192
+ }
193
+
194
+ return lines.length > 0 ? lines : [''];
195
+ }
196
+
197
+ export default TextLayer;
@@ -0,0 +1,150 @@
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
+ interface VideoLayerProps {
30
+ layer: VideoLayerType;
31
+ /** Base path for resolving relative video URLs */
32
+ basePath: string;
33
+ /** Viewport dimensions for percentage calculations */
34
+ viewport: { width: number; height: number };
35
+ /** Current time relative to block start (for playback sync) */
36
+ blockTime: number;
37
+ /** Whether the doc is currently playing */
38
+ isPlaying?: boolean;
39
+ }
40
+
41
+ export function VideoLayer({
42
+ layer,
43
+ basePath,
44
+ viewport,
45
+ blockTime: _blockTime,
46
+ isPlaying,
47
+ }: VideoLayerProps) {
48
+ const { content, position } = layer;
49
+ const videoRef = useRef<HTMLVideoElement>(null);
50
+ const hasStartedRef = useRef(false);
51
+
52
+ // Resolve position values to pixels
53
+ const x = resolveValue(position.x, viewport.width);
54
+ const y = resolveValue(position.y, viewport.height);
55
+ const width = position.width ? resolveValue(position.width, viewport.width) : viewport.width;
56
+ const height = position.height ? resolveValue(position.height, viewport.height) : viewport.height;
57
+
58
+ // Apply anchor offset
59
+ const offset = getAnchorOffset(position.anchor, width, height);
60
+ const finalX = x + offset.x;
61
+ const finalY = y + offset.y;
62
+
63
+ // Resolve video URL via MediaProvider (if available), falling back to basePath
64
+ const src = useMediaUrl(content.src, basePath);
65
+
66
+ // Always call the hook (Rules of Hooks), but pass empty string when no poster
67
+ const resolvedPoster = useMediaUrl(content.posterSrc || '', basePath);
68
+ const posterSrc = content.posterSrc ? resolvedPoster : undefined;
69
+
70
+ // On mount: seek to clipStart and set up clipEnd boundary.
71
+ // The video will be muted and play silently alongside the narration.
72
+ useEffect(() => {
73
+ const video = videoRef.current;
74
+ if (!video) return;
75
+
76
+ // Set initial time to clipStart
77
+ video.currentTime = content.clipStart;
78
+ hasStartedRef.current = true;
79
+
80
+ // Start playing if doc is already playing
81
+ if (isPlaying) {
82
+ const playPromise = video.play();
83
+ if (playPromise) {
84
+ playPromise.catch(() => {
85
+ // Autoplay blocked — fine for Playwright seekTo mode
86
+ });
87
+ }
88
+ }
89
+
90
+ // Monitor timeupdate to pause at clipEnd
91
+ const handleTimeUpdate = () => {
92
+ if (video.currentTime >= content.clipEnd) {
93
+ video.pause();
94
+ video.currentTime = content.clipEnd;
95
+ }
96
+ };
97
+
98
+ video.addEventListener('timeupdate', handleTimeUpdate);
99
+ return () => {
100
+ video.removeEventListener('timeupdate', handleTimeUpdate);
101
+ video.pause();
102
+ };
103
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- isPlaying is handled by the separate sync effect below
104
+ }, [content.src, content.clipStart, content.clipEnd]);
105
+
106
+ // Sync video play/pause with doc playback state
107
+ useEffect(() => {
108
+ const video = videoRef.current;
109
+ if (!video || !hasStartedRef.current) return;
110
+
111
+ // Don't resume if clip has already reached its end
112
+ if (video.currentTime >= content.clipEnd) return;
113
+
114
+ if (isPlaying) {
115
+ const playPromise = video.play();
116
+ if (playPromise) {
117
+ playPromise.catch(() => {});
118
+ }
119
+ } else {
120
+ video.pause();
121
+ }
122
+ }, [isPlaying, content.clipEnd]);
123
+
124
+ return (
125
+ <g className="block-layer block-layer--video" data-layer-id={layer.id}>
126
+ <foreignObject x={finalX} y={finalY} width={width} height={height}>
127
+ <video
128
+ ref={videoRef}
129
+ src={src}
130
+ poster={posterSrc}
131
+ muted
132
+ playsInline
133
+ preload="auto"
134
+ data-clip-start={content.clipStart}
135
+ data-clip-end={content.clipEnd}
136
+ style={{
137
+ width: '100%',
138
+ height: '100%',
139
+ objectFit: content.fit || 'cover',
140
+ objectPosition: 'center',
141
+ display: 'block',
142
+ pointerEvents: 'none',
143
+ }}
144
+ />
145
+ </foreignObject>
146
+ </g>
147
+ );
148
+ }
149
+
150
+ export default VideoLayer;
@@ -0,0 +1,5 @@
1
+ export { ImageLayer } from './ImageLayer';
2
+ export { TextLayer } from './TextLayer';
3
+ export { ShapeLayer } from './ShapeLayer';
4
+ export { VideoLayer } from './VideoLayer';
5
+ export { MapLayer } from './MapLayer';
@@ -0,0 +1,228 @@
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
+ * SquisqPlayer.mount(document.getElementById('root'), docJson, {
16
+ * mode: 'slideshow',
17
+ * images: { 'hero.jpg': 'data:image/jpeg;base64,...' }
18
+ * });
19
+ * </script>
20
+ */
21
+
22
+ import { createElement } from 'react';
23
+ import { createRoot, type Root } from 'react-dom/client';
24
+ import type { Doc, MediaProvider } from '@bendyline/squisq/schemas';
25
+ import type { Theme } from '@bendyline/squisq/schemas';
26
+ import { DocPlayer } from './DocPlayer';
27
+ import { LinearDocView } from './LinearDocView';
28
+ import { MediaContext } from './hooks/MediaContext';
29
+
30
+ // CSS is loaded as text via esbuild's text loader (configured in tsup.standalone.config.ts)
31
+ // @ts-expect-error — .css import returns a string when esbuild uses 'text' loader (standalone build only)
32
+ import animationCss from './styles/doc-animations.css';
33
+
34
+ // ── Types ──────────────────────────────────────────────────────────
35
+
36
+ export interface MountOptions {
37
+ /** Rendering mode: 'slideshow' (interactive, default) or 'static' (scrollable) */
38
+ mode?: 'slideshow' | 'static';
39
+ /** Base path for resolving relative media URLs */
40
+ basePath?: string;
41
+ /**
42
+ * Map of relative image paths to data URIs or blob URLs.
43
+ * Used in single-HTML exports where images are inlined as base64.
44
+ * Example: { 'hero.jpg': 'data:image/jpeg;base64,...' }
45
+ */
46
+ images?: Record<string, string>;
47
+ /**
48
+ * Map of audio segment names/paths to URLs (data URIs, blob URLs, or relative paths).
49
+ * Used in ZIP exports where audio files are included alongside the HTML.
50
+ */
51
+ audio?: Record<string, string>;
52
+ /** Optional theme override */
53
+ theme?: Theme;
54
+ /** Auto-play on mount (only for slideshow mode, default: false) */
55
+ autoPlay?: boolean;
56
+ }
57
+
58
+ // ── CSS Injection ──────────────────────────────────────────────────
59
+
60
+ let cssInjected = false;
61
+
62
+ function injectCss(): void {
63
+ if (cssInjected || typeof document === 'undefined') return;
64
+ const style = document.createElement('style');
65
+ style.setAttribute('data-squisq-player', 'animations');
66
+ style.textContent = animationCss;
67
+ document.head.appendChild(style);
68
+ cssInjected = true;
69
+ }
70
+
71
+ // ── Inline Media Provider ──────────────────────────────────────────
72
+
73
+ /**
74
+ * Creates a MediaProvider that resolves URLs from an inline image map.
75
+ * Falls back to basePath-based resolution for unknown paths.
76
+ */
77
+ function createInlineMediaProvider(
78
+ images: Record<string, string>,
79
+ basePath: string,
80
+ ): MediaProvider {
81
+ return {
82
+ async resolveUrl(relativePath: string): Promise<string> {
83
+ if (relativePath in images) return images[relativePath];
84
+ // Fallback to basePath
85
+ if (
86
+ relativePath.startsWith('http') ||
87
+ relativePath.startsWith('data:') ||
88
+ relativePath.startsWith('blob:')
89
+ ) {
90
+ return relativePath;
91
+ }
92
+ return `${basePath}/${relativePath}`;
93
+ },
94
+ async listMedia() {
95
+ return Object.keys(images).map((name) => ({
96
+ name,
97
+ mimeType: inferMimeType(name),
98
+ size: 0,
99
+ }));
100
+ },
101
+ async addMedia() {
102
+ throw new Error('Standalone player is read-only');
103
+ },
104
+ async removeMedia() {
105
+ throw new Error('Standalone player is read-only');
106
+ },
107
+ dispose() {
108
+ // no-op
109
+ },
110
+ };
111
+ }
112
+
113
+ function inferMimeType(filename: string): string {
114
+ const ext = filename.split('.').pop()?.toLowerCase() ?? '';
115
+ const map: Record<string, string> = {
116
+ jpg: 'image/jpeg',
117
+ jpeg: 'image/jpeg',
118
+ png: 'image/png',
119
+ gif: 'image/gif',
120
+ webp: 'image/webp',
121
+ svg: 'image/svg+xml',
122
+ mp3: 'audio/mpeg',
123
+ mp4: 'video/mp4',
124
+ webm: 'video/webm',
125
+ };
126
+ return map[ext] ?? 'application/octet-stream';
127
+ }
128
+
129
+ // ── Audio Rewriting ────────────────────────────────────────────────
130
+
131
+ /**
132
+ * Rewrite audio segment URLs in a Doc if an audio map is provided.
133
+ * Returns a shallow-modified copy — does not mutate the original.
134
+ */
135
+ function rewriteAudioUrls(doc: Doc, audioMap: Record<string, string>): Doc {
136
+ if (!doc.audio?.segments?.length) return doc;
137
+
138
+ const segments = doc.audio.segments.map((seg) => {
139
+ const resolved = audioMap[seg.name] ?? audioMap[seg.src] ?? seg.src;
140
+ return { ...seg, src: resolved };
141
+ });
142
+
143
+ return { ...doc, audio: { ...doc.audio, segments } };
144
+ }
145
+
146
+ // ── Root Tracking ──────────────────────────────────────────────────
147
+
148
+ const roots = new WeakMap<Element, Root>();
149
+
150
+ // ── Public API ─────────────────────────────────────────────────────
151
+
152
+ /**
153
+ * Mount a SquisqPlayer into a DOM element.
154
+ *
155
+ * @param element - The DOM element to render into
156
+ * @param doc - A Doc object (parsed JSON)
157
+ * @param options - Rendering options
158
+ */
159
+ export function mount(element: Element, doc: Doc, options: MountOptions = {}): void {
160
+ injectCss();
161
+
162
+ const { mode = 'slideshow', basePath = '.', images, audio, autoPlay = false, theme } = options;
163
+
164
+ // Rewrite audio URLs if map provided
165
+ const finalDoc = audio ? rewriteAudioUrls(doc, audio) : doc;
166
+
167
+ // Build the media provider if images are provided
168
+ const mediaProvider = images ? createInlineMediaProvider(images, basePath) : null;
169
+
170
+ let content: ReturnType<typeof createElement>;
171
+
172
+ if (mode === 'static') {
173
+ content = createElement(LinearDocView, {
174
+ doc: finalDoc,
175
+ basePath,
176
+ theme,
177
+ });
178
+ } else {
179
+ content = createElement(DocPlayer, {
180
+ script: finalDoc,
181
+ basePath,
182
+ displayMode: 'slideshow',
183
+ autoPlay,
184
+ showControls: true,
185
+ theme,
186
+ });
187
+ }
188
+
189
+ // Wrap in MediaContext if provider is available
190
+ if (mediaProvider) {
191
+ content = createElement(MediaContext.Provider, { value: mediaProvider }, content);
192
+ }
193
+
194
+ // Create or reuse React root
195
+ let root = roots.get(element);
196
+ if (!root) {
197
+ root = createRoot(element);
198
+ roots.set(element, root);
199
+ }
200
+ root.render(content);
201
+ }
202
+
203
+ /**
204
+ * Mount a static scrollable document view (alias for mount with mode='static').
205
+ */
206
+ export function mountStatic(
207
+ element: Element,
208
+ doc: Doc,
209
+ options: Omit<MountOptions, 'mode'> = {},
210
+ ): void {
211
+ mount(element, doc, { ...options, mode: 'static' });
212
+ }
213
+
214
+ /**
215
+ * Unmount a previously mounted SquisqPlayer from an element.
216
+ */
217
+ export function unmount(element: Element): void {
218
+ const root = roots.get(element);
219
+ if (root) {
220
+ root.unmount();
221
+ roots.delete(element);
222
+ }
223
+ }
224
+
225
+ /** Package version — injected at build time via esbuild define */
226
+ declare const __SQUISQ_VERSION__: string;
227
+ export const version: string =
228
+ typeof __SQUISQ_VERSION__ !== 'undefined' ? __SQUISQ_VERSION__ : '0.0.0';