@bendyline/squisq-react 2.0.1 → 2.2.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/LICENSE +21 -0
- package/NOTICE.md +20 -0
- package/README.md +6 -0
- package/dist/index.d.ts +101 -96
- package/dist/index.js +1483 -782
- package/dist/squisq-player.full.global.js +4165 -0
- package/dist/squisq-player.global.js +561 -168
- package/dist/standalone-source.d.ts +1 -0
- package/dist/standalone-source.js +1 -1
- package/dist/styles/index.css +64 -0
- package/package.json +8 -4
- package/dist/index.js.map +0 -1
- package/dist/squisq-player.css +0 -2
- package/dist/squisq-player.css.map +0 -1
- package/dist/squisq-player.global.js.map +0 -1
- package/src/BlockRenderer.tsx +0 -194
- package/src/CaptionOverlay.tsx +0 -111
- package/src/DocControlsBottom.tsx +0 -109
- package/src/DocControlsOverlay.tsx +0 -190
- package/src/DocControlsSidebar.tsx +0 -113
- package/src/DocControlsSlideshow.tsx +0 -360
- package/src/DocPlayer.tsx +0 -1528
- package/src/DocPlayerWithSidebar.tsx +0 -155
- package/src/DocProgressBar.tsx +0 -257
- package/src/InlineAudioPlayer.tsx +0 -46
- package/src/InlineVideoPlayer.tsx +0 -70
- package/src/LinearDocView.tsx +0 -569
- package/src/MarkdownRenderer.tsx +0 -624
- package/src/MediaClipLayer.tsx +0 -145
- package/src/SocialCaptionOverlay.tsx +0 -255
- package/src/__tests__/BlockRenderer.test.tsx +0 -235
- package/src/__tests__/DocControlsSlideshow.test.tsx +0 -221
- package/src/__tests__/DocPlayer.test.tsx +0 -635
- package/src/__tests__/DocPlayerStylesSentinel.test.tsx +0 -41
- package/src/__tests__/DocProgressBar.test.tsx +0 -102
- package/src/__tests__/JsonView.test.tsx +0 -111
- package/src/__tests__/LinearDocView.test.tsx +0 -426
- package/src/__tests__/MapLayer.test.tsx +0 -63
- package/src/__tests__/MarkdownRenderer.test.tsx +0 -357
- package/src/__tests__/MediaClipLayer.test.tsx +0 -70
- package/src/__tests__/MediaContext.test.tsx +0 -51
- package/src/__tests__/PathLayer.test.tsx +0 -84
- package/src/__tests__/TableLayer.test.tsx +0 -142
- package/src/__tests__/VideoLayer.test.tsx +0 -94
- package/src/__tests__/exports.test.ts +0 -55
- package/src/__tests__/fillStyle.test.tsx +0 -160
- package/src/__tests__/standaloneEntry.test.tsx +0 -103
- package/src/__tests__/transitionStyles.test.ts +0 -125
- package/src/__tests__/useAudioSync.test.ts +0 -49
- package/src/__tests__/useDocPlayback.transition.test.ts +0 -113
- package/src/__tests__/useJsonViewTokens.test.ts +0 -41
- package/src/__tests__/useSlideSwipe.test.ts +0 -81
- package/src/__tests__/useViewportOrientation.test.ts +0 -22
- package/src/hooks/AudioController.ts +0 -114
- package/src/hooks/MediaContext.tsx +0 -97
- package/src/hooks/index.ts +0 -11
- package/src/hooks/useAudioSync.ts +0 -456
- package/src/hooks/useAutoSurface.ts +0 -33
- package/src/hooks/useDocPlayback.ts +0 -265
- package/src/hooks/useMediaSchedule.ts +0 -39
- package/src/hooks/useSlideSwipe.ts +0 -265
- package/src/hooks/useViewportOrientation.ts +0 -115
- package/src/index.ts +0 -70
- package/src/jsonView/JsonView.tsx +0 -51
- package/src/jsonView/RenderNode.tsx +0 -51
- package/src/jsonView/index.ts +0 -2
- package/src/jsonView/json-view.css +0 -206
- package/src/jsonView/useJsonViewTokens.ts +0 -32
- package/src/jsonView/viewers.tsx +0 -343
- package/src/layers/ImageLayer.tsx +0 -297
- package/src/layers/MapLayer.tsx +0 -185
- package/src/layers/PathLayer.tsx +0 -155
- package/src/layers/ShapeLayer.tsx +0 -158
- package/src/layers/TableLayer.tsx +0 -129
- package/src/layers/TextLayer.tsx +0 -571
- package/src/layers/TreeLayer.tsx +0 -167
- package/src/layers/VideoLayer.tsx +0 -171
- package/src/layers/index.ts +0 -6
- package/src/standalone-entry.tsx +0 -337
- package/src/standalone-source.d.ts +0 -10
- package/src/styles/doc-animations.css +0 -2431
- package/src/styles/index.css +0 -7
- package/src/types.ts +0 -183
- package/src/utils/animationUtils.ts +0 -13
- package/src/utils/fillStyle.tsx +0 -148
- package/src/utils/layerUtils.ts +0 -42
- package/src/utils/mapTileUtils.ts +0 -375
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { fireEvent, render, screen } 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
|
-
|
|
77
|
-
it('exposes keyboard seeking and accessible marker buttons', () => {
|
|
78
|
-
const seekTo = vi.fn();
|
|
79
|
-
render(
|
|
80
|
-
<DocProgressBar
|
|
81
|
-
state={makeState({ currentTime: 12, totalDuration: 23 })}
|
|
82
|
-
actions={{ ...actions, seekTo }}
|
|
83
|
-
blockMarkers={[
|
|
84
|
-
{
|
|
85
|
-
block: { id: 'b', startTime: 8, duration: 5, audioSegment: 0, layers: [] },
|
|
86
|
-
index: 0,
|
|
87
|
-
position: 35,
|
|
88
|
-
title: 'Chapter two',
|
|
89
|
-
isSectionStart: false,
|
|
90
|
-
},
|
|
91
|
-
]}
|
|
92
|
-
expandedBlocks={[]}
|
|
93
|
-
/>,
|
|
94
|
-
);
|
|
95
|
-
const slider = screen.getByRole('slider', { name: 'Playback position' });
|
|
96
|
-
expect(slider.getAttribute('aria-valuenow')).toBe('12');
|
|
97
|
-
fireEvent.keyDown(slider, { key: 'ArrowRight' });
|
|
98
|
-
expect(seekTo).toHaveBeenCalledWith(17);
|
|
99
|
-
fireEvent.click(screen.getByRole('button', { name: 'Seek to Chapter two' }));
|
|
100
|
-
expect(seekTo).toHaveBeenLastCalledWith(8);
|
|
101
|
-
});
|
|
102
|
-
});
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { render } from '@testing-library/react';
|
|
3
|
-
import { JsonView } from '../jsonView';
|
|
4
|
-
import type { SquisqAnnotatedSchema } from '@bendyline/squisq/jsonForm';
|
|
5
|
-
|
|
6
|
-
describe('JsonView', () => {
|
|
7
|
-
it('renders an object schema as labeled rows', () => {
|
|
8
|
-
const schema: SquisqAnnotatedSchema = {
|
|
9
|
-
type: 'object',
|
|
10
|
-
properties: {
|
|
11
|
-
title: { type: 'string', title: 'Page Title' },
|
|
12
|
-
active: { type: 'boolean' },
|
|
13
|
-
},
|
|
14
|
-
};
|
|
15
|
-
const { container } = render(
|
|
16
|
-
<JsonView schema={schema} value={{ title: 'Hello', active: true }} />,
|
|
17
|
-
);
|
|
18
|
-
expect(container.textContent).toContain('Page Title');
|
|
19
|
-
expect(container.textContent).toContain('Hello');
|
|
20
|
-
expect(container.textContent).toContain('On');
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('renders an array of primitives as chips', () => {
|
|
24
|
-
const schema: SquisqAnnotatedSchema = {
|
|
25
|
-
type: 'array',
|
|
26
|
-
items: { type: 'string' },
|
|
27
|
-
};
|
|
28
|
-
const { container } = render(<JsonView schema={schema} value={['a', 'b', 'c']} />);
|
|
29
|
-
expect(container.querySelectorAll('.squisq-jv-chip')).toHaveLength(3);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('renders an array of objects as cards with itemLabel.fromField', () => {
|
|
33
|
-
const schema: SquisqAnnotatedSchema = {
|
|
34
|
-
type: 'array',
|
|
35
|
-
items: {
|
|
36
|
-
type: 'object',
|
|
37
|
-
properties: {
|
|
38
|
-
heading: { type: 'string' },
|
|
39
|
-
body: { type: 'string' },
|
|
40
|
-
},
|
|
41
|
-
squisq: { itemLabel: { fromField: 'heading' } },
|
|
42
|
-
},
|
|
43
|
-
};
|
|
44
|
-
const { container } = render(
|
|
45
|
-
<JsonView
|
|
46
|
-
schema={schema}
|
|
47
|
-
value={[
|
|
48
|
-
{ heading: 'Section A', body: 'Body A' },
|
|
49
|
-
{ heading: 'Section B', body: 'Body B' },
|
|
50
|
-
]}
|
|
51
|
-
/>,
|
|
52
|
-
);
|
|
53
|
-
const cards = container.querySelectorAll('.squisq-jv-card');
|
|
54
|
-
expect(cards).toHaveLength(2);
|
|
55
|
-
expect(cards[0].textContent).toContain('Section A');
|
|
56
|
-
expect(cards[1].textContent).toContain('Section B');
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('renders a color value as a swatch + hex', () => {
|
|
60
|
-
const schema: SquisqAnnotatedSchema = {
|
|
61
|
-
type: 'string',
|
|
62
|
-
format: 'color',
|
|
63
|
-
};
|
|
64
|
-
const { container } = render(<JsonView schema={schema} value="#ff0080" />);
|
|
65
|
-
const swatch = container.querySelector('.squisq-jv-color__swatch') as HTMLElement | null;
|
|
66
|
-
expect(swatch).not.toBeNull();
|
|
67
|
-
expect(swatch?.style.background).toContain('rgb(255, 0, 128)');
|
|
68
|
-
expect(container.textContent).toContain('#ff0080');
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('hides fields whose squisq.hidden rule matches', () => {
|
|
72
|
-
const schema: SquisqAnnotatedSchema = {
|
|
73
|
-
type: 'object',
|
|
74
|
-
properties: {
|
|
75
|
-
showAuthor: { type: 'boolean' },
|
|
76
|
-
authorName: {
|
|
77
|
-
type: 'string',
|
|
78
|
-
squisq: { hidden: { field: 'showAuthor', truthy: false } },
|
|
79
|
-
},
|
|
80
|
-
},
|
|
81
|
-
};
|
|
82
|
-
const visible = render(
|
|
83
|
-
<JsonView schema={schema} value={{ showAuthor: true, authorName: 'Alex' }} />,
|
|
84
|
-
);
|
|
85
|
-
expect(visible.container.textContent).toContain('Alex');
|
|
86
|
-
|
|
87
|
-
const hidden = render(
|
|
88
|
-
<JsonView schema={schema} value={{ showAuthor: false, authorName: 'Alex' }} />,
|
|
89
|
-
);
|
|
90
|
-
expect(hidden.container.textContent).not.toContain('Alex');
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('renders empty values as the em-dash placeholder', () => {
|
|
94
|
-
const schema: SquisqAnnotatedSchema = {
|
|
95
|
-
type: 'object',
|
|
96
|
-
properties: { title: { type: 'string' } },
|
|
97
|
-
};
|
|
98
|
-
const { container } = render(<JsonView schema={schema} value={{}} />);
|
|
99
|
-
expect(container.textContent).toContain('—');
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('respects squisq.enumLabels when displaying enum values', () => {
|
|
103
|
-
const schema: SquisqAnnotatedSchema = {
|
|
104
|
-
type: 'string',
|
|
105
|
-
enum: ['s', 'm', 'l'],
|
|
106
|
-
squisq: { enumLabels: { s: 'Small', m: 'Medium', l: 'Large' } },
|
|
107
|
-
};
|
|
108
|
-
const { container } = render(<JsonView schema={schema} value="m" />);
|
|
109
|
-
expect(container.textContent).toContain('Medium');
|
|
110
|
-
});
|
|
111
|
-
});
|
|
@@ -1,426 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { fireEvent, render } from '@testing-library/react';
|
|
3
|
-
import { LinearDocView } from '../LinearDocView';
|
|
4
|
-
import type { Doc, Block } from '@bendyline/squisq/schemas';
|
|
5
|
-
import { DARK_SURFACE, DEFAULT_THEME, LIGHT_SURFACE } from '@bendyline/squisq/schemas';
|
|
6
|
-
import type {
|
|
7
|
-
MarkdownBlockNode,
|
|
8
|
-
MarkdownInlineNode,
|
|
9
|
-
MarkdownHeading,
|
|
10
|
-
} from '@bendyline/squisq/markdown';
|
|
11
|
-
|
|
12
|
-
// ── Helpers ────────────────────────────────────────────────────────
|
|
13
|
-
|
|
14
|
-
function text(value: string): MarkdownInlineNode {
|
|
15
|
-
return { type: 'text', value };
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function paragraph(...children: MarkdownInlineNode[]): MarkdownBlockNode {
|
|
19
|
-
return { type: 'paragraph', children };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function mkHeading(depth: 1 | 2 | 3, value: string): MarkdownHeading {
|
|
23
|
-
return { type: 'heading', depth, children: [text(value)] };
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function mkBlock(overrides: Partial<Block> = {}): Block {
|
|
27
|
-
return {
|
|
28
|
-
id: 'block-1',
|
|
29
|
-
startTime: 0,
|
|
30
|
-
duration: 3,
|
|
31
|
-
audioSegment: 0,
|
|
32
|
-
...overrides,
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function mkDoc(blocks: Block[]): Doc {
|
|
37
|
-
return {
|
|
38
|
-
articleId: 'test-article',
|
|
39
|
-
duration: 10,
|
|
40
|
-
blocks,
|
|
41
|
-
audio: { segments: [] },
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// ── Tests ──────────────────────────────────────────────────────────
|
|
46
|
-
|
|
47
|
-
describe('LinearDocView', () => {
|
|
48
|
-
it('renders a scrollable container', () => {
|
|
49
|
-
const doc = mkDoc([
|
|
50
|
-
mkBlock({
|
|
51
|
-
id: 'preamble',
|
|
52
|
-
contents: [paragraph(text('Introduction text'))],
|
|
53
|
-
}),
|
|
54
|
-
]);
|
|
55
|
-
const { container } = render(<LinearDocView doc={doc} />);
|
|
56
|
-
const el = container.querySelector('.squisq-linear');
|
|
57
|
-
expect(el).toBeTruthy();
|
|
58
|
-
expect((el as HTMLElement).style.overflowY).toBe('auto');
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('globally scrolls with up and down arrows when enabled', () => {
|
|
62
|
-
const doc = mkDoc([mkBlock({ contents: [paragraph(text('Scrollable body'))] })]);
|
|
63
|
-
const { container } = render(<LinearDocView doc={doc} globalKeyboardShortcuts />);
|
|
64
|
-
const scroller = container.querySelector<HTMLElement>('.squisq-linear')!;
|
|
65
|
-
const scrollBy = vi.fn();
|
|
66
|
-
Object.defineProperty(scroller, 'scrollBy', { configurable: true, value: scrollBy });
|
|
67
|
-
|
|
68
|
-
fireEvent.keyDown(document, { key: 'ArrowDown' });
|
|
69
|
-
fireEvent.keyDown(document, { key: 'ArrowUp' });
|
|
70
|
-
|
|
71
|
-
expect(scrollBy).toHaveBeenNthCalledWith(1, { top: 64, behavior: 'smooth' });
|
|
72
|
-
expect(scrollBy).toHaveBeenNthCalledWith(2, { top: -64, behavior: 'smooth' });
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('renders preamble content (no heading)', () => {
|
|
76
|
-
const doc = mkDoc([
|
|
77
|
-
mkBlock({
|
|
78
|
-
id: 'preamble',
|
|
79
|
-
contents: [paragraph(text('Preamble body'))],
|
|
80
|
-
}),
|
|
81
|
-
]);
|
|
82
|
-
const { container } = render(<LinearDocView doc={doc} />);
|
|
83
|
-
// Should render paragraph but no heading
|
|
84
|
-
expect(container.textContent).toContain('Preamble body');
|
|
85
|
-
const headings = container.querySelectorAll('h1, h2, h3');
|
|
86
|
-
expect(headings.length).toBe(0);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('renders non-annotated block with heading + content', () => {
|
|
90
|
-
const doc = mkDoc([
|
|
91
|
-
mkBlock({
|
|
92
|
-
id: 'section-1',
|
|
93
|
-
sourceHeading: mkHeading(2, 'My Section'),
|
|
94
|
-
contents: [paragraph(text('Section body text'))],
|
|
95
|
-
}),
|
|
96
|
-
]);
|
|
97
|
-
const { container } = render(<LinearDocView doc={doc} />);
|
|
98
|
-
expect(container.querySelector('h2')?.textContent).toBe('My Section');
|
|
99
|
-
expect(container.textContent).toContain('Section body text');
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('feeds imageWithCaption blocks the first body image as imageSrc', () => {
|
|
103
|
-
// Regression: without auto-extracting imageSrc from contents, the
|
|
104
|
-
// template renders a layer with `src=undefined` and the SVG card
|
|
105
|
-
// shows a broken image in document (linear) mode.
|
|
106
|
-
const doc = mkDoc([
|
|
107
|
-
mkBlock({
|
|
108
|
-
id: 'img-1',
|
|
109
|
-
sourceHeading: {
|
|
110
|
-
type: 'heading',
|
|
111
|
-
depth: 1,
|
|
112
|
-
children: [text('Mike Ammerlaan')],
|
|
113
|
-
templateAnnotation: { template: 'imageWithCaption' },
|
|
114
|
-
},
|
|
115
|
-
contents: [paragraph({ type: 'image', url: 'mikehome_files/profile.png', alt: 'Mike' })],
|
|
116
|
-
}),
|
|
117
|
-
]);
|
|
118
|
-
const { container } = render(<LinearDocView doc={doc} />);
|
|
119
|
-
const card = container.querySelector('.squisq-linear-card');
|
|
120
|
-
expect(card).toBeTruthy();
|
|
121
|
-
const img = card!.querySelector('image, img') as Element | null;
|
|
122
|
-
expect(img).toBeTruthy();
|
|
123
|
-
const href =
|
|
124
|
-
img!.getAttribute('href') ?? img!.getAttribute('xlink:href') ?? img!.getAttribute('src');
|
|
125
|
-
expect(href).toContain('mikehome_files/profile.png');
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it('extracts imageSrc from raw HTML <img> (resized image)', () => {
|
|
129
|
-
// The WYSIWYG editor emits `<img src width>` for resized images
|
|
130
|
-
// because markdown shorthand has no width syntax. The linear view
|
|
131
|
-
// must read that form too, or every resized imageWithCaption block
|
|
132
|
-
// renders as a broken card.
|
|
133
|
-
const doc = mkDoc([
|
|
134
|
-
mkBlock({
|
|
135
|
-
id: 'img-2',
|
|
136
|
-
sourceHeading: {
|
|
137
|
-
type: 'heading',
|
|
138
|
-
depth: 1,
|
|
139
|
-
children: [text('Resized')],
|
|
140
|
-
templateAnnotation: { template: 'imageWithCaption' },
|
|
141
|
-
},
|
|
142
|
-
contents: [
|
|
143
|
-
{
|
|
144
|
-
type: 'htmlBlock',
|
|
145
|
-
rawHtml: '<img alt="resized" src="resized.png" width="194">',
|
|
146
|
-
htmlChildren: [
|
|
147
|
-
{
|
|
148
|
-
type: 'htmlElement',
|
|
149
|
-
tagName: 'img',
|
|
150
|
-
attributes: { src: 'resized.png', alt: 'resized', width: '194' },
|
|
151
|
-
children: [],
|
|
152
|
-
selfClosing: true,
|
|
153
|
-
},
|
|
154
|
-
],
|
|
155
|
-
} as unknown as MarkdownBlockNode,
|
|
156
|
-
],
|
|
157
|
-
}),
|
|
158
|
-
]);
|
|
159
|
-
const { container } = render(<LinearDocView doc={doc} />);
|
|
160
|
-
const card = container.querySelector('.squisq-linear-card');
|
|
161
|
-
expect(card).toBeTruthy();
|
|
162
|
-
const img = card!.querySelector('image, img') as Element | null;
|
|
163
|
-
const href =
|
|
164
|
-
img?.getAttribute('href') ?? img?.getAttribute('xlink:href') ?? img?.getAttribute('src');
|
|
165
|
-
expect(href).toContain('resized.png');
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it('renders annotated block as SVG card', () => {
|
|
169
|
-
const doc = mkDoc([
|
|
170
|
-
mkBlock({
|
|
171
|
-
id: 'annotated-1',
|
|
172
|
-
template: 'sectionHeader',
|
|
173
|
-
sourceHeading: {
|
|
174
|
-
type: 'heading',
|
|
175
|
-
depth: 2,
|
|
176
|
-
children: [text('Visual Block')],
|
|
177
|
-
templateAnnotation: {
|
|
178
|
-
template: 'sectionHeader',
|
|
179
|
-
},
|
|
180
|
-
},
|
|
181
|
-
contents: [paragraph(text('Body'))],
|
|
182
|
-
}),
|
|
183
|
-
]);
|
|
184
|
-
const { container } = render(<LinearDocView doc={doc} />);
|
|
185
|
-
// Should have a card wrapper
|
|
186
|
-
const card = container.querySelector('.squisq-linear-card');
|
|
187
|
-
expect(card).toBeTruthy();
|
|
188
|
-
// Should contain an SVG (from BlockRenderer)
|
|
189
|
-
const svg = card?.querySelector('svg');
|
|
190
|
-
expect(svg).toBeTruthy();
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
it('renders transform-generated template blocks without authoring nodes', () => {
|
|
194
|
-
const doc = mkDoc([
|
|
195
|
-
mkBlock({
|
|
196
|
-
id: 'transform-stat',
|
|
197
|
-
template: 'statHighlight',
|
|
198
|
-
stat: '42%',
|
|
199
|
-
description: 'Year-over-year growth',
|
|
200
|
-
} as Partial<Block>),
|
|
201
|
-
]);
|
|
202
|
-
|
|
203
|
-
const { container } = render(<LinearDocView doc={doc} />);
|
|
204
|
-
const section = container.querySelector('[data-block-id="transform-stat"]');
|
|
205
|
-
expect(section?.getAttribute('data-template')).toBe('statHighlight');
|
|
206
|
-
expect(section?.querySelector('.squisq-linear-card svg')).toBeTruthy();
|
|
207
|
-
expect(section?.textContent).toContain('42%');
|
|
208
|
-
expect(section?.textContent).toContain('Year-over-year growth');
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
it('renders children recursively', () => {
|
|
212
|
-
const doc = mkDoc([
|
|
213
|
-
mkBlock({
|
|
214
|
-
id: 'parent',
|
|
215
|
-
sourceHeading: mkHeading(1, 'Parent'),
|
|
216
|
-
contents: [paragraph(text('Parent body'))],
|
|
217
|
-
children: [
|
|
218
|
-
mkBlock({
|
|
219
|
-
id: 'child',
|
|
220
|
-
sourceHeading: mkHeading(2, 'Child'),
|
|
221
|
-
contents: [paragraph(text('Child body'))],
|
|
222
|
-
}),
|
|
223
|
-
],
|
|
224
|
-
}),
|
|
225
|
-
]);
|
|
226
|
-
const { container } = render(<LinearDocView doc={doc} />);
|
|
227
|
-
expect(container.querySelector('h1')?.textContent).toBe('Parent');
|
|
228
|
-
expect(container.querySelector('h2')?.textContent).toBe('Child');
|
|
229
|
-
expect(container.textContent).toContain('Parent body');
|
|
230
|
-
expect(container.textContent).toContain('Child body');
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
it('assigns unique pre-order indices across nested and top-level blocks', () => {
|
|
234
|
-
const doc = mkDoc([
|
|
235
|
-
mkBlock({
|
|
236
|
-
id: 'parent',
|
|
237
|
-
children: [mkBlock({ id: 'child' })],
|
|
238
|
-
}),
|
|
239
|
-
mkBlock({ id: 'sibling' }),
|
|
240
|
-
]);
|
|
241
|
-
const { container } = render(<LinearDocView doc={doc} />);
|
|
242
|
-
expect(
|
|
243
|
-
Array.from(container.querySelectorAll('.squisq-linear-section')).map((node) =>
|
|
244
|
-
node.getAttribute('data-block-index'),
|
|
245
|
-
),
|
|
246
|
-
).toEqual(['0', '1', '2']);
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
it('renders multiple top-level blocks', () => {
|
|
250
|
-
const doc = mkDoc([
|
|
251
|
-
mkBlock({
|
|
252
|
-
id: 'b1',
|
|
253
|
-
sourceHeading: mkHeading(1, 'First'),
|
|
254
|
-
contents: [],
|
|
255
|
-
}),
|
|
256
|
-
mkBlock({
|
|
257
|
-
id: 'b2',
|
|
258
|
-
sourceHeading: mkHeading(1, 'Second'),
|
|
259
|
-
contents: [],
|
|
260
|
-
}),
|
|
261
|
-
]);
|
|
262
|
-
const { container } = render(<LinearDocView doc={doc} />);
|
|
263
|
-
const sections = container.querySelectorAll('.squisq-linear-section');
|
|
264
|
-
expect(sections.length).toBe(2);
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
it('does not render SVG for non-annotated blocks', () => {
|
|
268
|
-
const doc = mkDoc([
|
|
269
|
-
mkBlock({
|
|
270
|
-
id: 'plain',
|
|
271
|
-
sourceHeading: mkHeading(2, 'Plain Section'),
|
|
272
|
-
contents: [paragraph(text('Just text'))],
|
|
273
|
-
}),
|
|
274
|
-
]);
|
|
275
|
-
const { container } = render(<LinearDocView doc={doc} />);
|
|
276
|
-
expect(container.querySelector('.squisq-linear-card')).toBeNull();
|
|
277
|
-
expect(container.querySelector('svg')).toBeNull();
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
it('applies custom className', () => {
|
|
281
|
-
const doc = mkDoc([mkBlock({ id: 'x', contents: [] })]);
|
|
282
|
-
const { container } = render(<LinearDocView doc={doc} className="my-class" />);
|
|
283
|
-
expect(container.querySelector('.squisq-linear.my-class')).toBeTruthy();
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
it('sets data-block-id on each section', () => {
|
|
287
|
-
const doc = mkDoc([
|
|
288
|
-
mkBlock({ id: 'alpha', contents: [] }),
|
|
289
|
-
mkBlock({ id: 'beta', contents: [] }),
|
|
290
|
-
]);
|
|
291
|
-
const { container } = render(<LinearDocView doc={doc} />);
|
|
292
|
-
expect(container.querySelector('[data-block-id="alpha"]')).toBeTruthy();
|
|
293
|
-
expect(container.querySelector('[data-block-id="beta"]')).toBeTruthy();
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
it('uses the theme background by default', () => {
|
|
297
|
-
const doc = mkDoc([mkBlock({ id: 'b', contents: [paragraph(text('hi'))] })]);
|
|
298
|
-
const { container } = render(<LinearDocView doc={doc} />);
|
|
299
|
-
const el = container.querySelector('.squisq-linear') as HTMLElement;
|
|
300
|
-
expect(el.style.background).toBeTruthy();
|
|
301
|
-
// DEFAULT_THEME has a specific background; applying LIGHT_SURFACE below
|
|
302
|
-
// must produce a different value to prove override is working.
|
|
303
|
-
expect(el.style.background).not.toBe(LIGHT_SURFACE.background);
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
it('light surface overlays the theme background', () => {
|
|
307
|
-
const doc = mkDoc([mkBlock({ id: 'b', contents: [paragraph(text('hi'))] })]);
|
|
308
|
-
const { container } = render(
|
|
309
|
-
<LinearDocView doc={doc} theme={DEFAULT_THEME} surface={LIGHT_SURFACE} />,
|
|
310
|
-
);
|
|
311
|
-
const el = container.querySelector('.squisq-linear') as HTMLElement;
|
|
312
|
-
// React inline style background may round-trip as hex or rgb; compare the
|
|
313
|
-
// colour-normalized value by mounting a plain div with the expected.
|
|
314
|
-
const probe = document.createElement('div');
|
|
315
|
-
probe.style.background = LIGHT_SURFACE.background;
|
|
316
|
-
expect(el.style.background).toBe(probe.style.background);
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
it('dark surface overlays the theme background', () => {
|
|
320
|
-
const doc = mkDoc([mkBlock({ id: 'b', contents: [paragraph(text('hi'))] })]);
|
|
321
|
-
const { container } = render(
|
|
322
|
-
<LinearDocView doc={doc} theme={DEFAULT_THEME} surface={DARK_SURFACE} />,
|
|
323
|
-
);
|
|
324
|
-
const el = container.querySelector('.squisq-linear') as HTMLElement;
|
|
325
|
-
const probe = document.createElement('div');
|
|
326
|
-
probe.style.background = DARK_SURFACE.background;
|
|
327
|
-
expect(el.style.background).toBe(probe.style.background);
|
|
328
|
-
});
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
describe('LinearDocView markdown prop', () => {
|
|
332
|
-
it('renders from the markdown prop when no doc is given', () => {
|
|
333
|
-
const { container } = render(<LinearDocView markdown={'# From Markdown\n\nParagraph body.'} />);
|
|
334
|
-
expect(container.textContent).toContain('From Markdown');
|
|
335
|
-
expect(container.textContent).toContain('Paragraph body.');
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
it('adds extra spacing between blank-line-separated paragraphs', () => {
|
|
339
|
-
const { container } = render(
|
|
340
|
-
<LinearDocView markdown={'First paragraph.\n\nSecond paragraph.'} />,
|
|
341
|
-
);
|
|
342
|
-
const paragraphs = container.querySelectorAll('.squisq-md-p');
|
|
343
|
-
const styles = container.querySelector('.squisq-linear-content > style')?.textContent;
|
|
344
|
-
|
|
345
|
-
expect(paragraphs).toHaveLength(2);
|
|
346
|
-
expect(paragraphs[0]?.nextElementSibling).toBe(paragraphs[1]);
|
|
347
|
-
expect(styles).toContain('.squisq-linear-content p + p');
|
|
348
|
-
expect(styles).toContain('margin-top: 1.25em');
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
it('doc wins over markdown when both are provided', () => {
|
|
352
|
-
const doc = mkDoc([mkBlock({ id: 'b', contents: [paragraph(text('Doc body wins'))] })]);
|
|
353
|
-
const { container } = render(<LinearDocView doc={doc} markdown="# Markdown Loses" />);
|
|
354
|
-
expect(container.textContent).toContain('Doc body wins');
|
|
355
|
-
expect(container.textContent).not.toContain('Markdown Loses');
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
it('renders an empty container when neither doc nor markdown is given', () => {
|
|
359
|
-
const { container } = render(<LinearDocView />);
|
|
360
|
-
expect(container.querySelector('.squisq-linear--empty')).toBeTruthy();
|
|
361
|
-
});
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
describe('LinearDocView unknown template annotations', () => {
|
|
365
|
-
it('renders the canonical visible fallback without hidden console output', () => {
|
|
366
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
367
|
-
const doc = mkDoc([
|
|
368
|
-
mkBlock({
|
|
369
|
-
id: 'unknown-1',
|
|
370
|
-
sourceHeading: {
|
|
371
|
-
type: 'heading',
|
|
372
|
-
depth: 2,
|
|
373
|
-
children: [text('Mystery Section')],
|
|
374
|
-
templateAnnotation: { template: 'no-such-template-xyz' },
|
|
375
|
-
},
|
|
376
|
-
contents: [paragraph(text('Fallback body content'))],
|
|
377
|
-
}),
|
|
378
|
-
]);
|
|
379
|
-
|
|
380
|
-
const { container } = render(<LinearDocView doc={doc} />);
|
|
381
|
-
expect(container.textContent).toContain('Mystery Section');
|
|
382
|
-
expect(container.textContent).toContain('Fallback body content');
|
|
383
|
-
expect(container.textContent).toContain('Unknown template "no-such-template-xyz"');
|
|
384
|
-
expect(container.querySelector('.squisq-linear-card')).not.toBeNull();
|
|
385
|
-
expect(warnSpy).not.toHaveBeenCalled();
|
|
386
|
-
warnSpy.mockRestore();
|
|
387
|
-
});
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
describe('LinearDocView custom template materialization', () => {
|
|
391
|
-
it('renders document-scoped templates through the canonical API', () => {
|
|
392
|
-
const doc: Doc = {
|
|
393
|
-
...mkDoc([
|
|
394
|
-
mkBlock({
|
|
395
|
-
id: 'custom-1',
|
|
396
|
-
sourceHeading: {
|
|
397
|
-
type: 'heading',
|
|
398
|
-
depth: 2,
|
|
399
|
-
children: [text('Custom Hero')],
|
|
400
|
-
templateAnnotation: { template: 'hero' },
|
|
401
|
-
},
|
|
402
|
-
contents: [paragraph(text('Custom body'))],
|
|
403
|
-
}),
|
|
404
|
-
]),
|
|
405
|
-
customTemplates: [
|
|
406
|
-
{
|
|
407
|
-
name: 'hero',
|
|
408
|
-
label: 'Hero',
|
|
409
|
-
viewport: { width: 1920, height: 1080 },
|
|
410
|
-
layers: [
|
|
411
|
-
{
|
|
412
|
-
id: 'hero-title',
|
|
413
|
-
type: 'text',
|
|
414
|
-
position: { x: '5%', y: '10%', width: '90%' },
|
|
415
|
-
content: { text: '{title}: {content}', style: { fontSize: 48, color: '#000000' } },
|
|
416
|
-
},
|
|
417
|
-
],
|
|
418
|
-
},
|
|
419
|
-
],
|
|
420
|
-
};
|
|
421
|
-
|
|
422
|
-
const { container } = render(<LinearDocView doc={doc} />);
|
|
423
|
-
expect(container.textContent).toContain('Custom Hero: Custom body');
|
|
424
|
-
expect(container.textContent).not.toContain('Unknown template');
|
|
425
|
-
});
|
|
426
|
-
});
|