@bendyline/squisq-react 1.3.1 → 1.4.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 (215) hide show
  1. package/dist/index.d.ts +766 -35
  2. package/dist/index.js +4793 -33
  3. package/dist/index.js.map +1 -1
  4. package/dist/squisq-player.css +1 -1
  5. package/dist/squisq-player.css.map +1 -1
  6. package/dist/squisq-player.global.js +17 -13
  7. package/dist/squisq-player.global.js.map +1 -1
  8. package/dist/standalone-source.js +1 -1
  9. package/package.json +3 -2
  10. package/src/BlockRenderer.tsx +15 -7
  11. package/src/DocPlayer.tsx +65 -12
  12. package/src/DocProgressBar.tsx +21 -3
  13. package/src/LinearDocView.tsx +11 -197
  14. package/src/MarkdownRenderer.tsx +165 -41
  15. package/src/MediaClipLayer.tsx +135 -0
  16. package/src/__tests__/DocPlayer.test.tsx +51 -0
  17. package/src/__tests__/DocProgressBar.test.tsx +76 -0
  18. package/src/__tests__/MarkdownRenderer.test.tsx +95 -1
  19. package/src/__tests__/PathLayer.test.tsx +73 -0
  20. package/src/__tests__/fillStyle.test.tsx +112 -0
  21. package/src/__tests__/transitionStyles.test.ts +125 -0
  22. package/src/__tests__/useDocPlayback.transition.test.ts +70 -0
  23. package/src/hooks/useAudioSync.ts +14 -1
  24. package/src/hooks/useDocPlayback.ts +81 -100
  25. package/src/hooks/useMediaSchedule.ts +39 -0
  26. package/src/index.ts +7 -0
  27. package/src/layers/ImageLayer.tsx +11 -1
  28. package/src/layers/PathLayer.tsx +146 -0
  29. package/src/layers/ShapeLayer.tsx +27 -5
  30. package/src/layers/TextLayer.tsx +395 -22
  31. package/src/layers/VideoLayer.tsx +16 -9
  32. package/src/layers/index.ts +1 -0
  33. package/src/styles/doc-animations.css +1857 -2
  34. package/src/utils/fillStyle.tsx +148 -0
  35. package/dist/BlockRenderer.d.ts +0 -37
  36. package/dist/BlockRenderer.d.ts.map +0 -1
  37. package/dist/BlockRenderer.js +0 -54
  38. package/dist/BlockRenderer.js.map +0 -1
  39. package/dist/CaptionOverlay.d.ts +0 -33
  40. package/dist/CaptionOverlay.d.ts.map +0 -1
  41. package/dist/CaptionOverlay.js +0 -44
  42. package/dist/CaptionOverlay.js.map +0 -1
  43. package/dist/DocControlsBottom.d.ts +0 -28
  44. package/dist/DocControlsBottom.d.ts.map +0 -1
  45. package/dist/DocControlsBottom.js +0 -27
  46. package/dist/DocControlsBottom.js.map +0 -1
  47. package/dist/DocControlsOverlay.d.ts +0 -28
  48. package/dist/DocControlsOverlay.d.ts.map +0 -1
  49. package/dist/DocControlsOverlay.js +0 -80
  50. package/dist/DocControlsOverlay.js.map +0 -1
  51. package/dist/DocControlsSidebar.d.ts +0 -27
  52. package/dist/DocControlsSidebar.d.ts.map +0 -1
  53. package/dist/DocControlsSidebar.js +0 -10
  54. package/dist/DocControlsSidebar.js.map +0 -1
  55. package/dist/DocControlsSlideshow.d.ts +0 -20
  56. package/dist/DocControlsSlideshow.d.ts.map +0 -1
  57. package/dist/DocControlsSlideshow.js +0 -69
  58. package/dist/DocControlsSlideshow.js.map +0 -1
  59. package/dist/DocPlayer.d.ts +0 -97
  60. package/dist/DocPlayer.d.ts.map +0 -1
  61. package/dist/DocPlayer.js +0 -657
  62. package/dist/DocPlayer.js.map +0 -1
  63. package/dist/DocPlayerWithSidebar.d.ts +0 -41
  64. package/dist/DocPlayerWithSidebar.d.ts.map +0 -1
  65. package/dist/DocPlayerWithSidebar.js +0 -69
  66. package/dist/DocPlayerWithSidebar.js.map +0 -1
  67. package/dist/DocProgressBar.d.ts +0 -31
  68. package/dist/DocProgressBar.d.ts.map +0 -1
  69. package/dist/DocProgressBar.js +0 -122
  70. package/dist/DocProgressBar.js.map +0 -1
  71. package/dist/InlineAudioPlayer.d.ts +0 -15
  72. package/dist/InlineAudioPlayer.d.ts.map +0 -1
  73. package/dist/InlineAudioPlayer.js +0 -22
  74. package/dist/InlineAudioPlayer.js.map +0 -1
  75. package/dist/InlineVideoPlayer.d.ts +0 -21
  76. package/dist/InlineVideoPlayer.d.ts.map +0 -1
  77. package/dist/InlineVideoPlayer.js +0 -29
  78. package/dist/InlineVideoPlayer.js.map +0 -1
  79. package/dist/LinearDocView.d.ts +0 -73
  80. package/dist/LinearDocView.d.ts.map +0 -1
  81. package/dist/LinearDocView.js +0 -451
  82. package/dist/LinearDocView.js.map +0 -1
  83. package/dist/MarkdownRenderer.d.ts +0 -32
  84. package/dist/MarkdownRenderer.d.ts.map +0 -1
  85. package/dist/MarkdownRenderer.js +0 -259
  86. package/dist/MarkdownRenderer.js.map +0 -1
  87. package/dist/SocialCaptionOverlay.d.ts +0 -27
  88. package/dist/SocialCaptionOverlay.d.ts.map +0 -1
  89. package/dist/SocialCaptionOverlay.js +0 -185
  90. package/dist/SocialCaptionOverlay.js.map +0 -1
  91. package/dist/__tests__/BlockRenderer.test.d.ts +0 -2
  92. package/dist/__tests__/BlockRenderer.test.d.ts.map +0 -1
  93. package/dist/__tests__/BlockRenderer.test.js +0 -82
  94. package/dist/__tests__/BlockRenderer.test.js.map +0 -1
  95. package/dist/__tests__/DocControlsSlideshow.test.d.ts +0 -2
  96. package/dist/__tests__/DocControlsSlideshow.test.d.ts.map +0 -1
  97. package/dist/__tests__/DocControlsSlideshow.test.js +0 -89
  98. package/dist/__tests__/DocControlsSlideshow.test.js.map +0 -1
  99. package/dist/__tests__/JsonView.test.d.ts +0 -2
  100. package/dist/__tests__/JsonView.test.d.ts.map +0 -1
  101. package/dist/__tests__/JsonView.test.js +0 -93
  102. package/dist/__tests__/JsonView.test.js.map +0 -1
  103. package/dist/__tests__/LinearDocView.test.d.ts +0 -2
  104. package/dist/__tests__/LinearDocView.test.d.ts.map +0 -1
  105. package/dist/__tests__/LinearDocView.test.js +0 -250
  106. package/dist/__tests__/LinearDocView.test.js.map +0 -1
  107. package/dist/__tests__/MarkdownRenderer.test.d.ts +0 -2
  108. package/dist/__tests__/MarkdownRenderer.test.d.ts.map +0 -1
  109. package/dist/__tests__/MarkdownRenderer.test.js +0 -208
  110. package/dist/__tests__/MarkdownRenderer.test.js.map +0 -1
  111. package/dist/__tests__/TableLayer.test.d.ts +0 -2
  112. package/dist/__tests__/TableLayer.test.d.ts.map +0 -1
  113. package/dist/__tests__/TableLayer.test.js +0 -121
  114. package/dist/__tests__/TableLayer.test.js.map +0 -1
  115. package/dist/__tests__/exports.test.d.ts +0 -2
  116. package/dist/__tests__/exports.test.d.ts.map +0 -1
  117. package/dist/__tests__/exports.test.js +0 -48
  118. package/dist/__tests__/exports.test.js.map +0 -1
  119. package/dist/hooks/AudioProvider.d.ts +0 -71
  120. package/dist/hooks/AudioProvider.d.ts.map +0 -1
  121. package/dist/hooks/AudioProvider.js +0 -52
  122. package/dist/hooks/AudioProvider.js.map +0 -1
  123. package/dist/hooks/MediaContext.d.ts +0 -35
  124. package/dist/hooks/MediaContext.d.ts.map +0 -1
  125. package/dist/hooks/MediaContext.js +0 -72
  126. package/dist/hooks/MediaContext.js.map +0 -1
  127. package/dist/hooks/index.d.ts +0 -6
  128. package/dist/hooks/index.d.ts.map +0 -1
  129. package/dist/hooks/index.js +0 -5
  130. package/dist/hooks/index.js.map +0 -1
  131. package/dist/hooks/useAudioSync.d.ts +0 -18
  132. package/dist/hooks/useAudioSync.d.ts.map +0 -1
  133. package/dist/hooks/useAudioSync.js +0 -340
  134. package/dist/hooks/useAudioSync.js.map +0 -1
  135. package/dist/hooks/useAutoSurface.d.ts +0 -11
  136. package/dist/hooks/useAutoSurface.d.ts.map +0 -1
  137. package/dist/hooks/useAutoSurface.js +0 -24
  138. package/dist/hooks/useAutoSurface.js.map +0 -1
  139. package/dist/hooks/useDocPlayback.d.ts +0 -47
  140. package/dist/hooks/useDocPlayback.d.ts.map +0 -1
  141. package/dist/hooks/useDocPlayback.js +0 -179
  142. package/dist/hooks/useDocPlayback.js.map +0 -1
  143. package/dist/hooks/useViewportOrientation.d.ts +0 -31
  144. package/dist/hooks/useViewportOrientation.d.ts.map +0 -1
  145. package/dist/hooks/useViewportOrientation.js +0 -92
  146. package/dist/hooks/useViewportOrientation.js.map +0 -1
  147. package/dist/index.d.ts.map +0 -1
  148. package/dist/jsonView/JsonView.d.ts +0 -26
  149. package/dist/jsonView/JsonView.d.ts.map +0 -1
  150. package/dist/jsonView/JsonView.js +0 -12
  151. package/dist/jsonView/JsonView.js.map +0 -1
  152. package/dist/jsonView/RenderNode.d.ts +0 -22
  153. package/dist/jsonView/RenderNode.d.ts.map +0 -1
  154. package/dist/jsonView/RenderNode.js +0 -30
  155. package/dist/jsonView/RenderNode.js.map +0 -1
  156. package/dist/jsonView/index.d.ts +0 -3
  157. package/dist/jsonView/index.d.ts.map +0 -1
  158. package/dist/jsonView/index.js +0 -2
  159. package/dist/jsonView/index.js.map +0 -1
  160. package/dist/jsonView/useJsonViewTokens.d.ts +0 -14
  161. package/dist/jsonView/useJsonViewTokens.d.ts.map +0 -1
  162. package/dist/jsonView/useJsonViewTokens.js +0 -34
  163. package/dist/jsonView/useJsonViewTokens.js.map +0 -1
  164. package/dist/jsonView/viewers.d.ts +0 -30
  165. package/dist/jsonView/viewers.d.ts.map +0 -1
  166. package/dist/jsonView/viewers.js +0 -226
  167. package/dist/jsonView/viewers.js.map +0 -1
  168. package/dist/layers/ImageLayer.d.ts +0 -23
  169. package/dist/layers/ImageLayer.d.ts.map +0 -1
  170. package/dist/layers/ImageLayer.js +0 -104
  171. package/dist/layers/ImageLayer.js.map +0 -1
  172. package/dist/layers/MapLayer.d.ts +0 -29
  173. package/dist/layers/MapLayer.d.ts.map +0 -1
  174. package/dist/layers/MapLayer.js +0 -97
  175. package/dist/layers/MapLayer.js.map +0 -1
  176. package/dist/layers/ShapeLayer.d.ts +0 -20
  177. package/dist/layers/ShapeLayer.d.ts.map +0 -1
  178. package/dist/layers/ShapeLayer.js +0 -39
  179. package/dist/layers/ShapeLayer.js.map +0 -1
  180. package/dist/layers/TableLayer.d.ts +0 -23
  181. package/dist/layers/TableLayer.d.ts.map +0 -1
  182. package/dist/layers/TableLayer.js +0 -58
  183. package/dist/layers/TableLayer.js.map +0 -1
  184. package/dist/layers/TextLayer.d.ts +0 -23
  185. package/dist/layers/TextLayer.d.ts.map +0 -1
  186. package/dist/layers/TextLayer.js +0 -126
  187. package/dist/layers/TextLayer.js.map +0 -1
  188. package/dist/layers/VideoLayer.d.ts +0 -40
  189. package/dist/layers/VideoLayer.d.ts.map +0 -1
  190. package/dist/layers/VideoLayer.js +0 -105
  191. package/dist/layers/VideoLayer.js.map +0 -1
  192. package/dist/layers/index.d.ts +0 -6
  193. package/dist/layers/index.d.ts.map +0 -1
  194. package/dist/layers/index.js +0 -6
  195. package/dist/layers/index.js.map +0 -1
  196. package/dist/standalone-entry.d.ts +0 -69
  197. package/dist/standalone-entry.d.ts.map +0 -1
  198. package/dist/standalone-entry.js +0 -185
  199. package/dist/standalone-entry.js.map +0 -1
  200. package/dist/types.d.ts +0 -154
  201. package/dist/types.d.ts.map +0 -1
  202. package/dist/types.js +0 -21
  203. package/dist/types.js.map +0 -1
  204. package/dist/utils/animationUtils.d.ts +0 -8
  205. package/dist/utils/animationUtils.d.ts.map +0 -1
  206. package/dist/utils/animationUtils.js +0 -8
  207. package/dist/utils/animationUtils.js.map +0 -1
  208. package/dist/utils/layerUtils.d.ts +0 -17
  209. package/dist/utils/layerUtils.d.ts.map +0 -1
  210. package/dist/utils/layerUtils.js +0 -37
  211. package/dist/utils/layerUtils.js.map +0 -1
  212. package/dist/utils/mapTileUtils.d.ts +0 -105
  213. package/dist/utils/mapTileUtils.d.ts.map +0 -1
  214. package/dist/utils/mapTileUtils.js +0 -268
  215. package/dist/utils/mapTileUtils.js.map +0 -1
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render } from '@testing-library/react';
3
+ import { DocProgressBar } from '../DocProgressBar';
4
+ import type { PlaybackState, PlaybackActions } from '../types';
5
+
6
+ function makeState(overrides: Partial<PlaybackState> = {}): PlaybackState {
7
+ return {
8
+ isPlaying: true,
9
+ currentTime: 0,
10
+ totalDuration: 60,
11
+ currentBlockIndex: 0,
12
+ totalBlocks: 3,
13
+ docProgress: 0,
14
+ hasCaptions: false,
15
+ captionsEnabled: false,
16
+ captionMode: 'off',
17
+ currentSegmentIndex: 0,
18
+ currentSegmentName: null,
19
+ currentBlock: null,
20
+ ...overrides,
21
+ };
22
+ }
23
+
24
+ const actions: PlaybackActions = {
25
+ toggle: () => {},
26
+ restart: () => {},
27
+ seekTo: () => {},
28
+ setCaptionsEnabled: () => {},
29
+ cycleCaptionMode: () => {},
30
+ };
31
+
32
+ /** The progress fill is the div tagged `doc-progress-fill`. */
33
+ function fillWidth(container: HTMLElement): string {
34
+ const fill = container.querySelector<HTMLElement>('[data-testid="doc-progress-fill"]');
35
+ if (!fill) throw new Error('progress fill not found');
36
+ return fill.style.width;
37
+ }
38
+
39
+ describe('DocProgressBar fill', () => {
40
+ it('tracks elapsed time over totalDuration (the clock/marker timeline)', () => {
41
+ const { container } = render(
42
+ <DocProgressBar
43
+ state={makeState({ currentTime: 12, totalDuration: 23, docProgress: 0.99 })}
44
+ actions={actions}
45
+ blockMarkers={[]}
46
+ expandedBlocks={[]}
47
+ />,
48
+ );
49
+ // Must reflect 12/23 ≈ 52%, NOT the divergent docProgress (0.99).
50
+ expect(fillWidth(container)).toBe(`${(12 / 23) * 100}%`);
51
+ });
52
+
53
+ it('is 0% when totalDuration is 0 (no divide-by-zero)', () => {
54
+ const { container } = render(
55
+ <DocProgressBar
56
+ state={makeState({ currentTime: 5, totalDuration: 0, docProgress: 0.5 })}
57
+ actions={actions}
58
+ blockMarkers={[]}
59
+ expandedBlocks={[]}
60
+ />,
61
+ );
62
+ expect(fillWidth(container)).toBe('0%');
63
+ });
64
+
65
+ it('clamps to 100% when currentTime exceeds totalDuration', () => {
66
+ const { container } = render(
67
+ <DocProgressBar
68
+ state={makeState({ currentTime: 30, totalDuration: 23 })}
69
+ actions={actions}
70
+ blockMarkers={[]}
71
+ expandedBlocks={[]}
72
+ />,
73
+ );
74
+ expect(fillWidth(container)).toBe('100%');
75
+ });
76
+ });
@@ -1,7 +1,11 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { render } from '@testing-library/react';
3
3
  import { MarkdownRenderer } from '../MarkdownRenderer';
4
- import type { MarkdownBlockNode, MarkdownInlineNode } from '@bendyline/squisq/markdown';
4
+ import {
5
+ parseMarkdown,
6
+ type MarkdownBlockNode,
7
+ type MarkdownInlineNode,
8
+ } from '@bendyline/squisq/markdown';
5
9
 
6
10
  // ── Helpers ────────────────────────────────────────────────────────
7
11
 
@@ -20,6 +24,10 @@ function heading(
20
24
  return { type: 'heading', depth, children };
21
25
  }
22
26
 
27
+ function parseNodes(markdown: string): MarkdownBlockNode[] {
28
+ return parseMarkdown(markdown).children;
29
+ }
30
+
23
31
  // ── Tests ──────────────────────────────────────────────────────────
24
32
 
25
33
  describe('MarkdownRenderer', () => {
@@ -85,6 +93,15 @@ describe('MarkdownRenderer', () => {
85
93
  expect(a.textContent).toBe('click');
86
94
  });
87
95
 
96
+ it('renders unsafe links as inert text by default', () => {
97
+ const { container } = render(
98
+ <MarkdownRenderer nodes={parseNodes('[x](javascript:alert(1))')} />,
99
+ );
100
+ expect(container.querySelector('a.squisq-md-link')).toBeNull();
101
+ expect(container.querySelector('.squisq-md-link--blocked')?.textContent).toBe('x');
102
+ expect(container.innerHTML).not.toContain('javascript:');
103
+ });
104
+
88
105
  it('renders an image', () => {
89
106
  const nodes: MarkdownBlockNode[] = [
90
107
  paragraph({
@@ -231,4 +248,81 @@ describe('MarkdownRenderer', () => {
231
248
  );
232
249
  expect(container.querySelector('.squisq-md.custom')).toBeTruthy();
233
250
  });
251
+
252
+ it('sanitizes raw HTML by default', () => {
253
+ const nodes = parseNodes(
254
+ '<div><img src="x.jpg" onerror="alert(1)"><script>alert(1)</script><span onclick="alert(1)">ok</span></div>',
255
+ );
256
+ const { container } = render(<MarkdownRenderer nodes={nodes} />);
257
+
258
+ expect(container.querySelector('script')).toBeNull();
259
+ expect(container.querySelector('img')?.getAttribute('src')).toBe('x.jpg');
260
+ expect(container.querySelector('img')?.getAttribute('onerror')).toBeNull();
261
+ expect(container.querySelector('span')?.getAttribute('onclick')).toBeNull();
262
+ expect(container.textContent).toContain('ok');
263
+ expect(container.textContent).not.toContain('alert(1)');
264
+ });
265
+
266
+ it('strips raw HTML when requested', () => {
267
+ const { container } = render(
268
+ <MarkdownRenderer nodes={parseNodes('<div><span>hidden</span></div>')} htmlPolicy="strip" />,
269
+ );
270
+ expect(container.textContent).not.toContain('hidden');
271
+ expect(container.querySelector('span')).toBeNull();
272
+ });
273
+
274
+ it('preserves raw HTML only with the trusted opt-in', () => {
275
+ const { container } = render(
276
+ <MarkdownRenderer
277
+ nodes={parseNodes('<div><span class="trusted" onclick="alert(1)">ok</span></div>')}
278
+ htmlPolicy="trusted"
279
+ />,
280
+ );
281
+ const span = container.querySelector('span.trusted');
282
+ expect(span?.getAttribute('onclick')).toBe('alert(1)');
283
+ });
284
+
285
+ // Host-affecting tags (style/script/…) must never reach the document.
286
+ // A bare <style> applies page-wide (CSS isn't scoped outside shadow DOM
287
+ // / iframes), so an embedded game's <style> in a chat message used to
288
+ // restyle the whole app chrome. Guard it across every policy.
289
+ it('drops a raw <style> by default so it cannot leak onto the host', () => {
290
+ const { container } = render(
291
+ <MarkdownRenderer
292
+ nodes={parseNodes('<div><style>body{font-family:monospace}</style><p>hi</p></div>')}
293
+ />,
294
+ );
295
+ expect(container.querySelector('style')).toBeNull();
296
+ expect(container.textContent).toContain('hi');
297
+ expect(container.textContent).not.toContain('font-family');
298
+ });
299
+
300
+ it('drops <style>/<script> even under the trusted policy, keeping safe content', () => {
301
+ const { container } = render(
302
+ <MarkdownRenderer
303
+ nodes={parseNodes(
304
+ '<div class="game"><style>body{font-family:monospace}</style><script>alert(1)</script><p>play</p></div>',
305
+ )}
306
+ htmlPolicy="trusted"
307
+ />,
308
+ );
309
+ expect(container.querySelector('style')).toBeNull();
310
+ expect(container.querySelector('script')).toBeNull();
311
+ expect(container.querySelector('div.game')).toBeTruthy();
312
+ expect(container.textContent).toContain('play');
313
+ expect(container.textContent).not.toContain('font-family');
314
+ });
315
+
316
+ it('drops a raw <style> on the verbatim path when htmlChildren was not parsed', () => {
317
+ // parseHtml:false leaves htmlChildren empty while rawHtml keeps the
318
+ // markup — the raw-string backstop must still refuse the fast path.
319
+ const node = {
320
+ type: 'htmlBlock',
321
+ rawHtml: '<style>body{font-family:monospace}</style><p>play</p>',
322
+ htmlChildren: [],
323
+ } as unknown as MarkdownBlockNode;
324
+ const { container } = render(<MarkdownRenderer nodes={[node]} htmlPolicy="trusted" />);
325
+ expect(container.querySelector('style')).toBeNull();
326
+ expect(container.textContent).not.toContain('font-family');
327
+ });
234
328
  });
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render } from '@testing-library/react';
3
+ import { PathLayer } from '../layers/PathLayer';
4
+ import type { PathLayer as PathLayerType } from '@bendyline/squisq/schemas';
5
+
6
+ const viewport = { width: 1000, height: 1000 };
7
+
8
+ function renderPath(layer: PathLayerType) {
9
+ const { container } = render(
10
+ <svg>
11
+ <PathLayer layer={layer} viewport={viewport} blockTime={0} />
12
+ </svg>,
13
+ );
14
+ return container.querySelector('path')!;
15
+ }
16
+
17
+ describe('PathLayer', () => {
18
+ it('uses the stored absolute `d` for plain paths (no shapeKind)', () => {
19
+ const path = renderPath({
20
+ id: 'p1',
21
+ type: 'path',
22
+ position: { x: 0, y: 0, width: 100, height: 100 },
23
+ content: { d: 'M 10 10 L 90 90' },
24
+ });
25
+ expect(path.getAttribute('d')).toBe('M 10 10 L 90 90');
26
+ });
27
+
28
+ it('derives `d` from the position box for a named shape', () => {
29
+ // A diamond's first vertex is the top-center of its box.
30
+ const path = renderPath({
31
+ id: 'p2',
32
+ type: 'path',
33
+ position: { x: 100, y: 200, width: 400, height: 200 },
34
+ content: { d: 'STALE', shapeKind: 'diamond' },
35
+ });
36
+ const d = path.getAttribute('d')!;
37
+ expect(d).not.toBe('STALE');
38
+ // Top vertex at (x + w/2, y) = (300, 200).
39
+ expect(d.startsWith('M 300 200')).toBe(true);
40
+ });
41
+
42
+ it('moving the position moves a named shape (regenerates `d`)', () => {
43
+ const at = (x: number) =>
44
+ renderPath({
45
+ id: 'p3',
46
+ type: 'path',
47
+ position: { x, y: 0, width: 200, height: 200 },
48
+ content: { d: 'M 0 0', shapeKind: 'diamond' },
49
+ }).getAttribute('d')!;
50
+ expect(at(0)).not.toBe(at(300));
51
+ });
52
+
53
+ it('resolves `%` positions against the viewport', () => {
54
+ const path = renderPath({
55
+ id: 'p4',
56
+ type: 'path',
57
+ position: { x: '0%', y: '0%', width: '50%', height: '50%' },
58
+ content: { d: '', shapeKind: 'diamond' },
59
+ });
60
+ // 50% of a 1000px viewport → 500px box; diamond top vertex at (250, 0).
61
+ expect(path.getAttribute('d')!.startsWith('M 250 0')).toBe(true);
62
+ });
63
+
64
+ it('falls back to the stored `d` when shapeKind is unknown', () => {
65
+ const path = renderPath({
66
+ id: 'p5',
67
+ type: 'path',
68
+ position: { x: 0, y: 0, width: 100, height: 100 },
69
+ content: { d: 'M 1 2 L 3 4', shapeKind: 'not-a-real-shape' },
70
+ });
71
+ expect(path.getAttribute('d')).toBe('M 1 2 L 3 4');
72
+ });
73
+ });
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render } from '@testing-library/react';
3
+ import { borderDashArray, resolveFill } from '../utils/fillStyle';
4
+ import { ShapeLayer } from '../layers/ShapeLayer';
5
+ import { TextLayer } from '../layers/TextLayer';
6
+ import type {
7
+ ShapeLayer as ShapeLayerType,
8
+ TextLayer as TextLayerType,
9
+ } from '@bendyline/squisq/schemas';
10
+
11
+ const viewport = { width: 1000, height: 1000 };
12
+
13
+ describe('borderDashArray', () => {
14
+ it('returns undefined for solid / unset', () => {
15
+ expect(borderDashArray(undefined, 2)).toBeUndefined();
16
+ expect(borderDashArray('solid', 2)).toBeUndefined();
17
+ });
18
+ it('scales the pattern by stroke width', () => {
19
+ expect(borderDashArray('dashed', 2)).toBe('6 4');
20
+ expect(borderDashArray('dotted', 2)).toBe('2 4');
21
+ });
22
+ });
23
+
24
+ describe('resolveFill', () => {
25
+ it('returns the solid color with no def when there is no gradient', () => {
26
+ const { fill, def } = resolveFill('x', '#abcdef', undefined);
27
+ expect(fill).toBe('#abcdef');
28
+ expect(def).toBeNull();
29
+ });
30
+ it('returns a url() fill and a gradient def when a gradient is set', () => {
31
+ const { fill, def } = resolveFill('x', '#abcdef', { from: '#000', to: '#fff', angle: 90 });
32
+ expect(fill).toBe('url(#squisq-grad-x)');
33
+ expect(def).not.toBeNull();
34
+ });
35
+ });
36
+
37
+ describe('ShapeLayer fill/border', () => {
38
+ function makeShape(content: Partial<ShapeLayerType['content']>): ShapeLayerType {
39
+ return {
40
+ id: 's1',
41
+ type: 'shape',
42
+ position: { x: 0, y: 0, width: 100, height: 100 },
43
+ content: { shape: 'rect', ...content },
44
+ } as ShapeLayerType;
45
+ }
46
+
47
+ it('applies fill opacity and a dashed border', () => {
48
+ const { container } = render(
49
+ <svg>
50
+ <ShapeLayer
51
+ layer={makeShape({
52
+ fill: '#3b82f6',
53
+ fillOpacity: 0.5,
54
+ stroke: '#000',
55
+ strokeWidth: 4,
56
+ borderStyle: 'dashed',
57
+ })}
58
+ viewport={viewport}
59
+ blockTime={0}
60
+ />
61
+ </svg>,
62
+ );
63
+ const rect = container.querySelector('rect')!;
64
+ expect(rect.getAttribute('fill-opacity')).toBe('0.5');
65
+ expect(rect.getAttribute('stroke-dasharray')).toBe('12 8');
66
+ });
67
+
68
+ it('renders a gradient via an SVG linearGradient', () => {
69
+ const { container } = render(
70
+ <svg>
71
+ <ShapeLayer
72
+ layer={makeShape({ gradient: { from: '#111', to: '#eee', angle: 0 } })}
73
+ viewport={viewport}
74
+ blockTime={0}
75
+ />
76
+ </svg>,
77
+ );
78
+ expect(container.querySelector('linearGradient')).not.toBeNull();
79
+ expect(container.querySelector('rect')!.getAttribute('fill')).toBe('url(#squisq-grad-s1)');
80
+ });
81
+ });
82
+
83
+ describe('TextLayer box border', () => {
84
+ it('draws a border rect on the position box', () => {
85
+ const layer: TextLayerType = {
86
+ id: 't1',
87
+ type: 'text',
88
+ position: { x: 0, y: 0, width: 400, height: 200 },
89
+ content: {
90
+ text: 'Hi',
91
+ style: {
92
+ fontSize: 40,
93
+ color: '#000',
94
+ borderColor: '#f00',
95
+ borderWidth: 3,
96
+ borderStyle: 'dotted',
97
+ },
98
+ },
99
+ };
100
+ const { container } = render(
101
+ <svg>
102
+ <TextLayer layer={layer} viewport={viewport} blockTime={0} />
103
+ </svg>,
104
+ );
105
+ const rect = container.querySelector('rect')!;
106
+ expect(rect).not.toBeNull();
107
+ expect(rect.getAttribute('stroke')).toBe('#f00');
108
+ expect(rect.getAttribute('stroke-width')).toBe('3');
109
+ expect(rect.getAttribute('width')).toBe('400');
110
+ expect(rect.getAttribute('height')).toBe('200');
111
+ });
112
+ });
@@ -0,0 +1,125 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+ import type { TransitionDirection, TransitionType } from '@bendyline/squisq/schemas';
5
+ import { TRANSITION_TYPES } from '@bendyline/squisq/schemas';
6
+ import { getTransitionClass } from '../utils/animationUtils';
7
+
8
+ const transitionCss = readFileSync(
9
+ resolve(process.cwd(), 'packages/react/src/styles/doc-animations.css'),
10
+ 'utf8',
11
+ );
12
+
13
+ const directionalTypes: TransitionType[] = [
14
+ 'cover',
15
+ 'pan',
16
+ 'pull',
17
+ 'push',
18
+ 'reveal',
19
+ 'strips',
20
+ 'uncover',
21
+ 'wipe',
22
+ ];
23
+
24
+ const axisTypes: TransitionType[] = ['blinds', 'comb', 'randomBar', 'randomBars', 'split'];
25
+ const cardinalDirections = [
26
+ 'left',
27
+ 'right',
28
+ 'up',
29
+ 'down',
30
+ ] as const satisfies readonly TransitionDirection[];
31
+ const axisDirections = ['horizontal', 'vertical'] as const satisfies readonly TransitionDirection[];
32
+
33
+ function collectGeneratedTransitionClasses(): string[] {
34
+ const classNames = new Set<string>();
35
+
36
+ for (const type of TRANSITION_TYPES) {
37
+ if (type === 'cut') continue;
38
+ classNames.add(getTransitionClass(type, true));
39
+ classNames.add(getTransitionClass(type, false));
40
+ }
41
+
42
+ for (const type of directionalTypes) {
43
+ for (const direction of cardinalDirections) {
44
+ classNames.add(getTransitionClass(type, true, direction));
45
+ classNames.add(getTransitionClass(type, false, direction));
46
+ }
47
+ }
48
+
49
+ for (const type of axisTypes) {
50
+ for (const direction of axisDirections) {
51
+ classNames.add(getTransitionClass(type, true, direction));
52
+ classNames.add(getTransitionClass(type, false, direction));
53
+ }
54
+ }
55
+
56
+ return [...classNames].sort();
57
+ }
58
+
59
+ describe('transition CSS coverage', () => {
60
+ it('contains enter and exit selectors for every generated transition class', () => {
61
+ for (const className of collectGeneratedTransitionClasses()) {
62
+ expect(transitionCss, className).toContain(`.${className}`);
63
+ }
64
+ });
65
+ });
66
+
67
+ /** All `@keyframes <name>` defined in the stylesheet. */
68
+ function definedKeyframes(): Set<string> {
69
+ const names = new Set<string>();
70
+ for (const m of transitionCss.matchAll(/@keyframes\s+([A-Za-z0-9_-]+)/g)) {
71
+ names.add(m[1]);
72
+ }
73
+ return names;
74
+ }
75
+
76
+ /**
77
+ * Map each `.transition-<x>-(enter|exit)` selector to the keyframe its rule
78
+ * animates. Parses top-level `selectors { body }` blocks and reads the first
79
+ * identifier of the `animation:` shorthand. Returns null for a transition rule
80
+ * with no `animation` property (itself a defect).
81
+ */
82
+ function transitionRuleKeyframes(): Map<string, string | null> {
83
+ const map = new Map<string, string | null>();
84
+ const ruleRe = /([^{}]+)\{([^{}]*)\}/g;
85
+ let rule: RegExpExecArray | null;
86
+ while ((rule = ruleRe.exec(transitionCss)) !== null) {
87
+ const selectors = rule[1].split(',').map((s) => s.trim());
88
+ const transitionSelectors = selectors.filter((s) =>
89
+ /\.transition-[A-Za-z0-9-]+-(?:enter|exit)\b/.test(s),
90
+ );
91
+ if (transitionSelectors.length === 0) continue;
92
+ const animMatch = rule[2].match(/animation:\s*([A-Za-z0-9_-]+)/);
93
+ const keyframe = animMatch ? animMatch[1] : null;
94
+ for (const selector of transitionSelectors) {
95
+ const classMatch = selector.match(/\.(transition-[A-Za-z0-9-]+-(?:enter|exit))/);
96
+ if (classMatch && !map.has(classMatch[1])) map.set(classMatch[1], keyframe);
97
+ }
98
+ }
99
+ return map;
100
+ }
101
+
102
+ describe('transition keyframe integrity', () => {
103
+ it('every transition rule animates a defined @keyframes (catches typos)', () => {
104
+ const keyframes = definedKeyframes();
105
+ const broken: string[] = [];
106
+ for (const [selector, keyframe] of transitionRuleKeyframes()) {
107
+ if (keyframe === null) broken.push(`${selector}: no animation property`);
108
+ else if (!keyframes.has(keyframe)) broken.push(`${selector}: missing @keyframes ${keyframe}`);
109
+ }
110
+ expect(broken, broken.join('\n')).toEqual([]);
111
+ });
112
+
113
+ it('every generated transition class resolves to a defined keyframe', () => {
114
+ // Ties the function output to the CSS end-to-end: a class the player can
115
+ // emit must have a rule, and that rule must animate a real keyframe.
116
+ const keyframes = definedKeyframes();
117
+ const rules = transitionRuleKeyframes();
118
+ for (const className of collectGeneratedTransitionClasses()) {
119
+ const keyframe = rules.get(className);
120
+ expect(keyframe, `${className} has no transition rule`).toBeDefined();
121
+ expect(keyframe, `${className} animates undefined keyframe ${keyframe}`).not.toBeNull();
122
+ if (keyframe) expect(keyframes.has(keyframe), `${className} -> ${keyframe}`).toBe(true);
123
+ }
124
+ });
125
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * useDocPlayback — block transition state is derived synchronously.
3
+ *
4
+ * Regression guard for the between-block "flash": a newly active block must
5
+ * report `isEntering` (and its outgoing `previousBlock`) on the SAME render it
6
+ * becomes current — not a frame later via an effect. Otherwise the block paints
7
+ * fully settled for a frame and then snaps back to the start of its entrance
8
+ * animation. The distinguishing assertion is `previousBlock` during the next
9
+ * block's entrance with NO timer/effect advanced (the old effect-based path
10
+ * only set it after a `setTimeout`).
11
+ */
12
+
13
+ import { describe, it, expect } from 'vitest';
14
+ import { renderHook } from '@testing-library/react';
15
+ import { VIEWPORT_PRESETS } from '@bendyline/squisq/doc';
16
+ import type { Doc, Block } from '@bendyline/squisq/schemas';
17
+ import { useDocPlayback } from '../hooks/useDocPlayback';
18
+
19
+ function block(id: string, startTime: number): Block {
20
+ return {
21
+ id,
22
+ startTime,
23
+ duration: 5,
24
+ audioSegment: 0,
25
+ transition: { type: 'fade', duration: 0.5 },
26
+ layers: [],
27
+ };
28
+ }
29
+
30
+ const doc: Doc = {
31
+ articleId: 'd',
32
+ duration: 10,
33
+ blocks: [block('a', 0), block('b', 5)],
34
+ audio: { segments: [] },
35
+ };
36
+
37
+ describe('useDocPlayback — synchronous block transitions', () => {
38
+ it('exposes the entering block + outgoing previousBlock on the same render (no effect flush)', () => {
39
+ const { result, rerender } = renderHook(
40
+ ({ t }: { t: number }) => useDocPlayback(doc, t, VIEWPORT_PRESETS.landscape),
41
+ { initialProps: { t: 0 } },
42
+ );
43
+
44
+ // Establish block A as the current block first.
45
+ expect(result.current.currentBlockIndex).toBe(0);
46
+
47
+ // Cross into block B's entrance window: it is entering AND crossfading from
48
+ // A immediately — the old code left previousBlock null until a setTimeout.
49
+ rerender({ t: 5.1 });
50
+ expect(result.current.currentBlockIndex).toBe(1);
51
+ expect(result.current.isEntering).toBe(true);
52
+ expect(result.current.isExiting).toBe(true);
53
+ expect(result.current.previousBlock?.id).toBe('a');
54
+
55
+ // Past the entrance window: settled, no crossfade.
56
+ rerender({ t: 7 });
57
+ expect(result.current.isEntering).toBe(false);
58
+ expect(result.current.isExiting).toBe(false);
59
+ expect(result.current.previousBlock).toBeNull();
60
+ });
61
+
62
+ it('isEntering is a pure function of blockTime vs the transition duration', () => {
63
+ const enteringAt = (t: number) =>
64
+ renderHook(() => useDocPlayback(doc, t, VIEWPORT_PRESETS.landscape)).result.current
65
+ .isEntering;
66
+ expect(enteringAt(5.0)).toBe(true); // blockTime 0.0 < 0.5
67
+ expect(enteringAt(5.4)).toBe(true); // blockTime 0.4 < 0.5
68
+ expect(enteringAt(5.6)).toBe(false); // blockTime 0.6 >= 0.5
69
+ });
70
+ });
@@ -120,6 +120,13 @@ export function useAudioSync(
120
120
  if (!audio) return;
121
121
 
122
122
  const handleTimeUpdate = () => {
123
+ // In fallback mode the <audio> element is NOT the clock — there's no real
124
+ // source (e.g. the editor preview's synthetic, empty-src track), so the
125
+ // synthetic timer and `seekTo` own `currentTime`. A `timeupdate` fired as
126
+ // a side effect of programmatically setting `audio.currentTime` (during a
127
+ // seek) would otherwise clobber the just-seeked position with the empty
128
+ // element's unreliable `currentTime`, snapping the scrubber back.
129
+ if (fallbackMode.current) return;
123
130
  // Calculate overall timeline position
124
131
  const segmentStart = segmentStarts.current[currentSegment] || 0;
125
132
  const overallTime = segmentStart + audio.currentTime;
@@ -127,7 +134,13 @@ export function useAudioSync(
127
134
  };
128
135
 
129
136
  const handlePlay = () => {
130
- fallbackMode.current = false;
137
+ // Don't clear `fallbackMode` here. Whether the <audio> element is really
138
+ // the clock is decided authoritatively by the play() promise: it only
139
+ // resolves (clearing fallback, see `play`) when a real source actually
140
+ // plays. The 'play' event, by contrast, can fire spuriously on the
141
+ // source-less preview element — and clearing fallback there makes the
142
+ // synthetic timer's tick guard bail on its next frame, freezing the
143
+ // clock and the scrubber after a seek/resume.
131
144
  setIsPlaying(true);
132
145
  };
133
146
  const handlePause = () => setIsPlaying(false);