@leavepulse/ui 0.93.2 → 0.94.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/README.md +53 -3
- package/dist/assets/LpTabs-D7EyG21l.css +18 -0
- package/dist/assets/LpVideoTrim-D8JDg9Qn.css +8 -0
- package/dist/chunks/AppShellContextNav-BoH4ffnC.js +54 -0
- package/dist/chunks/{LpAppShell-BWaVRsDB.js → LpAppShell-xEBmGV78.js} +12 -5
- package/dist/chunks/{LpDateRangePicker-CAn2yGbn.js → LpDateRangePicker-DRBB8z6t.js} +1 -1
- package/dist/chunks/{LpDrawer-iRHwJOGn.js → LpDrawer-BkWjFdV0.js} +81 -42
- package/dist/chunks/{LpLightbox-a24V_0Wk.js → LpLightbox-DPgz7IsT.js} +62 -49
- package/dist/chunks/{LpMapPicker-DVfiy3rR.js → LpMapPicker-V0Lybi10.js} +19 -52
- package/dist/chunks/{LpMediaGallery-B7DOV_aL.js → LpMediaGallery-CTejKF2-.js} +36 -15
- package/dist/chunks/{LpModalNav-DivqT1NV.js → LpModalNav-DVXAF-DG.js} +1 -1
- package/dist/chunks/{LpRangeCalendar-C9gkNMYw.js → LpRangeCalendar-9BvaLZji.js} +3 -4
- package/dist/chunks/LpScrollStrip-D4tsOfAQ.js +78 -0
- package/dist/chunks/{LpSidebar-DQtCYlT4.js → LpSidebar-BIhusppV.js} +1 -1
- package/dist/component-names.d.ts +1 -1
- package/dist/components/AppShellContextNav.vue.js +1 -1
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpChart.vue.js +3 -3
- package/dist/components/LpDateRangePicker.vue.js +1 -1
- package/dist/components/LpDrawer.vue.d.ts +10 -2
- package/dist/components/LpDrawer.vue.js +1 -1
- package/dist/components/LpDropzone.vue.d.ts +1 -1
- package/dist/components/LpLightbox.vue.js +1 -1
- package/dist/components/LpMapPicker.vue.js +1 -1
- package/dist/components/LpMediaGallery.vue.d.ts +2 -0
- package/dist/components/LpMediaGallery.vue.js +1 -1
- package/dist/components/LpModalNav.vue.js +1 -1
- package/dist/components/LpRangeCalendar.vue.js +1 -1
- package/dist/components/LpRevealOnScroll.vue.d.ts +1 -1
- package/dist/components/LpScrollStrip.vue.d.ts +48 -0
- package/dist/components/LpScrollStrip.vue.js +4 -0
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/LpTabs.vue.d.ts +4 -4
- package/dist/components/LpTabs.vue.js +104 -90
- package/dist/components/LpVideoTrim.vue.js +41 -54
- package/dist/composables/gesture.d.ts +141 -0
- package/dist/composables/gesture.js +176 -0
- package/dist/composables/scroll.d.ts +5 -1
- package/dist/composables/scroll.js +16 -9
- package/dist/composables/useAppUpdate.d.ts +26 -0
- package/dist/composables/useAppUpdate.js +91 -0
- package/dist/composables/useGesture.d.ts +107 -0
- package/dist/composables/useGesture.js +281 -0
- package/dist/composables/usePaintSelect.js +3 -4
- package/dist/composables/useScrollStrip.d.ts +27 -0
- package/dist/composables/useScrollStrip.js +194 -0
- package/dist/composables/useTouchHold.d.ts +3 -1
- package/dist/composables/useTouchHold.js +9 -51
- package/dist/composables/useZoomPan.js +4 -6
- package/dist/index.d.ts +9 -0
- package/dist/index.js +180 -152
- package/package.json +1 -1
- package/src/component-names.ts +1 -0
- package/src/components/AppShellContextNav.vue +3 -2
- package/src/components/LpAppShell.vue +10 -1
- package/src/components/LpChart.vue +2 -2
- package/src/components/LpDrawer.vue +121 -54
- package/src/components/LpLightbox.vue +33 -20
- package/src/components/LpMapPicker.vue +22 -64
- package/src/components/LpMediaGallery.vue +44 -11
- package/src/components/LpRangeCalendar.vue +4 -5
- package/src/components/LpScrollStrip.vue +96 -0
- package/src/components/LpSidebar.vue +2 -2
- package/src/components/LpTabs.vue +36 -17
- package/src/components/LpVideoTrim.vue +35 -46
- package/src/composables/gesture.ts +314 -0
- package/src/composables/scroll.ts +20 -9
- package/src/composables/useAppUpdate.ts +156 -0
- package/src/composables/useGesture.ts +451 -0
- package/src/composables/usePaintSelect.ts +4 -5
- package/src/composables/useScrollStrip.ts +249 -0
- package/src/composables/useTouchHold.ts +15 -73
- package/src/composables/useZoomPan.ts +6 -9
- package/src/index.ts +27 -0
- package/src/tokens/effects.css +40 -0
- package/dist/assets/LpTabs-Bh7a-Bdl.css +0 -18
- package/dist/assets/LpVideoTrim-CMoUw3dJ.css +0 -8
- package/dist/chunks/AppShellContextNav-C6Ho2mof.js +0 -49
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { clamp } from "./clamp"
|
|
2
|
+
import type { Direction } from "./spatialNav"
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* The arithmetic behind every gesture in the kit, kept apart from the DOM so
|
|
6
|
+
* it can be tested and shared: how far a finger may drift before a press
|
|
7
|
+
* becomes a drag, how fast it was moving when it let go, which way a swipe
|
|
8
|
+
* went, how much an edge gives when it is pulled past, and where a flick
|
|
9
|
+
* comes to rest. useGesture feeds pointer events into these; a component that
|
|
10
|
+
* has to read touch events itself (a pull from the screen edge) uses them
|
|
11
|
+
* directly, so both decide the same way.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface GesturePoint {
|
|
15
|
+
x: number
|
|
16
|
+
y: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type GestureAxis = "x" | "y"
|
|
20
|
+
|
|
21
|
+
/** How far a touch may drift before a press stops being a tap or a hold. */
|
|
22
|
+
export const TOUCH_SLOP = 8
|
|
23
|
+
/** A mouse is steadier: a press that moves this far is a drag. */
|
|
24
|
+
export const MOUSE_SLOP = 4
|
|
25
|
+
/** Px/ms at release past which a short swipe still counts (a flick). */
|
|
26
|
+
export const FLICK_VELOCITY = 0.45
|
|
27
|
+
/** How long a finger holds still before it drags (selects, paints) rather than scrolls the page. */
|
|
28
|
+
export const HOLD_TO_DRAG_MS = 320
|
|
29
|
+
/** How long a press must stay for a long press, in ms. */
|
|
30
|
+
export const LONG_PRESS_MS = 500
|
|
31
|
+
/** Only movement this recent says how fast the pointer was going at release. */
|
|
32
|
+
const VELOCITY_WINDOW_MS = 100
|
|
33
|
+
/** A pointer that rested this long before letting go was not flung. */
|
|
34
|
+
const REST_MS = 90
|
|
35
|
+
|
|
36
|
+
/** The slop for a pointer type: a finger gets more room than a mouse. */
|
|
37
|
+
export function slopFor(pointerType: string): number {
|
|
38
|
+
return pointerType === "mouse" ? MOUSE_SLOP : TOUCH_SLOP
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface VelocityTracker {
|
|
42
|
+
/** Record where the pointer is at `time` (ms, an event's timeStamp). */
|
|
43
|
+
add: (point: GesturePoint, time: number) => void
|
|
44
|
+
/** Px/ms over the last moments, zero when the pointer rested before `now`. */
|
|
45
|
+
velocity: (now?: number) => GesturePoint
|
|
46
|
+
reset: () => void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Velocity from the samples in the last VELOCITY_WINDOW_MS rather than from
|
|
51
|
+
* the last two events: a jittery final frame (two events a millisecond apart)
|
|
52
|
+
* would otherwise decide how far a flick travels.
|
|
53
|
+
*/
|
|
54
|
+
export function createVelocityTracker(): VelocityTracker {
|
|
55
|
+
let samples: { x: number; y: number; t: number }[] = []
|
|
56
|
+
return {
|
|
57
|
+
add(point, time) {
|
|
58
|
+
samples.push({ x: point.x, y: point.y, t: time })
|
|
59
|
+
const cut = time - VELOCITY_WINDOW_MS
|
|
60
|
+
while (samples.length > 2 && samples[0]!.t < cut) samples.shift()
|
|
61
|
+
},
|
|
62
|
+
velocity(now) {
|
|
63
|
+
if (samples.length < 2) return { x: 0, y: 0 }
|
|
64
|
+
const last = samples[samples.length - 1]!
|
|
65
|
+
if (now !== undefined && now - last.t > REST_MS) return { x: 0, y: 0 }
|
|
66
|
+
const first = samples[0]!
|
|
67
|
+
const dt = last.t - first.t
|
|
68
|
+
if (dt <= 0) return { x: 0, y: 0 }
|
|
69
|
+
return { x: (last.x - first.x) / dt, y: (last.y - first.y) / dt }
|
|
70
|
+
},
|
|
71
|
+
reset() {
|
|
72
|
+
samples = []
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Which axis a movement has committed to, or null while it is still inside
|
|
79
|
+
* the slop or too diagonal to call. `ratio` is how much the winning axis must
|
|
80
|
+
* lead by: 1 takes any lead, higher waits for a clearer one.
|
|
81
|
+
*/
|
|
82
|
+
export function lockAxis(movement: GesturePoint, slop: number, ratio = 1): GestureAxis | null {
|
|
83
|
+
const ax = Math.abs(movement.x)
|
|
84
|
+
const ay = Math.abs(movement.y)
|
|
85
|
+
if (Math.hypot(ax, ay) < slop) return null
|
|
86
|
+
if (ax > ay * ratio) return "x"
|
|
87
|
+
if (ay > ax * ratio) return "y"
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface SwipeOptions {
|
|
92
|
+
/** Distance in px that is a swipe at any speed. Default 60. */
|
|
93
|
+
distance?: number
|
|
94
|
+
/** Px/ms that turns a shorter move into a swipe. Default FLICK_VELOCITY. */
|
|
95
|
+
velocity?: number
|
|
96
|
+
/** A flick must still travel this far, so a twitch is not one. Default 16. */
|
|
97
|
+
minDistance?: number
|
|
98
|
+
/** Only these axes count. Default both. */
|
|
99
|
+
axis?: GestureAxis
|
|
100
|
+
/** How much the swipe's axis must lead the other. Default 1.5. */
|
|
101
|
+
ratio?: number
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Which way a released movement swiped, or null when it was not a swipe. */
|
|
105
|
+
export function swipeDirection(movement: GesturePoint, velocity: GesturePoint, options: SwipeOptions = {}): Direction | null {
|
|
106
|
+
const { distance = 60, velocity: flick = FLICK_VELOCITY, minDistance = 16, ratio = 1.5 } = options
|
|
107
|
+
const ax = Math.abs(movement.x)
|
|
108
|
+
const ay = Math.abs(movement.y)
|
|
109
|
+
const axis: GestureAxis | null = options.axis ?? (ax > ay * ratio ? "x" : ay > ax * ratio ? "y" : null)
|
|
110
|
+
if (!axis) return null
|
|
111
|
+
const along = axis === "x" ? movement.x : movement.y
|
|
112
|
+
const across = axis === "x" ? ay : ax
|
|
113
|
+
if (Math.abs(along) <= across * ratio) return null
|
|
114
|
+
const speed = axis === "x" ? velocity.x : velocity.y
|
|
115
|
+
const far = Math.abs(along) >= distance
|
|
116
|
+
const flung = Math.abs(along) >= minDistance && Math.abs(speed) >= flick && Math.sign(speed) === Math.sign(along)
|
|
117
|
+
if (!far && !flung) return null
|
|
118
|
+
if (axis === "x") return along < 0 ? "left" : "right"
|
|
119
|
+
return along < 0 ? "up" : "down"
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* How far an edge moves when pulled `overshoot` px past it: the further the
|
|
124
|
+
* pull, the less each pixel gives, approaching `size` and never passing it.
|
|
125
|
+
* The sign follows the overshoot. `c` is the stiffness, iOS uses 0.55.
|
|
126
|
+
*/
|
|
127
|
+
export function rubberBand(overshoot: number, size: number, c = 0.55): number {
|
|
128
|
+
if (!overshoot || size <= 0) return 0
|
|
129
|
+
const distance = Math.abs(overshoot)
|
|
130
|
+
return Math.sign(overshoot) * (1 - 1 / ((distance * c) / size + 1)) * size
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Clamp `value` into [min, max], letting it stretch past either end by rubberBand. */
|
|
134
|
+
export function rubberClamp(value: number, min: number, max: number, size: number, c?: number): number {
|
|
135
|
+
if (value < min) return min + rubberBand(value - min, size, c)
|
|
136
|
+
if (value > max) return max + rubberBand(value - max, size, c)
|
|
137
|
+
return value
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface DismissOptions {
|
|
141
|
+
/** Offset toward the dismissing side, in px (positive is toward it). */
|
|
142
|
+
offset: number
|
|
143
|
+
/** Velocity toward the dismissing side, px/ms. */
|
|
144
|
+
velocity: number
|
|
145
|
+
/** The size of what is being swiped away along the swipe, in px. */
|
|
146
|
+
size: number
|
|
147
|
+
/** Fraction of `size` past which a slow release dismisses. Default 0.5. */
|
|
148
|
+
ratio?: number
|
|
149
|
+
/** Px/ms past which a release dismisses from any distance. Default FLICK_VELOCITY. */
|
|
150
|
+
flick?: number
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Swipe to dismiss: whether a release should carry it away or let it spring back. */
|
|
154
|
+
export function shouldDismiss({ offset, velocity, size, ratio = 0.5, flick = FLICK_VELOCITY }: DismissOptions): boolean {
|
|
155
|
+
if (offset <= 0) return false
|
|
156
|
+
if (velocity >= flick && offset > 4) return true
|
|
157
|
+
return offset >= size * ratio && velocity > -flick
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface PageSwipeOptions {
|
|
161
|
+
index: number
|
|
162
|
+
count: number
|
|
163
|
+
/** Drag offset along the strip in px, positive toward the previous item. */
|
|
164
|
+
offset: number
|
|
165
|
+
/** Velocity in px/ms, same sign as `offset`. */
|
|
166
|
+
velocity: number
|
|
167
|
+
/** Width of one item in px. */
|
|
168
|
+
size: number
|
|
169
|
+
/** Fraction of `size` that pages at any speed. Default 0.25. */
|
|
170
|
+
ratio?: number
|
|
171
|
+
flick?: number
|
|
172
|
+
/** Wrap past the ends instead of stopping. */
|
|
173
|
+
loop?: boolean
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Swipe between items: the index a release lands on, one step at most. */
|
|
177
|
+
export function pageAfterSwipe({ index, count, offset, velocity, size, ratio = 0.25, flick = FLICK_VELOCITY, loop = false }: PageSwipeOptions): number {
|
|
178
|
+
if (count <= 0) return 0
|
|
179
|
+
const far = Math.abs(offset) >= size * ratio
|
|
180
|
+
const flung = Math.abs(velocity) >= flick && Math.sign(velocity) === Math.sign(offset) && Math.abs(offset) > 8
|
|
181
|
+
if (!far && !flung) return index
|
|
182
|
+
const next = index + (offset < 0 ? 1 : -1)
|
|
183
|
+
if (loop) return (next + count) % count
|
|
184
|
+
return clamp(next, 0, count - 1)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Where a fling at `velocity` px/ms comes to rest with exponential friction of time constant `tau` ms. */
|
|
188
|
+
export function momentumDistance(velocity: number, tau = 240): number {
|
|
189
|
+
return velocity * tau
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface PinchFrame {
|
|
193
|
+
/** Distance between the two pointers now, over the distance at the start. */
|
|
194
|
+
scale: number
|
|
195
|
+
/** Midpoint of the two pointers. */
|
|
196
|
+
origin: GesturePoint
|
|
197
|
+
distance: number
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function distance(a: GesturePoint, b: GesturePoint): number {
|
|
201
|
+
return Math.hypot(a.x - b.x, a.y - b.y)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function midpoint(a: GesturePoint, b: GesturePoint): GesturePoint {
|
|
205
|
+
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The pinch between two pointers, measured against the distance it started at. */
|
|
209
|
+
export function pinchFrame(a: GesturePoint, b: GesturePoint, startDistance: number): PinchFrame {
|
|
210
|
+
const now = Math.max(1, distance(a, b))
|
|
211
|
+
return { scale: now / Math.max(1, startDistance), origin: midpoint(a, b), distance: now }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The wheel's delta in pixels, whatever unit the device reported it in. */
|
|
215
|
+
export function wheelPixels(delta: number, mode: number, page: number): number {
|
|
216
|
+
if (mode === 1) return delta * 16
|
|
217
|
+
if (mode === 2) return delta * page
|
|
218
|
+
return delta
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface DragSessionOptions {
|
|
222
|
+
/** Px of travel before a press becomes a drag. */
|
|
223
|
+
slop: number
|
|
224
|
+
/** Only a drag along this axis counts; one along the other is rejected. `lock` takes whichever comes first. */
|
|
225
|
+
axis?: GestureAxis | "lock"
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface DragFrame {
|
|
229
|
+
/** `pending` inside the slop, `drag` once committed, `rejected` when it went the other way. */
|
|
230
|
+
phase: "pending" | "drag" | "rejected"
|
|
231
|
+
/** True on the frame the drag committed. */
|
|
232
|
+
first: boolean
|
|
233
|
+
start: GesturePoint
|
|
234
|
+
point: GesturePoint
|
|
235
|
+
movement: GesturePoint
|
|
236
|
+
delta: GesturePoint
|
|
237
|
+
velocity: GesturePoint
|
|
238
|
+
axis: GestureAxis | null
|
|
239
|
+
elapsed: number
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface DragSession {
|
|
243
|
+
readonly phase: DragFrame["phase"]
|
|
244
|
+
/** Feed the pointer's position; returns where the gesture stands. */
|
|
245
|
+
move: (point: GesturePoint, time: number) => DragFrame
|
|
246
|
+
/** The final frame at release, with the swipe it made (if it was a drag). */
|
|
247
|
+
end: (point: GesturePoint, time: number, swipe?: SwipeOptions) => DragFrame & { swipe: Direction | null }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* One press followed from down to up, whatever events carry it: useGesture
|
|
252
|
+
* feeds it pointer events, a pull from the screen edge feeds it touches. It
|
|
253
|
+
* decides when the press becomes a drag, along which axis, how fast it moves
|
|
254
|
+
* and whether the release was a swipe, so every gesture in the kit draws the
|
|
255
|
+
* line in the same place.
|
|
256
|
+
*/
|
|
257
|
+
export function createDragSession(start: GesturePoint, time: number, options: DragSessionOptions): DragSession {
|
|
258
|
+
const tracker = createVelocityTracker()
|
|
259
|
+
tracker.add(start, time)
|
|
260
|
+
let phase: DragFrame["phase"] = "pending"
|
|
261
|
+
let axis: GestureAxis | null = null
|
|
262
|
+
let last = start
|
|
263
|
+
|
|
264
|
+
function frame(point: GesturePoint, at: number, first: boolean): DragFrame {
|
|
265
|
+
const f: DragFrame = {
|
|
266
|
+
phase,
|
|
267
|
+
first,
|
|
268
|
+
start,
|
|
269
|
+
point,
|
|
270
|
+
movement: { x: point.x - start.x, y: point.y - start.y },
|
|
271
|
+
delta: { x: point.x - last.x, y: point.y - last.y },
|
|
272
|
+
velocity: tracker.velocity(at),
|
|
273
|
+
axis,
|
|
274
|
+
elapsed: at - time,
|
|
275
|
+
}
|
|
276
|
+
last = point
|
|
277
|
+
return f
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
get phase() {
|
|
282
|
+
return phase
|
|
283
|
+
},
|
|
284
|
+
move(point, at) {
|
|
285
|
+
tracker.add(point, at)
|
|
286
|
+
let first = false
|
|
287
|
+
if (phase === "pending") {
|
|
288
|
+
const movement = { x: point.x - start.x, y: point.y - start.y }
|
|
289
|
+
if (Math.hypot(movement.x, movement.y) >= options.slop) {
|
|
290
|
+
if (!options.axis) {
|
|
291
|
+
phase = "drag"
|
|
292
|
+
first = true
|
|
293
|
+
} else {
|
|
294
|
+
const taken = lockAxis(movement, options.slop)
|
|
295
|
+
if (taken && options.axis !== "lock" && taken !== options.axis) phase = "rejected"
|
|
296
|
+
else if (taken) {
|
|
297
|
+
axis = taken
|
|
298
|
+
phase = "drag"
|
|
299
|
+
first = true
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (phase !== "drag") return frame(point, at, false)
|
|
305
|
+
return frame(point, at, first)
|
|
306
|
+
},
|
|
307
|
+
end(point, at, swipe) {
|
|
308
|
+
tracker.add(point, at)
|
|
309
|
+
const f = frame(point, at, false)
|
|
310
|
+
const direction = phase === "drag" ? swipeDirection(f.movement, f.velocity, { axis: axis ?? undefined, ...swipe }) : null
|
|
311
|
+
return { ...f, swipe: direction }
|
|
312
|
+
},
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -45,6 +45,10 @@ export interface ScrollToElementOptions {
|
|
|
45
45
|
behavior?: ScrollBehavior
|
|
46
46
|
/** Room kept between the element and the scroller's edge. */
|
|
47
47
|
margin?: number
|
|
48
|
+
/** Which way to scroll: vertical (default) or horizontal, for a strip. */
|
|
49
|
+
axis?: "y" | "x"
|
|
50
|
+
/** Scroll this element rather than the nearest scroller along `axis`. */
|
|
51
|
+
container?: HTMLElement | null
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
/** Scroll the nearest scroller, and only it, so that `el` is in view. */
|
|
@@ -55,21 +59,28 @@ export function scrollMarginTop(el: Element): number {
|
|
|
55
59
|
|
|
56
60
|
export function scrollToElement(
|
|
57
61
|
el: Element | null | undefined,
|
|
58
|
-
{ block = "nearest", behavior = "auto", margin = 0 }: ScrollToElementOptions = {},
|
|
62
|
+
{ block = "nearest", behavior = "auto", margin = 0, axis = "y", container }: ScrollToElementOptions = {},
|
|
59
63
|
): void {
|
|
60
64
|
if (!el) return
|
|
61
65
|
// The element's own document and window: inside an LpFrame they are the frame's.
|
|
62
66
|
const doc = el.ownerDocument
|
|
63
67
|
const win = doc.defaultView ?? window
|
|
64
|
-
const box = scrollParent(el) ?? (doc.scrollingElement as HTMLElement | null)
|
|
68
|
+
const box = container ?? scrollParent(el, { axis }) ?? (doc.scrollingElement as HTMLElement | null)
|
|
65
69
|
if (!box) return
|
|
66
70
|
const outer = box === doc.scrollingElement ? new DOMRect(0, 0, win.innerWidth, win.innerHeight) : box.getBoundingClientRect()
|
|
67
71
|
const inner = el.getBoundingClientRect()
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
const x = axis === "x"
|
|
73
|
+
const from = x ? box.scrollLeft : box.scrollTop
|
|
74
|
+
const view = x ? box.clientWidth : box.clientHeight
|
|
75
|
+
const size = x ? inner.width : inner.height
|
|
76
|
+
const lo = x ? inner.left - outer.left : inner.top - outer.top
|
|
77
|
+
const hi = x ? inner.right - outer.right : inner.bottom - outer.bottom
|
|
78
|
+
const at = lo + from
|
|
79
|
+
let target = from
|
|
80
|
+
if (block === "start") target = at - margin
|
|
81
|
+
else if (block === "center") target = at - (view - size) / 2
|
|
82
|
+
else if (lo < margin) target = at - margin
|
|
83
|
+
else if (hi > -margin) target = at - view + size + margin
|
|
84
|
+
if (target === from) return
|
|
85
|
+
box.scrollTo(x ? { left: target, behavior } : { top: target, behavior })
|
|
75
86
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { onScopeDispose, readonly, ref, type Ref } from "vue"
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* A new build is out and every open tab should run it, without anyone losing
|
|
5
|
+
* what they were doing.
|
|
6
|
+
*
|
|
7
|
+
* The app tells the kit two things: which build this page is (`current`) and
|
|
8
|
+
* how to ask which build is live (`latest`). The kit asks on its own when the
|
|
9
|
+
* tab comes back into view and on a timer, and the app calls `check()` when it
|
|
10
|
+
* has a better hint (a live stream reconnecting usually means the server was
|
|
11
|
+
* redeployed).
|
|
12
|
+
*
|
|
13
|
+
* Reloading is the easy part; the rule is WHEN. A reload is only safe when it
|
|
14
|
+
* cannot take anything with it, so the kit waits until one of these holds:
|
|
15
|
+
* - the tab is hidden (nobody is looking, the next look gets the new build);
|
|
16
|
+
* - the page has been left alone for `idleMs`, no dialog, menu, popover or
|
|
17
|
+
* listbox is open, focus is not in a text field, and `busy()` (the app's own
|
|
18
|
+
* "there is unsaved work" answer) is false.
|
|
19
|
+
* What survives the reload: the address (routes keep their state in it), the
|
|
20
|
+
* window scroll position, and anything the app already keeps in storage, such
|
|
21
|
+
* as form drafts. After the reload `restored` is true for the app to say
|
|
22
|
+
* "updated" if it wants to.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface AppUpdateOptions {
|
|
26
|
+
/** The build this page is running. */
|
|
27
|
+
current: string
|
|
28
|
+
/** The build that is live now, or null when it cannot be told. */
|
|
29
|
+
latest: () => Promise<string | null>
|
|
30
|
+
/** Unsaved work the kit cannot see, such as a dirty form outside a dialog. */
|
|
31
|
+
busy?: () => boolean
|
|
32
|
+
/** Ms without input before a visible tab may reload. */
|
|
33
|
+
idleMs?: number
|
|
34
|
+
/** Ms between background checks; 0 turns the timer off. */
|
|
35
|
+
everyMs?: number
|
|
36
|
+
/** sessionStorage key for the scroll position carried over the reload. */
|
|
37
|
+
storageKey?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AppUpdate {
|
|
41
|
+
/** A newer build is live and the tab is waiting for a safe moment. */
|
|
42
|
+
pending: Readonly<Ref<boolean>>
|
|
43
|
+
/** This page load is the reload of an update. */
|
|
44
|
+
restored: Readonly<Ref<boolean>>
|
|
45
|
+
/** Ask the server now. */
|
|
46
|
+
check: () => Promise<void>
|
|
47
|
+
/** Reload at once, keeping the scroll position. */
|
|
48
|
+
reloadNow: () => void
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const OVERLAY = '[role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"], [data-state="open"][data-side]'
|
|
52
|
+
const INPUT_EVENTS = ["pointerdown", "keydown", "wheel", "touchstart", "input"] as const
|
|
53
|
+
const CARRY_MS = 60_000
|
|
54
|
+
|
|
55
|
+
interface Carried {
|
|
56
|
+
href: string
|
|
57
|
+
top: number
|
|
58
|
+
at: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function editableFocused(): boolean {
|
|
62
|
+
const el = document.activeElement
|
|
63
|
+
if (!(el instanceof HTMLElement)) return false
|
|
64
|
+
if (el.isContentEditable) return true
|
|
65
|
+
if (el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) return true
|
|
66
|
+
return el instanceof HTMLInputElement && !["button", "checkbox", "radio", "range", "submit", "reset"].includes(el.type)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function overlayOpen(): boolean {
|
|
70
|
+
return [...document.querySelectorAll<HTMLElement>(OVERLAY)].some((el) => el.getClientRects().length > 0)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readCarried(key: string): Carried | null {
|
|
74
|
+
try {
|
|
75
|
+
const raw = sessionStorage.getItem(key)
|
|
76
|
+
sessionStorage.removeItem(key)
|
|
77
|
+
if (!raw) return null
|
|
78
|
+
const value = JSON.parse(raw) as Partial<Carried>
|
|
79
|
+
if (typeof value.href !== "string" || typeof value.top !== "number" || typeof value.at !== "number") return null
|
|
80
|
+
return value as Carried
|
|
81
|
+
} catch {
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function useAppUpdate(options: AppUpdateOptions): AppUpdate {
|
|
87
|
+
const key = options.storageKey ?? "lp:app-update"
|
|
88
|
+
const idleMs = options.idleMs ?? 30_000
|
|
89
|
+
const pending = ref(false)
|
|
90
|
+
const restored = ref(false)
|
|
91
|
+
let lastInput = Date.now()
|
|
92
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
93
|
+
|
|
94
|
+
const carried = readCarried(key)
|
|
95
|
+
if (carried && carried.href === location.href && Date.now() - carried.at < CARRY_MS) {
|
|
96
|
+
restored.value = true
|
|
97
|
+
const top = carried.top
|
|
98
|
+
const restore = () => window.scrollTo({ top, behavior: "instant" })
|
|
99
|
+
requestAnimationFrame(() => requestAnimationFrame(restore))
|
|
100
|
+
window.addEventListener("load", restore, { once: true })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function reloadNow(): void {
|
|
104
|
+
try {
|
|
105
|
+
sessionStorage.setItem(key, JSON.stringify({ href: location.href, top: window.scrollY, at: Date.now() }))
|
|
106
|
+
} catch {
|
|
107
|
+
// Storage off: the reload still happens, only the scroll is lost.
|
|
108
|
+
}
|
|
109
|
+
location.reload()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function safe(): boolean {
|
|
113
|
+
if (document.hidden) return true
|
|
114
|
+
if (Date.now() - lastInput < idleMs) return false
|
|
115
|
+
return !overlayOpen() && !editableFocused() && !options.busy?.()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function attempt(): void {
|
|
119
|
+
if (pending.value && safe()) reloadNow()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function check(): Promise<void> {
|
|
123
|
+
if (pending.value) return attempt()
|
|
124
|
+
let live: string | null = null
|
|
125
|
+
try {
|
|
126
|
+
live = await options.latest()
|
|
127
|
+
} catch {
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
if (!live || live === options.current) return
|
|
131
|
+
pending.value = true
|
|
132
|
+
attempt()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const onInput = () => {
|
|
136
|
+
lastInput = Date.now()
|
|
137
|
+
}
|
|
138
|
+
const onVisibility = () => {
|
|
139
|
+
if (document.hidden) attempt()
|
|
140
|
+
else void check()
|
|
141
|
+
}
|
|
142
|
+
for (const name of INPUT_EVENTS) window.addEventListener(name, onInput, { passive: true, capture: true })
|
|
143
|
+
document.addEventListener("visibilitychange", onVisibility)
|
|
144
|
+
const every = options.everyMs ?? 5 * 60_000
|
|
145
|
+
const tick = setInterval(attempt, 5_000)
|
|
146
|
+
if (every > 0) timer = setInterval(() => void check(), every)
|
|
147
|
+
|
|
148
|
+
onScopeDispose(() => {
|
|
149
|
+
for (const name of INPUT_EVENTS) window.removeEventListener(name, onInput, { capture: true })
|
|
150
|
+
document.removeEventListener("visibilitychange", onVisibility)
|
|
151
|
+
clearInterval(tick)
|
|
152
|
+
if (timer) clearInterval(timer)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
return { pending: readonly(pending), restored: readonly(restored), check, reloadNow }
|
|
156
|
+
}
|