@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.
- package/dist/index.d.ts +766 -35
- package/dist/index.js +4793 -33
- 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 +17 -13
- package/dist/squisq-player.global.js.map +1 -1
- package/dist/standalone-source.js +1 -1
- package/package.json +3 -2
- package/src/BlockRenderer.tsx +15 -7
- package/src/DocPlayer.tsx +65 -12
- package/src/DocProgressBar.tsx +21 -3
- package/src/LinearDocView.tsx +11 -197
- package/src/MarkdownRenderer.tsx +165 -41
- package/src/MediaClipLayer.tsx +135 -0
- package/src/__tests__/DocPlayer.test.tsx +51 -0
- package/src/__tests__/DocProgressBar.test.tsx +76 -0
- package/src/__tests__/MarkdownRenderer.test.tsx +95 -1
- package/src/__tests__/PathLayer.test.tsx +73 -0
- package/src/__tests__/fillStyle.test.tsx +112 -0
- package/src/__tests__/transitionStyles.test.ts +125 -0
- package/src/__tests__/useDocPlayback.transition.test.ts +70 -0
- package/src/hooks/useAudioSync.ts +14 -1
- package/src/hooks/useDocPlayback.ts +81 -100
- package/src/hooks/useMediaSchedule.ts +39 -0
- package/src/index.ts +7 -0
- package/src/layers/ImageLayer.tsx +11 -1
- package/src/layers/PathLayer.tsx +146 -0
- package/src/layers/ShapeLayer.tsx +27 -5
- package/src/layers/TextLayer.tsx +395 -22
- package/src/layers/VideoLayer.tsx +16 -9
- package/src/layers/index.ts +1 -0
- package/src/styles/doc-animations.css +1857 -2
- package/src/utils/fillStyle.tsx +148 -0
- package/dist/BlockRenderer.d.ts +0 -37
- package/dist/BlockRenderer.d.ts.map +0 -1
- package/dist/BlockRenderer.js +0 -54
- package/dist/BlockRenderer.js.map +0 -1
- package/dist/CaptionOverlay.d.ts +0 -33
- package/dist/CaptionOverlay.d.ts.map +0 -1
- package/dist/CaptionOverlay.js +0 -44
- package/dist/CaptionOverlay.js.map +0 -1
- package/dist/DocControlsBottom.d.ts +0 -28
- package/dist/DocControlsBottom.d.ts.map +0 -1
- package/dist/DocControlsBottom.js +0 -27
- package/dist/DocControlsBottom.js.map +0 -1
- package/dist/DocControlsOverlay.d.ts +0 -28
- package/dist/DocControlsOverlay.d.ts.map +0 -1
- package/dist/DocControlsOverlay.js +0 -80
- package/dist/DocControlsOverlay.js.map +0 -1
- package/dist/DocControlsSidebar.d.ts +0 -27
- package/dist/DocControlsSidebar.d.ts.map +0 -1
- package/dist/DocControlsSidebar.js +0 -10
- package/dist/DocControlsSidebar.js.map +0 -1
- package/dist/DocControlsSlideshow.d.ts +0 -20
- package/dist/DocControlsSlideshow.d.ts.map +0 -1
- package/dist/DocControlsSlideshow.js +0 -69
- package/dist/DocControlsSlideshow.js.map +0 -1
- package/dist/DocPlayer.d.ts +0 -97
- package/dist/DocPlayer.d.ts.map +0 -1
- package/dist/DocPlayer.js +0 -657
- package/dist/DocPlayer.js.map +0 -1
- package/dist/DocPlayerWithSidebar.d.ts +0 -41
- package/dist/DocPlayerWithSidebar.d.ts.map +0 -1
- package/dist/DocPlayerWithSidebar.js +0 -69
- package/dist/DocPlayerWithSidebar.js.map +0 -1
- package/dist/DocProgressBar.d.ts +0 -31
- package/dist/DocProgressBar.d.ts.map +0 -1
- package/dist/DocProgressBar.js +0 -122
- package/dist/DocProgressBar.js.map +0 -1
- package/dist/InlineAudioPlayer.d.ts +0 -15
- package/dist/InlineAudioPlayer.d.ts.map +0 -1
- package/dist/InlineAudioPlayer.js +0 -22
- package/dist/InlineAudioPlayer.js.map +0 -1
- package/dist/InlineVideoPlayer.d.ts +0 -21
- package/dist/InlineVideoPlayer.d.ts.map +0 -1
- package/dist/InlineVideoPlayer.js +0 -29
- package/dist/InlineVideoPlayer.js.map +0 -1
- package/dist/LinearDocView.d.ts +0 -73
- package/dist/LinearDocView.d.ts.map +0 -1
- package/dist/LinearDocView.js +0 -451
- package/dist/LinearDocView.js.map +0 -1
- package/dist/MarkdownRenderer.d.ts +0 -32
- package/dist/MarkdownRenderer.d.ts.map +0 -1
- package/dist/MarkdownRenderer.js +0 -259
- package/dist/MarkdownRenderer.js.map +0 -1
- package/dist/SocialCaptionOverlay.d.ts +0 -27
- package/dist/SocialCaptionOverlay.d.ts.map +0 -1
- package/dist/SocialCaptionOverlay.js +0 -185
- package/dist/SocialCaptionOverlay.js.map +0 -1
- package/dist/__tests__/BlockRenderer.test.d.ts +0 -2
- package/dist/__tests__/BlockRenderer.test.d.ts.map +0 -1
- package/dist/__tests__/BlockRenderer.test.js +0 -82
- package/dist/__tests__/BlockRenderer.test.js.map +0 -1
- package/dist/__tests__/DocControlsSlideshow.test.d.ts +0 -2
- package/dist/__tests__/DocControlsSlideshow.test.d.ts.map +0 -1
- package/dist/__tests__/DocControlsSlideshow.test.js +0 -89
- package/dist/__tests__/DocControlsSlideshow.test.js.map +0 -1
- package/dist/__tests__/JsonView.test.d.ts +0 -2
- package/dist/__tests__/JsonView.test.d.ts.map +0 -1
- package/dist/__tests__/JsonView.test.js +0 -93
- package/dist/__tests__/JsonView.test.js.map +0 -1
- package/dist/__tests__/LinearDocView.test.d.ts +0 -2
- package/dist/__tests__/LinearDocView.test.d.ts.map +0 -1
- package/dist/__tests__/LinearDocView.test.js +0 -250
- package/dist/__tests__/LinearDocView.test.js.map +0 -1
- package/dist/__tests__/MarkdownRenderer.test.d.ts +0 -2
- package/dist/__tests__/MarkdownRenderer.test.d.ts.map +0 -1
- package/dist/__tests__/MarkdownRenderer.test.js +0 -208
- package/dist/__tests__/MarkdownRenderer.test.js.map +0 -1
- package/dist/__tests__/TableLayer.test.d.ts +0 -2
- package/dist/__tests__/TableLayer.test.d.ts.map +0 -1
- package/dist/__tests__/TableLayer.test.js +0 -121
- package/dist/__tests__/TableLayer.test.js.map +0 -1
- package/dist/__tests__/exports.test.d.ts +0 -2
- package/dist/__tests__/exports.test.d.ts.map +0 -1
- package/dist/__tests__/exports.test.js +0 -48
- package/dist/__tests__/exports.test.js.map +0 -1
- package/dist/hooks/AudioProvider.d.ts +0 -71
- package/dist/hooks/AudioProvider.d.ts.map +0 -1
- package/dist/hooks/AudioProvider.js +0 -52
- package/dist/hooks/AudioProvider.js.map +0 -1
- package/dist/hooks/MediaContext.d.ts +0 -35
- package/dist/hooks/MediaContext.d.ts.map +0 -1
- package/dist/hooks/MediaContext.js +0 -72
- package/dist/hooks/MediaContext.js.map +0 -1
- package/dist/hooks/index.d.ts +0 -6
- package/dist/hooks/index.d.ts.map +0 -1
- package/dist/hooks/index.js +0 -5
- package/dist/hooks/index.js.map +0 -1
- package/dist/hooks/useAudioSync.d.ts +0 -18
- package/dist/hooks/useAudioSync.d.ts.map +0 -1
- package/dist/hooks/useAudioSync.js +0 -340
- package/dist/hooks/useAudioSync.js.map +0 -1
- package/dist/hooks/useAutoSurface.d.ts +0 -11
- package/dist/hooks/useAutoSurface.d.ts.map +0 -1
- package/dist/hooks/useAutoSurface.js +0 -24
- package/dist/hooks/useAutoSurface.js.map +0 -1
- package/dist/hooks/useDocPlayback.d.ts +0 -47
- package/dist/hooks/useDocPlayback.d.ts.map +0 -1
- package/dist/hooks/useDocPlayback.js +0 -179
- package/dist/hooks/useDocPlayback.js.map +0 -1
- package/dist/hooks/useViewportOrientation.d.ts +0 -31
- package/dist/hooks/useViewportOrientation.d.ts.map +0 -1
- package/dist/hooks/useViewportOrientation.js +0 -92
- package/dist/hooks/useViewportOrientation.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/jsonView/JsonView.d.ts +0 -26
- package/dist/jsonView/JsonView.d.ts.map +0 -1
- package/dist/jsonView/JsonView.js +0 -12
- package/dist/jsonView/JsonView.js.map +0 -1
- package/dist/jsonView/RenderNode.d.ts +0 -22
- package/dist/jsonView/RenderNode.d.ts.map +0 -1
- package/dist/jsonView/RenderNode.js +0 -30
- package/dist/jsonView/RenderNode.js.map +0 -1
- package/dist/jsonView/index.d.ts +0 -3
- package/dist/jsonView/index.d.ts.map +0 -1
- package/dist/jsonView/index.js +0 -2
- package/dist/jsonView/index.js.map +0 -1
- package/dist/jsonView/useJsonViewTokens.d.ts +0 -14
- package/dist/jsonView/useJsonViewTokens.d.ts.map +0 -1
- package/dist/jsonView/useJsonViewTokens.js +0 -34
- package/dist/jsonView/useJsonViewTokens.js.map +0 -1
- package/dist/jsonView/viewers.d.ts +0 -30
- package/dist/jsonView/viewers.d.ts.map +0 -1
- package/dist/jsonView/viewers.js +0 -226
- package/dist/jsonView/viewers.js.map +0 -1
- package/dist/layers/ImageLayer.d.ts +0 -23
- package/dist/layers/ImageLayer.d.ts.map +0 -1
- package/dist/layers/ImageLayer.js +0 -104
- package/dist/layers/ImageLayer.js.map +0 -1
- package/dist/layers/MapLayer.d.ts +0 -29
- package/dist/layers/MapLayer.d.ts.map +0 -1
- package/dist/layers/MapLayer.js +0 -97
- package/dist/layers/MapLayer.js.map +0 -1
- package/dist/layers/ShapeLayer.d.ts +0 -20
- package/dist/layers/ShapeLayer.d.ts.map +0 -1
- package/dist/layers/ShapeLayer.js +0 -39
- package/dist/layers/ShapeLayer.js.map +0 -1
- package/dist/layers/TableLayer.d.ts +0 -23
- package/dist/layers/TableLayer.d.ts.map +0 -1
- package/dist/layers/TableLayer.js +0 -58
- package/dist/layers/TableLayer.js.map +0 -1
- package/dist/layers/TextLayer.d.ts +0 -23
- package/dist/layers/TextLayer.d.ts.map +0 -1
- package/dist/layers/TextLayer.js +0 -126
- package/dist/layers/TextLayer.js.map +0 -1
- package/dist/layers/VideoLayer.d.ts +0 -40
- package/dist/layers/VideoLayer.d.ts.map +0 -1
- package/dist/layers/VideoLayer.js +0 -105
- package/dist/layers/VideoLayer.js.map +0 -1
- package/dist/layers/index.d.ts +0 -6
- package/dist/layers/index.d.ts.map +0 -1
- package/dist/layers/index.js +0 -6
- package/dist/layers/index.js.map +0 -1
- package/dist/standalone-entry.d.ts +0 -69
- package/dist/standalone-entry.d.ts.map +0 -1
- package/dist/standalone-entry.js +0 -185
- package/dist/standalone-entry.js.map +0 -1
- package/dist/types.d.ts +0 -154
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -21
- package/dist/types.js.map +0 -1
- package/dist/utils/animationUtils.d.ts +0 -8
- package/dist/utils/animationUtils.d.ts.map +0 -1
- package/dist/utils/animationUtils.js +0 -8
- package/dist/utils/animationUtils.js.map +0 -1
- package/dist/utils/layerUtils.d.ts +0 -17
- package/dist/utils/layerUtils.d.ts.map +0 -1
- package/dist/utils/layerUtils.js +0 -37
- package/dist/utils/layerUtils.js.map +0 -1
- package/dist/utils/mapTileUtils.d.ts +0 -105
- package/dist/utils/mapTileUtils.d.ts.map +0 -1
- package/dist/utils/mapTileUtils.js +0 -268
- 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
|
|
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.
|
|
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);
|