@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.
- package/dist/index.cjs +623 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +416 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +416 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +614 -1
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
- package/src/canvas/animation.ts +304 -0
- package/src/canvas/context.ts +81 -0
- package/src/index.ts +43 -0
- package/src/piano/input.ts +185 -0
- package/src/pointer/drag.ts +295 -0
- package/src/pointer/dragValue.ts +229 -0
- package/src/pointer/wheel.ts +58 -0
- package/src/xy.ts +38 -0
|
@@ -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
|
+
}
|