@cntrl-site/sdk-nextjs 1.10.8 → 1.11.0-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/cntrl-site-sdk-nextjs-1.9.125.tgz +0 -0
  2. package/lib/components/StructuredBlockItem/BlockCaption/BlockCaption.js +35 -0
  3. package/lib/components/StructuredBlockItem/StructuredBlockItem.js +17 -42
  4. package/lib/components/StructuredBlockItem/StructuredCode/StructuredCode.js +75 -0
  5. package/lib/components/StructuredBlockItem/StructuredDivider/StructuredDivider.js +29 -0
  6. package/lib/components/StructuredBlockItem/StructuredEmbed/StructuredVimeoEmbed.js +89 -0
  7. package/lib/components/StructuredBlockItem/StructuredEmbed/StructuredYoutubeEmbed.js +97 -0
  8. package/lib/components/StructuredBlockItem/StructuredEmbed/embedFrameStyles.js +36 -0
  9. package/lib/components/StructuredBlockItem/StructuredHeaderImage/StructuredHeaderImage.js +43 -0
  10. package/lib/components/StructuredBlockItem/StructuredImage/StructuredImage.js +65 -0
  11. package/lib/components/StructuredBlockItem/StructuredRichText/StructuredRichText.js +46 -0
  12. package/lib/components/StructuredBlockItem/StructuredVideo/StructuredVideo.js +74 -0
  13. package/lib/components/StructuredBlockItem/blockPlacement.js +41 -0
  14. package/lib/components/StructuredBlockItem/blocksMap.js +29 -0
  15. package/lib/components/StructuredBlockItem/textStylesRule.js +46 -0
  16. package/lib/components/StructuredContent/StructuredContent.js +24 -17
  17. package/lib/components/StructuredContent/StructuredHeader.js +95 -0
  18. package/lib/components/items/EmbedVideoItem/VimeoEmbed.js +2 -15
  19. package/lib/utils/RichTextConverter/RichTextConverter.js +52 -14
  20. package/lib/utils/getVimeoEmbedUrl.js +18 -0
  21. package/package.json +3 -3
  22. package/src/components/StructuredBlockItem/BlockCaption/BlockCaption.tsx +42 -0
  23. package/src/components/StructuredBlockItem/StructuredBlockItem.tsx +23 -50
  24. package/src/components/StructuredBlockItem/StructuredCode/StructuredCode.tsx +86 -0
  25. package/src/components/StructuredBlockItem/StructuredDivider/StructuredDivider.tsx +31 -0
  26. package/src/components/StructuredBlockItem/StructuredEmbed/StructuredVimeoEmbed.tsx +101 -0
  27. package/src/components/StructuredBlockItem/StructuredEmbed/StructuredYoutubeEmbed.tsx +106 -0
  28. package/src/components/StructuredBlockItem/StructuredEmbed/embedFrameStyles.ts +32 -0
  29. package/src/components/StructuredBlockItem/StructuredHeaderImage/StructuredHeaderImage.tsx +43 -0
  30. package/src/components/StructuredBlockItem/StructuredImage/StructuredImage.tsx +75 -0
  31. package/src/components/StructuredBlockItem/StructuredRichText/StructuredRichText.tsx +53 -0
  32. package/src/components/StructuredBlockItem/StructuredVideo/StructuredVideo.tsx +106 -0
  33. package/src/components/StructuredBlockItem/blockPlacement.ts +39 -0
  34. package/src/components/StructuredBlockItem/blocksMap.ts +30 -0
  35. package/src/components/StructuredBlockItem/textStylesRule.ts +44 -0
  36. package/src/components/StructuredContent/StructuredContent.test.tsx +217 -0
  37. package/src/components/StructuredContent/StructuredContent.tsx +25 -19
  38. package/src/components/StructuredContent/StructuredHeader.tsx +119 -0
  39. package/src/components/items/EmbedVideoItem/VimeoEmbed.tsx +2 -14
  40. package/src/utils/RichTextConverter/RichTextConverter.test.tsx +67 -0
  41. package/src/utils/RichTextConverter/RichTextConverter.tsx +77 -14
  42. package/src/utils/getVimeoEmbedUrl.ts +16 -0
@@ -0,0 +1,106 @@
1
+ import { VideoStructuredBlock } from '@cntrl-site/sdk';
2
+ import { FC, useId, useState } from 'react';
3
+ import JSXStyle from 'styled-jsx/style';
4
+ import { useItemGeometry } from '../../../ItemGeometry/useItemGeometry';
5
+ import { useLayoutContext } from '../../useLayoutContext';
6
+ import { StructuredBlockItemProps } from '../StructuredBlockItem';
7
+
8
+ /**
9
+ * A video at its own proportions, playing on its own, once pointed at, or once clicked, as the
10
+ * layout says; its cover shows until it first plays. It takes the 16:9 of a video until its own
11
+ * proportions are known.
12
+ */
13
+ export const StructuredVideo: FC<StructuredBlockItemProps<VideoStructuredBlock>> = ({ block }) => {
14
+ const reactId = useId();
15
+ const layoutId = useLayoutContext();
16
+ const [ref, setRef] = useState<HTMLDivElement | null>(null);
17
+ const [video, setVideo] = useState<HTMLVideoElement | null>(null);
18
+ const [isLoaded, setIsLoaded] = useState(false);
19
+ const [isPlaying, setIsPlaying] = useState(false);
20
+ const [hasPlayed, setHasPlayed] = useState(false);
21
+ useItemGeometry(block.id, ref);
22
+ const { url, coverUrl } = block.commonParams;
23
+ const layoutParams = layoutId ? block.layoutParams[layoutId] : undefined;
24
+ const play = layoutParams?.play;
25
+ const start = () => {
26
+ if (!video) return;
27
+ setHasPlayed(true);
28
+ video.play();
29
+ };
30
+ return (
31
+ <div ref={setRef} className={`structured-video-${block.id}${isLoaded ? '' : ` structured-video-loading-${block.id}`}`}>
32
+ {layoutParams && (
33
+ <>
34
+ <video
35
+ key={layoutId}
36
+ ref={setVideo}
37
+ className={`structured-video-el-${block.id}`}
38
+ src={url}
39
+ autoPlay={play === 'auto'}
40
+ muted={layoutParams.muted}
41
+ controls={layoutParams.controls}
42
+ loop
43
+ playsInline
44
+ preload="auto"
45
+ onLoadedMetadata={() => setIsLoaded(true)}
46
+ onPlay={() => {
47
+ setIsPlaying(true);
48
+ setHasPlayed(true);
49
+ }}
50
+ onPause={() => setIsPlaying(false)}
51
+ onMouseEnter={() => {
52
+ if (play === 'on-hover') start();
53
+ }}
54
+ onMouseLeave={() => {
55
+ if (play === 'on-hover') video?.pause();
56
+ }}
57
+ />
58
+ {coverUrl && play !== 'auto' && !hasPlayed && (
59
+ <img
60
+ className={`structured-video-cover-${block.id}`}
61
+ src={coverUrl}
62
+ alt=""
63
+ onMouseEnter={() => {
64
+ if (play === 'on-hover') start();
65
+ }}
66
+ onClick={start}
67
+ />
68
+ )}
69
+ {play === 'on-click' && !layoutParams.controls && (
70
+ <div
71
+ className={`structured-video-overlay-${block.id}`}
72
+ onClick={() => (isPlaying ? video?.pause() : start())}
73
+ />
74
+ )}
75
+ </>
76
+ )}
77
+ <JSXStyle id={`${reactId}-video-${block.id}`}>{`
78
+ .structured-video-${block.id} {
79
+ position: relative;
80
+ width: 100%;
81
+ }
82
+ .structured-video-loading-${block.id} {
83
+ aspect-ratio: 16 / 9;
84
+ }
85
+ .structured-video-el-${block.id} {
86
+ display: block;
87
+ width: 100%;
88
+ height: auto;
89
+ }
90
+ .structured-video-cover-${block.id},
91
+ .structured-video-overlay-${block.id} {
92
+ position: absolute;
93
+ top: 0;
94
+ left: 0;
95
+ width: 100%;
96
+ height: 100%;
97
+ cursor: pointer;
98
+ }
99
+ .structured-video-cover-${block.id} {
100
+ object-fit: cover;
101
+ }
102
+ `}
103
+ </JSXStyle>
104
+ </div>
105
+ );
106
+ };
@@ -0,0 +1,39 @@
1
+ import { StructuredBlockArea } from '@cntrl-site/sdk';
2
+ import { parseSizing } from '../items/useSizing';
3
+
4
+ /**
5
+ * Where a block sits in a column of blocks, as CSS declarations: on the content grid where it is
6
+ * drawn from a preset, the area then carrying its `left` and its inner paddings; anywhere else sized
7
+ * and aligned by itself.
8
+ */
9
+ export function blockPlacement(area: StructuredBlockArea, layoutParams: object): string {
10
+ const sizing = parseSizing('sizing' in layoutParams ? layoutParams.sizing as string | undefined : undefined);
11
+ const height = sizing.y === 'manual' && area.height !== undefined ? `${area.height * 100}vw` : 'auto';
12
+ if (area.left !== undefined) {
13
+ return `
14
+ align-self: flex-start;
15
+ left: ${area.left * 100}vw;
16
+ width: ${(area.width ?? 1) * 100}vw;
17
+ height: ${height};
18
+ padding-top: ${(area.innerPaddingTop ?? 0) * 100}vw;
19
+ padding-bottom: ${(area.innerPaddingBottom ?? 0) * 100}vw;
20
+ `;
21
+ }
22
+ return `
23
+ align-self: ${getAlignSelf(area.alignment ?? 'center')};
24
+ left: ${(area.horizontalOffset ?? 0) * 100}vw;
25
+ width: ${sizing.x === 'manual' && area.width ? `${area.width * 100}vw` : 'max-content'};
26
+ height: ${height};
27
+ `;
28
+ }
29
+
30
+ function getAlignSelf(alignment: 'left' | 'center' | 'right') {
31
+ switch (alignment) {
32
+ case 'left':
33
+ return 'flex-start';
34
+ case 'right':
35
+ return 'flex-end';
36
+ default:
37
+ return 'center';
38
+ }
39
+ }
@@ -0,0 +1,30 @@
1
+ import { StructuredBlockType } from '@cntrl-site/sdk';
2
+ import { ComponentType } from 'react';
3
+ import type { StructuredBlockItemProps } from './StructuredBlockItem';
4
+ import { StructuredComponent } from './StructuredComponent/StructuredComponent';
5
+ import { StructuredRichText } from './StructuredRichText/StructuredRichText';
6
+ import { StructuredImage } from './StructuredImage/StructuredImage';
7
+ import { StructuredHeaderImage } from './StructuredHeaderImage/StructuredHeaderImage';
8
+ import { StructuredVideo } from './StructuredVideo/StructuredVideo';
9
+ import { StructuredDivider } from './StructuredDivider/StructuredDivider';
10
+ import { StructuredCode } from './StructuredCode/StructuredCode';
11
+ import { StructuredVimeoEmbed } from './StructuredEmbed/StructuredVimeoEmbed';
12
+ import { StructuredYoutubeEmbed } from './StructuredEmbed/StructuredYoutubeEmbed';
13
+
14
+ const noop = () => null;
15
+
16
+ export const blocksMap: Record<StructuredBlockType, ComponentType<StructuredBlockItemProps<any>>> = {
17
+ [StructuredBlockType.Component]: StructuredComponent,
18
+ [StructuredBlockType.RichText]: StructuredRichText,
19
+ [StructuredBlockType.Quote]: StructuredRichText,
20
+ [StructuredBlockType.Date]: StructuredRichText,
21
+ [StructuredBlockType.Image]: StructuredImage,
22
+ [StructuredBlockType.HeaderImage]: StructuredHeaderImage,
23
+ [StructuredBlockType.Video]: StructuredVideo,
24
+ [StructuredBlockType.Divider]: StructuredDivider,
25
+ [StructuredBlockType.Code]: StructuredCode,
26
+ [StructuredBlockType.VimeoEmbed]: StructuredVimeoEmbed,
27
+ [StructuredBlockType.YoutubeEmbed]: StructuredYoutubeEmbed,
28
+ // a section's header is drawn by its section, never in the stack
29
+ [StructuredBlockType.Header]: noop
30
+ };
@@ -0,0 +1,44 @@
1
+ import { CntrlColor } from '@cntrl-site/color';
2
+ import { StructuredBlockTextStyles } from '@cntrl-site/sdk';
3
+ import { getFontFamilyValue } from '../../utils/getFontFamilyValue';
4
+
5
+ /** A block's text styles as a CSS rule for `selector`, its lengths in viewport units as the block's own. */
6
+ export function textStylesRule(selector: string, styles: StructuredBlockTextStyles): string {
7
+ const color = CntrlColor.parse(styles.color);
8
+ return `
9
+ ${selector} {
10
+ font-family: ${getFontFamilyValue(styles.typeFace)};
11
+ font-size: ${styles.fontSize * 100}vw;
12
+ line-height: ${styles.lineHeight * 100}vw;
13
+ letter-spacing: ${styles.letterSpacing * 100}vw;
14
+ word-spacing: ${styles.wordSpacing * 100}vw;
15
+ font-weight: ${styles.fontWeight};
16
+ font-style: ${styles.fontStyle || 'normal'};
17
+ font-variant: ${styles.fontVariant};
18
+ text-transform: ${styles.textTransform};
19
+ vertical-align: ${styles.verticalAlign};
20
+ text-align: ${styles.textAlign};
21
+ color: ${color.fmt('oklch')};
22
+ }
23
+ @supports not (color: oklch(42% 0.3 90 / 1)) {
24
+ ${selector} {
25
+ color: ${color.fmt('rgba')};
26
+ }
27
+ }
28
+ `;
29
+ }
30
+
31
+ /** A colour of the palette as CSS for `property`, with a fallback where oklch is not supported. */
32
+ export function colorRule(selector: string, property: string, value: string): string {
33
+ const color = CntrlColor.parse(value);
34
+ return `
35
+ ${selector} {
36
+ ${property}: ${color.fmt('oklch')};
37
+ }
38
+ @supports not (color: oklch(42% 0.3 90 / 1)) {
39
+ ${selector} {
40
+ ${property}: ${color.fmt('rgba')};
41
+ }
42
+ }
43
+ `;
44
+ }
@@ -0,0 +1,217 @@
1
+ import { fireEvent, render } from '@testing-library/react';
2
+ import { Layout, Section, StructuredBlockType } from '@cntrl-site/sdk';
3
+ import { CntrlContext } from '../../provider/CntrlContext';
4
+ import { CntrlSdkContext } from '../../provider/CntrlSdkContext';
5
+ import { CustomItemRegistry } from '../../provider/CustomItemRegistry';
6
+ import { CustomSectionRegistry } from '../../provider/CustomSectionRegistry';
7
+ import { LayoutContext } from '../../provider/LayoutContext';
8
+ import { StructuredContent } from './StructuredContent';
9
+
10
+ // the styles each component writes, drawn where it is for the test to read them
11
+ jest.mock('styled-jsx/style', () => ({ __esModule: true, default: ({ children }: { children: string | string[] }) => <style>{children}</style> }));
12
+ jest.mock('@vimeo/player', () => jest.fn().mockImplementation(() => ({ on: jest.fn(), off: jest.fn(), play: jest.fn(), pause: jest.fn() })));
13
+
14
+ const layouts: Layout[] = [
15
+ { id: 'desktop', title: 'Desktop', startsWith: 800, exemplary: 1440 },
16
+ { id: 'mobile', title: 'Mobile', startsWith: 0, exemplary: 375 }
17
+ ];
18
+
19
+ const textStyles = {
20
+ typeFace: 'Arial',
21
+ fontSize: 0.02,
22
+ lineHeight: 0.03,
23
+ letterSpacing: 0,
24
+ wordSpacing: 0,
25
+ fontWeight: 400,
26
+ fontStyle: 'normal',
27
+ fontVariant: 'normal',
28
+ textTransform: 'none',
29
+ verticalAlign: 'unset',
30
+ textAlign: 'center',
31
+ color: '#000000'
32
+ };
33
+
34
+ const onGrid = { zIndex: 1, paddingTop: 0.01, left: 0.35, width: 0.3, innerPaddingTop: 0.005, innerPaddingBottom: 0.006 };
35
+
36
+ function block(type: StructuredBlockType, id: string, commonParams: object, layoutParams: object, area: object = onGrid) {
37
+ return {
38
+ id,
39
+ type,
40
+ hidden: {},
41
+ state: {},
42
+ area: { desktop: area, mobile: area },
43
+ layoutParams: { desktop: layoutParams, mobile: layoutParams },
44
+ commonParams
45
+ };
46
+ }
47
+
48
+ const paragraph = (text: string) => ({ text: `${text}\n`, blocks: [{ start: 0, end: text.length, type: 'unstyled', entities: [] }] });
49
+
50
+ const title = block(StructuredBlockType.RichText, 'title', paragraph('The title'), { ...textStyles, rangeStyles: [] });
51
+ const headerImage = block(StructuredBlockType.HeaderImage, 'key-image', { url: 'https://cdn.cntrl.site/key.jpg', altText: 'Key', caption: 'Under the key image' }, { opacity: 1 });
52
+ const text = block(StructuredBlockType.RichText, 'text', paragraph('Read the post'), {
53
+ ...textStyles,
54
+ rangeStyles: [{ start: 0, end: 4, style: 'FONTSIZE', value: '0.04' }]
55
+ });
56
+ const quote = block(StructuredBlockType.Quote, 'quote', { ...paragraph('A quote'), kind: 'boxed' }, {
57
+ ...textStyles,
58
+ rangeStyles: [],
59
+ background: '#EEEEEE',
60
+ boxPadding: 0.02,
61
+ boxRadius: 0.01
62
+ });
63
+ const date = block(StructuredBlockType.Date, 'date', { ...paragraph('23 Sep 2026'), date: '2026-09-23', format: 'D MMM YYYY' }, {
64
+ ...textStyles,
65
+ rangeStyles: [{ start: 3, end: 6, style: 'FONTWEIGHT', value: '700' }]
66
+ });
67
+ const gallery = block(StructuredBlockType.Image, 'gallery', {
68
+ urls: ['https://cdn.cntrl.site/a.jpg', 'https://cdn.cntrl.site/b.jpg'],
69
+ altText: 'Two views',
70
+ caption: 'Seen from the hill'
71
+ }, { opacity: 0.8, captionStyles: { ...textStyles, fontSize: 0.01 } });
72
+ const video = block(StructuredBlockType.Video, 'video', { url: 'https://cdn.cntrl.site/clip.mp4', coverUrl: 'https://cdn.cntrl.site/cover.jpg' }, {
73
+ play: 'on-click',
74
+ muted: true,
75
+ controls: false
76
+ });
77
+ const code = block(StructuredBlockType.Code, 'code', {
78
+ code: 'const a = 1;',
79
+ language: 'typescript',
80
+ theme: 'dracula',
81
+ lines: [[{ content: 'const', color: '#FF79C6', fontStyle: 2 }, { content: ' a = 1;', color: '#F8F8F2' }]],
82
+ colors: { background: '#282A36', foreground: '#F8F8F2' }
83
+ }, {});
84
+ const divider = block(StructuredBlockType.Divider, 'divider', {}, { color: '#C0C0C0' }, { ...onGrid, height: 0.001 });
85
+ const vimeo = block(StructuredBlockType.VimeoEmbed, 'vimeo', {
86
+ url: 'https://player.vimeo.com/video/1',
87
+ coverUrl: null,
88
+ ratioLock: true
89
+ }, { play: 'auto', controls: true, loop: true, muted: true, pictureInPicture: false, radius: 0.01, blur: 0, opacity: 1 });
90
+ const component = block(StructuredBlockType.Component, 'component', { componentId: 'unknown', content: [] }, { parameters: {}, opacity: 1, blur: 0 }, { zIndex: 1, alignment: 'left' });
91
+
92
+ const header = {
93
+ id: 'header',
94
+ type: StructuredBlockType.Header,
95
+ hidden: {},
96
+ state: {},
97
+ area: { desktop: { zIndex: 2 }, mobile: { zIndex: 2 } },
98
+ commonParams: { title: 'title', subtitle: 'subtitle', date: 'header-date', image: 'key-image' },
99
+ layoutParams: {
100
+ desktop: { variant: 'A', order: ['image', 'title', 'subtitle', 'date'], hiddenElements: [], imageFit: 'cover', coverHeight: 0.8 },
101
+ mobile: { variant: 'B', order: ['title', 'image', 'subtitle', 'date'], hiddenElements: ['image'], imageFit: 'original', coverHeight: 1 }
102
+ }
103
+ };
104
+
105
+ const stack = [text, quote, date, gallery, video, code, divider, vimeo, component];
106
+
107
+ function section(withHeader: boolean) {
108
+ return {
109
+ id: 'content',
110
+ items: [],
111
+ position: {},
112
+ minHeight: {},
113
+ color: {},
114
+ hidden: {},
115
+ type: 'content-based',
116
+ structuredContent: [title, headerImage, ...stack],
117
+ structuredContentSettings: { paddingBottom: { desktop: 0.02, mobile: 0.02 }, defaultWidth: { desktop: 0.3, mobile: 0.8 } },
118
+ ...(withHeader && { header })
119
+ } as unknown as Section;
120
+ }
121
+
122
+ function draw(section: Section) {
123
+ const sdk = new CntrlSdkContext(new CustomItemRegistry(), new CustomSectionRegistry());
124
+ sdk.setLayouts(layouts);
125
+ return render(
126
+ <CntrlContext.Provider value={sdk}>
127
+ <LayoutContext.Provider value="desktop">
128
+ <StructuredContent section={section} />
129
+ </LayoutContext.Provider>
130
+ </CntrlContext.Provider>
131
+ );
132
+ }
133
+
134
+ const css = () => [...document.body.querySelectorAll('style')].map(style => style.textContent).join('\n');
135
+
136
+ describe('StructuredContent', () => {
137
+ it('draws every block of the stack in order', () => {
138
+ const { container } = draw(section(false));
139
+ const items = [...container.querySelectorAll('[class^="structured-block-item-"]')].map(item => item.className.replace('structured-block-item-', ''));
140
+ expect(items).toEqual(['title', 'key-image', ...stack.map(block => block.id)]);
141
+ });
142
+
143
+ it('places a block drawn from a preset on the content grid, its inner paddings apart from the gap above it', () => {
144
+ draw(section(false));
145
+ expect(css()).toMatch(/\.structured-block-item-text \{[^}]*margin-top: 1vw;[^}]*left: 35vw;[^}]*width: 30vw;[^}]*padding-top: 0\.5vw;[^}]*padding-bottom: 0\.6vw;/);
146
+ expect(css()).toMatch(/\.structured-block-item-component \{[^}]*align-self: flex-start;/);
147
+ });
148
+
149
+ it('sets text in its styles, its ranges in viewport units', () => {
150
+ const { container } = draw(section(false));
151
+ expect(container.querySelector('.structured-text-text')!.textContent!.trim()).toBe('Read the post');
152
+ expect(css()).toMatch(/\.structured-text-text \{[^}]*font-size: 2vw;[^}]*line-height: 3vw;/);
153
+ expect(css()).toContain('font-size: 4vw');
154
+ });
155
+
156
+ it('draws a quote in its box and a date as the text it reads as', () => {
157
+ const { container } = draw(section(false));
158
+ expect(container.querySelector('.structured-quote-box-quote')!.textContent!.trim()).toBe('A quote');
159
+ expect(css()).toMatch(/\.structured-quote-box-quote \{[^}]*padding: 2vw;[^}]*border-radius: 1vw;/);
160
+ expect(container.querySelector('.structured-text-date')!.textContent!.trim()).toBe('23 Sep 2026');
161
+ });
162
+
163
+ it('draws a gallery\'s images side by side, its caption under them', () => {
164
+ const { container } = draw(section(false));
165
+ const images = [...container.querySelectorAll<HTMLImageElement>('.structured-gallery-image-gallery')];
166
+ expect(images.map(image => image.alt)).toEqual(['Two views', 'Two views']);
167
+ // equally wide until their own proportions are known
168
+ expect(images.map(image => image.style.flexGrow)).toEqual(['0.5', '0.5']);
169
+ expect(container.querySelector('.structured-caption-gallery')!.textContent).toBe('Seen from the hill');
170
+ });
171
+
172
+ it('fills a gallery\'s row whatever its images\' proportions, a lone portrait image as wide as the block', () => {
173
+ const portrait = block(StructuredBlockType.Image, 'portrait', { urls: ['https://cdn.cntrl.site/p.jpg'], altText: '', caption: '' }, { opacity: 1 });
174
+ const { container } = draw({ ...section(false), structuredContent: [portrait] } as Section);
175
+ const image = container.querySelector<HTMLImageElement>('.structured-gallery-image-portrait')!;
176
+ Object.defineProperties(image, { complete: { value: true }, naturalWidth: { value: 300 }, naturalHeight: { value: 400 } });
177
+ fireEvent.load(image);
178
+ expect(image.style.flexGrow).toBe('1');
179
+ });
180
+
181
+ it('draws code coloured as it comes, in its theme\'s box', () => {
182
+ const { container } = draw(section(false));
183
+ const tokens = [...container.querySelectorAll<HTMLSpanElement>('.structured-code-text-code span')];
184
+ expect(tokens.map(token => [token.textContent, token.style.color, token.style.fontWeight])).toEqual([
185
+ ['const', 'rgb(255, 121, 198)', 'bold'],
186
+ [' a = 1;', 'rgb(248, 248, 242)', '']
187
+ ]);
188
+ expect(css()).toContain('background: #282A36');
189
+ });
190
+
191
+ it('covers a video played on click until it plays', () => {
192
+ const { container } = draw(section(false));
193
+ expect(container.querySelector('video')!.getAttribute('src')).toBe('https://cdn.cntrl.site/clip.mp4');
194
+ expect(container.querySelector('.structured-video-cover-video')).not.toBeNull();
195
+ expect(container.querySelector('.structured-video-overlay-video')).not.toBeNull();
196
+ });
197
+
198
+ it('draws the header first, with its elements left out of the stack', () => {
199
+ const { container } = draw(section(true));
200
+ const content = container.firstElementChild!;
201
+ expect(content.firstElementChild!.className).toBe('structured-header-header');
202
+ expect(container.querySelectorAll('.structured-text-title')).toHaveLength(1);
203
+ const items = [...container.querySelectorAll('[class^="structured-block-item-"]')].map(item => item.className.replace('structured-block-item-', ''));
204
+ expect(items).toEqual(stack.map(block => block.id));
205
+ });
206
+
207
+ it('arranges the header by the variant of each layout', () => {
208
+ draw(section(true));
209
+ // desktop, variant A: the key image covers the header, the text on its bottom edge, no caption
210
+ expect(css()).toMatch(/@media \(min-width: 800px\) \{[\s\S]*\.structured-header-header \{[^}]*height: 80svh;/);
211
+ expect(css()).toMatch(/\.structured-header-stack-header \{[^}]*position: absolute;/);
212
+ expect(css()).toMatch(/\.structured-header-element-key-image \.structured-caption-key-image \{\s*display: none;/);
213
+ // mobile, variant B: the elements flow in its order, the image hidden there
214
+ expect(css()).toMatch(/\.structured-header-stack-header \{\s*display: contents;/);
215
+ expect(css()).toMatch(/\.structured-header-element-key-image \{\s*display: none;/);
216
+ });
217
+ });
@@ -3,38 +3,44 @@ import { FC, useId } from 'react';
3
3
  import JSXStyle from 'styled-jsx/style';
4
4
  import { useCntrlContext } from '../../provider/useCntrlContext';
5
5
  import { StructuredBlockItem } from '../StructuredBlockItem/StructuredBlockItem';
6
+ import { StructuredHeader } from './StructuredHeader';
6
7
 
7
8
  interface Props {
8
9
  section: Section;
9
10
  }
10
11
 
12
+ /**
13
+ * A section's blocks, one under another across the section's whole width: a block drawn from a
14
+ * preset is placed on the content grid by its own area. A content-based section's header comes
15
+ * first, drawing the blocks it points at, which the stack leaves out.
16
+ */
11
17
  export const StructuredContent: FC<Props> = ({ section }) => {
12
18
  const reactId = useId();
13
19
  const id = `${reactId}-structured-content-${section.id}`;
14
20
  const { layouts } = useCntrlContext();
15
- const structuredContentLength = section.structuredContent.length;
16
- const defaultWidthRecord = section.type === 'content-based' ? section.structuredContentSettings.defaultWidth : {};
17
- const layoutValues: Record<string, any>[] = [section.structuredContentSettings.paddingBottom, defaultWidthRecord];
18
- if (structuredContentLength === 0) return null;
21
+ const header = section.type === 'content-based' ? section.header : undefined;
22
+ const headerElementIds: string[] = header ? Object.values(header.commonParams) : [];
23
+ const readingWidth = section.type === 'content-based' ? section.structuredContentSettings.defaultWidth : {};
24
+ const layoutValues: Record<string, any>[] = [section.structuredContentSettings.paddingBottom];
25
+ if (section.structuredContent.length === 0 && !header) return null;
19
26
  return (
20
27
  <div className={`structured-content-${section.id}`}>
21
- {section.structuredContent.map(block => (
22
- <StructuredBlockItem block={block} key={block.id} maxWidthMap={defaultWidthRecord} />
23
- ))}
28
+ {header && <StructuredHeader header={header} blocks={section.structuredContent} readingWidth={readingWidth} />}
29
+ {section.structuredContent
30
+ .filter(block => !headerElementIds.includes(block.id))
31
+ .map(block => <StructuredBlockItem block={block} key={block.id} readingWidth={readingWidth} />)}
24
32
  <JSXStyle id={id}>
25
33
  {`
26
- ${getLayoutStyles(layouts, layoutValues, ([paddingBottom, defaultWidth]) => {
27
- return (`
28
- .structured-content-${section.id} {
29
- padding-bottom: ${paddingBottom * 100}vw;
30
- max-width: ${defaultWidth < 1 ? `${defaultWidth * 100}vw` : 'unset'};
31
- display: flex;
32
- flex-direction: column;
33
- margin: 0 auto;
34
- width: 100%;
35
- }
36
- `);
37
- })}
34
+ .structured-content-${section.id} {
35
+ display: flex;
36
+ flex-direction: column;
37
+ width: 100%;
38
+ }
39
+ ${getLayoutStyles(layouts, layoutValues, ([paddingBottom]) => (`
40
+ .structured-content-${section.id} {
41
+ padding-bottom: ${(paddingBottom ?? 0) * 100}vw;
42
+ }
43
+ `))}
38
44
  `}
39
45
  </JSXStyle>
40
46
  </div>
@@ -0,0 +1,119 @@
1
+ import {
2
+ getLayoutStyles,
3
+ HeaderElementKind,
4
+ HeaderImageStructuredBlock,
5
+ HeaderStructuredBlock,
6
+ StructuredBlockAny,
7
+ StructuredBlockType
8
+ } from '@cntrl-site/sdk';
9
+ import { FC, useId } from 'react';
10
+ import JSXStyle from 'styled-jsx/style';
11
+ import { useCntrlContext } from '../../provider/useCntrlContext';
12
+ import { blockPlacement } from '../StructuredBlockItem/blockPlacement';
13
+ import { blocksMap } from '../StructuredBlockItem/blocksMap';
14
+
15
+ interface Props {
16
+ header: HeaderStructuredBlock;
17
+ /** The section's blocks, the header's elements among them. */
18
+ blocks: StructuredBlockAny[];
19
+ readingWidth: Record<string, number>;
20
+ }
21
+
22
+ const TEXT_KINDS: HeaderElementKind[] = ['title', 'subtitle', 'date'];
23
+
24
+ /**
25
+ * A content-based section's header, drawn first. Variant A draws the key image full width with the
26
+ * other elements on its bottom edge, the image covering a share of the viewport's height if set to;
27
+ * variant B flows the shown elements in order, each placed as in the stack. The variant, the order
28
+ * and what is shown are a layout's, so every element is drawn once and arranged per layout.
29
+ */
30
+ export const StructuredHeader: FC<Props> = ({ header, blocks, readingWidth }) => {
31
+ const reactId = useId();
32
+ const { layouts } = useCntrlContext();
33
+ const elementOf = (kind: HeaderElementKind) => blocks.find(block => block.id === header.commonParams[kind]);
34
+ const image = elementOf('image');
35
+ const hasImage = image?.type === StructuredBlockType.HeaderImage && Boolean((image as HeaderImageStructuredBlock).commonParams.url);
36
+ const texts = TEXT_KINDS.flatMap((kind) => {
37
+ const block = elementOf(kind);
38
+ return block ? [{ kind, block }] : [];
39
+ });
40
+ const headerClass = `structured-header-${header.id}`;
41
+ const stackClass = `structured-header-stack-${header.id}`;
42
+ const elementClass = (block: StructuredBlockAny) => `structured-header-element-${block.id}`;
43
+ const renderElement = (block: StructuredBlockAny) => {
44
+ const BlockComponent = blocksMap[block.type];
45
+ return (
46
+ <div key={block.id} className={elementClass(block)}>
47
+ <BlockComponent block={block} readingWidth={readingWidth} />
48
+ </div>
49
+ );
50
+ };
51
+ const elementStyles = (kind: HeaderElementKind, block: StructuredBlockAny) => getLayoutStyles(
52
+ layouts,
53
+ [header.layoutParams, block.area, block.layoutParams] as Record<string, any>[],
54
+ ([layoutParams, area, blockLayoutParams]) => {
55
+ const isBackdrop = kind === 'image' && layoutParams.variant === 'A';
56
+ const isHidden = !isBackdrop && layoutParams.hiddenElements.includes(kind);
57
+ const isCover = isBackdrop && layoutParams.imageFit === 'cover';
58
+ return (`
59
+ .${elementClass(block)} {
60
+ display: ${isHidden ? 'none' : 'block'};
61
+ position: relative;
62
+ order: ${layoutParams.order.indexOf(kind)};
63
+ ${isBackdrop
64
+ ? `width: 100%; left: 0; height: ${isCover ? '100%' : 'auto'}; padding: 0;`
65
+ : blockPlacement(area, blockLayoutParams)}
66
+ }
67
+ ${isBackdrop ? `
68
+ .${elementClass(block)} .structured-caption-${block.id} {
69
+ display: none;
70
+ }
71
+ ` : ''}
72
+ ${isCover ? `
73
+ .${elementClass(block)} .structured-header-image-${block.id},
74
+ .${elementClass(block)} .structured-header-picture-${block.id} {
75
+ height: 100%;
76
+ }
77
+ .${elementClass(block)} .structured-header-picture-${block.id} {
78
+ object-fit: cover;
79
+ }
80
+ ` : ''}
81
+ `);
82
+ }
83
+ );
84
+ return (
85
+ <div className={headerClass}>
86
+ {image && renderElement(image)}
87
+ <div className={stackClass}>
88
+ {texts.map(({ block }) => renderElement(block))}
89
+ </div>
90
+ <JSXStyle id={`${reactId}-header-${header.id}`}>{`
91
+ .${headerClass} {
92
+ position: relative;
93
+ width: 100%;
94
+ }
95
+ ${getLayoutStyles(layouts, [header.layoutParams, header.area] as Record<string, any>[], ([layoutParams, area]) => {
96
+ const isA = layoutParams.variant === 'A';
97
+ const isCover = isA && layoutParams.imageFit === 'cover';
98
+ return (`
99
+ .${headerClass} {
100
+ display: ${isA ? 'block' : 'flex'};
101
+ flex-direction: column;
102
+ z-index: ${area?.zIndex ?? 0};
103
+ height: ${isCover ? `${layoutParams.coverHeight * 100}vh` : 'auto'};
104
+ height: ${isCover ? `${layoutParams.coverHeight * 100}svh` : 'auto'};
105
+ }
106
+ .${stackClass} {
107
+ ${isA
108
+ ? `display: flex; flex-direction: column; position: ${hasImage ? 'absolute' : 'relative'}; left: 0; right: 0; bottom: 0;`
109
+ : 'display: contents;'}
110
+ }
111
+ `);
112
+ })}
113
+ ${image ? elementStyles('image', image) : ''}
114
+ ${texts.map(({ kind, block }) => elementStyles(kind, block)).join('\n')}
115
+ `}
116
+ </JSXStyle>
117
+ </div>
118
+ );
119
+ };
@@ -13,6 +13,7 @@ import { useEmbedVideoItem } from './useEmbedVideoItem';
13
13
  import { LinkWrapper } from '../LinkWrapper';
14
14
  import { useLayoutContext } from '../../useLayoutContext';
15
15
  import { useItemGeometry } from '../../../ItemGeometry/useItemGeometry';
16
+ import { getVimeoEmbedUrl } from '../../../utils/getVimeoEmbedUrl';
16
17
 
17
18
  export const VimeoEmbedItem: FC<ItemProps<TVimeoEmbedItem>> = ({ item, sectionId, onResize, interactionCtrl, onVisibilityChange }) => {
18
19
  const reactId = useId();
@@ -41,20 +42,7 @@ export const VimeoEmbedItem: FC<ItemProps<TVimeoEmbedItem>> = ({ item, sectionId
41
42
  const radius = getStyleFromItemStateAndParams(frameStateParams?.styles?.radius, itemRadius);
42
43
  useRegisterResize(ref, onResize);
43
44
  useItemGeometry(item.id, ref);
44
- const validUrl = useMemo(() => {
45
- if (!layoutParams) return url;
46
- const validURL = new URL(url);
47
- validURL.searchParams.append('controls', String(layoutParams.controls));
48
- validURL.searchParams.append('autoplay', String(layoutParams.play === 'auto'));
49
- validURL.searchParams.append('muted', String(layoutParams.muted));
50
- validURL.searchParams.append('loop', String(layoutParams.loop));
51
- validURL.searchParams.append('pip', String(layoutParams.pictureInPicture));
52
- validURL.searchParams.append('title', '0');
53
- validURL.searchParams.append('byline', '0');
54
- validURL.searchParams.append('portrait', '0');
55
- validURL.searchParams.append('autopause', 'false');
56
- return validURL.href;
57
- }, [url, layoutParams]);
45
+ const validUrl = useMemo(() => (layoutParams ? getVimeoEmbedUrl(url, layoutParams) : url), [url, layoutParams]);
58
46
 
59
47
  useEffect(() => {
60
48
  if (!vimeoPlayer || !imgRef || !layoutParams) return;