@tremolo-ui/dom 0.4.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
+ }
@@ -8,16 +8,23 @@ export type DragState = {
8
8
  /** Pointer position in viewport coordinates. */
9
9
  clientX: number
10
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
11
16
  event: PointerEvent
12
17
  }
13
18
 
14
19
  export type DragOptions = {
15
20
  /**
16
- * Minimum movement in pixels before `onDrag` fires.
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
+ *
17
25
  * Prevents `onDrag` from firing on, for example, a double click.
18
- * Values below 1 are clamped to 1.
19
26
  *
20
- * @default 1
27
+ * @default 0
21
28
  */
22
29
  threshold?: number
23
30
 
@@ -25,15 +32,38 @@ export type DragOptions = {
25
32
  * CSS cursor to show while dragging. Applied to the element itself: pointer
26
33
  * capture keeps it in effect even once the pointer leaves the element, so
27
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.
28
38
  */
29
39
  cursor?: string
30
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
+
31
53
  onDragStart?: (state: DragState) => void
32
54
  onDrag?: (state: DragState) => void
33
55
  onDragEnd?: (state: DragState) => void
34
56
  }
35
57
 
36
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
37
67
  destroy: () => void
38
68
  }
39
69
 
@@ -57,6 +87,16 @@ type CaptureTarget = {
57
87
  hasPointerCapture?: (pointerId: number) => boolean
58
88
  }
59
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
+
60
100
  /**
61
101
  * Track a pointer drag on an element.
62
102
  *
@@ -65,18 +105,15 @@ type CaptureTarget = {
65
105
  * element gets `touch-action: none` so that touch dragging does not scroll the
66
106
  * page, plus `user-select: none` so that a long press does not start a text
67
107
  * selection instead.
108
+ *
109
+ * One pointer at a time by default; see {@link DragOptions.multiPointer}.
68
110
  */
69
111
  export function createDrag(
70
112
  element: Element,
71
- {
72
- threshold: _threshold = 1,
73
- cursor,
74
- onDragStart,
75
- onDrag,
76
- onDragEnd,
77
- }: DragOptions = {},
113
+ options: DragOptions = {},
78
114
  ): DragInstance {
79
- const threshold = Math.max(_threshold, 1)
115
+ let opts = options
116
+ const multiPointer = options.multiPointer ?? false
80
117
  const capture = element as CaptureTarget
81
118
  const style = (element as Partial<HTMLElement>).style
82
119
 
@@ -88,27 +125,29 @@ export function createDrag(
88
125
  }
89
126
  }
90
127
 
91
- let pointerId: number | null = null
92
- /** Where the listeners for the current drag live. */
93
- let moveTarget: EventTarget | null = null
94
- let startX = 0
95
- let startY = 0
96
- let lastX = 0
97
- let lastY = 0
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>()
98
135
  let previousCursor: string | undefined
99
136
 
100
137
  function state(
101
138
  event: PointerEvent,
139
+ pointer: PointerState,
102
140
  deltaX: number,
103
141
  deltaY: number,
104
142
  ): DragState {
105
143
  return {
106
- x: event.screenX - startX,
107
- y: event.screenY - startY,
144
+ x: event.screenX - pointer.startX,
145
+ y: event.screenY - pointer.startY,
108
146
  deltaX,
109
147
  deltaY,
110
148
  clientX: event.clientX,
111
149
  clientY: event.clientY,
150
+ pointerId: event.pointerId,
112
151
  event,
113
152
  }
114
153
  }
@@ -122,83 +161,124 @@ export function createDrag(
122
161
  event.preventDefault()
123
162
  }
124
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
+
125
186
  function handlePointerDown(event: Event) {
126
187
  const pointerEvent = event as PointerEvent
127
- // Only one pointer drives the drag; ignore additional touches.
128
- if (pointerId !== null) return
129
-
130
- pointerId = pointerEvent.pointerId
131
- startX = lastX = pointerEvent.screenX
132
- startY = lastY = pointerEvent.screenY
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
133
192
 
134
- if (cursor && style) {
193
+ const isFirst = pointers.size === 0
194
+ if (isFirst && opts.cursor && style) {
135
195
  previousCursor = style.cursor
136
- style.cursor = cursor
196
+ style.cursor = opts.cursor
137
197
  }
138
198
 
139
199
  capture.setPointerCapture?.(pointerId)
140
200
  // With pointer capture the element receives the rest of the gesture.
141
201
  // Without it (older engines, jsdom) fall back to the window.
142
- moveTarget =
202
+ const moveTarget =
143
203
  capture.hasPointerCapture?.(pointerId) === true
144
204
  ? element
145
205
  : (globalThis.window ?? element)
146
206
 
147
- moveTarget.addEventListener('pointermove', handlePointerMove)
148
- moveTarget.addEventListener('pointerup', handlePointerUp)
149
- moveTarget.addEventListener('pointercancel', handlePointerUp)
150
- globalThis.document?.addEventListener('selectstart', preventSelectStart)
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
+ }
151
220
 
152
- onDragStart?.(state(pointerEvent, 0, 0))
221
+ opts.onDragStart?.(state(pointerEvent, pointer, 0, 0))
153
222
  }
154
223
 
155
224
  function handlePointerMove(event: Event) {
156
225
  const pointerEvent = event as PointerEvent
157
- if (pointerEvent.pointerId !== pointerId) return
226
+ const pointer = pointers.get(pointerEvent.pointerId)
227
+ if (!pointer) return
158
228
 
159
- const deltaX = pointerEvent.screenX - lastX
160
- const deltaY = pointerEvent.screenY - lastY
161
- lastX = pointerEvent.screenX
162
- lastY = pointerEvent.screenY
229
+ const deltaX = pointerEvent.screenX - pointer.lastX
230
+ const deltaY = pointerEvent.screenY - pointer.lastY
163
231
 
164
- // Movement below the threshold is dropped rather than accumulated.
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
165
236
  if (Math.abs(deltaX) < threshold && Math.abs(deltaY) < threshold) return
166
237
 
167
- onDrag?.(state(pointerEvent, deltaX, deltaY))
238
+ pointer.lastX = pointerEvent.screenX
239
+ pointer.lastY = pointerEvent.screenY
240
+
241
+ opts.onDrag?.(state(pointerEvent, pointer, deltaX, deltaY))
168
242
  }
169
243
 
170
244
  function handlePointerUp(event: Event) {
171
245
  const pointerEvent = event as PointerEvent
172
- if (pointerEvent.pointerId !== pointerId) return
246
+ const pointer = pointers.get(pointerEvent.pointerId)
247
+ if (!pointer) return
173
248
 
174
- const deltaX = pointerEvent.screenX - lastX
175
- const deltaY = pointerEvent.screenY - lastY
176
- const finalState = state(pointerEvent, deltaX, deltaY)
249
+ const deltaX = pointerEvent.screenX - pointer.lastX
250
+ const deltaY = pointerEvent.screenY - pointer.lastY
251
+ const finalState = state(pointerEvent, pointer, deltaX, deltaY)
177
252
 
178
- stopTracking()
179
- onDragEnd?.(finalState)
253
+ stopTracking(pointerEvent.pointerId)
254
+ opts.onDragEnd?.(finalState)
180
255
  }
181
256
 
182
- function stopTracking() {
183
- if (pointerId === null) return
257
+ function stopTracking(pointerId: number) {
258
+ const pointer = pointers.get(pointerId)
259
+ if (!pointer) return
260
+
184
261
  capture.releasePointerCapture?.(pointerId)
185
- moveTarget?.removeEventListener('pointermove', handlePointerMove)
186
- moveTarget?.removeEventListener('pointerup', handlePointerUp)
187
- moveTarget?.removeEventListener('pointercancel', handlePointerUp)
262
+ releaseTarget(pointer.moveTarget)
263
+ pointers.delete(pointerId)
264
+
265
+ if (pointers.size > 0) return
266
+
188
267
  globalThis.document?.removeEventListener('selectstart', preventSelectStart)
189
- if (cursor && style) {
190
- style.cursor = previousCursor ?? ''
268
+ if (previousCursor !== undefined && style) {
269
+ style.cursor = previousCursor
191
270
  previousCursor = undefined
192
271
  }
193
- moveTarget = null
194
- pointerId = null
195
272
  }
196
273
 
197
274
  element.addEventListener('pointerdown', handlePointerDown)
198
275
 
199
276
  return {
277
+ update: (next) => {
278
+ opts = { ...opts, ...next, multiPointer }
279
+ },
200
280
  destroy: () => {
201
- stopTracking()
281
+ for (const pointerId of [...pointers.keys()]) stopTracking(pointerId)
202
282
  element.removeEventListener('pointerdown', handlePointerDown)
203
283
  if (style) {
204
284
  for (const [property] of MANAGED_STYLES) {