@tremolo-ui/dom 0.5.0 → 0.7.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.
@@ -38,6 +38,42 @@ export type DragOptions = {
38
38
  */
39
39
  cursor?: string
40
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
+
41
77
  /**
42
78
  * Track every pointer that goes down on the element, rather than only the
43
79
  * first. Each one gets its own `onDragStart` / `onDrag` / `onDragEnd` and
@@ -52,6 +88,10 @@ export type DragOptions = {
52
88
 
53
89
  onDragStart?: (state: DragState) => void
54
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
+ */
55
95
  onDragEnd?: (state: DragState) => void
56
96
  }
57
97
 
@@ -64,6 +104,7 @@ export interface DragInstance {
64
104
  * here.
65
105
  */
66
106
  update: (options: DragOptions) => void
107
+ /** End any active drags before removing the instance. */
67
108
  destroy: () => void
68
109
  }
69
110
 
@@ -81,7 +122,15 @@ const MANAGED_STYLES = [
81
122
  ['-webkit-touch-callout', 'none'],
82
123
  ] as const
83
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
+
84
132
  type CaptureTarget = {
133
+ requestPointerLock?: () => unknown
85
134
  setPointerCapture?: (pointerId: number) => void
86
135
  releasePointerCapture?: (pointerId: number) => void
87
136
  hasPointerCapture?: (pointerId: number) => boolean
@@ -95,6 +144,21 @@ type PointerState = {
95
144
  startY: number
96
145
  lastX: number
97
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
98
162
  }
99
163
 
100
164
  /**
@@ -117,11 +181,21 @@ export function createDrag(
117
181
  const capture = element as CaptureTarget
118
182
  const style = (element as Partial<HTMLElement>).style
119
183
 
120
- const previousStyles = new Map<string, string>()
184
+ let managedStyles = managedStyleStates.get(element)
121
185
  if (style) {
122
- for (const [property, value] of MANAGED_STYLES) {
123
- previousStyles.set(property, style.getPropertyValue(property))
124
- 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)
125
199
  }
126
200
  }
127
201
 
@@ -133,6 +207,10 @@ export function createDrag(
133
207
  */
134
208
  const targets = new Map<EventTarget, number>()
135
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
136
214
 
137
215
  function state(
138
216
  event: PointerEvent,
@@ -141,8 +219,14 @@ export function createDrag(
141
219
  deltaY: number,
142
220
  ): DragState {
143
221
  return {
144
- x: event.screenX - pointer.startX,
145
- y: event.screenY - pointer.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,
146
230
  deltaX,
147
231
  deltaY,
148
232
  clientX: event.clientX,
@@ -167,6 +251,9 @@ export function createDrag(
167
251
  target.addEventListener('pointermove', handlePointerMove)
168
252
  target.addEventListener('pointerup', handlePointerUp)
169
253
  target.addEventListener('pointercancel', handlePointerUp)
254
+ if (target === element) {
255
+ target.addEventListener('lostpointercapture', handleLostPointerCapture)
256
+ }
170
257
  }
171
258
  targets.set(target, count + 1)
172
259
  }
@@ -180,16 +267,22 @@ export function createDrag(
180
267
  target.removeEventListener('pointermove', handlePointerMove)
181
268
  target.removeEventListener('pointerup', handlePointerUp)
182
269
  target.removeEventListener('pointercancel', handlePointerUp)
270
+ if (target === element) {
271
+ target.removeEventListener('lostpointercapture', handleLostPointerCapture)
272
+ }
183
273
  targets.delete(target)
184
274
  }
185
275
 
186
276
  function handlePointerDown(event: Event) {
187
277
  const pointerEvent = event as PointerEvent
278
+ if (pointerEvent.button !== 0) return
188
279
  const pointerId = pointerEvent.pointerId
189
280
  // Without multiPointer only one pointer drives the drag; ignore the rest.
190
281
  if (pointers.has(pointerId)) return
191
282
  if (!multiPointer && pointers.size > 0) return
192
283
 
284
+ if (opts.shouldStart && !opts.shouldStart(pointerEvent)) return
285
+
193
286
  const isFirst = pointers.size === 0
194
287
  if (isFirst && opts.cursor && style) {
195
288
  previousCursor = style.cursor
@@ -210,6 +303,9 @@ export function createDrag(
210
303
  startY: pointerEvent.screenY,
211
304
  lastX: pointerEvent.screenX,
212
305
  lastY: pointerEvent.screenY,
306
+ lockMoveX: 0,
307
+ lockMoveY: 0,
308
+ lastEvent: pointerEvent,
213
309
  }
214
310
  pointers.set(pointerId, pointer)
215
311
 
@@ -218,6 +314,38 @@ export function createDrag(
218
314
  globalThis.document?.addEventListener('selectstart', preventSelectStart)
219
315
  }
220
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
+
221
349
  opts.onDragStart?.(state(pointerEvent, pointer, 0, 0))
222
350
  }
223
351
 
@@ -226,8 +354,15 @@ export function createDrag(
226
354
  const pointer = pointers.get(pointerEvent.pointerId)
227
355
  if (!pointer) return
228
356
 
229
- const deltaX = pointerEvent.screenX - pointer.lastX
230
- const deltaY = pointerEvent.screenY - pointer.lastY
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
231
366
 
232
367
  // Movement below the threshold accumulates until it crosses it. Dropping it
233
368
  // instead would swallow a slow drag entirely: pointer coordinates are
@@ -237,20 +372,62 @@ export function createDrag(
237
372
 
238
373
  pointer.lastX = pointerEvent.screenX
239
374
  pointer.lastY = pointerEvent.screenY
375
+ pointer.lastEvent = pointerEvent
376
+ if (locked) {
377
+ pointer.lockMoveX += deltaX
378
+ pointer.lockMoveY += deltaY
379
+ }
240
380
 
241
381
  opts.onDrag?.(state(pointerEvent, pointer, deltaX, deltaY))
242
382
  }
243
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)
405
+ }
406
+
244
407
  function handlePointerUp(event: Event) {
245
408
  const pointerEvent = event as PointerEvent
246
- const pointer = pointers.get(pointerEvent.pointerId)
409
+ finishDrag(pointerEvent.pointerId, pointerEvent)
410
+ }
411
+
412
+ function handleLostPointerCapture(event: Event) {
413
+ const pointerEvent = event as PointerEvent
414
+ finishDrag(pointerEvent.pointerId)
415
+ }
416
+
417
+ function finishDrag(pointerId: number, event?: PointerEvent) {
418
+ const pointer = pointers.get(pointerId)
247
419
  if (!pointer) return
248
420
 
249
- const deltaX = pointerEvent.screenX - pointer.lastX
250
- const deltaY = pointerEvent.screenY - pointer.lastY
251
- const finalState = state(pointerEvent, pointer, deltaX, deltaY)
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)
252
429
 
253
- stopTracking(pointerEvent.pointerId)
430
+ stopTracking(pointerId)
254
431
  opts.onDragEnd?.(finalState)
255
432
  }
256
433
 
@@ -258,9 +435,24 @@ export function createDrag(
258
435
  const pointer = pointers.get(pointerId)
259
436
  if (!pointer) return
260
437
 
261
- capture.releasePointerCapture?.(pointerId)
262
- releaseTarget(pointer.moveTarget)
263
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
+ }
264
456
 
265
457
  if (pointers.size > 0) return
266
458
 
@@ -278,17 +470,20 @@ export function createDrag(
278
470
  opts = { ...opts, ...next, multiPointer }
279
471
  },
280
472
  destroy: () => {
281
- for (const pointerId of [...pointers.keys()]) stopTracking(pointerId)
473
+ if (destroyed) return
474
+ destroyed = true
282
475
  element.removeEventListener('pointerdown', handlePointerDown)
283
- if (style) {
476
+ for (const pointerId of [...pointers.keys()]) finishDrag(pointerId)
477
+ if (style && managedStyles && --managedStyles.count === 0) {
284
478
  for (const [property] of MANAGED_STYLES) {
285
- const previous = previousStyles.get(property)
286
- if (previous) {
287
- style.setProperty(property, previous)
479
+ const previous = managedStyles.previous.get(property)
480
+ if (previous?.value) {
481
+ style.setProperty(property, previous.value, previous.priority)
288
482
  } else {
289
483
  style.removeProperty(property)
290
484
  }
291
485
  }
486
+ managedStyleStates.delete(element)
292
487
  }
293
488
  },
294
489
  }
@@ -1,4 +1,6 @@
1
1
  export interface WheelOptions {
2
+ /** Replace the callback through {@link WheelInstance.update}. */
3
+ onWheel?: (event: WheelEvent) => void
2
4
  /**
3
5
  * Only report events while the focus is inside the element.
4
6
  *
@@ -32,7 +34,7 @@ export function createWheel(
32
34
  onWheel: (event: WheelEvent) => void,
33
35
  options: WheelOptions = {},
34
36
  ): WheelInstance {
35
- let opts = options
37
+ let opts = { ...options, onWheel }
36
38
 
37
39
  function hasFocus() {
38
40
  const active = element.ownerDocument?.activeElement
@@ -41,7 +43,7 @@ export function createWheel(
41
43
 
42
44
  const handler = (event: Event) => {
43
45
  if (opts.requireFocus && !hasFocus()) return
44
- onWheel(event as WheelEvent)
46
+ opts.onWheel?.(event as WheelEvent)
45
47
  }
46
48
 
47
49
  element.addEventListener('wheel', handler, { passive: false })
@@ -0,0 +1,136 @@
1
+ import type { XY } from '../xy'
2
+
3
+ /**
4
+ * A rectangle in the 0..1 space of whatever the box is drawn over, with `y`
5
+ * growing downwards — the same space the items are given in.
6
+ */
7
+ export interface SelectionBoxRect {
8
+ x: number
9
+ y: number
10
+ width: number
11
+ height: number
12
+ }
13
+
14
+ export interface SelectionBoxOptions<Id> {
15
+ /**
16
+ * Everything the box can pick up, read on every move rather than taken as a
17
+ * snapshot: what is under the box changes while it is dragged, and an item
18
+ * may have moved since the last one.
19
+ */
20
+ items: () => Iterable<readonly [Id, XY<number>]>
21
+ /** The box as it is dragged, and `null` once it is gone. */
22
+ onBoxChange?: (box: SelectionBoxRect | null) => void
23
+ /** What the box covers, added to whatever it started from. */
24
+ onSelectionChange?: (ids: Id[]) => void
25
+ }
26
+
27
+ export interface SelectionBoxBeginOptions<Id> {
28
+ /**
29
+ * Add to `selection` rather than replacing it. Ctrl / meta rather than
30
+ * shift, for the controls where shift is the fine-adjustment key.
31
+ */
32
+ additive?: boolean
33
+ /** What was selected before the box started. Only read when `additive`. */
34
+ selection?: readonly Id[]
35
+ }
36
+
37
+ export interface SelectionBoxInstance<Id> {
38
+ /** Replace the given options, without interrupting a box in progress. */
39
+ update: (options: Partial<SelectionBoxOptions<Id>>) => void
40
+
41
+ /** Start a box at `at`, which is one of its corners. */
42
+ begin: (at: XY<number>, options?: SelectionBoxBeginOptions<Id>) => void
43
+ /** Drag the opposite corner to `to`. */
44
+ move: (to: XY<number>) => void
45
+ /**
46
+ * Finish the box. The selection stays as it is.
47
+ *
48
+ * @returns whether a box was running, so that a caller can tell a drag from
49
+ * a press that selected nothing.
50
+ */
51
+ end: () => boolean
52
+
53
+ /** The box being dragged, or `null` when there is none. */
54
+ box: () => SelectionBoxRect | null
55
+
56
+ destroy: () => void
57
+ }
58
+
59
+ function rectOf(from: XY<number>, to: XY<number>): SelectionBoxRect {
60
+ return {
61
+ x: Math.min(from[0], to[0]),
62
+ y: Math.min(from[1], to[1]),
63
+ width: Math.abs(to[0] - from[0]),
64
+ height: Math.abs(to[1] - from[1]),
65
+ }
66
+ }
67
+
68
+ /** Whether the box covers the point, edges included. */
69
+ export function selectionBoxCovers(
70
+ box: SelectionBoxRect,
71
+ [x, y]: XY<number>,
72
+ ): boolean {
73
+ return (
74
+ x >= box.x &&
75
+ x <= box.x + box.width &&
76
+ y >= box.y &&
77
+ y <= box.y + box.height
78
+ )
79
+ }
80
+
81
+ /**
82
+ * Selecting by dragging a box over a set of items.
83
+ *
84
+ * The drag itself is not here: the caller owns the pointer, and reports where
85
+ * it went in the same 0..1 space the items are given in.
86
+ */
87
+ export function createSelectionBox<Id>(
88
+ options: SelectionBoxOptions<Id>,
89
+ ): SelectionBoxInstance<Id> {
90
+ let opts = options
91
+ let drag: { from: XY<number>; base: readonly Id[] } | null = null
92
+ let box: SelectionBoxRect | null = null
93
+
94
+ function setBox(next: SelectionBoxRect | null) {
95
+ box = next
96
+ opts.onBoxChange?.(next)
97
+ }
98
+
99
+ function apply(rect: SelectionBoxRect, base: readonly Id[]) {
100
+ const inside: Id[] = []
101
+ for (const [id, at] of opts.items()) {
102
+ if (selectionBoxCovers(rect, at) && !base.includes(id)) inside.push(id)
103
+ }
104
+ opts.onSelectionChange?.([...base, ...inside])
105
+ }
106
+
107
+ return {
108
+ update: (next) => {
109
+ opts = { ...opts, ...next }
110
+ },
111
+ begin: (at, { additive = false, selection = [] } = {}) => {
112
+ drag = { from: at, base: additive ? [...selection] : [] }
113
+ setBox(rectOf(at, at))
114
+ // A plain press is a deselect of its own, so that a drag that picks
115
+ // nothing up leaves nothing selected.
116
+ if (!additive) opts.onSelectionChange?.([])
117
+ },
118
+ move: (to) => {
119
+ if (!drag) return
120
+ const rect = rectOf(drag.from, to)
121
+ setBox(rect)
122
+ apply(rect, drag.base)
123
+ },
124
+ end: () => {
125
+ const running = drag !== null
126
+ drag = null
127
+ if (box !== null) setBox(null)
128
+ return running
129
+ },
130
+ box: () => box,
131
+ destroy: () => {
132
+ drag = null
133
+ box = null
134
+ },
135
+ }
136
+ }