@tremolo-ui/dom 0.4.0 → 0.6.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.
@@ -8,16 +8,23 @@ export type DragState = {
8
8
  /** Pointer position in viewport coordinates. */
9
9
  clientX: number
10
10
  clientY: number
11
+ /**
12
+ * Which pointer this is. Always the same value for one drag; with
13
+ * {@link DragOptions.multiPointer} it tells concurrent drags apart.
14
+ */
15
+ pointerId: number
11
16
  event: PointerEvent
12
17
  }
13
18
 
14
19
  export type DragOptions = {
15
20
  /**
16
- * Minimum movement in pixels before `onDrag` fires.
21
+ * Minimum movement in pixels before `onDrag` fires. Movement below it is
22
+ * carried over to the next event rather than discarded, so a slow drag still
23
+ * reports once it adds up.
24
+ *
17
25
  * Prevents `onDrag` from firing on, for example, a double click.
18
- * Values below 1 are clamped to 1.
19
26
  *
20
- * @default 1
27
+ * @default 0
21
28
  */
22
29
  threshold?: number
23
30
 
@@ -25,15 +32,79 @@ export type DragOptions = {
25
32
  * CSS cursor to show while dragging. Applied to the element itself: pointer
26
33
  * capture keeps it in effect even once the pointer leaves the element, so
27
34
  * there is no need to touch the document.
35
+ *
36
+ * With {@link DragOptions.multiPointer} it is set for the first pointer and
37
+ * restored once the last one is up.
28
38
  */
29
39
  cursor?: string
30
40
 
41
+ /**
42
+ * Decide whether a pointerdown starts a drag at all.
43
+ *
44
+ * Checked before anything else — **before the pointer is captured** — so
45
+ * declining here leaves the whole gesture to whatever else is listening.
46
+ * Deciding later would be too late: the capture has already been taken from
47
+ * the element that was going to handle it.
48
+ *
49
+ * The use for it is a drag on a container that also holds draggable things
50
+ * of its own, such as a rubber-band selection that must not begin on top of
51
+ * one of the objects it would select.
52
+ */
53
+ shouldStart?: (event: PointerEvent) => boolean
54
+
55
+ /**
56
+ * Hide the pointer and read its movement directly, instead of following it
57
+ * around the screen.
58
+ *
59
+ * A relative drag — a knob, a stepper — does not care where the pointer is,
60
+ * only how far it moved, and letting it wander has two costs: the cursor
61
+ * ends up far from what it is holding, and **the drag stops at the edge of
62
+ * the screen**, where the operating system pins the pointer and the
63
+ * coordinates stop changing. A fine drag reaches that edge quickly.
64
+ *
65
+ * **Not for a drag whose value is the position pointed at** — anything on
66
+ * `elementMapping`. `clientX` / `clientY` freeze while the pointer is
67
+ * locked, so there is no position left to read.
68
+ *
69
+ * The request needs a user gesture, which a pointerdown is, but it can still
70
+ * be refused; the drag then carries on as an ordinary one. Read on
71
+ * pointerdown, so `update()` reaches the next drag rather than the current.
72
+ *
73
+ * @default false
74
+ */
75
+ pointerLock?: boolean
76
+
77
+ /**
78
+ * Track every pointer that goes down on the element, rather than only the
79
+ * first. Each one gets its own `onDragStart` / `onDrag` / `onDragEnd` and
80
+ * carries its own totals; {@link DragState.pointerId} says which is which.
81
+ *
82
+ * Fixed for the lifetime of the instance: switching part way through a drag
83
+ * has no meaning, so `update()` ignores it.
84
+ *
85
+ * @default false
86
+ */
87
+ multiPointer?: boolean
88
+
31
89
  onDragStart?: (state: DragState) => void
32
90
  onDrag?: (state: DragState) => void
91
+ /**
92
+ * Called exactly once for every drag that starts, whether tracking ends by
93
+ * pointer release, cancellation, capture or lock loss, or destruction.
94
+ */
33
95
  onDragEnd?: (state: DragState) => void
34
96
  }
35
97
 
36
98
  export interface DragInstance {
99
+ /**
100
+ * Replace the given options. Lets a wrapper feed fresh handlers in without
101
+ * tearing down the listeners, which would abort a drag in progress.
102
+ *
103
+ * `multiPointer` is fixed for the lifetime of the instance and is ignored
104
+ * here.
105
+ */
106
+ update: (options: DragOptions) => void
107
+ /** End any active drags before removing the instance. */
37
108
  destroy: () => void
38
109
  }
39
110
 
@@ -51,12 +122,45 @@ const MANAGED_STYLES = [
51
122
  ['-webkit-touch-callout', 'none'],
52
123
  ] as const
53
124
 
125
+ type ManagedStyleState = {
126
+ count: number
127
+ previous: Map<string, { value: string; priority: string }>
128
+ }
129
+
130
+ const managedStyleStates = new WeakMap<Element, ManagedStyleState>()
131
+
54
132
  type CaptureTarget = {
133
+ requestPointerLock?: () => unknown
55
134
  setPointerCapture?: (pointerId: number) => void
56
135
  releasePointerCapture?: (pointerId: number) => void
57
136
  hasPointerCapture?: (pointerId: number) => boolean
58
137
  }
59
138
 
139
+ /** What one pointer needs to report its own movement. */
140
+ type PointerState = {
141
+ /** Where the listeners for this pointer live. */
142
+ moveTarget: EventTarget
143
+ startX: number
144
+ startY: number
145
+ lastX: number
146
+ lastY: number
147
+ /**
148
+ * Where the travel stood when the pointer lock took effect, and how far it
149
+ * has moved since. Screen coordinates stop changing under the lock, so from
150
+ * that point the movement of each event is added up instead.
151
+ *
152
+ * The base is taken when the lock engages rather than on pointerdown: the
153
+ * request is asynchronous, and whatever movement happened while it was in
154
+ * flight was measured the ordinary way.
155
+ */
156
+ lockBaseX?: number
157
+ lockBaseY?: number
158
+ lockMoveX: number
159
+ lockMoveY: number
160
+ /** The most recent event, to end the drag with when the lock is lost. */
161
+ lastEvent: PointerEvent
162
+ }
163
+
60
164
  /**
61
165
  * Track a pointer drag on an element.
62
166
  *
@@ -65,50 +169,69 @@ type CaptureTarget = {
65
169
  * element gets `touch-action: none` so that touch dragging does not scroll the
66
170
  * page, plus `user-select: none` so that a long press does not start a text
67
171
  * selection instead.
172
+ *
173
+ * One pointer at a time by default; see {@link DragOptions.multiPointer}.
68
174
  */
69
175
  export function createDrag(
70
176
  element: Element,
71
- {
72
- threshold: _threshold = 1,
73
- cursor,
74
- onDragStart,
75
- onDrag,
76
- onDragEnd,
77
- }: DragOptions = {},
177
+ options: DragOptions = {},
78
178
  ): DragInstance {
79
- const threshold = Math.max(_threshold, 1)
179
+ let opts = options
180
+ const multiPointer = options.multiPointer ?? false
80
181
  const capture = element as CaptureTarget
81
182
  const style = (element as Partial<HTMLElement>).style
82
183
 
83
- const previousStyles = new Map<string, string>()
184
+ let managedStyles = managedStyleStates.get(element)
84
185
  if (style) {
85
- for (const [property, value] of MANAGED_STYLES) {
86
- previousStyles.set(property, style.getPropertyValue(property))
87
- style.setProperty(property, value)
186
+ if (managedStyles) {
187
+ managedStyles.count += 1
188
+ } else {
189
+ const previous = new Map<string, { value: string; priority: string }>()
190
+ for (const [property, value] of MANAGED_STYLES) {
191
+ previous.set(property, {
192
+ value: style.getPropertyValue(property),
193
+ priority: style.getPropertyPriority(property),
194
+ })
195
+ style.setProperty(property, value)
196
+ }
197
+ managedStyles = { count: 1, previous }
198
+ managedStyleStates.set(element, managedStyles)
88
199
  }
89
200
  }
90
201
 
91
- let pointerId: number | null = null
92
- /** Where the listeners for the current drag live. */
93
- let moveTarget: EventTarget | null = null
94
- let startX = 0
95
- let startY = 0
96
- let lastX = 0
97
- let lastY = 0
202
+ const pointers = new Map<number, PointerState>()
203
+ /**
204
+ * How many pointers each target carries. Adding the same listener twice is a
205
+ * no-op and removing it once removes it for good, so a target is subscribed
206
+ * to while at least one pointer is on it and no longer.
207
+ */
208
+ const targets = new Map<EventTarget, number>()
98
209
  let previousCursor: string | undefined
210
+ /** The pointer that asked for the lock, while it is still down. */
211
+ let lockedPointerId: number | null = null
212
+ let lockRequest = 0
213
+ let destroyed = false
99
214
 
100
215
  function state(
101
216
  event: PointerEvent,
217
+ pointer: PointerState,
102
218
  deltaX: number,
103
219
  deltaY: number,
104
220
  ): DragState {
105
221
  return {
106
- x: event.screenX - startX,
107
- y: event.screenY - startY,
222
+ x:
223
+ pointer.lockBaseX !== undefined
224
+ ? pointer.lockBaseX + pointer.lockMoveX
225
+ : event.screenX - pointer.startX,
226
+ y:
227
+ pointer.lockBaseY !== undefined
228
+ ? pointer.lockBaseY + pointer.lockMoveY
229
+ : event.screenY - pointer.startY,
108
230
  deltaX,
109
231
  deltaY,
110
232
  clientX: event.clientX,
111
233
  clientY: event.clientY,
234
+ pointerId: event.pointerId,
112
235
  event,
113
236
  }
114
237
  }
@@ -122,93 +245,245 @@ export function createDrag(
122
245
  event.preventDefault()
123
246
  }
124
247
 
248
+ function retainTarget(target: EventTarget) {
249
+ const count = targets.get(target) ?? 0
250
+ if (count === 0) {
251
+ target.addEventListener('pointermove', handlePointerMove)
252
+ target.addEventListener('pointerup', handlePointerUp)
253
+ target.addEventListener('pointercancel', handlePointerUp)
254
+ if (target === element) {
255
+ target.addEventListener('lostpointercapture', handleLostPointerCapture)
256
+ }
257
+ }
258
+ targets.set(target, count + 1)
259
+ }
260
+
261
+ function releaseTarget(target: EventTarget) {
262
+ const count = targets.get(target) ?? 0
263
+ if (count > 1) {
264
+ targets.set(target, count - 1)
265
+ return
266
+ }
267
+ target.removeEventListener('pointermove', handlePointerMove)
268
+ target.removeEventListener('pointerup', handlePointerUp)
269
+ target.removeEventListener('pointercancel', handlePointerUp)
270
+ if (target === element) {
271
+ target.removeEventListener('lostpointercapture', handleLostPointerCapture)
272
+ }
273
+ targets.delete(target)
274
+ }
275
+
125
276
  function handlePointerDown(event: Event) {
126
277
  const pointerEvent = event as PointerEvent
127
- // Only one pointer drives the drag; ignore additional touches.
128
- if (pointerId !== null) return
278
+ if (pointerEvent.button !== 0) return
279
+ const pointerId = pointerEvent.pointerId
280
+ // Without multiPointer only one pointer drives the drag; ignore the rest.
281
+ if (pointers.has(pointerId)) return
282
+ if (!multiPointer && pointers.size > 0) return
129
283
 
130
- pointerId = pointerEvent.pointerId
131
- startX = lastX = pointerEvent.screenX
132
- startY = lastY = pointerEvent.screenY
284
+ if (opts.shouldStart && !opts.shouldStart(pointerEvent)) return
133
285
 
134
- if (cursor && style) {
286
+ const isFirst = pointers.size === 0
287
+ if (isFirst && opts.cursor && style) {
135
288
  previousCursor = style.cursor
136
- style.cursor = cursor
289
+ style.cursor = opts.cursor
137
290
  }
138
291
 
139
292
  capture.setPointerCapture?.(pointerId)
140
293
  // With pointer capture the element receives the rest of the gesture.
141
294
  // Without it (older engines, jsdom) fall back to the window.
142
- moveTarget =
295
+ const moveTarget =
143
296
  capture.hasPointerCapture?.(pointerId) === true
144
297
  ? element
145
298
  : (globalThis.window ?? element)
146
299
 
147
- moveTarget.addEventListener('pointermove', handlePointerMove)
148
- moveTarget.addEventListener('pointerup', handlePointerUp)
149
- moveTarget.addEventListener('pointercancel', handlePointerUp)
150
- globalThis.document?.addEventListener('selectstart', preventSelectStart)
300
+ const pointer: PointerState = {
301
+ moveTarget,
302
+ startX: pointerEvent.screenX,
303
+ startY: pointerEvent.screenY,
304
+ lastX: pointerEvent.screenX,
305
+ lastY: pointerEvent.screenY,
306
+ lockMoveX: 0,
307
+ lockMoveY: 0,
308
+ lastEvent: pointerEvent,
309
+ }
310
+ pointers.set(pointerId, pointer)
151
311
 
152
- onDragStart?.(state(pointerEvent, 0, 0))
312
+ retainTarget(moveTarget)
313
+ if (isFirst) {
314
+ globalThis.document?.addEventListener('selectstart', preventSelectStart)
315
+ }
316
+
317
+ // One pointer can be locked, so the first one takes it.
318
+ if (isFirst && opts.pointerLock) {
319
+ const requestId = ++lockRequest
320
+ lockedPointerId = pointerId
321
+ globalThis.document?.addEventListener(
322
+ 'pointerlockchange',
323
+ handleLockChange,
324
+ )
325
+ try {
326
+ // Newer engines return a promise that rejects; older ones fire
327
+ // `pointerlockerror` instead. Either way a refusal only means the drag
328
+ // stays an ordinary one, so nothing here has to act on it.
329
+ const request = capture.requestPointerLock?.() as
330
+ | Promise<void>
331
+ | undefined
332
+ request?.then?.(
333
+ () => {
334
+ if (
335
+ requestId === lockRequest &&
336
+ (!pointers.has(pointerId) || destroyed) &&
337
+ globalThis.document?.pointerLockElement === element
338
+ ) {
339
+ globalThis.document.exitPointerLock?.()
340
+ }
341
+ },
342
+ () => {},
343
+ )
344
+ } catch {
345
+ // requestPointerLock threw synchronously; same story.
346
+ }
347
+ }
348
+
349
+ opts.onDragStart?.(state(pointerEvent, pointer, 0, 0))
153
350
  }
154
351
 
155
352
  function handlePointerMove(event: Event) {
156
353
  const pointerEvent = event as PointerEvent
157
- if (pointerEvent.pointerId !== pointerId) return
354
+ const pointer = pointers.get(pointerEvent.pointerId)
355
+ if (!pointer) return
158
356
 
159
- const deltaX = pointerEvent.screenX - lastX
160
- const deltaY = pointerEvent.screenY - lastY
161
- lastX = pointerEvent.screenX
162
- lastY = pointerEvent.screenY
357
+ // Under the lock the screen position no longer moves, so the movement the
358
+ // event reports is the only thing left to read.
359
+ const locked = pointer.lockBaseX !== undefined
360
+ const deltaX = locked
361
+ ? (pointerEvent.movementX ?? 0)
362
+ : pointerEvent.screenX - pointer.lastX
363
+ const deltaY = locked
364
+ ? (pointerEvent.movementY ?? 0)
365
+ : pointerEvent.screenY - pointer.lastY
163
366
 
164
- // Movement below the threshold is dropped rather than accumulated.
367
+ // Movement below the threshold accumulates until it crosses it. Dropping it
368
+ // instead would swallow a slow drag entirely: pointer coordinates are
369
+ // fractional, so each event can move less than a pixel and never report.
370
+ const threshold = opts.threshold ?? 0
165
371
  if (Math.abs(deltaX) < threshold && Math.abs(deltaY) < threshold) return
166
372
 
167
- onDrag?.(state(pointerEvent, deltaX, deltaY))
373
+ pointer.lastX = pointerEvent.screenX
374
+ pointer.lastY = pointerEvent.screenY
375
+ pointer.lastEvent = pointerEvent
376
+ if (locked) {
377
+ pointer.lockMoveX += deltaX
378
+ pointer.lockMoveY += deltaY
379
+ }
380
+
381
+ opts.onDrag?.(state(pointerEvent, pointer, deltaX, deltaY))
382
+ }
383
+
384
+ function handleLockChange() {
385
+ if (lockedPointerId === null) return
386
+ const pointer = pointers.get(lockedPointerId)
387
+ if (!pointer) return
388
+
389
+ if (globalThis.document?.pointerLockElement === element) {
390
+ // Engaged. Whatever moved while the request was in flight was measured
391
+ // the ordinary way, so the travel so far becomes the base and the
392
+ // per-event movement is added to it from here.
393
+ pointer.lockBaseX = pointer.lastX - pointer.startX
394
+ pointer.lockBaseY = pointer.lastY - pointer.startY
395
+ pointer.lockMoveX = 0
396
+ pointer.lockMoveY = 0
397
+ return
398
+ }
399
+
400
+ // Lost without the pointer coming up: Esc, a tab switch, leaving
401
+ // fullscreen. No pointerup is coming, so the drag ends here rather than
402
+ // hanging on with a pointer nobody can see.
403
+ if (pointer.lockBaseX === undefined) return
404
+ finishDrag(lockedPointerId)
168
405
  }
169
406
 
170
407
  function handlePointerUp(event: Event) {
171
408
  const pointerEvent = event as PointerEvent
172
- if (pointerEvent.pointerId !== pointerId) return
409
+ finishDrag(pointerEvent.pointerId, pointerEvent)
410
+ }
411
+
412
+ function handleLostPointerCapture(event: Event) {
413
+ const pointerEvent = event as PointerEvent
414
+ finishDrag(pointerEvent.pointerId)
415
+ }
173
416
 
174
- const deltaX = pointerEvent.screenX - lastX
175
- const deltaY = pointerEvent.screenY - lastY
176
- const finalState = state(pointerEvent, deltaX, deltaY)
417
+ function finishDrag(pointerId: number, event?: PointerEvent) {
418
+ const pointer = pointers.get(pointerId)
419
+ if (!pointer) return
177
420
 
178
- stopTracking()
179
- onDragEnd?.(finalState)
421
+ const finalState = event
422
+ ? state(
423
+ event,
424
+ pointer,
425
+ event.screenX - pointer.lastX,
426
+ event.screenY - pointer.lastY,
427
+ )
428
+ : state(pointer.lastEvent, pointer, 0, 0)
429
+
430
+ stopTracking(pointerId)
431
+ opts.onDragEnd?.(finalState)
180
432
  }
181
433
 
182
- function stopTracking() {
183
- if (pointerId === null) return
184
- capture.releasePointerCapture?.(pointerId)
185
- moveTarget?.removeEventListener('pointermove', handlePointerMove)
186
- moveTarget?.removeEventListener('pointerup', handlePointerUp)
187
- moveTarget?.removeEventListener('pointercancel', handlePointerUp)
434
+ function stopTracking(pointerId: number) {
435
+ const pointer = pointers.get(pointerId)
436
+ if (!pointer) return
437
+
438
+ pointers.delete(pointerId)
439
+ releaseTarget(pointer.moveTarget)
440
+ try {
441
+ if (capture.hasPointerCapture?.(pointerId) === true) {
442
+ capture.releasePointerCapture?.(pointerId)
443
+ }
444
+ } catch {
445
+ // The capture may disappear between checking and releasing it. Tracking
446
+ // is already cleared, so the drag still ends normally.
447
+ }
448
+
449
+ if (lockedPointerId === pointerId) {
450
+ lockedPointerId = null
451
+ const document = globalThis.document
452
+ document?.removeEventListener('pointerlockchange', handleLockChange)
453
+ // Already gone when the lock is what ended the drag.
454
+ if (document?.pointerLockElement === element) document.exitPointerLock?.()
455
+ }
456
+
457
+ if (pointers.size > 0) return
458
+
188
459
  globalThis.document?.removeEventListener('selectstart', preventSelectStart)
189
- if (cursor && style) {
190
- style.cursor = previousCursor ?? ''
460
+ if (previousCursor !== undefined && style) {
461
+ style.cursor = previousCursor
191
462
  previousCursor = undefined
192
463
  }
193
- moveTarget = null
194
- pointerId = null
195
464
  }
196
465
 
197
466
  element.addEventListener('pointerdown', handlePointerDown)
198
467
 
199
468
  return {
469
+ update: (next) => {
470
+ opts = { ...opts, ...next, multiPointer }
471
+ },
200
472
  destroy: () => {
201
- stopTracking()
473
+ if (destroyed) return
474
+ destroyed = true
202
475
  element.removeEventListener('pointerdown', handlePointerDown)
203
- if (style) {
476
+ for (const pointerId of [...pointers.keys()]) finishDrag(pointerId)
477
+ if (style && managedStyles && --managedStyles.count === 0) {
204
478
  for (const [property] of MANAGED_STYLES) {
205
- const previous = previousStyles.get(property)
206
- if (previous) {
207
- style.setProperty(property, previous)
479
+ const previous = managedStyles.previous.get(property)
480
+ if (previous?.value) {
481
+ style.setProperty(property, previous.value, previous.priority)
208
482
  } else {
209
483
  style.removeProperty(property)
210
484
  }
211
485
  }
486
+ managedStyleStates.delete(element)
212
487
  }
213
488
  },
214
489
  }
@@ -1,4 +1,25 @@
1
+ export interface WheelOptions {
2
+ /** Replace the callback through {@link WheelInstance.update}. */
3
+ onWheel?: (event: WheelEvent) => void
4
+ /**
5
+ * Only report events while the focus is inside the element.
6
+ *
7
+ * A control that reacts to the wheel on hover alone takes the scroll away
8
+ * from the page, so passing over one in a long form silently changes its
9
+ * value. Requiring focus makes that an explicit act.
10
+ *
11
+ * The check is `contains`, not an identity test: the element that actually
12
+ * takes focus is usually a descendant, such as a thumb or an `<input>`, and
13
+ * a caller may have replaced it with markup of their own.
14
+ *
15
+ * @default false
16
+ */
17
+ requireFocus?: boolean
18
+ }
19
+
1
20
  export interface WheelInstance {
21
+ /** Replace the given options, keeping the listener in place. */
22
+ update: (options: WheelOptions) => void
2
23
  destroy: () => void
3
24
  }
4
25
 
@@ -11,12 +32,26 @@ export interface WheelInstance {
11
32
  export function createWheel(
12
33
  element: Element,
13
34
  onWheel: (event: WheelEvent) => void,
35
+ options: WheelOptions = {},
14
36
  ): WheelInstance {
15
- const handler = (event: Event) => onWheel(event as WheelEvent)
37
+ let opts = { ...options, onWheel }
38
+
39
+ function hasFocus() {
40
+ const active = element.ownerDocument?.activeElement
41
+ return !!active && element.contains(active)
42
+ }
43
+
44
+ const handler = (event: Event) => {
45
+ if (opts.requireFocus && !hasFocus()) return
46
+ opts.onWheel?.(event as WheelEvent)
47
+ }
16
48
 
17
49
  element.addEventListener('wheel', handler, { passive: false })
18
50
 
19
51
  return {
52
+ update: (next) => {
53
+ opts = { ...opts, ...next }
54
+ },
20
55
  destroy: () => {
21
56
  // Only `capture` matters when removing, and it is false here.
22
57
  element.removeEventListener('wheel', handler)