@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
@@ -135,6 +135,22 @@
135
135
  transform: translateX(0);
136
136
  }
137
137
  }
138
+ @keyframes portraitCoverPanRight {
139
+ from {
140
+ object-position: left center;
141
+ }
142
+ to {
143
+ object-position: right center;
144
+ }
145
+ }
146
+ @keyframes portraitCoverPanLeft {
147
+ from {
148
+ object-position: right center;
149
+ }
150
+ to {
151
+ object-position: left center;
152
+ }
153
+ }
138
154
  @keyframes slideInLeft {
139
155
  from {
140
156
  transform: translateX(-100%);
@@ -1937,6 +1953,14 @@
1937
1953
  .anim-panRight {
1938
1954
  animation: panRight var(--anim-duration, 8s) var(--anim-easing, linear) var(--anim-delay, 0s) forwards;
1939
1955
  }
1956
+ .squisq-image--portrait-pan-right {
1957
+ animation: portraitCoverPanRight var(--portrait-pan-duration, 12s) var(--portrait-pan-easing, ease-in-out) var(--portrait-pan-delay, 0s) forwards;
1958
+ will-change: object-position;
1959
+ }
1960
+ .squisq-image--portrait-pan-left {
1961
+ animation: portraitCoverPanLeft var(--portrait-pan-duration, 12s) var(--portrait-pan-easing, ease-in-out) var(--portrait-pan-delay, 0s) forwards;
1962
+ will-change: object-position;
1963
+ }
1940
1964
  .anim-typewriter {
1941
1965
  animation: typewriter var(--anim-duration, 2s) var(--anim-easing, steps(40, end)) var(--anim-delay, 0s) forwards;
1942
1966
  }
@@ -2074,6 +2098,10 @@
2074
2098
  .anim-slowZoom-out,
2075
2099
  .anim-slowZoom-panLeft,
2076
2100
  .anim-slowZoom-panRight,
2101
+ .anim-panLeft,
2102
+ .anim-panRight,
2103
+ .squisq-image--portrait-pan-left,
2104
+ .squisq-image--portrait-pan-right,
2077
2105
  .anim-drift {
2078
2106
  animation: none;
2079
2107
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bendyline/squisq-react",
3
- "version": "1.4.2",
3
+ "version": "2.0.1",
4
4
  "description": "React component library for doc playback, block rendering, and media layers",
5
5
  "license": "MIT",
6
6
  "author": "Bendyline",
@@ -53,7 +53,7 @@
53
53
  "react-dom": "^18.0.0 || ^19.0.0"
54
54
  },
55
55
  "dependencies": {
56
- "@bendyline/squisq": "1.5.2"
56
+ "@bendyline/squisq": "2.0.1"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@types/react": "18.3.28",
@@ -6,6 +6,7 @@
6
6
  * Handles positioning, animations, and transitions.
7
7
  */
8
8
 
9
+ import { useId } from 'react';
9
10
  import type { Block, Layer, Transition } from '@bendyline/squisq/schemas';
10
11
  import { resolveTransitionDuration } from '@bendyline/squisq/schemas';
11
12
  import { ImageLayer } from './layers/ImageLayer';
@@ -15,11 +16,11 @@ import { PathLayer } from './layers/PathLayer';
15
16
  import { MapLayer } from './layers/MapLayer';
16
17
  import { VideoLayer } from './layers/VideoLayer';
17
18
  import { TableLayer } from './layers/TableLayer';
19
+ import { TreeLayer } from './layers/TreeLayer';
18
20
  import { getTransitionClass } from './utils/animationUtils';
19
21
 
20
- /** Default viewport dimensions (1080p landscape) - for backwards compatibility */
21
- // eslint-disable-next-line react-refresh/only-export-components
22
- export const VIEWPORT = {
22
+ /** Default viewport dimensions (1080p landscape). */
23
+ const DEFAULT_VIEWPORT = {
23
24
  width: 1920,
24
25
  height: 1080,
25
26
  };
@@ -47,6 +48,12 @@ interface BlockRendererProps {
47
48
  viewport?: ViewportDimensions;
48
49
  /** Whether the doc is currently playing (controls video playback) */
49
50
  isPlaying?: boolean;
51
+ /**
52
+ * Whether to render block transitions and layer animations (default: true).
53
+ * Disabling this only removes authored/render-style motion; timed video
54
+ * layers continue to advance normally.
55
+ */
56
+ animationsEnabled?: boolean;
50
57
  }
51
58
 
52
59
  export function BlockRenderer({
@@ -56,13 +63,14 @@ export function BlockRenderer({
56
63
  isEntering = false,
57
64
  isExiting = false,
58
65
  transition,
59
- viewport = VIEWPORT,
66
+ viewport = DEFAULT_VIEWPORT,
60
67
  isPlaying,
68
+ animationsEnabled = true,
61
69
  }: BlockRendererProps) {
62
70
  // Build transition class and inline style for dynamic duration
63
71
  let transitionClass = '';
64
72
  const transitionStyle: Record<string, string> = {};
65
- const activeTransition = transition ?? block.transition;
73
+ const activeTransition = animationsEnabled ? (transition ?? block.transition) : undefined;
66
74
  if (activeTransition && isEntering) {
67
75
  transitionClass = getTransitionClass(activeTransition.type, true, activeTransition.direction);
68
76
  transitionStyle['--transition-duration'] = `${resolveTransitionDuration(activeTransition)}s`;
@@ -71,8 +79,10 @@ export function BlockRenderer({
71
79
  transitionStyle['--transition-duration'] = `${resolveTransitionDuration(activeTransition)}s`;
72
80
  }
73
81
 
74
- // Unique clip path ID per block to avoid conflicts when multiple blocks render simultaneously
75
- const clipId = `vb-clip-${block.id}`;
82
+ // React's instance id keeps SVG fragment references local even when the
83
+ // same block is rendered in a thumbnail, preview, and player at once.
84
+ const instanceId = useId().replace(/:/g, '');
85
+ const clipId = `vb-clip-${instanceId}-${block.id}`;
76
86
 
77
87
  return (
78
88
  <svg
@@ -102,6 +112,7 @@ export function BlockRenderer({
102
112
  viewport={viewport}
103
113
  blockTime={blockTime}
104
114
  isPlaying={isPlaying}
115
+ animationsEnabled={animationsEnabled}
105
116
  />
106
117
  ))}
107
118
  </g>
@@ -115,31 +126,55 @@ interface LayerRendererProps {
115
126
  viewport: { width: number; height: number };
116
127
  blockTime: number;
117
128
  isPlaying?: boolean;
129
+ animationsEnabled: boolean;
118
130
  }
119
131
 
120
132
  /**
121
133
  * Dispatch to the appropriate layer component based on type.
122
134
  */
123
- function LayerRenderer({ layer, basePath, viewport, blockTime, isPlaying }: LayerRendererProps) {
124
- switch (layer.type) {
135
+ function LayerRenderer({
136
+ layer,
137
+ basePath,
138
+ viewport,
139
+ blockTime,
140
+ isPlaying,
141
+ animationsEnabled,
142
+ }: LayerRendererProps) {
143
+ // Render policy must not mutate caller-owned Docs. A shallow copy is enough:
144
+ // every layer renderer reads animation only from the base layer field.
145
+ const renderedLayer: Layer =
146
+ animationsEnabled || !layer.animation ? layer : { ...layer, animation: undefined };
147
+
148
+ switch (renderedLayer.type) {
125
149
  case 'image':
126
150
  return (
127
- <ImageLayer layer={layer} basePath={basePath} viewport={viewport} blockTime={blockTime} />
151
+ <ImageLayer
152
+ layer={renderedLayer}
153
+ basePath={basePath}
154
+ viewport={viewport}
155
+ blockTime={blockTime}
156
+ animationsEnabled={animationsEnabled}
157
+ />
128
158
  );
129
159
  case 'text':
130
- return <TextLayer layer={layer} viewport={viewport} blockTime={blockTime} />;
160
+ return <TextLayer layer={renderedLayer} viewport={viewport} blockTime={blockTime} />;
131
161
  case 'shape':
132
- return <ShapeLayer layer={layer} viewport={viewport} blockTime={blockTime} />;
162
+ return <ShapeLayer layer={renderedLayer} viewport={viewport} blockTime={blockTime} />;
133
163
  case 'path':
134
- return <PathLayer layer={layer} viewport={viewport} blockTime={blockTime} />;
164
+ return <PathLayer layer={renderedLayer} viewport={viewport} blockTime={blockTime} />;
135
165
  case 'map':
136
166
  return (
137
- <MapLayer layer={layer} basePath={basePath} viewport={viewport} blockTime={blockTime} />
167
+ <MapLayer
168
+ layer={renderedLayer}
169
+ basePath={basePath}
170
+ viewport={viewport}
171
+ blockTime={blockTime}
172
+ />
138
173
  );
139
174
  case 'video':
140
175
  return (
141
176
  <VideoLayer
142
- layer={layer}
177
+ layer={renderedLayer}
143
178
  basePath={basePath}
144
179
  viewport={viewport}
145
180
  blockTime={blockTime}
@@ -147,9 +182,11 @@ function LayerRenderer({ layer, basePath, viewport, blockTime, isPlaying }: Laye
147
182
  />
148
183
  );
149
184
  case 'table':
150
- return <TableLayer layer={layer} viewport={viewport} blockTime={blockTime} />;
185
+ return <TableLayer layer={renderedLayer} viewport={viewport} blockTime={blockTime} />;
186
+ case 'tree':
187
+ return <TreeLayer layer={renderedLayer} viewport={viewport} blockTime={blockTime} />;
151
188
  default:
152
- console.warn(`Unknown layer type: ${(layer as Layer).type}`);
189
+ console.warn(`Unknown layer type: ${(renderedLayer as Layer).type}`);
153
190
  return null;
154
191
  }
155
192
  }
@@ -11,14 +11,51 @@
11
11
  * - types.ts — Shared type definitions
12
12
  */
13
13
 
14
+ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
15
+ import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
14
16
  import type { PlaybackState, SlideNavActions } from './types';
15
17
 
18
+ export interface SlideshowPickerItem {
19
+ /** Stable identifier for the slide/block. */
20
+ id: string;
21
+ /** Human-facing slide number, or a special label such as "Cover". */
22
+ label: string;
23
+ /** Short description shown in the slide picker. */
24
+ summary: string;
25
+ }
26
+
16
27
  interface DocControlsSlideshowProps {
17
28
  state: PlaybackState;
18
29
  slideNav: SlideNavActions;
30
+ /** Slides available for direct navigation from the counter popover. */
31
+ slides?: readonly SlideshowPickerItem[];
32
+ /** Controlled open state for hosts that expose their own picker shortcut. */
33
+ pickerOpen?: boolean;
34
+ /** Called whenever the slide picker should open or close. */
35
+ onPickerOpenChange?: (open: boolean) => void;
19
36
  }
20
37
 
21
- export function DocControlsSlideshow({ state, slideNav }: DocControlsSlideshowProps) {
38
+ export function DocControlsSlideshow({
39
+ state,
40
+ slideNav,
41
+ slides = [],
42
+ pickerOpen,
43
+ onPickerOpenChange,
44
+ }: DocControlsSlideshowProps) {
45
+ const [uncontrolledPickerOpen, setUncontrolledPickerOpen] = useState(false);
46
+ const isPickerOpen = pickerOpen ?? uncontrolledPickerOpen;
47
+ const setPickerOpen = useCallback(
48
+ (open: boolean) => {
49
+ if (pickerOpen === undefined) setUncontrolledPickerOpen(open);
50
+ onPickerOpenChange?.(open);
51
+ },
52
+ [pickerOpen, onPickerOpenChange],
53
+ );
54
+ const [pickerMaxHeight, setPickerMaxHeight] = useState(280);
55
+ const controlsRef = useRef<HTMLDivElement>(null);
56
+ const triggerRef = useRef<HTMLButtonElement>(null);
57
+ const menuRef = useRef<HTMLDivElement>(null);
58
+ const menuId = useId();
22
59
  const {
23
60
  currentBlockIndex,
24
61
  currentSlideLabel,
@@ -34,8 +71,80 @@ export function DocControlsSlideshow({ state, slideNav }: DocControlsSlideshowPr
34
71
  `${currentSlideNumber ?? currentBlockIndex + 1} / ${totalSlideNumber ?? totalBlocks}`)
35
72
  : '—';
36
73
 
74
+ useEffect(() => {
75
+ if (!isPickerOpen) return;
76
+
77
+ const handlePointerDown = (event: PointerEvent) => {
78
+ if (!controlsRef.current?.contains(event.target as Node)) setPickerOpen(false);
79
+ };
80
+ const handleKeyDown = (event: KeyboardEvent) => {
81
+ if (event.key !== 'Escape') return;
82
+ event.preventDefault();
83
+ setPickerOpen(false);
84
+ triggerRef.current?.focus();
85
+ };
86
+
87
+ document.addEventListener('pointerdown', handlePointerDown);
88
+ document.addEventListener('keydown', handleKeyDown);
89
+ return () => {
90
+ document.removeEventListener('pointerdown', handlePointerDown);
91
+ document.removeEventListener('keydown', handleKeyDown);
92
+ };
93
+ }, [isPickerOpen, setPickerOpen]);
94
+
95
+ useLayoutEffect(() => {
96
+ if (!isPickerOpen) return;
97
+
98
+ const controls = controlsRef.current;
99
+ const player = controls?.closest<HTMLElement>('.doc-player') ?? controls?.parentElement;
100
+ if (!controls || !player) return;
101
+
102
+ const updateMaxHeight = () => {
103
+ const controlsBounds = controls.getBoundingClientRect();
104
+ const playerBounds = player.getBoundingClientRect();
105
+ // The picker sits 8px above the toolbar; retain another 16px of
106
+ // breathing room at the top of the player.
107
+ const availableHeight = Math.floor(controlsBounds.top - playerBounds.top - 8 - 16);
108
+ setPickerMaxHeight(Math.max(72, availableHeight));
109
+ };
110
+
111
+ updateMaxHeight();
112
+ const resizeObserver =
113
+ typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updateMaxHeight);
114
+ resizeObserver?.observe(player);
115
+ resizeObserver?.observe(controls);
116
+ window.addEventListener('resize', updateMaxHeight);
117
+ return () => {
118
+ resizeObserver?.disconnect();
119
+ window.removeEventListener('resize', updateMaxHeight);
120
+ };
121
+ }, [isPickerOpen]);
122
+
123
+ useEffect(() => {
124
+ if (!isPickerOpen) return;
125
+ menuRef.current?.querySelector<HTMLButtonElement>('[aria-current="true"]')?.focus();
126
+ }, [isPickerOpen]);
127
+
128
+ const handleMenuKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
129
+ if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
130
+ const items = Array.from(
131
+ menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [],
132
+ );
133
+ if (items.length === 0) return;
134
+
135
+ event.preventDefault();
136
+ const focusedIndex = items.indexOf(document.activeElement as HTMLButtonElement);
137
+ let nextIndex = focusedIndex;
138
+ if (event.key === 'Home') nextIndex = 0;
139
+ else if (event.key === 'End') nextIndex = items.length - 1;
140
+ else if (event.key === 'ArrowDown') nextIndex = (focusedIndex + 1) % items.length;
141
+ else nextIndex = (focusedIndex - 1 + items.length) % items.length;
142
+ items[nextIndex]?.focus();
143
+ };
144
+
37
145
  return (
38
146
  <div
147
+ ref={controlsRef}
39
148
  className="doc-controls-slideshow"
40
149
  data-testid="slideshow-controls"
41
150
  style={{
@@ -88,22 +197,130 @@ export function DocControlsSlideshow({ state, slideNav }: DocControlsSlideshowPr
88
197
  </svg>
89
198
  </button>
90
199
 
91
- {/* Slide counter */}
92
- <span
200
+ {/* Slide counter / direct-navigation picker */}
201
+ <button
202
+ ref={triggerRef}
203
+ type="button"
93
204
  data-testid="slide-counter"
205
+ aria-label={`Choose slide, current ${counterText}`}
206
+ aria-haspopup="menu"
207
+ aria-expanded={isPickerOpen}
208
+ aria-controls={isPickerOpen ? menuId : undefined}
209
+ title="Choose slide"
210
+ disabled={slides.length === 0}
211
+ onClick={(event) => {
212
+ event.stopPropagation();
213
+ setPickerOpen(!isPickerOpen);
214
+ }}
94
215
  style={{
216
+ background: isPickerOpen ? 'rgba(255,255,255,0.12)' : 'none',
217
+ border: 'none',
95
218
  color: 'rgba(255,255,255,0.9)',
219
+ cursor: slides.length > 0 ? 'pointer' : 'default',
96
220
  fontSize: '13px',
97
221
  fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
98
222
  fontVariantNumeric: 'tabular-nums',
99
223
  minWidth: '48px',
100
224
  textAlign: 'center',
101
- padding: '0 4px',
225
+ padding: '6px 4px',
102
226
  letterSpacing: '0.02em',
227
+ borderRadius: '4px',
228
+ transition: 'background 0.15s',
103
229
  }}
104
230
  >
105
231
  {counterText}
106
- </span>
232
+ </button>
233
+
234
+ {isPickerOpen && (
235
+ <div
236
+ ref={menuRef}
237
+ id={menuId}
238
+ role="menu"
239
+ aria-label="Choose a slide"
240
+ data-testid="slide-picker"
241
+ onClick={(event) => event.stopPropagation()}
242
+ onKeyDown={handleMenuKeyDown}
243
+ style={{
244
+ position: 'absolute',
245
+ right: 0,
246
+ bottom: 'calc(100% + 8px)',
247
+ width: 'min(280px, calc(100vw - 32px))',
248
+ maxHeight: `${pickerMaxHeight}px`,
249
+ overflowY: 'auto',
250
+ padding: '6px',
251
+ background: 'rgba(20, 20, 20, 0.94)',
252
+ border: '1px solid rgba(255,255,255,0.14)',
253
+ borderRadius: '8px',
254
+ boxShadow: '0 10px 30px rgba(0,0,0,0.38)',
255
+ backdropFilter: 'blur(12px)',
256
+ WebkitBackdropFilter: 'blur(12px)',
257
+ }}
258
+ >
259
+ {slides.map((slide, index) => {
260
+ const isCurrent = index === currentBlockIndex;
261
+ return (
262
+ <button
263
+ key={`${slide.id}-${index}`}
264
+ type="button"
265
+ role="menuitem"
266
+ aria-current={isCurrent ? 'true' : undefined}
267
+ data-testid={`slide-picker-item-${index}`}
268
+ onClick={() => {
269
+ slideNav.goToSlide(index);
270
+ setPickerOpen(false);
271
+ }}
272
+ style={{
273
+ display: 'grid',
274
+ gridTemplateColumns: '42px minmax(0, 1fr)',
275
+ alignItems: 'center',
276
+ gap: '8px',
277
+ width: '100%',
278
+ padding: '8px 10px',
279
+ background: isCurrent ? 'rgba(255,255,255,0.14)' : 'transparent',
280
+ border: 'none',
281
+ borderRadius: '5px',
282
+ color: 'rgba(255,255,255,0.94)',
283
+ cursor: 'pointer',
284
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
285
+ textAlign: 'left',
286
+ }}
287
+ onMouseEnter={(event) => {
288
+ event.currentTarget.style.background = 'rgba(255,255,255,0.1)';
289
+ }}
290
+ onMouseLeave={(event) => {
291
+ event.currentTarget.style.background = isCurrent
292
+ ? 'rgba(255,255,255,0.14)'
293
+ : 'transparent';
294
+ }}
295
+ >
296
+ <span
297
+ style={{
298
+ color: isCurrent ? '#fff' : 'rgba(255,255,255,0.58)',
299
+ fontSize: '12px',
300
+ fontVariantNumeric: 'tabular-nums',
301
+ textAlign: 'right',
302
+ }}
303
+ >
304
+ {slide.label}
305
+ </span>
306
+ <span
307
+ style={{
308
+ minWidth: 0,
309
+ overflow: 'hidden',
310
+ color: isCurrent ? '#fff' : 'rgba(255,255,255,0.82)',
311
+ fontSize: '13px',
312
+ lineHeight: 1.3,
313
+ textOverflow: 'ellipsis',
314
+ whiteSpace: 'nowrap',
315
+ }}
316
+ >
317
+ {slide.summary}
318
+ </span>
319
+ </button>
320
+ );
321
+ })}
322
+ </div>
323
+ )}
107
324
 
108
325
  {/* Next button */}
109
326
  <button