@boyernick/standard-ui-react 0.1.1-canary.8 → 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/package.json +5 -3
- package/src/accordion.tsx +5 -2
- package/src/alert-dialog.tsx +1 -1
- package/src/attachment.tsx +5 -2
- package/src/autocomplete.tsx +4 -3
- package/src/block-editor.tsx +1747 -0
- package/src/brand.tsx +2 -2
- package/src/breadcrumb.tsx +14 -7
- package/src/button.tsx +22 -11
- package/src/calendar.tsx +6 -3
- package/src/carousel.tsx +220 -53
- package/src/checkbox.tsx +3 -2
- package/src/code-block.tsx +66 -5
- package/src/collapsible.tsx +4 -2
- package/src/combobox.tsx +4 -3
- package/src/command.tsx +41 -13
- package/src/date-picker.tsx +7 -2
- package/src/dialog.tsx +33 -10
- package/src/empty.tsx +4 -4
- package/src/field.tsx +4 -3
- package/src/filter-group.tsx +143 -0
- package/src/icons.tsx +28 -1
- package/src/illustrations.tsx +219 -141
- package/src/index.ts +245 -18
- package/src/input.tsx +8 -5
- package/src/kbd.tsx +49 -0
- package/src/lib/focus.ts +41 -0
- package/src/lib/popup.ts +1 -1
- package/src/lifeline/company-icon.tsx +71 -0
- package/src/lifeline/index.ts +42 -0
- package/src/lifeline/lifeline-data.ts +97 -0
- package/src/lifeline/lifeline-desktop.tsx +204 -0
- package/src/lifeline/lifeline-event.tsx +108 -0
- package/src/lifeline/lifeline-fireworks.tsx +302 -0
- package/src/lifeline/lifeline-hover-image.tsx +269 -0
- package/src/lifeline/lifeline-icons.tsx +39 -0
- package/src/lifeline/lifeline-intro-timing.ts +105 -0
- package/src/lifeline/lifeline-labels.tsx +24 -0
- package/src/lifeline/lifeline-layout.ts +1 -0
- package/src/lifeline/lifeline-legend.tsx +31 -0
- package/src/lifeline/lifeline-lightbox.tsx +309 -0
- package/src/lifeline/lifeline-marker.tsx +183 -0
- package/src/lifeline/lifeline-people.tsx +99 -0
- package/src/lifeline/lifeline-photos.tsx +366 -0
- package/src/lifeline/lifeline-shell.tsx +135 -0
- package/src/lifeline/lifeline-utils.ts +83 -0
- package/src/lifeline/lifeline-vertical.tsx +482 -0
- package/src/lifeline/lifeline.tsx +69 -0
- package/src/lifeline/types.ts +112 -0
- package/src/lifeline/use-lifeline-intro.ts +130 -0
- package/src/lifeline/use-lifeline-scroll.ts +1011 -0
- package/src/lifeline/use-lifeline-vertical-scroll.ts +271 -0
- package/src/menubar.tsx +9 -2
- package/src/minimap.tsx +296 -0
- package/src/modal.tsx +608 -0
- package/src/navigation-menu.tsx +338 -67
- package/src/number-field.tsx +336 -61
- package/src/otp-field.tsx +4 -3
- package/src/pagination.tsx +262 -42
- package/src/password-protection.tsx +345 -0
- package/src/popover.tsx +1 -1
- package/src/progress.tsx +5 -0
- package/src/questionnaire.tsx +284 -0
- package/src/radio.tsx +4 -2
- package/src/scroll-area.tsx +4 -1
- package/src/select.tsx +5 -2
- package/src/sidebar.tsx +113 -28
- package/src/slider.tsx +66 -6
- package/src/sounds.tsx +6 -10
- package/src/spinner.tsx +105 -32
- package/src/switch.tsx +4 -1
- package/src/table.tsx +82 -15
- package/src/tabs.tsx +189 -38
- package/src/text-animate.tsx +71 -28
- package/src/textarea.tsx +6 -1
- package/src/timeline.tsx +378 -0
- package/src/toast.tsx +214 -35
- package/src/toggle.tsx +4 -2
- package/src/toolbar.tsx +12 -7
- package/src/tooltip.tsx +43 -3
- package/src/video-player.tsx +396 -127
- package/src/image-modal.tsx +0 -110
- package/src/markdown-editor.tsx +0 -302
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react"
|
|
12
|
+
import type { LifelineEventEffect } from "./types"
|
|
13
|
+
|
|
14
|
+
/** Tweak these */
|
|
15
|
+
const DURATION_S = 7.5
|
|
16
|
+
const MAX_DPR = 1.5
|
|
17
|
+
/** Wait for the theme cross-fade before the first burst. */
|
|
18
|
+
const NIGHTFALL_MS = 400
|
|
19
|
+
|
|
20
|
+
type Palette = [number[], number[], number[]]
|
|
21
|
+
|
|
22
|
+
const PALETTES: Record<LifelineEventEffect, Palette> = {
|
|
23
|
+
// Old Glory red / white / blue
|
|
24
|
+
fireworks: [
|
|
25
|
+
[0.9, 0.15, 0.25],
|
|
26
|
+
[1.0, 1.0, 1.0],
|
|
27
|
+
[0.25, 0.45, 0.95],
|
|
28
|
+
],
|
|
29
|
+
// celeste / white / pale celeste
|
|
30
|
+
"fireworks-argentina": [
|
|
31
|
+
[0.45, 0.75, 0.98],
|
|
32
|
+
[1.0, 1.0, 1.0],
|
|
33
|
+
[0.7, 0.87, 1.0],
|
|
34
|
+
],
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const VERTEX_SHADER = `
|
|
38
|
+
attribute vec2 a_pos;
|
|
39
|
+
void main() {
|
|
40
|
+
gl_Position = vec4(a_pos, 0.0, 1.0);
|
|
41
|
+
}
|
|
42
|
+
`
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Additive point-glow fireworks in Old Glory red, white, and blue,
|
|
46
|
+
* composited over a night-sky scrim via premultiplied canvas alpha.
|
|
47
|
+
*/
|
|
48
|
+
const FRAGMENT_SHADER = `
|
|
49
|
+
precision highp float;
|
|
50
|
+
|
|
51
|
+
uniform vec2 u_res;
|
|
52
|
+
uniform float u_time;
|
|
53
|
+
uniform float u_dur;
|
|
54
|
+
uniform vec3 u_c0;
|
|
55
|
+
uniform vec3 u_c1;
|
|
56
|
+
uniform vec3 u_c2;
|
|
57
|
+
|
|
58
|
+
#define TAU 6.28318530718
|
|
59
|
+
#define N_FIREWORKS 10
|
|
60
|
+
#define N_PARTICLES 42
|
|
61
|
+
|
|
62
|
+
float hash(float n) {
|
|
63
|
+
return fract(sin(n) * 43758.5453123);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
vec3 palette(float m) {
|
|
67
|
+
if (m < 0.5) return u_c0;
|
|
68
|
+
if (m < 1.5) return u_c1;
|
|
69
|
+
return u_c2;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
void main() {
|
|
73
|
+
vec2 uv = (gl_FragCoord.xy - 0.5 * u_res) / u_res.y;
|
|
74
|
+
float t = u_time;
|
|
75
|
+
float env = smoothstep(0.0, 0.5, t) * (1.0 - smoothstep(u_dur - 1.0, u_dur, t));
|
|
76
|
+
|
|
77
|
+
vec3 col = vec3(0.0);
|
|
78
|
+
|
|
79
|
+
for (int i = 0; i < N_FIREWORKS; i++) {
|
|
80
|
+
float fi = float(i);
|
|
81
|
+
float t0 = 0.35 + fi * (u_dur - 2.8) / float(N_FIREWORKS) + hash(fi * 7.31) * 0.3;
|
|
82
|
+
// No flow control in the loop — some WebGL1 driver translations
|
|
83
|
+
// mishandle continue, so inactive bursts multiply to zero instead.
|
|
84
|
+
float active = step(t0, t) * step(t, t0 + 1.8);
|
|
85
|
+
float lt = clamp((t - t0) / 1.8, 0.0, 1.0);
|
|
86
|
+
|
|
87
|
+
vec2 center = vec2((hash(fi * 3.7) - 0.5) * 1.6, -0.05 + hash(fi * 9.1) * 0.5);
|
|
88
|
+
vec3 base = palette(mod(fi, 3.0)); // strict red / white / blue rotation
|
|
89
|
+
// Ramp in fast so overlapping particles at ignition don't stack
|
|
90
|
+
// into a blown-out ball, then decay.
|
|
91
|
+
float fade = exp(-lt * 4.0) * min(1.0, lt * 6.0);
|
|
92
|
+
|
|
93
|
+
for (int j = 0; j < N_PARTICLES; j++) {
|
|
94
|
+
float fj = float(j);
|
|
95
|
+
float angle = (fj / float(N_PARTICLES)) * TAU + hash(fi * 100.0 + fj) * 0.15;
|
|
96
|
+
float speed = 0.16 + 0.22 * hash(fj * 7.77 + fi * 31.3);
|
|
97
|
+
|
|
98
|
+
vec2 p = center + vec2(cos(angle), sin(angle)) * speed * sqrt(lt);
|
|
99
|
+
p.y -= 0.09 * lt * lt; // gravity
|
|
100
|
+
|
|
101
|
+
float d = max(length(uv - p), 0.004);
|
|
102
|
+
float sparkle = 0.7 + 0.3 * sin(30.0 * lt + fj * 1.7);
|
|
103
|
+
col += base * active * fade * sparkle * 0.0006 / (d * d);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
col = clamp(col * env, 0.0, 1.0);
|
|
108
|
+
float spark = max(col.r, max(col.g, col.b));
|
|
109
|
+
float scrim = 0.45 * env;
|
|
110
|
+
gl_FragColor = vec4(col, max(spark, scrim));
|
|
111
|
+
}
|
|
112
|
+
`
|
|
113
|
+
|
|
114
|
+
interface LifelineFireworksApi {
|
|
115
|
+
launch: (effect: LifelineEventEffect) => void
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const LifelineFireworksContext = createContext<LifelineFireworksApi | null>(
|
|
119
|
+
null,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
export function useLifelineFireworks() {
|
|
123
|
+
return useContext(LifelineFireworksContext)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function FireworksCanvas({
|
|
127
|
+
palette,
|
|
128
|
+
onDone,
|
|
129
|
+
}: {
|
|
130
|
+
palette: Palette
|
|
131
|
+
onDone: () => void
|
|
132
|
+
}) {
|
|
133
|
+
const paletteRef = useRef(palette)
|
|
134
|
+
paletteRef.current = palette
|
|
135
|
+
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
136
|
+
const onDoneRef = useRef(onDone)
|
|
137
|
+
onDoneRef.current = onDone
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
const canvas = canvasRef.current
|
|
141
|
+
if (!canvas) return
|
|
142
|
+
|
|
143
|
+
const gl = canvas.getContext("webgl", {
|
|
144
|
+
alpha: true,
|
|
145
|
+
premultipliedAlpha: true,
|
|
146
|
+
antialias: false,
|
|
147
|
+
})
|
|
148
|
+
if (!gl) {
|
|
149
|
+
onDoneRef.current()
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const compile = (type: number, source: string) => {
|
|
154
|
+
const shader = gl.createShader(type)!
|
|
155
|
+
gl.shaderSource(shader, source)
|
|
156
|
+
gl.compileShader(shader)
|
|
157
|
+
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
158
|
+
console.warn("fireworks shader:", gl.getShaderInfoLog(shader))
|
|
159
|
+
}
|
|
160
|
+
return shader
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const program = gl.createProgram()!
|
|
164
|
+
gl.attachShader(program, compile(gl.VERTEX_SHADER, VERTEX_SHADER))
|
|
165
|
+
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAGMENT_SHADER))
|
|
166
|
+
gl.linkProgram(program)
|
|
167
|
+
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
|
168
|
+
console.warn("fireworks link:", gl.getProgramInfoLog(program))
|
|
169
|
+
onDoneRef.current()
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
gl.useProgram(program)
|
|
173
|
+
|
|
174
|
+
const buffer = gl.createBuffer()
|
|
175
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
|
|
176
|
+
gl.bufferData(
|
|
177
|
+
gl.ARRAY_BUFFER,
|
|
178
|
+
new Float32Array([-1, -1, 3, -1, -1, 3]),
|
|
179
|
+
gl.STATIC_DRAW,
|
|
180
|
+
)
|
|
181
|
+
const aPos = gl.getAttribLocation(program, "a_pos")
|
|
182
|
+
gl.enableVertexAttribArray(aPos)
|
|
183
|
+
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0)
|
|
184
|
+
|
|
185
|
+
const uRes = gl.getUniformLocation(program, "u_res")
|
|
186
|
+
const uTime = gl.getUniformLocation(program, "u_time")
|
|
187
|
+
const uDur = gl.getUniformLocation(program, "u_dur")
|
|
188
|
+
|
|
189
|
+
const [c0, c1, c2] = paletteRef.current
|
|
190
|
+
gl.uniform3f(gl.getUniformLocation(program, "u_c0"), c0[0], c0[1], c0[2])
|
|
191
|
+
gl.uniform3f(gl.getUniformLocation(program, "u_c1"), c1[0], c1[1], c1[2])
|
|
192
|
+
gl.uniform3f(gl.getUniformLocation(program, "u_c2"), c2[0], c2[1], c2[2])
|
|
193
|
+
|
|
194
|
+
const resize = () => {
|
|
195
|
+
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR)
|
|
196
|
+
canvas.width = Math.round(window.innerWidth * dpr)
|
|
197
|
+
canvas.height = Math.round(window.innerHeight * dpr)
|
|
198
|
+
gl.viewport(0, 0, canvas.width, canvas.height)
|
|
199
|
+
}
|
|
200
|
+
resize()
|
|
201
|
+
window.addEventListener("resize", resize)
|
|
202
|
+
|
|
203
|
+
let frame = 0
|
|
204
|
+
const start = performance.now()
|
|
205
|
+
|
|
206
|
+
const step = (now: number) => {
|
|
207
|
+
const t = (now - start) / 1000
|
|
208
|
+
if (t >= DURATION_S) {
|
|
209
|
+
onDoneRef.current()
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
gl.uniform2f(uRes, canvas.width, canvas.height)
|
|
214
|
+
gl.uniform1f(uTime, t)
|
|
215
|
+
gl.uniform1f(uDur, DURATION_S)
|
|
216
|
+
gl.clearColor(0, 0, 0, 0)
|
|
217
|
+
gl.clear(gl.COLOR_BUFFER_BIT)
|
|
218
|
+
gl.drawArrays(gl.TRIANGLES, 0, 3)
|
|
219
|
+
|
|
220
|
+
frame = requestAnimationFrame(step)
|
|
221
|
+
}
|
|
222
|
+
frame = requestAnimationFrame(step)
|
|
223
|
+
|
|
224
|
+
return () => {
|
|
225
|
+
cancelAnimationFrame(frame)
|
|
226
|
+
window.removeEventListener("resize", resize)
|
|
227
|
+
// No manual loseContext: StrictMode re-runs this effect on the
|
|
228
|
+
// same canvas, and a lost context can never compile again.
|
|
229
|
+
}
|
|
230
|
+
}, [])
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<canvas
|
|
234
|
+
ref={canvasRef}
|
|
235
|
+
aria-hidden="true"
|
|
236
|
+
className="pointer-events-none fixed inset-0 z-[70] h-full w-full"
|
|
237
|
+
/>
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function isDarkDocument() {
|
|
242
|
+
return document.documentElement.classList.contains("dark")
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function setDocumentDark(dark: boolean) {
|
|
246
|
+
document.documentElement.classList.toggle("dark", dark)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function LifelineFireworksProvider({
|
|
250
|
+
children,
|
|
251
|
+
}: {
|
|
252
|
+
children: ReactNode
|
|
253
|
+
}) {
|
|
254
|
+
const [playing, setPlaying] = useState(false)
|
|
255
|
+
const [effect, setEffect] = useState<LifelineEventEffect>("fireworks")
|
|
256
|
+
const restoreThemeRef = useRef<"light" | null>(null)
|
|
257
|
+
const nightfallRef = useRef(0)
|
|
258
|
+
const playingRef = useRef(false)
|
|
259
|
+
playingRef.current = playing
|
|
260
|
+
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
return () => window.clearTimeout(nightfallRef.current)
|
|
263
|
+
}, [])
|
|
264
|
+
|
|
265
|
+
const launch = useCallback((nextEffect: LifelineEventEffect) => {
|
|
266
|
+
if (playingRef.current) return
|
|
267
|
+
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
|
|
268
|
+
|
|
269
|
+
setEffect(nextEffect)
|
|
270
|
+
|
|
271
|
+
// Fireworks belong in the dark: switch a light page to dark for
|
|
272
|
+
// the show, and restore afterwards.
|
|
273
|
+
if (!isDarkDocument()) {
|
|
274
|
+
restoreThemeRef.current = "light"
|
|
275
|
+
setDocumentDark(true)
|
|
276
|
+
window.clearTimeout(nightfallRef.current)
|
|
277
|
+
nightfallRef.current = window.setTimeout(
|
|
278
|
+
() => setPlaying(true),
|
|
279
|
+
NIGHTFALL_MS,
|
|
280
|
+
)
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
restoreThemeRef.current = null
|
|
285
|
+
setPlaying(true)
|
|
286
|
+
}, [])
|
|
287
|
+
|
|
288
|
+
const done = useCallback(() => {
|
|
289
|
+
setPlaying(false)
|
|
290
|
+
if (restoreThemeRef.current) {
|
|
291
|
+
setDocumentDark(false)
|
|
292
|
+
restoreThemeRef.current = null
|
|
293
|
+
}
|
|
294
|
+
}, [])
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<LifelineFireworksContext.Provider value={{ launch }}>
|
|
298
|
+
{children}
|
|
299
|
+
{playing && <FireworksCanvas palette={PALETTES[effect]} onDone={done} />}
|
|
300
|
+
</LifelineFireworksContext.Provider>
|
|
301
|
+
)
|
|
302
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react"
|
|
12
|
+
import { clamp, snapToDevicePixel } from "./lifeline-utils"
|
|
13
|
+
import type { LifelineEventImage } from "./types"
|
|
14
|
+
|
|
15
|
+
/** Tweak these */
|
|
16
|
+
const FOLLOW_EASE = 0.16
|
|
17
|
+
const TILT_FACTOR = 0.14
|
|
18
|
+
const TILT_MAX_DEG = 7
|
|
19
|
+
const TILT_EASE = 0.1
|
|
20
|
+
const CURSOR_OFFSET_X = 24
|
|
21
|
+
const CURSOR_OFFSET_Y = 96
|
|
22
|
+
|
|
23
|
+
interface LifelineHoverImageApi {
|
|
24
|
+
show: (image: LifelineEventImage) => void
|
|
25
|
+
hide: () => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface LifelineHoverImageProviderProps {
|
|
29
|
+
children: ReactNode
|
|
30
|
+
/** Warmed into the browser cache during idle so swaps are instant. */
|
|
31
|
+
preload?: LifelineEventImage[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const LifelineHoverImageContext = createContext<LifelineHoverImageApi | null>(
|
|
35
|
+
null,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
export function useLifelineHoverImage() {
|
|
39
|
+
return useContext(LifelineHoverImageContext)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A cursor-following image reveal. The floating element lives at the
|
|
44
|
+
* provider level — it must stay outside the transformed track, since
|
|
45
|
+
* position: fixed resolves against the nearest transformed ancestor.
|
|
46
|
+
*/
|
|
47
|
+
export function LifelineHoverImageProvider({
|
|
48
|
+
children,
|
|
49
|
+
preload,
|
|
50
|
+
}: LifelineHoverImageProviderProps) {
|
|
51
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
52
|
+
const imageRef = useRef<HTMLImageElement>(null)
|
|
53
|
+
const videoRef = useRef<HTMLVideoElement>(null)
|
|
54
|
+
const state = useRef({
|
|
55
|
+
x: 0,
|
|
56
|
+
y: 0,
|
|
57
|
+
targetX: 0,
|
|
58
|
+
targetY: 0,
|
|
59
|
+
tilt: 0,
|
|
60
|
+
visible: false,
|
|
61
|
+
hoverCapable: false,
|
|
62
|
+
frame: 0,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
state.current.hoverCapable = window.matchMedia(
|
|
67
|
+
"(hover: hover) and (pointer: fine)",
|
|
68
|
+
).matches
|
|
69
|
+
|
|
70
|
+
const onMouseMove = (event: MouseEvent) => {
|
|
71
|
+
state.current.targetX = event.clientX
|
|
72
|
+
state.current.targetY = event.clientY
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
window.addEventListener("mousemove", onMouseMove, { passive: true })
|
|
76
|
+
return () => {
|
|
77
|
+
window.removeEventListener("mousemove", onMouseMove)
|
|
78
|
+
cancelAnimationFrame(state.current.frame)
|
|
79
|
+
}
|
|
80
|
+
}, [])
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!preload?.length || !state.current.hoverCapable) return
|
|
84
|
+
|
|
85
|
+
const warm = () => {
|
|
86
|
+
preload.forEach(({ src }) => {
|
|
87
|
+
const image = new window.Image()
|
|
88
|
+
image.src = src
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (typeof window.requestIdleCallback === "function") {
|
|
93
|
+
const handle = window.requestIdleCallback(warm)
|
|
94
|
+
return () => window.cancelIdleCallback(handle)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const timeout = window.setTimeout(warm, 2000)
|
|
98
|
+
return () => window.clearTimeout(timeout)
|
|
99
|
+
}, [preload])
|
|
100
|
+
|
|
101
|
+
const step = useCallback(() => {
|
|
102
|
+
const s = state.current
|
|
103
|
+
const container = containerRef.current
|
|
104
|
+
if (!container) return
|
|
105
|
+
|
|
106
|
+
const dx = s.targetX - s.x
|
|
107
|
+
s.x += dx * FOLLOW_EASE
|
|
108
|
+
s.y += (s.targetY - s.y) * FOLLOW_EASE
|
|
109
|
+
|
|
110
|
+
const targetTilt = clamp(dx * TILT_FACTOR, -TILT_MAX_DEG, TILT_MAX_DEG)
|
|
111
|
+
s.tilt += (targetTilt - s.tilt) * TILT_EASE
|
|
112
|
+
|
|
113
|
+
// The ease is asymptotic — it never actually arrives, so the card
|
|
114
|
+
// rests on a fractional offset with a residual tilt and the browser
|
|
115
|
+
// resamples it soft. Land it: snap sub-threshold deltas to done.
|
|
116
|
+
if (
|
|
117
|
+
Math.abs(s.targetX - s.x) < 0.1 &&
|
|
118
|
+
Math.abs(s.targetY - s.y) < 0.1 &&
|
|
119
|
+
Math.abs(s.tilt) < 0.05
|
|
120
|
+
) {
|
|
121
|
+
s.x = s.targetX
|
|
122
|
+
s.y = s.targetY
|
|
123
|
+
s.tilt = 0
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const rotate = s.tilt === 0 ? "" : ` rotate(${s.tilt}deg)`
|
|
127
|
+
container.style.transform = `translate3d(${snapToDevicePixel(
|
|
128
|
+
s.x + CURSOR_OFFSET_X,
|
|
129
|
+
)}px, ${snapToDevicePixel(s.y - CURSOR_OFFSET_Y)}px, 0)${rotate}`
|
|
130
|
+
|
|
131
|
+
if (s.visible) {
|
|
132
|
+
s.frame = requestAnimationFrame(step)
|
|
133
|
+
}
|
|
134
|
+
}, [])
|
|
135
|
+
|
|
136
|
+
const api = useMemo<LifelineHoverImageApi>(
|
|
137
|
+
() => ({
|
|
138
|
+
show(image) {
|
|
139
|
+
const s = state.current
|
|
140
|
+
const container = containerRef.current
|
|
141
|
+
const img = imageRef.current
|
|
142
|
+
const video = videoRef.current
|
|
143
|
+
if (!s.hoverCapable || !container || !img) return
|
|
144
|
+
|
|
145
|
+
if (image.video && video) {
|
|
146
|
+
// Video takes over the card; the image element sits this one out.
|
|
147
|
+
img.style.display = "none"
|
|
148
|
+
video.style.display = "block"
|
|
149
|
+
|
|
150
|
+
const targetVideo = new URL(image.video, window.location.origin).href
|
|
151
|
+
if (video.src !== targetVideo) {
|
|
152
|
+
video.src = targetVideo
|
|
153
|
+
video.poster = image.src
|
|
154
|
+
}
|
|
155
|
+
video.play().catch(() => {
|
|
156
|
+
// Autoplay rejection just leaves the poster frame showing.
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
if (!s.visible) {
|
|
160
|
+
s.x = s.targetX
|
|
161
|
+
s.y = s.targetY
|
|
162
|
+
s.tilt = 0
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
s.visible = true
|
|
166
|
+
container.style.opacity = "1"
|
|
167
|
+
video.style.transform = "scale(1)"
|
|
168
|
+
|
|
169
|
+
cancelAnimationFrame(s.frame)
|
|
170
|
+
s.frame = requestAnimationFrame(step)
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (video) {
|
|
175
|
+
video.pause()
|
|
176
|
+
video.style.display = "none"
|
|
177
|
+
}
|
|
178
|
+
img.style.display = "block"
|
|
179
|
+
|
|
180
|
+
const targetSrc = new URL(image.src, window.location.origin).href
|
|
181
|
+
|
|
182
|
+
if (img.src !== targetSrc) {
|
|
183
|
+
// Kill the previous bitmap instantly — the browser would keep
|
|
184
|
+
// showing it until the new file decodes.
|
|
185
|
+
img.style.visibility = "hidden"
|
|
186
|
+
img.src = targetSrc
|
|
187
|
+
img.alt = image.alt
|
|
188
|
+
|
|
189
|
+
const reveal = () => {
|
|
190
|
+
// Only reveal if this image is still the requested one.
|
|
191
|
+
if (img.src === targetSrc) {
|
|
192
|
+
img.style.visibility = "visible"
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (img.complete) {
|
|
197
|
+
reveal()
|
|
198
|
+
} else {
|
|
199
|
+
img.decode().then(reveal, reveal)
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
img.style.visibility = "visible"
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!s.visible) {
|
|
206
|
+
// Materialize at the cursor instead of flying in from the
|
|
207
|
+
// last resting point.
|
|
208
|
+
s.x = s.targetX
|
|
209
|
+
s.y = s.targetY
|
|
210
|
+
s.tilt = 0
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
s.visible = true
|
|
214
|
+
container.style.opacity = "1"
|
|
215
|
+
img.style.transform = "scale(1)"
|
|
216
|
+
|
|
217
|
+
cancelAnimationFrame(s.frame)
|
|
218
|
+
s.frame = requestAnimationFrame(step)
|
|
219
|
+
},
|
|
220
|
+
hide() {
|
|
221
|
+
const s = state.current
|
|
222
|
+
const container = containerRef.current
|
|
223
|
+
const img = imageRef.current
|
|
224
|
+
const video = videoRef.current
|
|
225
|
+
if (!container || !img) return
|
|
226
|
+
|
|
227
|
+
s.visible = false
|
|
228
|
+
container.style.opacity = "0"
|
|
229
|
+
img.style.transform = "scale(0.94)"
|
|
230
|
+
if (video) {
|
|
231
|
+
video.pause()
|
|
232
|
+
video.style.transform = "scale(0.94)"
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
}),
|
|
236
|
+
[step],
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<LifelineHoverImageContext.Provider value={api}>
|
|
241
|
+
{children}
|
|
242
|
+
<div
|
|
243
|
+
ref={containerRef}
|
|
244
|
+
aria-hidden="true"
|
|
245
|
+
className="pointer-events-none fixed left-0 top-0 z-[60] opacity-0 transition-opacity duration-200 ease-out will-change-transform"
|
|
246
|
+
>
|
|
247
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
248
|
+
<img
|
|
249
|
+
ref={imageRef}
|
|
250
|
+
alt=""
|
|
251
|
+
className="w-[280px] scale-95 rounded-xl shadow-2xl ring-1 ring-border-primary transition-[transform,box-shadow] duration-200 ease-out"
|
|
252
|
+
decoding="async"
|
|
253
|
+
/>
|
|
254
|
+
<video
|
|
255
|
+
ref={videoRef}
|
|
256
|
+
muted
|
|
257
|
+
loop
|
|
258
|
+
playsInline
|
|
259
|
+
preload="none"
|
|
260
|
+
// Landscape fills the same 280px card as images; portrait is
|
|
261
|
+
// capped by height instead, matching the floating cards'
|
|
262
|
+
// scale (180x320) so tall clips don't tower over the cursor.
|
|
263
|
+
className="max-h-[320px] w-auto max-w-[280px] scale-95 rounded-xl shadow-2xl ring-1 ring-border-primary transition-[transform,box-shadow] duration-200 ease-out"
|
|
264
|
+
style={{ display: "none" }}
|
|
265
|
+
/>
|
|
266
|
+
</div>
|
|
267
|
+
</LifelineHoverImageContext.Provider>
|
|
268
|
+
)
|
|
269
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { SVGProps } from "react"
|
|
2
|
+
|
|
3
|
+
type IconProps = SVGProps<SVGSVGElement>
|
|
4
|
+
|
|
5
|
+
const iconProps = {
|
|
6
|
+
viewBox: "0 0 24 24",
|
|
7
|
+
fill: "none",
|
|
8
|
+
stroke: "currentColor",
|
|
9
|
+
strokeLinecap: "round" as const,
|
|
10
|
+
strokeLinejoin: "round" as const,
|
|
11
|
+
"aria-hidden": true as const,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const LifelineFilmIcon = ({
|
|
15
|
+
strokeWidth = 1.75,
|
|
16
|
+
...props
|
|
17
|
+
}: IconProps) => (
|
|
18
|
+
<svg {...iconProps} strokeWidth={strokeWidth} {...props}>
|
|
19
|
+
<rect width="18" height="18" x="3" y="3" rx="2" />
|
|
20
|
+
<path d="M7 3v18" />
|
|
21
|
+
<path d="M3 7.5h4" />
|
|
22
|
+
<path d="M3 12h18" />
|
|
23
|
+
<path d="M3 16.5h4" />
|
|
24
|
+
<path d="M17 3v18" />
|
|
25
|
+
<path d="M17 7.5h4" />
|
|
26
|
+
<path d="M17 16.5h4" />
|
|
27
|
+
</svg>
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
export const LifelineImageIcon = ({
|
|
31
|
+
strokeWidth = 1.75,
|
|
32
|
+
...props
|
|
33
|
+
}: IconProps) => (
|
|
34
|
+
<svg {...iconProps} strokeWidth={strokeWidth} {...props}>
|
|
35
|
+
<rect width="18" height="18" x="3" y="3" rx="2" />
|
|
36
|
+
<circle cx="9" cy="9" r="2" />
|
|
37
|
+
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
|
38
|
+
</svg>
|
|
39
|
+
)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { clamp } from "./lifeline-utils"
|
|
2
|
+
|
|
3
|
+
/** Tweak these */
|
|
4
|
+
export const LIFELINE_SLOW_YEARS = 5
|
|
5
|
+
export const LIFELINE_SLOW_TIME_RATIO = 0.38
|
|
6
|
+
export const LIFELINE_SLOW_MARKER_FADE_MS = 720
|
|
7
|
+
export const LIFELINE_FAST_MARKER_FADE_MS = 280
|
|
8
|
+
/** Set > 0 to override auto-calibrated ease power */
|
|
9
|
+
export const LIFELINE_EASE_POWER = 0
|
|
10
|
+
|
|
11
|
+
function smoothstep(value: number) {
|
|
12
|
+
const t = clamp(value, 0, 1)
|
|
13
|
+
return t * t * (3 - 2 * t)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getSlowTrackWidth(widths: number[]) {
|
|
17
|
+
return widths
|
|
18
|
+
.slice(0, LIFELINE_SLOW_YEARS)
|
|
19
|
+
.reduce((sum, width) => sum + width, 0)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getSlowTrackPortion(widths: number[]) {
|
|
23
|
+
const total = widths.reduce((sum, width) => sum + width, 0)
|
|
24
|
+
if (total <= 0) return 0
|
|
25
|
+
return getSlowTrackWidth(widths) / total
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getEasePower(widths: number[]) {
|
|
29
|
+
if (LIFELINE_EASE_POWER > 0) return LIFELINE_EASE_POWER
|
|
30
|
+
|
|
31
|
+
const slowPortion = getSlowTrackPortion(widths)
|
|
32
|
+
const softenedPivot = smoothstep(LIFELINE_SLOW_TIME_RATIO)
|
|
33
|
+
|
|
34
|
+
if (slowPortion <= 0 || softenedPivot <= 0 || softenedPivot >= 1) {
|
|
35
|
+
return 2.6
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return Math.log(slowPortion) / Math.log(softenedPivot)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function softenedTime(elapsedMs: number, railMs: number) {
|
|
42
|
+
return smoothstep(elapsedMs / railMs)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function trackProgressAtTime(
|
|
46
|
+
elapsedMs: number,
|
|
47
|
+
widths: number[],
|
|
48
|
+
railMs: number,
|
|
49
|
+
) {
|
|
50
|
+
if (railMs <= 0) return 0
|
|
51
|
+
|
|
52
|
+
const t = softenedTime(elapsedMs, railMs)
|
|
53
|
+
const power = getEasePower(widths)
|
|
54
|
+
|
|
55
|
+
return clamp(Math.pow(t, power), 0, 1)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function timeAtTrackProgress(
|
|
59
|
+
progress: number,
|
|
60
|
+
widths: number[],
|
|
61
|
+
railMs: number,
|
|
62
|
+
) {
|
|
63
|
+
const clamped = clamp(progress, 0, 1)
|
|
64
|
+
if (clamped <= 0) return 0
|
|
65
|
+
if (clamped >= 1) return railMs
|
|
66
|
+
|
|
67
|
+
const power = getEasePower(widths)
|
|
68
|
+
const softened = Math.pow(clamped, 1 / power)
|
|
69
|
+
|
|
70
|
+
return invertSmoothstep(softened) * railMs
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function invertSmoothstep(value: number) {
|
|
74
|
+
const target = clamp(value, 0, 1)
|
|
75
|
+
let lo = 0
|
|
76
|
+
let hi = 1
|
|
77
|
+
|
|
78
|
+
for (let i = 0; i < 20; i++) {
|
|
79
|
+
const mid = (lo + hi) / 2
|
|
80
|
+
if (smoothstep(mid) < target) lo = mid
|
|
81
|
+
else hi = mid
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (lo + hi) / 2
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function isSlowIntroYear(index: number) {
|
|
88
|
+
return index < LIFELINE_SLOW_YEARS
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getTransitionMarkerFadeDuration(index: number) {
|
|
92
|
+
if (index < LIFELINE_SLOW_YEARS) return LIFELINE_SLOW_MARKER_FADE_MS
|
|
93
|
+
|
|
94
|
+
const rampYears = 3
|
|
95
|
+
const rampIndex = index - LIFELINE_SLOW_YEARS
|
|
96
|
+
if (rampIndex >= rampYears) return LIFELINE_FAST_MARKER_FADE_MS
|
|
97
|
+
|
|
98
|
+
const t = (rampIndex + 1) / rampYears
|
|
99
|
+
const blend = smoothstep(t)
|
|
100
|
+
|
|
101
|
+
return Math.round(
|
|
102
|
+
LIFELINE_SLOW_MARKER_FADE_MS +
|
|
103
|
+
(LIFELINE_FAST_MARKER_FADE_MS - LIFELINE_SLOW_MARKER_FADE_MS) * blend,
|
|
104
|
+
)
|
|
105
|
+
}
|