@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.
- package/README.md +30 -3
- package/dist/index.d.ts +177 -28
- package/dist/index.js +1330 -611
- package/dist/index.js.map +1 -1
- package/dist/squisq-player.css +1 -1
- package/dist/squisq-player.css.map +1 -1
- package/dist/squisq-player.global.js +57 -37
- package/dist/squisq-player.global.js.map +1 -1
- package/dist/standalone-source.js +1 -1
- package/dist/styles/index.css +28 -0
- package/package.json +2 -2
- package/src/BlockRenderer.tsx +54 -17
- package/src/DocControlsSlideshow.tsx +222 -5
- package/src/DocPlayer.tsx +367 -183
- package/src/DocPlayerWithSidebar.tsx +4 -0
- package/src/DocProgressBar.tsx +40 -1
- package/src/LinearDocView.tsx +138 -62
- package/src/MarkdownRenderer.tsx +40 -97
- package/src/MediaClipLayer.tsx +12 -2
- package/src/__tests__/BlockRenderer.test.tsx +138 -8
- package/src/__tests__/DocControlsSlideshow.test.tsx +94 -1
- package/src/__tests__/DocPlayer.test.tsx +505 -0
- package/src/__tests__/DocProgressBar.test.tsx +28 -2
- package/src/__tests__/LinearDocView.test.tsx +104 -11
- package/src/__tests__/MapLayer.test.tsx +63 -0
- package/src/__tests__/MarkdownRenderer.test.tsx +16 -5
- 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 +50 -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/ImageLayer.tsx +106 -1
- package/src/layers/MapLayer.tsx +7 -6
- package/src/layers/PathLayer.tsx +20 -11
- package/src/layers/ShapeLayer.tsx +33 -9
- 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/styles/doc-animations.css +36 -0
- package/src/types.ts +13 -13
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { render } from '@testing-library/react';
|
|
2
|
+
import { fireEvent, render } from '@testing-library/react';
|
|
3
3
|
import { LinearDocView } from '../LinearDocView';
|
|
4
4
|
import type { Doc, Block } from '@bendyline/squisq/schemas';
|
|
5
5
|
import { DARK_SURFACE, DEFAULT_THEME, LIGHT_SURFACE } from '@bendyline/squisq/schemas';
|
|
@@ -58,6 +58,20 @@ describe('LinearDocView', () => {
|
|
|
58
58
|
expect((el as HTMLElement).style.overflowY).toBe('auto');
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
+
it('globally scrolls with up and down arrows when enabled', () => {
|
|
62
|
+
const doc = mkDoc([mkBlock({ contents: [paragraph(text('Scrollable body'))] })]);
|
|
63
|
+
const { container } = render(<LinearDocView doc={doc} globalKeyboardShortcuts />);
|
|
64
|
+
const scroller = container.querySelector<HTMLElement>('.squisq-linear')!;
|
|
65
|
+
const scrollBy = vi.fn();
|
|
66
|
+
Object.defineProperty(scroller, 'scrollBy', { configurable: true, value: scrollBy });
|
|
67
|
+
|
|
68
|
+
fireEvent.keyDown(document, { key: 'ArrowDown' });
|
|
69
|
+
fireEvent.keyDown(document, { key: 'ArrowUp' });
|
|
70
|
+
|
|
71
|
+
expect(scrollBy).toHaveBeenNthCalledWith(1, { top: 64, behavior: 'smooth' });
|
|
72
|
+
expect(scrollBy).toHaveBeenNthCalledWith(2, { top: -64, behavior: 'smooth' });
|
|
73
|
+
});
|
|
74
|
+
|
|
61
75
|
it('renders preamble content (no heading)', () => {
|
|
62
76
|
const doc = mkDoc([
|
|
63
77
|
mkBlock({
|
|
@@ -176,6 +190,24 @@ describe('LinearDocView', () => {
|
|
|
176
190
|
expect(svg).toBeTruthy();
|
|
177
191
|
});
|
|
178
192
|
|
|
193
|
+
it('renders transform-generated template blocks without authoring nodes', () => {
|
|
194
|
+
const doc = mkDoc([
|
|
195
|
+
mkBlock({
|
|
196
|
+
id: 'transform-stat',
|
|
197
|
+
template: 'statHighlight',
|
|
198
|
+
stat: '42%',
|
|
199
|
+
description: 'Year-over-year growth',
|
|
200
|
+
} as Partial<Block>),
|
|
201
|
+
]);
|
|
202
|
+
|
|
203
|
+
const { container } = render(<LinearDocView doc={doc} />);
|
|
204
|
+
const section = container.querySelector('[data-block-id="transform-stat"]');
|
|
205
|
+
expect(section?.getAttribute('data-template')).toBe('statHighlight');
|
|
206
|
+
expect(section?.querySelector('.squisq-linear-card svg')).toBeTruthy();
|
|
207
|
+
expect(section?.textContent).toContain('42%');
|
|
208
|
+
expect(section?.textContent).toContain('Year-over-year growth');
|
|
209
|
+
});
|
|
210
|
+
|
|
179
211
|
it('renders children recursively', () => {
|
|
180
212
|
const doc = mkDoc([
|
|
181
213
|
mkBlock({
|
|
@@ -198,6 +230,22 @@ describe('LinearDocView', () => {
|
|
|
198
230
|
expect(container.textContent).toContain('Child body');
|
|
199
231
|
});
|
|
200
232
|
|
|
233
|
+
it('assigns unique pre-order indices across nested and top-level blocks', () => {
|
|
234
|
+
const doc = mkDoc([
|
|
235
|
+
mkBlock({
|
|
236
|
+
id: 'parent',
|
|
237
|
+
children: [mkBlock({ id: 'child' })],
|
|
238
|
+
}),
|
|
239
|
+
mkBlock({ id: 'sibling' }),
|
|
240
|
+
]);
|
|
241
|
+
const { container } = render(<LinearDocView doc={doc} />);
|
|
242
|
+
expect(
|
|
243
|
+
Array.from(container.querySelectorAll('.squisq-linear-section')).map((node) =>
|
|
244
|
+
node.getAttribute('data-block-index'),
|
|
245
|
+
),
|
|
246
|
+
).toEqual(['0', '1', '2']);
|
|
247
|
+
});
|
|
248
|
+
|
|
201
249
|
it('renders multiple top-level blocks', () => {
|
|
202
250
|
const doc = mkDoc([
|
|
203
251
|
mkBlock({
|
|
@@ -287,6 +335,19 @@ describe('LinearDocView markdown prop', () => {
|
|
|
287
335
|
expect(container.textContent).toContain('Paragraph body.');
|
|
288
336
|
});
|
|
289
337
|
|
|
338
|
+
it('adds extra spacing between blank-line-separated paragraphs', () => {
|
|
339
|
+
const { container } = render(
|
|
340
|
+
<LinearDocView markdown={'First paragraph.\n\nSecond paragraph.'} />,
|
|
341
|
+
);
|
|
342
|
+
const paragraphs = container.querySelectorAll('.squisq-md-p');
|
|
343
|
+
const styles = container.querySelector('.squisq-linear-content > style')?.textContent;
|
|
344
|
+
|
|
345
|
+
expect(paragraphs).toHaveLength(2);
|
|
346
|
+
expect(paragraphs[0]?.nextElementSibling).toBe(paragraphs[1]);
|
|
347
|
+
expect(styles).toContain('.squisq-linear-content p + p');
|
|
348
|
+
expect(styles).toContain('margin-top: 1.25em');
|
|
349
|
+
});
|
|
350
|
+
|
|
290
351
|
it('doc wins over markdown when both are provided', () => {
|
|
291
352
|
const doc = mkDoc([mkBlock({ id: 'b', contents: [paragraph(text('Doc body wins'))] })]);
|
|
292
353
|
const { container } = render(<LinearDocView doc={doc} markdown="# Markdown Loses" />);
|
|
@@ -301,7 +362,7 @@ describe('LinearDocView markdown prop', () => {
|
|
|
301
362
|
});
|
|
302
363
|
|
|
303
364
|
describe('LinearDocView unknown template annotations', () => {
|
|
304
|
-
it('
|
|
365
|
+
it('renders the canonical visible fallback without hidden console output', () => {
|
|
305
366
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
306
367
|
const doc = mkDoc([
|
|
307
368
|
mkBlock({
|
|
@@ -317,17 +378,49 @@ describe('LinearDocView unknown template annotations', () => {
|
|
|
317
378
|
]);
|
|
318
379
|
|
|
319
380
|
const { container } = render(<LinearDocView doc={doc} />);
|
|
320
|
-
// Renders as plain markdown: heading + body, no SVG card.
|
|
321
381
|
expect(container.textContent).toContain('Mystery Section');
|
|
322
382
|
expect(container.textContent).toContain('Fallback body content');
|
|
323
|
-
expect(container.
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
render(<LinearDocView doc={doc} />);
|
|
327
|
-
const sentinelCalls = warnSpy.mock.calls.filter((c) =>
|
|
328
|
-
String(c[0]).includes('no-such-template-xyz'),
|
|
329
|
-
);
|
|
330
|
-
expect(sentinelCalls.length).toBe(1);
|
|
383
|
+
expect(container.textContent).toContain('Unknown template "no-such-template-xyz"');
|
|
384
|
+
expect(container.querySelector('.squisq-linear-card')).not.toBeNull();
|
|
385
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
331
386
|
warnSpy.mockRestore();
|
|
332
387
|
});
|
|
333
388
|
});
|
|
389
|
+
|
|
390
|
+
describe('LinearDocView custom template materialization', () => {
|
|
391
|
+
it('renders document-scoped templates through the canonical API', () => {
|
|
392
|
+
const doc: Doc = {
|
|
393
|
+
...mkDoc([
|
|
394
|
+
mkBlock({
|
|
395
|
+
id: 'custom-1',
|
|
396
|
+
sourceHeading: {
|
|
397
|
+
type: 'heading',
|
|
398
|
+
depth: 2,
|
|
399
|
+
children: [text('Custom Hero')],
|
|
400
|
+
templateAnnotation: { template: 'hero' },
|
|
401
|
+
},
|
|
402
|
+
contents: [paragraph(text('Custom body'))],
|
|
403
|
+
}),
|
|
404
|
+
]),
|
|
405
|
+
customTemplates: [
|
|
406
|
+
{
|
|
407
|
+
name: 'hero',
|
|
408
|
+
label: 'Hero',
|
|
409
|
+
viewport: { width: 1920, height: 1080 },
|
|
410
|
+
layers: [
|
|
411
|
+
{
|
|
412
|
+
id: 'hero-title',
|
|
413
|
+
type: 'text',
|
|
414
|
+
position: { x: '5%', y: '10%', width: '90%' },
|
|
415
|
+
content: { text: '{title}: {content}', style: { fontSize: 48, color: '#000000' } },
|
|
416
|
+
},
|
|
417
|
+
],
|
|
418
|
+
},
|
|
419
|
+
],
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const { container } = render(<LinearDocView doc={doc} />);
|
|
423
|
+
expect(container.textContent).toContain('Custom Hero: Custom body');
|
|
424
|
+
expect(container.textContent).not.toContain('Unknown template');
|
|
425
|
+
});
|
|
426
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { render, waitFor } from '@testing-library/react';
|
|
3
|
+
import type { MapLayer as MapLayerSchema } from '@bendyline/squisq/schemas';
|
|
4
|
+
|
|
5
|
+
const { composeMapImage } = vi.hoisted(() => ({
|
|
6
|
+
composeMapImage: vi.fn(async () => 'data:image/png;base64,map'),
|
|
7
|
+
}));
|
|
8
|
+
vi.mock('../utils/mapTileUtils', () => ({ composeMapImage }));
|
|
9
|
+
|
|
10
|
+
import { MapLayer } from '../layers/MapLayer';
|
|
11
|
+
|
|
12
|
+
function layer(
|
|
13
|
+
markers: MapLayerSchema['content']['markers'],
|
|
14
|
+
showAttribution = true,
|
|
15
|
+
): MapLayerSchema {
|
|
16
|
+
return {
|
|
17
|
+
id: 'map',
|
|
18
|
+
type: 'map',
|
|
19
|
+
position: { x: 0, y: 0, width: 100, height: 100 },
|
|
20
|
+
content: {
|
|
21
|
+
center: { lat: 1, lng: 2 },
|
|
22
|
+
zoom: 4,
|
|
23
|
+
style: 'road',
|
|
24
|
+
markers,
|
|
25
|
+
showAttribution,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('MapLayer dependencies', () => {
|
|
31
|
+
beforeEach(() => composeMapImage.mockClear());
|
|
32
|
+
|
|
33
|
+
it('recomposes when markers or attribution change', async () => {
|
|
34
|
+
const firstMarkers = [{ lat: 1, lng: 2, label: 'A' }];
|
|
35
|
+
const { rerender } = render(
|
|
36
|
+
<svg>
|
|
37
|
+
<MapLayer
|
|
38
|
+
layer={layer(firstMarkers)}
|
|
39
|
+
basePath="."
|
|
40
|
+
viewport={{ width: 100, height: 100 }}
|
|
41
|
+
blockTime={0}
|
|
42
|
+
/>
|
|
43
|
+
</svg>,
|
|
44
|
+
);
|
|
45
|
+
await waitFor(() => expect(composeMapImage).toHaveBeenCalledTimes(1));
|
|
46
|
+
|
|
47
|
+
const secondMarkers = [{ lat: 3, lng: 4, label: 'B' }];
|
|
48
|
+
rerender(
|
|
49
|
+
<svg>
|
|
50
|
+
<MapLayer
|
|
51
|
+
layer={layer(secondMarkers, false)}
|
|
52
|
+
basePath="."
|
|
53
|
+
viewport={{ width: 100, height: 100 }}
|
|
54
|
+
blockTime={0}
|
|
55
|
+
/>
|
|
56
|
+
</svg>,
|
|
57
|
+
);
|
|
58
|
+
await waitFor(() => expect(composeMapImage).toHaveBeenCalledTimes(2));
|
|
59
|
+
expect(composeMapImage).toHaveBeenLastCalledWith(
|
|
60
|
+
expect.objectContaining({ markers: secondMarkers, showAttribution: false }),
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -103,13 +103,13 @@ describe('MarkdownRenderer', () => {
|
|
|
103
103
|
});
|
|
104
104
|
|
|
105
105
|
it('linkSchemes allows a host scheme as a real anchor, never executable ones', () => {
|
|
106
|
-
const nodes = parseNodes('[a](
|
|
106
|
+
const nodes = parseNodes('[a](workspace-nav:src%2Fa.ts) [b](javascript:alert(1))');
|
|
107
107
|
const blocked = render(<MarkdownRenderer nodes={nodes} />);
|
|
108
108
|
expect(blocked.container.querySelector('a.squisq-md-link')).toBeNull();
|
|
109
109
|
|
|
110
|
-
const allowed = render(<MarkdownRenderer nodes={nodes} linkSchemes={['
|
|
110
|
+
const allowed = render(<MarkdownRenderer nodes={nodes} linkSchemes={['workspace-nav']} />);
|
|
111
111
|
const a = allowed.container.querySelector('a.squisq-md-link') as HTMLAnchorElement;
|
|
112
|
-
expect(a?.getAttribute('href')).toBe('
|
|
112
|
+
expect(a?.getAttribute('href')).toBe('workspace-nav:src%2Fa.ts');
|
|
113
113
|
// javascript: stays blocked even when a host lists it
|
|
114
114
|
const evil = render(
|
|
115
115
|
<MarkdownRenderer
|
|
@@ -289,7 +289,7 @@ describe('MarkdownRenderer', () => {
|
|
|
289
289
|
expect(container.querySelector('span')).toBeNull();
|
|
290
290
|
});
|
|
291
291
|
|
|
292
|
-
it('preserves
|
|
292
|
+
it('preserves trusted HTML structure without executable attributes', () => {
|
|
293
293
|
const { container } = render(
|
|
294
294
|
<MarkdownRenderer
|
|
295
295
|
nodes={parseNodes('<div><span class="trusted" onclick="alert(1)">ok</span></div>')}
|
|
@@ -297,7 +297,18 @@ describe('MarkdownRenderer', () => {
|
|
|
297
297
|
/>,
|
|
298
298
|
);
|
|
299
299
|
const span = container.querySelector('span.trusted');
|
|
300
|
-
expect(span?.getAttribute('onclick')).
|
|
300
|
+
expect(span?.getAttribute('onclick')).toBeNull();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('blocks executable URLs under the trusted policy', () => {
|
|
304
|
+
const { container } = render(
|
|
305
|
+
<MarkdownRenderer
|
|
306
|
+
nodes={parseNodes('<a href="javascript:alert(1)">bad</a><img src="javascript:x">')}
|
|
307
|
+
htmlPolicy="trusted"
|
|
308
|
+
/>,
|
|
309
|
+
);
|
|
310
|
+
expect(container.querySelector('a')?.getAttribute('href')).toBeNull();
|
|
311
|
+
expect(container.querySelector('img')?.getAttribute('src')).toBeNull();
|
|
301
312
|
});
|
|
302
313
|
|
|
303
314
|
// Host-affecting tags (style/script/…) must never reach the document.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { act, render, waitFor } from '@testing-library/react';
|
|
3
|
+
import type { MediaProvider, ScheduledClip } from '@bendyline/squisq/schemas';
|
|
4
|
+
import { MediaClipLayer } from '../MediaClipLayer';
|
|
5
|
+
import { MediaContext } from '../hooks/MediaContext';
|
|
6
|
+
|
|
7
|
+
afterEach(() => vi.restoreAllMocks());
|
|
8
|
+
|
|
9
|
+
describe('MediaClipLayer', () => {
|
|
10
|
+
it('mutes scheduled audio when the player muted contract is enabled', () => {
|
|
11
|
+
vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => {});
|
|
12
|
+
const clip: ScheduledClip = {
|
|
13
|
+
id: 'narration',
|
|
14
|
+
kind: 'audio',
|
|
15
|
+
src: 'narration.mp3',
|
|
16
|
+
absoluteStart: 0,
|
|
17
|
+
absoluteEnd: 5,
|
|
18
|
+
sourceIn: 0,
|
|
19
|
+
anchor: 'document',
|
|
20
|
+
};
|
|
21
|
+
const { container } = render(
|
|
22
|
+
<MediaClipLayer schedule={[clip]} currentTime={0} isPlaying={false} basePath="." muted />,
|
|
23
|
+
);
|
|
24
|
+
expect(container.querySelector('audio')?.muted).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('restores a paused clip position after an async media URL resolves', async () => {
|
|
28
|
+
vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => {});
|
|
29
|
+
let resolveUrl!: (url: string) => void;
|
|
30
|
+
const provider = {
|
|
31
|
+
resolveUrl: vi.fn(
|
|
32
|
+
() =>
|
|
33
|
+
new Promise<string>((resolve) => {
|
|
34
|
+
resolveUrl = resolve;
|
|
35
|
+
}),
|
|
36
|
+
),
|
|
37
|
+
} as unknown as MediaProvider;
|
|
38
|
+
const clip: ScheduledClip = {
|
|
39
|
+
id: 'background-video',
|
|
40
|
+
kind: 'video',
|
|
41
|
+
src: 'background.mp4',
|
|
42
|
+
absoluteStart: 0,
|
|
43
|
+
absoluteEnd: 10,
|
|
44
|
+
sourceIn: 2,
|
|
45
|
+
anchor: 'document',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const { container } = render(
|
|
49
|
+
<MediaContext.Provider value={provider}>
|
|
50
|
+
<MediaClipLayer
|
|
51
|
+
schedule={[clip]}
|
|
52
|
+
currentTime={5}
|
|
53
|
+
isPlaying={false}
|
|
54
|
+
basePath="/media"
|
|
55
|
+
muted
|
|
56
|
+
/>
|
|
57
|
+
</MediaContext.Provider>,
|
|
58
|
+
);
|
|
59
|
+
const video = container.querySelector('video')!;
|
|
60
|
+
await waitFor(() => expect(video.currentTime).toBe(7));
|
|
61
|
+
|
|
62
|
+
// Replacing an HTMLMediaElement source resets its playback position in a
|
|
63
|
+
// browser. Simulate that reset before the provider's blob URL arrives.
|
|
64
|
+
video.currentTime = 0;
|
|
65
|
+
await act(async () => resolveUrl('blob:resolved-background'));
|
|
66
|
+
|
|
67
|
+
await waitFor(() => expect(video.getAttribute('src')).toBe('blob:resolved-background'));
|
|
68
|
+
expect(video.currentTime).toBe(7);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { act, render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { MediaProvider } from '@bendyline/squisq/schemas';
|
|
4
|
+
import { MediaContext, useMediaUrl } from '../hooks/MediaContext';
|
|
5
|
+
|
|
6
|
+
function deferred<T>() {
|
|
7
|
+
let resolve!: (value: T) => void;
|
|
8
|
+
let reject!: (reason?: unknown) => void;
|
|
9
|
+
const promise = new Promise<T>((res, rej) => {
|
|
10
|
+
resolve = res;
|
|
11
|
+
reject = rej;
|
|
12
|
+
});
|
|
13
|
+
return { promise, resolve, reject };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function Probe({ path }: { path: string }) {
|
|
17
|
+
return <span data-testid="url">{useMediaUrl(path, '.')}</span>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('useMediaUrl', () => {
|
|
21
|
+
it('clears a stale URL and consumes provider rejection', async () => {
|
|
22
|
+
const first = deferred<string>();
|
|
23
|
+
const second = deferred<string>();
|
|
24
|
+
const resolveUrl = vi
|
|
25
|
+
.fn()
|
|
26
|
+
.mockReturnValueOnce(first.promise)
|
|
27
|
+
.mockReturnValueOnce(second.promise);
|
|
28
|
+
const provider = { resolveUrl } as unknown as MediaProvider;
|
|
29
|
+
const { rerender } = render(
|
|
30
|
+
<MediaContext.Provider value={provider}>
|
|
31
|
+
<Probe path="first.png" />
|
|
32
|
+
</MediaContext.Provider>,
|
|
33
|
+
);
|
|
34
|
+
expect(screen.getByTestId('url').textContent).toBe('./first.png');
|
|
35
|
+
|
|
36
|
+
rerender(
|
|
37
|
+
<MediaContext.Provider value={provider}>
|
|
38
|
+
<Probe path="second.png" />
|
|
39
|
+
</MediaContext.Provider>,
|
|
40
|
+
);
|
|
41
|
+
expect(screen.getByTestId('url').textContent).toBe('./second.png');
|
|
42
|
+
|
|
43
|
+
await act(async () => {
|
|
44
|
+
first.resolve('blob:first');
|
|
45
|
+
await first.promise;
|
|
46
|
+
second.reject(new Error('missing'));
|
|
47
|
+
await second.promise.catch(() => undefined);
|
|
48
|
+
});
|
|
49
|
+
expect(screen.getByTestId('url').textContent).toBe('./second.png');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -11,7 +11,7 @@ function renderPath(layer: PathLayerType) {
|
|
|
11
11
|
<PathLayer layer={layer} viewport={viewport} blockTime={0} />
|
|
12
12
|
</svg>,
|
|
13
13
|
);
|
|
14
|
-
return container.querySelector('path')!;
|
|
14
|
+
return container.querySelector('.block-layer--path > path')!;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
describe('PathLayer', () => {
|
|
@@ -70,4 +70,15 @@ describe('PathLayer', () => {
|
|
|
70
70
|
});
|
|
71
71
|
expect(path.getAttribute('d')).toBe('M 1 2 L 3 4');
|
|
72
72
|
});
|
|
73
|
+
|
|
74
|
+
it('reads the old serialized arrow field without exposing it in the PathLayer type', () => {
|
|
75
|
+
const legacyLayer = {
|
|
76
|
+
id: 'legacy-arrow',
|
|
77
|
+
type: 'path',
|
|
78
|
+
position: { x: 0, y: 0, width: 100, height: 100 },
|
|
79
|
+
content: { d: 'M 0 0 L 100 100', arrow: 'end' },
|
|
80
|
+
} as unknown as PathLayerType;
|
|
81
|
+
|
|
82
|
+
expect(renderPath(legacyLayer).getAttribute('marker-end')).toMatch(/^url\(#marker-end-/);
|
|
83
|
+
});
|
|
73
84
|
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** @vitest-environment jsdom */
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, render, waitFor } from '@testing-library/react';
|
|
5
|
+
import type { VideoLayer as VideoLayerType } from '@bendyline/squisq/schemas';
|
|
6
|
+
import { VideoLayer } from '../layers/VideoLayer';
|
|
7
|
+
|
|
8
|
+
const layer: VideoLayerType = {
|
|
9
|
+
id: 'video',
|
|
10
|
+
type: 'video',
|
|
11
|
+
position: { x: 0, y: 0, width: 640, height: 360 },
|
|
12
|
+
content: {
|
|
13
|
+
src: 'clip.mp4',
|
|
14
|
+
alt: 'Demo clip',
|
|
15
|
+
clipStart: 2,
|
|
16
|
+
clipEnd: 8,
|
|
17
|
+
startAt: 1,
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
|
|
23
|
+
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
cleanup();
|
|
28
|
+
vi.restoreAllMocks();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('VideoLayer playback synchronization', () => {
|
|
32
|
+
it('joins the document clock when mounted partway through a block', async () => {
|
|
33
|
+
const { container, rerender } = render(
|
|
34
|
+
<svg>
|
|
35
|
+
<VideoLayer
|
|
36
|
+
layer={layer}
|
|
37
|
+
basePath="/media"
|
|
38
|
+
viewport={{ width: 640, height: 360 }}
|
|
39
|
+
blockTime={4}
|
|
40
|
+
isPlaying
|
|
41
|
+
/>
|
|
42
|
+
</svg>,
|
|
43
|
+
);
|
|
44
|
+
const video = container.querySelector('video')!;
|
|
45
|
+
|
|
46
|
+
// clipStart 2 + (blockTime 4 - startAt 1) = source time 5.
|
|
47
|
+
await waitFor(() => expect(video.currentTime).toBe(5));
|
|
48
|
+
|
|
49
|
+
rerender(
|
|
50
|
+
<svg>
|
|
51
|
+
<VideoLayer
|
|
52
|
+
layer={layer}
|
|
53
|
+
basePath="/media"
|
|
54
|
+
viewport={{ width: 640, height: 360 }}
|
|
55
|
+
blockTime={6}
|
|
56
|
+
isPlaying
|
|
57
|
+
/>
|
|
58
|
+
</svg>,
|
|
59
|
+
);
|
|
60
|
+
expect(video.currentTime).toBe(7);
|
|
61
|
+
|
|
62
|
+
rerender(
|
|
63
|
+
<svg>
|
|
64
|
+
<VideoLayer
|
|
65
|
+
layer={layer}
|
|
66
|
+
basePath="/media"
|
|
67
|
+
viewport={{ width: 640, height: 360 }}
|
|
68
|
+
blockTime={20}
|
|
69
|
+
isPlaying
|
|
70
|
+
/>
|
|
71
|
+
</svg>,
|
|
72
|
+
);
|
|
73
|
+
expect(video.currentTime).toBe(8);
|
|
74
|
+
expect(video.pause).toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('holds at the in-point until startAt', async () => {
|
|
78
|
+
const { container } = render(
|
|
79
|
+
<svg>
|
|
80
|
+
<VideoLayer
|
|
81
|
+
layer={layer}
|
|
82
|
+
basePath="/media"
|
|
83
|
+
viewport={{ width: 640, height: 360 }}
|
|
84
|
+
blockTime={0.5}
|
|
85
|
+
isPlaying
|
|
86
|
+
/>
|
|
87
|
+
</svg>,
|
|
88
|
+
);
|
|
89
|
+
const video = container.querySelector('video')!;
|
|
90
|
+
|
|
91
|
+
await waitFor(() => expect(video.currentTime).toBe(2));
|
|
92
|
+
expect(video.pause).toHaveBeenCalled();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -44,6 +44,13 @@ describe('ShapeLayer fill/border', () => {
|
|
|
44
44
|
} as ShapeLayerType;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
function makeFullBleedShape(content: Partial<ShapeLayerType['content']>): ShapeLayerType {
|
|
48
|
+
return {
|
|
49
|
+
...makeShape(content),
|
|
50
|
+
position: { x: 0, y: 0, width: '100%', height: '100%' },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
47
54
|
it('applies fill opacity and a dashed border', () => {
|
|
48
55
|
const { container } = render(
|
|
49
56
|
<svg>
|
|
@@ -75,8 +82,49 @@ describe('ShapeLayer fill/border', () => {
|
|
|
75
82
|
/>
|
|
76
83
|
</svg>,
|
|
77
84
|
);
|
|
78
|
-
|
|
79
|
-
expect(
|
|
85
|
+
const gradient = container.querySelector('linearGradient');
|
|
86
|
+
expect(gradient).not.toBeNull();
|
|
87
|
+
expect(container.querySelector('rect')!.getAttribute('fill')).toBe(`url(#${gradient!.id})`);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('overscans a full-bleed solid shade to prevent an image edge seam', () => {
|
|
91
|
+
const { container } = render(
|
|
92
|
+
<svg>
|
|
93
|
+
<ShapeLayer
|
|
94
|
+
layer={makeFullBleedShape({ fill: 'rgba(0, 0, 0, 0.5)' })}
|
|
95
|
+
viewport={viewport}
|
|
96
|
+
blockTime={0}
|
|
97
|
+
/>
|
|
98
|
+
</svg>,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const rect = container.querySelector('rect')!;
|
|
102
|
+
expect(rect.getAttribute('x')).toBe('-1');
|
|
103
|
+
expect(rect.getAttribute('y')).toBe('-1');
|
|
104
|
+
expect(rect.getAttribute('width')).toBe('1002');
|
|
105
|
+
expect(rect.getAttribute('height')).toBe('1002');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('overscans a full-bleed CSS gradient shade and its HTML fill', () => {
|
|
109
|
+
const { container } = render(
|
|
110
|
+
<svg>
|
|
111
|
+
<ShapeLayer
|
|
112
|
+
layer={makeFullBleedShape({
|
|
113
|
+
fill: 'linear-gradient(0deg, rgba(0,0,0,0.8), transparent)',
|
|
114
|
+
})}
|
|
115
|
+
viewport={viewport}
|
|
116
|
+
blockTime={0}
|
|
117
|
+
/>
|
|
118
|
+
</svg>,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const foreignObject = container.querySelector('foreignObject')!;
|
|
122
|
+
expect(foreignObject.getAttribute('x')).toBe('-1');
|
|
123
|
+
expect(foreignObject.getAttribute('y')).toBe('-1');
|
|
124
|
+
expect(foreignObject.getAttribute('width')).toBe('1002');
|
|
125
|
+
expect(foreignObject.getAttribute('height')).toBe('1002');
|
|
126
|
+
expect(foreignObject.querySelector('div')!.style.width).toBe('1002px');
|
|
127
|
+
expect(foreignObject.querySelector('div')!.style.height).toBe('1002px');
|
|
80
128
|
});
|
|
81
129
|
});
|
|
82
130
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import type { Doc } from '@bendyline/squisq/schemas';
|
|
3
|
+
import { getHandle, mount, unmount } from '../standalone-entry';
|
|
4
|
+
import * as standalone from '../standalone-entry';
|
|
5
|
+
|
|
6
|
+
function doc(id: string): Doc {
|
|
7
|
+
return {
|
|
8
|
+
articleId: id,
|
|
9
|
+
duration: 2,
|
|
10
|
+
blocks: [{ id: `${id}-block`, startTime: 0, duration: 2, audioSegment: 0, layers: [] }],
|
|
11
|
+
audio: { segments: [] },
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function animatedDoc(id: string): Doc {
|
|
16
|
+
const result = doc(id);
|
|
17
|
+
result.blocks[0].layers = [
|
|
18
|
+
{
|
|
19
|
+
type: 'text',
|
|
20
|
+
id: `${id}-title`,
|
|
21
|
+
content: { text: 'Standalone motion', style: { fontSize: 48, color: '#fff' } },
|
|
22
|
+
position: { x: 100, y: 100 },
|
|
23
|
+
animation: { type: 'fadeIn', duration: 1 },
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const mountedElements: Element[] = [];
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
for (const element of mountedElements.splice(0)) unmount(element);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('standalone player instance handles', () => {
|
|
36
|
+
it('does not expose the removed mountStatic compatibility alias', () => {
|
|
37
|
+
expect('mountStatic' in standalone).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns the render API for exactly the mounted player instance', async () => {
|
|
41
|
+
const firstRoot = document.createElement('div');
|
|
42
|
+
const secondRoot = document.createElement('div');
|
|
43
|
+
document.body.append(firstRoot, secondRoot);
|
|
44
|
+
mountedElements.push(firstRoot, secondRoot);
|
|
45
|
+
|
|
46
|
+
const first = mount(firstRoot, doc('first'), { renderMode: true });
|
|
47
|
+
const second = mount(secondRoot, doc('second'), { renderMode: true });
|
|
48
|
+
const [firstAPI, secondAPI] = await Promise.all([first.renderAPI, second.renderAPI]);
|
|
49
|
+
|
|
50
|
+
expect(firstAPI?.getBlocks()[0].id).toBe('first-block');
|
|
51
|
+
expect(secondAPI?.getBlocks()[0].id).toBe('second-block');
|
|
52
|
+
expect(getHandle(firstRoot)).toBe(first);
|
|
53
|
+
expect(getHandle(secondRoot)).toBe(second);
|
|
54
|
+
expect('seekTo' in window).toBe(false);
|
|
55
|
+
expect('getDuration' in window).toBe(false);
|
|
56
|
+
expect('squisqActivePlayerId' in window).toBe(false);
|
|
57
|
+
expect('squisqPlayers' in window).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('resolves a null render API outside render mode and owns unmounting', async () => {
|
|
61
|
+
const root = document.createElement('div');
|
|
62
|
+
document.body.append(root);
|
|
63
|
+
mountedElements.push(root);
|
|
64
|
+
|
|
65
|
+
const handle = mount(root, doc('static'), { mode: 'static' });
|
|
66
|
+
expect(await handle.renderAPI).toBeNull();
|
|
67
|
+
expect(getHandle(root)).toBe(handle);
|
|
68
|
+
|
|
69
|
+
handle.unmount();
|
|
70
|
+
expect(getHandle(root)).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('prevents a stale handle from unmounting a newer player in the same element', async () => {
|
|
74
|
+
const root = document.createElement('div');
|
|
75
|
+
document.body.append(root);
|
|
76
|
+
mountedElements.push(root);
|
|
77
|
+
|
|
78
|
+
const first = mount(root, doc('first'), { renderMode: true });
|
|
79
|
+
expect((await first.renderAPI)?.getBlocks()[0].id).toBe('first-block');
|
|
80
|
+
|
|
81
|
+
const second = mount(root, doc('second'), { renderMode: true });
|
|
82
|
+
expect((await second.renderAPI)?.getBlocks()[0].id).toBe('second-block');
|
|
83
|
+
expect(first.getRenderAPI()).toBeNull();
|
|
84
|
+
|
|
85
|
+
first.unmount();
|
|
86
|
+
expect(getHandle(root)).toBe(second);
|
|
87
|
+
expect(second.getRenderAPI()?.getBlocks()[0].id).toBe('second-block');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('forwards the animationsEnabled render policy to the mounted player', async () => {
|
|
91
|
+
const root = document.createElement('div');
|
|
92
|
+
document.body.append(root);
|
|
93
|
+
mountedElements.push(root);
|
|
94
|
+
|
|
95
|
+
const handle = mount(root, animatedDoc('motionless'), {
|
|
96
|
+
renderMode: true,
|
|
97
|
+
animationsEnabled: false,
|
|
98
|
+
});
|
|
99
|
+
await handle.renderAPI;
|
|
100
|
+
|
|
101
|
+
expect(root.querySelector('[class*="anim-"]')).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
});
|