@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
|
@@ -1,70 +1,43 @@
|
|
|
1
|
-
import { getLayoutStyles, StructuredBlockAny
|
|
2
|
-
import {
|
|
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 {
|
|
6
|
-
import {
|
|
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
|
-
|
|
16
|
+
readingWidth: Record<string, number>;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
const
|
|
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 ?? {}
|
|
32
|
-
const
|
|
23
|
+
const layoutValues: Record<string, any>[] = [block.area, block.layoutParams, block.hidden ?? {}];
|
|
24
|
+
const BlockComponent = blocksMap[block.type];
|
|
33
25
|
return (
|
|
34
|
-
<div
|
|
35
|
-
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
+
};
|