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