@tremolo-ui/dom 0.3.0 → 0.5.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.
@@ -0,0 +1,185 @@
1
+ import { noteAt, type PianoLayout } from '@tremolo-ui/functions'
2
+
3
+ import { createDrag } from '../pointer/drag'
4
+
5
+ /**
6
+ * What asked for a note to sound.
7
+ *
8
+ * A note stops only once everything that asked for it has let go, so two
9
+ * fingers on one key, or a key held by both the mouse and a MIDI keyboard,
10
+ * behave the way they look.
11
+ *
12
+ * Pointers use `pointer:<pointerId>`; everything else names its own source.
13
+ */
14
+ export type NoteSource = string
15
+
16
+ export interface PianoInputOptions {
17
+ /** Geometry of the drawn keyboard, used to find the note under a pointer. */
18
+ layout: PianoLayout
19
+
20
+ /**
21
+ * Let a pointer slide from one key to the next while it is down. With it off,
22
+ * the key that was pressed keeps sounding until the pointer is released.
23
+ *
24
+ * @default true
25
+ */
26
+ glissando?: boolean
27
+
28
+ /**
29
+ * Highest note that can sound. Above it a note is refused, whichever source
30
+ * asks for it.
31
+ *
32
+ * @default 127
33
+ */
34
+ midiMax?: number
35
+
36
+ /** Called when a note starts sounding, not for each source that asks. */
37
+ onPlayNote?: (note: number, velocity?: number) => void
38
+ /** Called once the last source holding a note has let go. */
39
+ onStopNote?: (note: number) => void
40
+ /** Called whenever {@link PianoInputInstance.activeNotes} would change. */
41
+ onActiveNotesChange?: (notes: number[]) => void
42
+ }
43
+
44
+ export interface PianoInputInstance {
45
+ /**
46
+ * Replace the given options. Lets a wrapper feed a fresh layout in without
47
+ * tearing down the listeners, which would abort a drag in progress.
48
+ */
49
+ update: (options: Partial<PianoInputOptions>) => void
50
+
51
+ /**
52
+ * Start a note from something other than a pointer: a keyboard shortcut, a
53
+ * MIDI message, an imperative call.
54
+ */
55
+ noteOn: (
56
+ note: number,
57
+ options?: { source?: NoteSource; velocity?: number },
58
+ ) => void
59
+ /** Release a note held by `source`. */
60
+ noteOff: (note: number, options?: { source?: NoteSource }) => void
61
+
62
+ /** The notes currently sounding, ascending. */
63
+ activeNotes: () => number[]
64
+
65
+ destroy: () => void
66
+ }
67
+
68
+ const DEFAULT_SOURCE: NoteSource = 'api'
69
+
70
+ /**
71
+ * Drive a piano keyboard with pointers, and own what is sounding.
72
+ *
73
+ * Every way of playing a note goes through the one instance — pointers here,
74
+ * keyboard shortcuts and MIDI through {@link PianoInputInstance.noteOn} — so
75
+ * there is a single answer to what is currently held, and drawing the keys is
76
+ * left entirely to the wrapper.
77
+ *
78
+ * Tracks every pointer at once, so a chord can be played with several fingers.
79
+ */
80
+ export function createPianoInput(
81
+ element: Element,
82
+ options: PianoInputOptions,
83
+ ): PianoInputInstance {
84
+ let opts = options
85
+
86
+ /** Which sources are holding each sounding note. */
87
+ const held = new Map<number, Set<NoteSource>>()
88
+ /** The note each pointer is currently on. */
89
+ const pointerNotes = new Map<number, number>()
90
+
91
+ function activeNotes(): number[] {
92
+ return [...held.keys()].sort((a, b) => a - b)
93
+ }
94
+
95
+ function noteOn(
96
+ note: number,
97
+ {
98
+ source = DEFAULT_SOURCE,
99
+ velocity,
100
+ }: { source?: NoteSource; velocity?: number } = {},
101
+ ) {
102
+ if (note > (opts.midiMax ?? 127)) return
103
+
104
+ const sources = held.get(note)
105
+ if (sources) {
106
+ // Already sounding: remember the extra holder and leave it alone.
107
+ sources.add(source)
108
+ return
109
+ }
110
+
111
+ held.set(note, new Set([source]))
112
+ opts.onPlayNote?.(note, velocity)
113
+ opts.onActiveNotesChange?.(activeNotes())
114
+ }
115
+
116
+ function noteOff(
117
+ note: number,
118
+ { source = DEFAULT_SOURCE }: { source?: NoteSource } = {},
119
+ ) {
120
+ const sources = held.get(note)
121
+ if (!sources) return
122
+
123
+ sources.delete(source)
124
+ if (sources.size > 0) return
125
+
126
+ held.delete(note)
127
+ opts.onStopNote?.(note)
128
+ opts.onActiveNotesChange?.(activeNotes())
129
+ }
130
+
131
+ /** The note under a pointer, or null where the pointer is off the keys. */
132
+ function noteUnder(clientX: number, clientY: number): number | null {
133
+ const { left, top, height } = element.getBoundingClientRect()
134
+ return noteAt(clientX - left, clientY - top, height, opts.layout)
135
+ }
136
+
137
+ /** Move a pointer onto a note, releasing whatever it held before. */
138
+ function movePointer(pointerId: number, note: number | null) {
139
+ const source = `pointer:${pointerId}`
140
+ const previous = pointerNotes.get(pointerId)
141
+ if (previous === note) return
142
+
143
+ if (previous !== undefined) noteOff(previous, { source })
144
+
145
+ if (note === null) {
146
+ pointerNotes.delete(pointerId)
147
+ } else {
148
+ pointerNotes.set(pointerId, note)
149
+ noteOn(note, { source })
150
+ }
151
+ }
152
+
153
+ const drag = createDrag(element, {
154
+ multiPointer: true,
155
+ onDragStart: (state) =>
156
+ movePointer(state.pointerId, noteUnder(state.clientX, state.clientY)),
157
+ onDrag: (state) => {
158
+ // Without glissando the pressed key holds until the pointer is released,
159
+ // so where the pointer wanders to does not matter.
160
+ if (opts.glissando === false) return
161
+ movePointer(state.pointerId, noteUnder(state.clientX, state.clientY))
162
+ },
163
+ onDragEnd: (state) => movePointer(state.pointerId, null),
164
+ })
165
+
166
+ return {
167
+ update: (next) => {
168
+ opts = { ...opts, ...next }
169
+ },
170
+ noteOn,
171
+ noteOff,
172
+ activeNotes,
173
+ destroy: () => {
174
+ drag.destroy()
175
+ // Anything still held is released, so a caller that mirrors these
176
+ // callbacks into a synth is not left with a stuck note.
177
+ for (const note of activeNotes()) {
178
+ held.delete(note)
179
+ opts.onStopNote?.(note)
180
+ }
181
+ pointerNotes.clear()
182
+ opts.onActiveNotesChange?.([])
183
+ },
184
+ }
185
+ }
@@ -0,0 +1,295 @@
1
+ export type DragState = {
2
+ /** Total movement from the drag start, in screen coordinates. */
3
+ x: number
4
+ y: number
5
+ /** Movement since the previous event, in screen coordinates. */
6
+ deltaX: number
7
+ deltaY: number
8
+ /** Pointer position in viewport coordinates. */
9
+ clientX: number
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
16
+ event: PointerEvent
17
+ }
18
+
19
+ export type DragOptions = {
20
+ /**
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
+ *
25
+ * Prevents `onDrag` from firing on, for example, a double click.
26
+ *
27
+ * @default 0
28
+ */
29
+ threshold?: number
30
+
31
+ /**
32
+ * CSS cursor to show while dragging. Applied to the element itself: pointer
33
+ * capture keeps it in effect even once the pointer leaves the element, so
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.
38
+ */
39
+ cursor?: string
40
+
41
+ /**
42
+ * Track every pointer that goes down on the element, rather than only the
43
+ * first. Each one gets its own `onDragStart` / `onDrag` / `onDragEnd` and
44
+ * carries its own totals; {@link DragState.pointerId} says which is which.
45
+ *
46
+ * Fixed for the lifetime of the instance: switching part way through a drag
47
+ * has no meaning, so `update()` ignores it.
48
+ *
49
+ * @default false
50
+ */
51
+ multiPointer?: boolean
52
+
53
+ onDragStart?: (state: DragState) => void
54
+ onDrag?: (state: DragState) => void
55
+ onDragEnd?: (state: DragState) => void
56
+ }
57
+
58
+ export interface DragInstance {
59
+ /**
60
+ * Replace the given options. Lets a wrapper feed fresh handlers in without
61
+ * tearing down the listeners, which would abort a drag in progress.
62
+ *
63
+ * `multiPointer` is fixed for the lifetime of the instance and is ignored
64
+ * here.
65
+ */
66
+ update: (options: DragOptions) => void
67
+ destroy: () => void
68
+ }
69
+
70
+ /**
71
+ * Applied to the element for the lifetime of the instance.
72
+ *
73
+ * `touch-action` stops touch dragging from scrolling the page. That alone makes
74
+ * the browser treat a long press as the start of a text selection, so selection
75
+ * and the iOS callout are suppressed here too.
76
+ */
77
+ const MANAGED_STYLES = [
78
+ ['touch-action', 'none'],
79
+ ['user-select', 'none'],
80
+ ['-webkit-user-select', 'none'],
81
+ ['-webkit-touch-callout', 'none'],
82
+ ] as const
83
+
84
+ type CaptureTarget = {
85
+ setPointerCapture?: (pointerId: number) => void
86
+ releasePointerCapture?: (pointerId: number) => void
87
+ hasPointerCapture?: (pointerId: number) => boolean
88
+ }
89
+
90
+ /** What one pointer needs to report its own movement. */
91
+ type PointerState = {
92
+ /** Where the listeners for this pointer live. */
93
+ moveTarget: EventTarget
94
+ startX: number
95
+ startY: number
96
+ lastX: number
97
+ lastY: number
98
+ }
99
+
100
+ /**
101
+ * Track a pointer drag on an element.
102
+ *
103
+ * Uses Pointer Events only, and pointer capture so that the drag keeps working
104
+ * once the pointer leaves the element. For the lifetime of the instance the
105
+ * element gets `touch-action: none` so that touch dragging does not scroll the
106
+ * page, plus `user-select: none` so that a long press does not start a text
107
+ * selection instead.
108
+ *
109
+ * One pointer at a time by default; see {@link DragOptions.multiPointer}.
110
+ */
111
+ export function createDrag(
112
+ element: Element,
113
+ options: DragOptions = {},
114
+ ): DragInstance {
115
+ let opts = options
116
+ const multiPointer = options.multiPointer ?? false
117
+ const capture = element as CaptureTarget
118
+ const style = (element as Partial<HTMLElement>).style
119
+
120
+ const previousStyles = new Map<string, string>()
121
+ if (style) {
122
+ for (const [property, value] of MANAGED_STYLES) {
123
+ previousStyles.set(property, style.getPropertyValue(property))
124
+ style.setProperty(property, value)
125
+ }
126
+ }
127
+
128
+ const pointers = new Map<number, PointerState>()
129
+ /**
130
+ * How many pointers each target carries. Adding the same listener twice is a
131
+ * no-op and removing it once removes it for good, so a target is subscribed
132
+ * to while at least one pointer is on it and no longer.
133
+ */
134
+ const targets = new Map<EventTarget, number>()
135
+ let previousCursor: string | undefined
136
+
137
+ function state(
138
+ event: PointerEvent,
139
+ pointer: PointerState,
140
+ deltaX: number,
141
+ deltaY: number,
142
+ ): DragState {
143
+ return {
144
+ x: event.screenX - pointer.startX,
145
+ y: event.screenY - pointer.startY,
146
+ deltaX,
147
+ deltaY,
148
+ clientX: event.clientX,
149
+ clientY: event.clientY,
150
+ pointerId: event.pointerId,
151
+ event,
152
+ }
153
+ }
154
+
155
+ /**
156
+ * `user-select: none` only makes the element's own text unselectable; the
157
+ * browser still starts a selection on long press and grabs whatever text it
158
+ * finds nearby. Cancelling the selection outright is what actually stops it.
159
+ */
160
+ function preventSelectStart(event: Event) {
161
+ event.preventDefault()
162
+ }
163
+
164
+ function retainTarget(target: EventTarget) {
165
+ const count = targets.get(target) ?? 0
166
+ if (count === 0) {
167
+ target.addEventListener('pointermove', handlePointerMove)
168
+ target.addEventListener('pointerup', handlePointerUp)
169
+ target.addEventListener('pointercancel', handlePointerUp)
170
+ }
171
+ targets.set(target, count + 1)
172
+ }
173
+
174
+ function releaseTarget(target: EventTarget) {
175
+ const count = targets.get(target) ?? 0
176
+ if (count > 1) {
177
+ targets.set(target, count - 1)
178
+ return
179
+ }
180
+ target.removeEventListener('pointermove', handlePointerMove)
181
+ target.removeEventListener('pointerup', handlePointerUp)
182
+ target.removeEventListener('pointercancel', handlePointerUp)
183
+ targets.delete(target)
184
+ }
185
+
186
+ function handlePointerDown(event: Event) {
187
+ const pointerEvent = event as PointerEvent
188
+ const pointerId = pointerEvent.pointerId
189
+ // Without multiPointer only one pointer drives the drag; ignore the rest.
190
+ if (pointers.has(pointerId)) return
191
+ if (!multiPointer && pointers.size > 0) return
192
+
193
+ const isFirst = pointers.size === 0
194
+ if (isFirst && opts.cursor && style) {
195
+ previousCursor = style.cursor
196
+ style.cursor = opts.cursor
197
+ }
198
+
199
+ capture.setPointerCapture?.(pointerId)
200
+ // With pointer capture the element receives the rest of the gesture.
201
+ // Without it (older engines, jsdom) fall back to the window.
202
+ const moveTarget =
203
+ capture.hasPointerCapture?.(pointerId) === true
204
+ ? element
205
+ : (globalThis.window ?? element)
206
+
207
+ const pointer: PointerState = {
208
+ moveTarget,
209
+ startX: pointerEvent.screenX,
210
+ startY: pointerEvent.screenY,
211
+ lastX: pointerEvent.screenX,
212
+ lastY: pointerEvent.screenY,
213
+ }
214
+ pointers.set(pointerId, pointer)
215
+
216
+ retainTarget(moveTarget)
217
+ if (isFirst) {
218
+ globalThis.document?.addEventListener('selectstart', preventSelectStart)
219
+ }
220
+
221
+ opts.onDragStart?.(state(pointerEvent, pointer, 0, 0))
222
+ }
223
+
224
+ function handlePointerMove(event: Event) {
225
+ const pointerEvent = event as PointerEvent
226
+ const pointer = pointers.get(pointerEvent.pointerId)
227
+ if (!pointer) return
228
+
229
+ const deltaX = pointerEvent.screenX - pointer.lastX
230
+ const deltaY = pointerEvent.screenY - pointer.lastY
231
+
232
+ // Movement below the threshold accumulates until it crosses it. Dropping it
233
+ // instead would swallow a slow drag entirely: pointer coordinates are
234
+ // fractional, so each event can move less than a pixel and never report.
235
+ const threshold = opts.threshold ?? 0
236
+ if (Math.abs(deltaX) < threshold && Math.abs(deltaY) < threshold) return
237
+
238
+ pointer.lastX = pointerEvent.screenX
239
+ pointer.lastY = pointerEvent.screenY
240
+
241
+ opts.onDrag?.(state(pointerEvent, pointer, deltaX, deltaY))
242
+ }
243
+
244
+ function handlePointerUp(event: Event) {
245
+ const pointerEvent = event as PointerEvent
246
+ const pointer = pointers.get(pointerEvent.pointerId)
247
+ if (!pointer) return
248
+
249
+ const deltaX = pointerEvent.screenX - pointer.lastX
250
+ const deltaY = pointerEvent.screenY - pointer.lastY
251
+ const finalState = state(pointerEvent, pointer, deltaX, deltaY)
252
+
253
+ stopTracking(pointerEvent.pointerId)
254
+ opts.onDragEnd?.(finalState)
255
+ }
256
+
257
+ function stopTracking(pointerId: number) {
258
+ const pointer = pointers.get(pointerId)
259
+ if (!pointer) return
260
+
261
+ capture.releasePointerCapture?.(pointerId)
262
+ releaseTarget(pointer.moveTarget)
263
+ pointers.delete(pointerId)
264
+
265
+ if (pointers.size > 0) return
266
+
267
+ globalThis.document?.removeEventListener('selectstart', preventSelectStart)
268
+ if (previousCursor !== undefined && style) {
269
+ style.cursor = previousCursor
270
+ previousCursor = undefined
271
+ }
272
+ }
273
+
274
+ element.addEventListener('pointerdown', handlePointerDown)
275
+
276
+ return {
277
+ update: (next) => {
278
+ opts = { ...opts, ...next, multiPointer }
279
+ },
280
+ destroy: () => {
281
+ for (const pointerId of [...pointers.keys()]) stopTracking(pointerId)
282
+ element.removeEventListener('pointerdown', handlePointerDown)
283
+ if (style) {
284
+ for (const [property] of MANAGED_STYLES) {
285
+ const previous = previousStyles.get(property)
286
+ if (previous) {
287
+ style.setProperty(property, previous)
288
+ } else {
289
+ style.removeProperty(property)
290
+ }
291
+ }
292
+ }
293
+ },
294
+ }
295
+ }
@@ -0,0 +1,229 @@
1
+ import {
2
+ clamp,
3
+ linearScale,
4
+ normalizeValue,
5
+ stepValue,
6
+ type ValueRange,
7
+ } from '@tremolo-ui/functions'
8
+
9
+ import { toXY, type XY, type XYInput } from '../xy'
10
+
11
+ import { createDrag, type DragState } from './drag'
12
+
13
+ /**
14
+ * How the 0-1 travel of one axis maps onto a value.
15
+ *
16
+ * Extends `ValueRange` so that a drag and an `applyDelta` nudge from a wheel
17
+ * or an arrow key share one description of the scaling.
18
+ */
19
+ export interface AxisOptions extends ValueRange {
20
+ /**
21
+ * Flip the axis so that its far end is `min`.
22
+ *
23
+ * Positions follow the screen: x grows to the right, y downwards. A vertical
24
+ * slider whose maximum is at the top therefore reverses its y axis.
25
+ *
26
+ * @default false
27
+ */
28
+ reverse?: boolean
29
+ }
30
+
31
+ /** Where the value of each axis currently sits, as a position (see {@link DragValueMapping}). */
32
+ export interface MappingContext {
33
+ position: () => XY<number>
34
+ }
35
+
36
+ /**
37
+ * Turns pointer movement into a position on each axis: 0 is the `min` end of
38
+ * the travel and 1 the `max` end, before `reverse` and the scaling of
39
+ * {@link AxisOptions} are applied.
40
+ *
41
+ * A mapping holds the state of the drag in progress, so an instance belongs to
42
+ * a single {@link createDragValue} instance.
43
+ */
44
+ export interface DragValueMapping {
45
+ /**
46
+ * @returns the position, or null when it cannot be determined and the event
47
+ * should be ignored.
48
+ */
49
+ start: (state: DragState, context: MappingContext) => XY<number> | null
50
+ move: (state: DragState, context: MappingContext) => XY<number> | null
51
+ }
52
+
53
+ /**
54
+ * Map the pointer onto the bounding rect of an element: the value *is* the
55
+ * position pointed at, so the middle of the element is 0.5.
56
+ *
57
+ * The element is read on every event, so it may be mounted after the drag is
58
+ * set up and may change size while a drag is in progress.
59
+ */
60
+ export function elementMapping(
61
+ getElement: () => Element | null | undefined,
62
+ ): DragValueMapping {
63
+ function positionIn(state: DragState): XY<number> | null {
64
+ const element = getElement()
65
+ if (!element) return null
66
+ const { left, top, right, bottom } = element.getBoundingClientRect()
67
+ // A collapsed element has no travel to normalize against, and
68
+ // normalizeValue rejects an empty range.
69
+ return [
70
+ right > left ? normalizeValue(state.clientX, left, right) : 0,
71
+ bottom > top ? normalizeValue(state.clientY, top, bottom) : 0,
72
+ ]
73
+ }
74
+
75
+ return { start: positionIn, move: positionIn }
76
+ }
77
+
78
+ /**
79
+ * Move the value away from where it stood when the drag started, by the
80
+ * distance dragged. The pointer position itself carries no meaning, so the
81
+ * value can be adjusted from anywhere on the screen.
82
+ */
83
+ export function relativeMapping({
84
+ pixelRange = 100,
85
+ }: {
86
+ /**
87
+ * Pixels of movement that span the whole range.
88
+ * @default 100
89
+ */
90
+ pixelRange?: XYInput<number>
91
+ } = {}): DragValueMapping {
92
+ const [rangeX, rangeY] = toXY(pixelRange)
93
+ let origin: XY<number> = [0, 0]
94
+
95
+ return {
96
+ start: (_state, context) => {
97
+ origin = context.position()
98
+ return origin
99
+ },
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
+ ],
106
+ }
107
+ }
108
+
109
+ export interface DragValueOptions {
110
+ /** Scaling of each axis; a single value applies to both. */
111
+ axis: XYInput<AxisOptions>
112
+
113
+ /** How pointer movement becomes a position. */
114
+ mapping: DragValueMapping
115
+
116
+ /**
117
+ * The current value of each axis. Read when a drag starts, by mappings that
118
+ * move the value relative to it, such as {@link relativeMapping}.
119
+ */
120
+ getValue?: () => XY<number>
121
+
122
+ /**
123
+ * Report the value on pointer down, before any movement.
124
+ *
125
+ * Enable it where the pointer position *is* the value, so that a plain click
126
+ * jumps to it. Leave it off where the element being dragged is an object in
127
+ * its own right, so that grabbing its edge does not shift it under the
128
+ * pointer.
129
+ *
130
+ * @default false
131
+ */
132
+ updateOnPointerDown?: boolean
133
+
134
+ /** @see DragOptions.threshold */
135
+ threshold?: number
136
+ /** @see DragOptions.cursor */
137
+ cursor?: string
138
+
139
+ onChange?: (value: XY<number>, state: DragState) => void
140
+ onDragStart?: (value: XY<number>, state: DragState) => void
141
+ onDragEnd?: (value: XY<number>, state: DragState) => void
142
+ }
143
+
144
+ export interface DragValueInstance {
145
+ /**
146
+ * Replace the given options. Lets a wrapper feed fresh values in without
147
+ * tearing down the listeners, which would abort a drag in progress.
148
+ *
149
+ * `mapping` is fixed for the lifetime of the instance and is ignored here.
150
+ */
151
+ update: (options: Partial<DragValueOptions>) => void
152
+ destroy: () => void
153
+ }
154
+
155
+ /**
156
+ * Drive a value with a pointer drag.
157
+ *
158
+ * Combines {@link createDrag} with the scaling of `@tremolo-ui/functions`: the
159
+ * mapping decides where the pointer sits on the 0-1 travel of each axis, and
160
+ * the axis options turn that into a value.
161
+ */
162
+ export function createDragValue(
163
+ element: Element,
164
+ options: DragValueOptions,
165
+ ): DragValueInstance {
166
+ let opts = options
167
+ let lastValue: XY<number> = [0, 0]
168
+
169
+ const axes = () => toXY(opts.axis)
170
+
171
+ function valueOf(position: XY<number>): XY<number> {
172
+ return axes().map((axis, i) => {
173
+ const p = axis.reverse ? 1 - position[i] : position[i]
174
+ // A scale clamps the position, so a mapping may report outside 0-1.
175
+ const scale = axis.scale ?? linearScale
176
+ const value = scale.denormalize(p, axis.min, axis.max)
177
+ 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)
180
+ }) as XY<number>
181
+ }
182
+
183
+ const context: MappingContext = {
184
+ position: () => {
185
+ const getValue = opts.getValue
186
+ if (!getValue) {
187
+ throw new Error(
188
+ 'createDragValue: getValue is required by the given mapping',
189
+ )
190
+ }
191
+ const value = getValue()
192
+ return axes().map((axis, i) => {
193
+ const scale = axis.scale ?? linearScale
194
+ const n = scale.normalize(value[i], axis.min, axis.max)
195
+ return axis.reverse ? 1 - n : n
196
+ }) as XY<number>
197
+ },
198
+ }
199
+
200
+ const drag = createDrag(element, {
201
+ threshold: opts.threshold,
202
+ cursor: opts.cursor,
203
+ onDragStart: (state) => {
204
+ const position = opts.mapping.start(state, context)
205
+ if (position) {
206
+ lastValue = valueOf(position)
207
+ if (opts.updateOnPointerDown) opts.onChange?.(lastValue, state)
208
+ }
209
+ opts.onDragStart?.(lastValue, state)
210
+ },
211
+ onDrag: (state) => {
212
+ const position = opts.mapping.move(state, context)
213
+ if (!position) return
214
+ lastValue = valueOf(position)
215
+ opts.onChange?.(lastValue, state)
216
+ },
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),
220
+ })
221
+
222
+ return {
223
+ update: (next) => {
224
+ opts = { ...opts, ...next, mapping: opts.mapping }
225
+ drag.update({ threshold: opts.threshold, cursor: opts.cursor })
226
+ },
227
+ destroy: () => drag.destroy(),
228
+ }
229
+ }