@akinon/pz-theme 2.0.82-beta.0 → 2.0.82
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/CHANGELOG.md +8 -4
- package/package.json +4 -4
- package/src/blocks/__tests__/link-block.test.tsx +87 -0
- package/src/blocks/__tests__/text-block.test.tsx +88 -0
- package/src/blocks/__tests__/video-block.test.tsx +207 -2
- package/src/blocks/item-template-utils.ts +51 -5
- package/src/blocks/link-block.tsx +43 -0
- package/src/blocks/slider-block.tsx +92 -4
- package/src/blocks/text-block.tsx +42 -4
- package/src/blocks/video-block.tsx +208 -80
- package/src/sections/__tests__/posts-slider-arrows.test.tsx +470 -0
- package/src/sections/__tests__/posts-slider-edges.test.tsx +394 -0
- package/src/sections/__tests__/posts-slider-quick-view-no-fetch.test.tsx +205 -0
- package/src/sections/__tests__/posts-slider-quick-view.test.tsx +660 -0
- package/src/sections/posts-slider-section.tsx +482 -115
- package/src/sections/slider-quick-view.tsx +375 -0
- package/src/utils/__tests__/line-breaks.test.ts +215 -0
- package/src/utils/index.ts +12 -1
- package/src/utils/iterator-utils.ts +46 -0
- package/src/utils/line-breaks.ts +142 -0
- package/src/utils/slider-arrows.tsx +252 -0
|
@@ -156,6 +156,74 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
156
156
|
const slideGap = parseNumber(getConfigEntry('slide-gap'), 16);
|
|
157
157
|
const slidePadding = slideGap / 2;
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Edge treatment, the same vocabulary the slider SECTIONS grew — this block
|
|
161
|
+
* is a second, independent renderer of the same carousel (a Featured Brand
|
|
162
|
+
* Collection is built from it), and an author who learned the setting on one
|
|
163
|
+
* should find it on the other.
|
|
164
|
+
*
|
|
165
|
+
* `flush-edges` moves the whole gap to one side so the outer edges carry
|
|
166
|
+
* none, which is what lets the last card's content reach the band; a
|
|
167
|
+
* FRACTIONAL item count is what slices the next one and says "there is
|
|
168
|
+
* more". `lead-in` is not padding — no padding is position-dependent — so it
|
|
169
|
+
* rides the library's own `additionalTransfrom` and is handed back the
|
|
170
|
+
* moment the track leaves the start.
|
|
171
|
+
*/
|
|
172
|
+
const flushEdges =
|
|
173
|
+
getResponsiveValue(
|
|
174
|
+
getConfigEntry('flush-edges'),
|
|
175
|
+
currentBreakpoint,
|
|
176
|
+
false
|
|
177
|
+
) === true;
|
|
178
|
+
const leadIn = clampNumber(
|
|
179
|
+
parseNumber(
|
|
180
|
+
getResponsiveValue(getConfigEntry('lead-in'), currentBreakpoint, 0),
|
|
181
|
+
0
|
|
182
|
+
),
|
|
183
|
+
0,
|
|
184
|
+
400
|
|
185
|
+
);
|
|
186
|
+
/**
|
|
187
|
+
* Which edge the gap belongs to, and when it exists.
|
|
188
|
+
*
|
|
189
|
+
* `left` is a LEAD-IN: it sits before the first card while the track is at
|
|
190
|
+
* the start and slides away the moment it moves. `right` is its mirror, a
|
|
191
|
+
* TRAIL-OUT at the far end — because a right-hand gap at the START is not
|
|
192
|
+
* something the geometry can offer: what sits against the right edge there
|
|
193
|
+
* is the sliced next card, and how much of it shows is the item count's
|
|
194
|
+
* job, not a gap's.
|
|
195
|
+
*
|
|
196
|
+
* Both ride the library's own `additionalTransfrom`: +N pushes the track
|
|
197
|
+
* right and opens the left gap, -N pulls it left and opens the right one.
|
|
198
|
+
*/
|
|
199
|
+
const leadInSide =
|
|
200
|
+
String(
|
|
201
|
+
getResponsiveValue(
|
|
202
|
+
getConfigEntry('lead-in-side'),
|
|
203
|
+
currentBreakpoint,
|
|
204
|
+
'left'
|
|
205
|
+
) ?? 'left'
|
|
206
|
+
) === 'right'
|
|
207
|
+
? 'right'
|
|
208
|
+
: 'left';
|
|
209
|
+
|
|
210
|
+
// This renderer only distinguishes narrow from wide, so the item count it
|
|
211
|
+
// is actually showing is the one its own `slidesToSlide` picks from.
|
|
212
|
+
const itemsShown = isNarrowBreakpoint(currentBreakpoint)
|
|
213
|
+
? mobileItems
|
|
214
|
+
: desktopItems;
|
|
215
|
+
const useLeadIn = leadIn > 0 && visibleCount > itemsShown && !useInfinite;
|
|
216
|
+
const [slidePosition, setSlidePosition] = React.useState(0);
|
|
217
|
+
const lastPosition = Math.max(0, visibleCount - itemsShown);
|
|
218
|
+
const edgeOffset =
|
|
219
|
+
leadInSide === 'left'
|
|
220
|
+
? slidePosition <= 0
|
|
221
|
+
? leadIn
|
|
222
|
+
: 0
|
|
223
|
+
: slidePosition >= lastPosition - 0.001
|
|
224
|
+
? -leadIn
|
|
225
|
+
: 0;
|
|
226
|
+
|
|
159
227
|
const blockStyles = block.styles || {};
|
|
160
228
|
const sectionStyles = getCSSStyles(
|
|
161
229
|
typeof blockStyles === 'object' && blockStyles !== null
|
|
@@ -620,6 +688,16 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
620
688
|
autoPlaySpeed={autoPlaySpeed}
|
|
621
689
|
containerClass="slider-carousel"
|
|
622
690
|
dotListClass="slider-dots"
|
|
691
|
+
/*
|
|
692
|
+
* Spread conditionally so a slider that has not opted in is handed
|
|
693
|
+
* the identical prop object it was handed before.
|
|
694
|
+
*/
|
|
695
|
+
{...(useLeadIn
|
|
696
|
+
? {
|
|
697
|
+
additionalTransfrom: edgeOffset,
|
|
698
|
+
beforeChange: (nextSlide: number) => setSlidePosition(nextSlide)
|
|
699
|
+
}
|
|
700
|
+
: {})}
|
|
623
701
|
customLeftArrow={
|
|
624
702
|
!isDesigner && showArrows && !arrowsOutside ? (
|
|
625
703
|
<CustomLeftArrow />
|
|
@@ -640,10 +718,20 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
640
718
|
return (
|
|
641
719
|
<div
|
|
642
720
|
key={childBlock.id}
|
|
643
|
-
style={
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
721
|
+
style={
|
|
722
|
+
flushEdges
|
|
723
|
+
? {
|
|
724
|
+
// The gap BETWEEN cards is unchanged — it just all sits
|
|
725
|
+
// on one side, so the outer edges carry none.
|
|
726
|
+
paddingLeft: 0,
|
|
727
|
+
paddingRight:
|
|
728
|
+
index === sortedBlocks.length - 1 ? 0 : slideGap
|
|
729
|
+
}
|
|
730
|
+
: {
|
|
731
|
+
paddingLeft: slidePadding,
|
|
732
|
+
paddingRight: slidePadding
|
|
733
|
+
}
|
|
734
|
+
}
|
|
647
735
|
>
|
|
648
736
|
<ThemeBlock
|
|
649
737
|
block={renderedChildBlock}
|
|
@@ -1,11 +1,39 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useLocalization } from '@akinon/next/hooks';
|
|
4
|
-
import React
|
|
4
|
+
import React from 'react';
|
|
5
5
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
6
6
|
import { getCSSStyles } from '../utils';
|
|
7
7
|
import { useThemeSettingsContext } from '../theme-settings-context';
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* The block's box geometry, which this div must not wear.
|
|
11
|
+
*
|
|
12
|
+
* Everything else in a text block's style map has to be applied here, and not
|
|
13
|
+
* because the stylesheet forgot it: a bound value (`{{item.badgeColor}}`) is
|
|
14
|
+
* deliberately stripped from the shared `[data-block-id]` rule, because every
|
|
15
|
+
* row of an iterator shares that id and a rule cannot hold a different colour
|
|
16
|
+
* per row. Inline is the only place a per-row value can land.
|
|
17
|
+
*
|
|
18
|
+
* Margin and padding are the exception. They describe the block's box, the
|
|
19
|
+
* stylesheet already puts them on the `[data-block-id]` wrapper this div renders
|
|
20
|
+
* inside, and a box inside a box means they compound — a designer setting 29px
|
|
21
|
+
* under a heading got 29 on the wrapper and 29 again here, with nothing in the
|
|
22
|
+
* inspector to explain the second one. Measured on a live page: nine blocks
|
|
23
|
+
* carried the same margin at both levels.
|
|
24
|
+
*
|
|
25
|
+
* A BOUND margin is the one thing this gives up, and it is not a thing: row data
|
|
26
|
+
* carries prices and colours, not the spacing of the box it is drawn in.
|
|
27
|
+
*/
|
|
28
|
+
const BOX_GEOMETRY_PREFIXES = ['margin', 'padding'];
|
|
29
|
+
|
|
30
|
+
const isBoxGeometryKey = (key: string): boolean => {
|
|
31
|
+
const normalized = key.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
|
|
32
|
+
return BOX_GEOMETRY_PREFIXES.some(
|
|
33
|
+
(prefix) => normalized === prefix || normalized.startsWith(`${prefix}-`)
|
|
34
|
+
);
|
|
35
|
+
};
|
|
36
|
+
|
|
9
37
|
const TextBlock = ({
|
|
10
38
|
block,
|
|
11
39
|
currentBreakpoint = 'desktop'
|
|
@@ -40,12 +68,22 @@ const TextBlock = ({
|
|
|
40
68
|
|
|
41
69
|
const content = getLocalizedContent();
|
|
42
70
|
|
|
43
|
-
const wrapperStyles = useMemo(() => {
|
|
44
|
-
|
|
71
|
+
const wrapperStyles = React.useMemo(() => {
|
|
72
|
+
const styles = block.styles || {};
|
|
73
|
+
const withoutBoxGeometry = Object.fromEntries(
|
|
74
|
+
Object.entries(styles).filter(([key]) => !isBoxGeometryKey(key))
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
return getCSSStyles(withoutBoxGeometry, themeSettings, currentBreakpoint);
|
|
45
78
|
}, [block.styles, themeSettings, currentBreakpoint]);
|
|
46
79
|
|
|
80
|
+
const hasStyles = Object.keys(wrapperStyles).length > 0;
|
|
81
|
+
|
|
47
82
|
return (
|
|
48
|
-
<div
|
|
83
|
+
<div
|
|
84
|
+
{...(hasStyles ? { style: wrapperStyles } : null)}
|
|
85
|
+
dangerouslySetInnerHTML={{ __html: content }}
|
|
86
|
+
/>
|
|
49
87
|
);
|
|
50
88
|
};
|
|
51
89
|
|
|
@@ -1,78 +1,197 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
+
import { getResponsiveValue } from '../utils';
|
|
2
3
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
/**
|
|
6
|
+
* What an author's URL turned out to be.
|
|
7
|
+
*
|
|
8
|
+
* Tagged rather than inferred: the branch that picks `<iframe>` over `<video>`
|
|
9
|
+
* used to re-read the parser's own OUTPUT for the string 'youtube.com', so a
|
|
10
|
+
* file whose path happened to contain that text rendered in the wrong element.
|
|
11
|
+
* The parser already knows which it produced — it should say so.
|
|
12
|
+
*/
|
|
13
|
+
type ParsedVideo =
|
|
14
|
+
| { kind: 'embed'; src: string; defaultRatio?: string }
|
|
15
|
+
| { kind: 'file'; src: string }
|
|
16
|
+
| null;
|
|
17
|
+
|
|
18
|
+
interface VideoProperties {
|
|
19
|
+
autoplay?: boolean;
|
|
20
|
+
loop?: boolean;
|
|
21
|
+
muted?: boolean;
|
|
22
|
+
controls?: boolean;
|
|
23
|
+
poster?: unknown;
|
|
24
|
+
playsInline?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* `auto` reads the address; `file` says "trust me, this plays". The escape
|
|
27
|
+
* hatch exists because a signed CDN link often carries no extension at all,
|
|
28
|
+
* and guessing from a url that merely mentions `.mp4` somewhere would let
|
|
29
|
+
* `…/watch?ref=clip.mp4` through as a playable file.
|
|
30
|
+
*/
|
|
31
|
+
sourceType?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Hosts, not substrings: `evil.com/?u=youtube.com/watch?v=x` is not YouTube. */
|
|
35
|
+
const YOUTUBE_HOSTS = /(^|\.)(youtube\.com|youtube-nocookie\.com|youtu\.be)$/i;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The full YouTube id alphabet. Also the guard that stops an author-supplied
|
|
39
|
+
* fragment being interpolated into an embed URL unencoded.
|
|
40
|
+
*/
|
|
41
|
+
const YOUTUBE_ID = /^[A-Za-z0-9_-]{6,20}$/;
|
|
42
|
+
|
|
43
|
+
const VIMEO_HOSTS = /(^|\.)vimeo\.com$/i;
|
|
44
|
+
const VIMEO_ID = /^\d+$/;
|
|
45
|
+
|
|
46
|
+
/** Extensions worth recognising on a path. Matched against the PATHNAME. */
|
|
47
|
+
const FILE_EXTENSION = /\.(mp4|webm|ogg|ogv|m4v|mov)$/i;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Only ever http(s). Verified before this guard existed:
|
|
51
|
+
* `javascript:alert(1)//x.mp4` and `data:text/html,…//x.mp4` both satisfied
|
|
52
|
+
* the old extension regex and were handed straight to `<video src>`.
|
|
53
|
+
*/
|
|
54
|
+
const SAFE_PROTOCOLS = new Set(['http:', 'https:']);
|
|
55
|
+
|
|
56
|
+
const parseUrl = (url: string): URL | null => {
|
|
57
|
+
try {
|
|
58
|
+
// A base is needed for relative paths (`/media/clip.mp4`), which are a
|
|
59
|
+
// legitimate way to serve a clip from the storefront's own public folder.
|
|
60
|
+
return new URL(url, 'https://theme.invalid');
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
11
63
|
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const buildEmbedParams = (
|
|
67
|
+
properties: VideoProperties | undefined,
|
|
68
|
+
videoId: string,
|
|
69
|
+
loopNeedsPlaylist: boolean
|
|
12
70
|
) => {
|
|
71
|
+
const params = new URLSearchParams();
|
|
72
|
+
if (properties?.autoplay) params.append('autoplay', '1');
|
|
73
|
+
if (properties?.loop) {
|
|
74
|
+
params.append('loop', '1');
|
|
75
|
+
if (loopNeedsPlaylist) params.append('playlist', videoId);
|
|
76
|
+
}
|
|
77
|
+
if (properties?.muted) {
|
|
78
|
+
params.append(loopNeedsPlaylist ? 'mute' : 'muted', '1');
|
|
79
|
+
}
|
|
80
|
+
if (properties?.controls === false) params.append('controls', '0');
|
|
81
|
+
|
|
82
|
+
const queryString = params.toString();
|
|
83
|
+
return queryString ? `?${queryString}` : '';
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const parseVideoSource = (
|
|
87
|
+
url: string,
|
|
88
|
+
properties?: VideoProperties
|
|
89
|
+
): ParsedVideo => {
|
|
13
90
|
if (!url || typeof url !== 'string') return null;
|
|
14
91
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if (url.includes('youtu.be/')) {
|
|
18
|
-
videoId = url.split('youtu.be/')[1].split('?')[0];
|
|
19
|
-
} else if (url.includes('watch?v=')) {
|
|
20
|
-
videoId = url.split('watch?v=')[1].split('&')[0];
|
|
21
|
-
} else if (url.includes('embed/')) {
|
|
22
|
-
videoId = url.split('embed/')[1].split('?')[0];
|
|
23
|
-
}
|
|
92
|
+
const parsed = parseUrl(url.trim());
|
|
93
|
+
if (!parsed) return null;
|
|
24
94
|
|
|
25
|
-
|
|
95
|
+
const host = parsed.hostname;
|
|
96
|
+
const path = parsed.pathname;
|
|
26
97
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
params.append('loop', '1');
|
|
31
|
-
params.append('playlist', videoId);
|
|
32
|
-
}
|
|
33
|
-
if (properties?.muted) params.append('mute', '1');
|
|
34
|
-
if (properties?.controls === false) params.append('controls', '0');
|
|
98
|
+
if (YOUTUBE_HOSTS.test(host)) {
|
|
99
|
+
let videoId = '';
|
|
100
|
+
let defaultRatio: string | undefined;
|
|
35
101
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
102
|
+
if (/(^|\.)youtu\.be$/i.test(host)) {
|
|
103
|
+
videoId = path.slice(1);
|
|
104
|
+
} else if (path === '/watch') {
|
|
105
|
+
videoId = parsed.searchParams.get('v') ?? '';
|
|
106
|
+
} else if (path.startsWith('/embed/')) {
|
|
107
|
+
videoId = path.slice('/embed/'.length);
|
|
108
|
+
} else if (path.startsWith('/shorts/')) {
|
|
109
|
+
videoId = path.slice('/shorts/'.length);
|
|
110
|
+
// A Short is filmed vertical. Supplying the ratio here is what makes
|
|
111
|
+
// pasting the link enough — the author sets nothing.
|
|
112
|
+
defaultRatio = '9/16';
|
|
113
|
+
} else if (path.startsWith('/live/')) {
|
|
114
|
+
videoId = path.slice('/live/'.length);
|
|
115
|
+
}
|
|
41
116
|
|
|
42
|
-
|
|
43
|
-
|
|
117
|
+
videoId = videoId.split('/')[0];
|
|
118
|
+
if (!YOUTUBE_ID.test(videoId)) return null;
|
|
44
119
|
|
|
45
|
-
|
|
120
|
+
return {
|
|
121
|
+
kind: 'embed',
|
|
122
|
+
// The emitted host stays `www.youtube.com`. Accepting a nocookie link as
|
|
123
|
+
// INPUT is a parsing fix; switching what we emit is a privacy decision
|
|
124
|
+
// for the brand to make, and there is no consent gate here to hang it on.
|
|
125
|
+
src: `https://www.youtube.com/embed/${videoId}${buildEmbedParams(
|
|
126
|
+
properties,
|
|
127
|
+
videoId,
|
|
128
|
+
true
|
|
129
|
+
)}`,
|
|
130
|
+
defaultRatio
|
|
131
|
+
};
|
|
132
|
+
}
|
|
46
133
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
-
if (properties?.muted) params.append('muted', '1');
|
|
51
|
-
if (properties?.controls === false) params.append('controls', '0');
|
|
134
|
+
if (VIMEO_HOSTS.test(host)) {
|
|
135
|
+
const videoId = path.slice(1).split('/')[0];
|
|
136
|
+
if (!VIMEO_ID.test(videoId)) return null;
|
|
52
137
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
138
|
+
return {
|
|
139
|
+
kind: 'embed',
|
|
140
|
+
src: `https://player.vimeo.com/video/${videoId}${buildEmbedParams(
|
|
141
|
+
properties,
|
|
142
|
+
videoId,
|
|
143
|
+
false
|
|
144
|
+
)}`
|
|
145
|
+
};
|
|
57
146
|
}
|
|
58
147
|
|
|
59
|
-
if (
|
|
60
|
-
|
|
148
|
+
if (!SAFE_PROTOCOLS.has(parsed.protocol)) return null;
|
|
149
|
+
|
|
150
|
+
// The extension is read off the PATH, so a signed or cache-busted address
|
|
151
|
+
// (`…/clip.mp4?sig=…`) plays. The old test matched the whole string and
|
|
152
|
+
// anchored at its end, which rejected every url carrying a query — the
|
|
153
|
+
// ordinary shape of an uploaded asset.
|
|
154
|
+
if (FILE_EXTENSION.test(path) || properties?.sourceType === 'file') {
|
|
155
|
+
return { kind: 'file', src: url };
|
|
61
156
|
}
|
|
62
157
|
|
|
63
158
|
return null;
|
|
64
159
|
};
|
|
65
160
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
161
|
+
/**
|
|
162
|
+
* A video: a YouTube or Vimeo embed, or a file the browser plays itself.
|
|
163
|
+
*
|
|
164
|
+
* Properties: url (on `block.value`), controls, autoplay, loop, muted,
|
|
165
|
+
* playsInline, poster, sourceType. Style: `aspect-ratio`.
|
|
166
|
+
*/
|
|
167
|
+
const VideoBlock = ({
|
|
168
|
+
block,
|
|
169
|
+
currentBreakpoint = 'desktop'
|
|
170
|
+
}: BlockRendererProps) => {
|
|
171
|
+
const videoUrl = block.value as string;
|
|
172
|
+
const properties = (block.properties || {}) as VideoProperties;
|
|
69
173
|
|
|
70
|
-
const
|
|
174
|
+
const parsed = parseVideoSource(videoUrl, properties);
|
|
71
175
|
|
|
72
|
-
|
|
176
|
+
/**
|
|
177
|
+
* The author's choice wins; a Short supplies its own when they made none.
|
|
178
|
+
* With neither, the historic fixed height is kept, so every composition
|
|
179
|
+
* already rendering a video keeps its exact box.
|
|
180
|
+
*/
|
|
181
|
+
const authored = getResponsiveValue(
|
|
182
|
+
block.styles?.['aspect-ratio'],
|
|
183
|
+
currentBreakpoint,
|
|
184
|
+
undefined
|
|
185
|
+
) as string | undefined;
|
|
186
|
+
const ratio =
|
|
187
|
+
authored && authored !== 'auto'
|
|
188
|
+
? authored
|
|
189
|
+
: parsed?.kind === 'embed'
|
|
190
|
+
? parsed.defaultRatio
|
|
191
|
+
: undefined;
|
|
73
192
|
|
|
74
|
-
if (!videoUrl || !
|
|
75
|
-
|
|
193
|
+
if (!videoUrl || !parsed) {
|
|
194
|
+
return (
|
|
76
195
|
<div
|
|
77
196
|
style={{
|
|
78
197
|
minHeight: '300px',
|
|
@@ -87,42 +206,51 @@ const VideoBlock = ({ block }: BlockRendererProps) => {
|
|
|
87
206
|
{!videoUrl ? 'No video URL provided' : 'Invalid video URL'}
|
|
88
207
|
</div>
|
|
89
208
|
);
|
|
90
|
-
}
|
|
91
|
-
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (parsed.kind === 'embed') {
|
|
212
|
+
return (
|
|
92
213
|
<iframe
|
|
93
|
-
src={
|
|
94
|
-
style={
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
214
|
+
src={parsed.src}
|
|
215
|
+
style={
|
|
216
|
+
ratio
|
|
217
|
+
? // The ratio decides the height. Asking for `height: 100%` instead
|
|
218
|
+
// collapses the player to nothing whenever the box around it is
|
|
219
|
+
// auto-height — which is what a block styled by its ratio is.
|
|
220
|
+
{
|
|
221
|
+
width: '100%',
|
|
222
|
+
height: 'auto',
|
|
223
|
+
aspectRatio: ratio,
|
|
224
|
+
border: 'none'
|
|
225
|
+
}
|
|
226
|
+
: { width: '100%', height: '400px', border: 'none' }
|
|
227
|
+
}
|
|
99
228
|
allow="autoplay; fullscreen; picture-in-picture"
|
|
100
229
|
allowFullScreen
|
|
101
230
|
/>
|
|
102
231
|
);
|
|
103
|
-
} else {
|
|
104
|
-
content = (
|
|
105
|
-
<video
|
|
106
|
-
src={embedUrl}
|
|
107
|
-
controls={properties.controls !== false}
|
|
108
|
-
autoPlay={properties.autoplay}
|
|
109
|
-
loop={properties.loop}
|
|
110
|
-
muted={properties.muted}
|
|
111
|
-
poster={
|
|
112
|
-
typeof properties.poster === 'string' ? properties.poster : undefined
|
|
113
|
-
}
|
|
114
|
-
// Without it iOS opens the video full screen, which is never what an
|
|
115
|
-
// inline background clip is for.
|
|
116
|
-
playsInline={properties.playsInline !== false}
|
|
117
|
-
style={{
|
|
118
|
-
width: '100%',
|
|
119
|
-
height: 'auto'
|
|
120
|
-
}}
|
|
121
|
-
/>
|
|
122
|
-
);
|
|
123
232
|
}
|
|
124
233
|
|
|
125
|
-
return
|
|
234
|
+
return (
|
|
235
|
+
<video
|
|
236
|
+
src={parsed.src}
|
|
237
|
+
controls={properties.controls !== false}
|
|
238
|
+
autoPlay={properties.autoplay}
|
|
239
|
+
loop={properties.loop}
|
|
240
|
+
muted={properties.muted}
|
|
241
|
+
poster={
|
|
242
|
+
typeof properties.poster === 'string' ? properties.poster : undefined
|
|
243
|
+
}
|
|
244
|
+
// Without it iOS opens the video full screen, which is never what an
|
|
245
|
+
// inline background clip is for.
|
|
246
|
+
playsInline={properties.playsInline !== false}
|
|
247
|
+
style={
|
|
248
|
+
ratio
|
|
249
|
+
? { width: '100%', aspectRatio: ratio, objectFit: 'cover' }
|
|
250
|
+
: { width: '100%', height: 'auto' }
|
|
251
|
+
}
|
|
252
|
+
/>
|
|
253
|
+
);
|
|
126
254
|
};
|
|
127
255
|
|
|
128
256
|
export default VideoBlock;
|