@tremolo-ui/dom 0.5.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.
@@ -3,6 +3,7 @@ import {
3
3
  linearScale,
4
4
  normalizeValue,
5
5
  stepValue,
6
+ toPrecision,
6
7
  type ValueRange,
7
8
  } from '@tremolo-ui/functions'
8
9
 
@@ -59,7 +60,32 @@ export interface DragValueMapping {
59
60
  */
60
61
  export function elementMapping(
61
62
  getElement: () => Element | null | undefined,
63
+ {
64
+ sensitivity,
65
+ }: {
66
+ /**
67
+ * How much the movement counts, read on every move. `1` is the pointer
68
+ * position itself; `0.1` makes the same movement cover a tenth of the
69
+ * travel, which is what a fine-adjustment modifier wants.
70
+ *
71
+ * Anything but `1` turns the mapping relative: the value stops being the
72
+ * position pointed at and starts being where it stood when the sensitivity
73
+ * changed, plus the movement since. **The pointer and the value stay apart
74
+ * for the rest of the drag** rather than snapping back together when the
75
+ * key is released, since snapping would move the value nobody asked to
76
+ * move.
77
+ */
78
+ sensitivity?: (state: DragState) => number
79
+ } = {},
62
80
  ): DragValueMapping {
81
+ /** The reported position when the sensitivity last changed. */
82
+ let origin: XY<number> = [0, 0]
83
+ /** The raw position at that same moment. */
84
+ let anchor: XY<number> = [0, 0]
85
+ /** The previous event, so a sensitivity change can be dated back to it. */
86
+ let previous: XY<number> = [0, 0]
87
+ let factor = 1
88
+
63
89
  function positionIn(state: DragState): XY<number> | null {
64
90
  const element = getElement()
65
91
  if (!element) return null
@@ -72,7 +98,48 @@ export function elementMapping(
72
98
  ]
73
99
  }
74
100
 
75
- return { start: positionIn, move: positionIn }
101
+ const reported = (raw: XY<number>, at: number): XY<number> => [
102
+ origin[0] + (raw[0] - anchor[0]) * at,
103
+ origin[1] + (raw[1] - anchor[1]) * at,
104
+ ]
105
+
106
+ return {
107
+ start: (state) => {
108
+ const raw = positionIn(state)
109
+ if (!raw) return null
110
+ // origin and anchor together, so the value is the position pointed at:
111
+ // a plain click still lands where it was aimed.
112
+ origin = raw
113
+ anchor = raw
114
+ previous = raw
115
+ // Read here too, so a modifier already held when the pointer went down
116
+ // applies from the first pixel rather than from the first change.
117
+ factor = sensitivity ? sensitivity(state) : 1
118
+ return raw
119
+ },
120
+ move: (state) => {
121
+ const raw = positionIn(state)
122
+ if (!raw) return null
123
+
124
+ const next = sensitivity ? sensitivity(state) : 1
125
+ if (next !== factor) {
126
+ // Fold the travel so far into the origin, or the new sensitivity would
127
+ // apply to the whole drag and the value would jump.
128
+ //
129
+ // Dated to the previous event rather than this one: a key produces no
130
+ // pointer event of its own, so the change is only seen on the next
131
+ // move, and that move's own distance belongs to the new sensitivity.
132
+ origin = reported(previous, factor)
133
+ anchor = previous
134
+ factor = next
135
+ }
136
+ previous = raw
137
+
138
+ // With no sensitivity given, origin and anchor never move apart and this
139
+ // is the raw position, exactly as before.
140
+ return reported(raw, factor)
141
+ },
142
+ }
76
143
  }
77
144
 
78
145
  /**
@@ -82,27 +149,65 @@ export function elementMapping(
82
149
  */
83
150
  export function relativeMapping({
84
151
  pixelRange = 100,
152
+ sensitivity,
85
153
  }: {
86
154
  /**
87
155
  * Pixels of movement that span the whole range.
88
156
  * @default 100
89
157
  */
90
158
  pixelRange?: XYInput<number>
159
+ /**
160
+ * How much the movement counts, read on every move. `1` is `pixelRange` as
161
+ * given; `0.1` makes the same movement cover a tenth of the range, which is
162
+ * what a fine-adjustment modifier wants.
163
+ *
164
+ * Changing it mid-drag does not disturb the value: the travel so far is
165
+ * folded into the origin and measuring starts again from there.
166
+ */
167
+ sensitivity?: (state: DragState) => number
91
168
  } = {}): DragValueMapping {
92
- const [rangeX, rangeY] = toXY(pixelRange)
169
+ const [baseX, baseY] = toXY(pixelRange)
93
170
  let origin: XY<number> = [0, 0]
171
+ /** Where the current sensitivity took over, in drag coordinates. */
172
+ let anchor: XY<number> = [0, 0]
173
+ /** The previous event, so a sensitivity change can be dated back to it. */
174
+ let previous: XY<number> = [0, 0]
175
+ let factor = 1
176
+
177
+ const travelled = (to: XY<number>, at: number): XY<number> => [
178
+ baseX === 0 ? origin[0] : origin[0] + ((to[0] - anchor[0]) * at) / baseX,
179
+ baseY === 0 ? origin[1] : origin[1] + ((to[1] - anchor[1]) * at) / baseY,
180
+ ]
94
181
 
95
182
  return {
96
- start: (_state, context) => {
183
+ start: (state, context) => {
97
184
  origin = context.position()
185
+ anchor = [0, 0]
186
+ previous = [0, 0]
187
+ // Read here too, so a modifier already held when the pointer went down
188
+ // applies from the first pixel rather than from the first change.
189
+ factor = sensitivity ? sensitivity(state) : 1
98
190
  return origin
99
191
  },
100
- // `x` and `y` are measured from the start of the drag, so the position
101
- // never accumulates a rounding error of its own.
102
- move: (state) => [
103
- origin[0] + state.x / rangeX,
104
- origin[1] + state.y / rangeY,
105
- ],
192
+ // Measured from the anchor rather than accumulated per event, so the
193
+ // position picks up no rounding error of its own. The anchor only moves
194
+ // when the sensitivity does, which is a handful of times per drag at most.
195
+ move: (state) => {
196
+ const next = sensitivity ? sensitivity(state) : 1
197
+ if (next !== factor) {
198
+ // Fold the travel so far into the origin, or the new sensitivity would
199
+ // apply to the whole drag and the value would jump.
200
+ //
201
+ // Dated to the previous event rather than this one: a key produces no
202
+ // pointer event of its own, so the change is only seen on the next
203
+ // move, and that move's own distance belongs to the new sensitivity.
204
+ origin = travelled(previous, factor)
205
+ anchor = previous
206
+ factor = next
207
+ }
208
+ previous = [state.x, state.y]
209
+ return travelled([state.x, state.y], factor)
210
+ },
106
211
  }
107
212
  }
108
213
 
@@ -135,6 +240,16 @@ export interface DragValueOptions {
135
240
  threshold?: number
136
241
  /** @see DragOptions.cursor */
137
242
  cursor?: string
243
+ /**
244
+ * @see DragOptions.pointerLock
245
+ *
246
+ * Only for a mapping that moves the value relative to where it stood, such
247
+ * as {@link relativeMapping}. {@link elementMapping} reads the pointer
248
+ * position, and there is none while it is locked.
249
+ */
250
+ pointerLock?: boolean
251
+ /** @see DragOptions.shouldStart */
252
+ shouldStart?: (event: PointerEvent) => boolean
138
253
 
139
254
  onChange?: (value: XY<number>, state: DragState) => void
140
255
  onDragStart?: (value: XY<number>, state: DragState) => void
@@ -165,6 +280,7 @@ export function createDragValue(
165
280
  ): DragValueInstance {
166
281
  let opts = options
167
282
  let lastValue: XY<number> = [0, 0]
283
+ let active = false
168
284
 
169
285
  const axes = () => toXY(opts.axis)
170
286
 
@@ -175,8 +291,9 @@ export function createDragValue(
175
291
  const scale = axis.scale ?? linearScale
176
292
  const value = scale.denormalize(p, axis.min, axis.max)
177
293
  const stepped = axis.step ? stepValue(value, axis.step) : value
178
- // Rounding to the step can leave the range.
179
- return clamp(stepped, axis.min, axis.max)
294
+ // Rounding to the step can leave the range, and so can dropping the
295
+ // binary artefact, so the clamp comes last.
296
+ return clamp(toPrecision(stepped), axis.min, axis.max)
180
297
  }) as XY<number>
181
298
  }
182
299
 
@@ -200,29 +317,48 @@ export function createDragValue(
200
317
  const drag = createDrag(element, {
201
318
  threshold: opts.threshold,
202
319
  cursor: opts.cursor,
320
+ pointerLock: opts.pointerLock,
321
+ shouldStart: (event) => opts.shouldStart?.(event) ?? true,
203
322
  onDragStart: (state) => {
204
323
  const position = opts.mapping.start(state, context)
205
- if (position) {
206
- lastValue = valueOf(position)
207
- if (opts.updateOnPointerDown) opts.onChange?.(lastValue, state)
208
- }
324
+ if (!position) return
325
+ active = true
326
+ lastValue = valueOf(position)
327
+ if (opts.updateOnPointerDown) opts.onChange?.(lastValue, state)
209
328
  opts.onDragStart?.(lastValue, state)
210
329
  },
211
330
  onDrag: (state) => {
331
+ if (!active) return
212
332
  const position = opts.mapping.move(state, context)
213
333
  if (!position) return
214
334
  lastValue = valueOf(position)
215
335
  opts.onChange?.(lastValue, state)
216
336
  },
217
- // The pointer has not moved since the last reported value, so `lastValue`
218
- // is where the drag ended.
219
- onDragEnd: (state) => opts.onDragEnd?.(lastValue, state),
337
+ onDragEnd: (state) => {
338
+ if (!active) return
339
+ if (
340
+ state.event.type === 'pointerup' &&
341
+ (state.deltaX !== 0 || state.deltaY !== 0)
342
+ ) {
343
+ const position = opts.mapping.move(state, context)
344
+ if (position) {
345
+ lastValue = valueOf(position)
346
+ opts.onChange?.(lastValue, state)
347
+ }
348
+ }
349
+ active = false
350
+ opts.onDragEnd?.(lastValue, state)
351
+ },
220
352
  })
221
353
 
222
354
  return {
223
355
  update: (next) => {
224
356
  opts = { ...opts, ...next, mapping: opts.mapping }
225
- drag.update({ threshold: opts.threshold, cursor: opts.cursor })
357
+ drag.update({
358
+ threshold: opts.threshold,
359
+ cursor: opts.cursor,
360
+ pointerLock: opts.pointerLock,
361
+ })
226
362
  },
227
363
  destroy: () => drag.destroy(),
228
364
  }
@@ -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 })