@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
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* only set it after a `setTimeout`).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { describe, it, expect } from 'vitest';
|
|
14
|
-
import { renderHook } from '@testing-library/react';
|
|
13
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
14
|
+
import { act, renderHook } from '@testing-library/react';
|
|
15
15
|
import { VIEWPORT_PRESETS } from '@bendyline/squisq/doc';
|
|
16
16
|
import type { Doc, Block } from '@bendyline/squisq/schemas';
|
|
17
17
|
import { useDocPlayback } from '../hooks/useDocPlayback';
|
|
@@ -37,7 +37,7 @@ const doc: Doc = {
|
|
|
37
37
|
describe('useDocPlayback — synchronous block transitions', () => {
|
|
38
38
|
it('exposes the entering block + outgoing previousBlock on the same render (no effect flush)', () => {
|
|
39
39
|
const { result, rerender } = renderHook(
|
|
40
|
-
({ t }: { t: number }) => useDocPlayback(doc, t, VIEWPORT_PRESETS.landscape),
|
|
40
|
+
({ t }: { t: number }) => useDocPlayback(doc, t, { viewport: VIEWPORT_PRESETS.landscape }),
|
|
41
41
|
{ initialProps: { t: 0 } },
|
|
42
42
|
);
|
|
43
43
|
|
|
@@ -61,10 +61,53 @@ describe('useDocPlayback — synchronous block transitions', () => {
|
|
|
61
61
|
|
|
62
62
|
it('isEntering is a pure function of blockTime vs the transition duration', () => {
|
|
63
63
|
const enteringAt = (t: number) =>
|
|
64
|
-
renderHook(() => useDocPlayback(doc, t, VIEWPORT_PRESETS.landscape)).result
|
|
65
|
-
.isEntering;
|
|
64
|
+
renderHook(() => useDocPlayback(doc, t, { viewport: VIEWPORT_PRESETS.landscape })).result
|
|
65
|
+
.current.isEntering;
|
|
66
66
|
expect(enteringAt(5.0)).toBe(true); // blockTime 0.0 < 0.5
|
|
67
67
|
expect(enteringAt(5.4)).toBe(true); // blockTime 0.4 < 0.5
|
|
68
68
|
expect(enteringAt(5.6)).toBe(false); // blockTime 0.6 >= 0.5
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
it('can enter a swipe destination without restoring the outgoing block', () => {
|
|
72
|
+
const { result, rerender } = renderHook(
|
|
73
|
+
({ t }: { t: number }) => useDocPlayback(doc, t, { viewport: VIEWPORT_PRESETS.landscape }),
|
|
74
|
+
{ initialProps: { t: 0 } },
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
act(() => result.current.suppressOutgoingForNextBlock('b'));
|
|
78
|
+
rerender({ t: 5 });
|
|
79
|
+
|
|
80
|
+
// The destination still gets its own entrance animation, but the slide
|
|
81
|
+
// already carried away by the swipe is not mounted again as context.
|
|
82
|
+
expect(result.current.isEntering).toBe(true);
|
|
83
|
+
expect(result.current.isExiting).toBe(false);
|
|
84
|
+
expect(result.current.previousBlock).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('clears stale outgoing context when a cover reveals the already-active block', () => {
|
|
88
|
+
const { result, rerender } = renderHook(
|
|
89
|
+
({ t }: { t: number }) => useDocPlayback(doc, t, { viewport: VIEWPORT_PRESETS.landscape }),
|
|
90
|
+
{ initialProps: { t: 0 } },
|
|
91
|
+
);
|
|
92
|
+
rerender({ t: 5 });
|
|
93
|
+
expect(result.current.previousBlock?.id).toBe('a');
|
|
94
|
+
|
|
95
|
+
act(() => result.current.suppressOutgoingForNextBlock('b'));
|
|
96
|
+
rerender({ t: 5 });
|
|
97
|
+
|
|
98
|
+
expect(result.current.isEntering).toBe(true);
|
|
99
|
+
expect(result.current.isExiting).toBe(false);
|
|
100
|
+
expect(result.current.previousBlock).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('navigation actions seek to the target block', () => {
|
|
104
|
+
const seek = vi.fn();
|
|
105
|
+
const { result } = renderHook(() =>
|
|
106
|
+
useDocPlayback(doc, 0, { viewport: VIEWPORT_PRESETS.landscape, onSeek: seek }),
|
|
107
|
+
);
|
|
108
|
+
act(() => result.current.nextBlock());
|
|
109
|
+
expect(seek).toHaveBeenCalledWith(5);
|
|
110
|
+
act(() => result.current.goToBlock(0));
|
|
111
|
+
expect(seek).toHaveBeenLastCalledWith(0);
|
|
112
|
+
});
|
|
70
113
|
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react';
|
|
2
|
+
import { VIEWPORT_PRESETS } from '@bendyline/squisq/doc';
|
|
3
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
4
|
+
import { useViewportOrientation } from '../hooks/useViewportOrientation';
|
|
5
|
+
|
|
6
|
+
const originalWidth = window.innerWidth;
|
|
7
|
+
const originalHeight = window.innerHeight;
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalWidth });
|
|
11
|
+
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalHeight });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('useViewportOrientation', () => {
|
|
15
|
+
it('uses the square preset for near-square viewports', () => {
|
|
16
|
+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1000 });
|
|
17
|
+
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 1000 });
|
|
18
|
+
const { result } = renderHook(() => useViewportOrientation());
|
|
19
|
+
expect(result.current.orientation).toBe('square');
|
|
20
|
+
expect(result.current.viewport).toBe(VIEWPORT_PRESETS.square);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -73,9 +73,18 @@ export function useMediaUrl(relativePath: string, basePath: string): string {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
let cancelled = false;
|
|
76
|
-
provider
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
// Never show the prior asset while a new provider/path is resolving.
|
|
77
|
+
setUrl(fallback);
|
|
78
|
+
provider!.resolveUrl(safePath).then(
|
|
79
|
+
(resolved) => {
|
|
80
|
+
if (!cancelled) setUrl(resolved);
|
|
81
|
+
},
|
|
82
|
+
() => {
|
|
83
|
+
// Resolution failures should be non-fatal and must not become
|
|
84
|
+
// unhandled promise rejections in rendering surfaces.
|
|
85
|
+
if (!cancelled) setUrl(fallback);
|
|
86
|
+
},
|
|
87
|
+
);
|
|
79
88
|
|
|
80
89
|
return () => {
|
|
81
90
|
cancelled = true;
|
|
@@ -17,10 +17,19 @@ import type { RefObject } from 'react';
|
|
|
17
17
|
import type { AudioTrack } from '@bendyline/squisq/schemas';
|
|
18
18
|
import type { AudioController } from './AudioController';
|
|
19
19
|
|
|
20
|
+
function resolveAudioUrl(src: string, basePath: string): string {
|
|
21
|
+
// Preserve absolute/protocol-relative/data/blob URLs. Prefixing an absolute
|
|
22
|
+
// URL with the common default base path (`.`) produces `./https://...`.
|
|
23
|
+
if (!src || /^(?:[a-z][a-z0-9+.-]*:|\/\/|\/)/i.test(src)) return src;
|
|
24
|
+
if (!basePath) return src;
|
|
25
|
+
return `${basePath.replace(/\/$/, '')}/${src.replace(/^\//, '')}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
20
28
|
export function useAudioSync(
|
|
21
29
|
audioRef: RefObject<HTMLAudioElement>,
|
|
22
30
|
audioTrack: AudioTrack | undefined,
|
|
23
31
|
basePath: string = '',
|
|
32
|
+
enabled: boolean = true,
|
|
24
33
|
): AudioController {
|
|
25
34
|
const [currentTime, setCurrentTime] = useState(0);
|
|
26
35
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
@@ -39,13 +48,27 @@ export function useAudioSync(
|
|
|
39
48
|
// Preloaded audio blob URLs (for seeking without range request support)
|
|
40
49
|
const blobUrls = useRef<Map<string, string>>(new Map());
|
|
41
50
|
const loadingPromises = useRef<Map<string, Promise<string>>>(new Map());
|
|
51
|
+
const abortControllers = useRef<Set<AbortController>>(new Set());
|
|
52
|
+
const loadGeneration = useRef(0);
|
|
42
53
|
|
|
43
54
|
// Fallback timer: when audio.play() is blocked (e.g., autoplay policy),
|
|
44
55
|
// advance currentTime synthetically so blocks still progress without audio.
|
|
45
56
|
const fallbackMode = useRef(false);
|
|
46
57
|
|
|
47
58
|
useEffect(() => {
|
|
48
|
-
|
|
59
|
+
loadGeneration.current += 1;
|
|
60
|
+
pendingSeekTime.current = null;
|
|
61
|
+
shouldPlayAfterLoad.current = false;
|
|
62
|
+
fallbackMode.current = false;
|
|
63
|
+
setCurrentTime(0);
|
|
64
|
+
setCurrentSegment(0);
|
|
65
|
+
setIsPlaying(false);
|
|
66
|
+
setIsEnded(false);
|
|
67
|
+
setIsAudioReady(false);
|
|
68
|
+
|
|
69
|
+
if (!enabled || !audioTrack?.segments) {
|
|
70
|
+
segmentStarts.current = [];
|
|
71
|
+
setTotalDuration(0);
|
|
49
72
|
return;
|
|
50
73
|
}
|
|
51
74
|
|
|
@@ -56,12 +79,12 @@ export function useAudioSync(
|
|
|
56
79
|
return start;
|
|
57
80
|
});
|
|
58
81
|
setTotalDuration(time);
|
|
59
|
-
}, [audioTrack]);
|
|
82
|
+
}, [audioTrack, enabled]);
|
|
60
83
|
|
|
61
84
|
// Preload audio file as blob (enables seeking without range request support)
|
|
62
85
|
const preloadAudio = useCallback(
|
|
63
86
|
async (src: string): Promise<string> => {
|
|
64
|
-
const audioUrl =
|
|
87
|
+
const audioUrl = resolveAudioUrl(src, basePath);
|
|
65
88
|
|
|
66
89
|
// Return cached blob URL if available
|
|
67
90
|
if (blobUrls.current.has(src)) {
|
|
@@ -74,18 +97,26 @@ export function useAudioSync(
|
|
|
74
97
|
}
|
|
75
98
|
|
|
76
99
|
// Start loading
|
|
100
|
+
const controller = new AbortController();
|
|
101
|
+
abortControllers.current.add(controller);
|
|
102
|
+
const generation = loadGeneration.current;
|
|
77
103
|
const loadPromise = (async () => {
|
|
78
104
|
try {
|
|
79
|
-
const response = await fetch(audioUrl);
|
|
105
|
+
const response = await fetch(audioUrl, { signal: controller.signal });
|
|
80
106
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
81
107
|
const blob = await response.blob();
|
|
82
108
|
const blobUrl = URL.createObjectURL(blob);
|
|
109
|
+
if (controller.signal.aborted || generation !== loadGeneration.current) {
|
|
110
|
+
URL.revokeObjectURL(blobUrl);
|
|
111
|
+
return audioUrl;
|
|
112
|
+
}
|
|
83
113
|
blobUrls.current.set(src, blobUrl);
|
|
84
114
|
return blobUrl;
|
|
85
115
|
} catch {
|
|
86
116
|
// Fall back to direct URL if blob loading fails
|
|
87
117
|
return audioUrl;
|
|
88
118
|
} finally {
|
|
119
|
+
abortControllers.current.delete(controller);
|
|
89
120
|
loadingPromises.current.delete(src);
|
|
90
121
|
}
|
|
91
122
|
})();
|
|
@@ -98,7 +129,7 @@ export function useAudioSync(
|
|
|
98
129
|
|
|
99
130
|
// Preload all audio segments on mount
|
|
100
131
|
useEffect(() => {
|
|
101
|
-
if (!audioTrack?.segments) return;
|
|
132
|
+
if (!enabled || !audioTrack?.segments) return;
|
|
102
133
|
|
|
103
134
|
// Preload all segments in parallel
|
|
104
135
|
audioTrack.segments.forEach((segment) => {
|
|
@@ -107,16 +138,23 @@ export function useAudioSync(
|
|
|
107
138
|
|
|
108
139
|
// Cleanup blob URLs on unmount
|
|
109
140
|
const currentBlobUrls = blobUrls.current;
|
|
141
|
+
const currentAbortControllers = abortControllers.current;
|
|
142
|
+
const currentLoadingPromises = loadingPromises.current;
|
|
110
143
|
return () => {
|
|
144
|
+
loadGeneration.current += 1;
|
|
145
|
+
currentAbortControllers.forEach((controller) => controller.abort());
|
|
146
|
+
currentAbortControllers.clear();
|
|
147
|
+
currentLoadingPromises.clear();
|
|
111
148
|
currentBlobUrls.forEach((url) => {
|
|
112
149
|
URL.revokeObjectURL(url);
|
|
113
150
|
});
|
|
114
151
|
currentBlobUrls.clear();
|
|
115
152
|
};
|
|
116
|
-
}, [audioTrack, preloadAudio]);
|
|
153
|
+
}, [audioTrack, preloadAudio, enabled]);
|
|
117
154
|
|
|
118
155
|
// Handle audio time updates
|
|
119
156
|
useEffect(() => {
|
|
157
|
+
if (!enabled) return;
|
|
120
158
|
const audio = audioRef.current;
|
|
121
159
|
if (!audio) return;
|
|
122
160
|
|
|
@@ -176,10 +214,11 @@ export function useAudioSync(
|
|
|
176
214
|
audio.removeEventListener('ended', handleEnded);
|
|
177
215
|
audio.removeEventListener('error', handleError);
|
|
178
216
|
};
|
|
179
|
-
}, [audioRef, currentSegment, audioTrack]);
|
|
217
|
+
}, [audioRef, currentSegment, audioTrack, enabled]);
|
|
180
218
|
|
|
181
219
|
// Load new segment when currentSegment changes
|
|
182
220
|
useEffect(() => {
|
|
221
|
+
if (!enabled) return;
|
|
183
222
|
const audio = audioRef.current;
|
|
184
223
|
if (!audio || !audioTrack?.segments) return;
|
|
185
224
|
|
|
@@ -209,15 +248,20 @@ export function useAudioSync(
|
|
|
209
248
|
const isSameSource =
|
|
210
249
|
currentSrc && (currentSrc === cachedBlobUrl || currentSrc.endsWith(segment.src));
|
|
211
250
|
|
|
251
|
+
let cancelled = false;
|
|
252
|
+
let handleCanPlay: (() => void) | null = null;
|
|
253
|
+
|
|
212
254
|
if (!isSameSource) {
|
|
213
255
|
// Need to load new source - use preloaded blob URL
|
|
214
256
|
const loadAndPlay = async () => {
|
|
215
257
|
const blobUrl = await preloadAudio(segment.src);
|
|
258
|
+
if (cancelled) return;
|
|
216
259
|
|
|
217
|
-
|
|
260
|
+
handleCanPlay = () => {
|
|
261
|
+
if (cancelled) return;
|
|
218
262
|
setIsAudioReady(true);
|
|
219
263
|
applyPendingSeek();
|
|
220
|
-
audio.removeEventListener('canplay', handleCanPlay);
|
|
264
|
+
if (handleCanPlay) audio.removeEventListener('canplay', handleCanPlay);
|
|
221
265
|
};
|
|
222
266
|
|
|
223
267
|
audio.addEventListener('canplay', handleCanPlay);
|
|
@@ -228,18 +272,23 @@ export function useAudioSync(
|
|
|
228
272
|
// Check after a microtask to see if it's ready
|
|
229
273
|
await Promise.resolve();
|
|
230
274
|
if (audio.readyState >= 3) {
|
|
231
|
-
audio.removeEventListener('canplay', handleCanPlay);
|
|
275
|
+
if (handleCanPlay) audio.removeEventListener('canplay', handleCanPlay);
|
|
232
276
|
setIsAudioReady(true);
|
|
233
277
|
applyPendingSeek();
|
|
234
278
|
}
|
|
235
279
|
};
|
|
236
280
|
|
|
237
|
-
loadAndPlay();
|
|
281
|
+
void loadAndPlay();
|
|
238
282
|
} else {
|
|
239
283
|
// Same source - apply seek directly
|
|
240
284
|
applyPendingSeek();
|
|
241
285
|
}
|
|
242
|
-
|
|
286
|
+
|
|
287
|
+
return () => {
|
|
288
|
+
cancelled = true;
|
|
289
|
+
if (handleCanPlay) audio.removeEventListener('canplay', handleCanPlay);
|
|
290
|
+
};
|
|
291
|
+
}, [audioRef, currentSegment, audioTrack, preloadAudio, enabled]);
|
|
243
292
|
|
|
244
293
|
const play = useCallback(() => {
|
|
245
294
|
const audio = audioRef.current;
|
|
@@ -58,18 +58,28 @@ interface PlaybackActions {
|
|
|
58
58
|
prevBlock: () => void;
|
|
59
59
|
/** Go to specific block by index */
|
|
60
60
|
goToBlock: (index: number) => void;
|
|
61
|
+
/**
|
|
62
|
+
* Let the identified block enter without remounting the outgoing block.
|
|
63
|
+
* Used when another interaction (such as a swipe) already removed it.
|
|
64
|
+
*/
|
|
65
|
+
suppressOutgoingForNextBlock: (blockId: string) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface UseDocPlaybackOptions {
|
|
69
|
+
/** Target viewport used to materialize template blocks. */
|
|
70
|
+
viewport?: ViewportConfig;
|
|
71
|
+
/** Active theme used for materialization and transition defaults. */
|
|
72
|
+
theme?: Theme;
|
|
73
|
+
/** Host seek callback used by block navigation actions. */
|
|
74
|
+
onSeek?: (time: number) => void;
|
|
61
75
|
}
|
|
62
76
|
|
|
63
77
|
export function useDocPlayback(
|
|
64
78
|
script: Doc | null,
|
|
65
79
|
currentTime: number,
|
|
66
|
-
|
|
67
|
-
renderMode: boolean = false,
|
|
68
|
-
theme?: Theme,
|
|
80
|
+
options: UseDocPlaybackOptions = {},
|
|
69
81
|
): PlaybackState & PlaybackActions {
|
|
70
|
-
|
|
71
|
-
// are now computed identically for real-time and render (export) modes.
|
|
72
|
-
void renderMode;
|
|
82
|
+
const { viewport = VIEWPORT_PRESETS.landscape, theme, onSeek } = options;
|
|
73
83
|
// Expand any template blocks into full blocks
|
|
74
84
|
const blocks = useMemo(() => {
|
|
75
85
|
if (!script?.blocks) {
|
|
@@ -174,8 +184,28 @@ export function useDocPlayback(
|
|
|
174
184
|
const outgoingBlockRef = useRef<Block | null>(null);
|
|
175
185
|
const activeBlockIdRef = useRef<string | null>(null);
|
|
176
186
|
const lastRenderedBlockRef = useRef<Block | null>(null);
|
|
187
|
+
const suppressOutgoingTargetRef = useRef<string | null>(null);
|
|
188
|
+
const suppressOutgoingForNextBlock = useCallback((blockId: string) => {
|
|
189
|
+
// The managed cover is outside the document timeline, so revealing block
|
|
190
|
+
// one can target the block that is already active underneath it. Clear any
|
|
191
|
+
// stale outgoing context immediately; the cover visibility update will
|
|
192
|
+
// provide the render that observes this ref change.
|
|
193
|
+
if (activeBlockIdRef.current === blockId) {
|
|
194
|
+
outgoingBlockRef.current = null;
|
|
195
|
+
suppressOutgoingTargetRef.current = null;
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
suppressOutgoingTargetRef.current = blockId;
|
|
199
|
+
}, []);
|
|
177
200
|
if (currentBlock && currentBlock.id !== activeBlockIdRef.current) {
|
|
178
|
-
|
|
201
|
+
// A swipe has already carried the outgoing slide fully off-screen. Keep
|
|
202
|
+
// the incoming block's own transition, but do not re-mount the old block
|
|
203
|
+
// as transition context when the armed destination becomes active.
|
|
204
|
+
const suppressOutgoing = suppressOutgoingTargetRef.current === currentBlock.id;
|
|
205
|
+
outgoingBlockRef.current = suppressOutgoing ? null : lastRenderedBlockRef.current;
|
|
206
|
+
// Consume on the first real block change even if the destination did not
|
|
207
|
+
// match (for example, a host performed a different seek in between).
|
|
208
|
+
suppressOutgoingTargetRef.current = null;
|
|
179
209
|
activeBlockIdRef.current = currentBlock.id;
|
|
180
210
|
}
|
|
181
211
|
lastRenderedBlockRef.current = currentBlock;
|
|
@@ -194,15 +224,12 @@ export function useDocPlayback(
|
|
|
194
224
|
const goToBlock = useCallback(
|
|
195
225
|
(index: number) => {
|
|
196
226
|
if (!script || index < 0 || index >= blocks.length) return;
|
|
197
|
-
// This would need to be coordinated with seekTo from audio sync
|
|
198
|
-
// For now, just return the target block's start time
|
|
199
227
|
const targetBlock = blocks[index];
|
|
200
228
|
if (targetBlock) {
|
|
201
|
-
|
|
202
|
-
return targetBlock.startTime;
|
|
229
|
+
onSeek?.(targetBlock.startTime);
|
|
203
230
|
}
|
|
204
231
|
},
|
|
205
|
-
[script, blocks],
|
|
232
|
+
[script, blocks, onSeek],
|
|
206
233
|
);
|
|
207
234
|
|
|
208
235
|
const nextBlock = useCallback(() => {
|
|
@@ -229,6 +256,7 @@ export function useDocPlayback(
|
|
|
229
256
|
nextBlock,
|
|
230
257
|
prevBlock,
|
|
231
258
|
goToBlock,
|
|
259
|
+
suppressOutgoingForNextBlock,
|
|
232
260
|
/** Expanded blocks (templates converted to full blocks with layers) */
|
|
233
261
|
blocks,
|
|
234
262
|
};
|
|
@@ -36,15 +36,13 @@ function getOrientationFromWindow(width: number, height: number): ViewportOrient
|
|
|
36
36
|
// Use thresholds to determine orientation
|
|
37
37
|
// - Ratio > 1.2 = landscape (wider than tall)
|
|
38
38
|
// - Ratio < 0.83 (1/1.2) = portrait (taller than wide)
|
|
39
|
-
// - Otherwise = square-ish
|
|
39
|
+
// - Otherwise = square-ish
|
|
40
40
|
if (ratio > 1.2) {
|
|
41
41
|
return 'landscape';
|
|
42
42
|
} else if (ratio < 0.83) {
|
|
43
43
|
return 'portrait';
|
|
44
44
|
} else {
|
|
45
|
-
|
|
46
|
-
// Could also use 'square' preset if available and desired
|
|
47
|
-
return 'landscape';
|
|
45
|
+
return 'square';
|
|
48
46
|
}
|
|
49
47
|
}
|
|
50
48
|
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Main components
|
|
2
2
|
export { DocPlayer } from './DocPlayer.js';
|
|
3
|
-
export {
|
|
3
|
+
export type { DocPlayerProps } from './DocPlayer.js';
|
|
4
|
+
export type { MountOptions, SquisqPlayerHandle } from './standalone-entry.js';
|
|
5
|
+
export { BlockRenderer } from './BlockRenderer.js';
|
|
4
6
|
export { CaptionOverlay } from './CaptionOverlay.js';
|
|
5
7
|
export { SocialCaptionOverlay } from './SocialCaptionOverlay.js';
|
|
6
8
|
export { DocControlsOverlay } from './DocControlsOverlay.js';
|
|
@@ -24,6 +26,7 @@ export { ShapeLayer } from './layers/ShapeLayer.js';
|
|
|
24
26
|
export { PathLayer } from './layers/PathLayer.js';
|
|
25
27
|
export { VideoLayer } from './layers/VideoLayer.js';
|
|
26
28
|
export { TableLayer } from './layers/TableLayer.js';
|
|
29
|
+
export { TreeLayer } from './layers/TreeLayer.js';
|
|
27
30
|
export { MapLayer } from './layers/MapLayer.js';
|
|
28
31
|
|
|
29
32
|
// Timed media clips (block.media + doc.documentMedia playback)
|
|
@@ -35,6 +38,7 @@ export { useAudioSync } from './hooks/useAudioSync.js';
|
|
|
35
38
|
export { useMediaSchedule } from './hooks/useMediaSchedule.js';
|
|
36
39
|
export type { MediaScheduleController } from './hooks/useMediaSchedule.js';
|
|
37
40
|
export { useDocPlayback } from './hooks/useDocPlayback.js';
|
|
41
|
+
export type { UseDocPlaybackOptions } from './hooks/useDocPlayback.js';
|
|
38
42
|
export { useViewportOrientation } from './hooks/useViewportOrientation.js';
|
|
39
43
|
export { MediaContext, useMediaProvider, useMediaUrl } from './hooks/MediaContext.js';
|
|
40
44
|
export { useAutoSurface } from './hooks/useAutoSurface.js';
|
|
@@ -51,7 +55,6 @@ export type {
|
|
|
51
55
|
CaptionMode,
|
|
52
56
|
SlideNavActions,
|
|
53
57
|
SquisqRenderAPI,
|
|
54
|
-
SquisqWindow,
|
|
55
58
|
RenderBlockInfo,
|
|
56
59
|
RenderAudioSegmentInfo,
|
|
57
60
|
RenderCaptionInfo,
|
package/src/layers/MapLayer.tsx
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* Playwright screenshot contexts.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { useState, useEffect } from 'react';
|
|
15
|
+
import { useId, useState, useEffect } from 'react';
|
|
16
16
|
import type { MapLayer as MapLayerType } from '@bendyline/squisq/schemas';
|
|
17
17
|
import { getAnimationStyle } from '../utils/animationUtils';
|
|
18
18
|
import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
|
|
@@ -30,6 +30,7 @@ interface MapLayerProps {
|
|
|
30
30
|
|
|
31
31
|
export function MapLayer({ layer, basePath, viewport, blockTime }: MapLayerProps) {
|
|
32
32
|
const { content, position, animation } = layer;
|
|
33
|
+
const clipId = `map-clip-${useId().replace(/:/g, '')}-${layer.id}`;
|
|
33
34
|
const [mapImageUrl, setMapImageUrl] = useState<string | null>(null);
|
|
34
35
|
const [isLoading, setIsLoading] = useState(true);
|
|
35
36
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -89,13 +90,13 @@ export function MapLayer({ layer, basePath, viewport, blockTime }: MapLayerProps
|
|
|
89
90
|
return () => {
|
|
90
91
|
cancelled = true;
|
|
91
92
|
};
|
|
92
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- content properties are destructured below; center/markers/showAttribution are stable per-render
|
|
93
93
|
}, [
|
|
94
|
-
content.center
|
|
95
|
-
content.center.lng,
|
|
94
|
+
content.center,
|
|
96
95
|
content.zoom,
|
|
97
96
|
content.style,
|
|
98
97
|
content.staticSrc,
|
|
98
|
+
content.markers,
|
|
99
|
+
content.showAttribution,
|
|
99
100
|
width,
|
|
100
101
|
height,
|
|
101
102
|
basePath,
|
|
@@ -160,13 +161,13 @@ export function MapLayer({ layer, basePath, viewport, blockTime }: MapLayerProps
|
|
|
160
161
|
>
|
|
161
162
|
{/* Clip path for overflow handling */}
|
|
162
163
|
<defs>
|
|
163
|
-
<clipPath id={
|
|
164
|
+
<clipPath id={clipId}>
|
|
164
165
|
<rect x={finalX} y={finalY} width={width} height={height} />
|
|
165
166
|
</clipPath>
|
|
166
167
|
</defs>
|
|
167
168
|
|
|
168
169
|
{/* Map image */}
|
|
169
|
-
<g clipPath={`url(
|
|
170
|
+
<g clipPath={`url(#${clipId})`}>
|
|
170
171
|
<image
|
|
171
172
|
href={mapImageUrl}
|
|
172
173
|
x={finalX}
|
package/src/layers/PathLayer.tsx
CHANGED
|
@@ -17,11 +17,11 @@
|
|
|
17
17
|
* rect/circle/line `ShapeLayer` behaves — rather than pinned to a baked
|
|
18
18
|
* absolute path.
|
|
19
19
|
*
|
|
20
|
-
* End markers are configured via `startMarker`/`endMarker
|
|
21
|
-
* `
|
|
22
|
-
* `markerPath` in core so the SSR renderer and the editor agree.
|
|
20
|
+
* End markers are configured via `startMarker`/`endMarker`. Marker geometry
|
|
21
|
+
* comes from `markerPath` in core so the SSR renderer and the editor agree.
|
|
23
22
|
*/
|
|
24
23
|
|
|
24
|
+
import { useId } from 'react';
|
|
25
25
|
import type { PathLayer as PathLayerType, MarkerStyle } from '@bendyline/squisq/schemas';
|
|
26
26
|
import { markerPath, shapePath } from '@bendyline/squisq/doc';
|
|
27
27
|
import { getAnimationStyle } from '../utils/animationUtils';
|
|
@@ -54,33 +54,42 @@ function effectivePath(layer: PathLayerType, viewport: { width: number; height:
|
|
|
54
54
|
return derived ?? content.d;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
type LegacyArrow = 'none' | 'end' | 'start' | 'both';
|
|
58
|
+
|
|
59
|
+
/** Tolerant-reader support for documents authored before endpoint markers. */
|
|
60
|
+
function readLegacyArrow(content: PathLayerType['content']): LegacyArrow | undefined {
|
|
61
|
+
return (content as PathLayerType['content'] & { arrow?: LegacyArrow }).arrow;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Resolve the effective marker for an endpoint. */
|
|
58
65
|
function effectiveMarker(
|
|
59
66
|
explicit: MarkerStyle | undefined,
|
|
60
|
-
|
|
67
|
+
legacyArrow: LegacyArrow | undefined,
|
|
61
68
|
end: 'start' | 'end',
|
|
62
69
|
): MarkerStyle {
|
|
63
70
|
if (explicit) return explicit;
|
|
64
|
-
const wants =
|
|
71
|
+
const wants = legacyArrow === 'both' || legacyArrow === end;
|
|
65
72
|
return wants ? 'arrow' : 'none';
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
export function PathLayer({ layer, viewport, blockTime }: PathLayerProps) {
|
|
69
76
|
const { content, animation, id } = layer;
|
|
77
|
+
const defsId = `${useId().replace(/:/g, '')}-${id}`;
|
|
70
78
|
const d = effectivePath(layer, viewport);
|
|
71
79
|
const stroke = content.stroke ?? '#1e293b';
|
|
72
80
|
const strokeWidth = content.strokeWidth ?? 2;
|
|
73
|
-
const { fill, def: fillDef } = resolveFill(
|
|
81
|
+
const { fill, def: fillDef } = resolveFill(defsId, content.fill ?? 'none', content.gradient);
|
|
74
82
|
// `borderStyle` (named shapes) takes precedence over a raw `dasharray`.
|
|
75
83
|
const dash = content.borderStyle
|
|
76
84
|
? borderDashArray(content.borderStyle, strokeWidth)
|
|
77
85
|
: content.dasharray;
|
|
78
86
|
const animStyle = getAnimationStyle(animation, blockTime);
|
|
79
87
|
|
|
80
|
-
const startId = `marker-start-${
|
|
81
|
-
const endId = `marker-end-${
|
|
82
|
-
const
|
|
83
|
-
const
|
|
88
|
+
const startId = `marker-start-${defsId}`;
|
|
89
|
+
const endId = `marker-end-${defsId}`;
|
|
90
|
+
const legacyArrow = readLegacyArrow(content);
|
|
91
|
+
const start = markerPath(effectiveMarker(content.startMarker, legacyArrow, 'start'), 'start');
|
|
92
|
+
const end = markerPath(effectiveMarker(content.endMarker, legacyArrow, 'end'), 'end');
|
|
84
93
|
|
|
85
94
|
return (
|
|
86
95
|
<g
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Useful for visual accents, dividers, and background elements.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { useId } from 'react';
|
|
8
9
|
import type { ShapeLayer as ShapeLayerType } from '@bendyline/squisq/schemas';
|
|
9
10
|
import { getAnimationStyle } from '../utils/animationUtils';
|
|
10
11
|
import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
|
|
@@ -20,6 +21,7 @@ interface ShapeLayerProps {
|
|
|
20
21
|
|
|
21
22
|
export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
|
|
22
23
|
const { content, position, animation } = layer;
|
|
24
|
+
const defsId = `${useId().replace(/:/g, '')}-${layer.id}`;
|
|
23
25
|
|
|
24
26
|
// Resolve position values to pixels
|
|
25
27
|
const rawX = resolveValue(position.x, viewport.width);
|
|
@@ -65,12 +67,12 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
|
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
const { fill: fillValue, def: fillDef } = resolveFill(
|
|
68
|
-
|
|
70
|
+
defsId,
|
|
69
71
|
fill,
|
|
70
72
|
content.gradient,
|
|
71
73
|
content.pattern,
|
|
72
74
|
);
|
|
73
|
-
const { filterAttr, def: filterDef } = resolveShapeFilter(
|
|
75
|
+
const { filterAttr, def: filterDef } = resolveShapeFilter(defsId, content.filter);
|
|
74
76
|
const dash = borderDashArray(content.borderStyle, content.strokeWidth);
|
|
75
77
|
|
|
76
78
|
// Common style props for native SVG shapes. `line` is stroke-only.
|
package/src/layers/TextLayer.tsx
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* PDF bypasses SVG) and already used by Video/Table layers.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { useMemo, type CSSProperties } from 'react';
|
|
19
|
+
import { useId, useMemo, type CSSProperties } from 'react';
|
|
20
20
|
import type { TextLayer as TextLayerType } from '@bendyline/squisq/schemas';
|
|
21
21
|
import { DEFAULT_DOC_FONT } from '@bendyline/squisq/schemas';
|
|
22
22
|
import {
|
|
@@ -165,6 +165,7 @@ function IconTextLayer({ layer, viewport, blockTime }: TextLayerProps) {
|
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
function PlainTextLayer({ layer, viewport, blockTime }: TextLayerProps) {
|
|
168
|
+
const defsId = `${useId().replace(/:/g, '')}-${layer.id}`;
|
|
168
169
|
const { content, position, animation } = layer;
|
|
169
170
|
const { text, style } = content;
|
|
170
171
|
|
|
@@ -225,7 +226,7 @@ function PlainTextLayer({ layer, viewport, blockTime }: TextLayerProps) {
|
|
|
225
226
|
};
|
|
226
227
|
|
|
227
228
|
// Add shadow filter if requested
|
|
228
|
-
const filterId = style.shadow ? `shadow-${
|
|
229
|
+
const filterId = style.shadow ? `shadow-${defsId}` : undefined;
|
|
229
230
|
|
|
230
231
|
return (
|
|
231
232
|
<g className={`block-layer block-layer--text ${animStyle.className}`} data-layer-id={layer.id}>
|
|
@@ -244,7 +245,7 @@ function PlainTextLayer({ layer, viewport, blockTime }: TextLayerProps) {
|
|
|
244
245
|
rectangle. Without a box we fall back to a snug rect hugging the
|
|
245
246
|
text (legacy behavior for point-anchored text). */}
|
|
246
247
|
<TextBox
|
|
247
|
-
layerId={
|
|
248
|
+
layerId={defsId}
|
|
248
249
|
style={style}
|
|
249
250
|
box={
|
|
250
251
|
boxWidth != null && boxHeight != null
|