@tremolo-ui/react 0.4.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 (90) 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 +2272 -1650
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +1461 -475
  12. package/dist/index.d.cts.map +1 -1
  13. package/dist/index.d.ts +1461 -475
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2258 -1648
  16. package/dist/index.js.map +1 -1
  17. package/package.json +32 -31
  18. package/src/components/AnimationCanvas/index.tsx +162 -194
  19. package/src/components/Knob/ActiveLine.tsx +9 -8
  20. package/src/components/Knob/InactiveLine.tsx +11 -14
  21. package/src/components/Knob/SVGRoot.tsx +10 -17
  22. package/src/components/Knob/Thumb.tsx +33 -11
  23. package/src/components/Knob/context.tsx +73 -89
  24. package/src/components/Knob/index.tsx +227 -105
  25. package/src/components/NumberInput/DecrementStepper.tsx +21 -50
  26. package/src/components/NumberInput/IncrementStepper.tsx +22 -50
  27. package/src/components/NumberInput/InputField.tsx +206 -0
  28. package/src/components/NumberInput/Stepper.tsx +150 -21
  29. package/src/components/NumberInput/StepperButton.tsx +95 -0
  30. package/src/components/NumberInput/context.tsx +104 -149
  31. package/src/components/NumberInput/index.tsx +461 -110
  32. package/src/components/Piano/index.tsx +413 -263
  33. package/src/components/Piano/keyboard-shortcuts.ts +61 -0
  34. package/src/components/PointsEditor/Background.tsx +19 -4
  35. package/src/components/PointsEditor/Container.tsx +92 -22
  36. package/src/components/PointsEditor/Point.tsx +293 -53
  37. package/src/components/PointsEditor/SelectionBox.tsx +54 -0
  38. package/src/components/PointsEditor/context.tsx +97 -75
  39. package/src/components/PointsEditor/index.tsx +497 -62
  40. package/src/components/Slider/Marks.tsx +84 -0
  41. package/src/components/Slider/MarksOption.tsx +115 -0
  42. package/src/components/Slider/Thumb.tsx +99 -54
  43. package/src/components/Slider/Track.tsx +73 -62
  44. package/src/components/Slider/context.tsx +40 -53
  45. package/src/components/Slider/index.tsx +237 -190
  46. package/src/components/Slider/type.ts +27 -9
  47. package/src/components/XYPad/Area.tsx +52 -33
  48. package/src/components/XYPad/Thumb.tsx +121 -60
  49. package/src/components/XYPad/context.tsx +49 -0
  50. package/src/components/XYPad/index.tsx +338 -246
  51. package/src/components/_util/Placement.tsx +68 -0
  52. package/src/components/_util/VisuallyHiddenRangeInput.tsx +24 -0
  53. package/src/components/_util/css-length.ts +13 -0
  54. package/src/{components/_util/composeRefs.tsx → compose-refs/index.tsx} +33 -5
  55. package/src/hooks/{useCallbackRef.ts → _internal/useCallbackRef.ts} +3 -1
  56. package/src/hooks/_internal/useCheckSteps.ts +170 -0
  57. package/src/hooks/useAnimationFrame.ts +17 -7
  58. package/src/hooks/useDrag.ts +39 -9
  59. package/src/hooks/useDragValue.ts +194 -0
  60. package/src/hooks/useEventListener.ts +11 -7
  61. package/src/hooks/useLongPress.ts +42 -8
  62. package/src/hooks/useMIDIAccess.ts +82 -11
  63. package/src/hooks/useMIDIInput.ts +45 -13
  64. package/src/hooks/useMIDIMessage.ts +23 -5
  65. package/src/hooks/useWheel.ts +29 -9
  66. package/src/index.ts +50 -27
  67. package/src/input-event.ts +32 -0
  68. package/dist/index.css +0 -536
  69. package/src/Globals.d.ts +0 -3
  70. package/src/components/AnimationCanvas/canvas.ts +0 -46
  71. package/src/components/Knob/index.css +0 -77
  72. package/src/components/NumberInput/InternalInput.tsx +0 -216
  73. package/src/components/NumberInput/index.css +0 -185
  74. package/src/components/NumberInput/type.ts +0 -58
  75. package/src/components/Piano/KeyLabel.tsx +0 -51
  76. package/src/components/Piano/context.tsx +0 -73
  77. package/src/components/Piano/index.css +0 -90
  78. package/src/components/Piano/key.tsx +0 -205
  79. package/src/components/Piano/keyboardShortcuts.ts +0 -31
  80. package/src/components/PointsEditor/index.css +0 -30
  81. package/src/components/Slider/Scale.tsx +0 -52
  82. package/src/components/Slider/ScaleOption.tsx +0 -103
  83. package/src/components/Slider/index.css +0 -112
  84. package/src/components/XYPad/index.css +0 -48
  85. package/src/components/_util/index.ts +0 -28
  86. package/src/components/_util/type.ts +0 -1
  87. package/src/hooks/useDragWithElement.ts +0 -100
  88. package/src/hooks/usePianoDrag.ts +0 -75
  89. package/src/hooks/useRefCallbackEvent.ts +0 -34
  90. package/src/styles/global.css +0 -19
@@ -1,12 +1,8 @@
1
- import clsx from 'clsx'
2
- import React, {
1
+ import {
3
2
  ComponentPropsWithoutRef,
4
- createRef,
5
3
  CSSProperties,
6
4
  forwardRef,
7
- ReactElement,
8
5
  ReactNode,
9
- RefObject,
10
6
  useCallback,
11
7
  useEffect,
12
8
  useImperativeHandle,
@@ -15,62 +11,190 @@ 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'
27
+ import { cssLength } from '../_util/css-length'
28
28
 
29
- import { NoteRange, PianoProvider } from './context'
30
- import {
31
- BlackKey,
32
- defaultBlackKeyWidth,
33
- defaultWhiteKeyWidth,
34
- KeyMethods,
35
- WhiteKey,
36
- } from './key'
37
- import { KeyboardShortcuts } from './keyboardShortcuts'
38
- import { KeyLabel } from './KeyLabel'
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
+ }
39
50
 
40
51
  /**
41
- * [noteRange.first, noteRange.first + 1 ..., noteRange.last]
52
+ * `style` that also takes CSS custom properties, which is how a key's colours
53
+ * are set: see `index.css` for the ones each key type reads.
42
54
  */
43
- export function getNoteRangeArray(noteRange: NoteRange) {
44
- return Array.from(
45
- { length: noteRange.last - noteRange.first + 1 },
46
- (_, i) => i + noteRange.first,
47
- )
55
+ export type CSSVariables = Record<`--${string}`, string | number | undefined>
56
+
57
+ /**
58
+ * What {@link PianoProps.keyProps} may return for one key.
59
+ *
60
+ * `data-*` attributes are spelled out because TypeScript only allows them on
61
+ * JSX syntax, not on an object type, and selecting on one is the usual way to
62
+ * mark a key out.
63
+ */
64
+ export type KeyAttributes = Omit<ComponentPropsWithoutRef<'div'>, 'style'> & {
65
+ style?: CSSProperties & CSSVariables
66
+ } & Record<`data-${string}`, string | number | boolean | undefined>
67
+
68
+ /** What a key is, when {@link PianoProps.label} or `keyProps` is asked about it. */
69
+ export interface KeyState {
70
+ /** Position in the note range, counting from `noteRange.first`. */
71
+ index: number
72
+ keyType: 'white' | 'black'
73
+ /** Whether the note is currently sounding. */
74
+ active: boolean
75
+ /** Whether the note is above {@link PianoProps.midiMax} and cannot sound. */
76
+ disabled: boolean
48
77
  }
49
78
 
50
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
+ */
51
94
  noteRange: NoteRange
52
95
 
96
+ /**
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.
99
+ *
100
+ * @default true
101
+ */
53
102
  glissando?: boolean
103
+
104
+ /**
105
+ * Highest note that can sound. Keys above it carry `data-disabled`.
106
+ *
107
+ * @default 127
108
+ */
54
109
  midiMax?: number
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
+ */
55
116
  keyboardShortcuts?: KeyboardShortcuts
56
- fill?: boolean
57
- whiteNoteWidth?: number
58
- blackNoteWidth?: number
117
+
118
+ /**
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.
130
+ *
131
+ * @default false
132
+ */
133
+ resizable?: boolean
134
+
135
+ /**
136
+ * Width of a white key in pixels, not counting `keyGap`. Ignored while
137
+ * `resizable` is on.
138
+ * @default 40
139
+ */
140
+ whiteKeyWidth?: number
141
+ /**
142
+ * Space between two white keys, in pixels.
143
+ * @default 1
144
+ */
145
+ keyGap?: number
146
+ /**
147
+ * Width of a black key, as a fraction of {@link PianoProps.whiteKeyWidth}.
148
+ * @default 0.65
149
+ */
150
+ blackKeyWidthRatio?: number
151
+ /**
152
+ * Height of a black key, as a fraction of the height of the keyboard.
153
+ * @default 0.6
154
+ */
155
+ blackKeyHeightRatio?: number
156
+
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
+ */
59
162
  height?: number | string
60
- style?: CSSProperties
61
163
 
62
- onPlayNote?: (note: number, velocity?: number) => void
63
- onStopNote?: (note: number) => void
164
+ style?: CSSProperties & CSSVariables
64
165
 
65
- label?: (note: number, index: number) => ReactNode
166
+ /**
167
+ * What to draw inside a key. `''`, `null` and `undefined` leave it bare, so
168
+ * a layout with gaps — {@link SHORTCUTS.HOME_ROW_NATURAL}, say — needs no
169
+ * special casing.
170
+ */
171
+ label?: (note: number, state: KeyState) => ReactNode
66
172
 
67
173
  /**
68
- * \<WhiteKey /> | \<BlackKey />
174
+ * Extra props for one key, by note: a class, a style, a `data-*` attribute
175
+ * to select on.
176
+ *
177
+ * The geometry of the key (`left`, `width`, `height`) is applied after the
178
+ * returned `style` and cannot be overridden, so a key cannot be drawn
179
+ * somewhere other than where it responds.
180
+ *
181
+ * @example highlight the notes of a scale
182
+ * ```tsx
183
+ * keyProps={(note) => ({ 'data-in-scale': inScale(note, root, 'major') ? '' : undefined })}
184
+ * ```
69
185
  */
70
- children?: ReactElement | ReactElement[]
71
- }
186
+ keyProps?: (note: number, state: KeyState) => KeyAttributes
72
187
 
73
- const blackPerWhiteWidth = defaultBlackKeyWidth / defaultWhiteKeyWidth // 0.65
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
+ */
194
+ onPlayNote?: (note: number, velocity?: number) => void
195
+ /** Called once everything holding a note has let go of it. */
196
+ onStopNote?: (note: number) => void
197
+ }
74
198
 
75
199
  export interface PianoMethods {
76
200
  playNote: (note: number, velocity?: number) => void
@@ -80,261 +204,289 @@ export interface PianoMethods {
80
204
  type Props = PianoProps &
81
205
  Omit<ComponentPropsWithoutRef<'div'>, keyof PianoProps>
82
206
 
83
- const Root = forwardRef<PianoMethods, Props>(
84
- (
207
+ export const Root = /* @__PURE__ */ forwardRef<PianoMethods, Props>(
208
+ function Root(
85
209
  {
86
210
  noteRange,
87
211
  glissando = true,
88
212
  midiMax = 127,
89
213
  keyboardShortcuts,
90
- fill = false,
91
- height = fill ? '100%' : 160,
92
- whiteNoteWidth: _whiteNoteWidth = defaultWhiteKeyWidth,
214
+ keyboardShortcutsScope = 'root',
215
+ resizable = false,
216
+ whiteKeyWidth = 40,
217
+ keyGap = 1,
218
+ blackKeyWidthRatio = 0.65,
219
+ blackKeyHeightRatio = 0.6,
220
+ height,
93
221
  style,
94
222
  className,
223
+ label,
224
+ keyProps,
225
+ classes,
95
226
  onPlayNote,
96
227
  onStopNote,
97
- label,
98
- children,
99
- onPointerDown,
100
228
  ...props
101
229
  },
102
230
  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
- }
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)
154
238
 
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],
239
+ const notes = useMemo(() => getNoteRangeArray(noteRange), [noteRange])
240
+ const whiteKeyCount = useMemo(
241
+ () => notes.filter(isWhiteKey).length,
242
+ [notes],
170
243
  )
171
244
 
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],
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
+ ],
191
262
  )
192
263
 
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],
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,
272
+ })
273
+ const instanceRef = useRef<PianoInputInstance | null>(null)
274
+ const shortcutNotes = useRef(
275
+ new Map<string, { note: number; source: string }>(),
211
276
  )
212
277
 
213
- const onDragEnd = useCallback(() => {
214
- if (keyRefs.current[hitKeyIndex.current]?.current?.played()) {
215
- keyRefs.current[hitKeyIndex.current]?.current?.stop()
278
+ const releaseShortcutNotes = useCallback(() => {
279
+ for (const { note, source } of shortcutNotes.current.values()) {
280
+ instanceRef.current?.noteOff(note, { source })
216
281
  }
217
- hitKeyIndex.current = -1
218
- }, [keyRefs])
282
+ shortcutNotes.current.clear()
283
+ }, [])
219
284
 
220
- // --- hooks ---
221
285
  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,
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,
245
296
  })
297
+ instanceRef.current = instance
246
298
 
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
- }
299
+ return () => {
300
+ instanceRef.current = null
301
+ instance.destroy()
255
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 })
256
312
  })
257
313
 
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
- }
314
+ useEffect(() => {
315
+ if (!resizable || !node) return
316
+ const parent = node.parentElement
317
+ if (!parent) throw new Error("doesn't have a parent element.")
318
+
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
354
+ }
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 })
265
386
  })
266
387
 
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])
388
+ useEventListener(shortcutTarget, 'keyup', (e) => {
389
+ const key = shortcutKey(e)
390
+ const shortcut = shortcutNotes.current.get(key)
391
+ if (!shortcut) return
392
+
393
+ shortcutNotes.current.delete(key)
394
+ instanceRef.current?.noteOff(shortcut.note, { source: shortcut.source })
395
+ })
396
+
397
+ useEventListener(focusOutTarget, 'focusout', (e) => {
398
+ if (e.relatedTarget instanceof Node && node?.contains(e.relatedTarget))
399
+ return
400
+ releaseShortcutNotes()
401
+ })
402
+
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
+ )
289
415
 
290
416
  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,
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',
310
432
  ...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
- ),
335
- )}
336
- </div>
337
- </PianoProvider>
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>
338
490
  )
339
491
  },
340
492
  )
@@ -342,8 +494,6 @@ const Root = forwardRef<PianoMethods, Props>(
342
494
  /**
343
495
  * Customizable piano component.
344
496
  */
345
- export const Piano = { Root, WhiteKey, BlackKey, KeyLabel }
497
+ export const Piano = { Root }
346
498
 
347
- export { type KeyboardShortcuts, SHORTCUTS } from './keyboardShortcuts'
348
- export { type KeyProps, type KeyMethods } from './key'
349
- export { type KeyLabelProps } from './KeyLabel'
499
+ export { type KeyboardShortcuts, SHORTCUTS } from './keyboard-shortcuts'