@tremolo-ui/dom 0.6.0 → 0.8.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,22 @@
1
+ /**
2
+ * The `update()` argument that leaves an instance with exactly `next` as its
3
+ * options.
4
+ *
5
+ * `update()` merges into what the instance has, which suits a wrapper that
6
+ * pushes one setting at a time. A wrapper handed the whole new set instead —
7
+ * a Svelte action, a Vue composable — has to clear what the new set no longer
8
+ * carries, or a handler taken away, or an option left to its default, keeps
9
+ * working.
10
+ *
11
+ * @example
12
+ * instance.update(replaceOptions(previous, next))
13
+ */
14
+ export function replaceOptions<T extends object>(
15
+ previous: T | undefined,
16
+ next: T | undefined,
17
+ ): Partial<T> {
18
+ const cleared = Object.fromEntries(
19
+ Object.keys(previous ?? {}).map((key) => [key, undefined]),
20
+ ) as Partial<T>
21
+ return { ...cleared, ...next }
22
+ }
@@ -1,7 +1,12 @@
1
- import { noteAt, type PianoLayout } from '@tremolo-ui/functions'
2
-
3
1
  import { createDrag } from '../pointer/drag'
4
2
 
3
+ import { noteAt, type PianoLayout } from './layout'
4
+ import {
5
+ isEditableTarget,
6
+ type KeyboardShortcuts,
7
+ type KeyboardShortcutsScope,
8
+ } from './shortcuts'
9
+
5
10
  /**
6
11
  * What asked for a note to sound.
7
12
  *
@@ -33,6 +38,26 @@ export interface PianoInputOptions {
33
38
  */
34
39
  midiMax?: number
35
40
 
41
+ /**
42
+ * Play notes from the computer keyboard: `keys[i]` plays
43
+ * `layout.noteRange.first + i`. `SHORTCUTS` has ready-made layouts.
44
+ *
45
+ * A held key keeps its note until it is released, the focus leaves (with
46
+ * `root`), or the window loses focus. Changing the keys, the scope or the
47
+ * note range releases every note a key is holding, since the key would
48
+ * mean something else on release.
49
+ */
50
+ keyboardShortcuts?: KeyboardShortcuts
51
+
52
+ /**
53
+ * Where keyboard shortcuts listen: on the element (`root`), which has to
54
+ * be focusable, or anywhere on the page (`window`). Keys typed into an
55
+ * editable element are ignored either way.
56
+ *
57
+ * @default 'root'
58
+ */
59
+ keyboardShortcutsScope?: KeyboardShortcutsScope
60
+
36
61
  /** Called when a note starts sounding, not for each source that asks. */
37
62
  onPlayNote?: (note: number, velocity?: number) => void
38
63
  /** Called once the last source holding a note has let go. */
@@ -49,8 +74,8 @@ export interface PianoInputInstance {
49
74
  update: (options: Partial<PianoInputOptions>) => void
50
75
 
51
76
  /**
52
- * Start a note from something other than a pointer: a keyboard shortcut, a
53
- * MIDI message, an imperative call.
77
+ * Start a note from something other than a pointer or a shortcut: a MIDI
78
+ * message, an imperative call.
54
79
  */
55
80
  noteOn: (
56
81
  note: number,
@@ -87,6 +112,11 @@ export function createPianoInput(
87
112
  const held = new Map<number, Set<NoteSource>>()
88
113
  /** The note each pointer is currently on. */
89
114
  const pointerNotes = new Map<number, number>()
115
+ /**
116
+ * The note each held shortcut key is playing, by `code` — the physical key,
117
+ * so that releasing it with a different modifier or layout still matches.
118
+ */
119
+ const shortcutNotes = new Map<string, number>()
90
120
 
91
121
  function activeNotes(): number[] {
92
122
  return [...held.keys()].sort((a, b) => a - b)
@@ -156,6 +186,97 @@ export function createPianoInput(
156
186
  }
157
187
  }
158
188
 
189
+ /** The note a shortcut key plays, or null when it has none. */
190
+ function shortcutNote(key: string) {
191
+ const keys = opts.keyboardShortcuts?.keys
192
+ if (!keys || key === '') return null
193
+ const { first, last } = opts.layout.noteRange
194
+ const index = keys.indexOf(key)
195
+ const note = first + index
196
+ return index === -1 || note > last ? null : note
197
+ }
198
+
199
+ function onKeyDown(event: KeyboardEvent) {
200
+ const id = event.code || event.key
201
+ if (event.repeat || shortcutNotes.has(id)) return
202
+ if (isEditableTarget(event.target)) return
203
+ const note = shortcutNote(event.key)
204
+ if (note === null) return
205
+ shortcutNotes.set(id, note)
206
+ noteOn(note, { source: `keyboard:${id}` })
207
+ }
208
+
209
+ function onKeyUp(event: KeyboardEvent) {
210
+ const id = event.code || event.key
211
+ const note = shortcutNotes.get(id)
212
+ if (note === undefined) return
213
+ shortcutNotes.delete(id)
214
+ noteOff(note, { source: `keyboard:${id}` })
215
+ }
216
+
217
+ function releaseShortcuts() {
218
+ for (const [id, note] of shortcutNotes) {
219
+ noteOff(note, { source: `keyboard:${id}` })
220
+ }
221
+ shortcutNotes.clear()
222
+ }
223
+
224
+ function onFocusOut(event: Event) {
225
+ const next = (event as FocusEvent).relatedTarget
226
+ // Moving between the keys and whatever else is inside keeps them held.
227
+ if (next instanceof Node && element.contains(next)) return
228
+ releaseShortcuts()
229
+ }
230
+
231
+ /** The scope the listeners are bound for, or null when none are. */
232
+ let boundScope: KeyboardShortcutsScope | null = null
233
+
234
+ function bindShortcuts() {
235
+ const scope = opts.keyboardShortcuts
236
+ ? (opts.keyboardShortcutsScope ?? 'root')
237
+ : null
238
+ if (scope === boundScope) return
239
+ unbindShortcuts()
240
+ const win = globalThis.window
241
+ if (!scope || !win) return
242
+ const target: EventTarget = scope === 'window' ? win : element
243
+ target.addEventListener('keydown', onKeyDown as EventListener)
244
+ target.addEventListener('keyup', onKeyUp as EventListener)
245
+ if (scope === 'root') element.addEventListener('focusout', onFocusOut)
246
+ // A key released while the window is in the background never sends its
247
+ // keyup, so everything is let go when the focus leaves the page.
248
+ win.addEventListener('blur', releaseShortcuts)
249
+ boundScope = scope
250
+ }
251
+
252
+ function unbindShortcuts() {
253
+ const win = globalThis.window
254
+ if (!boundScope || !win) return
255
+ const target: EventTarget = boundScope === 'window' ? win : element
256
+ target.removeEventListener('keydown', onKeyDown as EventListener)
257
+ target.removeEventListener('keyup', onKeyUp as EventListener)
258
+ element.removeEventListener('focusout', onFocusOut)
259
+ win.removeEventListener('blur', releaseShortcuts)
260
+ boundScope = null
261
+ }
262
+
263
+ /**
264
+ * What the held keys were started against. Compared by value: a caller
265
+ * who writes the keys inline hands over a new array on every update, and
266
+ * releasing on that would stop a note as soon as anything re-renders.
267
+ */
268
+ function shortcutMapping() {
269
+ const { first, last } = opts.layout.noteRange
270
+ return [
271
+ opts.keyboardShortcuts?.keys.join('\u0000') ?? '',
272
+ opts.keyboardShortcutsScope ?? 'root',
273
+ first,
274
+ last,
275
+ ].join('\u0001')
276
+ }
277
+
278
+ bindShortcuts()
279
+
159
280
  const drag = createDrag(element, {
160
281
  multiPointer: true,
161
282
  onDragStart: (state) =>
@@ -171,7 +292,10 @@ export function createPianoInput(
171
292
 
172
293
  return {
173
294
  update: (next) => {
295
+ const mapping = shortcutMapping()
174
296
  opts = { ...opts, ...next }
297
+ if (shortcutMapping() !== mapping) releaseShortcuts()
298
+ bindShortcuts()
175
299
 
176
300
  const midiMax = opts.midiMax ?? 127
177
301
  const stoppedNotes = activeNotes().filter((note) => note > midiMax)
@@ -191,6 +315,8 @@ export function createPianoInput(
191
315
  activeNotes,
192
316
  destroy: () => {
193
317
  drag.destroy()
318
+ unbindShortcuts()
319
+ shortcutNotes.clear()
194
320
  // Anything still held is released, so a caller that mirrors these
195
321
  // callbacks into a synth is not left with a stuck note.
196
322
  const notes = activeNotes()
@@ -0,0 +1,202 @@
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
+ }
184
+
185
+ /**
186
+ * The white key width that makes the keyboard exactly `width` wide, for a
187
+ * keyboard that follows the size of its container.
188
+ *
189
+ * Solved from {@link pianoWidth} rather than by dividing among the white
190
+ * keys, so that a range that starts or ends on a black key — which sticks out
191
+ * by a fraction of a white key — still fills the container. The width grows
192
+ * linearly with the white key width, so two samples pin it down.
193
+ */
194
+ export function fitWhiteKeyWidth(
195
+ width: number,
196
+ layout: Omit<PianoLayout, 'whiteKeyWidth'>,
197
+ ): number {
198
+ const at = (whiteKeyWidth: number) => pianoWidth({ ...layout, whiteKeyWidth })
199
+ const slope = at(2) - at(1)
200
+ if (slope <= 0) return 0
201
+ return (width - (at(1) - slope)) / slope
202
+ }
@@ -0,0 +1,86 @@
1
+ export type KeyboardShortcuts = {
2
+ /**
3
+ * Keys laid out from `noteRange.first`, one entry per semitone.
4
+ *
5
+ * An empty string leaves that note without a shortcut: `KeyboardEvent.key` is
6
+ * never empty, so the entry can never match. Use it to skip the black keys
7
+ * (see {@link SHORTCUTS.HOME_ROW_NATURAL}) and keep the remaining entries
8
+ * lined up with the notes.
9
+ */
10
+ keys: string[]
11
+ }
12
+
13
+ /**
14
+ * Ready-made keyboard layouts. Both assume `noteRange.first` is a C.
15
+ */
16
+ export const SHORTCUTS = {
17
+ /** Every semitone from C, over the two rows of a QWERTY keyboard. */
18
+ HOME_ROW: {
19
+ keys: [
20
+ 'a',
21
+ 'w',
22
+ 's',
23
+ 'e',
24
+ 'd',
25
+ 'f',
26
+ 't',
27
+ 'g',
28
+ 'y',
29
+ 'h',
30
+ 'u',
31
+ 'j',
32
+ 'k',
33
+ 'o',
34
+ 'l',
35
+ 'p',
36
+ ';',
37
+ ],
38
+ },
39
+ /** The white keys only, on the home row. Black keys have no shortcut. */
40
+ HOME_ROW_NATURAL: {
41
+ keys: [
42
+ 'a',
43
+ '',
44
+ 's',
45
+ '',
46
+ 'd',
47
+ 'f',
48
+ '',
49
+ 'g',
50
+ '',
51
+ 'h',
52
+ '',
53
+ 'j',
54
+ 'k',
55
+ '',
56
+ 'l',
57
+ '',
58
+ ';',
59
+ ],
60
+ },
61
+ }
62
+
63
+ /**
64
+ * Where keyboard shortcuts listen. `root` handles keys only while the piano
65
+ * or one of its descendants has focus; `window` handles them anywhere on the
66
+ * page except in editable elements.
67
+ */
68
+ export type KeyboardShortcutsScope = 'root' | 'window'
69
+
70
+ /**
71
+ * Whether a key press was typed into something: shortcuts must not play a
72
+ * note for a letter that is going into a text field.
73
+ */
74
+ export function isEditableTarget(target: EventTarget | null) {
75
+ if (!(target instanceof HTMLElement)) return false
76
+ if (target.matches('input, textarea, select')) return true
77
+
78
+ for (let element: HTMLElement | null = target; element;) {
79
+ const contentEditable = element.getAttribute('contenteditable')
80
+ if (contentEditable !== null)
81
+ return contentEditable.toLowerCase() !== 'false'
82
+ element = element.parentElement
83
+ }
84
+
85
+ return false
86
+ }
@@ -0,0 +1,103 @@
1
+ export interface LongPressOptions {
2
+ /** Called once on the press, then again every `interval` after `delay`. */
3
+ onPress: () => void
4
+ /**
5
+ * How long the press has to be held before it starts repeating, in
6
+ * milliseconds.
7
+ *
8
+ * @default 500
9
+ */
10
+ delay?: number
11
+ /**
12
+ * How often it repeats once it has started, in milliseconds.
13
+ *
14
+ * @default 40
15
+ */
16
+ interval?: number
17
+ }
18
+
19
+ export interface LongPressInstance {
20
+ /**
21
+ * Start a press, from a `pointerdown` or with no event at all. Only the
22
+ * primary button starts one, and a press already in progress is left alone.
23
+ */
24
+ start: (event?: Pick<PointerEvent, 'button' | 'pointerId'>) => void
25
+ /** End the press, as releasing the pointer would. */
26
+ stop: () => void
27
+ /** Replace the given options. The press in progress picks them up. */
28
+ update: (options: Partial<LongPressOptions>) => void
29
+ /** Whether a press is in progress. */
30
+ pressed: () => boolean
31
+ destroy: () => void
32
+ }
33
+
34
+ /**
35
+ * Repeat an action while a pointer is held down: once on the press, then
36
+ * again every `interval` after `delay` — the way a stepper button or a
37
+ * key held on a keyboard behaves.
38
+ *
39
+ * The release is listened for on the window, not on the element: the
40
+ * pointer may be let go anywhere. Only the pointer that started the press
41
+ * ends it, so a second finger lifting elsewhere does not. The window losing
42
+ * focus ends it too, since the release would never arrive.
43
+ */
44
+ export function createLongPress(options: LongPressOptions): LongPressInstance {
45
+ let opts = options
46
+ let pointerId: number | null = null
47
+ let active = false
48
+ let timer: ReturnType<typeof setTimeout> | null = null
49
+
50
+ function clearTimer() {
51
+ if (timer !== null) clearTimeout(timer)
52
+ timer = null
53
+ }
54
+
55
+ /** Wait `wait`, fire, and keep going at `interval` from then on. */
56
+ function schedule(wait: number) {
57
+ timer = setTimeout(() => {
58
+ opts.onPress()
59
+ if (active) schedule(opts.interval ?? 40)
60
+ }, wait)
61
+ }
62
+
63
+ function onPointerEnd(event: PointerEvent) {
64
+ // Pointer events carry an id; a press started without one ends on any.
65
+ if (pointerId !== null && event.pointerId !== pointerId) return
66
+ stop()
67
+ }
68
+
69
+ function listen(add: boolean) {
70
+ const target = globalThis.window
71
+ if (!target) return
72
+ const method = add ? 'addEventListener' : 'removeEventListener'
73
+ target[method]('pointerup', onPointerEnd as EventListener)
74
+ target[method]('pointercancel', onPointerEnd as EventListener)
75
+ target[method]('blur', stop)
76
+ }
77
+
78
+ function stop() {
79
+ if (!active) return
80
+ active = false
81
+ pointerId = null
82
+ clearTimer()
83
+ listen(false)
84
+ }
85
+
86
+ return {
87
+ start: (event) => {
88
+ if (active || (event && event.button !== 0)) return
89
+ active = true
90
+ pointerId = event?.pointerId ?? null
91
+ listen(true)
92
+ opts.onPress()
93
+ // The action may have stopped the press itself.
94
+ if (active) schedule(opts.delay ?? 500)
95
+ },
96
+ stop,
97
+ update: (next) => {
98
+ opts = { ...opts, ...next }
99
+ },
100
+ pressed: () => active,
101
+ destroy: stop,
102
+ }
103
+ }