@boyernick/standard-ui-react 0.1.1-canary.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/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 +27 -12
- 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 +238 -18
- package/src/input.tsx +8 -5
- package/src/kbd.tsx +10 -4
- 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,1011 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"
|
|
4
|
+
import {
|
|
5
|
+
LIFELINE_STICKY_LEFT,
|
|
6
|
+
LIFELINE_STICKY_SHIELD_WIDTH,
|
|
7
|
+
} from "./lifeline-labels"
|
|
8
|
+
import { clamp, snapToDevicePixel } from "./lifeline-utils"
|
|
9
|
+
import type { LifelineMode } from "./types"
|
|
10
|
+
|
|
11
|
+
const FADE_ZONE = 200
|
|
12
|
+
const FADE_ZONE_COARSE = 72
|
|
13
|
+
const LEFT_EXIT_FADE_ZONE = 400
|
|
14
|
+
const LEFT_EXIT_FADE_ZONE_COARSE = 160
|
|
15
|
+
const WHEEL_SPEED = 1.4
|
|
16
|
+
const WHEEL_VELOCITY_FRAME_MS = 16.67
|
|
17
|
+
const WHEEL_MOMENTUM_BLEND = 0.65
|
|
18
|
+
const DRAG_SPEED = 1
|
|
19
|
+
const TOUCH_DRAG_SPEED = 1.15
|
|
20
|
+
const TOUCH_GESTURE_LOCK_PX = 8
|
|
21
|
+
const NAV_HORIZONTAL_PADDING = 24
|
|
22
|
+
const MOMENTUM_FRICTION = 0.94
|
|
23
|
+
const MOMENTUM_MIN_VELOCITY = 0.025
|
|
24
|
+
const MOMENTUM_MIN_START = 0.08
|
|
25
|
+
|
|
26
|
+
/** Where the track starts when there is no host nav to align with. */
|
|
27
|
+
const LIFELINE_DEFAULT_START_INSET = 24
|
|
28
|
+
|
|
29
|
+
/** Page mode needs the stage to cover at least this much of the viewport. */
|
|
30
|
+
const PAGE_MODE_VIEWPORT_COVERAGE = 0.5
|
|
31
|
+
/** Scroll heights within this many px of the client height aren't scrollable. */
|
|
32
|
+
const PAGE_MODE_SCROLL_SLOP = 4
|
|
33
|
+
/** How long a resolved mode is trusted before it is measured again. */
|
|
34
|
+
const MODE_RESOLVE_STALE_MS = 250
|
|
35
|
+
|
|
36
|
+
/** A gap this long in the wheel stream ends one gesture and starts the next. */
|
|
37
|
+
const WHEEL_GESTURE_QUIET_MS = 120
|
|
38
|
+
/**
|
|
39
|
+
* Embedded: once the rail bottoms out, the wheel keeps being swallowed
|
|
40
|
+
* until the stream has been quiet this long. Trackpad inertia arrives as
|
|
41
|
+
* one unbroken stream, so a fast flick that eats the last of the rail
|
|
42
|
+
* dies here instead of spilling into the page behind it.
|
|
43
|
+
*/
|
|
44
|
+
const EMBED_BOUNDARY_QUIET_MS = 260
|
|
45
|
+
/**
|
|
46
|
+
* …but never hold longer than this. Inertia decays; a finger or a wheel
|
|
47
|
+
* that is still going does not, so a sustained scroll gets through.
|
|
48
|
+
*/
|
|
49
|
+
const EMBED_BOUNDARY_MAX_HOLD_MS = 900
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* How early the sweep is armed, relative to the module entering view. A
|
|
53
|
+
* couple of hundred pixels means it is already moving by the time it is
|
|
54
|
+
* properly on screen, rather than starting cold under the reader's eyes.
|
|
55
|
+
*/
|
|
56
|
+
const EMBED_INTRO_ARM_MARGIN = "0px 0px 200px 0px"
|
|
57
|
+
|
|
58
|
+
function normalizeWheelDelta(event: WheelEvent) {
|
|
59
|
+
let delta = Math.abs(event.deltaX) > Math.abs(event.deltaY)
|
|
60
|
+
? event.deltaX
|
|
61
|
+
: event.deltaY
|
|
62
|
+
|
|
63
|
+
if (event.deltaMode === 1) delta *= 16
|
|
64
|
+
if (event.deltaMode === 2) delta *= window.innerHeight
|
|
65
|
+
|
|
66
|
+
return delta
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isInteractiveTarget(target: EventTarget | null) {
|
|
70
|
+
// Pointer capture during drag retargets clicks to the section, so
|
|
71
|
+
// anything clickable must opt out of drag-start here.
|
|
72
|
+
return (
|
|
73
|
+
target instanceof Element &&
|
|
74
|
+
Boolean(target.closest("a, button, [data-lifeline-interactive]"))
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isEditableTarget(target: EventTarget | null) {
|
|
79
|
+
return (
|
|
80
|
+
target instanceof Element &&
|
|
81
|
+
Boolean(target.closest("input, textarea, select, [contenteditable]"))
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** How much of the viewport this element currently covers, 0..1. */
|
|
86
|
+
function getViewportCoverage(element: HTMLElement) {
|
|
87
|
+
const rect = element.getBoundingClientRect()
|
|
88
|
+
const visibleX =
|
|
89
|
+
Math.min(rect.right, window.innerWidth) - Math.max(rect.left, 0)
|
|
90
|
+
const visibleY =
|
|
91
|
+
Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0)
|
|
92
|
+
if (visibleX <= 0 || visibleY <= 0) return 0
|
|
93
|
+
|
|
94
|
+
return (visibleX * visibleY) / (window.innerWidth * window.innerHeight)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Is there a vertical scroll behind this element for a released wheel to
|
|
99
|
+
* drive? Walks out to the document, stopping at the first ancestor that
|
|
100
|
+
* clips — a `LifelineShell` is `overflow-hidden`, so nothing escapes it.
|
|
101
|
+
*/
|
|
102
|
+
function hasReleasableScroll(section: HTMLElement) {
|
|
103
|
+
let node = section.parentElement
|
|
104
|
+
|
|
105
|
+
while (node) {
|
|
106
|
+
const { overflowY } = window.getComputedStyle(node)
|
|
107
|
+
|
|
108
|
+
if (overflowY === "hidden" || overflowY === "clip") return false
|
|
109
|
+
|
|
110
|
+
if (
|
|
111
|
+
(overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") &&
|
|
112
|
+
node.scrollHeight > node.clientHeight + PAGE_MODE_SCROLL_SLOP
|
|
113
|
+
) {
|
|
114
|
+
return true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
node = node.parentElement
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const doc = document.scrollingElement
|
|
121
|
+
return Boolean(doc && doc.scrollHeight > doc.clientHeight + PAGE_MODE_SCROLL_SLOP)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface LifelineScrollOptions {
|
|
125
|
+
mode?: LifelineMode
|
|
126
|
+
isCoarsePointer?: boolean
|
|
127
|
+
introLocked?: boolean
|
|
128
|
+
introAnimating?: boolean
|
|
129
|
+
introSkipped?: boolean
|
|
130
|
+
introRailMs?: number
|
|
131
|
+
introGetTrackProgress?: (elapsedMs: number) => number
|
|
132
|
+
onIntroSettleComplete?: () => void
|
|
133
|
+
onIntroScrollStart?: () => void
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function useLifelineScroll(
|
|
137
|
+
markerCount: number,
|
|
138
|
+
options: LifelineScrollOptions = {},
|
|
139
|
+
) {
|
|
140
|
+
const sectionRef = useRef<HTMLDivElement>(null)
|
|
141
|
+
const trackRef = useRef<HTMLDivElement>(null)
|
|
142
|
+
const labelsRef = useRef<HTMLDivElement>(null)
|
|
143
|
+
const markerRefs = useRef<(HTMLDivElement | null)[]>([])
|
|
144
|
+
const maxTranslate = useRef(0)
|
|
145
|
+
const startInset = useRef(LIFELINE_DEFAULT_START_INSET)
|
|
146
|
+
const endInset = useRef(0)
|
|
147
|
+
const translatePx = useRef(0)
|
|
148
|
+
const initialized = useRef(false)
|
|
149
|
+
const dragging = useRef(false)
|
|
150
|
+
const gestureAxis = useRef<"x" | "y" | null>(null)
|
|
151
|
+
const gestureStart = useRef({ x: 0, y: 0 })
|
|
152
|
+
const dragOrigin = useRef({ x: 0, translate: 0 })
|
|
153
|
+
const dragVelocity = useRef(0)
|
|
154
|
+
const lastPointerSample = useRef({ x: 0, t: 0 })
|
|
155
|
+
const activePointerId = useRef<number | null>(null)
|
|
156
|
+
const isCoarsePointerRef = useRef(options.isCoarsePointer ?? false)
|
|
157
|
+
const momentumId = useRef(0)
|
|
158
|
+
const settleId = useRef(0)
|
|
159
|
+
const introLockedRef = useRef(options.introLocked ?? false)
|
|
160
|
+
const introAnimatingRef = useRef(options.introAnimating ?? false)
|
|
161
|
+
const introSkippedRef = useRef(options.introSkipped ?? false)
|
|
162
|
+
const onIntroSettleCompleteRef = useRef(options.onIntroSettleComplete)
|
|
163
|
+
const onIntroScrollStartRef = useRef(options.onIntroScrollStart)
|
|
164
|
+
const settlingRef = useRef(false)
|
|
165
|
+
const introWasAnimatingRef = useRef(false)
|
|
166
|
+
const introScrollId = useRef(0)
|
|
167
|
+
const introScrollStart = useRef(0)
|
|
168
|
+
const introStartedRef = useRef(false)
|
|
169
|
+
const introGetTrackProgressRef = useRef(options.introGetTrackProgress)
|
|
170
|
+
const applyTranslateRef = useRef<(value: number) => void>(() => {})
|
|
171
|
+
const scheduleMeasureRef = useRef<() => void>(() => {})
|
|
172
|
+
const modeRef = useRef<LifelineMode>(options.mode ?? "auto")
|
|
173
|
+
const isEmbedRef = useRef(false)
|
|
174
|
+
const modeResolvedAt = useRef(0)
|
|
175
|
+
const prefersReducedMotionRef = useRef(false)
|
|
176
|
+
const wheelStreamAt = useRef(0)
|
|
177
|
+
const wheelGapMs = useRef(Number.POSITIVE_INFINITY)
|
|
178
|
+
const gestureStartedHere = useRef(false)
|
|
179
|
+
const gestureReleased = useRef(false)
|
|
180
|
+
const boundaryHitAt = useRef(0)
|
|
181
|
+
const [isLayoutReady, setIsLayoutReady] = useState(false)
|
|
182
|
+
const [isEmbed, setIsEmbed] = useState(false)
|
|
183
|
+
const [introArmed, setIntroArmed] = useState(false)
|
|
184
|
+
|
|
185
|
+
modeRef.current = options.mode ?? "auto"
|
|
186
|
+
isCoarsePointerRef.current = options.isCoarsePointer ?? false
|
|
187
|
+
introLockedRef.current = options.introLocked ?? false
|
|
188
|
+
introAnimatingRef.current = options.introAnimating ?? false
|
|
189
|
+
introSkippedRef.current = options.introSkipped ?? false
|
|
190
|
+
onIntroSettleCompleteRef.current = options.onIntroSettleComplete
|
|
191
|
+
onIntroScrollStartRef.current = options.onIntroScrollStart
|
|
192
|
+
introGetTrackProgressRef.current = options.introGetTrackProgress
|
|
193
|
+
|
|
194
|
+
const setMarkerRef = useCallback(
|
|
195
|
+
(index: number, node: HTMLDivElement | null) => {
|
|
196
|
+
markerRefs.current[index] = node
|
|
197
|
+
|
|
198
|
+
if (index === markerCount - 1 && node) {
|
|
199
|
+
scheduleMeasureRef.current()
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
[markerCount],
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
const getLabelStageLeft = useCallback((translate: number) => {
|
|
206
|
+
const section = sectionRef.current
|
|
207
|
+
if (!section) return { isSticky: false, labelLeft: 0 }
|
|
208
|
+
|
|
209
|
+
// Stage-relative: labels pin LIFELINE_STICKY_LEFT px inside the
|
|
210
|
+
// section's own left edge, wherever the section sits on the page.
|
|
211
|
+
const naturalLeft = startInset.current - translate
|
|
212
|
+
const isSticky = naturalLeft <= LIFELINE_STICKY_LEFT
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
isSticky,
|
|
216
|
+
labelLeft: isSticky ? LIFELINE_STICKY_LEFT : naturalLeft,
|
|
217
|
+
}
|
|
218
|
+
}, [])
|
|
219
|
+
|
|
220
|
+
const applyLabelSticky = useCallback(
|
|
221
|
+
(translate: number) => {
|
|
222
|
+
const labels = labelsRef.current
|
|
223
|
+
const { isSticky, labelLeft } = getLabelStageLeft(translate)
|
|
224
|
+
|
|
225
|
+
if (!labels) return { isSticky, labelLeft }
|
|
226
|
+
|
|
227
|
+
if (isSticky) {
|
|
228
|
+
// Derived from the track's snapped offset so the two transforms
|
|
229
|
+
// cancel to exactly LIFELINE_STICKY_LEFT — the pinned labels
|
|
230
|
+
// must not drift a fraction against the snapped track.
|
|
231
|
+
const labelExtra =
|
|
232
|
+
LIFELINE_STICKY_LEFT -
|
|
233
|
+
snapToDevicePixel(startInset.current - translate)
|
|
234
|
+
labels.style.transform = `translate3d(${labelExtra}px, 0, 0)`
|
|
235
|
+
labels.classList.add("is-pinned")
|
|
236
|
+
} else {
|
|
237
|
+
labels.style.transform = ""
|
|
238
|
+
labels.classList.remove("is-pinned")
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { isSticky, labelLeft }
|
|
242
|
+
},
|
|
243
|
+
[getLabelStageLeft],
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
const isScrollLocked = useCallback(() => {
|
|
247
|
+
return (
|
|
248
|
+
(introLockedRef.current && introStartedRef.current) ||
|
|
249
|
+
settlingRef.current
|
|
250
|
+
)
|
|
251
|
+
}, [])
|
|
252
|
+
|
|
253
|
+
const updateFades = useCallback(() => {
|
|
254
|
+
if (settlingRef.current) return
|
|
255
|
+
|
|
256
|
+
const section = sectionRef.current
|
|
257
|
+
if (!section) return
|
|
258
|
+
|
|
259
|
+
// All fade math is relative to the section's own box — the lifeline
|
|
260
|
+
// may be embedded anywhere, not pinned to the viewport.
|
|
261
|
+
const stageRect = section.getBoundingClientRect()
|
|
262
|
+
const isCoarse = isCoarsePointerRef.current
|
|
263
|
+
const fadeZone = isCoarse ? FADE_ZONE_COARSE : FADE_ZONE
|
|
264
|
+
const leftFadeZone = isCoarse
|
|
265
|
+
? LEFT_EXIT_FADE_ZONE_COARSE
|
|
266
|
+
: LEFT_EXIT_FADE_ZONE
|
|
267
|
+
|
|
268
|
+
markerRefs.current.forEach((marker) => {
|
|
269
|
+
if (!marker) return
|
|
270
|
+
|
|
271
|
+
const rect = marker.getBoundingClientRect()
|
|
272
|
+
const markerLeft = rect.left - stageRect.left
|
|
273
|
+
const center = markerLeft + rect.width / 2
|
|
274
|
+
|
|
275
|
+
let opacity = 1
|
|
276
|
+
|
|
277
|
+
// Fade a marker out only as scrubbing carries it left of where
|
|
278
|
+
// it rests at translate 0 — the first markers naturally live
|
|
279
|
+
// inside the fade zone and must not open dimmed.
|
|
280
|
+
const naturalLeft = markerLeft + translatePx.current
|
|
281
|
+
const restLeft = Math.min(naturalLeft, leftFadeZone)
|
|
282
|
+
if (markerLeft < restLeft) {
|
|
283
|
+
opacity = markerLeft <= 0 ? 0 : markerLeft / restLeft
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (center > stageRect.width - fadeZone) {
|
|
287
|
+
opacity = Math.min(opacity, (stageRect.width - center) / fadeZone)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (isCoarse) {
|
|
291
|
+
const readableLeft = LIFELINE_STICKY_SHIELD_WIDTH
|
|
292
|
+
const readableRight = stageRect.width - 12
|
|
293
|
+
const markerRight = rect.right - stageRect.left
|
|
294
|
+
const visibleWidth =
|
|
295
|
+
Math.min(markerRight, readableRight) - Math.max(markerLeft, readableLeft)
|
|
296
|
+
const visibility = rect.width > 0 ? visibleWidth / rect.width : 0
|
|
297
|
+
|
|
298
|
+
if (visibility >= 0.5) {
|
|
299
|
+
opacity = 1
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
marker.style.opacity = String(clamp(opacity, 0, 1))
|
|
304
|
+
})
|
|
305
|
+
}, [])
|
|
306
|
+
|
|
307
|
+
const applyTranslate = useCallback(
|
|
308
|
+
(value: number) => {
|
|
309
|
+
const max = maxTranslate.current
|
|
310
|
+
const next = clamp(value, 0, max)
|
|
311
|
+
translatePx.current = next
|
|
312
|
+
|
|
313
|
+
if (trackRef.current) {
|
|
314
|
+
// Snapped only at the DOM boundary — translatePx stays float so
|
|
315
|
+
// wheel/drag/settle physics never accumulate rounding.
|
|
316
|
+
trackRef.current.style.transform = `translate3d(${snapToDevicePixel(
|
|
317
|
+
startInset.current - next,
|
|
318
|
+
)}px, 0, 0)`
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
applyLabelSticky(next)
|
|
322
|
+
updateFades()
|
|
323
|
+
},
|
|
324
|
+
[applyLabelSticky, updateFades],
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
applyTranslateRef.current = applyTranslate
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Page mode or embedded? An explicit `mode` decides it outright.
|
|
331
|
+
* `"auto"` measures: the timeline is the page only when it covers most
|
|
332
|
+
* of the viewport *and* there is nothing behind it left to scroll.
|
|
333
|
+
* Both halves matter — a full-bleed hero section on a long landing page
|
|
334
|
+
* covers the viewport but must still hand the wheel back at the ends.
|
|
335
|
+
*
|
|
336
|
+
* Cached for MODE_RESOLVE_STALE_MS because this reads layout and the
|
|
337
|
+
* wheel path calls it. `force` is for measure passes, which are already
|
|
338
|
+
* doing layout work.
|
|
339
|
+
*/
|
|
340
|
+
const resolveMode = useCallback((force = false) => {
|
|
341
|
+
const section = sectionRef.current
|
|
342
|
+
if (!section) return isEmbedRef.current
|
|
343
|
+
|
|
344
|
+
const mode = modeRef.current
|
|
345
|
+
|
|
346
|
+
if (mode !== "auto") {
|
|
347
|
+
const embed = mode === "embed"
|
|
348
|
+
if (embed !== isEmbedRef.current) {
|
|
349
|
+
isEmbedRef.current = embed
|
|
350
|
+
setIsEmbed(embed)
|
|
351
|
+
}
|
|
352
|
+
return embed
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const now = performance.now()
|
|
356
|
+
if (!force && now - modeResolvedAt.current < MODE_RESOLVE_STALE_MS) {
|
|
357
|
+
return isEmbedRef.current
|
|
358
|
+
}
|
|
359
|
+
modeResolvedAt.current = now
|
|
360
|
+
|
|
361
|
+
const isPage =
|
|
362
|
+
getViewportCoverage(section) >= PAGE_MODE_VIEWPORT_COVERAGE &&
|
|
363
|
+
!hasReleasableScroll(section)
|
|
364
|
+
|
|
365
|
+
if (isPage === !isEmbedRef.current) return isEmbedRef.current
|
|
366
|
+
|
|
367
|
+
isEmbedRef.current = !isPage
|
|
368
|
+
setIsEmbed(!isPage)
|
|
369
|
+
return isEmbedRef.current
|
|
370
|
+
}, [])
|
|
371
|
+
|
|
372
|
+
const measureLayout = useCallback(() => {
|
|
373
|
+
const track = trackRef.current
|
|
374
|
+
const section = sectionRef.current
|
|
375
|
+
if (!track || !section) return 0
|
|
376
|
+
|
|
377
|
+
const embed = resolveMode(true)
|
|
378
|
+
const stageRect = section.getBoundingClientRect()
|
|
379
|
+
|
|
380
|
+
const navLogo = document.querySelector("[data-site-nav-logo]")
|
|
381
|
+
const navInner = document.querySelector("[data-site-nav-inner]")
|
|
382
|
+
|
|
383
|
+
const logoLeft = navLogo
|
|
384
|
+
? navLogo.getBoundingClientRect().left - stageRect.left
|
|
385
|
+
: null
|
|
386
|
+
const navRight = navInner
|
|
387
|
+
? navInner.getBoundingClientRect().right -
|
|
388
|
+
stageRect.left -
|
|
389
|
+
NAV_HORIZONTAL_PADDING
|
|
390
|
+
: null
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* A full-page lifeline always follows the host chrome. An embedded one
|
|
394
|
+
* follows it only when the module actually spans it — a full-bleed
|
|
395
|
+
* module lines its rail up with the logo and the container's right edge,
|
|
396
|
+
* exactly as the full-page version does, while a timeline in a narrow
|
|
397
|
+
* card has nothing to align to a nav sitting outside its own box and
|
|
398
|
+
* measures itself instead.
|
|
399
|
+
*/
|
|
400
|
+
const followChrome =
|
|
401
|
+
!embed ||
|
|
402
|
+
(logoLeft !== null &&
|
|
403
|
+
navRight !== null &&
|
|
404
|
+
logoLeft >= 0 &&
|
|
405
|
+
navRight <= stageRect.width &&
|
|
406
|
+
navRight > logoLeft)
|
|
407
|
+
|
|
408
|
+
if (followChrome && logoLeft !== null) {
|
|
409
|
+
startInset.current = logoLeft
|
|
410
|
+
} else {
|
|
411
|
+
startInset.current = LIFELINE_DEFAULT_START_INSET
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (followChrome && navRight !== null) {
|
|
415
|
+
endInset.current = navRight
|
|
416
|
+
} else {
|
|
417
|
+
// No chrome to align with, or none this module spans — end the track
|
|
418
|
+
// at the stage's own right edge instead.
|
|
419
|
+
endInset.current = stageRect.width - NAV_HORIZONTAL_PADDING
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const lastMarker = markerRefs.current[markerCount - 1]
|
|
423
|
+
const lastMarkerRight = lastMarker
|
|
424
|
+
? LIFELINE_STICKY_SHIELD_WIDTH +
|
|
425
|
+
lastMarker.offsetLeft +
|
|
426
|
+
lastMarker.offsetWidth
|
|
427
|
+
: track.scrollWidth
|
|
428
|
+
|
|
429
|
+
const max = Math.max(
|
|
430
|
+
0,
|
|
431
|
+
startInset.current + lastMarkerRight - endInset.current,
|
|
432
|
+
)
|
|
433
|
+
maxTranslate.current = max
|
|
434
|
+
|
|
435
|
+
return max
|
|
436
|
+
}, [markerCount, resolveMode])
|
|
437
|
+
|
|
438
|
+
useLayoutEffect(() => {
|
|
439
|
+
markerRefs.current.length = markerCount
|
|
440
|
+
}, [markerCount])
|
|
441
|
+
|
|
442
|
+
useLayoutEffect(() => {
|
|
443
|
+
// A timeline short enough to fit its stage measures max = 0. It still has
|
|
444
|
+
// to be shown — gating readiness on a scrollable track left any small
|
|
445
|
+
// timeline permanently `invisible`.
|
|
446
|
+
const max = measureLayout()
|
|
447
|
+
|
|
448
|
+
if (!initialized.current) {
|
|
449
|
+
// A skipped intro parks the rail where the intro would have settled
|
|
450
|
+
// it — its end, the present. Embedded is no different: it is the same
|
|
451
|
+
// intro and the same resting place.
|
|
452
|
+
translatePx.current = introSkippedRef.current ? max : 0
|
|
453
|
+
initialized.current = true
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
applyTranslate(translatePx.current)
|
|
457
|
+
setIsLayoutReady(true)
|
|
458
|
+
// Sync initial position once before first paint; resize uses measure().
|
|
459
|
+
}, [])
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* A full-page lifeline opens as the page opens, so its intro needs no
|
|
463
|
+
* cue. An embedded one can be anywhere, including far below the fold —
|
|
464
|
+
* playing there would spend the sweep on nobody. So it waits until the
|
|
465
|
+
* module is about to come into view.
|
|
466
|
+
*/
|
|
467
|
+
useEffect(() => {
|
|
468
|
+
if (!isEmbed) return
|
|
469
|
+
|
|
470
|
+
const section = sectionRef.current
|
|
471
|
+
if (!section || introArmed) return
|
|
472
|
+
|
|
473
|
+
const observer = new IntersectionObserver(
|
|
474
|
+
(entries) => {
|
|
475
|
+
if (!entries.some((entry) => entry.isIntersecting)) return
|
|
476
|
+
setIntroArmed(true)
|
|
477
|
+
observer.disconnect()
|
|
478
|
+
},
|
|
479
|
+
{ rootMargin: EMBED_INTRO_ARM_MARGIN },
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
observer.observe(section)
|
|
483
|
+
return () => observer.disconnect()
|
|
484
|
+
}, [isEmbed, introArmed])
|
|
485
|
+
|
|
486
|
+
useEffect(() => {
|
|
487
|
+
if (!isLayoutReady) return
|
|
488
|
+
// Embedded, hold the sweep until the module is in view. `isLayoutReady`
|
|
489
|
+
// and `isEmbed` are set in the same commit, so by the time this runs for
|
|
490
|
+
// real the mode is already known and an embedded instance cannot slip
|
|
491
|
+
// through and start early.
|
|
492
|
+
if (isEmbed && !introArmed) return
|
|
493
|
+
if (options.introSkipped || !options.introAnimating) {
|
|
494
|
+
cancelAnimationFrame(introScrollId.current)
|
|
495
|
+
introScrollId.current = 0
|
|
496
|
+
introStartedRef.current = false
|
|
497
|
+
return
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
introWasAnimatingRef.current = true
|
|
501
|
+
const railMs = options.introRailMs ?? 3200
|
|
502
|
+
|
|
503
|
+
const step = (now: number) => {
|
|
504
|
+
const max = maxTranslate.current
|
|
505
|
+
|
|
506
|
+
if (max <= 0) {
|
|
507
|
+
// Nothing to travel: a timeline that fits its stage has no rail to
|
|
508
|
+
// sweep. Waiting for one spun this loop forever and left the intro
|
|
509
|
+
// lock on, so run the intro out where it already is — the markers
|
|
510
|
+
// and labels still get their fade, there is just no journey.
|
|
511
|
+
if (!introStartedRef.current) {
|
|
512
|
+
introStartedRef.current = true
|
|
513
|
+
introScrollStart.current = now
|
|
514
|
+
onIntroScrollStartRef.current?.()
|
|
515
|
+
}
|
|
516
|
+
sectionRef.current?.style.setProperty("--lifeline-intro-progress", "1")
|
|
517
|
+
introScrollId.current = 0
|
|
518
|
+
return
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (!introStartedRef.current) {
|
|
522
|
+
introStartedRef.current = true
|
|
523
|
+
introScrollStart.current = now
|
|
524
|
+
onIntroScrollStartRef.current?.()
|
|
525
|
+
sectionRef.current?.style.setProperty("--lifeline-intro-progress", "0")
|
|
526
|
+
applyTranslateRef.current(0)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const elapsed = now - introScrollStart.current
|
|
530
|
+
const progress = introGetTrackProgressRef.current
|
|
531
|
+
? clamp(introGetTrackProgressRef.current(elapsed), 0, 1)
|
|
532
|
+
: clamp(elapsed / railMs, 0, 1)
|
|
533
|
+
|
|
534
|
+
sectionRef.current?.style.setProperty(
|
|
535
|
+
"--lifeline-intro-progress",
|
|
536
|
+
String(progress),
|
|
537
|
+
)
|
|
538
|
+
applyTranslateRef.current(progress * max)
|
|
539
|
+
|
|
540
|
+
if (progress < 1) {
|
|
541
|
+
introScrollId.current = requestAnimationFrame(step)
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
sectionRef.current?.style.setProperty("--lifeline-intro-progress", "1")
|
|
546
|
+
applyTranslateRef.current(max)
|
|
547
|
+
introScrollId.current = 0
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
introScrollId.current = requestAnimationFrame(step)
|
|
551
|
+
|
|
552
|
+
return () => {
|
|
553
|
+
cancelAnimationFrame(introScrollId.current)
|
|
554
|
+
introScrollId.current = 0
|
|
555
|
+
introStartedRef.current = false
|
|
556
|
+
}
|
|
557
|
+
}, [
|
|
558
|
+
introArmed,
|
|
559
|
+
isEmbed,
|
|
560
|
+
isLayoutReady,
|
|
561
|
+
options.introAnimating,
|
|
562
|
+
options.introRailMs,
|
|
563
|
+
options.introSkipped,
|
|
564
|
+
])
|
|
565
|
+
|
|
566
|
+
useEffect(() => {
|
|
567
|
+
if (options.introSkipped) return
|
|
568
|
+
|
|
569
|
+
if (options.introAnimating) return
|
|
570
|
+
|
|
571
|
+
if (!introWasAnimatingRef.current) return
|
|
572
|
+
introWasAnimatingRef.current = false
|
|
573
|
+
|
|
574
|
+
sectionRef.current?.style.removeProperty("--lifeline-intro-progress")
|
|
575
|
+
markerRefs.current.forEach((marker) => {
|
|
576
|
+
if (marker) marker.style.opacity = ""
|
|
577
|
+
})
|
|
578
|
+
updateFades()
|
|
579
|
+
onIntroSettleCompleteRef.current?.()
|
|
580
|
+
}, [
|
|
581
|
+
options.introAnimating,
|
|
582
|
+
options.introSkipped,
|
|
583
|
+
updateFades,
|
|
584
|
+
])
|
|
585
|
+
|
|
586
|
+
useEffect(() => {
|
|
587
|
+
return () => {
|
|
588
|
+
initialized.current = false
|
|
589
|
+
}
|
|
590
|
+
}, [])
|
|
591
|
+
|
|
592
|
+
useEffect(() => {
|
|
593
|
+
const section = sectionRef.current
|
|
594
|
+
if (!section) return
|
|
595
|
+
|
|
596
|
+
let frameId = 0
|
|
597
|
+
let resizeObserver: ResizeObserver | null = null
|
|
598
|
+
|
|
599
|
+
const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)")
|
|
600
|
+
const onMotionChange = () => {
|
|
601
|
+
prefersReducedMotionRef.current = motionQuery.matches
|
|
602
|
+
}
|
|
603
|
+
prefersReducedMotionRef.current = motionQuery.matches
|
|
604
|
+
motionQuery.addEventListener("change", onMotionChange)
|
|
605
|
+
|
|
606
|
+
const stopMomentum = () => {
|
|
607
|
+
cancelAnimationFrame(momentumId.current)
|
|
608
|
+
momentumId.current = 0
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const startMomentum = () => {
|
|
612
|
+
if (Math.abs(dragVelocity.current) < MOMENTUM_MIN_START) return
|
|
613
|
+
|
|
614
|
+
stopMomentum()
|
|
615
|
+
let lastFrameTime = performance.now()
|
|
616
|
+
|
|
617
|
+
const step = (now: number) => {
|
|
618
|
+
const dt = Math.min(now - lastFrameTime, 32)
|
|
619
|
+
lastFrameTime = now
|
|
620
|
+
|
|
621
|
+
const velocity = dragVelocity.current
|
|
622
|
+
if (Math.abs(velocity) < MOMENTUM_MIN_VELOCITY) {
|
|
623
|
+
dragVelocity.current = 0
|
|
624
|
+
momentumId.current = 0
|
|
625
|
+
return
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const max = maxTranslate.current
|
|
629
|
+
const next = clamp(translatePx.current + velocity * dt, 0, max)
|
|
630
|
+
|
|
631
|
+
if (next !== translatePx.current) {
|
|
632
|
+
applyTranslate(next)
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (next <= 0 || next >= max) {
|
|
636
|
+
// Coasting into an end counts as hitting it, so the next wheel
|
|
637
|
+
// event starts its hold from the moment of contact rather than
|
|
638
|
+
// restarting the clock.
|
|
639
|
+
if (isEmbedRef.current && boundaryHitAt.current === 0) {
|
|
640
|
+
boundaryHitAt.current = performance.now()
|
|
641
|
+
}
|
|
642
|
+
dragVelocity.current = 0
|
|
643
|
+
momentumId.current = 0
|
|
644
|
+
return
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
dragVelocity.current =
|
|
648
|
+
velocity * Math.pow(MOMENTUM_FRICTION, dt / 16.67)
|
|
649
|
+
momentumId.current = requestAnimationFrame(step)
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
momentumId.current = requestAnimationFrame(step)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const measure = () => {
|
|
656
|
+
// max === 0 is a legitimate measurement — a timeline that fits — so it
|
|
657
|
+
// must not skip the ready flag either.
|
|
658
|
+
const max = measureLayout()
|
|
659
|
+
|
|
660
|
+
translatePx.current = clamp(translatePx.current, 0, max)
|
|
661
|
+
|
|
662
|
+
// During intro scroll, the rAF loop owns translate — only refresh bounds.
|
|
663
|
+
if (!(introAnimatingRef.current && introStartedRef.current)) {
|
|
664
|
+
applyTranslate(translatePx.current)
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
setIsLayoutReady(true)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const scheduleMeasure = () => {
|
|
671
|
+
cancelAnimationFrame(frameId)
|
|
672
|
+
frameId = requestAnimationFrame(measure)
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
scheduleMeasureRef.current = scheduleMeasure
|
|
676
|
+
|
|
677
|
+
scheduleMeasure()
|
|
678
|
+
frameId = requestAnimationFrame(() => {
|
|
679
|
+
measure()
|
|
680
|
+
requestAnimationFrame(measure)
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
resizeObserver = new ResizeObserver(scheduleMeasure)
|
|
684
|
+
if (trackRef.current) resizeObserver.observe(trackRef.current)
|
|
685
|
+
|
|
686
|
+
window.addEventListener("resize", scheduleMeasure)
|
|
687
|
+
|
|
688
|
+
const scrub = (movement: number, target: number) => {
|
|
689
|
+
applyTranslate(target)
|
|
690
|
+
|
|
691
|
+
const impulse = (movement / WHEEL_VELOCITY_FRAME_MS) * 0.35
|
|
692
|
+
dragVelocity.current =
|
|
693
|
+
dragVelocity.current * (1 - WHEEL_MOMENTUM_BLEND) +
|
|
694
|
+
impulse * WHEEL_MOMENTUM_BLEND
|
|
695
|
+
|
|
696
|
+
// Embedded under reduced motion, skip the coast: inertia is what the
|
|
697
|
+
// preference asks you to drop, and it is also the one thing that
|
|
698
|
+
// makes the release decision non-deterministic.
|
|
699
|
+
if (isEmbedRef.current && prefersReducedMotionRef.current) return
|
|
700
|
+
|
|
701
|
+
if (momentumId.current === 0) {
|
|
702
|
+
startMomentum()
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const release = () => {
|
|
707
|
+
gestureReleased.current = true
|
|
708
|
+
stopMomentum()
|
|
709
|
+
dragVelocity.current = 0
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Segments the wheel stream into gestures, before the section's own
|
|
714
|
+
* handler sees the event — a capture listener on the window runs ahead
|
|
715
|
+
* of the target. The section alone cannot tell a gesture that started
|
|
716
|
+
* on it from a page-scroll gesture that merely arrived on it, because
|
|
717
|
+
* it only sees events once it is under the pointer.
|
|
718
|
+
*/
|
|
719
|
+
const onWheelStream = (event: WheelEvent) => {
|
|
720
|
+
const now = performance.now()
|
|
721
|
+
wheelGapMs.current = now - wheelStreamAt.current
|
|
722
|
+
wheelStreamAt.current = now
|
|
723
|
+
|
|
724
|
+
if (wheelGapMs.current <= WHEEL_GESTURE_QUIET_MS) return
|
|
725
|
+
|
|
726
|
+
gestureStartedHere.current =
|
|
727
|
+
event.target instanceof Node && section.contains(event.target)
|
|
728
|
+
gestureReleased.current = false
|
|
729
|
+
boundaryHitAt.current = 0
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const onWheel = (event: WheelEvent) => {
|
|
733
|
+
if (isScrollLocked()) return
|
|
734
|
+
|
|
735
|
+
if (maxTranslate.current <= 0) {
|
|
736
|
+
scheduleMeasure()
|
|
737
|
+
return
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// A sideways swipe is never a request to scroll the page, so it
|
|
741
|
+
// scrubs at the rail ends too and never releases.
|
|
742
|
+
const horizontalIntent = Math.abs(event.deltaX) > Math.abs(event.deltaY)
|
|
743
|
+
|
|
744
|
+
const delta = normalizeWheelDelta(event)
|
|
745
|
+
/**
|
|
746
|
+
* The two axes are not the same gesture and do not share a sign.
|
|
747
|
+
*
|
|
748
|
+
* Vertical is page scrolling: the intro leaves the rail at the
|
|
749
|
+
* present, and scrolling down walks back through it — the same in both
|
|
750
|
+
* modes. Embedding changes where the wheel *goes* at the ends of the
|
|
751
|
+
* rail, not which way the rail travels.
|
|
752
|
+
*
|
|
753
|
+
* Horizontal is a drag by another name, so it has to match the pointer
|
|
754
|
+
* drag below: the rail follows the fingers. Swiping left (deltaX > 0)
|
|
755
|
+
* carries the timeline left, into the future, exactly as grabbing it
|
|
756
|
+
* and pulling left does.
|
|
757
|
+
*/
|
|
758
|
+
const movement = (horizontalIntent ? delta : -delta) * WHEEL_SPEED
|
|
759
|
+
|
|
760
|
+
if (!resolveMode()) {
|
|
761
|
+
// Page mode: the lifeline is the page and every wheel is ours.
|
|
762
|
+
event.preventDefault()
|
|
763
|
+
scrub(movement, translatePx.current + movement)
|
|
764
|
+
return
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
if (!horizontalIntent) {
|
|
768
|
+
// A gesture already in flight when it reached us belongs to the
|
|
769
|
+
// page: the flick carries past, and the next scroll scrubs.
|
|
770
|
+
if (!gestureStartedHere.current) return
|
|
771
|
+
if (gestureReleased.current) return
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const target = clamp(
|
|
775
|
+
translatePx.current + movement,
|
|
776
|
+
0,
|
|
777
|
+
maxTranslate.current,
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
if (target === translatePx.current && !horizontalIntent) {
|
|
781
|
+
// At an end of the rail, still being pushed further out. Hold the
|
|
782
|
+
// wheel until the stream goes quiet, then hand it to the page.
|
|
783
|
+
const now = performance.now()
|
|
784
|
+
if (boundaryHitAt.current === 0) boundaryHitAt.current = now
|
|
785
|
+
|
|
786
|
+
if (
|
|
787
|
+
wheelGapMs.current < EMBED_BOUNDARY_QUIET_MS &&
|
|
788
|
+
now - boundaryHitAt.current < EMBED_BOUNDARY_MAX_HOLD_MS
|
|
789
|
+
) {
|
|
790
|
+
event.preventDefault()
|
|
791
|
+
stopMomentum()
|
|
792
|
+
dragVelocity.current = 0
|
|
793
|
+
return
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
release()
|
|
797
|
+
return
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Moving again — a reversal re-arms the hold at the other end.
|
|
801
|
+
boundaryHitAt.current = 0
|
|
802
|
+
event.preventDefault()
|
|
803
|
+
scrub(movement, target)
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// The rail moves by transform; any native scroll on the section is the
|
|
807
|
+
// browser chasing a focused link deep in the track, and would leave the
|
|
808
|
+
// transform and the layout disagreeing about where the rail is.
|
|
809
|
+
const onSectionScroll = () => {
|
|
810
|
+
if (section.scrollLeft !== 0) section.scrollLeft = 0
|
|
811
|
+
if (section.scrollTop !== 0) section.scrollTop = 0
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const beginDrag = (event: PointerEvent) => {
|
|
815
|
+
stopMomentum()
|
|
816
|
+
dragVelocity.current = 0
|
|
817
|
+
dragging.current = true
|
|
818
|
+
activePointerId.current = event.pointerId
|
|
819
|
+
dragOrigin.current = { x: event.clientX, translate: translatePx.current }
|
|
820
|
+
lastPointerSample.current = {
|
|
821
|
+
x: event.clientX,
|
|
822
|
+
t: performance.now(),
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
if (section.setPointerCapture) {
|
|
826
|
+
section.setPointerCapture(event.pointerId)
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
section.style.cursor = "grabbing"
|
|
830
|
+
section.style.touchAction = "none"
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
834
|
+
if (isScrollLocked()) return
|
|
835
|
+
if (isInteractiveTarget(event.target)) return
|
|
836
|
+
if (maxTranslate.current <= 0) return
|
|
837
|
+
if (activePointerId.current !== null) return
|
|
838
|
+
|
|
839
|
+
gestureAxis.current = null
|
|
840
|
+
gestureStart.current = { x: event.clientX, y: event.clientY }
|
|
841
|
+
|
|
842
|
+
if (event.pointerType === "touch") {
|
|
843
|
+
activePointerId.current = event.pointerId
|
|
844
|
+
return
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
beginDrag(event)
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const onPointerMove = (event: PointerEvent) => {
|
|
851
|
+
if (activePointerId.current !== null && event.pointerId !== activePointerId.current) {
|
|
852
|
+
return
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
if (!dragging.current && event.pointerType === "touch") {
|
|
856
|
+
const deltaX = event.clientX - gestureStart.current.x
|
|
857
|
+
const deltaY = event.clientY - gestureStart.current.y
|
|
858
|
+
|
|
859
|
+
if (gestureAxis.current === null) {
|
|
860
|
+
if (
|
|
861
|
+
Math.abs(deltaX) < TOUCH_GESTURE_LOCK_PX &&
|
|
862
|
+
Math.abs(deltaY) < TOUCH_GESTURE_LOCK_PX
|
|
863
|
+
) {
|
|
864
|
+
return
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
gestureAxis.current =
|
|
868
|
+
Math.abs(deltaX) >= Math.abs(deltaY) ? "x" : "y"
|
|
869
|
+
|
|
870
|
+
if (gestureAxis.current === "y") {
|
|
871
|
+
activePointerId.current = null
|
|
872
|
+
return
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
beginDrag(event)
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
if (!dragging.current) return
|
|
880
|
+
|
|
881
|
+
if (event.pointerType === "touch") {
|
|
882
|
+
event.preventDefault()
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const now = performance.now()
|
|
886
|
+
const sample = lastPointerSample.current
|
|
887
|
+
const elapsed = now - sample.t
|
|
888
|
+
const dragSpeed = event.pointerType === "touch" ? TOUCH_DRAG_SPEED : DRAG_SPEED
|
|
889
|
+
|
|
890
|
+
if (elapsed > 0 && elapsed < 80) {
|
|
891
|
+
const instantVelocity =
|
|
892
|
+
(-(event.clientX - sample.x) / elapsed) * dragSpeed
|
|
893
|
+
dragVelocity.current =
|
|
894
|
+
instantVelocity * 0.65 + dragVelocity.current * 0.35
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
lastPointerSample.current = { x: event.clientX, t: now }
|
|
898
|
+
|
|
899
|
+
const deltaX = event.clientX - dragOrigin.current.x
|
|
900
|
+
applyTranslate(dragOrigin.current.translate - deltaX * dragSpeed)
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const endDrag = (event: PointerEvent) => {
|
|
904
|
+
if (activePointerId.current !== null && event.pointerId !== activePointerId.current) {
|
|
905
|
+
return
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const wasDragging = dragging.current
|
|
909
|
+
|
|
910
|
+
dragging.current = false
|
|
911
|
+
gestureAxis.current = null
|
|
912
|
+
activePointerId.current = null
|
|
913
|
+
|
|
914
|
+
if (section.hasPointerCapture(event.pointerId)) {
|
|
915
|
+
section.releasePointerCapture(event.pointerId)
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
section.style.cursor = ""
|
|
919
|
+
section.style.touchAction = ""
|
|
920
|
+
|
|
921
|
+
if (wasDragging) {
|
|
922
|
+
startMomentum()
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// Arrow keys scrub only when the lifeline effectively is the page, or
|
|
927
|
+
// when focus is inside it. An embedded instance must not capture host
|
|
928
|
+
// keyboard scrolling until the reader has tabbed into it.
|
|
929
|
+
const ownsKeyboard = () => {
|
|
930
|
+
const active = document.activeElement
|
|
931
|
+
if (active && section.contains(active)) return true
|
|
932
|
+
|
|
933
|
+
return !resolveMode()
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
937
|
+
if (maxTranslate.current <= 0) return
|
|
938
|
+
if (isScrollLocked()) return
|
|
939
|
+
if (isEditableTarget(event.target)) return
|
|
940
|
+
if (!ownsKeyboard()) return
|
|
941
|
+
|
|
942
|
+
stopMomentum()
|
|
943
|
+
dragVelocity.current = 0
|
|
944
|
+
|
|
945
|
+
if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
|
|
946
|
+
event.preventDefault()
|
|
947
|
+
applyTranslate(translatePx.current - maxTranslate.current * 0.05)
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
|
|
951
|
+
event.preventDefault()
|
|
952
|
+
applyTranslate(translatePx.current + maxTranslate.current * 0.05)
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
window.addEventListener("wheel", onWheelStream, {
|
|
957
|
+
passive: true,
|
|
958
|
+
capture: true,
|
|
959
|
+
})
|
|
960
|
+
section.addEventListener("scroll", onSectionScroll, { passive: true })
|
|
961
|
+
section.addEventListener("wheel", onWheel, { passive: false })
|
|
962
|
+
section.addEventListener("pointerdown", onPointerDown)
|
|
963
|
+
section.addEventListener("pointermove", onPointerMove, { passive: false })
|
|
964
|
+
section.addEventListener("pointerup", endDrag)
|
|
965
|
+
section.addEventListener("pointercancel", endDrag)
|
|
966
|
+
window.addEventListener("keydown", onKeyDown)
|
|
967
|
+
|
|
968
|
+
return () => {
|
|
969
|
+
cancelAnimationFrame(frameId)
|
|
970
|
+
stopMomentum()
|
|
971
|
+
cancelAnimationFrame(settleId.current)
|
|
972
|
+
settlingRef.current = false
|
|
973
|
+
resizeObserver?.disconnect()
|
|
974
|
+
motionQuery.removeEventListener("change", onMotionChange)
|
|
975
|
+
window.removeEventListener("resize", scheduleMeasure)
|
|
976
|
+
window.removeEventListener("wheel", onWheelStream, { capture: true })
|
|
977
|
+
section.removeEventListener("scroll", onSectionScroll)
|
|
978
|
+
section.removeEventListener("wheel", onWheel)
|
|
979
|
+
section.removeEventListener("pointerdown", onPointerDown)
|
|
980
|
+
section.removeEventListener("pointermove", onPointerMove)
|
|
981
|
+
section.removeEventListener("pointerup", endDrag)
|
|
982
|
+
section.removeEventListener("pointercancel", endDrag)
|
|
983
|
+
window.removeEventListener("keydown", onKeyDown)
|
|
984
|
+
dragging.current = false
|
|
985
|
+
gestureAxis.current = null
|
|
986
|
+
activePointerId.current = null
|
|
987
|
+
// A remount must not start out believing it is mid-release.
|
|
988
|
+
gestureStartedHere.current = false
|
|
989
|
+
gestureReleased.current = false
|
|
990
|
+
boundaryHitAt.current = 0
|
|
991
|
+
wheelGapMs.current = Number.POSITIVE_INFINITY
|
|
992
|
+
section.style.cursor = ""
|
|
993
|
+
section.style.touchAction = ""
|
|
994
|
+
}
|
|
995
|
+
}, [applyTranslate, isScrollLocked, markerCount, measureLayout, resolveMode])
|
|
996
|
+
|
|
997
|
+
return {
|
|
998
|
+
sectionRef,
|
|
999
|
+
trackRef,
|
|
1000
|
+
labelsRef,
|
|
1001
|
+
setMarkerRef,
|
|
1002
|
+
isLayoutReady,
|
|
1003
|
+
isEmbed,
|
|
1004
|
+
/**
|
|
1005
|
+
* Embedded only: whether the module has come into view and the intro is
|
|
1006
|
+
* cleared to play. Page mode never waits, so this stays false there and
|
|
1007
|
+
* callers should read it as `!isEmbed || introArmed`.
|
|
1008
|
+
*/
|
|
1009
|
+
introArmed,
|
|
1010
|
+
}
|
|
1011
|
+
}
|