@solidrt/core 0.0.56 → 0.0.58

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.
@@ -0,0 +1,306 @@
1
+ // A pointer feed: one element's pointer events turned into gesture
2
+ // sources for an input map. The pointer is the one device that is not
3
+ // global - the tree holds UI and scene together, so a drag belongs to the
4
+ // element it started on (a slider beside a viewport must not turn the
5
+ // camera) - hence a feed per element: spread `feed.handlers` on the leaf
6
+ // (a Scene, View3d, SpriteLayer or View2d takes it as its `pointer` prop
7
+ // and spreads it on the built-in leaf) and bind its sources.
8
+ //
9
+ // The sources are gestures, not raw events, because a pointer only means
10
+ // something once a gesture is recognized: a move without a press is
11
+ // nothing, one finger dragging is `drag`, two fingers moving together is
12
+ // `pan`, spreading is `pinch`, turning is `twist`, and the wheel is its
13
+ // own thing. One merged recognizer (createTransform) serves all of them,
14
+ // so they arbitrate in the app-wide gesture arena as one - a viewport
15
+ // inside a scroller never double-handles - and they never fight each
16
+ // other over the same fingers. `mouseDelta` is the pointer-lock path:
17
+ // raw mouse motion while the pointer is locked, when the cursor does not
18
+ // move and no drag can arm.
19
+ //
20
+ // Units are device-free (the input map's contract): drag and pan travel
21
+ // in the element's own heights (a drag across the element is 1 whatever
22
+ // the window size - Three's OrbitControls convention), pinch and wheel
23
+ // in octaves (log2 of the scale change, one notch about a fifth), twist
24
+ // in turns, mouse motion in view-height-equivalents through a fixed
25
+ // reference. The focal point of a pinch or a wheel rides along as a
26
+ // fraction of the element (0..1), the anchor a zoom-about-the-cursor
27
+ // consumer maps to its own space. Screen convention: x right, y down.
28
+ //
29
+ // Gesture brackets: `drag` begins when the first pointer lands and ends
30
+ // when the last lifts (or when a second lands, which begins `pan`,
31
+ // `pinch` and `twist` until the count drops back to one); the wheel
32
+ // sends no brackets, so a consumer can tell a notch from a finger.
33
+ // Brackets fire on the press itself, before the recognizer's slop, so a
34
+ // glide stops the moment a finger lands.
35
+ //
36
+ // Chords: every source is also callable with a modifier spec, and
37
+ // `pointer.drag("Ctrl")` is the variant that fires only when the event
38
+ // opening the gesture carries Ctrl - and then instead of the bare `drag`,
39
+ // as the keyboard's most specific spec wins a key down (input-chord.ts,
40
+ // the one grammar for both devices). That is the every-viewport default,
41
+ // plain drag orbits and Ctrl-drag pans, as one binding each. A chord is
42
+ // resolved once per bracket, at the event that opens it (the down that
43
+ // starts a drag, the second down that starts pan/pinch/twist, the up
44
+ // that hands the gesture back to drag), and held for the bracket, so a
45
+ // modifier tapped mid-drag cannot split one gesture across two actions
46
+ // (Three decides at mousedown; Unreal's continuous chord splits). The
47
+ // unbracketed wheel and mouseDelta resolve per event. Touch carries no
48
+ // modifiers, so a chord binding is the desktop path and the bare one
49
+ // stays for fingers.
50
+ //
51
+ // Buttons: a bracketed spec may end in the button that opens it, as a
52
+ // key spec ends in its key - `pointer.drag("Right")`, `drag("Shift+Middle")`
53
+ // (Left, Middle, Right; bare means Left) - so Three's right-drag pan is
54
+ // a binding too. A button is a discriminator, not a modifier: a right
55
+ // drag feeds only Right specs, and the chord rule picks among those. One
56
+ // button per pointer: a second button pressed while the first is held
57
+ // joins nothing, and only the held button's release closes the gesture.
58
+ // The wheel and mouseDelta have no button; a button word on them throws.
59
+ //
60
+ // The element's laid-out box normalizes the travel: read at the press
61
+ // through getLayoutBox (the untransformed read, so a designSize fit or
62
+ // an ancestor transform never skews it), or from `layout` when given -
63
+ // required for a detached d-* leaf, which has no layout box (the feed
64
+ // throws rather than treat pixels as heights), and handy for a headless
65
+ // check with no tree. A zero-height box passes pixels through unscaled.
66
+ //
67
+ // Create a feed in an owned scope (a component body): the recognizer
68
+ // registers its cleanup with the owner.
69
+
70
+ import { createTransform } from "./transform"
71
+ import { getLayoutBox } from "./core"
72
+ import { pointerLocked } from "./window"
73
+ import type { PointerEvent, WheelEvent } from "./types"
74
+ import type { DeltaSink, InputSource } from "./input-map"
75
+ import type { Vec2 } from "./input-axes"
76
+ import { chordName, mostSpecific, parseModifiers } from "./input-chord"
77
+ import type { Modified, Modifier } from "./input-chord"
78
+
79
+ // Octaves per wheel-delta unit: the zoom exponent both cameras used
80
+ // (0.0015 per unit) taken in log2, so a 100-unit notch zooms about a
81
+ // fifth of an octave and a fast scroll of ten notches doubles.
82
+ const WHEEL_OCTAVES = 0.0015 / Math.LN2
83
+ // Mouse travel per delta unit under pointer lock, in logical pixels: one
84
+ // unit is what a drag across the element's height means to a consumer (a
85
+ // half turn of look in the first-person control), and 1500 px per half
86
+ // turn is the ~0.002 rad/px of Three's PointerLockControls.
87
+ const MOUSE_PX_PER_UNIT = 1500
88
+
89
+ /**
90
+ * A pointer gesture as a source. Bound as is, it fires on the primary
91
+ * button under any modifiers; called with a spec ("Ctrl", "Shift+Alt",
92
+ * "Right", "Shift+Middle") it is the variant that fires only when the
93
+ * event opening the gesture carries those modifiers and that button, and
94
+ * then instead of the bare one: the most specific bound chord wins. The
95
+ * same spec returns the same source, so unbind by identity works.
96
+ */
97
+ export type PointerSource<K extends "axis" | "vec2"> = InputSource<K> & ((chord: string) => InputSource<K>)
98
+
99
+ export interface PointerFeed {
100
+ /** Spread on the element whose pointer events feed the sources. */
101
+ handlers: {
102
+ onPointerDown(event: PointerEvent): void
103
+ onPointerMove(event: PointerEvent): void
104
+ onPointerUp(event: PointerEvent): void
105
+ onPointerLeave(event: PointerEvent): void
106
+ onWheel(event: WheelEvent): void
107
+ }
108
+ /** One pointer's travel, in element heights; bracketed. */
109
+ drag: PointerSource<"vec2">
110
+ /** Two or more pointers' focal travel, in element heights; bracketed. */
111
+ pan: PointerSource<"vec2">
112
+ /** Span change of two pointers, in octaves (spreading positive), with
113
+ * the focal point; bracketed. */
114
+ pinch: PointerSource<"axis">
115
+ /** Rotation of the pointer pair, in turns, with the focal point; bracketed. */
116
+ twist: PointerSource<"axis">
117
+ /** Wheel notches in octaves (wheel up positive), with the cursor as the
118
+ * focal point; unbracketed. */
119
+ wheel: PointerSource<"axis">
120
+ /** Raw mouse motion while the pointer is locked, in view-height
121
+ * equivalents (see MOUSE_PX_PER_UNIT); unbracketed. */
122
+ mouseDelta: PointerSource<"vec2">
123
+ }
124
+
125
+ export interface PointerFeedOptions {
126
+ /** The element's size, when the app knows it - required for a detached
127
+ * d-* leaf, which has no layout box; default reads getLayoutBox at each
128
+ * press. */
129
+ layout?: () => { width: number; height: number } | null
130
+ }
131
+
132
+ // Button names a bracketed spec may end in, to the event's button index.
133
+ const BUTTONS: Record<string, number> = { Left: 0, Middle: 1, Right: 2 }
134
+ const PRIMARY = 0
135
+
136
+ // One gesture's sinks, bucketed by spec (the bare source is the empty
137
+ // chord on the primary button). A bracket opens the buckets of the
138
+ // button that opened it whose modifiers the opening event carries,
139
+ // narrowed to the most specific among those with a sink bound, and
140
+ // delivers to them until it closes; an unbracketed send resolves the
141
+ // same way per event. Sinks are live sets, as before chords: a consumer
142
+ // bound mid-gesture joins it.
143
+ type Bucket = { mods: Modifier[]; button: number; sinks: Set<DeltaSink>; source: InputSource }
144
+
145
+ function gesture<K extends "axis" | "vec2">(kind: K, label: string, buttons: boolean) {
146
+ let buckets = new Map<string, Bucket>()
147
+ let open: Set<DeltaSink>[] = []
148
+ let variant = (mods: Modifier[], button: number): InputSource<K> => {
149
+ let name = [chordName(mods), button === PRIMARY ? "" : Object.keys(BUTTONS)[button]!].filter(Boolean).join("+")
150
+ let bucket = buckets.get(name)
151
+ if (!bucket) {
152
+ let sinks = new Set<DeltaSink>()
153
+ let source: InputSource<K> = {
154
+ kind,
155
+ label: name ? `${label} (${name})` : label,
156
+ deltas(sink) {
157
+ sinks.add(sink)
158
+ return () => {
159
+ sinks.delete(sink)
160
+ }
161
+ },
162
+ }
163
+ bucket = { mods, button, sinks, source }
164
+ buckets.set(name, bucket)
165
+ }
166
+ return bucket.source as InputSource<K>
167
+ }
168
+ let bare = variant([], PRIMARY)
169
+ let spec = (text: string): InputSource<K> => {
170
+ if (typeof text !== "string" || text.length === 0) throw new Error(`${label}: expected a spec such as "Ctrl", "Shift+Alt"${buttons ? ', "Right" or "Shift+Middle"' : ""}, got ${String(text)}`)
171
+ let parts = text.split("+")
172
+ let button = PRIMARY
173
+ if (parts[parts.length - 1]! in BUTTONS) {
174
+ if (!buttons) throw new Error(`${label}: has no button, but "${text}" names one`)
175
+ button = BUTTONS[parts.pop()!]!
176
+ }
177
+ return variant(parseModifiers(label, text, parts), button)
178
+ }
179
+ let source: PointerSource<K> = Object.assign(spec, { kind, label: bare.label, deltas: bare.deltas! })
180
+ let resolve = (event: Modified, button: number): Set<DeltaSink>[] =>
181
+ mostSpecific(
182
+ [...buckets.values()].filter(b => b.sinks.size > 0 && b.button === button),
183
+ b => b.mods,
184
+ event,
185
+ ).map(b => b.sinks)
186
+ return {
187
+ source,
188
+ begin(event: Modified, button: number) {
189
+ open = resolve(event, button)
190
+ for (let sinks of open) sinks.forEach(k => k.begin())
191
+ },
192
+ delta(value: number | Vec2, focal?: Vec2) {
193
+ for (let sinks of open) sinks.forEach(k => k.delta(value, focal))
194
+ },
195
+ end() {
196
+ for (let sinks of open) sinks.forEach(k => k.end())
197
+ open = []
198
+ },
199
+ send(event: Modified, value: number | Vec2, focal?: Vec2) {
200
+ for (let sinks of resolve(event, PRIMARY)) sinks.forEach(k => k.delta(value, focal))
201
+ },
202
+ }
203
+ }
204
+
205
+ export function createPointerFeed(options: PointerFeedOptions = {}): PointerFeed {
206
+ let drag = gesture("vec2", "pointer drag", true)
207
+ let pan = gesture("vec2", "pointer two-finger pan", true)
208
+ let pinch = gesture("axis", "pointer pinch", true)
209
+ let twist = gesture("axis", "pointer twist", true)
210
+ let wheel = gesture("axis", "pointer wheel", false)
211
+ let mouseDelta = gesture("vec2", "mouse motion (pointer locked)", false)
212
+
213
+ // The element's box, read at each press (see the header). A detached
214
+ // leaf has no layout box and must bring `layout`: normalizing by
215
+ // nothing would turn a 100 px drag into 100 element heights, so that
216
+ // is a throw (the dev validation policy), while a zero-size box is a
217
+ // degenerate layout and passes pixels through.
218
+ let size = (event: { currentTarget: number }): { width: number; height: number } => {
219
+ let box = options.layout ? options.layout() : getLayoutBox({ id: event.currentTarget })
220
+ if (box === null && !options.layout) {
221
+ throw new Error("createPointerFeed: the element has no layout box (a detached d-* leaf, or before its first layout); create the feed with { layout: () => ({ width, height }) } for it")
222
+ }
223
+ return box && box.width > 0 && box.height > 0 ? { width: box.width, height: box.height } : { width: 0, height: 0 }
224
+ }
225
+ let box = { width: 0, height: 0 }
226
+ let unit = () => (box.height > 0 ? box.height : 1)
227
+ let focalOf = (x: number, y: number): Vec2 | undefined => (box.width > 0 && box.height > 0 ? [x / box.width, y / box.height] : undefined)
228
+
229
+ let transform = createTransform({
230
+ buttons: "any",
231
+ onTransformMove: t => {
232
+ let h = unit()
233
+ let travel: Vec2 = [t.dx / h, t.dy / h]
234
+ if (t.pointers >= 2) {
235
+ if (t.dx !== 0 || t.dy !== 0) pan.delta(travel)
236
+ let focal = focalOf(t.x, t.y)
237
+ if (t.scale !== 1) pinch.delta(Math.log2(t.scale), focal)
238
+ if (t.rotation !== 0) twist.delta(t.rotation / (2 * Math.PI), focal)
239
+ } else if (t.dx !== 0 || t.dy !== 0) {
240
+ drag.delta(travel)
241
+ }
242
+ },
243
+ })
244
+
245
+ // Pointers down on the element with the button that pressed them, for
246
+ // the gesture brackets: the first press opens drag, a second closes it
247
+ // and opens the two-pointer gestures, and the count coming back down
248
+ // reverses that. The event that opens a bracket resolves its chord,
249
+ // and the pointer it opens for supplies the button (the drag that
250
+ // resumes when a second pointer lifts belongs to the one still down).
251
+ let downs = new Map<number, number>()
252
+ let landed = (e: PointerEvent, button: number) => {
253
+ downs.set(e.pointerId, button)
254
+ if (downs.size === 1) drag.begin(e, button)
255
+ if (downs.size === 2) {
256
+ drag.end()
257
+ pan.begin(e, button)
258
+ pinch.begin(e, button)
259
+ twist.begin(e, button)
260
+ }
261
+ }
262
+ let lifted = (e: PointerEvent) => {
263
+ downs.delete(e.pointerId)
264
+ if (downs.size === 1) {
265
+ pan.end()
266
+ pinch.end()
267
+ twist.end()
268
+ drag.begin(e, [...downs.values()][0]!)
269
+ }
270
+ if (downs.size === 0) drag.end()
271
+ }
272
+
273
+ return {
274
+ handlers: {
275
+ onPointerDown(e) {
276
+ if (downs.has(e.pointerId)) return
277
+ if (downs.size === 0) box = size(e)
278
+ landed(e, e.button ?? PRIMARY)
279
+ transform.handlers.onPointerDown(e)
280
+ },
281
+ onPointerMove(e) {
282
+ if (pointerLocked() && e.pointerType === "mouse" && (e.movementX !== 0 || e.movementY !== 0)) {
283
+ mouseDelta.send(e, [e.movementX / MOUSE_PX_PER_UNIT, e.movementY / MOUSE_PX_PER_UNIT])
284
+ }
285
+ transform.handlers.onPointerMove(e)
286
+ },
287
+ onPointerUp(e) {
288
+ if (downs.get(e.pointerId) !== (e.button ?? PRIMARY)) return
289
+ transform.handlers.onPointerUp(e)
290
+ lifted(e)
291
+ },
292
+ onPointerLeave() {},
293
+ onWheel(e) {
294
+ if (e.deltaY === 0) return
295
+ box = size(e)
296
+ wheel.send(e, -e.deltaY * WHEEL_OCTAVES, focalOf(e.localX, e.localY))
297
+ },
298
+ },
299
+ drag: drag.source,
300
+ pan: pan.source,
301
+ pinch: pinch.source,
302
+ twist: twist.source,
303
+ wheel: wheel.source,
304
+ mouseDelta: mouseDelta.source,
305
+ }
306
+ }
package/src/input.ts ADDED
@@ -0,0 +1,12 @@
1
+ // The runtime-free half of the input surface, importable as
2
+ // `@solidrt/core/input` by modules that must stay headless (the camera
3
+ // controls and their checks run on the bare flux binary, which has no
4
+ // event bus). The map, the control-side axes contract and the keyboard
5
+ // device are pure; the gamepad device and the pointer feed need the
6
+ // runtime and live on the main entry.
7
+ export { createInputMap, invert, scale } from "./input-map"
8
+ export type { ActionKind, ActionValue, ActionsDecl, Binding, DeltaSink, GestureListener, InputMap, InputSource } from "./input-map"
9
+ export { createAxes, combineRates } from "./input-axes"
10
+ export type { Axes, AxesDecl, AxesHooks, AxisKind, AxisValue, Vec2 } from "./input-axes"
11
+ export { keyboard } from "./input-keyboard"
12
+ export type { KeyboardDevice, KeyboardVec2Keys } from "./input-keyboard"
package/src/transform.ts CHANGED
@@ -47,6 +47,9 @@ export type TransformDelta = {
47
47
  }
48
48
 
49
49
  export interface TransformOptions {
50
+ /** Which mouse buttons open a gesture: the primary only (default), or
51
+ * any - the pointer feed's choice, which tells its buttons apart itself. */
52
+ buttons?: "primary" | "any"
50
53
  onTransformStart?: () => void
51
54
  /** Streams one delta per frame; compose them multiplicatively (scale) / additively (dx, dy, rotation). */
52
55
  onTransformMove?: (t: TransformDelta) => void
@@ -251,7 +254,7 @@ export function createTransform(options: TransformOptions) {
251
254
 
252
255
  let handlers = {
253
256
  onPointerDown: (e: PointerEvent) => {
254
- if (e.button != null && e.button !== 0) return
257
+ if (options.buttons !== "any" && e.button != null && e.button !== 0) return
255
258
  if (pointers.has(e.pointerId)) return
256
259
  if (active) {
257
260
  // A finger joining an established gesture belongs to it outright; if
package/src/types.d.ts CHANGED
@@ -590,9 +590,10 @@ export interface TransitionProps {
590
590
  * `all` covers every animatable property the element has, and a bare
591
591
  * string is shorthand for it: `transition="300ms ease-out"`. Only
592
592
  * properties the element carries animate (a d-rect has x, a view's x is
593
- * its transform); the initial value never animates unless the entry sets
594
- * `from` (an enter animation), and a non-numeric write (e.g. null)
595
- * cancels the running animation and snaps. `null` clears the
593
+ * its transform); the initial values (every write before the element's
594
+ * first paint) never animate unless the entry sets `from` (an enter
595
+ * animation), and a non-numeric write (e.g. null) cancels the running
596
+ * animation and snaps. `null` clears the
596
597
  * declaration; already-running animations finish. A spec built in a
597
598
  * conditional widens `curve` to `string` for TypeScript; write
598
599
  * `satisfies Transition` on the branch.