@bendyline/squisq-react 1.4.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -3
- package/dist/index.d.ts +185 -27
- package/dist/index.js +1323 -614
- package/dist/index.js.map +1 -1
- package/dist/squisq-player.global.js +54 -37
- package/dist/squisq-player.global.js.map +1 -1
- package/dist/standalone-source.js +1 -1
- package/package.json +2 -2
- package/src/BlockRenderer.tsx +53 -17
- package/src/DocControlsSlideshow.tsx +235 -7
- package/src/DocPlayer.tsx +462 -195
- package/src/DocPlayerWithSidebar.tsx +4 -0
- package/src/DocProgressBar.tsx +40 -1
- package/src/LinearDocView.tsx +135 -62
- package/src/MarkdownRenderer.tsx +40 -97
- package/src/MediaClipLayer.tsx +12 -2
- package/src/__tests__/BlockRenderer.test.tsx +79 -8
- package/src/__tests__/DocControlsSlideshow.test.tsx +94 -1
- package/src/__tests__/DocPlayer.test.tsx +556 -2
- package/src/__tests__/DocProgressBar.test.tsx +28 -2
- package/src/__tests__/LinearDocView.test.tsx +91 -11
- package/src/__tests__/MapLayer.test.tsx +63 -0
- package/src/__tests__/MarkdownRenderer.test.tsx +13 -2
- package/src/__tests__/MediaClipLayer.test.tsx +70 -0
- package/src/__tests__/MediaContext.test.tsx +51 -0
- package/src/__tests__/PathLayer.test.tsx +12 -1
- package/src/__tests__/VideoLayer.test.tsx +94 -0
- package/src/__tests__/fillStyle.test.tsx +3 -2
- package/src/__tests__/standaloneEntry.test.tsx +103 -0
- package/src/__tests__/useAudioSync.test.ts +49 -0
- package/src/__tests__/useDocPlayback.transition.test.ts +48 -5
- package/src/__tests__/useViewportOrientation.test.ts +22 -0
- package/src/hooks/MediaContext.tsx +12 -3
- package/src/hooks/useAudioSync.ts +61 -12
- package/src/hooks/useDocPlayback.ts +40 -12
- package/src/hooks/useViewportOrientation.ts +2 -4
- package/src/index.ts +5 -2
- package/src/layers/MapLayer.tsx +7 -6
- package/src/layers/PathLayer.tsx +20 -11
- package/src/layers/ShapeLayer.tsx +4 -2
- package/src/layers/TextLayer.tsx +4 -3
- package/src/layers/TreeLayer.tsx +167 -0
- package/src/layers/VideoLayer.tsx +20 -6
- package/src/standalone-entry.tsx +91 -14
- package/src/types.ts +19 -13
package/src/MediaClipLayer.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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
|