diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html
new file mode 100644
index 0000000..7fc9351
--- /dev/null
+++ b/.storybook/preview-head.html
@@ -0,0 +1 @@
+
diff --git a/src/components/Keyboard/Keyboard.stories.tsx b/src/components/Keyboard/Keyboard.stories.tsx
index aa5c991..3f334fd 100644
--- a/src/components/Keyboard/Keyboard.stories.tsx
+++ b/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) => (
) => (
)
+
+const HasMapComponent = () => {
+ const [keyChannels, setKeyChannels] = React.useState<{ key: number; velocity: number; channel: number }[]>([])
+ const midiAccess = React.useRef(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 }>
+ }
+ if ('requestMIDIAccess' in navigator) {
+ navigator.requestMIDIAccess().then((m) => {
+ midiAccess.current = Array.from(m.outputs.values())[0]
+ })
+ }
+ }, [])
+
+ return (
+
+
+
+
+
+ )
+}
+
+export const HasMap = () =>
diff --git a/src/components/Keyboard/Keyboard.tsx b/src/components/Keyboard/Keyboard.tsx
index 7d19b01..dec3b47 100644
--- a/src/components/Keyboard/Keyboard.tsx
+++ b/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
* 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 = ({
startKey,
@@ -77,6 +82,7 @@ const Keyboard: React.FC = ({
width = '100%',
keyComponents = {},
height = 80,
+ children,
}) => {
const [clientSide, setClientSide] = React.useState(false)
const [clientSideKeys, setClientSideKeys] = React.useState([])
@@ -86,6 +92,7 @@ const Keyboard: React.FC = ({
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(null)
React.useEffect(() => {
setClientSide(true)
@@ -107,20 +114,62 @@ const Keyboard: React.FC = ({
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 (
= ({
)
})}
+ {children! &&
+ React.Children.map(children, (unknownChild) => {
+ const child = unknownChild as React.ReactElement
+ const { props = {} } = child
+ return React.cloneElement(child, {
+ ...props,
+ accidentalKeyLengthRatio,
+ })
+ })}
)
}
diff --git a/src/components/KeyboardMap/KeyboardMap.tsx b/src/components/KeyboardMap/KeyboardMap.tsx
new file mode 100644
index 0000000..5922241
--- /dev/null
+++ b/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 & { 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 = ({ channel, accidentalKeyLengthRatio, onChange, keyboardMapping = {} }) => {
+ const baseRef = React.useRef(null)
+ const keysOnRef = React.useRef([])
+ const lastVelocity = React.useRef(undefined)
+ const isTouch = React.useRef(false)
+
+ const handleContextMenu: React.EventHandler = (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
+
+ 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
+
+ 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 (
+
+ )
+}
+
+KeyboardMap.propTypes = propTypes
+
+export default KeyboardMap
diff --git a/src/index.ts b/src/index.ts
index 373e3cc..c001724 100644
--- a/src/index.ts
+++ b/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 }
diff --git a/src/services/reverseGetKeyFromPoint.ts b/src/services/reverseGetKeyFromPoint.ts
new file mode 100644
index 0000000..2792107
--- /dev/null
+++ b/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((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