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