ソースを参照

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
コミット
b6ea02a3ad
6個のファイルの変更551行の追加4行の削除
  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 ファイルの表示

@@ -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 ファイルの表示

@@ -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 ファイルの表示

@@ -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 ファイルの表示

@@ -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 ファイルの表示

@@ -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 ファイルの表示

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

読み込み中…
キャンセル
保存