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