@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.
@@ -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
+ }