@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.
- package/dist/compose-refs.cjs +72 -0
- package/dist/compose-refs.cjs.map +1 -0
- package/dist/compose-refs.d.cts +43 -0
- package/dist/compose-refs.d.cts.map +1 -0
- package/dist/compose-refs.d.ts +43 -0
- package/dist/compose-refs.d.ts.map +1 -0
- package/dist/compose-refs.js +70 -0
- package/dist/compose-refs.js.map +1 -0
- package/dist/index.cjs +2272 -1650
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1461 -475
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +1461 -475
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2258 -1648
- package/dist/index.js.map +1 -1
- package/package.json +32 -31
- package/src/components/AnimationCanvas/index.tsx +162 -194
- package/src/components/Knob/ActiveLine.tsx +9 -8
- package/src/components/Knob/InactiveLine.tsx +11 -14
- package/src/components/Knob/SVGRoot.tsx +10 -17
- package/src/components/Knob/Thumb.tsx +33 -11
- package/src/components/Knob/context.tsx +73 -89
- package/src/components/Knob/index.tsx +227 -105
- package/src/components/NumberInput/DecrementStepper.tsx +21 -50
- package/src/components/NumberInput/IncrementStepper.tsx +22 -50
- package/src/components/NumberInput/InputField.tsx +206 -0
- package/src/components/NumberInput/Stepper.tsx +150 -21
- package/src/components/NumberInput/StepperButton.tsx +95 -0
- package/src/components/NumberInput/context.tsx +104 -149
- package/src/components/NumberInput/index.tsx +461 -110
- package/src/components/Piano/index.tsx +413 -263
- package/src/components/Piano/keyboard-shortcuts.ts +61 -0
- package/src/components/PointsEditor/Background.tsx +19 -4
- package/src/components/PointsEditor/Container.tsx +92 -22
- package/src/components/PointsEditor/Point.tsx +293 -53
- package/src/components/PointsEditor/SelectionBox.tsx +54 -0
- package/src/components/PointsEditor/context.tsx +97 -75
- package/src/components/PointsEditor/index.tsx +497 -62
- package/src/components/Slider/Marks.tsx +84 -0
- package/src/components/Slider/MarksOption.tsx +115 -0
- package/src/components/Slider/Thumb.tsx +99 -54
- package/src/components/Slider/Track.tsx +73 -62
- package/src/components/Slider/context.tsx +40 -53
- package/src/components/Slider/index.tsx +237 -190
- package/src/components/Slider/type.ts +27 -9
- package/src/components/XYPad/Area.tsx +52 -33
- package/src/components/XYPad/Thumb.tsx +121 -60
- package/src/components/XYPad/context.tsx +49 -0
- package/src/components/XYPad/index.tsx +338 -246
- package/src/components/_util/Placement.tsx +68 -0
- package/src/components/_util/VisuallyHiddenRangeInput.tsx +24 -0
- package/src/components/_util/css-length.ts +13 -0
- package/src/{components/_util/composeRefs.tsx → compose-refs/index.tsx} +33 -5
- package/src/hooks/{useCallbackRef.ts → _internal/useCallbackRef.ts} +3 -1
- package/src/hooks/_internal/useCheckSteps.ts +170 -0
- package/src/hooks/useAnimationFrame.ts +17 -7
- package/src/hooks/useDrag.ts +39 -9
- package/src/hooks/useDragValue.ts +194 -0
- package/src/hooks/useEventListener.ts +11 -7
- package/src/hooks/useLongPress.ts +42 -8
- package/src/hooks/useMIDIAccess.ts +82 -11
- package/src/hooks/useMIDIInput.ts +45 -13
- package/src/hooks/useMIDIMessage.ts +23 -5
- package/src/hooks/useWheel.ts +29 -9
- package/src/index.ts +50 -27
- package/src/input-event.ts +32 -0
- package/dist/index.css +0 -536
- package/src/Globals.d.ts +0 -3
- package/src/components/AnimationCanvas/canvas.ts +0 -46
- package/src/components/Knob/index.css +0 -77
- package/src/components/NumberInput/InternalInput.tsx +0 -216
- package/src/components/NumberInput/index.css +0 -185
- package/src/components/NumberInput/type.ts +0 -58
- package/src/components/Piano/KeyLabel.tsx +0 -51
- package/src/components/Piano/context.tsx +0 -73
- package/src/components/Piano/index.css +0 -90
- package/src/components/Piano/key.tsx +0 -205
- package/src/components/Piano/keyboardShortcuts.ts +0 -31
- package/src/components/PointsEditor/index.css +0 -30
- package/src/components/Slider/Scale.tsx +0 -52
- package/src/components/Slider/ScaleOption.tsx +0 -103
- package/src/components/Slider/index.css +0 -112
- package/src/components/XYPad/index.css +0 -48
- package/src/components/_util/index.ts +0 -28
- package/src/components/_util/type.ts +0 -1
- package/src/hooks/useDragWithElement.ts +0 -100
- package/src/hooks/usePianoDrag.ts +0 -75
- package/src/hooks/useRefCallbackEvent.ts +0 -34
- package/src/styles/global.css +0 -19
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
16
|
+
blackKeyWidth,
|
|
17
|
+
getNoteRangeArray,
|
|
20
18
|
isWhiteKey,
|
|
21
19
|
noteKey,
|
|
22
|
-
|
|
23
|
-
|
|
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 {
|
|
27
|
+
import { cssLength } from '../_util/css-length'
|
|
28
28
|
|
|
29
|
-
import {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
*
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
63
|
-
onStopNote?: (note: number) => void
|
|
164
|
+
style?: CSSProperties & CSSVariables
|
|
64
165
|
|
|
65
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
71
|
-
}
|
|
186
|
+
keyProps?: (note: number, state: KeyState) => KeyAttributes
|
|
72
187
|
|
|
73
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
//
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
173
|
-
(
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
//
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
214
|
-
|
|
215
|
-
|
|
278
|
+
const releaseShortcutNotes = useCallback(() => {
|
|
279
|
+
for (const { note, source } of shortcutNotes.current.values()) {
|
|
280
|
+
instanceRef.current?.noteOff(note, { source })
|
|
216
281
|
}
|
|
217
|
-
|
|
218
|
-
}, [
|
|
282
|
+
shortcutNotes.current.clear()
|
|
283
|
+
}, [])
|
|
219
284
|
|
|
220
|
-
// --- hooks ---
|
|
221
285
|
useEffect(() => {
|
|
222
|
-
if (
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
259
|
-
if (
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
<
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
|
497
|
+
export const Piano = { Root }
|
|
346
498
|
|
|
347
|
-
export { type KeyboardShortcuts, SHORTCUTS } from './
|
|
348
|
-
export { type KeyProps, type KeyMethods } from './key'
|
|
349
|
-
export { type KeyLabelProps } from './KeyLabel'
|
|
499
|
+
export { type KeyboardShortcuts, SHORTCUTS } from './keyboard-shortcuts'
|