@anthusai/papyrus 1.0.0-next.18 → 1.0.0-next.19
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/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -0
- package/app/blog-rhythm.css +278 -0
- package/app/globals.css +2 -0
- package/app/layout.tsx +16 -0
- package/components/archive-shell.tsx +2 -22
- package/components/article-video.tsx +136 -1
- package/components/blog-page-background.tsx +23 -6
- package/components/pictogram-figure.tsx +9 -2
- package/components/presentation-header.tsx +135 -0
- package/components/presentation-rhythm-hrule.tsx +17 -0
- package/components/use-blog-header-obstacles.ts +81 -0
- package/components/use-brand-fonts-ready.ts +31 -0
- package/components/use-rhythm-overlay.ts +29 -0
- package/lib/brand-fonts.ts +112 -0
- package/lib/content-types.ts +3 -0
- package/lib/graphql-content-repository.ts +8 -3
- package/lib/renderer.ts +7 -0
- package/lib/site-brand.ts +36 -0
- package/package.json +11 -1
- package/renderers/pretext/article-page.tsx +39 -3
- package/renderers/pretext/presentation-shared.tsx +113 -0
- package/renderers/pretext/presentation-shell.tsx +15 -102
- package/renderers/pretext/rhythm-article-page.tsx +165 -0
- package/renderers/pretext/rhythm-blog-presentation.tsx +280 -0
- package/renderers/pretext/rhythm-shell-style.ts +35 -0
- package/routes.manifest.json +3 -2
- package/src/sync.mjs +2 -2
|
@@ -52,10 +52,12 @@ export default async function DateScopedArticlePage({ params }: DateScopedArticl
|
|
|
52
52
|
editionFooter={{
|
|
53
53
|
editionBasePath,
|
|
54
54
|
entries: buildPresentationFooterEntries(content),
|
|
55
|
+
sections: content.sections.map((section) => ({ key: section.key, label: section.label })),
|
|
55
56
|
subtitle: SITE_BRAND.footerSubtitleOverride ?? (content.description?.trim() || "Inside Papyrus"),
|
|
56
57
|
title: SITE_BRAND.footerTitle,
|
|
57
58
|
}}
|
|
58
59
|
item={item}
|
|
60
|
+
videoScript={content.videoScripts?.[item.slug] ?? null}
|
|
59
61
|
backHref={`${editionBasePath}#${item.slug}`}
|
|
60
62
|
/>
|
|
61
63
|
);
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Structural CSS for the rhythm blog layout (blogLayout: "rhythm").
|
|
3
|
+
Every rule is scoped to .blog-rhythm-shell, which only the rhythm layout
|
|
4
|
+
renders, so classic layouts are unaffected. The shell sets --blog-rhythm,
|
|
5
|
+
--blog-row-height, --blog-paint-buffer and --blog-paint-height inline.
|
|
6
|
+
Typography, colors and the header look belong to the publication's theme.css.
|
|
7
|
+
Optional tokens a publication may set:
|
|
8
|
+
--blog-hrule-foreground, --blog-section-rule, --blog-section-band-height,
|
|
9
|
+
--blog-page-padding-x, --blog-secondary-pair-min-width.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
.blog-rhythm-shell {
|
|
13
|
+
--blog-hrule-foreground: var(--border);
|
|
14
|
+
--blog-section-rule: var(--border);
|
|
15
|
+
--blog-section-band-height: calc(var(--blog-row-height) * 2);
|
|
16
|
+
--blog-page-padding-x: calc(var(--blog-rhythm) * 3);
|
|
17
|
+
--paper-rhythm: var(--blog-row-height);
|
|
18
|
+
|
|
19
|
+
isolation: isolate;
|
|
20
|
+
position: relative;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.blog-rhythm-shell.presentation-page--blog {
|
|
24
|
+
padding: calc(var(--blog-row-height) * 4) var(--blog-page-padding-x) 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.blog-rhythm-shell > :not(.blog-page-background) {
|
|
28
|
+
position: relative;
|
|
29
|
+
z-index: 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.blog-rhythm-shell[data-rhythm-overlay="true"]::before {
|
|
33
|
+
background-image:
|
|
34
|
+
repeating-linear-gradient(
|
|
35
|
+
to bottom,
|
|
36
|
+
rgb(var(--rhythm-overlay-rule-rgb) / var(--rhythm-overlay-rule-alpha)) 0,
|
|
37
|
+
rgb(var(--rhythm-overlay-rule-rgb) / var(--rhythm-overlay-rule-alpha)) 1px,
|
|
38
|
+
transparent 1px,
|
|
39
|
+
transparent var(--blog-row-height)
|
|
40
|
+
),
|
|
41
|
+
repeating-linear-gradient(
|
|
42
|
+
to bottom,
|
|
43
|
+
rgb(var(--rhythm-overlay-blue-rgb) / var(--rhythm-overlay-blue-alpha)) 0,
|
|
44
|
+
rgb(var(--rhythm-overlay-blue-rgb) / var(--rhythm-overlay-blue-alpha)) var(--blog-row-height),
|
|
45
|
+
rgb(var(--rhythm-overlay-yellow-rgb) / var(--rhythm-overlay-yellow-alpha)) var(--blog-row-height),
|
|
46
|
+
rgb(var(--rhythm-overlay-yellow-rgb) / var(--rhythm-overlay-yellow-alpha)) calc(var(--blog-row-height) * 2)
|
|
47
|
+
);
|
|
48
|
+
content: "";
|
|
49
|
+
inset: 0;
|
|
50
|
+
pointer-events: none;
|
|
51
|
+
position: absolute;
|
|
52
|
+
z-index: 900;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.blog-rhythm-shell .presentation-header {
|
|
56
|
+
max-width: 100%;
|
|
57
|
+
min-width: 0;
|
|
58
|
+
width: 100%;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.blog-rhythm-shell .presentation-section-header,
|
|
62
|
+
.blog-rhythm-shell .presentation-item,
|
|
63
|
+
.blog-rhythm-shell .presentation-item__copy {
|
|
64
|
+
max-width: 100%;
|
|
65
|
+
min-width: 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.blog-rhythm-shell .presentation-section-header {
|
|
69
|
+
align-items: stretch;
|
|
70
|
+
border-bottom: 0;
|
|
71
|
+
border-top: 0;
|
|
72
|
+
display: flex;
|
|
73
|
+
flex-direction: column;
|
|
74
|
+
gap: var(--blog-row-height);
|
|
75
|
+
margin: 0;
|
|
76
|
+
min-height: 0;
|
|
77
|
+
padding: calc(var(--blog-row-height) * 4) 0 calc(var(--blog-row-height) * 2);
|
|
78
|
+
width: 100%;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.blog-rhythm-shell .presentation-section-header__band {
|
|
82
|
+
align-items: center;
|
|
83
|
+
display: grid;
|
|
84
|
+
gap: 0 var(--blog-rhythm);
|
|
85
|
+
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
|
86
|
+
grid-template-rows: var(--blog-section-band-height);
|
|
87
|
+
width: 100%;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.blog-rhythm-shell .presentation-section-header__band::before,
|
|
91
|
+
.blog-rhythm-shell .presentation-section-header__band::after {
|
|
92
|
+
background: var(--blog-section-rule);
|
|
93
|
+
content: "";
|
|
94
|
+
grid-row: 1;
|
|
95
|
+
height: var(--blog-section-band-height);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.blog-rhythm-shell .presentation-section-header__band::before {
|
|
99
|
+
grid-column: 1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.blog-rhythm-shell .presentation-section-header__band::after {
|
|
103
|
+
grid-column: 3;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.blog-rhythm-shell .presentation-section-header__band p {
|
|
107
|
+
grid-column: 2;
|
|
108
|
+
grid-row: 1;
|
|
109
|
+
height: var(--blog-section-band-height);
|
|
110
|
+
justify-self: center;
|
|
111
|
+
line-height: var(--blog-section-band-height);
|
|
112
|
+
margin: 0;
|
|
113
|
+
padding: 0 var(--blog-rhythm);
|
|
114
|
+
width: max-content;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.blog-rhythm-shell .presentation-item {
|
|
118
|
+
border-bottom: 0;
|
|
119
|
+
gap: var(--blog-row-height) calc(var(--blog-rhythm) * 4);
|
|
120
|
+
padding-bottom: 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.blog-rhythm-shell .presentation-item--blog {
|
|
124
|
+
grid-template-columns: minmax(0, 1fr);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.blog-rhythm-shell .presentation-rhythm-hrule {
|
|
128
|
+
display: grid;
|
|
129
|
+
grid-column: 1 / -1;
|
|
130
|
+
grid-template-rows: repeat(5, var(--blog-row-height));
|
|
131
|
+
height: calc(var(--blog-row-height) * 5);
|
|
132
|
+
width: 100%;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.blog-rhythm-shell .presentation-rhythm-hrule__line {
|
|
136
|
+
background: var(--blog-hrule-foreground);
|
|
137
|
+
grid-row: 3;
|
|
138
|
+
height: var(--blog-row-height);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@media (min-width: 1100px) {
|
|
142
|
+
.blog-rhythm-shell .blog-section:has(> .presentation-item[data-item-index="2"]) {
|
|
143
|
+
column-gap: calc(var(--blog-row-height) * 2);
|
|
144
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.blog-rhythm-shell .blog-section:has(> .presentation-item[data-item-index="2"]) > .presentation-section-header,
|
|
148
|
+
.blog-rhythm-shell .blog-section:has(> .presentation-item[data-item-index="2"]) > .presentation-item[data-item-index="0"] {
|
|
149
|
+
grid-column: 1 / -1;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.blog-rhythm-shell .blog-section:has(> .presentation-item[data-item-index="2"]) > .presentation-rhythm-hrule[data-hide-in-secondary-pair="true"] {
|
|
153
|
+
display: none;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.blog-rhythm-shell .presentation-item[data-feature-layout][data-has-image="true"] {
|
|
158
|
+
display: flex;
|
|
159
|
+
flex-direction: column;
|
|
160
|
+
gap: 0;
|
|
161
|
+
grid-template-columns: minmax(0, 1fr);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.blog-rhythm-shell .presentation-item[data-feature-layout] .presentation-item__text-frame {
|
|
165
|
+
height: var(--feature-text-frame-height);
|
|
166
|
+
max-width: none;
|
|
167
|
+
overflow: hidden;
|
|
168
|
+
position: relative;
|
|
169
|
+
width: 100%;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.blog-rhythm-shell .presentation-item[data-feature-layout] .presentation-item__media {
|
|
173
|
+
height: var(--feature-media-height);
|
|
174
|
+
width: var(--feature-image-width);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.blog-rhythm-shell .presentation-item[data-feature-layout] .pictogram-figure__frame {
|
|
178
|
+
aspect-ratio: auto;
|
|
179
|
+
height: var(--feature-image-height);
|
|
180
|
+
width: var(--feature-image-width);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.blog-rhythm-shell .presentation-item--blog[data-feature-layout][data-has-image="true"] .presentation-item__copy {
|
|
184
|
+
display: contents;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.blog-rhythm-shell .presentation-item--blog[data-feature-layout][data-has-image="true"] .presentation-item__header {
|
|
188
|
+
width: 100%;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.blog-rhythm-shell .presentation-item--blog[data-feature-layout][data-has-image="true"] .presentation-item__body {
|
|
192
|
+
min-width: 0;
|
|
193
|
+
position: relative;
|
|
194
|
+
width: 100%;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.blog-rhythm-shell .presentation-item--blog[data-feature-layout][data-has-image="true"] .presentation-item__body .presentation-item__text-frame {
|
|
198
|
+
margin-top: 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.blog-rhythm-shell .presentation-item--blog[data-feature-layout][data-has-image="true"] .presentation-item__media {
|
|
202
|
+
margin: 0;
|
|
203
|
+
position: absolute;
|
|
204
|
+
right: 0;
|
|
205
|
+
top: 0;
|
|
206
|
+
z-index: 1;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.blog-rhythm-shell .presentation-item__image img {
|
|
210
|
+
height: 100%;
|
|
211
|
+
object-fit: contain;
|
|
212
|
+
width: 100%;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.blog-rhythm-shell .presentation-item__video {
|
|
216
|
+
grid-column: 1 / -1;
|
|
217
|
+
margin: var(--blog-row-height) 0 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.blog-rhythm-shell .article-page__hero-video {
|
|
221
|
+
margin: 0 0 calc(var(--blog-rhythm) * 5);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.blog-rhythm-shell .article-page__hero-video .article-video,
|
|
225
|
+
.blog-rhythm-shell .presentation-item__video-figure {
|
|
226
|
+
container-type: inline-size;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.blog-rhythm-shell .presentation-item__video-figure .article-video__media,
|
|
230
|
+
.blog-rhythm-shell .article-page__hero-video .article-video__media {
|
|
231
|
+
height: round(up, calc(100cqw * 9 / 16), var(--blog-row-height));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.blog-rhythm-shell .article-float-grid[data-feature-layout][data-has-image="true"] {
|
|
235
|
+
display: grid;
|
|
236
|
+
gap: var(--feature-layout-gap);
|
|
237
|
+
grid-template-columns: minmax(0, 1fr) var(--feature-image-width);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.blog-rhythm-shell .article-float-grid[data-feature-layout] .article-page__hero-video,
|
|
241
|
+
.blog-rhythm-shell .article-float-grid[data-feature-layout] .article-float-grid__header {
|
|
242
|
+
grid-column: 1 / -1;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.blog-rhythm-shell .article-float-grid[data-feature-layout] .article-float-grid__body {
|
|
246
|
+
align-self: start;
|
|
247
|
+
grid-column: 1;
|
|
248
|
+
max-width: var(--feature-copy-width);
|
|
249
|
+
min-width: 0;
|
|
250
|
+
width: var(--feature-copy-width);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.blog-rhythm-shell .article-float-grid[data-feature-layout] .article-float-grid__media,
|
|
254
|
+
.blog-rhythm-shell .article-float-grid[data-feature-layout] .pictogram-figure__frame {
|
|
255
|
+
height: var(--feature-image-height);
|
|
256
|
+
width: var(--feature-image-width);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.blog-rhythm-shell .article-float-grid[data-feature-layout] .pictogram-figure__frame {
|
|
260
|
+
aspect-ratio: auto;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.blog-rhythm-shell .article-float-grid[data-feature-layout] .article-float-grid__media {
|
|
264
|
+
align-self: start;
|
|
265
|
+
grid-column: 2;
|
|
266
|
+
margin: 0;
|
|
267
|
+
position: relative;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.blog-rhythm-shell .article-float-grid .presentation-item__image {
|
|
271
|
+
margin: 0;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
@media (max-width: 900px) {
|
|
275
|
+
.blog-rhythm-shell .presentation-item--blog {
|
|
276
|
+
grid-template-columns: 1fr;
|
|
277
|
+
}
|
|
278
|
+
}
|
package/app/globals.css
CHANGED
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
--color-surface-foreground: var(--surface-foreground);
|
|
46
46
|
--color-foreground-strong: var(--foreground-strong);
|
|
47
47
|
--color-foreground-muted: var(--foreground-muted);
|
|
48
|
+
--color-foreground-subtle: var(--foreground-subtle);
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
:root {
|
|
@@ -71,6 +72,7 @@
|
|
|
71
72
|
--surface-foreground: var(--card-foreground);
|
|
72
73
|
--foreground-strong: var(--slate-12);
|
|
73
74
|
--foreground-muted: var(--slate-10);
|
|
75
|
+
--foreground-subtle: var(--slate-9);
|
|
74
76
|
--border: var(--slate-6);
|
|
75
77
|
--input: var(--slate-6);
|
|
76
78
|
--ring: var(--slate-8);
|
package/app/layout.tsx
CHANGED
|
@@ -4,12 +4,14 @@ import Script from "next/script";
|
|
|
4
4
|
import { AmplifyClientProvider } from "../components/amplify-client-provider";
|
|
5
5
|
import { PapyrusConsoleShell } from "../components/papyrus-console-shell";
|
|
6
6
|
import { TooltipProvider } from "../components/ui/tooltip";
|
|
7
|
+
import { resolveBrandFontAssets } from "../lib/brand-fonts";
|
|
7
8
|
import { getPresentationChoices, getSiteBrand } from "../lib/site-brand";
|
|
8
9
|
import { getSiteStack } from "../lib/site-stack";
|
|
9
10
|
import { isIndexable, showStagingBanner } from "../lib/site-env";
|
|
10
11
|
import { StagingBanner } from "../components/staging-banner";
|
|
11
12
|
import "./tailwind.css";
|
|
12
13
|
import "./globals.css";
|
|
14
|
+
import "./blog-rhythm.css";
|
|
13
15
|
import "papyrus-site-theme";
|
|
14
16
|
|
|
15
17
|
const playfairDisplay = Playfair_Display({
|
|
@@ -43,6 +45,7 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac
|
|
|
43
45
|
const siteBrand = getSiteBrand();
|
|
44
46
|
const presentationChoices = getPresentationChoices(siteBrand);
|
|
45
47
|
const siteStack = getSiteStack(siteBrand);
|
|
48
|
+
const brandFonts = resolveBrandFontAssets(siteBrand.fonts);
|
|
46
49
|
|
|
47
50
|
return (
|
|
48
51
|
<html
|
|
@@ -56,8 +59,21 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac
|
|
|
56
59
|
data-default-presentation={siteBrand.defaultPresentation}
|
|
57
60
|
data-presentation-choices={presentationChoices.join(",")}
|
|
58
61
|
{...(siteBrand.forcedPresentation ? { "data-forced-presentation": siteBrand.forcedPresentation } : {})}
|
|
62
|
+
{...(siteBrand.blogLayout === "rhythm" ? { "data-blog-layout": "rhythm" } : {})}
|
|
63
|
+
{...(brandFonts.isEmpty ? {} : { style: brandFonts.rootStyle as React.CSSProperties })}
|
|
59
64
|
suppressHydrationWarning
|
|
60
65
|
>
|
|
66
|
+
{brandFonts.isEmpty ? null : (
|
|
67
|
+
<head>
|
|
68
|
+
{brandFonts.preconnectOrigins.map((origin) => (
|
|
69
|
+
<link crossOrigin="anonymous" href={origin} key={origin} rel="preconnect" />
|
|
70
|
+
))}
|
|
71
|
+
{brandFonts.stylesheetHrefs.map((href) => (
|
|
72
|
+
<link href={href} key={href} rel="stylesheet" />
|
|
73
|
+
))}
|
|
74
|
+
{brandFonts.fontFaceCss ? <style data-brand-fonts="true">{brandFonts.fontFaceCss}</style> : null}
|
|
75
|
+
</head>
|
|
76
|
+
)}
|
|
61
77
|
<body className={`${playfairDisplay.variable} ${plusJakartaSans.variable}`}>
|
|
62
78
|
<Script id="papyrus-favicon-color-scheme" strategy="beforeInteractive">
|
|
63
79
|
{`(() => {
|
|
@@ -1,27 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import type { ReactNode } from "react";
|
|
4
|
-
import {
|
|
4
|
+
import { useRhythmOverlay } from "./use-rhythm-overlay";
|
|
5
5
|
|
|
6
6
|
type ArchiveShellProps = {
|
|
7
7
|
children: ReactNode;
|
|
8
8
|
};
|
|
9
9
|
|
|
10
10
|
export function ArchiveShell({ children }: ArchiveShellProps) {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
useEffect(() => {
|
|
14
|
-
const handleKeyDown = (event: KeyboardEvent) => {
|
|
15
|
-
if (isEditableEventTarget(event.target)) return;
|
|
16
|
-
if ((event.key === "=" || event.code === "Equal") && event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
17
|
-
event.preventDefault();
|
|
18
|
-
setShowRhythmOverlay((current) => !current);
|
|
19
|
-
}
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
window.addEventListener("keydown", handleKeyDown);
|
|
23
|
-
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
24
|
-
}, []);
|
|
11
|
+
const showRhythmOverlay = useRhythmOverlay();
|
|
25
12
|
|
|
26
13
|
return (
|
|
27
14
|
<main
|
|
@@ -32,10 +19,3 @@ export function ArchiveShell({ children }: ArchiveShellProps) {
|
|
|
32
19
|
</main>
|
|
33
20
|
);
|
|
34
21
|
}
|
|
35
|
-
|
|
36
|
-
function isEditableEventTarget(target: EventTarget | null): boolean {
|
|
37
|
-
if (!(target instanceof HTMLElement)) return false;
|
|
38
|
-
const tagName = target.tagName.toLowerCase();
|
|
39
|
-
if (tagName === "input" || tagName === "textarea" || tagName === "select") return true;
|
|
40
|
-
return target.isContentEditable || target.closest("[contenteditable='true']") !== null;
|
|
41
|
-
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
|
4
|
+
import { Pause, Play } from "lucide-react";
|
|
4
5
|
import type { ArticleVideoAsset } from "../lib/articles";
|
|
5
6
|
import { resolveThemedVideoSrc } from "../lib/themed-image";
|
|
6
7
|
import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
|
|
@@ -32,6 +33,8 @@ export function ArticleVideoFigure({
|
|
|
32
33
|
const [duration, setDuration] = useState(0);
|
|
33
34
|
const [previewReady, setPreviewReady] = useState(false);
|
|
34
35
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
36
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
37
|
+
const isFramed = getSiteBrand().videoPlayer === "framed";
|
|
35
38
|
|
|
36
39
|
useEffect(() => {
|
|
37
40
|
setHasHydrated(true);
|
|
@@ -91,6 +94,58 @@ export function ArticleVideoFigure({
|
|
|
91
94
|
setCurrentTime(clamped);
|
|
92
95
|
}, []);
|
|
93
96
|
|
|
97
|
+
const toggleNativePlayback = useCallback(() => {
|
|
98
|
+
const element = videoRef.current;
|
|
99
|
+
if (!element) return;
|
|
100
|
+
if (element.paused) void element.play().catch(() => setIsPlaying(false));
|
|
101
|
+
else element.pause();
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
104
|
+
const seekNative = useCallback((nextTime: number) => {
|
|
105
|
+
const element = videoRef.current;
|
|
106
|
+
if (!element) return;
|
|
107
|
+
element.currentTime = Math.max(0, Number.isFinite(nextTime) ? nextTime : 0);
|
|
108
|
+
setCurrentTime(element.currentTime);
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
const framedControls = (
|
|
112
|
+
<FramedVideoControls
|
|
113
|
+
currentTime={currentTime}
|
|
114
|
+
duration={duration}
|
|
115
|
+
isPlaying={isPlaying}
|
|
116
|
+
onSeek={usePreview ? seekPreview : seekNative}
|
|
117
|
+
onToggle={usePreview ? togglePreviewPlayback : toggleNativePlayback}
|
|
118
|
+
/>
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if (isFramed && usePreview) {
|
|
122
|
+
return (
|
|
123
|
+
<figure
|
|
124
|
+
className={`${figureClassName} article-video--framed article-video--preview`}
|
|
125
|
+
data-media-type="videoml-preview"
|
|
126
|
+
data-video-theme={resolvedTheme}
|
|
127
|
+
data-video-mode={videoMode}
|
|
128
|
+
>
|
|
129
|
+
<div className="article-video__media">
|
|
130
|
+
{hasHydrated ? (
|
|
131
|
+
<iframe
|
|
132
|
+
key={`${slug}-${resolvedTheme}-${src}`}
|
|
133
|
+
ref={iframeRef}
|
|
134
|
+
className="article-video__preview-frame"
|
|
135
|
+
src={previewSrc}
|
|
136
|
+
title={video.alt}
|
|
137
|
+
onLoad={() => setPreviewReady(true)}
|
|
138
|
+
/>
|
|
139
|
+
) : null}
|
|
140
|
+
</div>
|
|
141
|
+
{framedControls}
|
|
142
|
+
<span className="sr-only" data-video-slug={slug}>
|
|
143
|
+
{video.alt}
|
|
144
|
+
</span>
|
|
145
|
+
</figure>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
94
149
|
if (usePreview) {
|
|
95
150
|
return (
|
|
96
151
|
<figure
|
|
@@ -133,6 +188,43 @@ export function ArticleVideoFigure({
|
|
|
133
188
|
);
|
|
134
189
|
}
|
|
135
190
|
|
|
191
|
+
if (isFramed) {
|
|
192
|
+
return (
|
|
193
|
+
<figure
|
|
194
|
+
className={`${figureClassName} article-video--framed`}
|
|
195
|
+
data-media-type="video"
|
|
196
|
+
data-video-theme={resolvedTheme}
|
|
197
|
+
data-video-mode={videoMode}
|
|
198
|
+
>
|
|
199
|
+
<div className="article-video__media">
|
|
200
|
+
<video
|
|
201
|
+
ref={videoRef}
|
|
202
|
+
playsInline
|
|
203
|
+
preload="metadata"
|
|
204
|
+
poster={video.posterSrc}
|
|
205
|
+
aria-label={video.alt}
|
|
206
|
+
className="article-video__player"
|
|
207
|
+
key={hasHydrated ? src : "ssr"}
|
|
208
|
+
onPlay={() => setIsPlaying(true)}
|
|
209
|
+
onPause={() => setIsPlaying(false)}
|
|
210
|
+
onEnded={() => setIsPlaying(false)}
|
|
211
|
+
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
|
|
212
|
+
onDurationChange={(event) => {
|
|
213
|
+
const nextDuration = event.currentTarget.duration;
|
|
214
|
+
if (Number.isFinite(nextDuration) && nextDuration > 0) setDuration(nextDuration);
|
|
215
|
+
}}
|
|
216
|
+
>
|
|
217
|
+
<source src={src} type="video/mp4" />
|
|
218
|
+
</video>
|
|
219
|
+
</div>
|
|
220
|
+
{framedControls}
|
|
221
|
+
<span className="sr-only" data-video-slug={slug}>
|
|
222
|
+
{video.alt}
|
|
223
|
+
</span>
|
|
224
|
+
</figure>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
136
228
|
return (
|
|
137
229
|
<figure className={figureClassName} data-media-type="video" data-video-theme={resolvedTheme} data-video-mode={videoMode}>
|
|
138
230
|
<video
|
|
@@ -154,3 +246,46 @@ export function ArticleVideoFigure({
|
|
|
154
246
|
</figure>
|
|
155
247
|
);
|
|
156
248
|
}
|
|
249
|
+
|
|
250
|
+
function FramedVideoControls({
|
|
251
|
+
currentTime,
|
|
252
|
+
duration,
|
|
253
|
+
isPlaying,
|
|
254
|
+
onSeek,
|
|
255
|
+
onToggle,
|
|
256
|
+
}: {
|
|
257
|
+
currentTime: number;
|
|
258
|
+
duration: number;
|
|
259
|
+
isPlaying: boolean;
|
|
260
|
+
onSeek: (nextTime: number) => void;
|
|
261
|
+
onToggle: () => void;
|
|
262
|
+
}) {
|
|
263
|
+
const seekProgress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
|
264
|
+
return (
|
|
265
|
+
<div className="article-video__cta" data-playing={isPlaying ? "true" : "false"}>
|
|
266
|
+
<button type="button" className="article-video__cta-toggle" onClick={onToggle} aria-pressed={isPlaying}>
|
|
267
|
+
<span className="article-video__cta-label">
|
|
268
|
+
<span className="article-video__cta-label-sizer" aria-hidden="true">
|
|
269
|
+
Pause Video
|
|
270
|
+
</span>
|
|
271
|
+
<span className="article-video__cta-label-text">{isPlaying ? "Pause Video" : "Play Video"}</span>
|
|
272
|
+
</span>
|
|
273
|
+
<span className="article-video__cta-icon" aria-hidden="true">
|
|
274
|
+
{isPlaying ? <Pause /> : <Play />}
|
|
275
|
+
</span>
|
|
276
|
+
</button>
|
|
277
|
+
<input
|
|
278
|
+
className="article-video__cta-seek"
|
|
279
|
+
type="range"
|
|
280
|
+
min={0}
|
|
281
|
+
max={duration || 0}
|
|
282
|
+
step={0.1}
|
|
283
|
+
value={Math.min(currentTime, duration || 0)}
|
|
284
|
+
onChange={(event) => onSeek(Number(event.target.value))}
|
|
285
|
+
aria-label="Seek video"
|
|
286
|
+
disabled={!duration}
|
|
287
|
+
style={{ "--seek-progress": `${seekProgress}%` } as CSSProperties}
|
|
288
|
+
/>
|
|
289
|
+
</div>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
@@ -2,18 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
import type { RefObject } from "react";
|
|
4
4
|
|
|
5
|
+
/** A rectangle, in the background layer's own coordinates, that header text occupies. */
|
|
6
|
+
export type BlogHeaderObstacle = {
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Vertical rhythm grid the rhythm blog layout is built on. */
|
|
14
|
+
export type BlogBackgroundRhythm = {
|
|
15
|
+
rowHeight: number;
|
|
16
|
+
paintBuffer: number;
|
|
17
|
+
paintHeight: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
5
20
|
export type BlogPageBackgroundProps = {
|
|
6
21
|
pageRef: RefObject<HTMLElement | null>;
|
|
22
|
+
/** Rhythm blog layout only: header text boxes the artwork should keep clear of. */
|
|
23
|
+
headerObstacles?: BlogHeaderObstacle[];
|
|
24
|
+
/** Rhythm blog layout only: the rhythm grid, so artwork can align to it. */
|
|
25
|
+
rhythm?: BlogBackgroundRhythm;
|
|
7
26
|
};
|
|
8
27
|
|
|
9
28
|
/**
|
|
10
29
|
* Default (no-op) blog page background for publications without a bespoke
|
|
11
|
-
* backdrop.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* `renderers/pretext` falls back to
|
|
15
|
-
* Intelligence, so the blog layout loads cleanly without depending on TI's
|
|
16
|
-
* package.
|
|
30
|
+
* backdrop. A publication supplies header artwork through the
|
|
31
|
+
* `components.BlogPageBackground` brand slot; with the rhythm blog layout the
|
|
32
|
+
* slot also receives `headerObstacles` and `rhythm`. This is what
|
|
33
|
+
* `renderers/pretext` falls back to when no slot component is registered.
|
|
17
34
|
*/
|
|
18
35
|
export function BlogPageBackground(_props: BlogPageBackgroundProps) {
|
|
19
36
|
return null;
|
|
@@ -12,6 +12,9 @@ export type PictogramFigureProps = {
|
|
|
12
12
|
credit: string;
|
|
13
13
|
figureClassName: string;
|
|
14
14
|
height: number;
|
|
15
|
+
/** Rhythm blog layout: solved frame size in CSS pixels; wins over `height`/`width` when set. */
|
|
16
|
+
frameHeight?: number;
|
|
17
|
+
frameWidth?: number;
|
|
15
18
|
layout?: ArticleImageLayout;
|
|
16
19
|
priority?: boolean;
|
|
17
20
|
sizes: string;
|
|
@@ -34,6 +37,8 @@ export function PictogramFigure({
|
|
|
34
37
|
caption,
|
|
35
38
|
credit,
|
|
36
39
|
figureClassName,
|
|
40
|
+
frameHeight,
|
|
41
|
+
frameWidth,
|
|
37
42
|
height,
|
|
38
43
|
priority = false,
|
|
39
44
|
sizes,
|
|
@@ -41,6 +46,8 @@ export function PictogramFigure({
|
|
|
41
46
|
themeVariants,
|
|
42
47
|
width,
|
|
43
48
|
}: PictogramFigureProps) {
|
|
49
|
+
const imageHeight = frameHeight ?? height;
|
|
50
|
+
const imageWidth = frameWidth ?? width;
|
|
44
51
|
const resolvedTheme = useResolvedPapyrusTheme();
|
|
45
52
|
const themedImageSrc = resolveThemedImageSrc(src, themeVariants, resolvedTheme);
|
|
46
53
|
|
|
@@ -53,8 +60,8 @@ export function PictogramFigure({
|
|
|
53
60
|
<Image
|
|
54
61
|
src={themedImageSrc}
|
|
55
62
|
alt={alt}
|
|
56
|
-
width={
|
|
57
|
-
height={
|
|
63
|
+
width={imageWidth}
|
|
64
|
+
height={imageHeight}
|
|
58
65
|
sizes={sizes}
|
|
59
66
|
priority={priority}
|
|
60
67
|
unoptimized={shouldBypassImageOptimization(themedImageSrc)}
|