@bendyline/squisq-react 1.3.2 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -23
- package/dist/index.d.ts +131 -26
- package/dist/index.js +1475 -755
- 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 +49 -13
- package/dist/squisq-player.global.js.map +1 -1
- package/dist/standalone-source.js +1 -1
- package/dist/styles/index.css +2263 -0
- package/package.json +9 -5
- package/src/BlockRenderer.tsx +15 -7
- package/src/DocPlayer.tsx +222 -55
- package/src/DocPlayerWithSidebar.tsx +21 -9
- package/src/DocProgressBar.tsx +21 -3
- package/src/LinearDocView.tsx +69 -206
- package/src/MarkdownRenderer.tsx +182 -41
- package/src/MediaClipLayer.tsx +135 -0
- package/src/__tests__/DocPlayer.test.tsx +81 -0
- package/src/__tests__/DocPlayerStylesSentinel.test.tsx +41 -0
- package/src/__tests__/DocProgressBar.test.tsx +76 -0
- package/src/__tests__/LinearDocView.test.tsx +53 -1
- package/src/__tests__/MarkdownRenderer.test.tsx +113 -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/__tests__/useJsonViewTokens.test.ts +41 -0
- package/src/__tests__/useSlideSwipe.test.ts +81 -0
- package/src/hooks/{AudioProvider.ts → AudioController.ts} +3 -3
- package/src/hooks/index.ts +7 -2
- package/src/hooks/useAudioSync.ts +19 -5
- package/src/hooks/useDocPlayback.ts +81 -100
- package/src/hooks/useMediaSchedule.ts +39 -0
- package/src/hooks/useSlideSwipe.ts +265 -0
- package/src/index.ts +8 -1
- package/src/jsonView/useJsonViewTokens.ts +6 -31
- 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/standalone-entry.tsx +1 -1
- package/src/styles/doc-animations.css +1936 -35
- package/src/utils/fillStyle.tsx +148 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
import { DocPlayer } from '../DocPlayer';
|
|
4
|
+
import type { Doc } from '@bendyline/squisq/schemas';
|
|
5
|
+
|
|
6
|
+
function minimalDoc(): Doc {
|
|
7
|
+
return {
|
|
8
|
+
articleId: 'smoke',
|
|
9
|
+
duration: 5,
|
|
10
|
+
blocks: [{ id: 'b1', startTime: 0, duration: 5, audioSegment: 0, layers: [] }],
|
|
11
|
+
audio: { segments: [] },
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('DocPlayer smoke test', () => {
|
|
16
|
+
it('renders without crashing in video mode (default)', () => {
|
|
17
|
+
const { container } = render(<DocPlayer doc={minimalDoc()} basePath="/test" />);
|
|
18
|
+
expect(container.firstChild).toBeTruthy();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('renders without crashing in slideshow mode', () => {
|
|
22
|
+
const { container } = render(
|
|
23
|
+
<DocPlayer doc={minimalDoc()} basePath="/test" displayMode="slideshow" />,
|
|
24
|
+
);
|
|
25
|
+
expect(container.firstChild).toBeTruthy();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('renders without crashing in linear mode', () => {
|
|
29
|
+
const { container } = render(
|
|
30
|
+
<DocPlayer doc={minimalDoc()} basePath="/test" displayMode="linear" />,
|
|
31
|
+
);
|
|
32
|
+
expect(container.firstChild).toBeTruthy();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('exposes playback controls via onControlsReady', () => {
|
|
36
|
+
let controls: { play: () => void; pause: () => void } | null = null;
|
|
37
|
+
render(
|
|
38
|
+
<DocPlayer
|
|
39
|
+
doc={minimalDoc()}
|
|
40
|
+
basePath="/test"
|
|
41
|
+
showControls={false}
|
|
42
|
+
onControlsReady={(c) => {
|
|
43
|
+
controls = c;
|
|
44
|
+
}}
|
|
45
|
+
/>,
|
|
46
|
+
);
|
|
47
|
+
expect(controls).not.toBeNull();
|
|
48
|
+
expect(typeof controls!.play).toBe('function');
|
|
49
|
+
expect(typeof controls!.pause).toBe('function');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('DocPlayer front door (doc / markdown resolution)', () => {
|
|
54
|
+
it('renders a doc built from the markdown prop', () => {
|
|
55
|
+
const { container } = render(
|
|
56
|
+
<DocPlayer markdown={'# Hello From Markdown\n\nBody text here.'} displayMode="linear" />,
|
|
57
|
+
);
|
|
58
|
+
expect(container.querySelector('.doc-player')).toBeTruthy();
|
|
59
|
+
expect(container.textContent).toContain('Hello From Markdown');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('doc wins over markdown when both are provided', () => {
|
|
63
|
+
const { container } = render(
|
|
64
|
+
<DocPlayer doc={minimalDoc()} markdown="# Markdown Loses" displayMode="linear" />,
|
|
65
|
+
);
|
|
66
|
+
expect(container.querySelector('.doc-player')).toBeTruthy();
|
|
67
|
+
expect(container.textContent).not.toContain('Markdown Loses');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('renders an empty state without throwing when neither doc nor markdown is given', () => {
|
|
71
|
+
const { container } = render(<DocPlayer />);
|
|
72
|
+
const empty = container.querySelector('.doc-player--empty');
|
|
73
|
+
expect(empty).toBeTruthy();
|
|
74
|
+
expect(empty!.classList.contains('doc-player')).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('defaults basePath when omitted', () => {
|
|
78
|
+
const { container } = render(<DocPlayer doc={minimalDoc()} />);
|
|
79
|
+
expect(container.querySelector('.doc-player')).toBeTruthy();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Missing-stylesheet sentinel test.
|
|
3
|
+
*
|
|
4
|
+
* Lives in its own file so it owns a fresh module instance of DocPlayer
|
|
5
|
+
* (vitest isolates module registries per test file) — the sentinel warning
|
|
6
|
+
* is one-shot at module level, so this file's first mount deterministically
|
|
7
|
+
* observes it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
11
|
+
import { render } from '@testing-library/react';
|
|
12
|
+
import { DocPlayer } from '../DocPlayer';
|
|
13
|
+
import type { Doc } from '@bendyline/squisq/schemas';
|
|
14
|
+
|
|
15
|
+
function minimalDoc(): Doc {
|
|
16
|
+
return {
|
|
17
|
+
articleId: 'sentinel',
|
|
18
|
+
duration: 5,
|
|
19
|
+
blocks: [{ id: 'b1', startTime: 0, duration: 5, audioSegment: 0, layers: [] }],
|
|
20
|
+
audio: { segments: [] },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
vi.restoreAllMocks();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('DocPlayer missing-CSS sentinel', () => {
|
|
29
|
+
it('warns once (and only once) in dev when the stylesheet is not loaded', () => {
|
|
30
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
31
|
+
|
|
32
|
+
render(<DocPlayer doc={minimalDoc()} />);
|
|
33
|
+
const sentinelCalls = () =>
|
|
34
|
+
warnSpy.mock.calls.filter((c) => String(c[0]).includes('squisq-react/styles'));
|
|
35
|
+
expect(sentinelCalls().length).toBe(1);
|
|
36
|
+
|
|
37
|
+
// Second mount must not warn again — module-level one-shot.
|
|
38
|
+
render(<DocPlayer doc={minimalDoc()} />);
|
|
39
|
+
expect(sentinelCalls().length).toBe(1);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -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,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import { render } from '@testing-library/react';
|
|
3
3
|
import { LinearDocView } from '../LinearDocView';
|
|
4
4
|
import type { Doc, Block } from '@bendyline/squisq/schemas';
|
|
@@ -279,3 +279,55 @@ describe('LinearDocView', () => {
|
|
|
279
279
|
expect(el.style.background).toBe(probe.style.background);
|
|
280
280
|
});
|
|
281
281
|
});
|
|
282
|
+
|
|
283
|
+
describe('LinearDocView markdown prop', () => {
|
|
284
|
+
it('renders from the markdown prop when no doc is given', () => {
|
|
285
|
+
const { container } = render(<LinearDocView markdown={'# From Markdown\n\nParagraph body.'} />);
|
|
286
|
+
expect(container.textContent).toContain('From Markdown');
|
|
287
|
+
expect(container.textContent).toContain('Paragraph body.');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('doc wins over markdown when both are provided', () => {
|
|
291
|
+
const doc = mkDoc([mkBlock({ id: 'b', contents: [paragraph(text('Doc body wins'))] })]);
|
|
292
|
+
const { container } = render(<LinearDocView doc={doc} markdown="# Markdown Loses" />);
|
|
293
|
+
expect(container.textContent).toContain('Doc body wins');
|
|
294
|
+
expect(container.textContent).not.toContain('Markdown Loses');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('renders an empty container when neither doc nor markdown is given', () => {
|
|
298
|
+
const { container } = render(<LinearDocView />);
|
|
299
|
+
expect(container.querySelector('.squisq-linear--empty')).toBeTruthy();
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe('LinearDocView unknown template annotations', () => {
|
|
304
|
+
it('warns once per unknown template name and falls back to plain markdown', () => {
|
|
305
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
306
|
+
const doc = mkDoc([
|
|
307
|
+
mkBlock({
|
|
308
|
+
id: 'unknown-1',
|
|
309
|
+
sourceHeading: {
|
|
310
|
+
type: 'heading',
|
|
311
|
+
depth: 2,
|
|
312
|
+
children: [text('Mystery Section')],
|
|
313
|
+
templateAnnotation: { template: 'no-such-template-xyz' },
|
|
314
|
+
},
|
|
315
|
+
contents: [paragraph(text('Fallback body content'))],
|
|
316
|
+
}),
|
|
317
|
+
]);
|
|
318
|
+
|
|
319
|
+
const { container } = render(<LinearDocView doc={doc} />);
|
|
320
|
+
// Renders as plain markdown: heading + body, no SVG card.
|
|
321
|
+
expect(container.textContent).toContain('Mystery Section');
|
|
322
|
+
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);
|
|
331
|
+
warnSpy.mockRestore();
|
|
332
|
+
});
|
|
333
|
+
});
|
|
@@ -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,33 @@ 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
|
+
|
|
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))');
|
|
107
|
+
const blocked = render(<MarkdownRenderer nodes={nodes} />);
|
|
108
|
+
expect(blocked.container.querySelector('a.squisq-md-link')).toBeNull();
|
|
109
|
+
|
|
110
|
+
const allowed = render(<MarkdownRenderer nodes={nodes} linkSchemes={['gezel-nav']} />);
|
|
111
|
+
const a = allowed.container.querySelector('a.squisq-md-link') as HTMLAnchorElement;
|
|
112
|
+
expect(a?.getAttribute('href')).toBe('gezel-nav:src%2Fa.ts');
|
|
113
|
+
// javascript: stays blocked even when a host lists it
|
|
114
|
+
const evil = render(
|
|
115
|
+
<MarkdownRenderer
|
|
116
|
+
nodes={parseNodes('[b](javascript:alert(1))')}
|
|
117
|
+
linkSchemes={['javascript']}
|
|
118
|
+
/>,
|
|
119
|
+
);
|
|
120
|
+
expect(evil.container.querySelector('a.squisq-md-link')).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
|
|
88
123
|
it('renders an image', () => {
|
|
89
124
|
const nodes: MarkdownBlockNode[] = [
|
|
90
125
|
paragraph({
|
|
@@ -231,4 +266,81 @@ describe('MarkdownRenderer', () => {
|
|
|
231
266
|
);
|
|
232
267
|
expect(container.querySelector('.squisq-md.custom')).toBeTruthy();
|
|
233
268
|
});
|
|
269
|
+
|
|
270
|
+
it('sanitizes raw HTML by default', () => {
|
|
271
|
+
const nodes = parseNodes(
|
|
272
|
+
'<div><img src="x.jpg" onerror="alert(1)"><script>alert(1)</script><span onclick="alert(1)">ok</span></div>',
|
|
273
|
+
);
|
|
274
|
+
const { container } = render(<MarkdownRenderer nodes={nodes} />);
|
|
275
|
+
|
|
276
|
+
expect(container.querySelector('script')).toBeNull();
|
|
277
|
+
expect(container.querySelector('img')?.getAttribute('src')).toBe('x.jpg');
|
|
278
|
+
expect(container.querySelector('img')?.getAttribute('onerror')).toBeNull();
|
|
279
|
+
expect(container.querySelector('span')?.getAttribute('onclick')).toBeNull();
|
|
280
|
+
expect(container.textContent).toContain('ok');
|
|
281
|
+
expect(container.textContent).not.toContain('alert(1)');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('strips raw HTML when requested', () => {
|
|
285
|
+
const { container } = render(
|
|
286
|
+
<MarkdownRenderer nodes={parseNodes('<div><span>hidden</span></div>')} htmlPolicy="strip" />,
|
|
287
|
+
);
|
|
288
|
+
expect(container.textContent).not.toContain('hidden');
|
|
289
|
+
expect(container.querySelector('span')).toBeNull();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('preserves raw HTML only with the trusted opt-in', () => {
|
|
293
|
+
const { container } = render(
|
|
294
|
+
<MarkdownRenderer
|
|
295
|
+
nodes={parseNodes('<div><span class="trusted" onclick="alert(1)">ok</span></div>')}
|
|
296
|
+
htmlPolicy="trusted"
|
|
297
|
+
/>,
|
|
298
|
+
);
|
|
299
|
+
const span = container.querySelector('span.trusted');
|
|
300
|
+
expect(span?.getAttribute('onclick')).toBe('alert(1)');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Host-affecting tags (style/script/…) must never reach the document.
|
|
304
|
+
// A bare <style> applies page-wide (CSS isn't scoped outside shadow DOM
|
|
305
|
+
// / iframes), so an embedded game's <style> in a chat message used to
|
|
306
|
+
// restyle the whole app chrome. Guard it across every policy.
|
|
307
|
+
it('drops a raw <style> by default so it cannot leak onto the host', () => {
|
|
308
|
+
const { container } = render(
|
|
309
|
+
<MarkdownRenderer
|
|
310
|
+
nodes={parseNodes('<div><style>body{font-family:monospace}</style><p>hi</p></div>')}
|
|
311
|
+
/>,
|
|
312
|
+
);
|
|
313
|
+
expect(container.querySelector('style')).toBeNull();
|
|
314
|
+
expect(container.textContent).toContain('hi');
|
|
315
|
+
expect(container.textContent).not.toContain('font-family');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('drops <style>/<script> even under the trusted policy, keeping safe content', () => {
|
|
319
|
+
const { container } = render(
|
|
320
|
+
<MarkdownRenderer
|
|
321
|
+
nodes={parseNodes(
|
|
322
|
+
'<div class="game"><style>body{font-family:monospace}</style><script>alert(1)</script><p>play</p></div>',
|
|
323
|
+
)}
|
|
324
|
+
htmlPolicy="trusted"
|
|
325
|
+
/>,
|
|
326
|
+
);
|
|
327
|
+
expect(container.querySelector('style')).toBeNull();
|
|
328
|
+
expect(container.querySelector('script')).toBeNull();
|
|
329
|
+
expect(container.querySelector('div.game')).toBeTruthy();
|
|
330
|
+
expect(container.textContent).toContain('play');
|
|
331
|
+
expect(container.textContent).not.toContain('font-family');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('drops a raw <style> on the verbatim path when htmlChildren was not parsed', () => {
|
|
335
|
+
// parseHtml:false leaves htmlChildren empty while rawHtml keeps the
|
|
336
|
+
// markup — the raw-string backstop must still refuse the fast path.
|
|
337
|
+
const node = {
|
|
338
|
+
type: 'htmlBlock',
|
|
339
|
+
rawHtml: '<style>body{font-family:monospace}</style><p>play</p>',
|
|
340
|
+
htmlChildren: [],
|
|
341
|
+
} as unknown as MarkdownBlockNode;
|
|
342
|
+
const { container } = render(<MarkdownRenderer nodes={[node]} htmlPolicy="trusted" />);
|
|
343
|
+
expect(container.querySelector('style')).toBeNull();
|
|
344
|
+
expect(container.textContent).not.toContain('font-family');
|
|
345
|
+
});
|
|
234
346
|
});
|
|
@@ -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
|
+
});
|