@solidrt/core 0.0.57 → 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.
package/AGENTS.md CHANGED
@@ -361,7 +361,17 @@ latest (solid-js 1.x, off Solid 2.0 entirely). The recipe that works is
361
361
  `<View2d pointer>` take a feed and spread it on their built-in leaf) and
362
362
  bind its gestures `drag` (one pointer), `pan` (two), `pinch`, `twist`,
363
363
  `wheel` and `mouseDelta` (raw motion while pointerLocked()). One
364
- merged recognizer serves them, so they arbitrate in the arena as one. A
364
+ merged recognizer serves them, so they arbitrate in the arena as one.
365
+ Each gesture takes a chord in the key-spec grammar: `pointer.drag("Ctrl")`
366
+ fires only when the event that opens the gesture carries Ctrl, and then
367
+ instead of the bare `drag` (the most specific bound chord wins, as a
368
+ key spec does), held for the whole gesture - so plain drag on `rotate`
369
+ and `drag("Ctrl")` on `pan` is one binding each, the every-viewport
370
+ default. A bracketed spec may end in its button (`drag("Right")`,
371
+ `drag("Shift+Middle")`; bare means Left), a discriminator rather than
372
+ a modifier: a right drag feeds only Right specs. Touch carries no
373
+ modifiers or buttons: a chord is the desktop path, the bare binding
374
+ stays for fingers. A
365
375
  detached d-* leaf has no layout box: give the feed `{ layout }` or it
366
376
  throws at the first press. `invert(source)` and `scale(source, k)` are
367
377
  the two processors: keys and sticks move the CAMERA where a drag moves
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/core",
3
- "version": "0.0.57",
3
+ "version": "0.0.58",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -31,7 +31,7 @@
31
31
  "AGENTS.md"
32
32
  ],
33
33
  "devDependencies": {
34
- "@solidrt/flux-types": "0.0.57"
34
+ "@solidrt/flux-types": "0.0.58"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@solidjs/signals": "2.0.0-rc.6",
package/src/index.ts CHANGED
@@ -37,7 +37,7 @@ export type { KeyboardDevice, KeyboardVec2Keys } from "./input-keyboard"
37
37
  export { gamepad } from "./input-gamepad"
38
38
  export type { GamepadDevice } from "./input-gamepad"
39
39
  export { createPointerFeed } from "./input-pointer"
40
- export type { PointerFeed, PointerFeedOptions } from "./input-pointer"
40
+ export type { PointerFeed, PointerFeedOptions, PointerSource } from "./input-pointer"
41
41
  export type {
42
42
  Element,
43
43
  LayoutProps,
@@ -0,0 +1,44 @@
1
+ // Modifier chords, the vocabulary two devices share: a spec names
2
+ // modifiers ahead of what it qualifies ("Shift+Tab" on the keyboard,
3
+ // "Ctrl" on a pointer gesture), the event must carry them, a bare spec
4
+ // ignores them (a shifted W still walks, a Ctrl-drag still drags), and
5
+ // among the specs one event satisfies the most specific wins - Unreal's
6
+ // chord blocker, Three's OrbitControls picking PAN over ROTATE at
7
+ // mousedown; Godot and Unity fire both by default and every app writes
8
+ // the exclusion itself. Which event resolves a chord is the device's
9
+ // call: the keyboard settles each key down, the pointer feed the event
10
+ // that opens a gesture bracket.
11
+
12
+ export type Modifier = "shiftKey" | "ctrlKey" | "altKey" | "metaKey"
13
+ /** The modifier flags every key, pointer and wheel event carries. */
14
+ export type Modified = Record<Modifier, boolean>
15
+
16
+ const MODIFIERS: Record<string, Modifier> = { Shift: "shiftKey", Ctrl: "ctrlKey", Control: "ctrlKey", Alt: "altKey", Meta: "metaKey" }
17
+ // The canonical order a chord is spelled in, so one modifier set has one name.
18
+ const ORDER: Modifier[] = ["shiftKey", "ctrlKey", "altKey", "metaKey"]
19
+ const NAMES: Record<Modifier, string> = { shiftKey: "Shift", ctrlKey: "Ctrl", altKey: "Alt", metaKey: "Meta" }
20
+
21
+ /** The modifiers `parts` name (["Ctrl", "Shift"]), deduplicated, in
22
+ * canonical order; `what` and `text` name the caller and its spec in the
23
+ * throw on an unknown name. */
24
+ export function parseModifiers(what: string, text: string, parts: string[]): Modifier[] {
25
+ let mods = new Set<Modifier>()
26
+ for (let part of parts) {
27
+ let mod = MODIFIERS[part]
28
+ if (!mod) throw new Error(`${what}: unknown modifier "${part}" in "${text}" (Shift, Ctrl, Alt or Meta)`)
29
+ mods.add(mod)
30
+ }
31
+ return ORDER.filter(m => mods.has(m))
32
+ }
33
+
34
+ /** The canonical spelling of a modifier set: "Shift+Ctrl"; "" for none. */
35
+ export let chordName = (mods: Modifier[]): string => mods.map(m => NAMES[m]).join("+")
36
+
37
+ /** Of `items`, those whose modifiers the event carries, narrowed to the
38
+ * most specific (the longest modifier lists, all of them on a tie);
39
+ * empty when none match. */
40
+ export function mostSpecific<T>(items: T[], mods: (item: T) => Modifier[], event: Modified): T[] {
41
+ let hits = items.filter(item => mods(item).every(m => event[m]))
42
+ let most = hits.reduce((n, item) => Math.max(n, mods(item).length), 0)
43
+ return hits.filter(item => mods(item).length === most)
44
+ }
@@ -8,11 +8,12 @@
8
8
  // a shifted "W".
9
9
  //
10
10
  // A spec may name modifiers ahead of the key ("Shift+Tab", "Ctrl+KeyS";
11
- // Shift, Ctrl, Alt, Meta): the down must carry them, while a bare spec
12
- // ignores them (a shifted W still walks). Within one source the most
13
- // specific matching spec wins a down, so `axis("Shift+Tab", "Tab")` reads
14
- // -1, not 0, on Shift+Tab; and an up releases on the key alone, so a
15
- // modifier let go first cannot leave the key stuck.
11
+ // the chord vocabulary of input-chord.ts, shared with the pointer feed):
12
+ // the down must carry them, while a bare spec ignores them (a shifted W
13
+ // still walks). Within one source the most specific matching spec wins
14
+ // a down, so `axis("Shift+Tab", "Tab")` reads -1, not 0, on Shift+Tab;
15
+ // and an up releases on the key alone, so a modifier let go first
16
+ // cannot leave the key stuck.
16
17
  //
17
18
  // Held state is a count behind a signal, so a source's rate is reactive
18
19
  // (a held key wakes a control's frame loop) and a blur - the up that never
@@ -27,10 +28,8 @@ import { createSignal } from "@solidjs/signals"
27
28
  import type { KeyEvent } from "./types"
28
29
  import type { InputSource } from "./input-map"
29
30
  import type { Vec2 } from "./input-axes"
30
-
31
- type Modifier = "shiftKey" | "ctrlKey" | "altKey" | "metaKey"
32
-
33
- const MODIFIERS: Record<string, Modifier> = { Shift: "shiftKey", Ctrl: "ctrlKey", Control: "ctrlKey", Alt: "altKey", Meta: "metaKey" }
31
+ import { mostSpecific, parseModifiers } from "./input-chord"
32
+ import type { Modifier } from "./input-chord"
34
33
 
35
34
  // A parsed spec: the text as given (the held-state key and the label),
36
35
  // the key it names, and the modifiers the down must carry.
@@ -41,13 +40,7 @@ function parse(what: string, text: unknown): Spec {
41
40
  let parts = text.split("+")
42
41
  let key = parts.pop()!
43
42
  if (key.length === 0) throw new Error(`keyboard.${what}: "${text}" names no key`)
44
- let mods: Modifier[] = []
45
- for (let part of parts) {
46
- let mod = MODIFIERS[part]
47
- if (!mod) throw new Error(`keyboard.${what}: unknown modifier "${part}" in "${text}" (Shift, Ctrl, Alt or Meta)`)
48
- if (!mods.includes(mod)) mods.push(mod)
49
- }
50
- return { text, key, mods }
43
+ return { text, key, mods: parseModifiers(`keyboard.${what}`, text, parts) }
51
44
  }
52
45
 
53
46
  // A key matches its physical code or its logical key; a letter code
@@ -75,11 +68,7 @@ function held(specs: Spec[]) {
75
68
  // moves "Tab" to "Shift+Tab" and back), an up frees them all.
76
69
  let onKey = specs.filter(s => matches(event, s.key))
77
70
  for (let s of onKey) down.delete(s.text)
78
- if (isDown) {
79
- let hits = onKey.filter(s => s.mods.every(m => event[m]))
80
- let most = hits.reduce((n, s) => Math.max(n, s.mods.length), 0)
81
- for (let s of hits) if (s.mods.length === most) down.add(s.text)
82
- }
71
+ if (isDown) for (let s of mostSpecific(onKey, s => s.mods, event)) down.add(s.text)
83
72
  setCount(down.size)
84
73
  },
85
74
  blur() {
package/src/input-map.ts CHANGED
@@ -213,7 +213,8 @@ function checkValue(what: string, kind: ActionKind, v: unknown): void {
213
213
 
214
214
  function checkSource(source: unknown): InputSource {
215
215
  let s = source as InputSource
216
- if (!s || typeof s !== "object" || (s.kind !== "button" && s.kind !== "axis" && s.kind !== "vec2") || typeof s.label !== "string") {
216
+ // A pointer gesture is a callable source (`pointer.drag("Ctrl")` is its chord variant).
217
+ if (!s || (typeof s !== "object" && typeof s !== "function") || (s.kind !== "button" && s.kind !== "axis" && s.kind !== "vec2") || typeof s.label !== "string") {
217
218
  throw new Error(`createInputMap: not an input source: ${String(source)}`)
218
219
  }
219
220
  return s
@@ -33,6 +33,30 @@
33
33
  // Brackets fire on the press itself, before the recognizer's slop, so a
34
34
  // glide stops the moment a finger lands.
35
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
+ //
36
60
  // The element's laid-out box normalizes the travel: read at the press
37
61
  // through getLayoutBox (the untransformed read, so a designSize fit or
38
62
  // an ancestor transform never skews it), or from `layout` when given -
@@ -49,6 +73,8 @@ import { pointerLocked } from "./window"
49
73
  import type { PointerEvent, WheelEvent } from "./types"
50
74
  import type { DeltaSink, InputSource } from "./input-map"
51
75
  import type { Vec2 } from "./input-axes"
76
+ import { chordName, mostSpecific, parseModifiers } from "./input-chord"
77
+ import type { Modified, Modifier } from "./input-chord"
52
78
 
53
79
  // Octaves per wheel-delta unit: the zoom exponent both cameras used
54
80
  // (0.0015 per unit) taken in log2, so a 100-unit notch zooms about a
@@ -60,6 +86,16 @@ const WHEEL_OCTAVES = 0.0015 / Math.LN2
60
86
  // turn is the ~0.002 rad/px of Three's PointerLockControls.
61
87
  const MOUSE_PX_PER_UNIT = 1500
62
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
+
63
99
  export interface PointerFeed {
64
100
  /** Spread on the element whose pointer events feed the sources. */
65
101
  handlers: {
@@ -70,20 +106,20 @@ export interface PointerFeed {
70
106
  onWheel(event: WheelEvent): void
71
107
  }
72
108
  /** One pointer's travel, in element heights; bracketed. */
73
- drag: InputSource<"vec2">
109
+ drag: PointerSource<"vec2">
74
110
  /** Two or more pointers' focal travel, in element heights; bracketed. */
75
- pan: InputSource<"vec2">
111
+ pan: PointerSource<"vec2">
76
112
  /** Span change of two pointers, in octaves (spreading positive), with
77
113
  * the focal point; bracketed. */
78
- pinch: InputSource<"axis">
114
+ pinch: PointerSource<"axis">
79
115
  /** Rotation of the pointer pair, in turns, with the focal point; bracketed. */
80
- twist: InputSource<"axis">
116
+ twist: PointerSource<"axis">
81
117
  /** Wheel notches in octaves (wheel up positive), with the cursor as the
82
118
  * focal point; unbracketed. */
83
- wheel: InputSource<"axis">
119
+ wheel: PointerSource<"axis">
84
120
  /** Raw mouse motion while the pointer is locked, in view-height
85
121
  * equivalents (see MOUSE_PX_PER_UNIT); unbracketed. */
86
- mouseDelta: InputSource<"vec2">
122
+ mouseDelta: PointerSource<"vec2">
87
123
  }
88
124
 
89
125
  export interface PointerFeedOptions {
@@ -93,33 +129,86 @@ export interface PointerFeedOptions {
93
129
  layout?: () => { width: number; height: number } | null
94
130
  }
95
131
 
96
- type Sinks = Set<DeltaSink>
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
97
135
 
98
- function source<K extends "axis" | "vec2">(kind: K, label: string, sinks: Sinks): InputSource<K> {
99
- return {
100
- kind,
101
- label,
102
- deltas(sink) {
103
- sinks.add(sink)
104
- return () => {
105
- sinks.delete(sink)
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
+ },
106
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))
107
201
  },
108
202
  }
109
203
  }
110
204
 
111
205
  export function createPointerFeed(options: PointerFeedOptions = {}): PointerFeed {
112
- let sinks = {
113
- drag: new Set<DeltaSink>(),
114
- pan: new Set<DeltaSink>(),
115
- pinch: new Set<DeltaSink>(),
116
- twist: new Set<DeltaSink>(),
117
- wheel: new Set<DeltaSink>(),
118
- mouseDelta: new Set<DeltaSink>(),
119
- }
120
- let begin = (s: Sinks) => s.forEach(k => k.begin())
121
- let end = (s: Sinks) => s.forEach(k => k.end())
122
- let delta = (s: Sinks, value: number | Vec2, focal?: Vec2) => s.forEach(k => k.delta(value, focal))
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)
123
212
 
124
213
  // The element's box, read at each press (see the header). A detached
125
214
  // leaf has no layout box and must bring `layout`: normalizing by
@@ -138,76 +227,80 @@ export function createPointerFeed(options: PointerFeedOptions = {}): PointerFeed
138
227
  let focalOf = (x: number, y: number): Vec2 | undefined => (box.width > 0 && box.height > 0 ? [x / box.width, y / box.height] : undefined)
139
228
 
140
229
  let transform = createTransform({
230
+ buttons: "any",
141
231
  onTransformMove: t => {
142
232
  let h = unit()
143
233
  let travel: Vec2 = [t.dx / h, t.dy / h]
144
234
  if (t.pointers >= 2) {
145
- if (t.dx !== 0 || t.dy !== 0) delta(sinks.pan, travel)
235
+ if (t.dx !== 0 || t.dy !== 0) pan.delta(travel)
146
236
  let focal = focalOf(t.x, t.y)
147
- if (t.scale !== 1) delta(sinks.pinch, Math.log2(t.scale), focal)
148
- if (t.rotation !== 0) delta(sinks.twist, t.rotation / (2 * Math.PI), focal)
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)
149
239
  } else if (t.dx !== 0 || t.dy !== 0) {
150
- delta(sinks.drag, travel)
240
+ drag.delta(travel)
151
241
  }
152
242
  },
153
243
  })
154
244
 
155
- // Pointers down on the element, for the gesture brackets: the first
156
- // press opens drag, a second closes it and opens the two-pointer
157
- // gestures, and the count coming back down reverses that.
158
- let downs = new Set<number>()
159
- let landed = (id: number) => {
160
- if (downs.has(id)) return
161
- downs.add(id)
162
- if (downs.size === 1) begin(sinks.drag)
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)
163
255
  if (downs.size === 2) {
164
- end(sinks.drag)
165
- begin(sinks.pan)
166
- begin(sinks.pinch)
167
- begin(sinks.twist)
256
+ drag.end()
257
+ pan.begin(e, button)
258
+ pinch.begin(e, button)
259
+ twist.begin(e, button)
168
260
  }
169
261
  }
170
- let lifted = (id: number) => {
171
- if (!downs.delete(id)) return
262
+ let lifted = (e: PointerEvent) => {
263
+ downs.delete(e.pointerId)
172
264
  if (downs.size === 1) {
173
- end(sinks.pan)
174
- end(sinks.pinch)
175
- end(sinks.twist)
176
- begin(sinks.drag)
265
+ pan.end()
266
+ pinch.end()
267
+ twist.end()
268
+ drag.begin(e, [...downs.values()][0]!)
177
269
  }
178
- if (downs.size === 0) end(sinks.drag)
270
+ if (downs.size === 0) drag.end()
179
271
  }
180
272
 
181
273
  return {
182
274
  handlers: {
183
275
  onPointerDown(e) {
184
- if (e.button != null && e.button !== 0) return
276
+ if (downs.has(e.pointerId)) return
185
277
  if (downs.size === 0) box = size(e)
186
- landed(e.pointerId)
278
+ landed(e, e.button ?? PRIMARY)
187
279
  transform.handlers.onPointerDown(e)
188
280
  },
189
281
  onPointerMove(e) {
190
282
  if (pointerLocked() && e.pointerType === "mouse" && (e.movementX !== 0 || e.movementY !== 0)) {
191
- delta(sinks.mouseDelta, [e.movementX / MOUSE_PX_PER_UNIT, e.movementY / MOUSE_PX_PER_UNIT])
283
+ mouseDelta.send(e, [e.movementX / MOUSE_PX_PER_UNIT, e.movementY / MOUSE_PX_PER_UNIT])
192
284
  }
193
285
  transform.handlers.onPointerMove(e)
194
286
  },
195
287
  onPointerUp(e) {
288
+ if (downs.get(e.pointerId) !== (e.button ?? PRIMARY)) return
196
289
  transform.handlers.onPointerUp(e)
197
- lifted(e.pointerId)
290
+ lifted(e)
198
291
  },
199
292
  onPointerLeave() {},
200
293
  onWheel(e) {
201
294
  if (e.deltaY === 0) return
202
295
  box = size(e)
203
- delta(sinks.wheel, -e.deltaY * WHEEL_OCTAVES, focalOf(e.localX, e.localY))
296
+ wheel.send(e, -e.deltaY * WHEEL_OCTAVES, focalOf(e.localX, e.localY))
204
297
  },
205
298
  },
206
- drag: source("vec2", "pointer drag", sinks.drag),
207
- pan: source("vec2", "pointer two-finger pan", sinks.pan),
208
- pinch: source("axis", "pointer pinch", sinks.pinch),
209
- twist: source("axis", "pointer twist", sinks.twist),
210
- wheel: source("axis", "pointer wheel", sinks.wheel),
211
- mouseDelta: source("vec2", "mouse motion (pointer locked)", sinks.mouseDelta),
299
+ drag: drag.source,
300
+ pan: pan.source,
301
+ pinch: pinch.source,
302
+ twist: twist.source,
303
+ wheel: wheel.source,
304
+ mouseDelta: mouseDelta.source,
212
305
  }
213
306
  }
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