@tremolo-ui/react 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.
Files changed (86) hide show
  1. package/dist/compose-refs.cjs +72 -0
  2. package/dist/compose-refs.cjs.map +1 -0
  3. package/dist/compose-refs.d.cts +43 -0
  4. package/dist/compose-refs.d.cts.map +1 -0
  5. package/dist/compose-refs.d.ts +43 -0
  6. package/dist/compose-refs.d.ts.map +1 -0
  7. package/dist/compose-refs.js +70 -0
  8. package/dist/compose-refs.js.map +1 -0
  9. package/dist/index.cjs +1669 -541
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +1571 -486
  12. package/dist/index.d.cts.map +1 -1
  13. package/dist/index.d.ts +1571 -486
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1640 -538
  16. package/dist/index.js.map +1 -1
  17. package/package.json +29 -27
  18. package/src/components/AnimationCanvas/index.tsx +115 -76
  19. package/src/components/DropZone/index.tsx +100 -0
  20. package/src/components/FileInput/Trigger.tsx +39 -0
  21. package/src/components/FileInput/context.tsx +24 -0
  22. package/src/components/FileInput/index.tsx +144 -0
  23. package/src/components/Knob/ActiveLine.tsx +7 -6
  24. package/src/components/Knob/InactiveLine.tsx +9 -10
  25. package/src/components/Knob/SVGRoot.tsx +5 -3
  26. package/src/components/Knob/Thumb.tsx +33 -11
  27. package/src/components/Knob/context.tsx +17 -1
  28. package/src/components/Knob/index.tsx +156 -60
  29. package/src/components/NumberInput/DecrementStepper.tsx +3 -10
  30. package/src/components/NumberInput/IncrementStepper.tsx +3 -10
  31. package/src/components/NumberInput/InputField.tsx +185 -108
  32. package/src/components/NumberInput/Stepper.tsx +120 -84
  33. package/src/components/NumberInput/StepperButton.tsx +100 -0
  34. package/src/components/NumberInput/context.tsx +34 -8
  35. package/src/components/NumberInput/index.tsx +270 -82
  36. package/src/components/Piano/index.tsx +363 -200
  37. package/src/components/PointsEditor/Background.tsx +13 -10
  38. package/src/components/PointsEditor/Container.tsx +83 -29
  39. package/src/components/PointsEditor/Point.tsx +241 -79
  40. package/src/components/PointsEditor/SelectionBox.tsx +48 -0
  41. package/src/components/PointsEditor/context.tsx +78 -10
  42. package/src/components/PointsEditor/index.tsx +440 -41
  43. package/src/components/Slider/Marks.tsx +48 -19
  44. package/src/components/Slider/MarksOption.tsx +64 -45
  45. package/src/components/Slider/Thumb.tsx +107 -55
  46. package/src/components/Slider/Track.tsx +66 -62
  47. package/src/components/Slider/context.tsx +4 -2
  48. package/src/components/Slider/index.tsx +136 -77
  49. package/src/components/Slider/type.ts +29 -9
  50. package/src/components/XYPad/Area.tsx +46 -41
  51. package/src/components/XYPad/Thumb.tsx +131 -64
  52. package/src/components/XYPad/context.tsx +4 -1
  53. package/src/components/XYPad/index.tsx +183 -53
  54. package/src/components/_util/Placement.tsx +68 -0
  55. package/src/components/_util/VisuallyHiddenRangeInput.tsx +12 -0
  56. package/src/components/_util/css-length.ts +13 -0
  57. package/src/components/_util/decimal-digits.ts +23 -0
  58. package/src/components/_util/visually-hidden.ts +25 -0
  59. package/src/{components/_util/composeRefs.tsx → compose-refs/index.tsx} +33 -5
  60. package/src/css-variables.ts +23 -0
  61. package/src/hooks/_internal/useCallbackRef.ts +3 -1
  62. package/src/hooks/_internal/useCheckSteps.ts +169 -0
  63. package/src/hooks/useAnimationFrame.ts +17 -7
  64. package/src/hooks/useDrag.ts +51 -12
  65. package/src/hooks/useDragValue.ts +71 -11
  66. package/src/hooks/useDropZone.ts +78 -0
  67. package/src/hooks/useEventListener.ts +10 -6
  68. package/src/hooks/useLongPress.ts +42 -8
  69. package/src/hooks/useMIDIAccess.ts +82 -11
  70. package/src/hooks/useMIDIInput.ts +45 -13
  71. package/src/hooks/useMIDIMessage.ts +23 -5
  72. package/src/hooks/useWheel.ts +4 -1
  73. package/src/index.ts +45 -25
  74. package/src/input-event.ts +32 -0
  75. package/dist/index.css +0 -501
  76. package/src/Globals.d.ts +0 -3
  77. package/src/components/Knob/index.css +0 -81
  78. package/src/components/NumberInput/index.css +0 -121
  79. package/src/components/NumberInput/stepperButton.tsx +0 -90
  80. package/src/components/Piano/index.css +0 -90
  81. package/src/components/PointsEditor/index.css +0 -35
  82. package/src/components/Slider/index.css +0 -125
  83. package/src/components/XYPad/index.css +0 -60
  84. package/src/components/_util/index.ts +0 -28
  85. package/src/styles/global.css +0 -19
  86. /package/src/components/Piano/{keyboardShortcuts.ts → keyboard-shortcuts.ts} +0 -0
@@ -1,9 +1,9 @@
1
- import clsx from 'clsx'
2
1
  import {
3
2
  ComponentPropsWithoutRef,
4
3
  CSSProperties,
5
4
  forwardRef,
6
5
  ReactNode,
6
+ useCallback,
7
7
  useEffect,
8
8
  useImperativeHandle,
9
9
  useMemo,
@@ -11,27 +11,44 @@ import {
11
11
  useState,
12
12
  } from 'react'
13
13
 
14
- import { createPianoInput, type PianoInputInstance } from '@tremolo-ui/dom'
15
14
  import {
16
15
  blackKeyWidth,
16
+ createPianoInput,
17
17
  getNoteRangeArray,
18
- isWhiteKey,
19
- noteKey,
20
18
  notePosition,
21
19
  pianoWidth,
22
20
  type NoteRange,
21
+ type PianoInputInstance,
23
22
  type PianoLayout,
24
- } from '@tremolo-ui/functions'
23
+ } from '@tremolo-ui/dom'
24
+ import { isWhiteKey, noteKey } from '@tremolo-ui/functions'
25
25
 
26
26
  import { useEventListener } from '../../hooks/useEventListener'
27
+ import { cssLength } from '../_util/css-length'
27
28
 
28
- import { KeyboardShortcuts } from './keyboardShortcuts'
29
+ import { KeyboardShortcuts } from './keyboard-shortcuts'
29
30
 
30
- /**
31
- * `style` that also takes CSS custom properties, which is how a key's colours
32
- * are set: see `index.css` for the ones each key type reads.
33
- */
34
- export type CSSVariables = Record<`--${string}`, string | number | undefined>
31
+ import type { CSSVariables } from '../../css-variables'
32
+
33
+ type KeyboardShortcutsScope = 'root' | 'window'
34
+
35
+ function isEditableTarget(target: EventTarget | null) {
36
+ if (!(target instanceof HTMLElement)) return false
37
+ if (target.matches('input, textarea, select')) return true
38
+
39
+ for (let element: HTMLElement | null = target; element;) {
40
+ const contentEditable = element.getAttribute('contenteditable')
41
+ if (contentEditable !== null)
42
+ return contentEditable.toLowerCase() !== 'false'
43
+ element = element.parentElement
44
+ }
45
+
46
+ return false
47
+ }
48
+
49
+ function shortcutKey(event: KeyboardEvent) {
50
+ return event.code || event.key
51
+ }
35
52
 
36
53
  /**
37
54
  * What {@link PianoProps.keyProps} may return for one key.
@@ -41,7 +58,8 @@ export type CSSVariables = Record<`--${string}`, string | number | undefined>
41
58
  * mark a key out.
42
59
  */
43
60
  export type KeyAttributes = Omit<ComponentPropsWithoutRef<'div'>, 'style'> & {
44
- style?: CSSProperties & CSSVariables
61
+ style?: CSSProperties &
62
+ CSSVariables<'color' | 'bg' | 'active-color' | 'active-bg'>
45
63
  } & Record<`data-${string}`, string | number | boolean | undefined>
46
64
 
47
65
  /** What a key is, when {@link PianoProps.label} or `keyProps` is asked about it. */
@@ -56,36 +74,69 @@ export interface KeyState {
56
74
  }
57
75
 
58
76
  export interface PianoProps {
77
+ /**
78
+ * Classes for what a key draws inside itself: `keyLabelWrapper`, and
79
+ * `keyLabel` around what `label` returns. The key itself takes a class
80
+ * through `keyProps`, which is also where the note and its state are.
81
+ */
82
+ classes?: {
83
+ keyLabelWrapper?: string
84
+ keyLabel?: string
85
+ }
86
+
87
+ /**
88
+ * The notes to draw keys for, as MIDI note numbers from `first` to `last`,
89
+ * both included.
90
+ */
59
91
  noteRange: NoteRange
60
92
 
61
93
  /**
62
- * Let a pointer slide from one key to the next while it is down.
94
+ * Let a pointer slide from one key to the next while it is down. With it
95
+ * off, the key that was pressed sounds until the pointer is released.
63
96
  *
64
97
  * @default true
65
98
  */
66
99
  glissando?: boolean
67
100
 
68
101
  /**
69
- * Highest note that can sound. Keys above it are drawn `aria-disabled`.
102
+ * Highest note that can sound. Keys above it carry `data-disabled`.
70
103
  *
71
104
  * @default 127
72
105
  */
73
106
  midiMax?: number
74
107
 
108
+ /**
109
+ * Play notes from the computer keyboard: `keys[i]` plays
110
+ * `noteRange.first + i`. `SHORTCUTS` has ready-made layouts, which assume
111
+ * `noteRange.first` is a C. Where they listen is `keyboardShortcutsScope`.
112
+ */
75
113
  keyboardShortcuts?: KeyboardShortcuts
76
114
 
77
115
  /**
78
- * Fill the parent element, deriving the width of a white key from it.
79
- * {@link PianoProps.whiteKeyWidth} is ignored.
116
+ * Where keyboard shortcuts listen. `root` handles keys only while the Piano
117
+ * root or one of its descendants has focus; `window` handles them anywhere
118
+ * on the page except in editable elements.
119
+ *
120
+ * @default 'root'
121
+ */
122
+ keyboardShortcutsScope?: KeyboardShortcutsScope
123
+
124
+ /**
125
+ * Follow the width of the parent element, deriving the width of a white key
126
+ * from it. {@link PianoProps.whiteKeyWidth} is ignored.
80
127
  *
81
128
  * @default false
82
129
  */
83
- fill?: boolean
130
+ resizable?: boolean
84
131
 
85
- /** @default 40 */
132
+ /**
133
+ * Width of a white key in pixels, not counting `keyGap`. Ignored while
134
+ * `resizable` is on.
135
+ * @default 40
136
+ */
86
137
  whiteKeyWidth?: number
87
138
  /**
88
- * Space between two white keys.
139
+ * Space between two white keys, in pixels.
89
140
  * @default 1
90
141
  */
91
142
  keyGap?: number
@@ -100,11 +151,13 @@ export interface PianoProps {
100
151
  */
101
152
  blackKeyHeightRatio?: number
102
153
 
103
- /** @default `fill ? '100%' : 160` */
154
+ /**
155
+ * Height of the keyboard. Sets `--height`; when it is omitted, the height
156
+ * the theme gives stands, and the theme can tell `resizable` apart through
157
+ * `data-resizable`.
158
+ */
104
159
  height?: number | string
105
160
 
106
- style?: CSSProperties & CSSVariables
107
-
108
161
  /**
109
162
  * What to draw inside a key. `''`, `null` and `undefined` leave it bare, so
110
163
  * a layout with gaps — {@link SHORTCUTS.HOME_ROW_NATURAL}, say — needs no
@@ -122,13 +175,22 @@ export interface PianoProps {
122
175
  *
123
176
  * @example highlight the notes of a scale
124
177
  * ```tsx
125
- * keyProps={(note) => ({ 'data-in-scale': inScale(note, root, 'major') })}
178
+ * keyProps={(note) => ({ 'data-in-scale': inScale(note, root, 'major') ? '' : undefined })}
126
179
  * ```
127
180
  */
128
181
  keyProps?: (note: number, state: KeyState) => KeyAttributes
129
182
 
183
+ /**
184
+ * Called when a note starts sounding, whether a pointer, a keyboard shortcut
185
+ * or `playNote` asked for it. A note held by several of them at once is
186
+ * reported once. `velocity` is what `playNote` was given, and `undefined`
187
+ * otherwise.
188
+ */
130
189
  onPlayNote?: (note: number, velocity?: number) => void
190
+ /** Called once everything holding a note has let go of it. */
131
191
  onStopNote?: (note: number) => void
192
+
193
+ style?: CSSProperties & CSSVariables<'height'>
132
194
  }
133
195
 
134
196
  export interface PianoMethods {
@@ -139,195 +201,296 @@ export interface PianoMethods {
139
201
  type Props = PianoProps &
140
202
  Omit<ComponentPropsWithoutRef<'div'>, keyof PianoProps>
141
203
 
142
- export const Root = forwardRef<PianoMethods, Props>(function Root(
143
- {
144
- noteRange,
145
- glissando = true,
146
- midiMax = 127,
147
- keyboardShortcuts,
148
- fill = false,
149
- whiteKeyWidth = 40,
150
- keyGap = 1,
151
- blackKeyWidthRatio = 0.65,
152
- blackKeyHeightRatio = 0.6,
153
- height = fill ? '100%' : 160,
154
- style,
155
- className,
156
- label,
157
- keyProps,
158
- onPlayNote,
159
- onStopNote,
160
- ...props
161
- },
162
- forwardedRef,
163
- ) {
164
- // See useDrag for why the node is held in state rather than a ref: an inline
165
- // ref would be re-attached on every render and tear the instance down.
166
- const [node, setNode] = useState<HTMLDivElement | null>(null)
167
- const [activeNotes, setActiveNotes] = useState<number[]>([])
168
- /** Set while `fill` is on, measured from the parent. */
169
- const [filledKeyWidth, setFilledKeyWidth] = useState(whiteKeyWidth)
170
-
171
- const notes = useMemo(() => getNoteRangeArray(noteRange), [noteRange])
172
- const whiteKeyCount = useMemo(() => notes.filter(isWhiteKey).length, [notes])
173
-
174
- const layout: PianoLayout = useMemo(
175
- () => ({
176
- noteRange,
177
- whiteKeyWidth: fill ? filledKeyWidth : whiteKeyWidth,
178
- keyGap,
179
- blackKeyWidthRatio,
180
- blackKeyHeightRatio,
181
- }),
182
- [
204
+ export const Root = /* @__PURE__ */ forwardRef<PianoMethods, Props>(
205
+ function Root(
206
+ {
183
207
  noteRange,
184
- fill,
185
- filledKeyWidth,
186
- whiteKeyWidth,
187
- keyGap,
188
- blackKeyWidthRatio,
189
- blackKeyHeightRatio,
190
- ],
191
- )
192
-
193
- // Read when the instance is created. The effect below keeps it current, and
194
- // runs right after, so a stale handler is replaced within the same commit.
195
- const latest = useRef({ layout, glissando, midiMax, onPlayNote, onStopNote })
196
- const instanceRef = useRef<PianoInputInstance | null>(null)
197
-
198
- useEffect(() => {
199
- if (!node) return
200
-
201
- const instance = createPianoInput(node, {
202
- layout: latest.current.layout,
203
- glissando: latest.current.glissando,
204
- midiMax: latest.current.midiMax,
205
- onPlayNote: (note, velocity) =>
206
- latest.current.onPlayNote?.(note, velocity),
207
- onStopNote: (note) => latest.current.onStopNote?.(note),
208
- onActiveNotesChange: setActiveNotes,
208
+ glissando = true,
209
+ midiMax = 127,
210
+ keyboardShortcuts,
211
+ keyboardShortcutsScope = 'root',
212
+ resizable = false,
213
+ whiteKeyWidth = 40,
214
+ keyGap = 1,
215
+ blackKeyWidthRatio = 0.65,
216
+ blackKeyHeightRatio = 0.6,
217
+ height,
218
+ style,
219
+ className,
220
+ label,
221
+ keyProps,
222
+ classes,
223
+ onPlayNote,
224
+ onStopNote,
225
+ ...props
226
+ },
227
+ forwardedRef,
228
+ ) {
229
+ // See useDrag for why the node is held in state rather than a ref: an inline
230
+ // ref would be re-attached on every render and tear the instance down.
231
+ const [node, setNode] = useState<HTMLDivElement | null>(null)
232
+ const [activeNotes, setActiveNotes] = useState<number[]>([])
233
+ /** Set while `resizable` is on, measured from the parent. */
234
+ const [resizedKeyWidth, setResizedKeyWidth] = useState(whiteKeyWidth)
235
+
236
+ const notes = useMemo(() => getNoteRangeArray(noteRange), [noteRange])
237
+ const whiteKeyCount = useMemo(
238
+ () => notes.filter(isWhiteKey).length,
239
+ [notes],
240
+ )
241
+
242
+ const layout: PianoLayout = useMemo(
243
+ () => ({
244
+ noteRange,
245
+ whiteKeyWidth: resizable ? resizedKeyWidth : whiteKeyWidth,
246
+ keyGap,
247
+ blackKeyWidthRatio,
248
+ blackKeyHeightRatio,
249
+ }),
250
+ [
251
+ noteRange,
252
+ resizable,
253
+ resizedKeyWidth,
254
+ whiteKeyWidth,
255
+ keyGap,
256
+ blackKeyWidthRatio,
257
+ blackKeyHeightRatio,
258
+ ],
259
+ )
260
+
261
+ // Read when the instance is created. The effect below keeps it current, and
262
+ // runs right after, so a stale handler is replaced within the same commit.
263
+ const latest = useRef({
264
+ layout,
265
+ glissando,
266
+ midiMax,
267
+ onPlayNote,
268
+ onStopNote,
269
+ })
270
+ const instanceRef = useRef<PianoInputInstance | null>(null)
271
+ const shortcutNotes = useRef(
272
+ new Map<string, { note: number; source: string }>(),
273
+ )
274
+
275
+ const releaseShortcutNotes = useCallback(() => {
276
+ for (const { note, source } of shortcutNotes.current.values()) {
277
+ instanceRef.current?.noteOff(note, { source })
278
+ }
279
+ shortcutNotes.current.clear()
280
+ }, [])
281
+
282
+ useEffect(() => {
283
+ if (!node) return
284
+
285
+ const instance = createPianoInput(node, {
286
+ layout: latest.current.layout,
287
+ glissando: latest.current.glissando,
288
+ midiMax: latest.current.midiMax,
289
+ onPlayNote: (note, velocity) =>
290
+ latest.current.onPlayNote?.(note, velocity),
291
+ onStopNote: (note) => latest.current.onStopNote?.(note),
292
+ onActiveNotesChange: setActiveNotes,
293
+ })
294
+ instanceRef.current = instance
295
+
296
+ return () => {
297
+ instanceRef.current = null
298
+ instance.destroy()
299
+ }
300
+ // Only the element decides how the instance is wired. Everything else is
301
+ // pushed with update() below, so that changing the layout mid-drag — the
302
+ // parent being resized under `resizable`, say — does not abort the drag.
303
+ }, [node])
304
+
305
+ // Runs after every render.
306
+ useEffect(() => {
307
+ latest.current = { layout, glissando, midiMax, onPlayNote, onStopNote }
308
+ instanceRef.current?.update({ layout, glissando, midiMax })
209
309
  })
210
- instanceRef.current = instance
211
310
 
212
- return () => {
213
- instanceRef.current = null
214
- instance.destroy()
311
+ useEffect(() => {
312
+ if (!resizable || !node) return
313
+ const parent = node.parentElement
314
+ if (!parent) throw new Error("doesn't have a parent element.")
315
+
316
+ const resizeObserver = new ResizeObserver(() => {
317
+ setResizedKeyWidth(
318
+ node.clientWidth / Math.max(whiteKeyCount, 1) - keyGap,
319
+ )
320
+ })
321
+ resizeObserver.observe(parent)
322
+ return () => resizeObserver.disconnect()
323
+ }, [resizable, node, whiteKeyCount, keyGap])
324
+
325
+ const shortcutKeys = keyboardShortcuts?.keys
326
+ const hasShortcuts = shortcutKeys !== undefined
327
+ // What the held keys were started against, as a value rather than the
328
+ // identity of the array: a caller who writes the keys inline hands over a
329
+ // new array on every render, and releasing on that would stop a note as
330
+ // soon as anything else re-renders — including the state change that
331
+ // playing the note caused.
332
+ const shortcutMapping = shortcutKeys?.join('\u0000')
333
+
334
+ useEffect(
335
+ () => releaseShortcutNotes,
336
+ [
337
+ releaseShortcutNotes,
338
+ shortcutMapping,
339
+ keyboardShortcutsScope,
340
+ noteRange.first,
341
+ noteRange.last,
342
+ ],
343
+ )
344
+
345
+ /** The note a shortcut key plays, or null when it has none. */
346
+ function shortcutNote(key: string) {
347
+ if (!shortcutKeys || key === '') return null
348
+ const index = shortcutKeys.indexOf(key)
349
+ const note = noteRange.first + index
350
+ return index === -1 || note > noteRange.last ? null : note
215
351
  }
216
- // Only the element decides how the instance is wired. Everything else is
217
- // pushed with update() below, so that changing the layout mid-drag — the
218
- // parent being resized under `fill`, say — does not abort the drag.
219
- }, [node])
220
-
221
- // Runs after every render.
222
- useEffect(() => {
223
- latest.current = { layout, glissando, midiMax, onPlayNote, onStopNote }
224
- instanceRef.current?.update({ layout, glissando, midiMax })
225
- })
226
-
227
- useEffect(() => {
228
- if (!fill || !node) return
229
- const parent = node.parentElement
230
- if (!parent) throw new Error("doesn't have a parent element.")
231
-
232
- const resizeObserver = new ResizeObserver(() => {
233
- setFilledKeyWidth(node.clientWidth / whiteKeyCount - keyGap)
352
+
353
+ const shortcutTarget = useMemo(
354
+ () => () => {
355
+ if (!hasShortcuts) return null
356
+ return keyboardShortcutsScope === 'window' ? globalThis.window : node
357
+ },
358
+ [hasShortcuts, keyboardShortcutsScope, node],
359
+ )
360
+
361
+ const focusOutTarget = useMemo(
362
+ () => () =>
363
+ hasShortcuts && keyboardShortcutsScope === 'root' ? node : null,
364
+ [hasShortcuts, keyboardShortcutsScope, node],
365
+ )
366
+
367
+ const windowBlurTarget = useMemo(
368
+ () => () => (hasShortcuts ? globalThis.window : null),
369
+ [hasShortcuts],
370
+ )
371
+
372
+ useEventListener(shortcutTarget, 'keydown', (e) => {
373
+ const key = shortcutKey(e)
374
+ if (e.repeat || shortcutNotes.current.has(key)) return
375
+ if (isEditableTarget(e.target)) return
376
+
377
+ const note = shortcutNote(e.key)
378
+ if (note === null) return
379
+
380
+ const source = `keyboard:${key}`
381
+ shortcutNotes.current.set(key, { note, source })
382
+ instanceRef.current?.noteOn(note, { source })
234
383
  })
235
- resizeObserver.observe(parent)
236
- return () => resizeObserver.disconnect()
237
- }, [fill, node, whiteKeyCount, keyGap])
238
-
239
- /** The note a shortcut key plays, or null when it has none. */
240
- function shortcutNote(key: string) {
241
- if (!keyboardShortcuts || key === '') return null
242
- const index = keyboardShortcuts.keys.indexOf(key)
243
- return index === -1 ? null : noteRange.first + index
244
- }
245
384
 
246
- useEventListener(globalThis.window, 'keydown', (e) => {
247
- if (e.repeat) return
248
- const note = shortcutNote(e.key)
249
- if (note !== null) instanceRef.current?.noteOn(note, { source: 'keyboard' })
250
- })
385
+ useEventListener(shortcutTarget, 'keyup', (e) => {
386
+ const key = shortcutKey(e)
387
+ const shortcut = shortcutNotes.current.get(key)
388
+ if (!shortcut) return
251
389
 
252
- useEventListener(globalThis.window, 'keyup', (e) => {
253
- const note = shortcutNote(e.key)
254
- if (note !== null)
255
- instanceRef.current?.noteOff(note, { source: 'keyboard' })
256
- })
390
+ shortcutNotes.current.delete(key)
391
+ instanceRef.current?.noteOff(shortcut.note, { source: shortcut.source })
392
+ })
257
393
 
258
- useImperativeHandle(
259
- forwardedRef,
260
- () => ({
261
- playNote: (note, velocity) =>
262
- instanceRef.current?.noteOn(note, { source: 'api', velocity }),
263
- stopNote: (note) => instanceRef.current?.noteOff(note, { source: 'api' }),
264
- }),
265
- [],
266
- )
267
-
268
- return (
269
- <div
270
- ref={setNode}
271
- className={clsx('tremolo-piano', className)}
272
- style={{
273
- width: fill ? '100%' : pianoWidth(layout),
274
- height,
275
- ...style,
276
- }}
277
- {...props}
278
- >
279
- {notes.map((note, index) => {
280
- const keyType = isWhiteKey(note) ? 'white' : 'black'
281
- const state: KeyState = {
282
- index,
283
- keyType,
284
- active: activeNotes.includes(note),
285
- disabled: note > midiMax,
286
- }
394
+ useEventListener(focusOutTarget, 'focusout', (e) => {
395
+ if (e.relatedTarget instanceof Node && node?.contains(e.relatedTarget))
396
+ return
397
+ releaseShortcutNotes()
398
+ })
287
399
 
288
- const {
289
- className: keyClassName,
290
- style: keyStyle,
291
- ...rest
292
- } = keyProps?.(note, state) ?? {}
293
-
294
- const content = label?.(note, state)
295
-
296
- return (
297
- <div
298
- key={note}
299
- className={clsx(`tremolo-piano-${keyType}-key`, keyClassName)}
300
- data-note={note}
301
- data-note-key={noteKey(note)}
302
- data-active={state.active}
303
- aria-disabled={state.disabled}
304
- {...rest}
305
- style={{
306
- ...keyStyle,
307
- left: notePosition(note, layout),
308
- width:
309
- keyType === 'white'
310
- ? layout.whiteKeyWidth
311
- : blackKeyWidth(layout),
312
- height:
313
- keyType === 'white' ? '100%' : `${blackKeyHeightRatio * 100}%`,
314
- }}
315
- >
316
- {content !== '' && content !== null && content !== undefined && (
317
- <div className="tremolo-piano-key-label-wrapper">
318
- <div className="tremolo-piano-key-label">{content}</div>
319
- </div>
320
- )}
321
- </div>
322
- )
323
- })}
324
- </div>
325
- )
326
- })
400
+ useEventListener(windowBlurTarget, 'blur', releaseShortcutNotes)
401
+
402
+ useImperativeHandle(
403
+ forwardedRef,
404
+ () => ({
405
+ playNote: (note, velocity) =>
406
+ instanceRef.current?.noteOn(note, { source: 'api', velocity }),
407
+ stopNote: (note) =>
408
+ instanceRef.current?.noteOff(note, { source: 'api' }),
409
+ }),
410
+ [],
411
+ )
412
+
413
+ return (
414
+ <div
415
+ ref={setNode}
416
+ className={className}
417
+ data-resizable={resizable ? '' : undefined}
418
+ role="group"
419
+ // The group can own keyboard shortcuts and must receive focus.
420
+ // oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex
421
+ tabIndex={0}
422
+ style={
423
+ {
424
+ // Computed from the layout rather than chosen, so it stays inline.
425
+ width: resizable ? '100%' : pianoWidth(layout),
426
+ '--height': cssLength(height),
427
+ // The keys inside are placed against this box.
428
+ position: 'relative',
429
+ ...style,
430
+ } as CSSProperties
431
+ }
432
+ {...props}
433
+ >
434
+ {notes.map((note, index) => {
435
+ const keyType = isWhiteKey(note) ? 'white' : 'black'
436
+ const state: KeyState = {
437
+ index,
438
+ keyType,
439
+ active: activeNotes.includes(note),
440
+ disabled: note > midiMax,
441
+ }
442
+
443
+ const {
444
+ className: keyClassName,
445
+ style: keyStyle,
446
+ ...rest
447
+ } = keyProps?.(note, state) ?? {}
448
+
449
+ const content = label?.(note, state)
450
+
451
+ return (
452
+ <div
453
+ key={note}
454
+ className={keyClassName}
455
+ data-note={note}
456
+ data-note-key={noteKey(note)}
457
+ data-active={state.active ? '' : undefined}
458
+ data-disabled={state.disabled ? '' : undefined}
459
+ {...rest}
460
+ style={{
461
+ // The key is placed and sized from the layout below, which
462
+ // needs it out of flow; the black keys sit over the white
463
+ // ones whichever order they are drawn in.
464
+ position: 'absolute',
465
+ zIndex: keyType === 'white' ? 1 : 2,
466
+ ...keyStyle,
467
+ left: notePosition(note, layout),
468
+ width:
469
+ keyType === 'white'
470
+ ? layout.whiteKeyWidth
471
+ : blackKeyWidth(layout),
472
+ height:
473
+ keyType === 'white'
474
+ ? '100%'
475
+ : `${blackKeyHeightRatio * 100}%`,
476
+ }}
477
+ >
478
+ {content !== '' && content !== null && content !== undefined && (
479
+ <div className={classes?.keyLabelWrapper}>
480
+ <div className={classes?.keyLabel}>{content}</div>
481
+ </div>
482
+ )}
483
+ </div>
484
+ )
485
+ })}
486
+ </div>
487
+ )
488
+ },
489
+ )
327
490
 
328
491
  /**
329
492
  * Customizable piano component.
330
493
  */
331
494
  export const Piano = { Root }
332
495
 
333
- export { type KeyboardShortcuts, SHORTCUTS } from './keyboardShortcuts'
496
+ export { type KeyboardShortcuts, SHORTCUTS } from './keyboard-shortcuts'