@tremolo-ui/react 0.3.0 → 0.5.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 (64) hide show
  1. package/dist/index.cjs +1162 -1554
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +58 -93
  4. package/dist/index.d.cts +592 -278
  5. package/dist/index.d.cts.map +1 -1
  6. package/dist/index.d.ts +592 -278
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1163 -1555
  9. package/dist/index.js.map +1 -1
  10. package/package.json +12 -13
  11. package/src/components/AnimationCanvas/index.tsx +84 -155
  12. package/src/components/Knob/ActiveLine.tsx +6 -6
  13. package/src/components/Knob/InactiveLine.tsx +9 -11
  14. package/src/components/Knob/SVGRoot.tsx +9 -18
  15. package/src/components/Knob/context.tsx +57 -89
  16. package/src/components/Knob/index.css +6 -2
  17. package/src/components/Knob/index.tsx +95 -83
  18. package/src/components/NumberInput/DecrementStepper.tsx +17 -50
  19. package/src/components/NumberInput/IncrementStepper.tsx +17 -50
  20. package/src/components/NumberInput/InputField.tsx +123 -0
  21. package/src/components/NumberInput/Stepper.tsx +90 -12
  22. package/src/components/NumberInput/context.tsx +74 -146
  23. package/src/components/NumberInput/index.css +38 -102
  24. package/src/components/NumberInput/index.tsx +252 -98
  25. package/src/components/NumberInput/stepperButton.tsx +90 -0
  26. package/src/components/Piano/index.tsx +272 -288
  27. package/src/components/Piano/keyboardShortcuts.ts +34 -4
  28. package/src/components/PointsEditor/Background.tsx +8 -2
  29. package/src/components/PointsEditor/Container.tsx +19 -9
  30. package/src/components/PointsEditor/Point.tsx +117 -52
  31. package/src/components/PointsEditor/context.tsx +28 -74
  32. package/src/components/PointsEditor/index.css +5 -0
  33. package/src/components/PointsEditor/index.tsx +77 -43
  34. package/src/components/Slider/{Scale.tsx → Marks.tsx} +8 -8
  35. package/src/components/Slider/{ScaleOption.tsx → MarksOption.tsx} +16 -18
  36. package/src/components/Slider/Thumb.tsx +55 -68
  37. package/src/components/Slider/Track.tsx +15 -15
  38. package/src/components/Slider/context.tsx +36 -51
  39. package/src/components/Slider/index.css +18 -5
  40. package/src/components/Slider/index.tsx +136 -170
  41. package/src/components/Slider/type.ts +4 -4
  42. package/src/components/XYPad/Area.tsx +16 -7
  43. package/src/components/XYPad/Thumb.tsx +62 -74
  44. package/src/components/XYPad/context.tsx +46 -0
  45. package/src/components/XYPad/index.css +12 -0
  46. package/src/components/XYPad/index.tsx +193 -250
  47. package/src/hooks/useDrag.ts +41 -76
  48. package/src/hooks/useDragValue.ts +149 -0
  49. package/src/hooks/useEventListener.ts +1 -1
  50. package/src/hooks/useWheel.ts +51 -0
  51. package/src/index.ts +15 -13
  52. package/src/components/AnimationCanvas/canvas.ts +0 -46
  53. package/src/components/DragObserver/index.tsx +0 -58
  54. package/src/components/NumberInput/InternalInput.tsx +0 -221
  55. package/src/components/NumberInput/type.ts +0 -58
  56. package/src/components/Piano/KeyLabel.tsx +0 -51
  57. package/src/components/Piano/context.tsx +0 -73
  58. package/src/components/Piano/key.tsx +0 -205
  59. package/src/components/WheelObserver/index.tsx +0 -48
  60. package/src/components/_util/type.ts +0 -1
  61. package/src/hooks/useDragWithElement.ts +0 -72
  62. package/src/hooks/usePianoDrag.ts +0 -75
  63. package/src/hooks/useRefCallbackEvent.ts +0 -34
  64. /package/src/hooks/{useCallbackRef.ts → _internal/useCallbackRef.ts} +0 -0
@@ -1,13 +1,9 @@
1
1
  import clsx from 'clsx'
2
- import React, {
2
+ import {
3
3
  ComponentPropsWithoutRef,
4
- createRef,
5
4
  CSSProperties,
6
5
  forwardRef,
7
- ReactElement,
8
6
  ReactNode,
9
- RefObject,
10
- useCallback,
11
7
  useEffect,
12
8
  useImperativeHandle,
13
9
  useMemo,
@@ -15,62 +11,125 @@ import React, {
15
11
  useState,
16
12
  } from 'react'
17
13
 
14
+ import { createPianoInput, type PianoInputInstance } from '@tremolo-ui/dom'
18
15
  import {
19
- isBlackKey,
16
+ blackKeyWidth,
17
+ getNoteRangeArray,
20
18
  isWhiteKey,
21
19
  noteKey,
22
- NoteKey,
23
- noteKeys,
20
+ notePosition,
21
+ pianoWidth,
22
+ type NoteRange,
23
+ type PianoLayout,
24
24
  } from '@tremolo-ui/functions'
25
25
 
26
26
  import { useEventListener } from '../../hooks/useEventListener'
27
- import { usePianoDrag } from '../../hooks/usePianoDrag'
28
27
 
29
- import { NoteRange, PianoProvider } from './context'
30
- import {
31
- BlackKey,
32
- defaultBlackKeyWidth,
33
- defaultWhiteKeyWidth,
34
- KeyMethods,
35
- WhiteKey,
36
- } from './key'
37
28
  import { KeyboardShortcuts } from './keyboardShortcuts'
38
- import { KeyLabel } from './KeyLabel'
39
29
 
40
30
  /**
41
- * [noteRange.first, noteRange.first + 1 ..., noteRange.last]
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.
42
33
  */
43
- export function getNoteRangeArray(noteRange: NoteRange) {
44
- return Array.from(
45
- { length: noteRange.last - noteRange.first + 1 },
46
- (_, i) => i + noteRange.first,
47
- )
34
+ export type CSSVariables = Record<`--${string}`, string | number | undefined>
35
+
36
+ /**
37
+ * What {@link PianoProps.keyProps} may return for one key.
38
+ *
39
+ * `data-*` attributes are spelled out because TypeScript only allows them on
40
+ * JSX syntax, not on an object type, and selecting on one is the usual way to
41
+ * mark a key out.
42
+ */
43
+ export type KeyAttributes = Omit<ComponentPropsWithoutRef<'div'>, 'style'> & {
44
+ style?: CSSProperties & CSSVariables
45
+ } & Record<`data-${string}`, string | number | boolean | undefined>
46
+
47
+ /** What a key is, when {@link PianoProps.label} or `keyProps` is asked about it. */
48
+ export interface KeyState {
49
+ /** Position in the note range, counting from `noteRange.first`. */
50
+ index: number
51
+ keyType: 'white' | 'black'
52
+ /** Whether the note is currently sounding. */
53
+ active: boolean
54
+ /** Whether the note is above {@link PianoProps.midiMax} and cannot sound. */
55
+ disabled: boolean
48
56
  }
49
57
 
50
58
  export interface PianoProps {
51
59
  noteRange: NoteRange
52
60
 
61
+ /**
62
+ * Let a pointer slide from one key to the next while it is down.
63
+ *
64
+ * @default true
65
+ */
53
66
  glissando?: boolean
67
+
68
+ /**
69
+ * Highest note that can sound. Keys above it are drawn `aria-disabled`.
70
+ *
71
+ * @default 127
72
+ */
54
73
  midiMax?: number
74
+
55
75
  keyboardShortcuts?: KeyboardShortcuts
76
+
77
+ /**
78
+ * Fill the parent element, deriving the width of a white key from it.
79
+ * {@link PianoProps.whiteKeyWidth} is ignored.
80
+ *
81
+ * @default false
82
+ */
56
83
  fill?: boolean
57
- whiteNoteWidth?: number
58
- blackNoteWidth?: number
84
+
85
+ /** @default 40 */
86
+ whiteKeyWidth?: number
87
+ /**
88
+ * Space between two white keys.
89
+ * @default 1
90
+ */
91
+ keyGap?: number
92
+ /**
93
+ * Width of a black key, as a fraction of {@link PianoProps.whiteKeyWidth}.
94
+ * @default 0.65
95
+ */
96
+ blackKeyWidthRatio?: number
97
+ /**
98
+ * Height of a black key, as a fraction of the height of the keyboard.
99
+ * @default 0.6
100
+ */
101
+ blackKeyHeightRatio?: number
102
+
103
+ /** @default `fill ? '100%' : 160` */
59
104
  height?: number | string
60
- style?: CSSProperties
61
105
 
62
- onPlayNote?: (note: number, velocity?: number) => void
63
- onStopNote?: (note: number) => void
106
+ style?: CSSProperties & CSSVariables
64
107
 
65
- label?: (note: number, index: number) => ReactNode
108
+ /**
109
+ * What to draw inside a key. `''`, `null` and `undefined` leave it bare, so
110
+ * a layout with gaps — {@link SHORTCUTS.HOME_ROW_NATURAL}, say — needs no
111
+ * special casing.
112
+ */
113
+ label?: (note: number, state: KeyState) => ReactNode
66
114
 
67
115
  /**
68
- * \<WhiteKey /> | \<BlackKey />
116
+ * Extra props for one key, by note: a class, a style, a `data-*` attribute
117
+ * to select on.
118
+ *
119
+ * The geometry of the key (`left`, `width`, `height`) is applied after the
120
+ * returned `style` and cannot be overridden, so a key cannot be drawn
121
+ * somewhere other than where it responds.
122
+ *
123
+ * @example highlight the notes of a scale
124
+ * ```tsx
125
+ * keyProps={(note) => ({ 'data-in-scale': inScale(note, root, 'major') })}
126
+ * ```
69
127
  */
70
- children?: ReactElement | ReactElement[]
71
- }
128
+ keyProps?: (note: number, state: KeyState) => KeyAttributes
72
129
 
73
- const blackPerWhiteWidth = defaultBlackKeyWidth / defaultWhiteKeyWidth // 0.65
130
+ onPlayNote?: (note: number, velocity?: number) => void
131
+ onStopNote?: (note: number) => void
132
+ }
74
133
 
75
134
  export interface PianoMethods {
76
135
  playNote: (note: number, velocity?: number) => void
@@ -80,270 +139,195 @@ export interface PianoMethods {
80
139
  type Props = PianoProps &
81
140
  Omit<ComponentPropsWithoutRef<'div'>, keyof PianoProps>
82
141
 
83
- const Root = forwardRef<PianoMethods, Props>(
84
- (
85
- {
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
+ () => ({
86
176
  noteRange,
87
- glissando = true,
88
- midiMax = 127,
89
- keyboardShortcuts,
90
- fill = false,
91
- height = fill ? '100%' : 160,
92
- whiteNoteWidth: _whiteNoteWidth = defaultWhiteKeyWidth,
93
- style,
94
- className,
95
- onPlayNote,
96
- onStopNote,
97
- label,
98
- children,
99
- onPointerDown,
100
- ...props
101
- },
102
- forwardedRef,
103
- ) => {
104
- // -- state and ref ---
105
- const [whiteNoteWidth, setWhiteNoteWidth] = useState(_whiteNoteWidth)
106
- const keyRefs = useRef<RefObject<KeyMethods | null>[]>([])
107
- for (let i = 0; i < noteRange.last - noteRange.first + 1; i++) {
108
- keyRefs.current[i] = createRef<KeyMethods>()
109
- }
110
- const pianoRef = useRef<HTMLDivElement>(null)
111
- const hitKeyIndex = useRef(-1)
112
-
113
- // --- interpret props ---
114
- const noteRangeArray = getNoteRangeArray(noteRange)
115
- const whiteNotes = noteRangeArray.filter((v) => isWhiteKey(v))
116
- const blackNotes = noteRangeArray.filter((v) => isBlackKey(v))
117
- const whiteNoteCount = whiteNotes.length
118
- const padding = 1
119
- const staticWidth = (whiteNoteWidth + padding) * whiteNoteCount
120
- // const blackNoteShiftPercent = 0.3
121
-
122
- const childrenWithProps = useMemo(() => {
123
- return (
124
- children &&
125
- React.Children.map(children, (child, index) => {
126
- if (React.isValidElement(child)) {
127
- const __width =
128
- child.type == WhiteKey ? whiteNoteWidth : whiteNoteWidth * 0.65
129
- const props = { ref: keyRefs.current[index], __width }
130
- return React.cloneElement(child, props)
131
- }
132
- return child
133
- })
134
- )
135
- }, [children, whiteNoteWidth])
136
-
137
- // --- internal functions ---
138
- const notePosition = useCallback(
139
- (note: number) => {
140
- const pitchPositions: Record<NoteKey, number> = {
141
- C: 0,
142
- 'C#': 1,
143
- D: 1,
144
- 'D#': 2,
145
- E: 2,
146
- F: 3,
147
- 'F#': 4,
148
- G: 4,
149
- 'G#': 5,
150
- A: 5,
151
- 'A#': 6,
152
- B: 6,
153
- }
177
+ whiteKeyWidth: fill ? filledKeyWidth : whiteKeyWidth,
178
+ keyGap,
179
+ blackKeyWidthRatio,
180
+ blackKeyHeightRatio,
181
+ }),
182
+ [
183
+ noteRange,
184
+ fill,
185
+ filledKeyWidth,
186
+ whiteKeyWidth,
187
+ keyGap,
188
+ blackKeyWidthRatio,
189
+ blackKeyHeightRatio,
190
+ ],
191
+ )
154
192
 
155
- const blackNoteWidth = whiteNoteWidth * blackPerWhiteWidth
156
- const padding = 1
157
- const targetNoteKey = noteKey(note)
158
- const firstNoteKey = noteKey(noteRange.first)
159
- const octave = Math.floor((note - noteRange.first) / 12)
160
- const octaveOffset =
161
- noteKeys.indexOf(firstNoteKey) > noteKeys.indexOf(targetNoteKey)
162
- ? 1
163
- : 0
164
- const w = whiteNoteWidth + padding
165
- const pos = pitchPositions[targetNoteKey] - pitchPositions[firstNoteKey]
166
- const blackKeyOffset = isBlackKey(note) ? blackNoteWidth / 2 : 0
167
- return pos * w + (octave + octaveOffset) * 7 * w - blackKeyOffset
168
- },
169
- [noteRange.first, whiteNoteWidth],
170
- )
171
-
172
- const getHitKeyIndex = useCallback(
173
- (x: number, y: number) => {
174
- if (!pianoRef.current) return -1
175
- const containerHeight = pianoRef.current.clientHeight
176
- const notes = [...blackNotes, ...whiteNotes]
177
- for (let i = 0; i < notes.length; i++) {
178
- const note = notes[i]
179
- const pos = notePosition(note)
180
- const w = isWhiteKey(note)
181
- ? whiteNoteWidth
182
- : whiteNoteWidth * blackPerWhiteWidth
183
- const h = isWhiteKey(note) ? containerHeight : containerHeight * 0.6
184
- if (pos <= x && x < pos + w && 0 <= y && y < h) {
185
- return note
186
- }
187
- }
188
- return -1
189
- },
190
- [blackNotes, notePosition, whiteNoteWidth, whiteNotes],
191
- )
192
-
193
- // TODO: 単一のポインターに対しては、useDragで対応可能だが、
194
- // マルチタッチに対しては、TouchEventを使う必要がありそう
195
- // 取り敢えず、シングルタッチだけ対応
196
- const onDrag = useCallback(
197
- (perX: number, perY: number) => {
198
- if (!pianoRef.current) return
199
- const x = perX * staticWidth
200
- const y = perY * pianoRef.current.clientHeight
201
- const note = getHitKeyIndex(x, y)
202
- const index = noteRangeArray.indexOf(note)
203
- if (index == -1) return
204
- if (hitKeyIndex.current != index) {
205
- keyRefs.current[hitKeyIndex.current]?.current?.stop()
206
- keyRefs.current[index]?.current?.play()
207
- hitKeyIndex.current = index
208
- }
209
- },
210
- [getHitKeyIndex, noteRangeArray, staticWidth],
211
- )
212
-
213
- const onDragEnd = useCallback(() => {
214
- if (keyRefs.current[hitKeyIndex.current]?.current?.played()) {
215
- keyRefs.current[hitKeyIndex.current]?.current?.stop()
216
- }
217
- hitKeyIndex.current = -1
218
- }, [keyRefs])
219
-
220
- // --- hooks ---
221
- useEffect(() => {
222
- if (fill && pianoRef.current) {
223
- const parent = pianoRef.current.parentElement
224
- if (!parent) throw new Error("doesn't have a parent element.")
225
- const resizeObserver = new ResizeObserver(() => {
226
- const w = pianoRef.current!.clientWidth
227
- setWhiteNoteWidth(w / whiteNoteCount - padding)
228
- })
229
- resizeObserver.observe(parent)
230
- return () => {
231
- resizeObserver.unobserve(parent)
232
- }
233
- } else {
234
- setWhiteNoteWidth(_whiteNoteWidth)
235
- }
236
- }, [fill, _whiteNoteWidth, whiteNoteCount])
237
-
238
- // FIXME
239
- const { refHandler: touchMoveRefCallback, pointerDownHandler } =
240
- usePianoDrag<HTMLDivElement>({
241
- baseElementRef: pianoRef,
242
- onDrag: onDrag,
243
- // onDragStart: onDrag,
244
- onDragEnd: onDragEnd,
245
- })
246
-
247
- useEventListener(globalThis.window, 'keydown', (e) => {
248
- if (e.repeat) return
249
- if (!keyboardShortcuts) return
250
- const index = keyboardShortcuts.keys.indexOf(e.key)
251
- if (index != -1) {
252
- if (!keyRefs.current[index]?.current?.played()) {
253
- keyRefs.current[index]?.current?.play()
254
- }
255
- }
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,
256
209
  })
210
+ instanceRef.current = instance
257
211
 
258
- useEventListener(globalThis.window, 'keyup', (e) => {
259
- if (e.repeat) return
260
- if (!keyboardShortcuts) return
261
- const index = keyboardShortcuts.keys.indexOf(e.key)
262
- if (index != -1) {
263
- keyRefs.current[index]?.current?.stop()
264
- }
212
+ return () => {
213
+ instanceRef.current = null
214
+ instance.destroy()
215
+ }
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)
265
234
  })
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
+ }
266
245
 
267
- useImperativeHandle(forwardedRef, () => {
268
- return {
269
- playNote(note, velocity) {
270
- const index = noteRangeArray.indexOf(note)
271
- if (index != -1) {
272
- if (!keyRefs.current[index]?.current?.played()) {
273
- keyRefs.current[index]?.current?.play(velocity)
274
- }
275
- } else {
276
- onPlayNote?.(note, velocity)
277
- }
278
- },
279
- stopNote(note: number) {
280
- const index = noteRangeArray.indexOf(note)
281
- if (index != -1) {
282
- keyRefs.current[index]?.current?.stop()
283
- } else {
284
- onStopNote?.(note)
285
- }
286
- },
287
- }
288
- }, [noteRangeArray, onPlayNote, onStopNote])
289
-
290
- return (
291
- <PianoProvider
292
- notePosition={notePosition}
293
- noteRange={noteRange}
294
- glissando={glissando}
295
- midiMax={midiMax}
296
- fill={fill}
297
- onPlayNote={onPlayNote}
298
- onStopNote={onStopNote}
299
- label={label}
300
- >
301
- <div
302
- ref={(div) => {
303
- pianoRef.current = div
304
- touchMoveRefCallback(div)
305
- }}
306
- className={clsx('tremolo-piano', className)}
307
- style={{
308
- width: fill ? '100%' : staticWidth,
309
- height: height,
310
- ...style,
311
- }}
312
- onPointerDown={(event) => {
313
- pointerDownHandler(event)
314
- onPointerDown?.(event)
315
- }}
316
- {...props}
317
- >
318
- {childrenWithProps ||
319
- noteRangeArray.map((note, index) =>
320
- isWhiteKey(note) ? (
321
- <WhiteKey
322
- ref={keyRefs.current[index]}
323
- key={note}
324
- noteNumber={note}
325
- __width={whiteNoteWidth}
326
- />
327
- ) : (
328
- <BlackKey
329
- ref={keyRefs.current[index]}
330
- key={note}
331
- noteNumber={note}
332
- __width={whiteNoteWidth * blackPerWhiteWidth}
333
- />
334
- ),
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
+ })
251
+
252
+ useEventListener(globalThis.window, 'keyup', (e) => {
253
+ const note = shortcutNote(e.key)
254
+ if (note !== null)
255
+ instanceRef.current?.noteOff(note, { source: 'keyboard' })
256
+ })
257
+
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
+ }
287
+
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>
335
320
  )}
336
- </div>
337
- </PianoProvider>
338
- )
339
- },
340
- )
321
+ </div>
322
+ )
323
+ })}
324
+ </div>
325
+ )
326
+ })
341
327
 
342
328
  /**
343
329
  * Customizable piano component.
344
330
  */
345
- export const Piano = { Root, WhiteKey, BlackKey, KeyLabel }
331
+ export const Piano = { Root }
346
332
 
347
333
  export { type KeyboardShortcuts, SHORTCUTS } from './keyboardShortcuts'
348
- export { type KeyProps, type KeyMethods } from './key'
349
- export { type KeyLabelProps } from './KeyLabel'
@@ -1,12 +1,20 @@
1
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
+ */
2
10
  keys: string[]
3
- // TODO
4
- flags?: {
5
- naturalOnly?: boolean
6
- }
7
11
  }
8
12
 
13
+ /**
14
+ * Ready-made keyboard layouts. Both assume `noteRange.first` is a C.
15
+ */
9
16
  export const SHORTCUTS = {
17
+ /** Every semitone from C, over the two rows of a QWERTY keyboard. */
10
18
  HOME_ROW: {
11
19
  keys: [
12
20
  'a',
@@ -28,4 +36,26 @@ export const SHORTCUTS = {
28
36
  ';',
29
37
  ],
30
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
+ },
31
61
  }