scavold 0.2.0-rc.9 → 0.2.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/CHANGELOG.md +79 -3
- package/COMPONENTS.md +629 -429
- package/FRONTMATTER.md +30 -0
- package/components/ScavoldCover.vue +162 -0
- package/components/ScavoldImage.vue +5 -1
- package/components/ScavoldMenu.vue +20 -1
- package/components/ScavoldVideo.vue +12 -53
- package/composables/hierarchy.ts +72 -1
- package/composables/useAutoplay.js +128 -0
- package/composables/useCover.js +128 -0
- package/composables/useVideo.js +11 -21
- package/index.d.ts +3 -0
- package/l10n/de.json +4 -0
- package/l10n/en.json +4 -0
- package/lib/config.js +28 -6
- package/lib/containers.js +53 -7
- package/lib/media.js +9 -2
- package/lib/menus.d.ts +17 -0
- package/lib/menus.js +73 -0
- package/lib/sectionManifest.js +40 -10
- package/package.json +1 -1
- package/scripts/check-fixture.js +87 -8
package/FRONTMATTER.md
CHANGED
|
@@ -274,6 +274,36 @@ in the hierarchy tree — they are only excluded from rendered navigation.
|
|
|
274
274
|
|
|
275
275
|
---
|
|
276
276
|
|
|
277
|
+
## `menus`
|
|
278
|
+
|
|
279
|
+
**Type:** `string | string[]`
|
|
280
|
+
|
|
281
|
+
Names the menus this page appears in: menus the site declares under
|
|
282
|
+
[`menus`](./docs/reference/config.md#menus) in `.cratly.config.yaml`, plus `auto` for the menus following the
|
|
283
|
+
page tree. Pages then no longer need a folder of their own just to be collected into one
|
|
284
|
+
menu.
|
|
285
|
+
|
|
286
|
+
| Value | Effect |
|
|
287
|
+
|---|---|
|
|
288
|
+
| absent / empty | Same as `auto` (default) |
|
|
289
|
+
| `auto` | In the menus following the page tree, where the page sits among the folders |
|
|
290
|
+
| `footer` or `[ footer ]` | In the named menu only; the page tree's menus leave the page out |
|
|
291
|
+
| `[ auto, footer ]` | Both |
|
|
292
|
+
|
|
293
|
+
Choosing a named menu replaces `auto` rather than adding to it. [`hide: menu`](#hide)
|
|
294
|
+
rules over any choice. A name the site does not declare is warned about at build time.
|
|
295
|
+
|
|
296
|
+
Where a named menu shows up is decided by the theme, rendering it with
|
|
297
|
+
[`<ScavoldMenu menu="…">`](./COMPONENTS.md#scavoldmenu).
|
|
298
|
+
|
|
299
|
+
```yaml
|
|
300
|
+
---
|
|
301
|
+
menus: [ auto, footer ]
|
|
302
|
+
---
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
277
307
|
## `aliases`
|
|
278
308
|
|
|
279
309
|
**Type:** `string | string[]`
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, ref } from "vue";
|
|
3
|
+
import { useAutoplay } from "../composables/useAutoplay.js";
|
|
4
|
+
import { useCover, coverProps } from "../composables/useCover.js";
|
|
5
|
+
import { useScavoldI18n } from "../composables/useI18n.js";
|
|
6
|
+
import ScavoldImage from "./ScavoldImage.vue";
|
|
7
|
+
|
|
8
|
+
const props = defineProps( coverProps );
|
|
9
|
+
|
|
10
|
+
const { src, isVideo, srcset, webpSrcset, sizes, poster, focus, label, tone } = useCover( props );
|
|
11
|
+
|
|
12
|
+
const { t } = useScavoldI18n();
|
|
13
|
+
const playLabel = t( "cover.play" );
|
|
14
|
+
const pauseLabel = t( "cover.pause" );
|
|
15
|
+
|
|
16
|
+
const video = ref( null );
|
|
17
|
+
const playing = ref( false );
|
|
18
|
+
|
|
19
|
+
const style = computed( () => ( focus.value ? { "--scavold-cover-focus": focus.value } : undefined ) );
|
|
20
|
+
|
|
21
|
+
// Plays while on screen, not for visitors asking for reduced motion — see useAutoplay.
|
|
22
|
+
useAutoplay( video, () => isVideo.value );
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Starts or pauses the video. Moving content lasting longer than five seconds must
|
|
26
|
+
* be pausable (WCAG 2.2.2), which is why a cover video always carries this button.
|
|
27
|
+
*/
|
|
28
|
+
function toggle() {
|
|
29
|
+
const element = video.value;
|
|
30
|
+
|
|
31
|
+
if ( !element ) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if ( element.paused ) {
|
|
36
|
+
element.play()?.catch( () => {
|
|
37
|
+
// refused by the browser; the button keeps offering to start it
|
|
38
|
+
} );
|
|
39
|
+
} else {
|
|
40
|
+
element.pause();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<template>
|
|
46
|
+
<div
|
|
47
|
+
v-if="src"
|
|
48
|
+
class="scavold-cover"
|
|
49
|
+
:class="[ { 'scavold-cover--video': isVideo }, tone && `scavold-cover--${tone}` ]"
|
|
50
|
+
:style="style"
|
|
51
|
+
>
|
|
52
|
+
<video
|
|
53
|
+
v-if="isVideo"
|
|
54
|
+
ref="video"
|
|
55
|
+
class="scavold-cover__media"
|
|
56
|
+
:src="src"
|
|
57
|
+
:poster="poster || undefined"
|
|
58
|
+
:aria-label="label || undefined"
|
|
59
|
+
:aria-hidden="label ? undefined : 'true'"
|
|
60
|
+
preload="metadata"
|
|
61
|
+
muted
|
|
62
|
+
loop
|
|
63
|
+
playsinline
|
|
64
|
+
@play="playing = true"
|
|
65
|
+
@pause="playing = false"
|
|
66
|
+
/>
|
|
67
|
+
<ScavoldImage
|
|
68
|
+
v-else
|
|
69
|
+
class="scavold-cover__media"
|
|
70
|
+
:src="src"
|
|
71
|
+
:srcset="srcset"
|
|
72
|
+
:webp-srcset="webpSrcset"
|
|
73
|
+
:sizes="sizes"
|
|
74
|
+
:alt="label"
|
|
75
|
+
loading="eager"
|
|
76
|
+
fetchpriority="high"
|
|
77
|
+
/>
|
|
78
|
+
|
|
79
|
+
<div class="scavold-cover__overlay">
|
|
80
|
+
<slot />
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<button
|
|
84
|
+
v-if="isVideo"
|
|
85
|
+
type="button"
|
|
86
|
+
class="scavold-cover__toggle"
|
|
87
|
+
:aria-label="playing ? pauseLabel : playLabel"
|
|
88
|
+
@click="toggle"
|
|
89
|
+
>
|
|
90
|
+
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">
|
|
91
|
+
<path v-if="playing" d="M4 2h3v12H4zM9 2h3v12H9z" fill="currentColor" />
|
|
92
|
+
<path v-else d="M4 2l10 6-10 6z" fill="currentColor" />
|
|
93
|
+
</svg>
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
</template>
|
|
97
|
+
|
|
98
|
+
<style scoped>
|
|
99
|
+
/*
|
|
100
|
+
* Functional layout: the cover leaves the column it is written in and spans the
|
|
101
|
+
* window, at least as high as the window. Media and content share one grid cell,
|
|
102
|
+
* so content taller than the window grows the cover instead of being cut off.
|
|
103
|
+
*
|
|
104
|
+
* The breakout assumes a column centred in the window. Themes adjust the height
|
|
105
|
+
* through --scavold-cover-height, e.g. to leave room for a fixed header.
|
|
106
|
+
*/
|
|
107
|
+
.scavold-cover {
|
|
108
|
+
position: relative;
|
|
109
|
+
display: grid;
|
|
110
|
+
overflow: hidden;
|
|
111
|
+
width: 100vw;
|
|
112
|
+
margin-inline: calc(50% - 50vw);
|
|
113
|
+
min-height: 100vh;
|
|
114
|
+
min-height: var(--scavold-cover-height, 100svh);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.scavold-cover__media,
|
|
118
|
+
.scavold-cover__overlay {
|
|
119
|
+
grid-area: 1 / 1;
|
|
120
|
+
min-width: 0;
|
|
121
|
+
min-height: 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.scavold-cover__media,
|
|
125
|
+
.scavold-cover__media :deep(img) {
|
|
126
|
+
display: block;
|
|
127
|
+
width: 100%;
|
|
128
|
+
height: 100%;
|
|
129
|
+
margin: 0;
|
|
130
|
+
border-radius: 0;
|
|
131
|
+
object-fit: cover;
|
|
132
|
+
object-position: var(--scavold-cover-focus, 50% 50%);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.scavold-cover__overlay {
|
|
136
|
+
position: relative;
|
|
137
|
+
z-index: 1;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* A sensible default only — themes restyle it through the class. */
|
|
141
|
+
.scavold-cover__toggle {
|
|
142
|
+
position: absolute;
|
|
143
|
+
z-index: 2;
|
|
144
|
+
inset-block-end: 1rem;
|
|
145
|
+
inset-inline-end: 1rem;
|
|
146
|
+
display: grid;
|
|
147
|
+
place-items: center;
|
|
148
|
+
width: 2.75rem;
|
|
149
|
+
height: 2.75rem;
|
|
150
|
+
padding: 0;
|
|
151
|
+
border: 0;
|
|
152
|
+
border-radius: 50%;
|
|
153
|
+
color: #fff;
|
|
154
|
+
background: rgb(0 0 0 / 0.55);
|
|
155
|
+
cursor: pointer;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.scavold-cover__toggle:focus-visible {
|
|
159
|
+
outline: 2px solid #fff;
|
|
160
|
+
outline-offset: 2px;
|
|
161
|
+
}
|
|
162
|
+
</style>
|
|
@@ -5,6 +5,9 @@ defineProps( {
|
|
|
5
5
|
webpSrcset: { type: String, default: "" },
|
|
6
6
|
sizes: { type: String, default: "100vw" },
|
|
7
7
|
alt: { type: String, default: "" },
|
|
8
|
+
// An image above the fold — a cover — is wanted at once, not when it scrolls into view.
|
|
9
|
+
loading: { type: String, default: "lazy" },
|
|
10
|
+
fetchpriority: { type: String, default: undefined },
|
|
8
11
|
} );
|
|
9
12
|
</script>
|
|
10
13
|
|
|
@@ -26,7 +29,8 @@ defineProps( {
|
|
|
26
29
|
:alt="alt"
|
|
27
30
|
:role="alt ? undefined : 'presentation'"
|
|
28
31
|
:sizes="sizes"
|
|
29
|
-
loading="
|
|
32
|
+
:loading="loading"
|
|
33
|
+
:fetchpriority="fetchpriority"
|
|
30
34
|
decoding="async"
|
|
31
35
|
/>
|
|
32
36
|
</picture>
|
|
@@ -6,6 +6,12 @@ import type { Scavold } from "../index";
|
|
|
6
6
|
|
|
7
7
|
interface MenuProps {
|
|
8
8
|
|
|
9
|
+
/** Renders a named menu declared in `.cratly.config.yaml` instead of the page tree:
|
|
10
|
+
* the pages choosing it in their `menus` front matter, wherever they sit. `fromPath`
|
|
11
|
+
* and `fromRoot` then narrow the part of the site searched — `"{locale}"` keeps a
|
|
12
|
+
* multilingual site's menu to one language — and `from` is ignored. */
|
|
13
|
+
menu?: string;
|
|
14
|
+
|
|
9
15
|
/** Selects the parent node by path, supporting a `{locale}` placeholder that is
|
|
10
16
|
* replaced with the current page's locale at runtime. Takes priority over
|
|
11
17
|
* `fromRoot` and `from` when set. Examples: `"de/footer"`, `"{locale}/footer"`. */
|
|
@@ -36,6 +42,7 @@ interface MenuProps {
|
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
const props = withDefaults( defineProps<MenuProps>(), {
|
|
45
|
+
menu: undefined,
|
|
39
46
|
fromPath: undefined,
|
|
40
47
|
fromRoot: undefined,
|
|
41
48
|
from: 0,
|
|
@@ -45,9 +52,21 @@ const props = withDefaults( defineProps<MenuProps>(), {
|
|
|
45
52
|
label: undefined,
|
|
46
53
|
} );
|
|
47
54
|
|
|
48
|
-
const { current, ancestorAtDepth, collectItems, resolveByPath } = useHierarchy();
|
|
55
|
+
const { hierarchy, current, ancestorAtDepth, collectItems, collectMenuItems, resolveByPath } = useHierarchy();
|
|
49
56
|
|
|
50
57
|
const items = computed<Scavold.MenuItem[]>( () => {
|
|
58
|
+
if ( props.menu !== undefined ) {
|
|
59
|
+
let scope: Scavold.HierarchyNode | undefined = hierarchy.value;
|
|
60
|
+
|
|
61
|
+
if ( props.fromPath !== undefined ) {
|
|
62
|
+
scope = resolveByPath( props.fromPath );
|
|
63
|
+
} else if ( props.fromRoot !== undefined ) {
|
|
64
|
+
scope = ancestorAtDepth( current.value, props.fromRoot );
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return collectMenuItems( props.menu, scope, props.depth, props.activeOnly, props.expand );
|
|
68
|
+
}
|
|
69
|
+
|
|
51
70
|
let parent: Scavold.HierarchyNode | undefined;
|
|
52
71
|
|
|
53
72
|
if ( props.fromPath !== undefined ) {
|
|
@@ -1,39 +1,26 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
+
import { ref } from "vue";
|
|
3
|
+
import { useAutoplay } from "../composables/useAutoplay.js";
|
|
2
4
|
import { useVideo, videoProps } from "../composables/useVideo.js";
|
|
3
5
|
|
|
4
6
|
const props = defineProps( videoProps );
|
|
5
7
|
|
|
6
|
-
const { src, poster, autoplay, loop, muted, preload,
|
|
7
|
-
|
|
8
|
+
const { src, poster, autoplay, loop, muted, preload, ariaLabel } = useVideo( props );
|
|
9
|
+
|
|
10
|
+
// Started and paused by script while on screen — see useAutoplay.
|
|
11
|
+
const video = ref( null );
|
|
12
|
+
|
|
13
|
+
useAutoplay( video, () => autoplay.value );
|
|
8
14
|
</script>
|
|
9
15
|
|
|
10
16
|
<template>
|
|
11
|
-
<!--
|
|
12
|
-
<div v-if="overlay && src" class="scavold-video scavold-video--overlay">
|
|
13
|
-
<video
|
|
14
|
-
class="scavold-video__media"
|
|
15
|
-
:src="src"
|
|
16
|
-
:poster="poster || undefined"
|
|
17
|
-
:autoplay="autoplay || undefined"
|
|
18
|
-
:loop="loop || undefined"
|
|
19
|
-
:muted="muted || undefined"
|
|
20
|
-
:controls="controls || undefined"
|
|
21
|
-
:preload="preload"
|
|
22
|
-
:aria-label="ariaLabel"
|
|
23
|
-
playsinline
|
|
24
|
-
/>
|
|
25
|
-
<div class="scavold-video__overlay">
|
|
26
|
-
<slot />
|
|
27
|
-
</div>
|
|
28
|
-
</div>
|
|
29
|
-
|
|
30
|
-
<!-- default mode: plain inline player; the slot is fallback content -->
|
|
17
|
+
<!-- the slot is fallback content, and where <track> elements go -->
|
|
31
18
|
<video
|
|
32
|
-
v-
|
|
33
|
-
|
|
19
|
+
v-if="src"
|
|
20
|
+
ref="video"
|
|
21
|
+
class="scavold-video"
|
|
34
22
|
:src="src"
|
|
35
23
|
:poster="poster || undefined"
|
|
36
|
-
:autoplay="autoplay || undefined"
|
|
37
24
|
:loop="loop || undefined"
|
|
38
25
|
:muted="muted || undefined"
|
|
39
26
|
:preload="preload"
|
|
@@ -41,34 +28,6 @@ const { src, poster, autoplay, loop, muted, preload, overlay, controls, ariaLabe
|
|
|
41
28
|
controls
|
|
42
29
|
playsinline
|
|
43
30
|
>
|
|
44
|
-
<!-- <track kind="captions" src="..." srclang="..." label="..." /> -->
|
|
45
31
|
<slot />
|
|
46
32
|
</video>
|
|
47
33
|
</template>
|
|
48
|
-
|
|
49
|
-
<style scoped>
|
|
50
|
-
/*
|
|
51
|
-
* Functional layout only — visual styling (scrim, colours, alignment, min-height)
|
|
52
|
-
* belongs to the consuming theme. The overlay stacks the video and the content in
|
|
53
|
-
* a single grid cell so the content defines the block's height and the video
|
|
54
|
-
* covers the same area behind it.
|
|
55
|
-
*/
|
|
56
|
-
.scavold-video--overlay {
|
|
57
|
-
position: relative;
|
|
58
|
-
display: grid;
|
|
59
|
-
overflow: hidden;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
.scavold-video--overlay .scavold-video__media {
|
|
63
|
-
grid-area: 1 / 1;
|
|
64
|
-
width: 100%;
|
|
65
|
-
height: 100%;
|
|
66
|
-
object-fit: cover;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
.scavold-video--overlay .scavold-video__overlay {
|
|
70
|
-
grid-area: 1 / 1;
|
|
71
|
-
position: relative;
|
|
72
|
-
z-index: 1;
|
|
73
|
-
}
|
|
74
|
-
</style>
|
package/composables/hierarchy.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { useData } from "vitepress";
|
|
|
2
2
|
import { computed } from "vue";
|
|
3
3
|
import { useL10n } from "@cepharum/vue3-i18n";
|
|
4
4
|
import { hidesFrom } from "../lib/hide.js";
|
|
5
|
+
import { AUTO_MENU, appearsIn } from "../lib/menus.js";
|
|
5
6
|
import { nodePath, pageHref } from "../lib/href.js";
|
|
6
7
|
import type { Scavold } from "../index";
|
|
7
8
|
|
|
@@ -136,7 +137,7 @@ export function useHierarchy() {
|
|
|
136
137
|
}
|
|
137
138
|
|
|
138
139
|
return Object.values( parent.subs )
|
|
139
|
-
.filter( node =>
|
|
140
|
+
.filter( node => appearsIn( node.frontmatter, AUTO_MENU ) )
|
|
140
141
|
.map( node => {
|
|
141
142
|
const shouldDescend = remaining !== 0 && (
|
|
142
143
|
expand ||
|
|
@@ -154,6 +155,75 @@ export function useHierarchy() {
|
|
|
154
155
|
} );
|
|
155
156
|
};
|
|
156
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Collects the items of a named menu: the pages below `scope` choosing that menu in
|
|
160
|
+
* their `menus` front matter, in page-tree order, wherever they sit among the folders.
|
|
161
|
+
*
|
|
162
|
+
* Below each of them, `remaining`, `activeOnly` and `expand` descend into sub-pages as
|
|
163
|
+
* collectItems() does. A page choosing the menu along with one of its ancestors is
|
|
164
|
+
* listed below that ancestor rather than a second time — unless the menu shows no
|
|
165
|
+
* sub-pages at all, which keeps it flat and lists the page beside its ancestor.
|
|
166
|
+
*
|
|
167
|
+
* @param name menu name as declared in .cratly.config.yaml
|
|
168
|
+
* @param scope the node whose descendants are searched
|
|
169
|
+
* @param remaining levels to descend below each listed page; -1 means unlimited
|
|
170
|
+
* @param activeOnly only expand children along the active path
|
|
171
|
+
* @param expand expand all nodes with children regardless of active path
|
|
172
|
+
*/
|
|
173
|
+
const collectMenuItems = (
|
|
174
|
+
name: string,
|
|
175
|
+
scope: Scavold.HierarchyNode | undefined,
|
|
176
|
+
remaining: number,
|
|
177
|
+
activeOnly: boolean,
|
|
178
|
+
expand: boolean,
|
|
179
|
+
): Scavold.MenuItem[] => {
|
|
180
|
+
const contains = ( items: Scavold.MenuItem[], node: Scavold.HierarchyNode ): boolean =>
|
|
181
|
+
items.some( item => item.node === node || contains( item.children, node ) );
|
|
182
|
+
|
|
183
|
+
const items: Scavold.MenuItem[] = [];
|
|
184
|
+
|
|
185
|
+
for ( const node of Object.values( scope?.subs ?? {} ) ) {
|
|
186
|
+
// Searched even below a page left out of the menu: choosing a menu is
|
|
187
|
+
// deliberate, and a folder that only groups pages is not a reason to drop it.
|
|
188
|
+
const listedBelow = collectMenuItems( name, node, remaining, activeOnly, expand );
|
|
189
|
+
|
|
190
|
+
if ( !appearsIn( node.frontmatter, name ) ) {
|
|
191
|
+
items.push( ...listedBelow );
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const shouldDescend = remaining !== 0 && (
|
|
196
|
+
expand ||
|
|
197
|
+
( activeOnly && isOnActivePath( node ) )
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const children = shouldDescend
|
|
201
|
+
? collectItems( node, remaining === -1 ? -1 : remaining - 1, activeOnly, expand )
|
|
202
|
+
: [];
|
|
203
|
+
|
|
204
|
+
// A menu showing no sub-pages stays flat: what was chosen below is listed beside
|
|
205
|
+
// its ancestor rather than dropped, or nested where the theme expects no list.
|
|
206
|
+
if ( remaining === 0 ) {
|
|
207
|
+
items.push( {
|
|
208
|
+
node,
|
|
209
|
+
active: isOnActivePath( node ),
|
|
210
|
+
current: node === current.value,
|
|
211
|
+
children: [],
|
|
212
|
+
}, ...listedBelow );
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
items.push( {
|
|
217
|
+
node,
|
|
218
|
+
active: isOnActivePath( node ),
|
|
219
|
+
current: node === current.value,
|
|
220
|
+
children: [ ...children, ...listedBelow.filter( item => !contains( children, item.node ) ) ],
|
|
221
|
+
} );
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return items;
|
|
225
|
+
};
|
|
226
|
+
|
|
157
227
|
/**
|
|
158
228
|
* Collects the pages below a starting node as a flat array, in hierarchy order.
|
|
159
229
|
* Unlike collectItems() this keeps no tree shape — a page list presents its
|
|
@@ -431,6 +501,7 @@ export function useHierarchy() {
|
|
|
431
501
|
ancestorAtDepth,
|
|
432
502
|
isOnActivePath,
|
|
433
503
|
collectItems,
|
|
504
|
+
collectMenuItems,
|
|
434
505
|
collectPages,
|
|
435
506
|
collectAncestors,
|
|
436
507
|
collectLocaleLinks,
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { onBeforeUnmount, onMounted } from "vue";
|
|
2
|
+
|
|
3
|
+
/** Share of an element that has to be on screen before its video starts. */
|
|
4
|
+
export const VISIBLE_SHARE = 0.5;
|
|
5
|
+
|
|
6
|
+
/** Thresholds the observer reports at, fine enough to catch the share above. */
|
|
7
|
+
const THRESHOLDS = Array.from( { length: 21 }, ( _, i ) => i / 20 );
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Decides whether an observed element is on screen far enough to play: half of it,
|
|
11
|
+
* or — for an element higher than the window, which never gets half of itself on
|
|
12
|
+
* screen — half of the window.
|
|
13
|
+
*
|
|
14
|
+
* @param {{intersectionRect: DOMRectReadOnly, boundingClientRect: DOMRectReadOnly, rootBounds: DOMRectReadOnly|null}} entry
|
|
15
|
+
* @param {number} [share] required share
|
|
16
|
+
* @returns {boolean}
|
|
17
|
+
*/
|
|
18
|
+
export function isVisibleEnough( { intersectionRect, boundingClientRect, rootBounds }, share = VISIBLE_SHARE ) {
|
|
19
|
+
const visible = intersectionRect.height;
|
|
20
|
+
const needed = share * Math.min( boundingClientRect.height, rootBounds?.height ?? Infinity );
|
|
21
|
+
|
|
22
|
+
return visible > 0 && visible >= needed;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Starts and pauses a muted video as it scrolls into and out of view, without
|
|
27
|
+
* overruling the visitor:
|
|
28
|
+
*
|
|
29
|
+
* - a video the visitor paused stays paused, even when scrolled back into view,
|
|
30
|
+
* until they start it again;
|
|
31
|
+
* - a video playing with sound is not paused when scrolled away — someone who turned
|
|
32
|
+
* it up is listening.
|
|
33
|
+
*
|
|
34
|
+
* @param {HTMLVideoElement} element
|
|
35
|
+
* @returns {{ setVisible: function(boolean): void, detach: function(): void }}
|
|
36
|
+
*/
|
|
37
|
+
export function autoplayControl( element ) {
|
|
38
|
+
// What this control itself just asked the element to do, to tell the events it
|
|
39
|
+
// causes apart from the visitor's.
|
|
40
|
+
let own = null;
|
|
41
|
+
let heldByVisitor = false;
|
|
42
|
+
|
|
43
|
+
const onPlay = () => {
|
|
44
|
+
if ( own === "play" ) {
|
|
45
|
+
own = null;
|
|
46
|
+
} else {
|
|
47
|
+
heldByVisitor = false;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const onPause = () => {
|
|
52
|
+
if ( own === "pause" ) {
|
|
53
|
+
own = null;
|
|
54
|
+
} else {
|
|
55
|
+
heldByVisitor = true;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
element.addEventListener( "play", onPlay );
|
|
60
|
+
element.addEventListener( "pause", onPause );
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
setVisible( visible ) {
|
|
64
|
+
if ( visible ) {
|
|
65
|
+
if ( element.paused && !heldByVisitor ) {
|
|
66
|
+
own = "play";
|
|
67
|
+
element.muted = true;
|
|
68
|
+
element.play()?.catch( () => {
|
|
69
|
+
// Refused by the browser, e.g. in power saving mode; the visitor
|
|
70
|
+
// can still start it by hand.
|
|
71
|
+
own = null;
|
|
72
|
+
} );
|
|
73
|
+
}
|
|
74
|
+
} else if ( !element.paused && element.muted ) {
|
|
75
|
+
own = "pause";
|
|
76
|
+
element.pause();
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
detach() {
|
|
81
|
+
element.removeEventListener( "play", onPlay );
|
|
82
|
+
element.removeEventListener( "pause", onPause );
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Plays a video automatically while it is on screen, for components that offer
|
|
89
|
+
* autoplay. Nothing happens for visitors who ask for reduced motion: they find the
|
|
90
|
+
* video paused, with a way to start it.
|
|
91
|
+
*
|
|
92
|
+
* Deliberately not done with the `autoplay` attribute, which would start the video
|
|
93
|
+
* before hydration, regardless of both reduced motion and where it is on the page.
|
|
94
|
+
*
|
|
95
|
+
* @param {import("vue").Ref<HTMLVideoElement|null>} videoRef template ref of the video
|
|
96
|
+
* @param {function(): boolean} enabled whether the video is meant to play by itself
|
|
97
|
+
*/
|
|
98
|
+
export function useAutoplay( videoRef, enabled ) {
|
|
99
|
+
let control = null;
|
|
100
|
+
let observer = null;
|
|
101
|
+
|
|
102
|
+
onMounted( () => {
|
|
103
|
+
const element = videoRef.value;
|
|
104
|
+
|
|
105
|
+
if ( !element || !enabled() || window.matchMedia?.( "(prefers-reduced-motion: reduce)" ).matches ) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
control = autoplayControl( element );
|
|
110
|
+
|
|
111
|
+
if ( typeof IntersectionObserver === "undefined" ) {
|
|
112
|
+
control.setVisible( true );
|
|
113
|
+
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
observer = new IntersectionObserver(
|
|
118
|
+
entries => control.setVisible( isVisibleEnough( entries[entries.length - 1] ) ),
|
|
119
|
+
{ threshold: THRESHOLDS }
|
|
120
|
+
);
|
|
121
|
+
observer.observe( element );
|
|
122
|
+
} );
|
|
123
|
+
|
|
124
|
+
onBeforeUnmount( () => {
|
|
125
|
+
observer?.disconnect();
|
|
126
|
+
control?.detach();
|
|
127
|
+
} );
|
|
128
|
+
}
|