@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.
Files changed (45) hide show
  1. package/README.md +30 -3
  2. package/dist/index.d.ts +174 -27
  3. package/dist/index.js +1244 -603
  4. package/dist/index.js.map +1 -1
  5. package/dist/squisq-player.global.js +54 -37
  6. package/dist/squisq-player.global.js.map +1 -1
  7. package/dist/standalone-source.js +1 -1
  8. package/package.json +2 -2
  9. package/src/BlockRenderer.tsx +53 -17
  10. package/src/DocControlsSlideshow.tsx +222 -5
  11. package/src/DocPlayer.tsx +367 -183
  12. package/src/DocPlayerWithSidebar.tsx +4 -0
  13. package/src/DocProgressBar.tsx +40 -1
  14. package/src/LinearDocView.tsx +135 -62
  15. package/src/MarkdownRenderer.tsx +40 -97
  16. package/src/MediaClipLayer.tsx +12 -2
  17. package/src/__tests__/BlockRenderer.test.tsx +79 -8
  18. package/src/__tests__/DocControlsSlideshow.test.tsx +94 -1
  19. package/src/__tests__/DocPlayer.test.tsx +505 -0
  20. package/src/__tests__/DocProgressBar.test.tsx +28 -2
  21. package/src/__tests__/LinearDocView.test.tsx +91 -11
  22. package/src/__tests__/MapLayer.test.tsx +63 -0
  23. package/src/__tests__/MarkdownRenderer.test.tsx +13 -2
  24. package/src/__tests__/MediaClipLayer.test.tsx +70 -0
  25. package/src/__tests__/MediaContext.test.tsx +51 -0
  26. package/src/__tests__/PathLayer.test.tsx +12 -1
  27. package/src/__tests__/VideoLayer.test.tsx +94 -0
  28. package/src/__tests__/fillStyle.test.tsx +3 -2
  29. package/src/__tests__/standaloneEntry.test.tsx +103 -0
  30. package/src/__tests__/useAudioSync.test.ts +49 -0
  31. package/src/__tests__/useDocPlayback.transition.test.ts +48 -5
  32. package/src/__tests__/useViewportOrientation.test.ts +22 -0
  33. package/src/hooks/MediaContext.tsx +12 -3
  34. package/src/hooks/useAudioSync.ts +61 -12
  35. package/src/hooks/useDocPlayback.ts +40 -12
  36. package/src/hooks/useViewportOrientation.ts +2 -4
  37. package/src/index.ts +5 -2
  38. package/src/layers/MapLayer.tsx +7 -6
  39. package/src/layers/PathLayer.tsx +20 -11
  40. package/src/layers/ShapeLayer.tsx +4 -2
  41. package/src/layers/TextLayer.tsx +4 -3
  42. package/src/layers/TreeLayer.tsx +167 -0
  43. package/src/layers/VideoLayer.tsx +20 -6
  44. package/src/standalone-entry.tsx +91 -14
  45. 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.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,
@@ -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.lat,
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={`clip-${layer.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(#clip-${layer.id})`}>
170
+ <g clipPath={`url(#${clipId})`}>
170
171
  <image
171
172
  href={mapImageUrl}
172
173
  x={finalX}
@@ -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` (with the legacy
21
- * `arrow` flag mapping to a filled triangle). Marker geometry comes from
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
- /** Resolve the effective marker for an endpoint (explicit field, else `arrow`). */
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
- arrow: PathLayerType['content']['arrow'],
67
+ legacyArrow: LegacyArrow | undefined,
61
68
  end: 'start' | 'end',
62
69
  ): MarkerStyle {
63
70
  if (explicit) return explicit;
64
- const wants = arrow === 'both' || arrow === end;
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(id, content.fill ?? 'none', content.gradient);
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-${id}`;
81
- const endId = `marker-end-${id}`;
82
- const start = markerPath(effectiveMarker(content.startMarker, content.arrow, 'start'), 'start');
83
- const end = markerPath(effectiveMarker(content.endMarker, content.arrow, 'end'), 'end');
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
- layer.id,
70
+ defsId,
69
71
  fill,
70
72
  content.gradient,
71
73
  content.pattern,
72
74
  );
73
- const { filterAttr, def: filterDef } = resolveShapeFilter(layer.id, content.filter);
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.
@@ -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-${layer.id}` : undefined;
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={layer.id}
248
+ layerId={defsId}
248
249
  style={style}
249
250
  box={
250
251
  boxWidth != null && boxHeight != null