@tremolo-ui/dom 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +934 -89
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +580 -31
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +580 -31
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +923 -90
- package/dist/index.js.map +1 -1
- package/package.json +6 -2
- package/src/canvas/animation.ts +322 -0
- package/src/canvas/context.ts +108 -0
- package/src/index.ts +48 -1
- package/src/midi/access.ts +83 -13
- package/src/midi/input.ts +97 -26
- package/src/midi/message.ts +37 -8
- package/src/piano/index.ts +205 -0
- package/src/pointer/drag-value.ts +365 -0
- package/src/pointer/drag.ts +340 -65
- package/src/pointer/wheel.ts +36 -1
- package/src/selection/box.ts +136 -0
- package/src/xy.ts +38 -0
|
@@ -0,0 +1,205 @@
|
|
|
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 (!Number.isInteger(note) || note < 0 || note > 127) {
|
|
103
|
+
throw new RangeError('note: requirements: an integer from 0 to 127')
|
|
104
|
+
}
|
|
105
|
+
if (note > (opts.midiMax ?? 127)) return
|
|
106
|
+
|
|
107
|
+
const sources = held.get(note)
|
|
108
|
+
if (sources) {
|
|
109
|
+
// Already sounding: remember the extra holder and leave it alone.
|
|
110
|
+
sources.add(source)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
held.set(note, new Set([source]))
|
|
115
|
+
opts.onPlayNote?.(note, velocity)
|
|
116
|
+
opts.onActiveNotesChange?.(activeNotes())
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function noteOff(
|
|
120
|
+
note: number,
|
|
121
|
+
{ source = DEFAULT_SOURCE }: { source?: NoteSource } = {},
|
|
122
|
+
) {
|
|
123
|
+
if (!Number.isInteger(note) || note < 0 || note > 127) {
|
|
124
|
+
throw new RangeError('note: requirements: an integer from 0 to 127')
|
|
125
|
+
}
|
|
126
|
+
const sources = held.get(note)
|
|
127
|
+
if (!sources) return
|
|
128
|
+
|
|
129
|
+
sources.delete(source)
|
|
130
|
+
if (sources.size > 0) return
|
|
131
|
+
|
|
132
|
+
held.delete(note)
|
|
133
|
+
opts.onStopNote?.(note)
|
|
134
|
+
opts.onActiveNotesChange?.(activeNotes())
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The note under a pointer, or null where the pointer is off the keys. */
|
|
138
|
+
function noteUnder(clientX: number, clientY: number): number | null {
|
|
139
|
+
const { left, top, height } = element.getBoundingClientRect()
|
|
140
|
+
return noteAt(clientX - left, clientY - top, height, opts.layout)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Move a pointer onto a note, releasing whatever it held before. */
|
|
144
|
+
function movePointer(pointerId: number, note: number | null) {
|
|
145
|
+
const source = `pointer:${pointerId}`
|
|
146
|
+
const previous = pointerNotes.get(pointerId)
|
|
147
|
+
if (previous === note) return
|
|
148
|
+
|
|
149
|
+
if (previous !== undefined) noteOff(previous, { source })
|
|
150
|
+
|
|
151
|
+
if (note === null || note > (opts.midiMax ?? 127)) {
|
|
152
|
+
pointerNotes.delete(pointerId)
|
|
153
|
+
} else {
|
|
154
|
+
pointerNotes.set(pointerId, note)
|
|
155
|
+
noteOn(note, { source })
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const drag = createDrag(element, {
|
|
160
|
+
multiPointer: true,
|
|
161
|
+
onDragStart: (state) =>
|
|
162
|
+
movePointer(state.pointerId, noteUnder(state.clientX, state.clientY)),
|
|
163
|
+
onDrag: (state) => {
|
|
164
|
+
// Without glissando the pressed key holds until the pointer is released,
|
|
165
|
+
// so where the pointer wanders to does not matter.
|
|
166
|
+
if (opts.glissando === false) return
|
|
167
|
+
movePointer(state.pointerId, noteUnder(state.clientX, state.clientY))
|
|
168
|
+
},
|
|
169
|
+
onDragEnd: (state) => movePointer(state.pointerId, null),
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
update: (next) => {
|
|
174
|
+
opts = { ...opts, ...next }
|
|
175
|
+
|
|
176
|
+
const midiMax = opts.midiMax ?? 127
|
|
177
|
+
const stoppedNotes = activeNotes().filter((note) => note > midiMax)
|
|
178
|
+
if (stoppedNotes.length === 0) return
|
|
179
|
+
|
|
180
|
+
for (const note of stoppedNotes) {
|
|
181
|
+
held.delete(note)
|
|
182
|
+
opts.onStopNote?.(note)
|
|
183
|
+
}
|
|
184
|
+
for (const [pointerId, note] of pointerNotes) {
|
|
185
|
+
if (note > midiMax) pointerNotes.delete(pointerId)
|
|
186
|
+
}
|
|
187
|
+
opts.onActiveNotesChange?.(activeNotes())
|
|
188
|
+
},
|
|
189
|
+
noteOn,
|
|
190
|
+
noteOff,
|
|
191
|
+
activeNotes,
|
|
192
|
+
destroy: () => {
|
|
193
|
+
drag.destroy()
|
|
194
|
+
// Anything still held is released, so a caller that mirrors these
|
|
195
|
+
// callbacks into a synth is not left with a stuck note.
|
|
196
|
+
const notes = activeNotes()
|
|
197
|
+
for (const note of notes) {
|
|
198
|
+
held.delete(note)
|
|
199
|
+
opts.onStopNote?.(note)
|
|
200
|
+
}
|
|
201
|
+
pointerNotes.clear()
|
|
202
|
+
if (notes.length > 0) opts.onActiveNotesChange?.([])
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clamp,
|
|
3
|
+
linearScale,
|
|
4
|
+
normalizeValue,
|
|
5
|
+
stepValue,
|
|
6
|
+
toPrecision,
|
|
7
|
+
type ValueRange,
|
|
8
|
+
} from '@tremolo-ui/functions'
|
|
9
|
+
|
|
10
|
+
import { toXY, type XY, type XYInput } from '../xy'
|
|
11
|
+
|
|
12
|
+
import { createDrag, type DragState } from './drag'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* How the 0-1 travel of one axis maps onto a value.
|
|
16
|
+
*
|
|
17
|
+
* Extends `ValueRange` so that a drag and an `applyDelta` nudge from a wheel
|
|
18
|
+
* or an arrow key share one description of the scaling.
|
|
19
|
+
*/
|
|
20
|
+
export interface AxisOptions extends ValueRange {
|
|
21
|
+
/**
|
|
22
|
+
* Flip the axis so that its far end is `min`.
|
|
23
|
+
*
|
|
24
|
+
* Positions follow the screen: x grows to the right, y downwards. A vertical
|
|
25
|
+
* slider whose maximum is at the top therefore reverses its y axis.
|
|
26
|
+
*
|
|
27
|
+
* @default false
|
|
28
|
+
*/
|
|
29
|
+
reverse?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Where the value of each axis currently sits, as a position (see {@link DragValueMapping}). */
|
|
33
|
+
export interface MappingContext {
|
|
34
|
+
position: () => XY<number>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Turns pointer movement into a position on each axis: 0 is the `min` end of
|
|
39
|
+
* the travel and 1 the `max` end, before `reverse` and the scaling of
|
|
40
|
+
* {@link AxisOptions} are applied.
|
|
41
|
+
*
|
|
42
|
+
* A mapping holds the state of the drag in progress, so an instance belongs to
|
|
43
|
+
* a single {@link createDragValue} instance.
|
|
44
|
+
*/
|
|
45
|
+
export interface DragValueMapping {
|
|
46
|
+
/**
|
|
47
|
+
* @returns the position, or null when it cannot be determined and the event
|
|
48
|
+
* should be ignored.
|
|
49
|
+
*/
|
|
50
|
+
start: (state: DragState, context: MappingContext) => XY<number> | null
|
|
51
|
+
move: (state: DragState, context: MappingContext) => XY<number> | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Map the pointer onto the bounding rect of an element: the value *is* the
|
|
56
|
+
* position pointed at, so the middle of the element is 0.5.
|
|
57
|
+
*
|
|
58
|
+
* The element is read on every event, so it may be mounted after the drag is
|
|
59
|
+
* set up and may change size while a drag is in progress.
|
|
60
|
+
*/
|
|
61
|
+
export function elementMapping(
|
|
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
|
+
} = {},
|
|
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
|
+
|
|
89
|
+
function positionIn(state: DragState): XY<number> | null {
|
|
90
|
+
const element = getElement()
|
|
91
|
+
if (!element) return null
|
|
92
|
+
const { left, top, right, bottom } = element.getBoundingClientRect()
|
|
93
|
+
// A collapsed element has no travel to normalize against, and
|
|
94
|
+
// normalizeValue rejects an empty range.
|
|
95
|
+
return [
|
|
96
|
+
right > left ? normalizeValue(state.clientX, left, right) : 0,
|
|
97
|
+
bottom > top ? normalizeValue(state.clientY, top, bottom) : 0,
|
|
98
|
+
]
|
|
99
|
+
}
|
|
100
|
+
|
|
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
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Move the value away from where it stood when the drag started, by the
|
|
147
|
+
* distance dragged. The pointer position itself carries no meaning, so the
|
|
148
|
+
* value can be adjusted from anywhere on the screen.
|
|
149
|
+
*/
|
|
150
|
+
export function relativeMapping({
|
|
151
|
+
pixelRange = 100,
|
|
152
|
+
sensitivity,
|
|
153
|
+
}: {
|
|
154
|
+
/**
|
|
155
|
+
* Pixels of movement that span the whole range.
|
|
156
|
+
* @default 100
|
|
157
|
+
*/
|
|
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
|
|
168
|
+
} = {}): DragValueMapping {
|
|
169
|
+
const [baseX, baseY] = toXY(pixelRange)
|
|
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
|
+
]
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
start: (state, context) => {
|
|
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
|
|
190
|
+
return origin
|
|
191
|
+
},
|
|
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
|
+
},
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface DragValueOptions {
|
|
215
|
+
/** Scaling of each axis; a single value applies to both. */
|
|
216
|
+
axis: XYInput<AxisOptions>
|
|
217
|
+
|
|
218
|
+
/** How pointer movement becomes a position. */
|
|
219
|
+
mapping: DragValueMapping
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The current value of each axis. Read when a drag starts, by mappings that
|
|
223
|
+
* move the value relative to it, such as {@link relativeMapping}.
|
|
224
|
+
*/
|
|
225
|
+
getValue?: () => XY<number>
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Report the value on pointer down, before any movement.
|
|
229
|
+
*
|
|
230
|
+
* Enable it where the pointer position *is* the value, so that a plain click
|
|
231
|
+
* jumps to it. Leave it off where the element being dragged is an object in
|
|
232
|
+
* its own right, so that grabbing its edge does not shift it under the
|
|
233
|
+
* pointer.
|
|
234
|
+
*
|
|
235
|
+
* @default false
|
|
236
|
+
*/
|
|
237
|
+
updateOnPointerDown?: boolean
|
|
238
|
+
|
|
239
|
+
/** @see DragOptions.threshold */
|
|
240
|
+
threshold?: number
|
|
241
|
+
/** @see DragOptions.cursor */
|
|
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
|
|
253
|
+
|
|
254
|
+
onChange?: (value: XY<number>, state: DragState) => void
|
|
255
|
+
onDragStart?: (value: XY<number>, state: DragState) => void
|
|
256
|
+
onDragEnd?: (value: XY<number>, state: DragState) => void
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export interface DragValueInstance {
|
|
260
|
+
/**
|
|
261
|
+
* Replace the given options. Lets a wrapper feed fresh values in without
|
|
262
|
+
* tearing down the listeners, which would abort a drag in progress.
|
|
263
|
+
*
|
|
264
|
+
* `mapping` is fixed for the lifetime of the instance and is ignored here.
|
|
265
|
+
*/
|
|
266
|
+
update: (options: Partial<DragValueOptions>) => void
|
|
267
|
+
destroy: () => void
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Drive a value with a pointer drag.
|
|
272
|
+
*
|
|
273
|
+
* Combines {@link createDrag} with the scaling of `@tremolo-ui/functions`: the
|
|
274
|
+
* mapping decides where the pointer sits on the 0-1 travel of each axis, and
|
|
275
|
+
* the axis options turn that into a value.
|
|
276
|
+
*/
|
|
277
|
+
export function createDragValue(
|
|
278
|
+
element: Element,
|
|
279
|
+
options: DragValueOptions,
|
|
280
|
+
): DragValueInstance {
|
|
281
|
+
let opts = options
|
|
282
|
+
let lastValue: XY<number> = [0, 0]
|
|
283
|
+
let active = false
|
|
284
|
+
|
|
285
|
+
const axes = () => toXY(opts.axis)
|
|
286
|
+
|
|
287
|
+
function valueOf(position: XY<number>): XY<number> {
|
|
288
|
+
return axes().map((axis, i) => {
|
|
289
|
+
const p = axis.reverse ? 1 - position[i] : position[i]
|
|
290
|
+
// A scale clamps the position, so a mapping may report outside 0-1.
|
|
291
|
+
const scale = axis.scale ?? linearScale
|
|
292
|
+
const value = scale.denormalize(p, axis.min, axis.max)
|
|
293
|
+
const stepped = axis.step ? stepValue(value, axis.step) : value
|
|
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)
|
|
297
|
+
}) as XY<number>
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const context: MappingContext = {
|
|
301
|
+
position: () => {
|
|
302
|
+
const getValue = opts.getValue
|
|
303
|
+
if (!getValue) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
'createDragValue: getValue is required by the given mapping',
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
const value = getValue()
|
|
309
|
+
return axes().map((axis, i) => {
|
|
310
|
+
const scale = axis.scale ?? linearScale
|
|
311
|
+
const n = scale.normalize(value[i], axis.min, axis.max)
|
|
312
|
+
return axis.reverse ? 1 - n : n
|
|
313
|
+
}) as XY<number>
|
|
314
|
+
},
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const drag = createDrag(element, {
|
|
318
|
+
threshold: opts.threshold,
|
|
319
|
+
cursor: opts.cursor,
|
|
320
|
+
pointerLock: opts.pointerLock,
|
|
321
|
+
shouldStart: (event) => opts.shouldStart?.(event) ?? true,
|
|
322
|
+
onDragStart: (state) => {
|
|
323
|
+
const position = opts.mapping.start(state, context)
|
|
324
|
+
if (!position) return
|
|
325
|
+
active = true
|
|
326
|
+
lastValue = valueOf(position)
|
|
327
|
+
if (opts.updateOnPointerDown) opts.onChange?.(lastValue, state)
|
|
328
|
+
opts.onDragStart?.(lastValue, state)
|
|
329
|
+
},
|
|
330
|
+
onDrag: (state) => {
|
|
331
|
+
if (!active) return
|
|
332
|
+
const position = opts.mapping.move(state, context)
|
|
333
|
+
if (!position) return
|
|
334
|
+
lastValue = valueOf(position)
|
|
335
|
+
opts.onChange?.(lastValue, state)
|
|
336
|
+
},
|
|
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
|
+
},
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
update: (next) => {
|
|
356
|
+
opts = { ...opts, ...next, mapping: opts.mapping }
|
|
357
|
+
drag.update({
|
|
358
|
+
threshold: opts.threshold,
|
|
359
|
+
cursor: opts.cursor,
|
|
360
|
+
pointerLock: opts.pointerLock,
|
|
361
|
+
})
|
|
362
|
+
},
|
|
363
|
+
destroy: () => drag.destroy(),
|
|
364
|
+
}
|
|
365
|
+
}
|