@bendyline/squisq-react 1.4.2 → 2.0.1

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 (50) hide show
  1. package/README.md +30 -3
  2. package/dist/index.d.ts +177 -28
  3. package/dist/index.js +1330 -611
  4. package/dist/index.js.map +1 -1
  5. package/dist/squisq-player.css +1 -1
  6. package/dist/squisq-player.css.map +1 -1
  7. package/dist/squisq-player.global.js +57 -37
  8. package/dist/squisq-player.global.js.map +1 -1
  9. package/dist/standalone-source.js +1 -1
  10. package/dist/styles/index.css +28 -0
  11. package/package.json +2 -2
  12. package/src/BlockRenderer.tsx +54 -17
  13. package/src/DocControlsSlideshow.tsx +222 -5
  14. package/src/DocPlayer.tsx +367 -183
  15. package/src/DocPlayerWithSidebar.tsx +4 -0
  16. package/src/DocProgressBar.tsx +40 -1
  17. package/src/LinearDocView.tsx +138 -62
  18. package/src/MarkdownRenderer.tsx +40 -97
  19. package/src/MediaClipLayer.tsx +12 -2
  20. package/src/__tests__/BlockRenderer.test.tsx +138 -8
  21. package/src/__tests__/DocControlsSlideshow.test.tsx +94 -1
  22. package/src/__tests__/DocPlayer.test.tsx +505 -0
  23. package/src/__tests__/DocProgressBar.test.tsx +28 -2
  24. package/src/__tests__/LinearDocView.test.tsx +104 -11
  25. package/src/__tests__/MapLayer.test.tsx +63 -0
  26. package/src/__tests__/MarkdownRenderer.test.tsx +16 -5
  27. package/src/__tests__/MediaClipLayer.test.tsx +70 -0
  28. package/src/__tests__/MediaContext.test.tsx +51 -0
  29. package/src/__tests__/PathLayer.test.tsx +12 -1
  30. package/src/__tests__/VideoLayer.test.tsx +94 -0
  31. package/src/__tests__/fillStyle.test.tsx +50 -2
  32. package/src/__tests__/standaloneEntry.test.tsx +103 -0
  33. package/src/__tests__/useAudioSync.test.ts +49 -0
  34. package/src/__tests__/useDocPlayback.transition.test.ts +48 -5
  35. package/src/__tests__/useViewportOrientation.test.ts +22 -0
  36. package/src/hooks/MediaContext.tsx +12 -3
  37. package/src/hooks/useAudioSync.ts +61 -12
  38. package/src/hooks/useDocPlayback.ts +40 -12
  39. package/src/hooks/useViewportOrientation.ts +2 -4
  40. package/src/index.ts +5 -2
  41. package/src/layers/ImageLayer.tsx +106 -1
  42. package/src/layers/MapLayer.tsx +7 -6
  43. package/src/layers/PathLayer.tsx +20 -11
  44. package/src/layers/ShapeLayer.tsx +33 -9
  45. package/src/layers/TextLayer.tsx +4 -3
  46. package/src/layers/TreeLayer.tsx +167 -0
  47. package/src/layers/VideoLayer.tsx +20 -6
  48. package/src/standalone-entry.tsx +91 -14
  49. package/src/styles/doc-animations.css +36 -0
  50. package/src/types.ts +13 -13
@@ -0,0 +1,49 @@
1
+ import { act, renderHook, waitFor } from '@testing-library/react';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import type { AudioTrack } from '@bendyline/squisq/schemas';
4
+ import { useAudioSync } from '../hooks/useAudioSync';
5
+
6
+ const track: AudioTrack = {
7
+ segments: [{ src: 'https://cdn.example.test/a.mp3', name: 'a', duration: 2, startTime: 0 }],
8
+ };
9
+
10
+ afterEach(() => vi.restoreAllMocks());
11
+
12
+ describe('useAudioSync resource loading', () => {
13
+ it('does not prefix absolute URLs and revokes a blob that resolves after cleanup', async () => {
14
+ let resolveFetch!: (value: unknown) => void;
15
+ const fetchPromise = new Promise((resolve) => {
16
+ resolveFetch = resolve;
17
+ });
18
+ const fetchSpy = vi
19
+ .spyOn(globalThis, 'fetch')
20
+ .mockReturnValue(fetchPromise as Promise<Response>);
21
+ const create = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:late');
22
+ const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
23
+ const audioRef = { current: null };
24
+ const { unmount } = renderHook(() => useAudioSync(audioRef, track, '.'));
25
+
26
+ await waitFor(() => expect(fetchSpy).toHaveBeenCalled());
27
+ expect(fetchSpy.mock.calls[0][0]).toBe('https://cdn.example.test/a.mp3');
28
+ unmount();
29
+
30
+ await act(async () => {
31
+ resolveFetch({ ok: true, blob: async () => new Blob(['audio']) });
32
+ await fetchPromise;
33
+ await Promise.resolve();
34
+ await Promise.resolve();
35
+ });
36
+ expect(create).toHaveBeenCalled();
37
+ expect(revoke).toHaveBeenCalledWith('blob:late');
38
+ });
39
+
40
+ it('does not preload when an external controller disables the hook', async () => {
41
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
42
+ ok: true,
43
+ blob: async () => new Blob(['audio']),
44
+ } as Response);
45
+ renderHook(() => useAudioSync({ current: null }, track, '.', false));
46
+ await act(async () => Promise.resolve());
47
+ expect(fetchSpy).not.toHaveBeenCalled();
48
+ });
49
+ });
@@ -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.current
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!.resolveUrl(safePath).then((resolved) => {
77
- if (!cancelled) setUrl(resolved);
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
- if (!audioTrack?.segments) {
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 = basePath ? `${basePath}/${src}` : src;
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
- const handleCanPlay = () => {
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
- }, [audioRef, currentSegment, audioTrack, preloadAudio]);
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
- viewport: ViewportConfig = VIEWPORT_PRESETS.landscape,
67
- renderMode: boolean = false,
68
- theme?: Theme,
80
+ options: UseDocPlaybackOptions = {},
69
81
  ): PlaybackState & PlaybackActions {
70
- // `renderMode` is retained for API/signature compatibility; block transitions
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
- outgoingBlockRef.current = lastRenderedBlockRef.current;
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
- // Caller should use this time with audio seekTo
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, use landscape for better readability
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
- // Near-square viewports: use landscape for better text readability
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 { BlockRenderer, VIEWPORT } from './BlockRenderer.js';
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,
@@ -20,9 +20,17 @@ interface ImageLayerProps {
20
20
  viewport: { width: number; height: number };
21
21
  /** Current time relative to block start (for animation timing) */
22
22
  blockTime: number;
23
+ /** Whether authored and responsive image motion should run. */
24
+ animationsEnabled?: boolean;
23
25
  }
24
26
 
25
- export function ImageLayer({ layer, basePath, viewport, blockTime }: ImageLayerProps) {
27
+ export function ImageLayer({
28
+ layer,
29
+ basePath,
30
+ viewport,
31
+ blockTime,
32
+ animationsEnabled = true,
33
+ }: ImageLayerProps) {
26
34
  const { content, position, animation } = layer;
27
35
 
28
36
  // Resolve position values to pixels
@@ -55,6 +63,61 @@ export function ImageLayer({ layer, basePath, viewport, blockTime }: ImageLayerP
55
63
  // than shifting the entire image container.
56
64
  const isSpatialAnim = animation && SPATIAL_ANIMATION_TYPES.has(animation.type);
57
65
 
66
+ // Wide cover photos lose most of their composition when centered inside a
67
+ // portrait frame. For dominant imagery, scan the cover crop horizontally so
68
+ // the whole width is revealed over time. `object-position` only moves when
69
+ // the fitted image actually has horizontal overflow, so portrait sources stay
70
+ // visually still. This path deliberately avoids the extra Ken Burns scale:
71
+ // cover already scales the source by the minimum amount needed to fit one
72
+ // viewport dimension.
73
+ const usesPortraitPan =
74
+ isCover && shouldUsePortraitPan(viewport, width, height, animationsEnabled, animation);
75
+
76
+ if (usesPortraitPan) {
77
+ const panClass = getPortraitPanClass(animation);
78
+ const panStyle = getPortraitPanStyle(isSpatialAnim ? animation : undefined);
79
+ // Entrance/opacity animations can safely run on the fixed container while
80
+ // object-position pans the image. Spatial transforms are replaced by the
81
+ // pan so they cannot zoom an already aggressive portrait crop.
82
+ const containerAnim = isSpatialAnim ? { className: '', style: {} } : animStyle;
83
+
84
+ return (
85
+ <g
86
+ className={`block-layer block-layer--image ${containerAnim.className}`}
87
+ style={containerAnim.style}
88
+ data-layer-id={layer.id}
89
+ data-image-framing="portrait-pan"
90
+ >
91
+ <foreignObject x={finalX} y={finalY} width={width} height={height}>
92
+ <div
93
+ style={{
94
+ width: `${width}px`,
95
+ height: `${height}px`,
96
+ overflow: 'hidden',
97
+ }}
98
+ >
99
+ <img
100
+ src={src}
101
+ alt={content.alt || ''}
102
+ className={panClass}
103
+ style={{
104
+ width: `${width}px`,
105
+ height: `${height}px`,
106
+ objectFit: 'cover',
107
+ objectPosition: 'center',
108
+ display: 'block',
109
+ pointerEvents: 'none',
110
+ ...(filter ? { filter } : {}),
111
+ ...(content.blur && content.blur > 0 ? { transform: 'scale(1.06)' } : {}),
112
+ ...panStyle,
113
+ }}
114
+ />
115
+ </div>
116
+ </foreignObject>
117
+ </g>
118
+ );
119
+ }
120
+
58
121
  // Ken Burns mode: cover image with spatial animation.
59
122
  // Keep the container static, animate the inner <img> within clipped bounds.
60
123
  if (isCover && isSpatialAnim && animation) {
@@ -166,6 +229,48 @@ function getPreserveAspectRatio(fit?: 'cover' | 'contain' | 'fill'): string {
166
229
  /** Animation types that use CSS transforms (translate/scale). */
167
230
  const SPATIAL_ANIMATION_TYPES = new Set(['panLeft', 'panRight', 'slowZoom', 'zoomIn', 'zoomOut']);
168
231
 
232
+ /** Portrait threshold matches useViewportOrientation's 0.83 aspect cutoff. */
233
+ const PORTRAIT_ASPECT_CUTOFF = 0.83;
234
+
235
+ /**
236
+ * Only auto-pan images that act as the slide's dominant visual. This keeps
237
+ * photo-grid tiles, thumbnails, and small authored insets from all moving at
238
+ * once while covering the full-screen autogenerated layouts.
239
+ */
240
+ function shouldUsePortraitPan(
241
+ viewport: { width: number; height: number },
242
+ layerWidth: number,
243
+ layerHeight: number,
244
+ animationsEnabled: boolean,
245
+ animation: Animation | undefined,
246
+ ): boolean {
247
+ if (!animationsEnabled || animation?.type === 'none') return false;
248
+ if (viewport.width / viewport.height >= PORTRAIT_ASPECT_CUTOFF) return false;
249
+
250
+ return layerWidth >= viewport.width * 0.7 && layerHeight >= viewport.height * 0.7;
251
+ }
252
+
253
+ /**
254
+ * `panLeft` means the image moves left (the framing scans toward its right
255
+ * edge); `panRight` is the reverse. Unspecified motion defaults to revealing
256
+ * the source from left to right.
257
+ */
258
+ function getPortraitPanClass(animation: Animation | undefined): string {
259
+ const pansBack =
260
+ animation?.type === 'panRight' ||
261
+ (animation?.type === 'slowZoom' && animation.panDirection === 'right');
262
+ return pansBack ? 'squisq-image--portrait-pan-left' : 'squisq-image--portrait-pan-right';
263
+ }
264
+
265
+ /** Use authored spatial timing when present, otherwise a slow ambient scan. */
266
+ function getPortraitPanStyle(animation: Animation | undefined): Record<string, string> {
267
+ return {
268
+ '--portrait-pan-duration': `${animation?.duration ?? 12}s`,
269
+ '--portrait-pan-delay': `${animation?.delay ?? 0}s`,
270
+ '--portrait-pan-easing': animation?.easing ?? 'ease-in-out',
271
+ };
272
+ }
273
+
169
274
  /**
170
275
  * Remap animation for Ken Burns inner-image rendering.
171
276
  *