@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.
- package/cntrl-site-sdk-nextjs-1.9.125.tgz +0 -0
- package/lib/components/StructuredBlockItem/BlockCaption/BlockCaption.js +35 -0
- package/lib/components/StructuredBlockItem/StructuredBlockItem.js +17 -42
- package/lib/components/StructuredBlockItem/StructuredCode/StructuredCode.js +75 -0
- package/lib/components/StructuredBlockItem/StructuredDivider/StructuredDivider.js +29 -0
- package/lib/components/StructuredBlockItem/StructuredEmbed/StructuredVimeoEmbed.js +89 -0
- package/lib/components/StructuredBlockItem/StructuredEmbed/StructuredYoutubeEmbed.js +97 -0
- package/lib/components/StructuredBlockItem/StructuredEmbed/embedFrameStyles.js +36 -0
- package/lib/components/StructuredBlockItem/StructuredHeaderImage/StructuredHeaderImage.js +43 -0
- package/lib/components/StructuredBlockItem/StructuredImage/StructuredImage.js +65 -0
- package/lib/components/StructuredBlockItem/StructuredRichText/StructuredRichText.js +46 -0
- package/lib/components/StructuredBlockItem/StructuredVideo/StructuredVideo.js +74 -0
- package/lib/components/StructuredBlockItem/blockPlacement.js +41 -0
- package/lib/components/StructuredBlockItem/blocksMap.js +29 -0
- package/lib/components/StructuredBlockItem/textStylesRule.js +46 -0
- package/lib/components/StructuredContent/StructuredContent.js +24 -17
- package/lib/components/StructuredContent/StructuredHeader.js +95 -0
- package/lib/components/items/EmbedVideoItem/VimeoEmbed.js +2 -15
- package/lib/utils/RichTextConverter/RichTextConverter.js +52 -14
- package/lib/utils/getVimeoEmbedUrl.js +18 -0
- package/package.json +3 -3
- package/src/components/StructuredBlockItem/BlockCaption/BlockCaption.tsx +42 -0
- package/src/components/StructuredBlockItem/StructuredBlockItem.tsx +23 -50
- package/src/components/StructuredBlockItem/StructuredCode/StructuredCode.tsx +86 -0
- package/src/components/StructuredBlockItem/StructuredDivider/StructuredDivider.tsx +31 -0
- package/src/components/StructuredBlockItem/StructuredEmbed/StructuredVimeoEmbed.tsx +101 -0
- package/src/components/StructuredBlockItem/StructuredEmbed/StructuredYoutubeEmbed.tsx +106 -0
- package/src/components/StructuredBlockItem/StructuredEmbed/embedFrameStyles.ts +32 -0
- package/src/components/StructuredBlockItem/StructuredHeaderImage/StructuredHeaderImage.tsx +43 -0
- package/src/components/StructuredBlockItem/StructuredImage/StructuredImage.tsx +75 -0
- package/src/components/StructuredBlockItem/StructuredRichText/StructuredRichText.tsx +53 -0
- package/src/components/StructuredBlockItem/StructuredVideo/StructuredVideo.tsx +106 -0
- package/src/components/StructuredBlockItem/blockPlacement.ts +39 -0
- package/src/components/StructuredBlockItem/blocksMap.ts +30 -0
- package/src/components/StructuredBlockItem/textStylesRule.ts +44 -0
- package/src/components/StructuredContent/StructuredContent.test.tsx +217 -0
- package/src/components/StructuredContent/StructuredContent.tsx +25 -19
- package/src/components/StructuredContent/StructuredHeader.tsx +119 -0
- package/src/components/items/EmbedVideoItem/VimeoEmbed.tsx +2 -14
- package/src/utils/RichTextConverter/RichTextConverter.test.tsx +67 -0
- package/src/utils/RichTextConverter/RichTextConverter.tsx +77 -14
- package/src/utils/getVimeoEmbedUrl.ts +16 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import { Layout, TextAlign } from '@cntrl-site/sdk';
|
|
3
|
+
import { RICH_TEXT_LAYOUT_PENDING_CLASS, RichTextConverter, RichTextSource } from './RichTextConverter';
|
|
4
|
+
|
|
5
|
+
const layouts: Layout[] = [{ id: 'desktop', title: 'Desktop', startsWith: 800, exemplary: 1440 }];
|
|
6
|
+
|
|
7
|
+
function text(blocks: { text: string; type?: string }[], rangeStyles = [{ start: 0, end: 4, style: 'FONTSIZE', value: '0.02' }]): RichTextSource {
|
|
8
|
+
let offset = 0;
|
|
9
|
+
const richTextBlocks = blocks.map(({ text, type = 'unstyled' }) => {
|
|
10
|
+
const block = { start: offset, end: offset + text.length, type, entities: [] };
|
|
11
|
+
offset += text.length + 1;
|
|
12
|
+
return block;
|
|
13
|
+
});
|
|
14
|
+
return {
|
|
15
|
+
id: 'text',
|
|
16
|
+
commonParams: { text: blocks.map(block => `${block.text}\n`).join(''), blocks: richTextBlocks },
|
|
17
|
+
layoutParams: { desktop: { rangeStyles, textAlign: TextAlign.Left } }
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('RichTextConverter', () => {
|
|
22
|
+
const converter = new RichTextConverter();
|
|
23
|
+
|
|
24
|
+
it('sizes an item\'s ranges in pixels of the exemplary, in viewport units while the layout is not known', () => {
|
|
25
|
+
const [, styles] = converter.toHtml(text([{ text: 'Read on' }]), layouts);
|
|
26
|
+
expect(styles).toContain('font-size: 28.8px');
|
|
27
|
+
expect(styles).toContain(`.${RICH_TEXT_LAYOUT_PENDING_CLASS}`);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('sizes the ranges of text in the page\'s flow in viewport units throughout', () => {
|
|
31
|
+
const [, styles] = converter.toHtml(text([{ text: 'Read on' }]), layouts, { isScaledToViewport: false });
|
|
32
|
+
expect(styles).toContain('font-size: 2vw');
|
|
33
|
+
expect(styles).not.toMatch(/font-size: [\d.]+px/);
|
|
34
|
+
expect(styles).not.toContain(RICH_TEXT_LAYOUT_PENDING_CLASS);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('draws list items with their markers, a numbered run restarting after anything else', () => {
|
|
38
|
+
const [content, styles] = converter.toHtml(text([
|
|
39
|
+
{ text: 'Intro' },
|
|
40
|
+
{ text: 'One', type: 'ordered-list-item' },
|
|
41
|
+
{ text: 'Two', type: 'ordered-list-item' },
|
|
42
|
+
{ text: 'Dot', type: 'unordered-list-item' }
|
|
43
|
+
], []), layouts, { isScaledToViewport: false });
|
|
44
|
+
const { container } = render(<>{content}</>);
|
|
45
|
+
expect([...container.children].map(child => child.className.split(' ')[1])).toEqual([
|
|
46
|
+
'rt-paragraph',
|
|
47
|
+
'rt-ordered-list-item',
|
|
48
|
+
'rt-ordered-list-item',
|
|
49
|
+
'rt-unordered-list-item'
|
|
50
|
+
]);
|
|
51
|
+
expect(styles).toContain('counter-increment: cntrl-list');
|
|
52
|
+
expect(styles).toContain('content: \'•\'');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('draws a highlighted range on its colour, with an rgba fallback where oklch is not supported', () => {
|
|
56
|
+
const [, styles] = converter.toHtml(text([{ text: 'Read on' }], [{ start: 0, end: 4, style: 'HIGHLIGHT', value: '#FFFF00' }]), layouts);
|
|
57
|
+
expect(styles).toMatch(/\.s-0-4 \{\s*background-color: [^;]+;/);
|
|
58
|
+
expect(styles).toContain('background-color: rgba(255, 255, 0, 1)');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('leaves text without lists as it was', () => {
|
|
62
|
+
const [content, styles] = converter.toHtml(text([{ text: 'Plain' }]), layouts);
|
|
63
|
+
const { container } = render(<>{content}</>);
|
|
64
|
+
expect(container.firstElementChild!.className).not.toContain('rt-paragraph');
|
|
65
|
+
expect(styles).not.toContain('cntrl-list');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -4,14 +4,37 @@ import {
|
|
|
4
4
|
getLayoutMediaQuery,
|
|
5
5
|
TextTransform,
|
|
6
6
|
Layout,
|
|
7
|
-
RichTextItem,
|
|
8
7
|
VerticalAlign,
|
|
9
8
|
RichTextStyle,
|
|
10
|
-
RichTextEntity
|
|
9
|
+
RichTextEntity,
|
|
10
|
+
RichTextBlock,
|
|
11
|
+
TextAlign
|
|
11
12
|
} from '@cntrl-site/sdk';
|
|
12
13
|
import { LinkWrapper } from '../../components/items/LinkWrapper';
|
|
13
14
|
import { getFontFamilyValue } from '../getFontFamilyValue';
|
|
14
15
|
|
|
16
|
+
/** Rich text to draw: a rich text item, or a block of a section's structured content that holds text. */
|
|
17
|
+
export interface RichTextSource {
|
|
18
|
+
id: string;
|
|
19
|
+
commonParams: {
|
|
20
|
+
text: string;
|
|
21
|
+
blocks?: RichTextBlock[];
|
|
22
|
+
};
|
|
23
|
+
layoutParams: Record<string, {
|
|
24
|
+
rangeStyles?: RichTextStyle[];
|
|
25
|
+
textAlign: TextAlign;
|
|
26
|
+
}>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ToHtmlOptions {
|
|
30
|
+
/**
|
|
31
|
+
* An item is drawn at its layout's exemplary width and scaled to the viewport as a whole, so once
|
|
32
|
+
* the layout is known its lengths are pixels of the exemplary. Text in the page's flow, whose box a
|
|
33
|
+
* scale would leave at the unscaled size, takes viewport units throughout.
|
|
34
|
+
*/
|
|
35
|
+
isScaledToViewport?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
15
38
|
interface StyleGroup {
|
|
16
39
|
start: number;
|
|
17
40
|
end: number;
|
|
@@ -41,12 +64,47 @@ export const RICH_TEXT_LAYOUT_PENDING_CLASS = 'rich-text-layout-pending';
|
|
|
41
64
|
|
|
42
65
|
const SCALING_STYLES = new Set(['FONTSIZE', 'LINEHEIGHT', 'LETTERSPACING', 'WORDSPACING']);
|
|
43
66
|
|
|
67
|
+
const LIST_ITEM_CLASSES: Record<string, string> = {
|
|
68
|
+
'unordered-list-item': 'rt-unordered-list-item',
|
|
69
|
+
'ordered-list-item': 'rt-ordered-list-item'
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// a list item's marker hangs before its first line; a numbered run restarts after anything else
|
|
73
|
+
const LIST_STYLES = `
|
|
74
|
+
.rt-unordered-list-item,
|
|
75
|
+
.rt-ordered-list-item {
|
|
76
|
+
padding-left: 1.5em;
|
|
77
|
+
text-indent: -1.5em;
|
|
78
|
+
}
|
|
79
|
+
.rt-unordered-list-item::before,
|
|
80
|
+
.rt-ordered-list-item::before {
|
|
81
|
+
display: inline-block;
|
|
82
|
+
width: 1.5em;
|
|
83
|
+
text-indent: 0;
|
|
84
|
+
}
|
|
85
|
+
.rt-unordered-list-item::before {
|
|
86
|
+
content: '•';
|
|
87
|
+
}
|
|
88
|
+
.rt-paragraph,
|
|
89
|
+
.rt-unordered-list-item {
|
|
90
|
+
counter-reset: cntrl-list;
|
|
91
|
+
}
|
|
92
|
+
.rt-ordered-list-item {
|
|
93
|
+
counter-increment: cntrl-list;
|
|
94
|
+
}
|
|
95
|
+
.rt-ordered-list-item::before {
|
|
96
|
+
content: counter(cntrl-list) '.';
|
|
97
|
+
}
|
|
98
|
+
`;
|
|
99
|
+
|
|
44
100
|
export class RichTextConverter {
|
|
45
101
|
toHtml(
|
|
46
|
-
richText:
|
|
47
|
-
layouts: Layout[]
|
|
102
|
+
richText: RichTextSource,
|
|
103
|
+
layouts: Layout[],
|
|
104
|
+
{ isScaledToViewport = true }: ToHtmlOptions = {}
|
|
48
105
|
): [ReactNode[], string] {
|
|
49
106
|
const { text, blocks = [] } = richText.commonParams;
|
|
107
|
+
const hasLists = blocks.some(block => block.type in LIST_ITEM_CLASSES);
|
|
50
108
|
const root: ReactElement[] = [];
|
|
51
109
|
const styleRules = layouts.reduce<Record<string, string[]>>((rec, layout) => {
|
|
52
110
|
rec[layout.id] = [];
|
|
@@ -62,16 +120,18 @@ export class RichTextConverter {
|
|
|
62
120
|
const block = blocks[blockIndex];
|
|
63
121
|
const content = text.slice(block.start, block.end + 1);
|
|
64
122
|
const entities = block.entities!.sort((a, b) => a.start - b.start) ?? [];
|
|
123
|
+
const typeClass = hasLists ? ` ${LIST_ITEM_CLASSES[block.type] ?? 'rt-paragraph'}` : '';
|
|
65
124
|
if (content.length === 1) {
|
|
66
125
|
const id = `rt_${richText.id}_br_${blockIndex}`;
|
|
67
|
-
root.push(<div key={id} className={id}><br /></div>);
|
|
126
|
+
root.push(<div key={id} className={`${id}${typeClass}`}><br /></div>);
|
|
68
127
|
layouts.forEach(l => {
|
|
69
128
|
const lhForLayout = currentLineHeight[l.id];
|
|
70
129
|
if (lhForLayout === undefined) return;
|
|
71
130
|
const lineHeightStyle = { name: 'LINEHEIGHT', value: lhForLayout };
|
|
72
|
-
const lh = RichTextConverter.fromRangeStylesToInline(lineHeightStyle, l.exemplary,
|
|
73
|
-
const lhPending = RichTextConverter.fromRangeStylesToInline(lineHeightStyle, l.exemplary, false);
|
|
131
|
+
const lh = RichTextConverter.fromRangeStylesToInline(lineHeightStyle, l.exemplary, isScaledToViewport);
|
|
74
132
|
styleRules[l.id].push(`.rt_${richText.id}_br_${blockIndex} {${lh}}`);
|
|
133
|
+
if (!isScaledToViewport) return;
|
|
134
|
+
const lhPending = RichTextConverter.fromRangeStylesToInline(lineHeightStyle, l.exemplary, false);
|
|
75
135
|
styleRules[l.id].push(`.${RICH_TEXT_LAYOUT_PENDING_CLASS} .rt_${richText.id}_br_${blockIndex} {${lhPending}}`);
|
|
76
136
|
});
|
|
77
137
|
continue;
|
|
@@ -158,27 +218,29 @@ export class RichTextConverter {
|
|
|
158
218
|
for (const styleGroup of entitiesGroup.stylesGroup) {
|
|
159
219
|
const lineHeight = styleGroup.styles.find(s => s.name === 'LINEHEIGHT');
|
|
160
220
|
const color = styleGroup.styles.find(s => s.name === 'COLOR');
|
|
221
|
+
const highlight = styleGroup.styles.find(s => s.name === 'HIGHLIGHT');
|
|
161
222
|
if (lineHeight?.value) {
|
|
162
223
|
currentLineHeight[item.layout] = lineHeight.value;
|
|
163
224
|
}
|
|
164
225
|
styleRules[item.layout].push(`
|
|
165
226
|
.${blockClass} .s-${styleGroup.start}-${styleGroup.end} {
|
|
166
|
-
${styleGroup.styles.map(s => RichTextConverter.fromRangeStylesToInline(s, exemplary,
|
|
227
|
+
${styleGroup.styles.map(s => RichTextConverter.fromRangeStylesToInline(s, exemplary, isScaledToViewport)).join('\n')}
|
|
167
228
|
}
|
|
168
229
|
`);
|
|
169
230
|
const lengthStyles = styleGroup.styles.filter(s => SCALING_STYLES.has(s.name));
|
|
170
|
-
if (lengthStyles.length !== 0) {
|
|
231
|
+
if (isScaledToViewport && lengthStyles.length !== 0) {
|
|
171
232
|
styleRules[item.layout].push(`
|
|
172
233
|
.${RICH_TEXT_LAYOUT_PENDING_CLASS} .${blockClass} .s-${styleGroup.start}-${styleGroup.end} {
|
|
173
234
|
${lengthStyles.map(s => RichTextConverter.fromRangeStylesToInline(s, exemplary, false)).join('\n')}
|
|
174
235
|
}
|
|
175
236
|
`);
|
|
176
237
|
}
|
|
177
|
-
if (color) {
|
|
238
|
+
if (color?.value || highlight?.value) {
|
|
178
239
|
styleRules[item.layout].push(`
|
|
179
240
|
@supports not (color: oklch(42% 0.3 90 / 1)) {
|
|
180
241
|
.${blockClass} .s-${styleGroup.start}-${styleGroup.end} {
|
|
181
|
-
color: ${CntrlColor.parse(
|
|
242
|
+
${color?.value ? `color: ${CntrlColor.parse(color.value).fmt('rgba')};` : ''}
|
|
243
|
+
${highlight?.value ? `background-color: ${CntrlColor.parse(highlight.value).fmt('rgba')};` : ''}
|
|
182
244
|
}
|
|
183
245
|
}
|
|
184
246
|
`);
|
|
@@ -186,14 +248,14 @@ export class RichTextConverter {
|
|
|
186
248
|
}
|
|
187
249
|
}
|
|
188
250
|
}
|
|
189
|
-
root.push(<div key={blockClass} className={blockClass}>{kids}</div>);
|
|
251
|
+
root.push(<div key={blockClass} className={`${blockClass}${typeClass}`}>{kids}</div>);
|
|
190
252
|
}
|
|
191
253
|
}
|
|
192
254
|
const styles = layouts.map(l => `
|
|
193
255
|
${getLayoutMediaQuery(l.id, layouts)} {
|
|
194
256
|
${styleRules[l.id].join('\n')}
|
|
195
257
|
}
|
|
196
|
-
`).join('\n');
|
|
258
|
+
`).join('\n') + (hasLists ? LIST_STYLES : '');
|
|
197
259
|
return [
|
|
198
260
|
root,
|
|
199
261
|
styles
|
|
@@ -291,6 +353,7 @@ export class RichTextConverter {
|
|
|
291
353
|
const { value, name } = draftStyle;
|
|
292
354
|
const map: Record<string, Record<string, string | undefined>> = {
|
|
293
355
|
COLOR: { color: getResolvedValue(value, name) },
|
|
356
|
+
HIGHLIGHT: { 'background-color': getResolvedValue(value, name) },
|
|
294
357
|
TYPEFACE: { 'font-family': `${getFontFamilyValue(value!)}` },
|
|
295
358
|
FONTSTYLE: value ? { ...FontStyles[value] } : {},
|
|
296
359
|
FONTWEIGHT: { 'font-weight': value },
|
|
@@ -329,7 +392,7 @@ function getScaledValue(value: string | undefined, exemplary: number, isLayoutDe
|
|
|
329
392
|
}
|
|
330
393
|
|
|
331
394
|
function getResolvedValue(value: string | undefined, name: string) {
|
|
332
|
-
if (name !== 'COLOR') return value;
|
|
395
|
+
if (name !== 'COLOR' && name !== 'HIGHLIGHT') return value;
|
|
333
396
|
return value ? CntrlColor.parse(value).toCss() : value;
|
|
334
397
|
}
|
|
335
398
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { VimeoEmbedItem } from '@cntrl-site/sdk';
|
|
2
|
+
|
|
3
|
+
/** The player's url with how the layout plays the video, and none of Vimeo's own title, byline or portrait. */
|
|
4
|
+
export function getVimeoEmbedUrl(url: string, layoutParams: VimeoEmbedItem['layoutParams'][string]): string {
|
|
5
|
+
const validURL = new URL(url);
|
|
6
|
+
validURL.searchParams.append('controls', String(layoutParams.controls));
|
|
7
|
+
validURL.searchParams.append('autoplay', String(layoutParams.play === 'auto'));
|
|
8
|
+
validURL.searchParams.append('muted', String(layoutParams.muted));
|
|
9
|
+
validURL.searchParams.append('loop', String(layoutParams.loop));
|
|
10
|
+
validURL.searchParams.append('pip', String(layoutParams.pictureInPicture));
|
|
11
|
+
validURL.searchParams.append('title', '0');
|
|
12
|
+
validURL.searchParams.append('byline', '0');
|
|
13
|
+
validURL.searchParams.append('portrait', '0');
|
|
14
|
+
validURL.searchParams.append('autopause', 'false');
|
|
15
|
+
return validURL.href;
|
|
16
|
+
}
|