@tremolo-ui/dom 0.5.0 → 0.7.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,183 @@
1
+ import {
2
+ isBlackKey,
3
+ isWhiteKey,
4
+ noteKey,
5
+ noteKeys,
6
+ type NoteKey,
7
+ } from '@tremolo-ui/functions'
8
+
9
+ export type NoteRange = {
10
+ first: number
11
+ last: number
12
+ }
13
+
14
+ /**
15
+ * `[noteRange.first, noteRange.first + 1, ..., noteRange.last]`
16
+ */
17
+ export function getNoteRangeArray(noteRange: NoteRange): number[] {
18
+ return Array.from(
19
+ { length: noteRange.last - noteRange.first + 1 },
20
+ (_, i) => i + noteRange.first,
21
+ )
22
+ }
23
+
24
+ /**
25
+ * The geometry of a drawn keyboard.
26
+ *
27
+ * One description is shared by the drawing and the hit testing, so a key cannot
28
+ * be drawn somewhere other than where it responds.
29
+ */
30
+ export interface PianoLayout {
31
+ noteRange: NoteRange
32
+
33
+ /** Width of a white key, excluding {@link PianoLayout.keyGap}. */
34
+ whiteKeyWidth: number
35
+
36
+ /**
37
+ * Space between two white keys. Part of the slot a white key occupies, so it
38
+ * still belongs to one of the keys for the purpose of hit testing.
39
+ *
40
+ * @default 1
41
+ */
42
+ keyGap?: number
43
+
44
+ /**
45
+ * Width of a black key, as a fraction of {@link PianoLayout.whiteKeyWidth}.
46
+ *
47
+ * @default 0.65
48
+ */
49
+ blackKeyWidthRatio?: number
50
+
51
+ /**
52
+ * Height of a black key, as a fraction of the height of the keyboard.
53
+ *
54
+ * @default 0.6
55
+ */
56
+ blackKeyHeightRatio?: number
57
+ }
58
+
59
+ const DEFAULT_KEY_GAP = 1
60
+ const DEFAULT_BLACK_KEY_WIDTH_RATIO = 0.65
61
+ const DEFAULT_BLACK_KEY_HEIGHT_RATIO = 0.6
62
+
63
+ /**
64
+ * How many white keys sit at or before each pitch class, counting from C.
65
+ *
66
+ * A black key shares the number of the white key to its left plus one, which
67
+ * puts it on the boundary between the two; {@link notePosition} then shifts it
68
+ * back by half its width to centre it there.
69
+ */
70
+ const whiteKeysBefore: Record<NoteKey, number> = {
71
+ C: 0,
72
+ 'C#': 1,
73
+ D: 1,
74
+ 'D#': 2,
75
+ E: 2,
76
+ F: 3,
77
+ 'F#': 4,
78
+ G: 4,
79
+ 'G#': 5,
80
+ A: 5,
81
+ 'A#': 6,
82
+ B: 6,
83
+ }
84
+
85
+ /** Width of a black key in pixels. */
86
+ export function blackKeyWidth(layout: PianoLayout): number {
87
+ return (
88
+ layout.whiteKeyWidth *
89
+ (layout.blackKeyWidthRatio ?? DEFAULT_BLACK_KEY_WIDTH_RATIO)
90
+ )
91
+ }
92
+
93
+ function rawNotePosition(note: number, layout: PianoLayout): number {
94
+ const slot = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP)
95
+ const target = noteKey(note)
96
+ const first = noteKey(layout.noteRange.first)
97
+
98
+ const octave = Math.floor((note - layout.noteRange.first) / 12)
99
+ const octaveOffset =
100
+ noteKeys.indexOf(first) > noteKeys.indexOf(target) ? 1 : 0
101
+ const whiteKeysIn =
102
+ whiteKeysBefore[target] -
103
+ whiteKeysBefore[first] +
104
+ (octave + octaveOffset) * 7
105
+
106
+ return isBlackKey(note)
107
+ ? whiteKeysIn * slot - blackKeyWidth(layout) / 2
108
+ : whiteKeysIn * slot
109
+ }
110
+
111
+ function pianoBounds(layout: PianoLayout) {
112
+ const notes = getNoteRangeArray(layout.noteRange)
113
+ if (notes.length === 0) return { left: 0, right: 0 }
114
+
115
+ let left = Infinity
116
+ let right = -Infinity
117
+ const whiteWidth = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP)
118
+ for (const note of notes) {
119
+ const noteLeft = rawNotePosition(note, layout)
120
+ const width = isBlackKey(note) ? blackKeyWidth(layout) : whiteWidth
121
+ left = Math.min(left, noteLeft)
122
+ right = Math.max(right, noteLeft + width)
123
+ }
124
+ return { left, right }
125
+ }
126
+
127
+ /** Width of the whole keyboard in pixels. */
128
+ export function pianoWidth(layout: PianoLayout): number {
129
+ const { left, right } = pianoBounds(layout)
130
+ return right - left
131
+ }
132
+
133
+ /**
134
+ * Offset of the left edge of a key from the left edge of the keyboard, in
135
+ * pixels.
136
+ *
137
+ * Notes outside `noteRange` are placed too, so the value is negative below
138
+ * `noteRange.first`.
139
+ */
140
+ export function notePosition(note: number, layout: PianoLayout): number {
141
+ return rawNotePosition(note, layout) - pianoBounds(layout).left
142
+ }
143
+
144
+ /**
145
+ * The note drawn at a point, or null where there is none.
146
+ *
147
+ * Black keys are tested first, so they win where they overlap a white one. A
148
+ * white key covers its gap as well as its width, so the whole width of the
149
+ * keyboard belongs to some key and a click cannot fall between two.
150
+ *
151
+ * @param x offset from the left edge of the keyboard, in pixels
152
+ * @param y offset from its top edge, in pixels
153
+ * @param height height of the keyboard, in pixels
154
+ */
155
+ export function noteAt(
156
+ x: number,
157
+ y: number,
158
+ height: number,
159
+ layout: PianoLayout,
160
+ ): number | null {
161
+ if (x < 0 || x >= pianoWidth(layout) || y < 0 || y >= height) return null
162
+
163
+ const notes = getNoteRangeArray(layout.noteRange)
164
+ const blackHeight =
165
+ height * (layout.blackKeyHeightRatio ?? DEFAULT_BLACK_KEY_HEIGHT_RATIO)
166
+
167
+ if (y < blackHeight) {
168
+ for (const note of notes) {
169
+ if (isWhiteKey(note)) continue
170
+ const left = notePosition(note, layout)
171
+ if (left <= x && x < left + blackKeyWidth(layout)) return note
172
+ }
173
+ }
174
+
175
+ const slot = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP)
176
+ for (const note of notes) {
177
+ if (isBlackKey(note)) continue
178
+ const left = notePosition(note, layout)
179
+ if (left <= x && x < left + slot) return note
180
+ }
181
+
182
+ return null
183
+ }
@@ -3,6 +3,7 @@ import {
3
3
  linearScale,
4
4
  normalizeValue,
5
5
  stepValue,
6
+ toPrecision,
6
7
  type ValueRange,
7
8
  } from '@tremolo-ui/functions'
8
9
 
@@ -59,7 +60,32 @@ export interface DragValueMapping {
59
60
  */
60
61
  export function elementMapping(
61
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
+ } = {},
62
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
+
63
89
  function positionIn(state: DragState): XY<number> | null {
64
90
  const element = getElement()
65
91
  if (!element) return null
@@ -72,7 +98,48 @@ export function elementMapping(
72
98
  ]
73
99
  }
74
100
 
75
- return { start: positionIn, move: positionIn }
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
+ }
76
143
  }
77
144
 
78
145
  /**
@@ -82,27 +149,65 @@ export function elementMapping(
82
149
  */
83
150
  export function relativeMapping({
84
151
  pixelRange = 100,
152
+ sensitivity,
85
153
  }: {
86
154
  /**
87
155
  * Pixels of movement that span the whole range.
88
156
  * @default 100
89
157
  */
90
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
91
168
  } = {}): DragValueMapping {
92
- const [rangeX, rangeY] = toXY(pixelRange)
169
+ const [baseX, baseY] = toXY(pixelRange)
93
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
+ ]
94
181
 
95
182
  return {
96
- start: (_state, context) => {
183
+ start: (state, context) => {
97
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
98
190
  return origin
99
191
  },
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
- ],
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
+ },
106
211
  }
107
212
  }
108
213
 
@@ -135,6 +240,16 @@ export interface DragValueOptions {
135
240
  threshold?: number
136
241
  /** @see DragOptions.cursor */
137
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
138
253
 
139
254
  onChange?: (value: XY<number>, state: DragState) => void
140
255
  onDragStart?: (value: XY<number>, state: DragState) => void
@@ -165,6 +280,7 @@ export function createDragValue(
165
280
  ): DragValueInstance {
166
281
  let opts = options
167
282
  let lastValue: XY<number> = [0, 0]
283
+ let active = false
168
284
 
169
285
  const axes = () => toXY(opts.axis)
170
286
 
@@ -175,8 +291,9 @@ export function createDragValue(
175
291
  const scale = axis.scale ?? linearScale
176
292
  const value = scale.denormalize(p, axis.min, axis.max)
177
293
  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)
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)
180
297
  }) as XY<number>
181
298
  }
182
299
 
@@ -200,29 +317,48 @@ export function createDragValue(
200
317
  const drag = createDrag(element, {
201
318
  threshold: opts.threshold,
202
319
  cursor: opts.cursor,
320
+ pointerLock: opts.pointerLock,
321
+ shouldStart: (event) => opts.shouldStart?.(event) ?? true,
203
322
  onDragStart: (state) => {
204
323
  const position = opts.mapping.start(state, context)
205
- if (position) {
206
- lastValue = valueOf(position)
207
- if (opts.updateOnPointerDown) opts.onChange?.(lastValue, state)
208
- }
324
+ if (!position) return
325
+ active = true
326
+ lastValue = valueOf(position)
327
+ if (opts.updateOnPointerDown) opts.onChange?.(lastValue, state)
209
328
  opts.onDragStart?.(lastValue, state)
210
329
  },
211
330
  onDrag: (state) => {
331
+ if (!active) return
212
332
  const position = opts.mapping.move(state, context)
213
333
  if (!position) return
214
334
  lastValue = valueOf(position)
215
335
  opts.onChange?.(lastValue, state)
216
336
  },
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),
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
+ },
220
352
  })
221
353
 
222
354
  return {
223
355
  update: (next) => {
224
356
  opts = { ...opts, ...next, mapping: opts.mapping }
225
- drag.update({ threshold: opts.threshold, cursor: opts.cursor })
357
+ drag.update({
358
+ threshold: opts.threshold,
359
+ cursor: opts.cursor,
360
+ pointerLock: opts.pointerLock,
361
+ })
226
362
  },
227
363
  destroy: () => drag.destroy(),
228
364
  }