@solidrt/core 0.0.60 → 0.0.62
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/AGENTS.md +100 -16
- package/agents/performance.md +40 -3
- package/docs/index.md +13 -0
- package/examples/README.md +2 -1
- package/examples/gestures.tsx +197 -0
- package/examples/layout-slide.tsx +44 -15
- package/package.json +6 -5
- package/src/arena.ts +55 -0
- package/src/camera-control.ts +428 -0
- package/src/cursor.ts +113 -0
- package/src/double-tap.ts +150 -0
- package/src/environment.ts +27 -0
- package/src/gesture.ts +32 -0
- package/src/index.ts +17 -3
- package/src/input-axes.ts +12 -7
- package/src/input-chord.ts +3 -0
- package/src/input-gamepad-device.ts +115 -6
- package/src/input-id.ts +92 -0
- package/src/input-keyboard.ts +49 -2
- package/src/input-map.ts +268 -68
- package/src/input-pointer.ts +240 -16
- package/src/input-processors.ts +214 -0
- package/src/input.ts +4 -2
- package/src/long-press.ts +137 -0
- package/src/pan.ts +23 -4
- package/src/renderer.ts +16 -6
- package/src/runtime-modules.d.ts +27 -0
- package/src/svg.ts +1 -0
- package/src/swipe.ts +130 -0
- package/src/text-input.ts +193 -48
- package/src/transform.ts +23 -3
- package/src/types.d.ts +44 -7
- package/src/velocity.ts +134 -0
- package/src/video.ts +7 -6
- package/src/window.ts +48 -10
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// The long-press recognizer: a pointer held LONG_PRESS_MS without leaving
|
|
2
|
+
// TAP_SLOP of its down fires while still down, then streams moves until
|
|
3
|
+
// the lift (drag-after-long-press is the reorder idiom: the finger that
|
|
4
|
+
// held a row now carries it).
|
|
5
|
+
//
|
|
6
|
+
// Arena: the down arms silently, no claim. A claim at the down would be
|
|
7
|
+
// refused wherever a press already holds the pointer (the same node, or
|
|
8
|
+
// an inner pressable), and the wanted behaviour there is the press
|
|
9
|
+
// showing pressed and the long-press winning at the timer, so the steal
|
|
10
|
+
// happens at the timer instead: the press is cancelled (its feedback
|
|
11
|
+
// retracts, it never fires), an ancestor pan can no longer take the
|
|
12
|
+
// finger, moves stream to the consumer. A pan that crossed its slop first
|
|
13
|
+
// resolved the arena, so the timer's steal is refused and the recognizer
|
|
14
|
+
// stands down: scrolling a list of long-pressable rows scrolls. Nested
|
|
15
|
+
// long-presses resolve innermost-first for free: the inner timer was
|
|
16
|
+
// registered first in the leaf-to-root walk. Options are read at event
|
|
17
|
+
// time; cancel() retracts without an end; an unmount clears the timer and
|
|
18
|
+
// the claim. Mouse and touch alike (a held button long-presses).
|
|
19
|
+
//
|
|
20
|
+
// The hold timer itself (createHoldTimer) is shared with the pointer
|
|
21
|
+
// feed's longPress source, which pulses instead of stealing: the feed is
|
|
22
|
+
// one arena claimant (its transform) and must stay so.
|
|
23
|
+
|
|
24
|
+
import { onSettled } from "@solidjs/signals"
|
|
25
|
+
import type { PointerEvent } from "./types"
|
|
26
|
+
import { arena, type ArenaOwner } from "./arena"
|
|
27
|
+
import { pastSlop, pointAt, type PointerPoint } from "./gesture"
|
|
28
|
+
|
|
29
|
+
// Hold before a long-press fires: Flutter, iOS and Android's upper value.
|
|
30
|
+
const LONG_PRESS_MS = 500
|
|
31
|
+
|
|
32
|
+
export interface HoldTimerOptions {
|
|
33
|
+
/** Read when the timer is armed. */
|
|
34
|
+
ms?: () => number | undefined
|
|
35
|
+
onFire: (pointerId: number, at: PointerPoint, event: { shiftKey: boolean; ctrlKey: boolean; altKey: boolean; metaKey: boolean; button?: number }) => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The arm / slop / timer machine under a long-press: one pointer at a
|
|
39
|
+
* time, disarmed by travel, by a lift, by a second pointer or by cancel(). */
|
|
40
|
+
export function createHoldTimer(options: HoldTimerOptions) {
|
|
41
|
+
let armed: { id: number; at: PointerPoint; mods: { shiftKey: boolean; ctrlKey: boolean; altKey: boolean; metaKey: boolean; button?: number } } | null = null
|
|
42
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
43
|
+
let disarm = () => {
|
|
44
|
+
if (timer !== null) clearTimeout(timer)
|
|
45
|
+
timer = null
|
|
46
|
+
armed = null
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
/** The pointer armed right now, if any. */
|
|
50
|
+
armed: () => armed?.id ?? null,
|
|
51
|
+
down(e: PointerEvent) {
|
|
52
|
+
if (armed) {
|
|
53
|
+
// A second finger is not a long-press.
|
|
54
|
+
disarm()
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
let ms = options.ms?.() ?? LONG_PRESS_MS
|
|
58
|
+
armed = { id: e.pointerId, at: pointAt(e), mods: { shiftKey: e.shiftKey, ctrlKey: e.ctrlKey, altKey: e.altKey, metaKey: e.metaKey, button: e.button } }
|
|
59
|
+
timer = setTimeout(() => {
|
|
60
|
+
timer = null
|
|
61
|
+
let a = armed!
|
|
62
|
+
armed = null
|
|
63
|
+
options.onFire(a.id, a.at, a.mods)
|
|
64
|
+
}, ms)
|
|
65
|
+
},
|
|
66
|
+
move(e: PointerEvent) {
|
|
67
|
+
if (armed && armed.id === e.pointerId && pastSlop(e, armed.at)) disarm()
|
|
68
|
+
},
|
|
69
|
+
up(e: PointerEvent) {
|
|
70
|
+
if (armed && armed.id === e.pointerId) disarm()
|
|
71
|
+
},
|
|
72
|
+
cancel: disarm,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface LongPressOptions {
|
|
77
|
+
/** Hold before the fire, default 500 ms. */
|
|
78
|
+
ms?: number
|
|
79
|
+
/** Fired at the timer while the pointer is still down, with the down's
|
|
80
|
+
* position. */
|
|
81
|
+
onLongPress?: (at: PointerPoint) => void
|
|
82
|
+
/** Movement since the previous event after the fire, in the handler
|
|
83
|
+
* node's parent frame (createPan's frame rule). */
|
|
84
|
+
onLongPressMove?: (dx: number, dy: number) => void
|
|
85
|
+
/** The lift after a fire. */
|
|
86
|
+
onLongPressEnd?: () => void
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createLongPress(options: LongPressOptions) {
|
|
90
|
+
// The pointer the long-press won, and its last delivered parent position.
|
|
91
|
+
let fired: number | null = null
|
|
92
|
+
let last: { x: number; y: number } | null = null
|
|
93
|
+
let hold = createHoldTimer({
|
|
94
|
+
ms: () => options.ms,
|
|
95
|
+
onFire: (id, at) => {
|
|
96
|
+
if (!arena.steal(id, owner)) return
|
|
97
|
+
fired = id
|
|
98
|
+
last = { x: at.parentX, y: at.parentY }
|
|
99
|
+
options.onLongPress?.(at)
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
let cancel = () => {
|
|
103
|
+
hold.cancel()
|
|
104
|
+
if (fired !== null) {
|
|
105
|
+
arena.release(fired, owner)
|
|
106
|
+
fired = null
|
|
107
|
+
last = null
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
let owner: ArenaOwner = { cancel }
|
|
111
|
+
onSettled(() => cancel)
|
|
112
|
+
|
|
113
|
+
let handlers = {
|
|
114
|
+
onPointerDown: (e: PointerEvent) => {
|
|
115
|
+
if (e.button != null && e.button !== 0) return
|
|
116
|
+
if (fired !== null) return
|
|
117
|
+
hold.down(e)
|
|
118
|
+
},
|
|
119
|
+
onPointerMove: (e: PointerEvent) => {
|
|
120
|
+
if (fired === e.pointerId && last) {
|
|
121
|
+
options.onLongPressMove?.(e.parentX - last.x, e.parentY - last.y)
|
|
122
|
+
last = { x: e.parentX, y: e.parentY }
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
hold.move(e)
|
|
126
|
+
},
|
|
127
|
+
onPointerUp: (e: PointerEvent) => {
|
|
128
|
+
if (fired === e.pointerId) {
|
|
129
|
+
cancel()
|
|
130
|
+
options.onLongPressEnd?.()
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
hold.up(e)
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
return { handlers, cancel }
|
|
137
|
+
}
|
package/src/pan.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { onSettled } from "@solidjs/signals"
|
|
2
2
|
import type { PointerEvent } from "./types"
|
|
3
3
|
import { arena } from "./arena"
|
|
4
|
+
import { createVelocityTracker, flingVelocity } from "./velocity"
|
|
5
|
+
import type { Velocity } from "./velocity"
|
|
4
6
|
|
|
5
7
|
// Movement in logical pixels before a pan activates. Below this a drag still
|
|
6
8
|
// reads as a press (tap wiggle); crossing it is the positive evidence that the
|
|
@@ -26,7 +28,13 @@ export interface PanOptions {
|
|
|
26
28
|
* Positive dx is rightward, positive dy downward.
|
|
27
29
|
*/
|
|
28
30
|
onPanMove?: (dx: number, dy: number) => void
|
|
29
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The lift, with the pointer's velocity at it in the same parent-frame
|
|
33
|
+
* pixels per second (a least-squares fit over the last 100 ms, see
|
|
34
|
+
* velocity.ts): the fling fact for whoever animates on. Zero when the
|
|
35
|
+
* finger rested before lifting or moved under FLING_MIN_VELOCITY.
|
|
36
|
+
*/
|
|
37
|
+
onPanEnd?: (velocity: Velocity) => void
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
// The pan recognizer: turns a drag into a movement-delta stream. On a down it
|
|
@@ -36,8 +44,11 @@ export interface PanOptions {
|
|
|
36
44
|
// resolving it so no other recognizer can take the drag over. If the arena is
|
|
37
45
|
// already resolved (an inner pan won first) the recognizer disarms and stays
|
|
38
46
|
// out. The slop distance itself is swallowed: deltas stream from the
|
|
39
|
-
// activation point on
|
|
40
|
-
//
|
|
47
|
+
// activation point on, and the up carries the lift velocity (velocity.ts: a
|
|
48
|
+
// fit over the last 100 ms of positions, zero after a rest or under the
|
|
49
|
+
// fling minimum) for momentum. Moves and the up arrive on the frozen down
|
|
50
|
+
// path, so an active pan keeps streaming when the pointer leaves the node
|
|
51
|
+
// or the window.
|
|
41
52
|
// cancel() is the external-cancel hook; it ends an active pan without
|
|
42
53
|
// onPanEnd. Options are read at event time. Single-pointer by design; for
|
|
43
54
|
// multi-pointer pinch/rotate compose createTransform instead.
|
|
@@ -55,6 +66,8 @@ export function createPan(options: PanOptions) {
|
|
|
55
66
|
let origin: { x: number; y: number } | null = null
|
|
56
67
|
let active: number | null = null
|
|
57
68
|
let armed: number | null = null
|
|
69
|
+
// Parent-frame positions of the active pointer, for the lift's velocity.
|
|
70
|
+
let tracker = createVelocityTracker()
|
|
58
71
|
|
|
59
72
|
let past = (e: PointerEvent) => {
|
|
60
73
|
if (!origin) return false
|
|
@@ -94,6 +107,8 @@ export function createPan(options: PanOptions) {
|
|
|
94
107
|
active = e.pointerId
|
|
95
108
|
armed = null
|
|
96
109
|
origin = { x: e.parentX, y: e.parentY }
|
|
110
|
+
tracker.reset()
|
|
111
|
+
tracker.push(e.parentX, e.parentY)
|
|
97
112
|
options.onPanStart?.()
|
|
98
113
|
} else {
|
|
99
114
|
// The arena is resolved against us; the drag belongs elsewhere.
|
|
@@ -102,14 +117,18 @@ export function createPan(options: PanOptions) {
|
|
|
102
117
|
return
|
|
103
118
|
}
|
|
104
119
|
if (active === e.pointerId && origin) {
|
|
120
|
+
tracker.push(e.parentX, e.parentY)
|
|
105
121
|
options.onPanMove?.(e.parentX - origin.x, e.parentY - origin.y)
|
|
106
122
|
origin = { x: e.parentX, y: e.parentY }
|
|
107
123
|
}
|
|
108
124
|
},
|
|
109
125
|
onPointerUp: (e: PointerEvent) => {
|
|
110
126
|
if (active === e.pointerId) {
|
|
127
|
+
// The up is not a sample: it sits where the last move left the
|
|
128
|
+
// pointer, and its age is what tells a rest from a flick.
|
|
129
|
+
let velocity = flingVelocity(tracker.velocity())
|
|
111
130
|
reset()
|
|
112
|
-
options.onPanEnd?.()
|
|
131
|
+
options.onPanEnd?.(velocity)
|
|
113
132
|
} else if (armed === e.pointerId) {
|
|
114
133
|
reset()
|
|
115
134
|
}
|
package/src/renderer.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
// createErrorBoundary comes from signals, not solid-js: solid-js marks it
|
|
2
|
+
// @internal (an app writes <Errored>, a renderer reaches for the primitive)
|
|
3
|
+
// and strips it from its .d.ts, so the solid-js re-export types as any.
|
|
4
|
+
import { createErrorBoundary, createRoot, onCleanup, NotReadyError, untrack } from "@solidjs/signals"
|
|
2
5
|
import { createRenderer } from "@solidjs/universal"
|
|
3
|
-
import { createErrorBoundary } from "solid-js"
|
|
4
6
|
import type { Element } from "solid-js"
|
|
5
7
|
import * as tree from "flux:rendertree"
|
|
6
8
|
import { attachWindow, setWindowRoot } from "./window"
|
|
@@ -116,6 +118,10 @@ function removeNode(parent: ProxyNode, node: ProxyNode): void {
|
|
|
116
118
|
const SENTINEL_INTERVAL_MS = 5000
|
|
117
119
|
let sentinelDue = 0
|
|
118
120
|
let warnedLeakTypes = new Set<string>()
|
|
121
|
+
// The orphan total's order of magnitude (floor of log10) at the last
|
|
122
|
+
// warning: a leak that keeps growing at a stable set of types warns again
|
|
123
|
+
// each time the total crosses the next power of ten.
|
|
124
|
+
let warnedMagnitude = -1
|
|
119
125
|
|
|
120
126
|
export function scanForOrphans(now: number): void {
|
|
121
127
|
if (!import.meta.env.DEV) return
|
|
@@ -129,18 +135,22 @@ export function scanForOrphans(now: number): void {
|
|
|
129
135
|
counts.set(node.elementType, (counts.get(node.elementType) ?? 0) + 1)
|
|
130
136
|
}
|
|
131
137
|
if (total === 0) return
|
|
132
|
-
// Warn
|
|
133
|
-
//
|
|
138
|
+
// Warn when a new element type joins the orphans or the total crosses the
|
|
139
|
+
// next order of magnitude, but list every type with its count so the
|
|
140
|
+
// breakdown always adds up to the total.
|
|
134
141
|
let fresh = [...counts].filter(([type]) => !warnedLeakTypes.has(type))
|
|
135
|
-
|
|
142
|
+
let magnitude = Math.floor(Math.log10(total))
|
|
143
|
+
if (fresh.length === 0 && magnitude <= warnedMagnitude) return
|
|
136
144
|
for (let [type] of fresh) warnedLeakTypes.add(type)
|
|
145
|
+
warnedMagnitude = magnitude
|
|
137
146
|
let list = [...counts].map(([type, n]) => `<${type}> x${n}`).join(", ")
|
|
138
147
|
console.warn(
|
|
139
148
|
`Leak sentinel: ${total} nodes are unreachable and will never be freed: ${list}. ` +
|
|
140
149
|
`The usual cause is reading an element-valued prop more than once (every read ` +
|
|
141
150
|
`builds a new subtree); read it once where it mounts, or resolve it with ` +
|
|
142
151
|
`children(). If these nodes are intentionally kept for later mounting, ignore ` +
|
|
143
|
-
`this. The next warning comes when a new element type joins the list
|
|
152
|
+
`this. The next warning comes when a new element type joins the list or the ` +
|
|
153
|
+
`total passes ${10 ** (magnitude + 1)}.`,
|
|
144
154
|
)
|
|
145
155
|
}
|
|
146
156
|
|
package/src/runtime-modules.d.ts
CHANGED
|
@@ -51,6 +51,10 @@ declare module "*.glb" {
|
|
|
51
51
|
const bytes: Uint8Array
|
|
52
52
|
export default bytes
|
|
53
53
|
}
|
|
54
|
+
declare module "*.srtm" {
|
|
55
|
+
const bytes: Uint8Array
|
|
56
|
+
export default bytes
|
|
57
|
+
}
|
|
54
58
|
|
|
55
59
|
// UI event bus (lattice), provided by the runtime as a builtin module.
|
|
56
60
|
// on/once return an unsubscribe function. Notable events: the routed pointer
|
|
@@ -79,6 +83,18 @@ declare module "srt:app" {
|
|
|
79
83
|
* `back` on Android; exposed there as `background`.
|
|
80
84
|
*/
|
|
81
85
|
export function background(): void
|
|
86
|
+
/**
|
|
87
|
+
* Make this install the handler for the app's own scheme (its appId, as
|
|
88
|
+
* in `com.example.app://settings`), so links of it open the app: on Linux
|
|
89
|
+
* a desktop entry plus the mimeapps.list default, on Windows the
|
|
90
|
+
* HKCU\Software\Classes protocol key, both for the current user. Throws
|
|
91
|
+
* when the registration cannot be written. Android and macOS packages
|
|
92
|
+
* declare the scheme themselves, so the call has nothing to do there; a
|
|
93
|
+
* dev client registers nothing and logs a warning. Prefer the
|
|
94
|
+
* @solidrt/core `registerProtocolHandler`, the same call documented with
|
|
95
|
+
* the rest of the link surface.
|
|
96
|
+
*/
|
|
97
|
+
export function registerProtocolHandler(): void
|
|
82
98
|
}
|
|
83
99
|
|
|
84
100
|
// Dev-server control surface (lattice). Present only in dev/go builds; in other
|
|
@@ -106,6 +122,17 @@ declare module "srt:dev" {
|
|
|
106
122
|
* clients ever invoke commands.
|
|
107
123
|
*/
|
|
108
124
|
export function registerDebug(name: string, fn: (args?: any) => unknown): void
|
|
125
|
+
/**
|
|
126
|
+
* Report where the app is, for tooling: a string naming the current
|
|
127
|
+
* location (a router's path; an app routing by hand may report its own),
|
|
128
|
+
* called again on every change. The runtime stores the value and never
|
|
129
|
+
* interprets it. The dev server reads it (`GET /__control__/link`, the
|
|
130
|
+
* get_location MCP tool), and a hot reload of the same app starts with the
|
|
131
|
+
* last reported location as its `env.launchLink`, so a rebuild comes back
|
|
132
|
+
* to the screen it left. null withdraws it. Callable in every build, but
|
|
133
|
+
* only dev clients ever read it.
|
|
134
|
+
*/
|
|
135
|
+
export function reportLocation(location: string | null): void
|
|
109
136
|
}
|
|
110
137
|
|
|
111
138
|
// Installed-app management (lattice), the player's surface over the client's
|
package/src/svg.ts
CHANGED
|
@@ -27,6 +27,7 @@ export type SvgDraw = {
|
|
|
27
27
|
color: string | Gradient
|
|
28
28
|
drawStyle: "fill" | "stroke"
|
|
29
29
|
fillRule?: "nonzero" | "evenodd"
|
|
30
|
+
/** Absent when the source omits it: the d-path default of 1 applies, SVG's own. */
|
|
30
31
|
strokeWidth?: number
|
|
31
32
|
strokeCap?: StrokeCap
|
|
32
33
|
strokeJoin?: StrokeJoin
|
package/src/swipe.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// The swipe recognizer: a one-pointer drag that, at the lift, has
|
|
2
|
+
// travelled SWIPE_MIN_DISTANCE from its down at SWIPE_MIN_VELOCITY or
|
|
3
|
+
// more, within SWIPE_ANGLE_TOLERANCE of one of the four axes. Discrete:
|
|
4
|
+
// onSwipe fires once, at the lift, with the direction and the velocity.
|
|
5
|
+
// Direction is the dominant axis of the VELOCITY, not of the travel (a
|
|
6
|
+
// finger that wandered then flicked swipes where it flicked). A drag
|
|
7
|
+
// that ends without qualifying gets onSwipeEnd alone, so a consumer that
|
|
8
|
+
// followed the finger snaps back.
|
|
9
|
+
//
|
|
10
|
+
// Built on createPan with the pan's axis derived from the allowed
|
|
11
|
+
// directions: a horizontal-only swipe inside a vertical ScrollView takes
|
|
12
|
+
// only horizontal drags, exactly as nested scrollers share drags today,
|
|
13
|
+
// and it steals at the pan's slop, so a pressable under it retracts. The
|
|
14
|
+
// pan measures the parent-frame velocity it hands the consumer; the
|
|
15
|
+
// thresholds are finger travel, so the swipe keeps a second tracker in
|
|
16
|
+
// window pixels over the events it wraps. The classifier is shared with
|
|
17
|
+
// the pointer feed's swipe sources. Mouse and touch alike.
|
|
18
|
+
|
|
19
|
+
import { createPan } from "./pan"
|
|
20
|
+
import type { PointerEvent } from "./types"
|
|
21
|
+
import { createVelocityTracker } from "./velocity"
|
|
22
|
+
import type { Velocity } from "./velocity"
|
|
23
|
+
|
|
24
|
+
export type SwipeDirection = "Left" | "Right" | "Up" | "Down"
|
|
25
|
+
|
|
26
|
+
export const SWIPE_DIRECTIONS: readonly SwipeDirection[] = ["Left", "Right", "Up", "Down"]
|
|
27
|
+
|
|
28
|
+
// Least finger travel (window px) from the down to the lift.
|
|
29
|
+
const SWIPE_MIN_DISTANCE = 24
|
|
30
|
+
// Least finger speed at the lift (window px/s): Android's usual
|
|
31
|
+
// minimumFlingVelocity of 50 dp/s at the density it assumes.
|
|
32
|
+
const SWIPE_MIN_VELOCITY = 300
|
|
33
|
+
// How far off an axis (degrees) the velocity may point.
|
|
34
|
+
const SWIPE_ANGLE_TOLERANCE = 30
|
|
35
|
+
const OFF_AXIS_RATIO = Math.tan((SWIPE_ANGLE_TOLERANCE * Math.PI) / 180)
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The direction a lift swipes in, or null: `finger` is the velocity in
|
|
39
|
+
* window px/s, `travel` the window-px offset from the down.
|
|
40
|
+
*/
|
|
41
|
+
export function classifySwipe(finger: Velocity, travel: { dx: number; dy: number }, directions: readonly SwipeDirection[] = SWIPE_DIRECTIONS): SwipeDirection | null {
|
|
42
|
+
if (Math.hypot(travel.dx, travel.dy) < SWIPE_MIN_DISTANCE) return null
|
|
43
|
+
let ax = Math.abs(finger.vx)
|
|
44
|
+
let ay = Math.abs(finger.vy)
|
|
45
|
+
if (Math.hypot(ax, ay) < SWIPE_MIN_VELOCITY) return null
|
|
46
|
+
let direction: SwipeDirection
|
|
47
|
+
if (ax >= ay) {
|
|
48
|
+
if (ay > ax * OFF_AXIS_RATIO) return null
|
|
49
|
+
direction = finger.vx < 0 ? "Left" : "Right"
|
|
50
|
+
} else {
|
|
51
|
+
if (ax > ay * OFF_AXIS_RATIO) return null
|
|
52
|
+
direction = finger.vy < 0 ? "Up" : "Down"
|
|
53
|
+
}
|
|
54
|
+
return directions.includes(direction) ? direction : null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SwipeOptions {
|
|
58
|
+
/** Which directions count; default all four. Decides the pan's axis:
|
|
59
|
+
* only Left/Right takes horizontal drags, only Up/Down vertical ones. */
|
|
60
|
+
directions?: SwipeDirection[]
|
|
61
|
+
onSwipeStart?: () => void
|
|
62
|
+
/** Movement since the previous event in the handler node's parent frame,
|
|
63
|
+
* so a card follows the finger (createPan's frame rule). */
|
|
64
|
+
onSwipeMove?: (dx: number, dy: number) => void
|
|
65
|
+
/** The lift qualified: fired once, before onSwipeEnd. `velocity` is the
|
|
66
|
+
* pan's, parent-frame px/s. */
|
|
67
|
+
onSwipe?: (direction: SwipeDirection, velocity: Velocity) => void
|
|
68
|
+
/** Every lift of an active swipe, qualified or not. */
|
|
69
|
+
onSwipeEnd?: (velocity: Velocity) => void
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createSwipe(options: SwipeOptions) {
|
|
73
|
+
let directions = () => options.directions ?? SWIPE_DIRECTIONS
|
|
74
|
+
let axisOf = (): "horizontal" | "vertical" | "both" => {
|
|
75
|
+
let d = directions()
|
|
76
|
+
let h = d.includes("Left") || d.includes("Right")
|
|
77
|
+
let v = d.includes("Up") || d.includes("Down")
|
|
78
|
+
return h && !v ? "horizontal" : v && !h ? "vertical" : "both"
|
|
79
|
+
}
|
|
80
|
+
// The down's window position and the finger's window-px velocity, over
|
|
81
|
+
// every event the wrapped pan sees.
|
|
82
|
+
let down: { id: number; x: number; y: number } | null = null
|
|
83
|
+
// The lift's window position, set just before the pan's up runs.
|
|
84
|
+
let lift: { x: number; y: number } | null = null
|
|
85
|
+
let finger = createVelocityTracker()
|
|
86
|
+
let pan = createPan({
|
|
87
|
+
get axis() {
|
|
88
|
+
return axisOf()
|
|
89
|
+
},
|
|
90
|
+
onPanStart: () => options.onSwipeStart?.(),
|
|
91
|
+
onPanMove: (dx, dy) => options.onSwipeMove?.(dx, dy),
|
|
92
|
+
onPanEnd: velocity => {
|
|
93
|
+
let direction: SwipeDirection | null = null
|
|
94
|
+
if (down && lift) {
|
|
95
|
+
direction = classifySwipe(finger.velocity(), { dx: lift.x - down.x, dy: lift.y - down.y }, directions())
|
|
96
|
+
}
|
|
97
|
+
if (direction) options.onSwipe?.(direction, velocity)
|
|
98
|
+
options.onSwipeEnd?.(velocity)
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
let handlers = {
|
|
103
|
+
onPointerDown: (e: PointerEvent) => {
|
|
104
|
+
if (down === null && (e.button == null || e.button === 0)) {
|
|
105
|
+
down = { id: e.pointerId, x: e.clientX, y: e.clientY }
|
|
106
|
+
finger.reset()
|
|
107
|
+
finger.push(e.clientX, e.clientY)
|
|
108
|
+
}
|
|
109
|
+
pan.handlers.onPointerDown(e)
|
|
110
|
+
},
|
|
111
|
+
onPointerMove: (e: PointerEvent) => {
|
|
112
|
+
if (down && down.id === e.pointerId) finger.push(e.clientX, e.clientY)
|
|
113
|
+
pan.handlers.onPointerMove(e)
|
|
114
|
+
},
|
|
115
|
+
onPointerUp: (e: PointerEvent) => {
|
|
116
|
+
if (down && down.id === e.pointerId) lift = { x: e.clientX, y: e.clientY }
|
|
117
|
+
pan.handlers.onPointerUp(e)
|
|
118
|
+
if (down && down.id === e.pointerId) {
|
|
119
|
+
down = null
|
|
120
|
+
lift = null
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
let cancel = () => {
|
|
125
|
+
pan.cancel()
|
|
126
|
+
down = null
|
|
127
|
+
lift = null
|
|
128
|
+
}
|
|
129
|
+
return { handlers, cancel }
|
|
130
|
+
}
|