@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
@@ -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('warns once per unknown template name and falls back to plain markdown', () => {
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.querySelector('.squisq-linear-card')).toBeNull();
324
-
325
- // Re-render: the warning stays one-shot per unknown template name.
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](gezel-nav:src%2Fa.ts) [b](javascript:alert(1))');
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={['gezel-nav']} />);
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('gezel-nav:src%2Fa.ts');
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 raw HTML only with the trusted opt-in', () => {
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')).toBe('alert(1)');
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
- expect(container.querySelector('linearGradient')).not.toBeNull();
79
- expect(container.querySelector('rect')!.getAttribute('fill')).toBe('url(#squisq-grad-s1)');
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
+ });