@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
@@ -27,6 +27,8 @@ export interface MediaClipLayerProps {
27
27
  isPlaying: boolean;
28
28
  basePath: string;
29
29
  renderMode?: boolean;
30
+ /** Silence every scheduled clip during live playback. */
31
+ muted?: boolean;
30
32
  }
31
33
 
32
34
  export function MediaClipLayer({
@@ -35,6 +37,7 @@ export function MediaClipLayer({
35
37
  isPlaying,
36
38
  basePath,
37
39
  renderMode = false,
40
+ muted = false,
38
41
  }: MediaClipLayerProps) {
39
42
  const { renderClips, activeIds } = useMediaSchedule(schedule, currentTime);
40
43
  if (renderClips.length === 0) return null;
@@ -49,6 +52,7 @@ export function MediaClipLayer({
49
52
  isPlaying={isPlaying}
50
53
  basePath={basePath}
51
54
  renderMode={renderMode}
55
+ muted={muted}
52
56
  />
53
57
  ))}
54
58
  </div>
@@ -62,6 +66,7 @@ interface MediaClipElementProps {
62
66
  isPlaying: boolean;
63
67
  basePath: string;
64
68
  renderMode: boolean;
69
+ muted: boolean;
65
70
  }
66
71
 
67
72
  function MediaClipElement({
@@ -71,6 +76,7 @@ function MediaClipElement({
71
76
  isPlaying,
72
77
  basePath,
73
78
  renderMode,
79
+ muted,
74
80
  }: MediaClipElementProps) {
75
81
  const ref = useRef<HTMLMediaElement | null>(null);
76
82
  const src = useMediaUrl(clip.src, basePath);
@@ -96,7 +102,7 @@ function MediaClipElement({
96
102
  } else {
97
103
  el.pause();
98
104
  }
99
- }, [active, currentTime, isPlaying, renderMode, clip.sourceIn, clip.absoluteStart]);
105
+ }, [active, currentTime, isPlaying, renderMode, clip.sourceIn, clip.absoluteStart, src]);
100
106
 
101
107
  const isVideo = clip.kind === 'video';
102
108
  const common = {
@@ -130,6 +136,10 @@ function MediaClipElement({
130
136
  }
131
137
 
132
138
  return (
133
- <audio {...common} muted={renderMode} style={{ position: 'absolute', width: 0, height: 0 }} />
139
+ <audio
140
+ {...common}
141
+ muted={renderMode || muted}
142
+ style={{ position: 'absolute', width: 0, height: 0 }}
143
+ />
134
144
  );
135
145
  }
@@ -1,15 +1,8 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { render } from '@testing-library/react';
3
- import { BlockRenderer, VIEWPORT } from '../BlockRenderer';
3
+ import { BlockRenderer } from '../BlockRenderer';
4
4
  import type { Block } from '@bendyline/squisq/schemas';
5
5
 
6
- describe('VIEWPORT constant', () => {
7
- it('has correct 1080p dimensions', () => {
8
- expect(VIEWPORT.width).toBe(1920);
9
- expect(VIEWPORT.height).toBe(1080);
10
- });
11
- });
12
-
13
6
  describe('BlockRenderer', () => {
14
7
  const minimalBlock: Block = {
15
8
  id: 'test-block',
@@ -102,4 +95,82 @@ describe('BlockRenderer', () => {
102
95
  const rect = container.querySelector('rect');
103
96
  expect(rect).toBeTruthy();
104
97
  });
98
+
99
+ it('namespaces SVG definitions per renderer instance', () => {
100
+ const { container } = render(
101
+ <>
102
+ <BlockRenderer block={minimalBlock} blockTime={0} basePath="/test" />
103
+ <BlockRenderer block={minimalBlock} blockTime={0} basePath="/test" />
104
+ </>,
105
+ );
106
+ const ids = Array.from(container.querySelectorAll('clipPath')).map((node) => node.id);
107
+ expect(ids).toHaveLength(2);
108
+ expect(new Set(ids).size).toBe(2);
109
+ });
110
+
111
+ it('renders transitions and layer animations by default', () => {
112
+ const animatedBlock: Block = {
113
+ ...minimalBlock,
114
+ transition: { type: 'fade', duration: 0.5 },
115
+ layers: [
116
+ {
117
+ type: 'text',
118
+ id: 'animated-title',
119
+ content: { text: 'Animated', style: { fontSize: 48, color: '#fff' } },
120
+ position: { x: 100, y: 100 },
121
+ animation: { type: 'fadeIn', duration: 1 },
122
+ },
123
+ ],
124
+ };
125
+
126
+ const { container } = render(
127
+ <BlockRenderer block={animatedBlock} blockTime={0} basePath="/test" isEntering />,
128
+ );
129
+
130
+ expect(container.querySelector('svg')?.classList.contains('transition-fade-enter')).toBe(true);
131
+ expect(
132
+ container
133
+ .querySelector('[data-layer-id="animated-title"]')
134
+ ?.classList.contains('anim-fadeIn'),
135
+ ).toBe(true);
136
+ });
137
+
138
+ it('suppresses transitions and every layer animation without mutating the block', () => {
139
+ const animatedBlock: Block = {
140
+ ...minimalBlock,
141
+ transition: { type: 'fade', duration: 0.5 },
142
+ layers: [
143
+ {
144
+ type: 'text',
145
+ id: 'animated-title',
146
+ content: { text: 'Static', style: { fontSize: 48, color: '#fff' } },
147
+ position: { x: 100, y: 100 },
148
+ animation: { type: 'fadeIn', duration: 1 },
149
+ },
150
+ {
151
+ type: 'image',
152
+ id: 'animated-hero',
153
+ content: { src: 'hero.jpg', alt: 'Hero', fit: 'cover' },
154
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
155
+ animation: { type: 'slowZoom', duration: 5 },
156
+ },
157
+ ],
158
+ };
159
+ const originalAnimations = animatedBlock.layers?.map((layer) => layer.animation);
160
+
161
+ const { container } = render(
162
+ <BlockRenderer
163
+ block={animatedBlock}
164
+ blockTime={0}
165
+ basePath="/test"
166
+ isEntering
167
+ animationsEnabled={false}
168
+ />,
169
+ );
170
+
171
+ expect(container.querySelector('svg')?.className.baseVal).not.toContain('transition-');
172
+ expect(container.querySelector('[class*="anim-"]')).toBeNull();
173
+ expect(animatedBlock.transition).toEqual({ type: 'fade', duration: 0.5 });
174
+ expect(animatedBlock.layers?.map((layer) => layer.animation)).toEqual(originalAnimations);
175
+ });
105
176
  });
@@ -1,8 +1,14 @@
1
- import { describe, it, expect } from 'vitest';
1
+ import { describe, it, expect, vi } from 'vitest';
2
2
  import { render, fireEvent } from '@testing-library/react';
3
3
  import { DocControlsSlideshow } from '../DocControlsSlideshow';
4
4
  import type { PlaybackState, SlideNavActions } from '../types';
5
5
 
6
+ const slides = [
7
+ { id: 'intro', label: '1', summary: 'Introduction' },
8
+ { id: 'results', label: '2', summary: 'Key results' },
9
+ { id: 'next', label: '3', summary: 'What comes next' },
10
+ ];
11
+
6
12
  function makeState(overrides: Partial<PlaybackState> = {}): PlaybackState {
7
13
  return {
8
14
  isPlaying: false,
@@ -49,6 +55,93 @@ describe('DocControlsSlideshow', () => {
49
55
  expect(getByTestId('slide-counter').textContent).toBe('5 / 12');
50
56
  });
51
57
 
58
+ it('opens an upward slide picker with block summaries', () => {
59
+ const { getByTestId, getByRole } = render(
60
+ <DocControlsSlideshow
61
+ state={makeState({ currentBlockIndex: 1, totalBlocks: slides.length })}
62
+ slideNav={makeSlideNav()}
63
+ slides={slides}
64
+ />,
65
+ );
66
+
67
+ fireEvent.click(getByTestId('slide-counter'));
68
+
69
+ const picker = getByRole('menu', { name: 'Choose a slide' });
70
+ expect(picker.style.bottom).toBe('calc(100% + 8px)');
71
+ expect(getByTestId('slide-picker-item-0').textContent).toContain('1Introduction');
72
+ expect(getByTestId('slide-picker-item-1').textContent).toContain('2Key results');
73
+ expect(getByTestId('slide-picker-item-1').getAttribute('aria-current')).toBe('true');
74
+ });
75
+
76
+ it('uses the available player height above the toolbar', () => {
77
+ const { getByTestId } = render(
78
+ <div className="doc-player">
79
+ <DocControlsSlideshow
80
+ state={makeState({ currentBlockIndex: 1, totalBlocks: slides.length })}
81
+ slideNav={makeSlideNav()}
82
+ slides={slides}
83
+ />
84
+ </div>,
85
+ );
86
+ const controls = getByTestId('slideshow-controls');
87
+ const player = controls.parentElement as HTMLElement;
88
+ const rect = (top: number, height: number) =>
89
+ ({
90
+ x: 0,
91
+ y: top,
92
+ top,
93
+ right: 640,
94
+ bottom: top + height,
95
+ left: 0,
96
+ width: 640,
97
+ height,
98
+ toJSON: () => ({}),
99
+ }) as DOMRect;
100
+ const playerBounds = vi.spyOn(player, 'getBoundingClientRect').mockReturnValue(rect(0, 480));
101
+ const controlsBounds = vi
102
+ .spyOn(controls, 'getBoundingClientRect')
103
+ .mockReturnValue(rect(420, 40));
104
+
105
+ fireEvent.click(getByTestId('slide-counter'));
106
+
107
+ expect(getByTestId('slide-picker').style.maxHeight).toBe('396px');
108
+ playerBounds.mockRestore();
109
+ controlsBounds.mockRestore();
110
+ });
111
+
112
+ it('navigates directly to a selected slide and closes the picker', () => {
113
+ let selectedIndex = -1;
114
+ const { getByTestId, queryByTestId } = render(
115
+ <DocControlsSlideshow
116
+ state={makeState({ currentBlockIndex: 0, totalBlocks: slides.length })}
117
+ slideNav={makeSlideNav({ goToSlide: (index) => (selectedIndex = index) })}
118
+ slides={slides}
119
+ />,
120
+ );
121
+
122
+ fireEvent.click(getByTestId('slide-counter'));
123
+ fireEvent.click(getByTestId('slide-picker-item-2'));
124
+
125
+ expect(selectedIndex).toBe(2);
126
+ expect(queryByTestId('slide-picker')).toBeNull();
127
+ });
128
+
129
+ it('closes the slide picker with Escape', () => {
130
+ const { getByTestId, queryByTestId } = render(
131
+ <DocControlsSlideshow
132
+ state={makeState({ currentBlockIndex: 0, totalBlocks: slides.length })}
133
+ slideNav={makeSlideNav()}
134
+ slides={slides}
135
+ />,
136
+ );
137
+
138
+ fireEvent.click(getByTestId('slide-counter'));
139
+ fireEvent.keyDown(document, { key: 'Escape' });
140
+
141
+ expect(queryByTestId('slide-picker')).toBeNull();
142
+ expect(document.activeElement).toBe(getByTestId('slide-counter'));
143
+ });
144
+
52
145
  it('disables prev button on first slide', () => {
53
146
  const { getByTestId } = render(
54
147
  <DocControlsSlideshow