@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
@@ -1,70 +1,43 @@
1
- import { getLayoutStyles, StructuredBlockAny, StructuredBlockType } from '@cntrl-site/sdk';
2
- import { ComponentType, FC, useId } from 'react';
1
+ import { getLayoutStyles, StructuredBlockAny } from '@cntrl-site/sdk';
2
+ import { FC, useId } from 'react';
3
3
  import JSXStyle from 'styled-jsx/style';
4
4
  import { useCntrlContext } from '../../provider/useCntrlContext';
5
- import { parseSizing } from '../items/useSizing';
6
- import { StructuredComponent } from './StructuredComponent/StructuredComponent';
5
+ import { blockPlacement } from './blockPlacement';
6
+ import { blocksMap } from './blocksMap';
7
7
 
8
8
  export interface StructuredBlockItemProps<I extends StructuredBlockAny> {
9
9
  block: I;
10
+ /** The section's reading column on each layout, which a caption keeps to. */
11
+ readingWidth: Record<string, number>;
10
12
  }
11
13
 
12
14
  interface Props {
13
15
  block: StructuredBlockAny;
14
- maxWidthMap: Record<string, number>;
16
+ readingWidth: Record<string, number>;
15
17
  }
16
18
 
17
- const noop = () => null;
18
-
19
- const itemsMap: Record<StructuredBlockType, ComponentType<StructuredBlockItemProps<any>>> = {
20
- [StructuredBlockType.Component]: StructuredComponent,
21
- [StructuredBlockType.RichText]: noop,
22
- [StructuredBlockType.Image]: noop,
23
- [StructuredBlockType.VimeoEmbed]: noop,
24
- [StructuredBlockType.YoutubeEmbed]: noop,
25
- };
26
-
27
- export const StructuredBlockItem: FC<Props> = ({ block, maxWidthMap }) => {
19
+ export const StructuredBlockItem: FC<Props> = ({ block, readingWidth }) => {
28
20
  const reactId = useId();
29
21
  const id = `${reactId}-item-${block.id}`;
30
22
  const { layouts } = useCntrlContext();
31
- const layoutValues: Record<string, any>[] = [block.area, block.layoutParams, block.hidden ?? {}, maxWidthMap];
32
- const ItemComponent = itemsMap[block.type] || noop;
23
+ const layoutValues: Record<string, any>[] = [block.area, block.layoutParams, block.hidden ?? {}];
24
+ const BlockComponent = blocksMap[block.type];
33
25
  return (
34
- <div
35
- className={`structured-block-item-${block.id}`}
36
- >
37
- <ItemComponent block={block} />
26
+ <div className={`structured-block-item-${block.id}`}>
27
+ <BlockComponent block={block} readingWidth={readingWidth} />
38
28
  <JSXStyle id={id}>{`
39
- ${getLayoutStyles(layouts, layoutValues, ([area, layoutParams, hidden, maxWidth]) => {
40
- const sizingAxis = parseSizing(layoutParams.sizing);
41
- return (`
42
- .structured-block-item-${block.id} {
43
- display: ${hidden ? 'none' : 'block'};
44
- align-self: ${getAlignSelf(area.alignment ?? 'center')};
45
- width: ${sizingAxis.x === 'manual' && area.width ? `${area.width * 100}vw` : maxWidth ?? 'max-content'};
46
- height: ${sizingAxis.y === 'manual' && area.height ? `${area.height * 100}vw` : 'unset'};
47
- margin-top: ${area.paddingTop ? `${area.paddingTop * 100}vw` : 'unset'};
48
- outline: none;
49
- left: ${(area.horizontalOffset ?? 0) * 100}vw;
50
- position: relative;
51
- z-index: ${area.zIndex};
52
- }
53
- `);
54
- })}
55
- `}
29
+ ${getLayoutStyles(layouts, layoutValues, ([area, layoutParams, hidden]) => (`
30
+ .structured-block-item-${block.id} {
31
+ display: ${hidden ? 'none' : 'block'};
32
+ position: relative;
33
+ z-index: ${area.zIndex};
34
+ margin-top: ${area.paddingTop ? `${area.paddingTop * 100}vw` : 0};
35
+ outline: none;
36
+ ${blockPlacement(area, layoutParams)}
37
+ }
38
+ `))}
39
+ `}
56
40
  </JSXStyle>
57
41
  </div>
58
42
  );
59
43
  };
60
-
61
- function getAlignSelf(alignment: 'left' | 'center' | 'right') {
62
- switch (alignment) {
63
- case 'left':
64
- return 'start';
65
- case 'right':
66
- return 'end';
67
- default:
68
- return 'center';
69
- }
70
- }
@@ -0,0 +1,86 @@
1
+ import { CodeStructuredBlock, CodeToken, getLayoutStyles } from '@cntrl-site/sdk';
2
+ import { CSSProperties, FC, Fragment, useId, useState } from 'react';
3
+ import JSXStyle from 'styled-jsx/style';
4
+ import { useCntrlContext } from '../../../provider/useCntrlContext';
5
+ import { useItemGeometry } from '../../../ItemGeometry/useItemGeometry';
6
+ import { StructuredBlockItemProps } from '../StructuredBlockItem';
7
+
8
+ // the box and its type are fixed, in pixels of the layout, scaled with it like everything drawn in it
9
+ const BOX = { paddingTop: 14, paddingRight: 18, paddingBottom: 16, paddingLeft: 18, radius: 6, border: 1 };
10
+ const CODE_FONT_PX = 14;
11
+ const CODE_FONT = 'ui-monospace, \'SF Mono\', Menlo, Consolas, \'Liberation Mono\', monospace';
12
+ // the default theme's own, for code whose theme could not be loaded
13
+ const DEFAULT_COLORS = { background: '#FFFFFF', foreground: '#000000' };
14
+ const FONT_STYLE_ITALIC = 1;
15
+ const FONT_STYLE_BOLD = 2;
16
+ const FONT_STYLE_UNDERLINE = 4;
17
+
18
+ function tokenStyle({ color, fontStyle = 0 }: CodeToken): CSSProperties {
19
+ return {
20
+ color,
21
+ fontStyle: fontStyle & FONT_STYLE_ITALIC ? 'italic' : undefined,
22
+ fontWeight: fontStyle & FONT_STYLE_BOLD ? 'bold' : undefined,
23
+ textDecoration: fontStyle & FONT_STYLE_UNDERLINE ? 'underline' : undefined
24
+ };
25
+ }
26
+
27
+ /** Code in a box coloured by its theme, as it comes from the API: in lines of coloured tokens. */
28
+ export const StructuredCode: FC<StructuredBlockItemProps<CodeStructuredBlock>> = ({ block }) => {
29
+ const reactId = useId();
30
+ const { layouts } = useCntrlContext();
31
+ const [ref, setRef] = useState<HTMLDivElement | null>(null);
32
+ useItemGeometry(block.id, ref);
33
+ const { lines, colors = DEFAULT_COLORS } = block.commonParams;
34
+ return (
35
+ <div ref={setRef} className={`structured-code-${block.id}`}>
36
+ <pre className={`structured-code-text-${block.id}`}>
37
+ <code>
38
+ {lines.map((line, index) => (
39
+ <Fragment key={index}>
40
+ {index > 0 && '\n'}
41
+ {line.map((token, tokenIndex) => <span key={tokenIndex} style={tokenStyle(token)}>{token.content}</span>)}
42
+ </Fragment>
43
+ ))}
44
+ </code>
45
+ </pre>
46
+ <JSXStyle id={`${reactId}-code-${block.id}`}>{`
47
+ .structured-code-${block.id} {
48
+ box-sizing: border-box;
49
+ width: 100%;
50
+ border-style: solid;
51
+ background: ${colors.background};
52
+ border-color: color-mix(in srgb, ${colors.foreground} 15%, ${colors.background});
53
+ }
54
+ .structured-code-text-${block.id} {
55
+ margin: 0;
56
+ color: ${colors.foreground};
57
+ font-family: ${CODE_FONT};
58
+ line-height: 1.5;
59
+ tab-size: 2;
60
+ white-space: pre-wrap;
61
+ overflow-wrap: anywhere;
62
+ word-break: normal;
63
+ font-variant-ligatures: none;
64
+ letter-spacing: 0;
65
+ }
66
+ .structured-code-text-${block.id} code {
67
+ font: inherit;
68
+ }
69
+ ${getLayoutStyles(layouts, [], (_, exemplary) => {
70
+ const px = (value: number) => `${value / exemplary * 100}vw`;
71
+ return (`
72
+ .structured-code-${block.id} {
73
+ padding: ${px(BOX.paddingTop)} ${px(BOX.paddingRight)} ${px(BOX.paddingBottom)} ${px(BOX.paddingLeft)};
74
+ border-width: ${px(BOX.border)};
75
+ border-radius: ${px(BOX.radius)};
76
+ }
77
+ .structured-code-text-${block.id} {
78
+ font-size: ${px(CODE_FONT_PX)};
79
+ }
80
+ `);
81
+ })}
82
+ `}
83
+ </JSXStyle>
84
+ </div>
85
+ );
86
+ };
@@ -0,0 +1,31 @@
1
+ import { DividerStructuredBlock, getLayoutStyles } from '@cntrl-site/sdk';
2
+ import { FC, useId, useState } from 'react';
3
+ import JSXStyle from 'styled-jsx/style';
4
+ import { useCntrlContext } from '../../../provider/useCntrlContext';
5
+ import { useItemGeometry } from '../../../ItemGeometry/useItemGeometry';
6
+ import { StructuredBlockItemProps } from '../StructuredBlockItem';
7
+ import { colorRule } from '../textStylesRule';
8
+
9
+ /** A line filling its block, which is as tall as the line. */
10
+ export const StructuredDivider: FC<StructuredBlockItemProps<DividerStructuredBlock>> = ({ block }) => {
11
+ const reactId = useId();
12
+ const { layouts } = useCntrlContext();
13
+ const [ref, setRef] = useState<HTMLDivElement | null>(null);
14
+ useItemGeometry(block.id, ref);
15
+ const layoutValues: Record<string, any>[] = [block.layoutParams];
16
+ return (
17
+ <>
18
+ <div ref={setRef} className={`structured-divider-${block.id}`} />
19
+ <JSXStyle id={`${reactId}-divider-${block.id}`}>{`
20
+ .structured-divider-${block.id} {
21
+ width: 100%;
22
+ height: 100%;
23
+ }
24
+ ${getLayoutStyles(layouts, layoutValues, ([layoutParams]) => (
25
+ colorRule(`.structured-divider-${block.id}`, 'background-color', layoutParams.color)
26
+ ))}
27
+ `}
28
+ </JSXStyle>
29
+ </>
30
+ );
31
+ };
@@ -0,0 +1,101 @@
1
+ import { getLayoutStyles, VimeoEmbedStructuredBlock } from '@cntrl-site/sdk';
2
+ import Player from '@vimeo/player';
3
+ import { FC, useEffect, useId, useMemo, useState } from 'react';
4
+ import JSXStyle from 'styled-jsx/style';
5
+ import { useCntrlContext } from '../../../provider/useCntrlContext';
6
+ import { useItemGeometry } from '../../../ItemGeometry/useItemGeometry';
7
+ import { getVimeoEmbedUrl } from '../../../utils/getVimeoEmbedUrl';
8
+ import { useLayoutContext } from '../../useLayoutContext';
9
+ import { StructuredBlockItemProps } from '../StructuredBlockItem';
10
+ import { embedFrameStyles } from './embedFrameStyles';
11
+
12
+ /** A Vimeo video playing as the Vimeo item does, its cover over it until it plays. */
13
+ export const StructuredVimeoEmbed: FC<StructuredBlockItemProps<VimeoEmbedStructuredBlock>> = ({ block }) => {
14
+ const reactId = useId();
15
+ const { layouts } = useCntrlContext();
16
+ const layoutId = useLayoutContext();
17
+ const layoutParams = layoutId ? block.layoutParams[layoutId] : null;
18
+ const { url, coverUrl } = block.commonParams;
19
+ const [ref, setRef] = useState<HTMLDivElement | null>(null);
20
+ const [iframe, setIframe] = useState<HTMLIFrameElement | null>(null);
21
+ const player = useMemo(() => (iframe ? new Player(iframe) : undefined), [iframe]);
22
+ const [isCoverVisible, setIsCoverVisible] = useState(coverUrl !== null);
23
+ useItemGeometry(block.id, ref);
24
+ const src = useMemo(() => (layoutParams ? getVimeoEmbedUrl(url, layoutParams) : url), [url, layoutParams]);
25
+
26
+ useEffect(() => {
27
+ if (!player || !layoutParams) return;
28
+ setIsCoverVisible(layoutParams.play !== 'auto' && coverUrl !== null);
29
+ const showCover = () => setIsCoverVisible(coverUrl !== null);
30
+ const handlePause = ({ seconds }: { seconds: number }) => {
31
+ if (seconds === 0) showCover();
32
+ };
33
+ player.on('pause', handlePause);
34
+ player.on('ended', showCover);
35
+ return () => {
36
+ player.off('pause', handlePause);
37
+ player.off('ended', showCover);
38
+ };
39
+ }, [player, layoutParams, coverUrl]);
40
+
41
+ const play = () => {
42
+ setIsCoverVisible(false);
43
+ player?.play();
44
+ };
45
+ const togglePlay = async () => {
46
+ if (!player) return;
47
+ if (await player.getPaused()) {
48
+ play();
49
+ } else {
50
+ player.pause();
51
+ }
52
+ };
53
+
54
+ const layoutValues: Record<string, any>[] = [block.area, block.layoutParams];
55
+ return (
56
+ <div
57
+ ref={setRef}
58
+ className={`structured-embed-${block.id}`}
59
+ onMouseEnter={() => {
60
+ if (layoutParams?.play === 'on-hover' && !isCoverVisible) player?.play();
61
+ }}
62
+ onMouseLeave={() => {
63
+ if (layoutParams?.play === 'on-hover') player?.pause();
64
+ }}
65
+ >
66
+ {coverUrl && isCoverVisible && (
67
+ <img
68
+ className={`structured-embed-cover-${block.id}`}
69
+ src={coverUrl}
70
+ alt=""
71
+ onClick={layoutParams?.play === 'on-click' ? play : undefined}
72
+ onMouseEnter={layoutParams?.play === 'on-hover' ? play : undefined}
73
+ />
74
+ )}
75
+ {/* an iframe swallows its clicks, so they are caught over it */}
76
+ {layoutParams && !layoutParams.controls && (layoutParams.play === 'on-click' || layoutParams.play === 'auto') && (
77
+ <div className={`structured-embed-overlay-${block.id}`} onClick={togglePlay} />
78
+ )}
79
+ <iframe
80
+ ref={setIframe}
81
+ className={`structured-embed-frame-${block.id}`}
82
+ src={src}
83
+ allow="autoplay; fullscreen; picture-in-picture;"
84
+ allowFullScreen
85
+ />
86
+ <JSXStyle id={`${reactId}-vimeo-${block.id}`}>{`
87
+ ${embedFrameStyles(block.id)}
88
+ ${getLayoutStyles(layouts, layoutValues, ([area, layoutParams]) => (`
89
+ .structured-embed-${block.id} {
90
+ ${area.height === undefined ? 'aspect-ratio: 16 / 9;' : 'height: 100%;'}
91
+ opacity: ${layoutParams.opacity};
92
+ }
93
+ .structured-embed-frame-${block.id} {
94
+ border-radius: ${layoutParams.radius * 100}vw;
95
+ }
96
+ `))}
97
+ `}
98
+ </JSXStyle>
99
+ </div>
100
+ );
101
+ };
@@ -0,0 +1,106 @@
1
+ import { getLayoutStyles, YoutubeEmbedStructuredBlock } from '@cntrl-site/sdk';
2
+ import { FC, useEffect, useId, useState } from 'react';
3
+ import JSXStyle from 'styled-jsx/style';
4
+ import { useCntrlContext } from '../../../provider/useCntrlContext';
5
+ import { useItemGeometry } from '../../../ItemGeometry/useItemGeometry';
6
+ import { getYoutubeId } from '../../../utils/getValidYoutubeUrl';
7
+ import { useYouTubeIframeApi } from '../../../utils/Youtube/useYouTubeIframeApi';
8
+ import { YTPlayer, YTPlayerState } from '../../../utils/Youtube/YoutubeIframeApi';
9
+ import { useLayoutContext } from '../../useLayoutContext';
10
+ import { StructuredBlockItemProps } from '../StructuredBlockItem';
11
+ import { embedFrameStyles } from './embedFrameStyles';
12
+
13
+ /** A YouTube video playing as the YouTube item does, its cover over it until it plays. */
14
+ export const StructuredYoutubeEmbed: FC<StructuredBlockItemProps<YoutubeEmbedStructuredBlock>> = ({ block }) => {
15
+ const reactId = useId();
16
+ const { layouts } = useCntrlContext();
17
+ const layoutId = useLayoutContext();
18
+ const layoutParams = layoutId ? block.layoutParams[layoutId] : null;
19
+ const { url, coverUrl } = block.commonParams;
20
+ const YT = useYouTubeIframeApi();
21
+ const [ref, setRef] = useState<HTMLDivElement | null>(null);
22
+ const [frame, setFrame] = useState<HTMLDivElement | null>(null);
23
+ const [player, setPlayer] = useState<YTPlayer | undefined>(undefined);
24
+ const [isCoverVisible, setIsCoverVisible] = useState(coverUrl !== null);
25
+ useItemGeometry(block.id, ref);
26
+
27
+ useEffect(() => {
28
+ if (!YT || !frame || !layoutParams) return;
29
+ const { play, controls } = layoutParams;
30
+ setIsCoverVisible(play !== 'auto' && coverUrl !== null);
31
+ const placeholder = document.createElement('div');
32
+ frame.appendChild(placeholder);
33
+ const created = new YT.Player(placeholder, {
34
+ videoId: getYoutubeId(new URL(url)),
35
+ playerVars: {
36
+ autoplay: play === 'auto' ? '1' : '0',
37
+ controls: controls ? '1' : '0'
38
+ },
39
+ events: {
40
+ onStateChange: (event) => {
41
+ if (play !== 'auto') return;
42
+ if (event.data === YTPlayerState.Playing) setIsCoverVisible(false);
43
+ if (event.data === YTPlayerState.Paused || event.data === YTPlayerState.Unstarted) setIsCoverVisible(coverUrl !== null);
44
+ },
45
+ onReady: (event) => {
46
+ setPlayer(event.target);
47
+ // only a video started by a click may play with sound
48
+ if (play !== 'on-click') created.mute();
49
+ }
50
+ }
51
+ });
52
+ return () => {
53
+ setPlayer(undefined);
54
+ created.destroy();
55
+ placeholder.parentElement?.removeChild(placeholder);
56
+ };
57
+ }, [YT, frame, layoutParams, url, coverUrl]);
58
+
59
+ const play = () => {
60
+ setIsCoverVisible(false);
61
+ player?.playVideo();
62
+ };
63
+
64
+ const layoutValues: Record<string, any>[] = [block.area, block.layoutParams];
65
+ return (
66
+ <div
67
+ ref={setRef}
68
+ className={`structured-embed-${block.id}`}
69
+ onMouseEnter={() => {
70
+ if (layoutParams?.play === 'on-hover' && !isCoverVisible) player?.playVideo();
71
+ }}
72
+ onMouseLeave={() => {
73
+ if (layoutParams?.play === 'on-hover') player?.pauseVideo();
74
+ }}
75
+ >
76
+ {coverUrl && isCoverVisible && (
77
+ <img
78
+ className={`structured-embed-cover-${block.id}`}
79
+ src={coverUrl}
80
+ alt=""
81
+ onClick={layoutParams?.play === 'on-click' ? play : undefined}
82
+ onMouseEnter={layoutParams?.play === 'on-hover' ? play : undefined}
83
+ />
84
+ )}
85
+ <div ref={setFrame} className={`structured-embed-frame-${block.id}`} />
86
+ <JSXStyle id={`${reactId}-youtube-${block.id}`}>{`
87
+ ${embedFrameStyles(block.id)}
88
+ .structured-embed-frame-${block.id} iframe {
89
+ width: 100%;
90
+ height: 100%;
91
+ border: none;
92
+ }
93
+ ${getLayoutStyles(layouts, layoutValues, ([area, layoutParams]) => (`
94
+ .structured-embed-${block.id} {
95
+ ${area.height === undefined ? 'aspect-ratio: 16 / 9;' : 'height: 100%;'}
96
+ opacity: ${layoutParams.opacity};
97
+ }
98
+ .structured-embed-frame-${block.id} {
99
+ border-radius: ${layoutParams.radius * 100}vw;
100
+ }
101
+ `))}
102
+ `}
103
+ </JSXStyle>
104
+ </div>
105
+ );
106
+ };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The box an embedded player fills: as tall as its block where the block has a height of its own,
3
+ * else at a video's 16:9, with the cover and the click catcher laid over the player.
4
+ */
5
+ export function embedFrameStyles(blockId: string): string {
6
+ return `
7
+ .structured-embed-${blockId} {
8
+ position: relative;
9
+ width: 100%;
10
+ }
11
+ .structured-embed-frame-${blockId} {
12
+ display: block;
13
+ width: 100%;
14
+ height: 100%;
15
+ border: none;
16
+ overflow: hidden;
17
+ }
18
+ .structured-embed-cover-${blockId},
19
+ .structured-embed-overlay-${blockId} {
20
+ position: absolute;
21
+ top: 0;
22
+ left: 0;
23
+ width: 100%;
24
+ height: 100%;
25
+ cursor: pointer;
26
+ z-index: 1;
27
+ }
28
+ .structured-embed-cover-${blockId} {
29
+ object-fit: cover;
30
+ }
31
+ `;
32
+ }
@@ -0,0 +1,43 @@
1
+ import { getLayoutStyles, HeaderImageStructuredBlock } from '@cntrl-site/sdk';
2
+ import { FC, useId, useState } from 'react';
3
+ import JSXStyle from 'styled-jsx/style';
4
+ import { useCntrlContext } from '../../../provider/useCntrlContext';
5
+ import { useItemGeometry } from '../../../ItemGeometry/useItemGeometry';
6
+ import { StructuredBlockItemProps } from '../StructuredBlockItem';
7
+ import { BlockCaption } from '../BlockCaption/BlockCaption';
8
+
9
+ /**
10
+ * A section header's key image with its caption; nothing until an image is uploaded. Where the
11
+ * header draws it as its backdrop, the header styles it: no caption, and covering the header if set to.
12
+ */
13
+ export const StructuredHeaderImage: FC<StructuredBlockItemProps<HeaderImageStructuredBlock>> = ({ block, readingWidth }) => {
14
+ const reactId = useId();
15
+ const { layouts } = useCntrlContext();
16
+ const [ref, setRef] = useState<HTMLDivElement | null>(null);
17
+ useItemGeometry(block.id, ref);
18
+ const { url, altText, caption } = block.commonParams;
19
+ if (!url) return null;
20
+ const layoutValues: Record<string, any>[] = [block.layoutParams];
21
+ return (
22
+ <div ref={setRef} className={`structured-header-image-${block.id}`}>
23
+ <img className={`structured-header-picture-${block.id}`} src={url} alt={altText} />
24
+ {caption && <BlockCaption blockId={block.id} caption={caption} layoutParams={block.layoutParams} readingWidth={readingWidth} />}
25
+ <JSXStyle id={`${reactId}-header-image-${block.id}`}>{`
26
+ .structured-header-image-${block.id} {
27
+ width: 100%;
28
+ }
29
+ .structured-header-picture-${block.id} {
30
+ display: block;
31
+ width: 100%;
32
+ height: auto;
33
+ }
34
+ ${getLayoutStyles(layouts, layoutValues, ([layoutParams]) => (`
35
+ .structured-header-picture-${block.id} {
36
+ opacity: ${layoutParams.opacity};
37
+ }
38
+ `))}
39
+ `}
40
+ </JSXStyle>
41
+ </div>
42
+ );
43
+ };
@@ -0,0 +1,75 @@
1
+ import { getLayoutStyles, ImageStructuredBlock } from '@cntrl-site/sdk';
2
+ import { FC, useCallback, useId, useState } from 'react';
3
+ import JSXStyle from 'styled-jsx/style';
4
+ import { useCntrlContext } from '../../../provider/useCntrlContext';
5
+ import { useItemGeometry } from '../../../ItemGeometry/useItemGeometry';
6
+ import { StructuredBlockItemProps } from '../StructuredBlockItem';
7
+ import { BlockCaption } from '../BlockCaption/BlockCaption';
8
+
9
+ const GALLERY_GAP_PX = 8;
10
+
11
+ /**
12
+ * A gallery: its images side by side in one row, each growing from nothing by its share of the row's
13
+ * aspect ratios, so that they fill the row as their ratios do and come out equally tall, with the
14
+ * caption under them. Until an image has loaded its ratio is taken to be square.
15
+ */
16
+ export const StructuredImage: FC<StructuredBlockItemProps<ImageStructuredBlock>> = ({ block, readingWidth }) => {
17
+ const reactId = useId();
18
+ const { layouts } = useCntrlContext();
19
+ const [ref, setRef] = useState<HTMLDivElement | null>(null);
20
+ useItemGeometry(block.id, ref);
21
+ const { urls, altText, caption } = block.commonParams;
22
+ const [ratios, setRatios] = useState<Record<string, number>>({});
23
+ // an image already loaded when the page hydrates fires no load event of its own
24
+ const measure = useCallback((url: string, image: HTMLImageElement | null) => {
25
+ if (!image || !image.complete || !image.naturalHeight) return;
26
+ const ratio = image.naturalWidth / image.naturalHeight;
27
+ setRatios(prev => (prev[url] === ratio ? prev : { ...prev, [url]: ratio }));
28
+ }, []);
29
+ // growth factors summing to less than one leave part of the row empty, so a lone portrait image
30
+ // would fill only its ratio of the block: each image grows by its share of the total instead
31
+ const totalRatio = urls.reduce((sum, url) => sum + (ratios[url] ?? 1), 0);
32
+ const layoutValues: Record<string, any>[] = [block.layoutParams];
33
+ return (
34
+ <div ref={setRef} className={`structured-gallery-${block.id}`}>
35
+ <div className={`structured-gallery-row-${block.id}`}>
36
+ {urls.map((url, index) => (
37
+ <img
38
+ key={`${url}-${index}`}
39
+ ref={image => measure(url, image)}
40
+ className={`structured-gallery-image-${block.id}`}
41
+ src={url}
42
+ alt={altText}
43
+ onLoad={event => measure(url, event.currentTarget)}
44
+ style={{ flexGrow: (ratios[url] ?? 1) / totalRatio }}
45
+ />
46
+ ))}
47
+ </div>
48
+ {caption && <BlockCaption blockId={block.id} caption={caption} layoutParams={block.layoutParams} readingWidth={readingWidth} />}
49
+ <JSXStyle id={`${reactId}-gallery-${block.id}`}>{`
50
+ .structured-gallery-row-${block.id} {
51
+ display: flex;
52
+ align-items: flex-start;
53
+ width: 100%;
54
+ gap: var(--gallery-gap);
55
+ }
56
+ .structured-gallery-image-${block.id} {
57
+ display: block;
58
+ flex-shrink: 1;
59
+ flex-basis: 0;
60
+ min-width: 0;
61
+ height: auto;
62
+ }
63
+ ${getLayoutStyles(layouts, layoutValues, ([layoutParams], exemplary) => (`
64
+ .structured-gallery-${block.id} {
65
+ --gallery-gap: ${GALLERY_GAP_PX / exemplary * 100}vw;
66
+ }
67
+ .structured-gallery-row-${block.id} {
68
+ opacity: ${layoutParams.opacity};
69
+ }
70
+ `))}
71
+ `}
72
+ </JSXStyle>
73
+ </div>
74
+ );
75
+ };
@@ -0,0 +1,53 @@
1
+ import { DateStructuredBlock, getLayoutStyles, QuoteStructuredBlock, RichTextStructuredBlock, StructuredBlockType } from '@cntrl-site/sdk';
2
+ import { FC, useId, useMemo, useState } from 'react';
3
+ import JSXStyle from 'styled-jsx/style';
4
+ import { useCntrlContext } from '../../../provider/useCntrlContext';
5
+ import { useItemGeometry } from '../../../ItemGeometry/useItemGeometry';
6
+ import { RichTextConverter } from '../../../utils/RichTextConverter/RichTextConverter';
7
+ import { detectLang } from '../../../utils/detectLang';
8
+ import { StructuredBlockItemProps } from '../StructuredBlockItem';
9
+ import { colorRule, textStylesRule } from '../textStylesRule';
10
+
11
+ type TextBlock = RichTextStructuredBlock | QuoteStructuredBlock | DateStructuredBlock;
12
+
13
+ const richTextConverter = new RichTextConverter();
14
+
15
+ /**
16
+ * A block of text: rich text, a quote, which is drawn in a box of its own, or a date, which comes as
17
+ * the text it reads as. Its text is set in the styles it was drawn with, its ranges styled over them.
18
+ */
19
+ export const StructuredRichText: FC<StructuredBlockItemProps<TextBlock>> = ({ block }) => {
20
+ const reactId = useId();
21
+ const id = `${reactId}-structured-text-${block.id}`;
22
+ const { layouts } = useCntrlContext();
23
+ const [ref, setRef] = useState<HTMLDivElement | null>(null);
24
+ useItemGeometry(block.id, ref);
25
+ const [content, rangeStyles] = useMemo(
26
+ () => richTextConverter.toHtml(block, layouts, { isScaledToViewport: false }),
27
+ [block, layouts]
28
+ );
29
+ const lang = useMemo(() => detectLang(block.commonParams.text), [block.commonParams.text]);
30
+ const isQuote = block.type === StructuredBlockType.Quote;
31
+ const layoutValues: Record<string, any>[] = [block.layoutParams];
32
+ return (
33
+ <>
34
+ <div ref={setRef} lang={lang} className={`structured-text-${block.id}`}>
35
+ {isQuote ? <div className={`structured-quote-box-${block.id}`}>{content}</div> : content}
36
+ </div>
37
+ <JSXStyle id={id}>
38
+ {rangeStyles}
39
+ {`${getLayoutStyles(layouts, layoutValues, ([layoutParams]) => (`
40
+ ${textStylesRule(`.structured-text-${block.id}`, layoutParams)}
41
+ ${isQuote ? `
42
+ .structured-quote-box-${block.id} {
43
+ padding: ${(layoutParams.boxPadding ?? 0) * 100}vw;
44
+ border-radius: ${(layoutParams.boxRadius ?? 0) * 100}vw;
45
+ background-color: transparent;
46
+ }
47
+ ${layoutParams.background ? colorRule(`.structured-quote-box-${block.id}`, 'background-color', layoutParams.background) : ''}
48
+ ` : ''}
49
+ `))}`}
50
+ </JSXStyle>
51
+ </>
52
+ );
53
+ };