Browse Source

Add keyboard map

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
TheoryOfNekomata 3 years ago
parent
commit
b6ea02a3ad
6 changed files with 551 additions and 4 deletions
  1. +1
    -0
      .storybook/preview-head.html
  2. +100
    -0
      src/components/Keyboard/Keyboard.stories.tsx
  3. +61
    -3
      src/components/Keyboard/Keyboard.tsx
  4. +328
    -0
      src/components/KeyboardMap/KeyboardMap.tsx
  5. +2
    -1
      src/index.ts
  6. +59
    -0
      src/services/reverseGetKeyFromPoint.ts

+ 1
- 0
.storybook/preview-head.html View File

@@ -0,0 +1 @@
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1" />

+ 100
- 0
src/components/Keyboard/Keyboard.stories.tsx View File

@@ -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 />

+ 61
- 3
src/components/Keyboard/Keyboard.tsx View File

@@ -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>
)
}


+ 328
- 0
src/components/KeyboardMap/KeyboardMap.tsx View File

@@ -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

+ 2
- 1
src/index.ts View File

@@ -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 }

+ 59
- 0
src/services/reverseGetKeyFromPoint.ts View File

@@ -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

Loading…
Cancel
Save