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