@hina-ui/vue 1.7.13 → 1.7.14
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/dist/index.js +1295 -283
- package/dist/index.js.map +1 -1
- package/dist/shared/lib/thumbhash.ts +12 -0
- package/dist/shared/lib/tour-engine.ts +721 -0
- package/dist/shared/lib/tour.ts +202 -24
- package/dist/shared/locale/en-US.ts +1 -0
- package/dist/shared/locale/ja-JP.ts +1 -0
- package/dist/shared/locale/types.ts +1 -0
- package/dist/shared/locale/zh-CN.ts +1 -0
- package/dist/shared/primitives/dialog/index.ts +2 -2
- package/dist/shared/primitives/dismissable-layer/index.ts +48 -1
- package/dist/shared/primitives/focus-scope/index.ts +5 -3
- package/dist/shared/styles/semantic.css +1 -0
- package/dist/shared/styles/tour.css +107 -2
- package/dist/shared/variants/image.ts +28 -0
- package/dist/shared/variants/tour.ts +22 -4
- package/dist/types/shared/src/lib/thumbhash.d.ts +2 -0
- package/dist/types/shared/src/lib/thumbhash.d.ts.map +1 -0
- package/dist/types/shared/src/lib/tour-engine.d.ts +47 -0
- package/dist/types/shared/src/lib/tour-engine.d.ts.map +1 -0
- package/dist/types/shared/src/lib/tour.d.ts +65 -9
- package/dist/types/shared/src/lib/tour.d.ts.map +1 -1
- package/dist/types/shared/src/locale/en-US.d.ts.map +1 -1
- package/dist/types/shared/src/locale/ja-JP.d.ts.map +1 -1
- package/dist/types/shared/src/locale/types.d.ts +1 -0
- package/dist/types/shared/src/locale/types.d.ts.map +1 -1
- package/dist/types/shared/src/locale/zh-CN.d.ts.map +1 -1
- package/dist/types/shared/src/primitives/dialog/index.d.ts +1 -1
- package/dist/types/shared/src/primitives/dialog/index.d.ts.map +1 -1
- package/dist/types/shared/src/primitives/dismissable-layer/index.d.ts +1 -0
- package/dist/types/shared/src/primitives/dismissable-layer/index.d.ts.map +1 -1
- package/dist/types/shared/src/primitives/focus-scope/index.d.ts.map +1 -1
- package/dist/types/shared/src/variants/image.d.ts +35 -0
- package/dist/types/shared/src/variants/image.d.ts.map +1 -1
- package/dist/types/shared/src/variants/tour.d.ts +9 -4
- package/dist/types/shared/src/variants/tour.d.ts.map +1 -1
- package/dist/types/vue/src/components/button/Button.vue.d.ts.map +1 -1
- package/dist/types/vue/src/components/dialog/Dialog.vue.d.ts +3 -0
- package/dist/types/vue/src/components/dialog/Dialog.vue.d.ts.map +1 -1
- package/dist/types/vue/src/components/drawer/Drawer.vue.d.ts +3 -0
- package/dist/types/vue/src/components/drawer/Drawer.vue.d.ts.map +1 -1
- package/dist/types/vue/src/components/image/Image.vue.d.ts +1 -0
- package/dist/types/vue/src/components/image/Image.vue.d.ts.map +1 -1
- package/dist/types/vue/src/components/image/composables/useImage.d.ts +2 -0
- package/dist/types/vue/src/components/image/composables/useImage.d.ts.map +1 -1
- package/dist/types/vue/src/components/overlay-branch/OverlayBranch.vue.d.ts +17 -0
- package/dist/types/vue/src/components/overlay-branch/OverlayBranch.vue.d.ts.map +1 -0
- package/dist/types/vue/src/components/sheet/Sheet.vue.d.ts +3 -0
- package/dist/types/vue/src/components/sheet/Sheet.vue.d.ts.map +1 -1
- package/dist/types/vue/src/components/tour/Tour.vue.d.ts +10 -1
- package/dist/types/vue/src/components/tour/Tour.vue.d.ts.map +1 -1
- package/dist/types/vue/src/components/tour/types.d.ts +9 -3
- package/dist/types/vue/src/components/tour/types.d.ts.map +1 -1
- package/dist/types/vue/src/index.d.ts +2 -1
- package/dist/types/vue/src/index.d.ts.map +1 -1
- package/dist/types/vue/src/primitives/dismissable-layer/DismissableLayerBranch.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/components/button/Button.vue +1 -1
- package/src/components/dialog/Dialog.vue +14 -1
- package/src/components/drawer/Drawer.vue +14 -1
- package/src/components/image/Image.vue +58 -24
- package/src/components/image/composables/useImage.ts +46 -21
- package/src/components/overlay-branch/OverlayBranch.vue +14 -0
- package/src/components/sheet/Sheet.vue +14 -1
- package/src/components/tour/Tour.vue +302 -141
- package/src/components/tour/types.ts +33 -2
- package/src/index.ts +11 -0
- package/src/primitives/dismissable-layer/DismissableLayerBranch.ts +9 -18
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
import { EASE, type Bezier } from '../motion'
|
|
2
|
+
import {
|
|
3
|
+
resolveTourTarget,
|
|
4
|
+
revealTourTarget,
|
|
5
|
+
spotlightRect,
|
|
6
|
+
TOUR_PADDING,
|
|
7
|
+
TOUR_RADIUS,
|
|
8
|
+
waitForTourTarget,
|
|
9
|
+
watchTourTarget,
|
|
10
|
+
type TourPlayContext,
|
|
11
|
+
type TourPoint,
|
|
12
|
+
type TourPointer,
|
|
13
|
+
type TourPointerKind,
|
|
14
|
+
type TourRect,
|
|
15
|
+
type TourResolvedTarget,
|
|
16
|
+
type TourSpotlightOptions,
|
|
17
|
+
type TourStepBase,
|
|
18
|
+
type TourTarget,
|
|
19
|
+
} from './tour'
|
|
20
|
+
|
|
21
|
+
export type TourStatus = 'idle' | 'waiting' | 'ready' | 'missing'
|
|
22
|
+
|
|
23
|
+
export interface TourEngineState {
|
|
24
|
+
status: TourStatus
|
|
25
|
+
settled: boolean
|
|
26
|
+
hole: TourRect | null
|
|
27
|
+
targetRect: TourRect | null
|
|
28
|
+
radius: number
|
|
29
|
+
elements: HTMLElement[]
|
|
30
|
+
playing: boolean
|
|
31
|
+
viewport: { width: number; height: number }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TourEngineOptions {
|
|
35
|
+
pointer: () => HTMLElement | null | undefined
|
|
36
|
+
keyBadge: () => HTMLElement | null | undefined
|
|
37
|
+
pointerKind: () => TourPointerKind
|
|
38
|
+
cardGap?: () => number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const INITIAL: TourEngineState = {
|
|
42
|
+
status: 'idle',
|
|
43
|
+
settled: false,
|
|
44
|
+
hole: null,
|
|
45
|
+
targetRect: null,
|
|
46
|
+
radius: TOUR_RADIUS,
|
|
47
|
+
elements: [],
|
|
48
|
+
playing: false,
|
|
49
|
+
viewport: { width: 0, height: 0 },
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const TOUR_SPRING = { stiffness: 260, damping: 30 }
|
|
53
|
+
|
|
54
|
+
type Keys = 'x' | 'y' | 'width' | 'height'
|
|
55
|
+
const KEYS: Keys[] = ['x', 'y', 'width', 'height']
|
|
56
|
+
|
|
57
|
+
export function stepSpring(
|
|
58
|
+
position: TourRect,
|
|
59
|
+
velocity: TourRect,
|
|
60
|
+
goal: TourRect,
|
|
61
|
+
seconds: number,
|
|
62
|
+
{ stiffness, damping } = TOUR_SPRING,
|
|
63
|
+
) {
|
|
64
|
+
const step = 1 / 240
|
|
65
|
+
let left = seconds
|
|
66
|
+
while (left > 0) {
|
|
67
|
+
const dt = Math.min(step, left)
|
|
68
|
+
for (const key of KEYS) {
|
|
69
|
+
const force = -stiffness * (position[key] - goal[key]) - damping * velocity[key]
|
|
70
|
+
velocity[key] += force * dt
|
|
71
|
+
position[key] += velocity[key] * dt
|
|
72
|
+
}
|
|
73
|
+
left -= dt
|
|
74
|
+
}
|
|
75
|
+
return KEYS.every(
|
|
76
|
+
key => Math.abs(position[key] - goal[key]) < 0.25 && Math.abs(velocity[key]) < 2,
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function bezierEasing([x1, y1, x2, y2]: Bezier) {
|
|
81
|
+
const curve = (a: number, b: number, t: number) =>
|
|
82
|
+
3 * a * t * (1 - t) ** 2 + 3 * b * t * t * (1 - t) + t ** 3
|
|
83
|
+
const slope = (a: number, b: number, t: number) =>
|
|
84
|
+
3 * a * (1 - t) ** 2 + 6 * (b - a) * t * (1 - t) + 3 * (1 - b) * t * t
|
|
85
|
+
return (progress: number) => {
|
|
86
|
+
if (progress <= 0) return 0
|
|
87
|
+
if (progress >= 1) return 1
|
|
88
|
+
let t = progress
|
|
89
|
+
for (let i = 0; i < 8; i++) {
|
|
90
|
+
const error = curve(x1, x2, t) - progress
|
|
91
|
+
const derivative = slope(x1, x2, t)
|
|
92
|
+
if (Math.abs(error) < 1e-5 || derivative === 0) break
|
|
93
|
+
t -= error / derivative
|
|
94
|
+
}
|
|
95
|
+
return curve(y1, y2, Math.min(Math.max(t, 0), 1))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const easeMove = bezierEasing(EASE.move)
|
|
100
|
+
|
|
101
|
+
function reducedMotion() {
|
|
102
|
+
return (
|
|
103
|
+
typeof window !== 'undefined' &&
|
|
104
|
+
!!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function abortError() {
|
|
109
|
+
return new DOMException('The tour step was left', 'AbortError')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function isAbortError(error: unknown) {
|
|
113
|
+
return error instanceof DOMException && error.name === 'AbortError'
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function guard(signal: AbortSignal) {
|
|
117
|
+
if (signal.aborted) throw abortError()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function tourSleep(ms: number, signal: AbortSignal) {
|
|
121
|
+
return new Promise<void>((resolve, reject) => {
|
|
122
|
+
if (signal.aborted) return reject(abortError())
|
|
123
|
+
const timer = window.setTimeout(done, ms)
|
|
124
|
+
const abort = () => {
|
|
125
|
+
window.clearTimeout(timer)
|
|
126
|
+
reject(abortError())
|
|
127
|
+
}
|
|
128
|
+
function done() {
|
|
129
|
+
signal.removeEventListener('abort', abort)
|
|
130
|
+
resolve()
|
|
131
|
+
}
|
|
132
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function tween(
|
|
137
|
+
duration: number,
|
|
138
|
+
onFrame: (progress: number) => void,
|
|
139
|
+
signal: AbortSignal,
|
|
140
|
+
easing: (progress: number) => number = easeMove,
|
|
141
|
+
) {
|
|
142
|
+
return new Promise<void>((resolve, reject) => {
|
|
143
|
+
if (signal.aborted) return reject(abortError())
|
|
144
|
+
if (duration <= 0 || reducedMotion()) {
|
|
145
|
+
onFrame(1)
|
|
146
|
+
return resolve()
|
|
147
|
+
}
|
|
148
|
+
const start = performance.now()
|
|
149
|
+
let frame = 0
|
|
150
|
+
const abort = () => {
|
|
151
|
+
cancelAnimationFrame(frame)
|
|
152
|
+
reject(abortError())
|
|
153
|
+
}
|
|
154
|
+
const tick = (now: number) => {
|
|
155
|
+
const progress = Math.min((now - start) / duration, 1)
|
|
156
|
+
onFrame(easing(progress))
|
|
157
|
+
if (progress < 1) frame = requestAnimationFrame(tick)
|
|
158
|
+
else {
|
|
159
|
+
signal.removeEventListener('abort', abort)
|
|
160
|
+
resolve()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
164
|
+
frame = requestAnimationFrame(tick)
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const nextFrame = () => new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
|
|
169
|
+
const delay = (ms: number) => new Promise<void>(resolve => window.setTimeout(resolve, ms))
|
|
170
|
+
|
|
171
|
+
function finiteAnimations(affects: (node: Node) => boolean = () => true) {
|
|
172
|
+
if (typeof document.getAnimations !== 'function') return []
|
|
173
|
+
return document.getAnimations().filter(animation => {
|
|
174
|
+
if (animation.playState !== 'running' || animation.timeline !== document.timeline) return false
|
|
175
|
+
const end = animation.effect?.getComputedTiming().endTime
|
|
176
|
+
if (typeof end !== 'number' || !Number.isFinite(end)) return false
|
|
177
|
+
const node = (animation.effect as KeyframeEffect | null)?.target
|
|
178
|
+
return !!node && affects(node)
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function settle(animations: Animation[], until: number) {
|
|
183
|
+
if (!animations.length) return Promise.resolve()
|
|
184
|
+
return Promise.race([
|
|
185
|
+
Promise.allSettled(animations.map(animation => animation.finished)),
|
|
186
|
+
delay(Math.max(until - performance.now(), 0)),
|
|
187
|
+
])
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function waitForStillTarget(target: TourResolvedTarget, limit = 1000) {
|
|
191
|
+
const until = performance.now() + limit
|
|
192
|
+
await nextFrame()
|
|
193
|
+
await settle(
|
|
194
|
+
finiteAnimations(node =>
|
|
195
|
+
target.elements.some(element => node === element || node.contains(element)),
|
|
196
|
+
),
|
|
197
|
+
until,
|
|
198
|
+
)
|
|
199
|
+
let previous = target.rect()
|
|
200
|
+
let still = 0
|
|
201
|
+
while (still < 2 && performance.now() < until) {
|
|
202
|
+
await nextFrame()
|
|
203
|
+
const rect = target.rect()
|
|
204
|
+
const moved = KEYS.some(key => Math.abs(rect[key] - previous[key]) > 0.5)
|
|
205
|
+
previous = rect
|
|
206
|
+
still = moved ? 0 : still + 1
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function waitForFiniteAnimations(limit = 1000) {
|
|
211
|
+
const until = performance.now() + limit
|
|
212
|
+
await nextFrame()
|
|
213
|
+
await nextFrame()
|
|
214
|
+
await settle(finiteAnimations(), until)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function isPoint(value: unknown): value is TourPoint {
|
|
218
|
+
return (
|
|
219
|
+
!!value &&
|
|
220
|
+
typeof value === 'object' &&
|
|
221
|
+
typeof (value as TourPoint).x === 'number' &&
|
|
222
|
+
typeof (value as TourPoint).y === 'number' &&
|
|
223
|
+
!('width' in value)
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function tourPointerPoint(target: TourTarget | TourPoint): TourPoint | null {
|
|
228
|
+
if (isPoint(target)) return { x: target.x, y: target.y }
|
|
229
|
+
const resolved = resolveTourTarget(target as TourTarget)
|
|
230
|
+
if (!resolved) return null
|
|
231
|
+
const rect = resolved.rect()
|
|
232
|
+
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function createPointer(
|
|
236
|
+
element: () => HTMLElement | null | undefined,
|
|
237
|
+
keyBadge: () => HTMLElement | null | undefined,
|
|
238
|
+
signal: AbortSignal,
|
|
239
|
+
memory: { point: TourPoint | null },
|
|
240
|
+
ready: () => Promise<void>,
|
|
241
|
+
): TourPointer {
|
|
242
|
+
const place = (point: TourPoint) => {
|
|
243
|
+
memory.point = point
|
|
244
|
+
const node = element()
|
|
245
|
+
if (node) node.style.transform = `translate3d(${point.x}px, ${point.y}px, 0)`
|
|
246
|
+
}
|
|
247
|
+
const setState = (name: string, value: string | null) => {
|
|
248
|
+
const node = element()
|
|
249
|
+
if (!node) return
|
|
250
|
+
if (value === null) node.removeAttribute(name)
|
|
251
|
+
else node.setAttribute(name, value)
|
|
252
|
+
}
|
|
253
|
+
const ripple = () => {
|
|
254
|
+
const node = element()?.querySelector('[data-hn-tour-pointer-ripples]')
|
|
255
|
+
if (!node) return
|
|
256
|
+
const span = document.createElement('span')
|
|
257
|
+
span.setAttribute('data-hn-tour-ripple', '')
|
|
258
|
+
span.addEventListener('animationend', () => span.remove(), { once: true })
|
|
259
|
+
window.setTimeout(() => span.remove(), 1000)
|
|
260
|
+
node.appendChild(span)
|
|
261
|
+
}
|
|
262
|
+
const resolvePoint = (target: TourTarget | TourPoint) => {
|
|
263
|
+
const point = tourPointerPoint(target)
|
|
264
|
+
if (!point) throw new Error('[Hina UI] Tour: the pointer target was not found')
|
|
265
|
+
return point
|
|
266
|
+
}
|
|
267
|
+
const travel = async (
|
|
268
|
+
target: TourTarget | TourPoint,
|
|
269
|
+
duration: number,
|
|
270
|
+
onMove?: (point: TourPoint) => void,
|
|
271
|
+
) => {
|
|
272
|
+
guard(signal)
|
|
273
|
+
let to = resolvePoint(target)
|
|
274
|
+
const hidden = element()?.getAttribute('data-state') !== 'shown'
|
|
275
|
+
const from = hidden || !memory.point ? { x: to.x + 40, y: to.y + 40 } : memory.point
|
|
276
|
+
place(from)
|
|
277
|
+
setState('data-state', 'shown')
|
|
278
|
+
await tween(
|
|
279
|
+
duration,
|
|
280
|
+
progress => {
|
|
281
|
+
to = tourPointerPoint(target) ?? to
|
|
282
|
+
const point = {
|
|
283
|
+
x: from.x + (to.x - from.x) * progress,
|
|
284
|
+
y: from.y + (to.y - from.y) * progress,
|
|
285
|
+
}
|
|
286
|
+
place(point)
|
|
287
|
+
onMove?.(point)
|
|
288
|
+
},
|
|
289
|
+
signal,
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
async move(target, duration = 600) {
|
|
294
|
+
await ready()
|
|
295
|
+
await travel(target, duration)
|
|
296
|
+
},
|
|
297
|
+
async tap() {
|
|
298
|
+
await ready()
|
|
299
|
+
setState('data-pressed', '')
|
|
300
|
+
ripple()
|
|
301
|
+
await tourSleep(120, signal)
|
|
302
|
+
setState('data-pressed', null)
|
|
303
|
+
await tourSleep(180, signal)
|
|
304
|
+
},
|
|
305
|
+
async press(duration = 800) {
|
|
306
|
+
await ready()
|
|
307
|
+
element()?.style.setProperty('--hn-tour-press', `${duration}ms`)
|
|
308
|
+
setState('data-pressed', 'long')
|
|
309
|
+
try {
|
|
310
|
+
await tourSleep(duration, signal)
|
|
311
|
+
} finally {
|
|
312
|
+
setState('data-pressed', null)
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
async drag(target, duration = 800, onMove) {
|
|
316
|
+
await ready()
|
|
317
|
+
setState('data-pressed', '')
|
|
318
|
+
try {
|
|
319
|
+
await tourSleep(80, signal)
|
|
320
|
+
if (memory.point) onMove?.(memory.point)
|
|
321
|
+
await travel(target, duration, onMove)
|
|
322
|
+
await tourSleep(80, signal)
|
|
323
|
+
} finally {
|
|
324
|
+
setState('data-pressed', null)
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
async key(key, duration = 700) {
|
|
328
|
+
await ready()
|
|
329
|
+
const badge = keyBadge()
|
|
330
|
+
if (!badge) return
|
|
331
|
+
const label = badge.querySelector('[data-hn-tour-key-label]') ?? badge
|
|
332
|
+
label.textContent = key
|
|
333
|
+
const near = element()?.getAttribute('data-state') === 'shown' && memory.point
|
|
334
|
+
const x = near ? memory.point!.x + 20 : window.innerWidth / 2
|
|
335
|
+
const y = near ? memory.point!.y + 28 : window.innerHeight - 72
|
|
336
|
+
badge.style.transform = `translate3d(${x}px, ${y}px, 0)`
|
|
337
|
+
badge.setAttribute('data-placement', near ? 'pointer' : 'screen')
|
|
338
|
+
badge.setAttribute('data-state', 'shown')
|
|
339
|
+
try {
|
|
340
|
+
await tourSleep(duration, signal)
|
|
341
|
+
} finally {
|
|
342
|
+
badge.setAttribute('data-state', 'hidden')
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
async hide() {
|
|
346
|
+
setState('data-state', 'hidden')
|
|
347
|
+
},
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function createTourEngine(options: TourEngineOptions) {
|
|
352
|
+
let state = INITIAL
|
|
353
|
+
const listeners = new Set<() => void>()
|
|
354
|
+
const set = (patch: Partial<TourEngineState>) => {
|
|
355
|
+
state = { ...state, ...patch }
|
|
356
|
+
for (const listener of [...listeners]) listener()
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
let step: TourStepBase = {}
|
|
360
|
+
let stepNumber = 0
|
|
361
|
+
let padding = TOUR_PADDING
|
|
362
|
+
let resolved: TourResolvedTarget | null = null
|
|
363
|
+
let stopTracking = () => {}
|
|
364
|
+
let token = 0
|
|
365
|
+
let epoch = 0
|
|
366
|
+
let revealAt = 0
|
|
367
|
+
let entering = false
|
|
368
|
+
let entry = Promise.resolve()
|
|
369
|
+
let markEntered = () => {}
|
|
370
|
+
let waiting: AbortController | null = null
|
|
371
|
+
|
|
372
|
+
let position: TourRect | null = null
|
|
373
|
+
let velocity: TourRect = { x: 0, y: 0, width: 0, height: 0 }
|
|
374
|
+
let frame = 0
|
|
375
|
+
let last = 0
|
|
376
|
+
let settleWaiters: (() => void)[] = []
|
|
377
|
+
|
|
378
|
+
let run: { controller: AbortController; cleanups: (() => void)[] } | null = null
|
|
379
|
+
const pointerMemory: { point: TourPoint | null } = { point: null }
|
|
380
|
+
|
|
381
|
+
const viewport = () => ({ width: window.innerWidth, height: window.innerHeight })
|
|
382
|
+
const attached = (found: TourResolvedTarget) =>
|
|
383
|
+
found.elements.every(element => element.isConnected)
|
|
384
|
+
const goal = () => {
|
|
385
|
+
if (!resolved) return null
|
|
386
|
+
if (!attached(resolved)) return position ? { ...position } : null
|
|
387
|
+
return spotlightRect(toBox(resolved.rect()), padding)
|
|
388
|
+
}
|
|
389
|
+
const toBox = (rect: TourRect) => ({
|
|
390
|
+
left: rect.x,
|
|
391
|
+
top: rect.y,
|
|
392
|
+
width: rect.width,
|
|
393
|
+
height: rect.height,
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
function flushSettled() {
|
|
397
|
+
const waiters = settleWaiters
|
|
398
|
+
settleWaiters = []
|
|
399
|
+
for (const resolve of waiters) resolve()
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function stopSpring() {
|
|
403
|
+
if (frame) cancelAnimationFrame(frame)
|
|
404
|
+
frame = 0
|
|
405
|
+
flushSettled()
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function snap() {
|
|
409
|
+
if (resolved && !attached(resolved)) {
|
|
410
|
+
set({ viewport: viewport() })
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
const target = goal()
|
|
414
|
+
position = target ? { ...target } : null
|
|
415
|
+
velocity = { x: 0, y: 0, width: 0, height: 0 }
|
|
416
|
+
set({
|
|
417
|
+
hole: position ? { ...position } : null,
|
|
418
|
+
targetRect: resolved ? resolved.rect() : null,
|
|
419
|
+
viewport: viewport(),
|
|
420
|
+
})
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function tick(now: number) {
|
|
424
|
+
const target = goal()
|
|
425
|
+
if (!target || !position) {
|
|
426
|
+
frame = 0
|
|
427
|
+
snap()
|
|
428
|
+
flushSettled()
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
const seconds = Math.min((now - last) / 1000, 1 / 20)
|
|
432
|
+
last = now
|
|
433
|
+
const done = stepSpring(position, velocity, target, seconds)
|
|
434
|
+
if (done) {
|
|
435
|
+
frame = 0
|
|
436
|
+
snap()
|
|
437
|
+
flushSettled()
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
set({
|
|
441
|
+
hole: { ...position },
|
|
442
|
+
...(attached(resolved!) ? { targetRect: resolved!.rect() } : {}),
|
|
443
|
+
viewport: viewport(),
|
|
444
|
+
})
|
|
445
|
+
frame = requestAnimationFrame(tick)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function springTo(animate: boolean) {
|
|
449
|
+
if (!animate || !position || reducedMotion()) {
|
|
450
|
+
stopSpring()
|
|
451
|
+
snap()
|
|
452
|
+
return Promise.resolve()
|
|
453
|
+
}
|
|
454
|
+
const settled = new Promise<void>(resolve => settleWaiters.push(resolve))
|
|
455
|
+
if (!frame) {
|
|
456
|
+
last = performance.now()
|
|
457
|
+
frame = requestAnimationFrame(tick)
|
|
458
|
+
}
|
|
459
|
+
return settled
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function track() {
|
|
463
|
+
if (frame) return
|
|
464
|
+
snap()
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function follow(target: TourTarget | undefined, found: TourResolvedTarget) {
|
|
468
|
+
let stopWatch = watchTourTarget(found, track)
|
|
469
|
+
const observer = new MutationObserver(() => {
|
|
470
|
+
if (!resolved || attached(resolved)) return
|
|
471
|
+
const again = resolveTourTarget(target)
|
|
472
|
+
if (!again || !attached(again)) return
|
|
473
|
+
resolved = again
|
|
474
|
+
stopWatch()
|
|
475
|
+
set({ elements: again.elements })
|
|
476
|
+
void springTo(!!position)
|
|
477
|
+
stopWatch = watchTourTarget(again, track)
|
|
478
|
+
})
|
|
479
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
480
|
+
return () => {
|
|
481
|
+
observer.disconnect()
|
|
482
|
+
stopWatch()
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function onResize() {
|
|
487
|
+
if (frame) return
|
|
488
|
+
if (resolved) snap()
|
|
489
|
+
else set({ viewport: viewport() })
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function reveal(current: number) {
|
|
493
|
+
if (revealAt > performance.now()) {
|
|
494
|
+
await delay(revealAt - performance.now())
|
|
495
|
+
if (current !== token) return false
|
|
496
|
+
}
|
|
497
|
+
entering = false
|
|
498
|
+
set({ settled: true })
|
|
499
|
+
markEntered()
|
|
500
|
+
return true
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function untilEntered(signal: AbortSignal) {
|
|
504
|
+
return new Promise<void>((resolve, reject) => {
|
|
505
|
+
if (signal.aborted) return reject(abortError())
|
|
506
|
+
const abort = () => reject(abortError())
|
|
507
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
508
|
+
void entry.then(() => {
|
|
509
|
+
signal.removeEventListener('abort', abort)
|
|
510
|
+
if (signal.aborted) reject(abortError())
|
|
511
|
+
else resolve()
|
|
512
|
+
})
|
|
513
|
+
})
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function aim(
|
|
517
|
+
target: TourTarget | undefined,
|
|
518
|
+
{
|
|
519
|
+
wait = 0,
|
|
520
|
+
scroll = true,
|
|
521
|
+
padding: nextPadding,
|
|
522
|
+
radius,
|
|
523
|
+
}: TourSpotlightOptions & { scroll?: boolean },
|
|
524
|
+
) {
|
|
525
|
+
const current = ++token
|
|
526
|
+
waiting?.abort()
|
|
527
|
+
const controller = new AbortController()
|
|
528
|
+
waiting = controller
|
|
529
|
+
let found = resolveTourTarget(target)
|
|
530
|
+
if (!found && target && wait > 0) {
|
|
531
|
+
found = await waitForTourTarget(target, wait, controller.signal)
|
|
532
|
+
if (current !== token) return 'stale' as const
|
|
533
|
+
}
|
|
534
|
+
if (!found) {
|
|
535
|
+
stopTracking()
|
|
536
|
+
stopTracking = () => {}
|
|
537
|
+
resolved = null
|
|
538
|
+
stopSpring()
|
|
539
|
+
position = null
|
|
540
|
+
set({
|
|
541
|
+
status: 'missing',
|
|
542
|
+
settled: !entering,
|
|
543
|
+
hole: null,
|
|
544
|
+
targetRect: null,
|
|
545
|
+
elements: [],
|
|
546
|
+
viewport: viewport(),
|
|
547
|
+
})
|
|
548
|
+
if (entering && !(await reveal(current))) return 'stale' as const
|
|
549
|
+
return target ? ('missing' as const) : ('empty' as const)
|
|
550
|
+
}
|
|
551
|
+
stopTracking()
|
|
552
|
+
stopTracking = () => {}
|
|
553
|
+
const animate = !!position
|
|
554
|
+
if (scroll) revealTourTarget(found)
|
|
555
|
+
if (animate && !reducedMotion()) {
|
|
556
|
+
stopSpring()
|
|
557
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
558
|
+
await waitForStillTarget(found)
|
|
559
|
+
if (current !== token) return 'stale' as const
|
|
560
|
+
if (attached(found)) break
|
|
561
|
+
const again = resolveTourTarget(target)
|
|
562
|
+
if (!again) break
|
|
563
|
+
found = again
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
resolved = found
|
|
567
|
+
padding = nextPadding ?? step.padding ?? TOUR_PADDING
|
|
568
|
+
set({
|
|
569
|
+
status: 'ready',
|
|
570
|
+
elements: found.elements,
|
|
571
|
+
radius: radius ?? step.radius ?? TOUR_RADIUS,
|
|
572
|
+
...(entering ? { settled: false } : {}),
|
|
573
|
+
})
|
|
574
|
+
const settled = springTo(animate)
|
|
575
|
+
stopTracking = follow(target, found)
|
|
576
|
+
await settled
|
|
577
|
+
if (current !== token) return 'stale' as const
|
|
578
|
+
if (entering && !(await reveal(current))) return 'stale' as const
|
|
579
|
+
return 'ready' as const
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function abortRun() {
|
|
583
|
+
const current = run
|
|
584
|
+
if (!current) return
|
|
585
|
+
run = null
|
|
586
|
+
current.controller.abort()
|
|
587
|
+
for (const cleanup of current.cleanups.reverse()) {
|
|
588
|
+
try {
|
|
589
|
+
cleanup()
|
|
590
|
+
} catch (error) {
|
|
591
|
+
console.error(error)
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
options.pointer()?.setAttribute('data-state', 'hidden')
|
|
595
|
+
options.pointer()?.removeAttribute('data-pressed')
|
|
596
|
+
options.keyBadge()?.setAttribute('data-state', 'hidden')
|
|
597
|
+
set({ playing: false })
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function play() {
|
|
601
|
+
abortRun()
|
|
602
|
+
epoch += 1
|
|
603
|
+
const script = step.play
|
|
604
|
+
if (!script) return
|
|
605
|
+
const controller = new AbortController()
|
|
606
|
+
const cleanups: (() => void)[] = []
|
|
607
|
+
const current = { controller, cleanups }
|
|
608
|
+
run = current
|
|
609
|
+
const { signal } = controller
|
|
610
|
+
const context: TourPlayContext = {
|
|
611
|
+
step: stepNumber,
|
|
612
|
+
signal,
|
|
613
|
+
defer(cleanup) {
|
|
614
|
+
if (signal.aborted) cleanup()
|
|
615
|
+
else cleanups.push(cleanup)
|
|
616
|
+
},
|
|
617
|
+
pointer: createPointer(options.pointer, options.keyBadge, signal, pointerMemory, () =>
|
|
618
|
+
untilEntered(signal),
|
|
619
|
+
),
|
|
620
|
+
async spotlight(target, spotlightOptions = {}) {
|
|
621
|
+
guard(signal)
|
|
622
|
+
await aim(target === undefined ? step.target : target, {
|
|
623
|
+
...spotlightOptions,
|
|
624
|
+
scroll: false,
|
|
625
|
+
})
|
|
626
|
+
guard(signal)
|
|
627
|
+
},
|
|
628
|
+
async waitFor(target, timeout = 5000) {
|
|
629
|
+
guard(signal)
|
|
630
|
+
const found = await waitForTourTarget(target, timeout, signal)
|
|
631
|
+
guard(signal)
|
|
632
|
+
return !!found
|
|
633
|
+
},
|
|
634
|
+
sleep: ms => tourSleep(ms, signal),
|
|
635
|
+
}
|
|
636
|
+
set({ playing: true })
|
|
637
|
+
Promise.resolve()
|
|
638
|
+
.then(() => script(context))
|
|
639
|
+
.catch(error => {
|
|
640
|
+
if (!isAbortError(error)) console.error(error)
|
|
641
|
+
})
|
|
642
|
+
.finally(() => {
|
|
643
|
+
if (run === current) set({ playing: false })
|
|
644
|
+
})
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return {
|
|
648
|
+
subscribe(listener: () => void) {
|
|
649
|
+
listeners.add(listener)
|
|
650
|
+
return () => {
|
|
651
|
+
listeners.delete(listener)
|
|
652
|
+
}
|
|
653
|
+
},
|
|
654
|
+
getState: () => state,
|
|
655
|
+
pointerKind: options.pointerKind,
|
|
656
|
+
enter(next: TourStepBase, number: number) {
|
|
657
|
+
abortRun()
|
|
658
|
+
epoch += 1
|
|
659
|
+
const fresh = state.status === 'idle'
|
|
660
|
+
if (fresh) {
|
|
661
|
+
stopSpring()
|
|
662
|
+
position = null
|
|
663
|
+
pointerMemory.point = null
|
|
664
|
+
window.addEventListener('resize', onResize)
|
|
665
|
+
}
|
|
666
|
+
revealAt = fresh ? 0 : performance.now() + (options.cardGap?.() ?? 0)
|
|
667
|
+
markEntered()
|
|
668
|
+
entering = true
|
|
669
|
+
entry = new Promise<void>(resolve => (markEntered = resolve))
|
|
670
|
+
step = next
|
|
671
|
+
stepNumber = number
|
|
672
|
+
set({
|
|
673
|
+
status: 'waiting',
|
|
674
|
+
settled: false,
|
|
675
|
+
viewport: viewport(),
|
|
676
|
+
...(fresh ? { hole: null, targetRect: null, elements: [] } : {}),
|
|
677
|
+
})
|
|
678
|
+
return aim(next.target, {
|
|
679
|
+
wait: next.wait ?? 0,
|
|
680
|
+
scroll: next.scroll !== false,
|
|
681
|
+
})
|
|
682
|
+
},
|
|
683
|
+
play,
|
|
684
|
+
replay() {
|
|
685
|
+
if (!step.play) return
|
|
686
|
+
abortRun()
|
|
687
|
+
const current = ++epoch
|
|
688
|
+
stopTracking()
|
|
689
|
+
stopTracking = () => {}
|
|
690
|
+
stopSpring()
|
|
691
|
+
void waitForFiniteAnimations().then(() => {
|
|
692
|
+
if (current !== epoch) return
|
|
693
|
+
void aim(step.target, { wait: step.wait ?? 0, scroll: false })
|
|
694
|
+
play()
|
|
695
|
+
})
|
|
696
|
+
},
|
|
697
|
+
leave() {
|
|
698
|
+
abortRun()
|
|
699
|
+
epoch += 1
|
|
700
|
+
token += 1
|
|
701
|
+
waiting?.abort()
|
|
702
|
+
waiting = null
|
|
703
|
+
},
|
|
704
|
+
close() {
|
|
705
|
+
abortRun()
|
|
706
|
+
epoch += 1
|
|
707
|
+
token += 1
|
|
708
|
+
waiting?.abort()
|
|
709
|
+
waiting = null
|
|
710
|
+
stopTracking()
|
|
711
|
+
stopTracking = () => {}
|
|
712
|
+
stopSpring()
|
|
713
|
+
window.removeEventListener('resize', onResize)
|
|
714
|
+
entering = false
|
|
715
|
+
markEntered()
|
|
716
|
+
set({ status: 'idle', settled: false, playing: false })
|
|
717
|
+
},
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
export type TourEngine = ReturnType<typeof createTourEngine>
|