Keyboard map is the one responsible for handling mouse, touch, and keyboard events. This is to keep the keyboard rendering and external events handling separate.master
@@ -0,0 +1 @@ | |||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1" /> |
@@ -3,6 +3,7 @@ import * as PropTypes from 'prop-types' | |||
import StyledAccidentalKey from '../StyledAccidentalKey/StyledAccidentalKey' | |||
import StyledNaturalKey from '../StyledNaturalKey/StyledNaturalKey' | |||
import Keyboard, { propTypes } from './Keyboard' | |||
import KeyboardMap from '../KeyboardMap/KeyboardMap' | |||
const Wrapper: React.FC = (props) => ( | |||
<div | |||
@@ -144,3 +145,102 @@ export const AnotherStyled = (props?: Partial<Props>) => ( | |||
</Wrapper> | |||
</div> | |||
) | |||
const HasMapComponent = () => { | |||
const [keyChannels, setKeyChannels] = React.useState<{ key: number; velocity: number; channel: number }[]>([]) | |||
const midiAccess = React.useRef<any>(undefined) | |||
const handleKeyOn = (newKeys: { key: number; velocity: number; channel: number; id: number }[]) => { | |||
setKeyChannels((oldKeys) => { | |||
const oldKeysKeys = oldKeys.map((k) => k.key) | |||
const newKeysKeys = newKeys.map((k) => k.key) | |||
const keysOff = oldKeys | |||
.filter((ok) => !newKeysKeys.includes(ok.key)) | |||
.map((k) => ({ | |||
...k, | |||
velocity: k.velocity > 1 ? 1 : k.velocity < 0 ? 0 : k.velocity, | |||
})) | |||
const keysOn = newKeys | |||
.filter((nk) => !oldKeysKeys.includes(nk.key)) | |||
.map((k) => ({ | |||
...k, | |||
velocity: k.velocity > 1 ? 1 : k.velocity < 0 ? 0 : k.velocity, | |||
})) | |||
keysOn.forEach((k) => { | |||
midiAccess.current?.send([0b10010000 + k.channel, k.key, Math.floor(k.velocity * 127)]) | |||
}) | |||
keysOff.forEach((k) => { | |||
midiAccess.current?.send([0b10000000 + k.channel, k.key, Math.floor(k.velocity * 127)]) | |||
}) | |||
return newKeys | |||
}) | |||
} | |||
React.useEffect(() => { | |||
const { navigator: maybeNavigator } = window | |||
const navigator = maybeNavigator as Navigator & { | |||
requestMIDIAccess: () => Promise<{ outputs: Map<string, unknown> }> | |||
} | |||
if ('requestMIDIAccess' in navigator) { | |||
navigator.requestMIDIAccess().then((m) => { | |||
midiAccess.current = Array.from(m.outputs.values())[0] | |||
}) | |||
} | |||
}, []) | |||
return ( | |||
<Wrapper> | |||
<Keyboard hasMap startKey={21} endKey={108} keyChannels={keyChannels}> | |||
<KeyboardMap | |||
channel={0} | |||
onChange={handleKeyOn} | |||
keyboardMapping={{ | |||
KeyQ: 60, | |||
Digit2: 61, | |||
KeyW: 62, | |||
Digit3: 63, | |||
KeyE: 64, | |||
KeyR: 65, | |||
Digit5: 66, | |||
KeyT: 67, | |||
Digit6: 68, | |||
KeyY: 69, | |||
Digit7: 70, | |||
KeyU: 71, | |||
KeyI: 72, | |||
Digit9: 73, | |||
KeyO: 74, | |||
Digit0: 75, | |||
KeyP: 76, | |||
BracketLeft: 77, | |||
Equal: 78, | |||
BracketRight: 79, | |||
KeyZ: 48, | |||
KeyS: 49, | |||
KeyX: 50, | |||
KeyD: 51, | |||
KeyC: 52, | |||
KeyV: 53, | |||
KeyG: 54, | |||
KeyB: 55, | |||
KeyH: 56, | |||
KeyN: 57, | |||
KeyJ: 58, | |||
KeyM: 59, | |||
Comma: 60, | |||
KeyL: 61, | |||
Period: 62, | |||
Semicolon: 63, | |||
Slash: 64, | |||
}} | |||
/> | |||
</Keyboard> | |||
</Wrapper> | |||
) | |||
} | |||
export const HasMap = () => <HasMapComponent /> |
@@ -18,6 +18,11 @@ export const propTypes = { | |||
*/ | |||
endKey: PropTypes.number.isRequired, | |||
/** | |||
* Does the component have a clickable map? | |||
*/ | |||
hasMap: PropTypes.bool, | |||
//octaveDivision: PropTypes.number, | |||
/** | |||
@@ -61,12 +66,12 @@ type Props = PropTypes.InferProps<typeof propTypes> | |||
* Component for displaying musical notes in the form of a piano keyboard. | |||
* @param startKey - MIDI note of the first key. | |||
* @param endKey - MIDI note of the last key. | |||
* @param hasMap - The component's clickable map component. | |||
* @param accidentalKeyLengthRatio - Ratio of the length of the accidental keys to the natural keys. | |||
* @param keyChannels - Current active keys and their channel assignments. | |||
* @param width - Width of the component. | |||
* @param keyComponents - Components to use for each kind of key. | |||
* @param height - Height of the component. | |||
* @constructor | |||
*/ | |||
const Keyboard: React.FC<Props> = ({ | |||
startKey, | |||
@@ -77,6 +82,7 @@ const Keyboard: React.FC<Props> = ({ | |||
width = '100%', | |||
keyComponents = {}, | |||
height = 80, | |||
children, | |||
}) => { | |||
const [clientSide, setClientSide] = React.useState(false) | |||
const [clientSideKeys, setClientSideKeys] = React.useState<number[]>([]) | |||
@@ -86,6 +92,7 @@ const Keyboard: React.FC<Props> = ({ | |||
const getKeyWidth = React.useCallback((k) => getKeyWidthUnmemoized(startKey, endKey)(k), [startKey, endKey]) | |||
const getKeyLeft = React.useCallback((k) => getKeyLeftUnmemoized(startKey, endKey)(k), [startKey, endKey]) | |||
const isNaturalKey = React.useCallback((k) => isNaturalKeyUnmemoized(k), []) | |||
const baseRef = React.useRef<HTMLDivElement>(null) | |||
React.useEffect(() => { | |||
setClientSide(true) | |||
@@ -107,20 +114,62 @@ const Keyboard: React.FC<Props> = ({ | |||
overflow: 'hidden', | |||
}} | |||
role="presentation" | |||
ref={baseRef} | |||
> | |||
{keys.map((key) => { | |||
const isNatural = isNaturalKey(key) | |||
const Component: any = isNatural ? NaturalKey! : AccidentalKey! | |||
const currentKeyChannels = Array.isArray(keyChannels!) ? keyChannels.filter((kc) => kc!.key === key) : null | |||
const width = getKeyWidth(key) | |||
const left = getKeyLeft(key) | |||
let leftBounds: number | |||
let rightBounds: number | |||
switch (key % 12) { | |||
case 0: | |||
case 5: | |||
leftBounds = left | |||
rightBounds = key + 1 > endKey! ? left + width : getKeyLeft(key + 1) | |||
break | |||
case 4: | |||
case 11: | |||
leftBounds = key - 1 < startKey! ? left : getKeyLeft(key - 1) + getKeyWidth(key - 1) | |||
rightBounds = left + width | |||
break | |||
case 2: | |||
case 7: | |||
case 9: | |||
leftBounds = key - 1 < startKey! ? left : getKeyLeft(key - 1) + getKeyWidth(key - 1) | |||
rightBounds = key + 1 > endKey! ? left + width : getKeyLeft(key + 1) | |||
break | |||
default: | |||
leftBounds = left | |||
rightBounds = left + width | |||
break | |||
} | |||
const octaveStart = Math.floor(key / 12) * 12 | |||
const octaveEnd = octaveStart + 11 | |||
const octaveLeftBounds = getKeyLeft(octaveStart) | |||
const octaveRightBounds = getKeyLeft(octaveEnd) + getKeyWidth(octaveEnd) | |||
return ( | |||
<div | |||
key={key} | |||
data-key={key} | |||
data-octave-left-bounds={octaveLeftBounds} | |||
data-octave-right-bounds={octaveRightBounds} | |||
data-left-bounds={leftBounds} | |||
data-right-bounds={rightBounds} | |||
data-left-full-bounds={isNatural ? left : undefined} | |||
data-right-full-bounds={isNatural ? left + width : undefined} | |||
style={{ | |||
zIndex: isNatural ? 0 : 2, | |||
width: getKeyWidth(key) + '%', | |||
width: width + '%', | |||
height: (isNatural ? 100 : 100 * accidentalKeyLengthRatio!) + '%', | |||
left: getKeyLeft(key) + '%', | |||
left: left + '%', | |||
position: 'absolute', | |||
top: 0, | |||
}} | |||
@@ -129,6 +178,15 @@ const Keyboard: React.FC<Props> = ({ | |||
</div> | |||
) | |||
})} | |||
{children! && | |||
React.Children.map(children, (unknownChild) => { | |||
const child = unknownChild as React.ReactElement | |||
const { props = {} } = child | |||
return React.cloneElement(child, { | |||
...props, | |||
accidentalKeyLengthRatio, | |||
}) | |||
})} | |||
</div> | |||
) | |||
} | |||
@@ -0,0 +1,328 @@ | |||
import * as React from 'react' | |||
import * as PropTypes from 'prop-types' | |||
import reverseGetKeyFromPoint from '../../services/reverseGetKeyFromPoint' | |||
const propTypes = { | |||
/** | |||
* Event handler triggered upon change in activated keys in the component. | |||
*/ | |||
onChange: PropTypes.func, | |||
/** | |||
* Map from key code to key number. | |||
*/ | |||
keyboardMapping: PropTypes.object, | |||
/** | |||
* Active MIDI channel for registering keys. | |||
*/ | |||
channel: PropTypes.number.isRequired, | |||
} | |||
type Props = PropTypes.InferProps<typeof propTypes> & { accidentalKeyLengthRatio?: number } | |||
/** | |||
* Keyboard map for allowing interactivity with the keyboard. | |||
* @param channel - Active MIDI channel for registering keys. | |||
* @param accidentalKeyLengthRatio - Ratio of the length of the accidental keys to the natural keys. This is set by the Keyboard component. | |||
* @param onChange - Event handler triggered upon change in activated keys in the component. | |||
* @param keyboardMapping - Map from key code to key number. | |||
*/ | |||
const KeyboardMap: React.FC<Props> = ({ channel, accidentalKeyLengthRatio, onChange, keyboardMapping = {} }) => { | |||
const baseRef = React.useRef<HTMLDivElement>(null) | |||
const keysOnRef = React.useRef<any[]>([]) | |||
const lastVelocity = React.useRef<number | undefined>(undefined) | |||
const isTouch = React.useRef<boolean>(false) | |||
const handleContextMenu: React.EventHandler<any> = (e) => { | |||
e.preventDefault() | |||
} | |||
const handleDragStart: React.DragEventHandler = (e) => { | |||
e.preventDefault() | |||
} | |||
const handleMouseDown: React.MouseEventHandler = (e) => { | |||
if (isTouch.current) { | |||
return | |||
} | |||
if (baseRef.current === null) { | |||
return | |||
} | |||
if (baseRef.current.parentElement === null) { | |||
return | |||
} | |||
const keyData = reverseGetKeyFromPoint(baseRef.current!.parentElement!, accidentalKeyLengthRatio!)( | |||
e.clientX, | |||
e.clientY, | |||
) | |||
if (keyData! === null) { | |||
return | |||
} | |||
if (e.buttons === 1) { | |||
if (lastVelocity.current === undefined) { | |||
lastVelocity.current = keyData.velocity | |||
} | |||
keysOnRef.current = [...keysOnRef.current, { ...keyData, velocity: lastVelocity.current, channel, id: -1 }] | |||
if (typeof onChange! === 'function') { | |||
onChange(keysOnRef.current) | |||
} | |||
} | |||
} | |||
const handleTouchStart: React.TouchEventHandler = (e) => { | |||
isTouch.current = true | |||
if (baseRef.current === null) { | |||
return | |||
} | |||
if (baseRef.current.parentElement === null) { | |||
return | |||
} | |||
Array.from(e.changedTouches).forEach((t) => { | |||
const keyData = reverseGetKeyFromPoint(baseRef.current!.parentElement!, accidentalKeyLengthRatio!)( | |||
t.clientX, | |||
t.clientY, | |||
) | |||
if (keyData! === null) { | |||
return | |||
} | |||
if (lastVelocity.current === undefined) { | |||
lastVelocity.current = keyData.velocity | |||
} | |||
keysOnRef.current = [ | |||
...keysOnRef.current, | |||
{ ...keyData, velocity: lastVelocity.current, channel, id: t.identifier }, | |||
] | |||
if (typeof onChange! === 'function') { | |||
onChange(keysOnRef.current) | |||
} | |||
}) | |||
} | |||
React.useEffect(() => { | |||
const handleTouchMove = (e: TouchEvent) => { | |||
if (baseRef.current === null) { | |||
return | |||
} | |||
if (baseRef.current.parentElement === null) { | |||
return | |||
} | |||
e.preventDefault() | |||
Array.from(e.changedTouches).forEach((t) => { | |||
const keyData = reverseGetKeyFromPoint(baseRef.current!.parentElement!, accidentalKeyLengthRatio!)( | |||
t.clientX, | |||
t.clientY, | |||
) | |||
if (keyData! === null) { | |||
keysOnRef.current = keysOnRef.current.filter((k) => k.id !== t.identifier) | |||
if (typeof onChange! === 'function') { | |||
onChange(keysOnRef.current) | |||
} | |||
return | |||
} | |||
const [mouseKey = null] = keysOnRef.current.filter((k) => k.id === t.identifier) | |||
if (mouseKey === null) { | |||
keysOnRef.current = keysOnRef.current.filter((k) => k.id !== t.identifier) | |||
if (typeof onChange! === 'function') { | |||
onChange(keysOnRef.current) | |||
} | |||
return | |||
} | |||
if (mouseKey.key !== keyData.key) { | |||
keysOnRef.current = [ | |||
...keysOnRef.current.filter((k) => k.id !== t.identifier), | |||
{ | |||
...keyData, | |||
channel, | |||
velocity: lastVelocity.current, | |||
id: t.identifier, | |||
}, | |||
] | |||
if (typeof onChange! === 'function') { | |||
onChange(keysOnRef.current) | |||
} | |||
} | |||
}) | |||
} | |||
window.addEventListener('touchmove', handleTouchMove, { passive: false }) | |||
return () => { | |||
window.removeEventListener('touchmove', handleTouchMove) | |||
} | |||
}, [accidentalKeyLengthRatio, channel, onChange]) | |||
React.useEffect(() => { | |||
const handleMouseMove = (e: MouseEvent) => { | |||
e.preventDefault() | |||
if (baseRef.current === null) { | |||
return | |||
} | |||
if (baseRef.current.parentElement === null) { | |||
return | |||
} | |||
if (e.buttons === 1) { | |||
const keyData = reverseGetKeyFromPoint(baseRef.current!.parentElement, accidentalKeyLengthRatio!)( | |||
e.clientX, | |||
e.clientY, | |||
) | |||
if (keyData! === null) { | |||
keysOnRef.current = keysOnRef.current.filter((k) => k.id !== -1) | |||
if (typeof onChange! === 'function') { | |||
onChange(keysOnRef.current) | |||
} | |||
return | |||
} | |||
const [mouseKey = null] = keysOnRef.current.filter((k) => k.id === -1) | |||
if (mouseKey === null) { | |||
keysOnRef.current = keysOnRef.current.filter((k) => k.id !== -1) | |||
if (typeof onChange! === 'function') { | |||
onChange(keysOnRef.current) | |||
} | |||
return | |||
} | |||
if (mouseKey.key !== keyData.key) { | |||
keysOnRef.current = [ | |||
...keysOnRef.current.filter((k) => k.id !== -1), | |||
{ ...keyData, velocity: lastVelocity.current, channel, id: -1 }, | |||
] | |||
if (typeof onChange! === 'function') { | |||
onChange(keysOnRef.current) | |||
} | |||
} | |||
} | |||
} | |||
window.addEventListener('mousemove', handleMouseMove) | |||
return () => { | |||
window.removeEventListener('mousemove', handleMouseMove) | |||
} | |||
}, [accidentalKeyLengthRatio, channel, onChange]) | |||
React.useEffect(() => { | |||
const handleTouchEnd = (e: TouchEvent) => { | |||
if (baseRef.current === null) { | |||
return | |||
} | |||
if (baseRef.current.parentElement === null) { | |||
return | |||
} | |||
Array.from(e.changedTouches).forEach((t) => { | |||
keysOnRef.current = keysOnRef.current.filter((k) => k.id !== t.identifier) | |||
lastVelocity.current = undefined | |||
if (typeof onChange! === 'function') { | |||
onChange(keysOnRef.current) | |||
} | |||
}) | |||
} | |||
window.addEventListener('touchend', handleTouchEnd) | |||
return () => { | |||
window.removeEventListener('touchend', handleTouchEnd) | |||
} | |||
}) | |||
React.useEffect(() => { | |||
const handleMouseUp = (e: MouseEvent) => { | |||
e.preventDefault() | |||
if (baseRef.current === null) { | |||
return | |||
} | |||
if (baseRef.current.parentElement === null) { | |||
return | |||
} | |||
keysOnRef.current = keysOnRef.current.filter((k) => k.id !== -1) | |||
lastVelocity.current = undefined | |||
if (typeof onChange! === 'function') { | |||
onChange(keysOnRef.current) | |||
} | |||
} | |||
window.addEventListener('mouseup', handleMouseUp) | |||
return () => { | |||
window.removeEventListener('mouseup', handleMouseUp) | |||
} | |||
}, [accidentalKeyLengthRatio, channel, onChange]) | |||
React.useEffect(() => { | |||
const baseRefComponent = baseRef.current | |||
const handleKeyDown = (e: KeyboardEvent) => { | |||
if (!keyboardMapping!) { | |||
return | |||
} | |||
const { [e.code]: key = null } = keyboardMapping as Record<string, number> | |||
if (key === null) { | |||
return | |||
} | |||
if (keysOnRef.current.some((k) => k.key === key && k.id === -2)) { | |||
return | |||
} | |||
keysOnRef.current = [...keysOnRef.current, { key, velocity: 0.75, channel, id: -2 }] | |||
if (typeof onChange! === 'function') { | |||
onChange(keysOnRef.current) | |||
} | |||
} | |||
if (baseRefComponent) { | |||
baseRefComponent.addEventListener('keydown', handleKeyDown) | |||
} | |||
return () => { | |||
if (baseRefComponent) { | |||
baseRefComponent.removeEventListener('keydown', handleKeyDown) | |||
} | |||
} | |||
}) | |||
React.useEffect(() => { | |||
const handleKeyUp = (e: KeyboardEvent) => { | |||
if (!keyboardMapping!) { | |||
return | |||
} | |||
const { [e.code]: key = null } = keyboardMapping as Record<string, number> | |||
if (key === null) { | |||
return | |||
} | |||
keysOnRef.current = keysOnRef.current.filter((k) => k.key !== key) | |||
if (typeof onChange! === 'function') { | |||
onChange(keysOnRef.current) | |||
} | |||
} | |||
window.addEventListener('keyup', handleKeyUp) | |||
return () => { | |||
window.removeEventListener('keyup', handleKeyUp) | |||
} | |||
}) | |||
return ( | |||
<div | |||
ref={baseRef} | |||
style={{ | |||
position: 'absolute', | |||
top: 0, | |||
left: 0, | |||
width: '100%', | |||
height: '100%', | |||
zIndex: 4, | |||
}} | |||
onContextMenu={handleContextMenu} | |||
onDragStart={handleDragStart} | |||
onMouseDown={handleMouseDown} | |||
onTouchStart={handleTouchStart} | |||
tabIndex={0} | |||
/> | |||
) | |||
} | |||
KeyboardMap.propTypes = propTypes | |||
export default KeyboardMap |
@@ -1,7 +1,8 @@ | |||
import Keyboard from './components/Keyboard/Keyboard' | |||
import KeyboardMap from './components/KeyboardMap/KeyboardMap' | |||
import StyledNaturalKey from './components/StyledNaturalKey/StyledNaturalKey' | |||
import StyledAccidentalKey from './components/StyledAccidentalKey/StyledAccidentalKey' | |||
export default Keyboard | |||
export { StyledNaturalKey, StyledAccidentalKey } | |||
export { StyledNaturalKey, StyledAccidentalKey, KeyboardMap } |
@@ -0,0 +1,59 @@ | |||
type ReverseGetKeyFromPoint = ( | |||
baseElement: HTMLElement, | |||
accidentalKeyLengthRatio: number, | |||
) => (clientX: number, clientY?: number) => { key: number; velocity: number } | null | |||
const reverseGetKeyFromPoint: ReverseGetKeyFromPoint = (baseElement, accidentalKeyLengthRatio) => { | |||
const { top, left, width, height } = baseElement.getBoundingClientRect() | |||
return (clientX, clientY = top) => { | |||
const realTop = clientY - top | |||
const realLeft = clientX - left | |||
// convert the clientX to units in which keys are displayed (percentage) | |||
const leftInKeyUnits = (realLeft / width) * 100 | |||
const maybeAccidental = realTop <= height * accidentalKeyLengthRatio! | |||
const keysArray = Array.from(baseElement.children) as HTMLElement[] | |||
const keys = keysArray.filter((c) => 'key' in c.dataset) | |||
const currentOctave = keys.filter((k) => { | |||
const octaveLeftBounds = Number(k.dataset.octaveLeftBounds) | |||
const octaveRightBounds = Number(k.dataset.octaveRightBounds) | |||
return octaveLeftBounds <= leftInKeyUnits && leftInKeyUnits < octaveRightBounds | |||
}) | |||
const key: HTMLElement | undefined = currentOctave.reduce<HTMLElement | undefined>((selectedKey, octaveKey) => { | |||
if (maybeAccidental) { | |||
if (selectedKey !== undefined) { | |||
return selectedKey | |||
} | |||
const keyLeftBounds = Number(octaveKey.dataset.leftBounds) | |||
const keyRightBounds = Number(octaveKey.dataset.rightBounds) | |||
if (keyLeftBounds <= leftInKeyUnits && leftInKeyUnits < keyRightBounds) { | |||
return octaveKey | |||
} | |||
return selectedKey | |||
} | |||
if (selectedKey !== undefined) { | |||
return selectedKey | |||
} | |||
if ( | |||
'leftFullBounds' in octaveKey.dataset && | |||
'rightFullBounds' in octaveKey.dataset && | |||
Number(octaveKey.dataset.leftFullBounds) <= leftInKeyUnits && | |||
leftInKeyUnits < Number(octaveKey.dataset.rightFullBounds) | |||
) { | |||
return octaveKey | |||
} | |||
return selectedKey | |||
}, undefined) | |||
if (key! === undefined) { | |||
return null | |||
} | |||
const { height: keyHeight } = key.getBoundingClientRect() | |||
return { | |||
velocity: realTop / keyHeight, | |||
key: Number(key.dataset.key), | |||
} | |||
} | |||
} | |||
export default reverseGetKeyFromPoint |