@tremolo-ui/dom 0.7.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,139 @@
1
+ import { type ValueRange } from '@tremolo-ui/functions'
2
+
3
+ import { applyDelta } from '../input/apply-delta'
4
+ import { DEFAULT_DRAG_SENSITIVITY } from '../input/defaults'
5
+ import {
6
+ mapModifier,
7
+ selectModifier,
8
+ type InputEventOption,
9
+ type ModifierValue,
10
+ } from '../input/modifiers'
11
+ import { createDrag } from '../pointer/drag'
12
+
13
+ export interface StepperDragOptions {
14
+ /** The value now, read when the drag starts moving it. */
15
+ getValue: () => number
16
+ /**
17
+ * The range the value moves across. Its `step` is what one step of the drag
18
+ * is worth; see `numberInputRanges` for the one a number input uses.
19
+ */
20
+ range: ValueRange
21
+ /**
22
+ * How many pixels of vertical movement make one step.
23
+ *
24
+ * @default 1
25
+ */
26
+ pixels?: number
27
+ /**
28
+ * How much a step is worth, per modifier key: `0.1` makes the same movement
29
+ * count a tenth as much. Pressing or releasing the key mid-drag does not
30
+ * move the value.
31
+ *
32
+ * @default { default: 1, shift: 0.1 }
33
+ */
34
+ sensitivity?: ModifierValue<number>
35
+ /** Hide the pointer and keep it from hitting the edge of the screen. */
36
+ pointerLock?: boolean
37
+ /** Called with the new value whenever the drag moves it. */
38
+ onChange: (value: number) => void
39
+ }
40
+
41
+ export interface StepperDragInstance {
42
+ /** Replace the given options. `pointerLock` reaches the next drag. */
43
+ update: (options: Partial<StepperDragOptions>) => void
44
+ /**
45
+ * Whether the drag in progress has moved the value. A stepper button's
46
+ * press-and-hold repeat stands down once it has, so the value is not moved
47
+ * twice.
48
+ */
49
+ moved: () => boolean
50
+ destroy: () => void
51
+ }
52
+
53
+ /**
54
+ * Drag up and down on the steppers of a number input to move its value, one
55
+ * `step` every `pixels` — up raises it, as on a knob.
56
+ *
57
+ * Counted from where the drag started moving rather than added up per
58
+ * event, so rounding cannot accumulate. The start is taken on the first
59
+ * move, not on pointerdown: a stepper button acts on pointerdown, so by then
60
+ * the value may already have been nudged once, and the drag carries on from
61
+ * there.
62
+ */
63
+ export function createStepperDrag(
64
+ element: Element,
65
+ options: StepperDragOptions,
66
+ ): StepperDragInstance {
67
+ let opts = options
68
+ let origin: { y: number; value: number } | null = null
69
+ let moved = false
70
+ /**
71
+ * Which sensitivity the drag is counting at, and where the previous event
72
+ * was — a key produces no pointer event of its own, so a change is only
73
+ * seen on the next move and has to be dated back to the one before it.
74
+ */
75
+ let factor = 1
76
+ let previousY = 0
77
+
78
+ const drag = createDrag(element, {
79
+ threshold: 1,
80
+ cursor: 'ns-resize',
81
+ pointerLock: opts.pointerLock,
82
+ onDragStart: (state) => {
83
+ origin = null
84
+ moved = false
85
+ factor = selectModifier(
86
+ opts.sensitivity ?? DEFAULT_DRAG_SENSITIVITY,
87
+ state.event,
88
+ ).value
89
+ },
90
+ onDrag: (state) => {
91
+ const { y } = state
92
+ if (!origin) {
93
+ origin = { y, value: opts.getValue() }
94
+ previousY = y
95
+ return
96
+ }
97
+
98
+ const sensitivity = opts.sensitivity ?? DEFAULT_DRAG_SENSITIVITY
99
+ // Pressing or releasing the key mid-drag must not move the value, so the
100
+ // travel so far is folded into the origin and measuring starts again
101
+ // from the previous event: that event's own distance belongs to the new
102
+ // sensitivity.
103
+ const next = selectModifier(sensitivity, state.event).value
104
+ if (next !== factor) {
105
+ origin = { y: previousY, value: opts.getValue() }
106
+ factor = next
107
+ }
108
+ previousY = y
109
+
110
+ const steps = Math.round(-(y - origin.y) / (opts.pixels ?? 1))
111
+ if (steps === 0) return
112
+ moved = true
113
+
114
+ // The sensitivity as an amount per step, carried over as a modifier map
115
+ // rather than resolved here: that keeps `step` out of the pipeline for
116
+ // a modifier entry, since naming one is a request to move off the grid.
117
+ const step = opts.range.step ?? 1
118
+ const amounts = mapModifier(sensitivity, (f): InputEventOption => [
119
+ 'raw',
120
+ step * f,
121
+ ])
122
+ opts.onChange(
123
+ applyDelta(origin.value, steps, amounts, opts.range, state.event),
124
+ )
125
+ },
126
+ onDragEnd: () => {
127
+ moved = false
128
+ },
129
+ })
130
+
131
+ return {
132
+ update: (next) => {
133
+ opts = { ...opts, ...next }
134
+ if ('pointerLock' in next) drag.update({ pointerLock: opts.pointerLock })
135
+ },
136
+ moved: () => moved,
137
+ destroy: () => drag.destroy(),
138
+ }
139
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Reading a number out of the text of a number input, and keeping the caret
3
+ * in place while the number under it changes.
4
+ *
5
+ * The text is whatever `format` made of the value — `"440 Hz"`, `"-6.0 dB"` —
6
+ * or a half-typed entry, so none of this assumes the text is a number alone.
7
+ */
8
+
9
+ /** The signs a number can carry. U+2212 is what some `Intl.NumberFormat` locales write. */
10
+ const SIGNS = '+-\u2212'
11
+
12
+ /** Where the number is in the text; the rest, on either side, is the unit. */
13
+ export interface NumberSpan {
14
+ start: number
15
+ end: number
16
+ }
17
+
18
+ /**
19
+ * Where the number is in the text: from its first digit to its last, with the
20
+ * sign and the decimal point in front of it. Whatever is left on either side
21
+ * is taken for the unit, so it does not matter whether a space separates them,
22
+ * or what the number looks like — `+6.0`, `1e+21`, `1,000` and `1:30` are each
23
+ * one number. `null` when the text has no digit at all.
24
+ */
25
+ export function numberSpan(text: string): NumberSpan | null {
26
+ const first = text.search(/\d/)
27
+ if (first === -1) return null
28
+ let start = first
29
+ if (text[start - 1] === '.') start -= 1
30
+ if (start > 0 && SIGNS.includes(text[start - 1])) start -= 1
31
+ return { start, end: text.search(/\d\D*$/) + 1 }
32
+ }
33
+
34
+ /** A plain number, and nothing else: no grouping, no other separators. */
35
+ const PLAIN_NUMBER =
36
+ /^[+\-\u2212]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+\-\u2212]?\d+)?$/
37
+
38
+ /**
39
+ * The number in the text, with the unit after it ignored: the default `parse`
40
+ * of a number input.
41
+ *
42
+ * `NaN`, which leaves the value alone, whenever the number cannot be read
43
+ * safely, rather than a part of it: text with no number, a number that is not
44
+ * a plain one (`1,000` would otherwise read as 1, and `1:30` as 1), and text
45
+ * with something in front of the number, which can change what it means (the
46
+ * `L` of a pan reading `L 30`). A `format` that writes any of those needs its
47
+ * own `parse`.
48
+ */
49
+ export function parseNumberText(text: string): number {
50
+ const span = numberSpan(text)
51
+ if (!span || text.slice(0, span.start).trim() !== '') return NaN
52
+ const number = text.slice(span.start, span.end)
53
+ return PLAIN_NUMBER.test(number)
54
+ ? Number(number.replace(/\u2212/g, '-'))
55
+ : NaN
56
+ }
57
+
58
+ /**
59
+ * The index the caret is measured against: the decimal point, or where one
60
+ * would go if the number has none — in front of an exponent, if there is one.
61
+ *
62
+ * Measuring from an end instead would slide the caret across a digit whenever
63
+ * the number changed length — `9.9` to `10.0` gains a character in front, `10`
64
+ * to `9` loses one, and so does `9e+9` to `1e+10` behind — which is exactly
65
+ * what stepping does.
66
+ */
67
+ function decimalAnchor(text: string, span: NumberSpan) {
68
+ const number = text.slice(span.start, span.end)
69
+ const exponent = number.search(/[eE][+\-\u2212]?\d/)
70
+ const mantissa = exponent === -1 ? number : number.slice(0, exponent)
71
+ const dot = mantissa.indexOf('.')
72
+ return span.start + (dot === -1 ? mantissa.length : dot)
73
+ }
74
+
75
+ const NO_NUMBER: NumberSpan = { start: 0, end: 0 }
76
+
77
+ /**
78
+ * Where the caret is, relative to the decimal point, so that it can be put
79
+ * back at the same digit once the value has changed. See
80
+ * {@link caretAtDecimalOffset}.
81
+ */
82
+ export function caretDecimalOffset(text: string, caret: number): number {
83
+ return caret - decimalAnchor(text, numberSpan(text) ?? NO_NUMBER)
84
+ }
85
+
86
+ /**
87
+ * The caret position `offset` characters from the decimal point of the new
88
+ * text, kept within the number.
89
+ *
90
+ * @example
91
+ * // The caret sits in front of the point of "9.9" when ArrowUp turns it
92
+ * // into "10.0"
93
+ * const offset = caretDecimalOffset('9.9', 1) // 0
94
+ * caretAtDecimalOffset('10.0', offset) // 2: still in front of the point
95
+ */
96
+ export function caretAtDecimalOffset(text: string, offset: number): number {
97
+ const span = numberSpan(text) ?? NO_NUMBER
98
+ const place = decimalAnchor(text, span) + offset
99
+ return Math.max(span.start, Math.min(place, span.end))
100
+ }
@@ -0,0 +1,129 @@
1
+ import { type Scale, type ValueRange } from '@tremolo-ui/functions'
2
+
3
+ import { applyDelta } from '../input/apply-delta'
4
+ import {
5
+ selectModifier,
6
+ type InputEventOption,
7
+ type ModifierState,
8
+ type ModifierValue,
9
+ } from '../input/modifiers'
10
+
11
+ /**
12
+ * The value a number input edits, and how far it may go.
13
+ *
14
+ * Unlike a slider, either end may be left open, and `clampValue: false` lets
15
+ * the value past the ends that are set.
16
+ */
17
+ export interface NumberInputValueOptions {
18
+ min?: number
19
+ max?: number
20
+ step?: number
21
+ scale?: Scale
22
+ /**
23
+ * Keep the value between `min` and `max`.
24
+ *
25
+ * @default true
26
+ */
27
+ clampValue?: boolean
28
+ }
29
+
30
+ /** The ranges {@link nudgeNumberInput} moves a value across. */
31
+ export interface NumberInputRanges {
32
+ /** For a `normalized` amount, which needs a finite span to take a share of. */
33
+ normalized: ValueRange
34
+ /** For a `raw` amount, which does not. */
35
+ raw: ValueRange
36
+ }
37
+
38
+ /**
39
+ * The ranges a number input moves its value across, with the open ends
40
+ * filled in.
41
+ *
42
+ * A normalized amount needs a finite span even when an end is unbounded or
43
+ * clamping is off. Safe integers provide one without overflowing the span a
44
+ * scale calculates. A raw amount needs no span, so its open ends can cover
45
+ * every finite number instead of stopping at the safe-integer range.
46
+ */
47
+ export function numberInputRanges({
48
+ min,
49
+ max,
50
+ step,
51
+ scale,
52
+ clampValue = true,
53
+ }: NumberInputValueOptions): NumberInputRanges {
54
+ const lo = clampValue ? min : undefined
55
+ const hi = clampValue ? max : undefined
56
+ return {
57
+ normalized: {
58
+ min: lo ?? Number.MIN_SAFE_INTEGER,
59
+ max: hi ?? Number.MAX_SAFE_INTEGER,
60
+ step,
61
+ scale,
62
+ },
63
+ raw: {
64
+ min: lo ?? -Number.MAX_VALUE,
65
+ max: hi ?? Number.MAX_VALUE,
66
+ step,
67
+ scale,
68
+ },
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Move a number input's value by one press of a key, a wheel notch or a
74
+ * stepper, picking the range that suits the kind of amount. See `applyDelta`.
75
+ */
76
+ export function nudgeNumberInput(
77
+ value: number,
78
+ direction: number,
79
+ options: ModifierValue<InputEventOption>,
80
+ ranges: NumberInputRanges,
81
+ modifiers?: ModifierState,
82
+ ): number {
83
+ const [mode] = selectModifier(options, modifiers).value
84
+ return applyDelta(
85
+ value,
86
+ direction,
87
+ options,
88
+ mode === 'raw' ? ranges.raw : ranges.normalized,
89
+ modifiers,
90
+ )
91
+ }
92
+
93
+ /**
94
+ * Where the value stands against the ends: whether it can go no further
95
+ * down or up, and whether it lies outside them — which only an unclamped
96
+ * input, or a value set from outside, can do.
97
+ */
98
+ export function numberInputBounds(
99
+ value: number,
100
+ { min, max, clampValue = true }: NumberInputValueOptions,
101
+ ) {
102
+ return {
103
+ atMin: clampValue && min !== undefined && value <= min,
104
+ atMax: clampValue && max !== undefined && value >= max,
105
+ outOfRange:
106
+ (min !== undefined && value < min) || (max !== undefined && value > max),
107
+ }
108
+ }
109
+
110
+ /**
111
+ * The value typed text commits to, or `null` when there is no number in it.
112
+ *
113
+ * Text with no number is not a value: the input should go back to what it
114
+ * was showing rather than commit a zero the user never typed. What is read
115
+ * is clamped here and not while typing, since clamping as the user types
116
+ * would make "1500" impossible to enter into an input whose max is 100.
117
+ */
118
+ export function commitNumberInputText(
119
+ text: string,
120
+ parse: (text: string) => number,
121
+ { min, max, clampValue = true }: NumberInputValueOptions,
122
+ ): number | null {
123
+ const parsed = parse(text)
124
+ if (!Number.isFinite(parsed)) return null
125
+ let committed = parsed
126
+ if (clampValue && min !== undefined) committed = Math.max(committed, min)
127
+ if (clampValue && max !== undefined) committed = Math.min(committed, max)
128
+ return committed
129
+ }
@@ -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,6 +1,11 @@
1
1
  import { createDrag } from '../pointer/drag'
2
2
 
3
3
  import { noteAt, type PianoLayout } from './layout'
4
+ import {
5
+ isEditableTarget,
6
+ type KeyboardShortcuts,
7
+ type KeyboardShortcutsScope,
8
+ } from './shortcuts'
4
9
 
5
10
  /**
6
11
  * What asked for a note to sound.
@@ -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()
@@ -181,3 +181,22 @@ export function noteAt(
181
181
 
182
182
  return null
183
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
+ }