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