diff --git a/src/components/Keyboard/Keyboard.stories.tsx b/src/components/Keyboard/Keyboard.stories.tsx index 1bb9651..6256683 100644 --- a/src/components/Keyboard/Keyboard.stories.tsx +++ b/src/components/Keyboard/Keyboard.stories.tsx @@ -184,7 +184,7 @@ export const DarkStyled = (props?: Partial) => ( ) -const HasMapComponent = () => { +const HasMapComponent = (props: any) => { const [keyChannels, setKeyChannels] = React.useState<{ key: number; velocity: number; channel: number }[]>([]) const midiAccess = React.useRef(undefined) @@ -223,6 +223,7 @@ const HasMapComponent = () => { return ( { export const HasMap = () => +export const Mirrored = () => + export const Checkbox = (props?: Partial) => ( @@ -291,3 +294,23 @@ export const Link = (props?: Partial) => ( `?key=${key}`} /> ) + +export const Rotated90 = (props?: Partial) => ( + +) + +export const Rotated180 = (props?: Partial) => + +export const Rotated270 = (props?: Partial) => ( + +) + +export const Rotated90Mirrored = (props?: Partial) => ( + +) + +export const Rotated180Mirrored = (props?: Partial) => + +export const Rotated270Mirrored = (props?: Partial) => ( + +) diff --git a/src/components/Keyboard/Keyboard.tsx b/src/components/Keyboard/Keyboard.tsx index e6d23ed..88aabcb 100644 --- a/src/components/Keyboard/Keyboard.tsx +++ b/src/components/Keyboard/Keyboard.tsx @@ -8,8 +8,7 @@ import DefaultAccidentalKey from '../AccidentalKey/AccidentalKey' import DefaultNaturalKey from '../NaturalKey/NaturalKey' import KeyboardMap from '../KeyboardMap/KeyboardMap' import getKeyBounds from '../../services/getKeyBounds' - -const BEHAVIOR = ['link', 'checkbox', 'radio'] as const +import { BEHAVIORS, OCTAVE_DIVISIONS, ORIENTATIONS } from '../../services/constants' export const propTypes = { /** @@ -22,7 +21,10 @@ export const propTypes = { */ endKey: PropTypes.number.isRequired, - //octaveDivision: PropTypes.number, + /** + * Equal parts of an octave. + */ + octaveDivision: PropTypes.oneOf(OCTAVE_DIVISIONS), /** * Ratio of the length of the accidental keys to the natural keys. @@ -67,7 +69,7 @@ export const propTypes = { /** * Behavior of the component when clicking. */ - behavior: PropTypes.oneOf(BEHAVIOR), + behavior: PropTypes.oneOf(BEHAVIORS), /** * Name of the component used for forms. */ @@ -87,31 +89,25 @@ export const propTypes = { * Received velocity when activating the component through the keyboard. */ keyboardVelocity: PropTypes.number, + /** + * Orientation of the component. + */ + orientation: PropTypes.oneOf(ORIENTATIONS), + /** + * Is the component mirrored? + */ + mirrored: PropTypes.bool, } 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 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. - * @param name - Name of the component used for forms. - * @param href - Destination of the component upon clicking a key, if behavior is set to 'link'. - * @param behavior - Behavior of the component when clicking. - * @param onChange - Event handler triggered upon change in activated keys in the component. - * @param keyboardMapping - Map from key code to key number, used to activate the component from the keyboard. - * @param midiInput - Can MIDI input messages activate the component? - * @param keyboardVelocity - Received velocity when activating the component through the keyboard. */ const Keyboard: React.FC = ({ startKey, endKey, - //octaveDivision = 12, + octaveDivision = 12, accidentalKeyLengthRatio = 0.65, keyChannels = [], width = '100%', @@ -124,6 +120,8 @@ const Keyboard: React.FC = ({ href, midiInput, keyboardVelocity, + orientation = 0, + mirrored = false, }) => { const [clientSide, setClientSide] = React.useState(false) const [clientSideKeys, setClientSideKeys] = React.useState([]) @@ -144,6 +142,30 @@ const Keyboard: React.FC = ({ }, [startKey, endKey]) const keys = clientSide ? clientSideKeys : generateKeys(startKey, endKey) + const widthDimension = orientation === 90 || orientation === 270 ? 'height' : 'width' + const heightDimension = orientation === 90 || orientation === 270 ? 'width' : 'height' + let leftDirection: string + let topDirection: string + + switch (orientation) { + default: + case 0: + leftDirection = 'left' + topDirection = 'top' + break + case 90: + leftDirection = 'bottom' + topDirection = 'left' + break + case 180: + leftDirection = 'right' + topDirection = 'bottom' + break + case 270: + leftDirection = 'top' + topDirection = 'right' + break + } return ( @@ -176,7 +198,8 @@ const Keyboard: React.FC = ({ getKeyWidth, )(key, left, width) const octaveStart = Math.floor(key / 12) * 12 - const octaveEnd = octaveStart + 11 + const theOctaveDivision = (octaveDivision as number) !== 12 ? 12 : octaveDivision + const octaveEnd = octaveStart + 12 * (1 - 1 / theOctaveDivision!) const octaveLeftBounds = getKeyLeft(octaveStart) const octaveRightBounds = getKeyLeft(octaveEnd) + getKeyWidth(octaveEnd) const components: Record = { @@ -202,11 +225,11 @@ const Keyboard: React.FC = ({ data-right-full-bounds={isNatural ? left + width : undefined} style={{ zIndex: isNatural ? 0 : 2, - width: width + '%', - height: (isNatural ? 100 : 100 * accidentalKeyLengthRatio!) + '%', - left: left + '%', + [widthDimension]: width + '%', + [heightDimension]: (isNatural ? 100 : 100 * accidentalKeyLengthRatio!) + '%', + [leftDirection]: (mirrored ? 100 - width - left : left) + '%', position: 'absolute', - top: 0, + [topDirection]: 0, cursor: onChange || behavior ? 'pointer' : undefined, color: 'inherit', '--opacity-highlight': currentKey !== null ? 1 : 0, @@ -238,6 +261,8 @@ const Keyboard: React.FC = ({ keyboardMapping={keyboardMapping} midiInput={midiInput} keyboardVelocity={keyboardVelocity} + orientation={orientation} + mirrored={mirrored} /> )} diff --git a/src/components/KeyboardMap/KeyboardMap.tsx b/src/components/KeyboardMap/KeyboardMap.tsx index ebde761..8db4f1a 100644 --- a/src/components/KeyboardMap/KeyboardMap.tsx +++ b/src/components/KeyboardMap/KeyboardMap.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import * as PropTypes from 'prop-types' import reverseGetKeyFromPoint from '../../services/reverseGetKeyFromPoint' import { MIDIMessageEvent } from '../../services/midi' +import { ORIENTATIONS } from '../../services/constants' const propTypes = { /** @@ -27,17 +28,20 @@ const propTypes = { addEventListener: PropTypes.func.isRequired, removeEventListener: PropTypes.func.isRequired, }), + /** + * Orientation of the component. + */ + orientation: PropTypes.oneOf(ORIENTATIONS), + /** + * Is the component mirrored? + */ + mirrored: PropTypes.bool, } type Props = PropTypes.InferProps /** * Keyboard map for allowing interactivity with the keyboard. - * @param accidentalKeyLengthRatio - Ratio of the length of the accidental keys to the natural keys. - * @param onChange - Event handler triggered upon change in activated keys in the component. - * @param keyboardMapping - Map from key code to key number. - * @param midiInput - MIDI input for sending MIDI messages to the component. - * @param keyboardVelocity - Received velocity when activating the component through the keyboard. */ const KeyboardMap: React.FC = ({ accidentalKeyLengthRatio, @@ -45,6 +49,8 @@ const KeyboardMap: React.FC = ({ keyboardMapping = {}, midiInput, keyboardVelocity = 0.75, + orientation = 0, + mirrored = false, }) => { const baseRef = React.useRef(null) const keysOnRef = React.useRef([]) @@ -54,46 +60,66 @@ const KeyboardMap: React.FC = ({ e.preventDefault() } - const handleMouseDown: React.MouseEventHandler = (e) => { - if (baseRef.current === null) { - return - } - if (baseRef.current.parentElement === null) { - return - } - if (e.buttons !== 1) { - return - } - const keyData = reverseGetKeyFromPoint(baseRef.current!.parentElement!, accidentalKeyLengthRatio!)( - e.clientX, - e.clientY, - ) - if (keyData! === null) { - return + React.useEffect(() => { + const baseRefCurrent = baseRef.current + const handleMouseDown = (e: MouseEvent) => { + if (baseRef.current === null) { + return + } + if (baseRef.current.parentElement === null) { + return + } + if (e.buttons !== 1) { + return + } + e.preventDefault() + const keyData = reverseGetKeyFromPoint( + baseRef.current!.parentElement!, + accidentalKeyLengthRatio!, + orientation!, + mirrored!, + )(e.clientX, e.clientY) + if (keyData! === null) { + return + } + if (lastVelocity.current === undefined) { + lastVelocity.current = keyData.velocity > 1 ? 1 : keyData.velocity < 0 ? 0 : keyData.velocity + } + keysOnRef.current = [...keysOnRef.current, { ...keyData, velocity: lastVelocity.current, id: -1 }] + if (typeof onChange! === 'function') { + onChange(keysOnRef.current) + } } - if (lastVelocity.current === undefined) { - lastVelocity.current = keyData.velocity > 1 ? 1 : keyData.velocity < 0 ? 0 : keyData.velocity + + if (baseRefCurrent !== null) { + baseRefCurrent.addEventListener('mousedown', handleMouseDown, { passive: false }) } - keysOnRef.current = [...keysOnRef.current, { ...keyData, velocity: lastVelocity.current, id: -1 }] - if (typeof onChange! === 'function') { - onChange(keysOnRef.current) + return () => { + if (baseRefCurrent !== null) { + baseRefCurrent.removeEventListener('mousedown', handleMouseDown) + } } - } + }, [accidentalKeyLengthRatio, onChange, orientation, mirrored]) React.useEffect(() => { const baseRefCurrent = baseRef.current const handleTouchStart = (e: TouchEvent) => { - e.preventDefault() if (baseRef.current === null) { return } if (baseRef.current.parentElement === null) { return } + e.preventDefault() const touches = Array.from(e.changedTouches) const touchKeyData = touches.map<[React.Touch, { key: number; velocity: number } | null]>((t) => [ t, - reverseGetKeyFromPoint(baseRef.current!.parentElement!, accidentalKeyLengthRatio!)(t.clientX, t.clientY), + reverseGetKeyFromPoint( + baseRef.current!.parentElement!, + accidentalKeyLengthRatio!, + orientation!, + mirrored!, + )(t.clientX, t.clientY), ]) const validTouchKeyData = touchKeyData.filter(([, keyData]) => keyData! !== null) validTouchKeyData.forEach(([t, keyData]) => { @@ -116,22 +142,24 @@ const KeyboardMap: React.FC = ({ baseRefCurrent.removeEventListener('touchstart', handleTouchStart) } } - }, [accidentalKeyLengthRatio, onChange]) + }, [accidentalKeyLengthRatio, onChange, orientation, mirrored]) React.useEffect(() => { const handleTouchMove = (e: TouchEvent) => { - e.preventDefault() 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, - ) + const keyData = reverseGetKeyFromPoint( + baseRef.current!.parentElement!, + accidentalKeyLengthRatio!, + orientation!, + mirrored!, + )(t.clientX, t.clientY) if (keyData! === null) { keysOnRef.current = keysOnRef.current.filter((k) => k.id !== t.identifier) if (typeof onChange! === 'function') { @@ -167,11 +195,10 @@ const KeyboardMap: React.FC = ({ return () => { window.removeEventListener('touchmove', handleTouchMove) } - }, [accidentalKeyLengthRatio, onChange]) + }, [accidentalKeyLengthRatio, onChange, orientation, mirrored]) React.useEffect(() => { const handleMouseMove = (e: MouseEvent) => { - e.preventDefault() if (baseRef.current === null) { return } @@ -181,10 +208,13 @@ const KeyboardMap: React.FC = ({ if (e.buttons !== 1) { return } - const keyData = reverseGetKeyFromPoint(baseRef.current!.parentElement, accidentalKeyLengthRatio!)( - e.clientX, - e.clientY, - ) + e.preventDefault() + const keyData = reverseGetKeyFromPoint( + baseRef.current!.parentElement, + accidentalKeyLengthRatio!, + orientation!, + mirrored!, + )(e.clientX, e.clientY) if (keyData! === null) { keysOnRef.current = keysOnRef.current.filter((k) => k.id !== -1) if (typeof onChange! === 'function') { @@ -215,7 +245,7 @@ const KeyboardMap: React.FC = ({ return () => { window.removeEventListener('mousemove', handleMouseMove) } - }, [accidentalKeyLengthRatio, onChange]) + }, [accidentalKeyLengthRatio, onChange, orientation, mirrored]) React.useEffect(() => { const handleTouchEnd = (e: TouchEvent) => { @@ -233,43 +263,23 @@ const KeyboardMap: React.FC = ({ } }) } + window.addEventListener('touchcancel', handleTouchEnd) window.addEventListener('touchend', handleTouchEnd) return () => { + window.removeEventListener('touchcancel', handleTouchEnd) window.removeEventListener('touchend', handleTouchEnd) } }, [onChange]) - React.useEffect(() => { - const handleTouchCancel = (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('touchcancel', handleTouchCancel) - return () => { - window.removeEventListener('touchcancel', handleTouchCancel) - } - }, [onChange]) - React.useEffect(() => { const handleMouseUp = (e: MouseEvent) => { - e.preventDefault() if (baseRef.current === null) { return } if (baseRef.current.parentElement === null) { return } + e.preventDefault() keysOnRef.current = keysOnRef.current.filter((k) => k.id !== -1) lastVelocity.current = undefined if (typeof onChange! === 'function') { @@ -281,7 +291,7 @@ const KeyboardMap: React.FC = ({ return () => { window.removeEventListener('mouseup', handleMouseUp) } - }, [accidentalKeyLengthRatio, onChange]) + }, [onChange]) React.useEffect(() => { const baseRefComponent = baseRef.current @@ -295,11 +305,9 @@ const KeyboardMap: React.FC = ({ } const { [e.code]: key = null } = theKeyboardMapping - if (key === null) { return } - if (keysOnRef.current.some((k) => k.key === key && k.id === -2)) { return } @@ -357,10 +365,11 @@ const KeyboardMap: React.FC = ({ let velocity: number switch (arg0 & 0b11110000) { - case 0b10010000: + case 0b10010000: // Note On velocity = arg2 & 0b01111111 key = arg1 & 0b01111111 if (velocity > 0) { + // some MIDI inputs set note off as simply note on with zero velocity keysOnRef.current = [ ...keysOnRef.current, { @@ -376,7 +385,7 @@ const KeyboardMap: React.FC = ({ onChange(keysOnRef.current) } break - case 0b10000000: + case 0b10000000: // Note off key = arg1 & 0b01111111 keysOnRef.current = keysOnRef.current.filter((k) => k.key !== key) if (typeof onChange! === 'function') { @@ -412,7 +421,6 @@ const KeyboardMap: React.FC = ({ }} onContextMenu={preventDefault} onDragStart={preventDefault} - onMouseDown={handleMouseDown} tabIndex={0} /> ) diff --git a/src/services/constants.ts b/src/services/constants.ts index cfec8a7..b6ad536 100644 --- a/src/services/constants.ts +++ b/src/services/constants.ts @@ -87,3 +87,9 @@ export const KEY_OFFSETS = [ export const ACCIDENTAL_KEY_TO_NATURAL_KEY_WIDTH_RATIO = 9 / 16 // export const ACCIDENTAL_KEY_TO_NATURAL_KEY_WIDTH_RATIO = 13 / 23 + +export const BEHAVIORS = ['link', 'checkbox', 'radio'] as const + +export const OCTAVE_DIVISIONS = [12, 17, 19, 21, 24, 36] as const + +export const ORIENTATIONS = [0, 90, 180, 270] as const diff --git a/src/services/reverseGetKeyFromPoint.ts b/src/services/reverseGetKeyFromPoint.ts index 2792107..e7e6e06 100644 --- a/src/services/reverseGetKeyFromPoint.ts +++ b/src/services/reverseGetKeyFromPoint.ts @@ -1,16 +1,38 @@ type ReverseGetKeyFromPoint = ( baseElement: HTMLElement, accidentalKeyLengthRatio: number, + orientation: number, + mirrored: boolean, ) => (clientX: number, clientY?: number) => { key: number; velocity: number } | null -const reverseGetKeyFromPoint: ReverseGetKeyFromPoint = (baseElement, accidentalKeyLengthRatio) => { +const reverseGetKeyFromPoint: ReverseGetKeyFromPoint = ( + baseElement, + accidentalKeyLengthRatio, + orientation, + mirrored, +) => { + const isRealTopFlipped = orientation === 180 || orientation === 270 + const isRealLeftFlipped = orientation === 90 || orientation === 180 + const isVertical = orientation === 90 || orientation === 270 const { top, left, width, height } = baseElement.getBoundingClientRect() + const realWidth = isVertical ? height : width + const realHeight = isVertical ? width : height + const realLeft = isVertical ? top : left + const realTop = isVertical ? left : top return (clientX, clientY = top) => { - const realTop = clientY - top - const realLeft = clientX - left + const realClientX = isVertical ? clientY : clientX + const realClientY = isVertical ? clientX : clientY + const touchTop = isRealTopFlipped ? realHeight - realClientY + realTop : realClientY - realTop + const touchLeft = mirrored + ? isRealLeftFlipped + ? realClientX - realLeft + : realWidth - realClientX + realLeft + : isRealLeftFlipped + ? realWidth - realClientX + realLeft + : realClientX - realLeft // convert the clientX to units in which keys are displayed (percentage) - const leftInKeyUnits = (realLeft / width) * 100 - const maybeAccidental = realTop <= height * accidentalKeyLengthRatio! + const leftInKeyUnits = (touchLeft / realWidth) * 100 + const maybeAccidental = touchTop <= realHeight * accidentalKeyLengthRatio! const keysArray = Array.from(baseElement.children) as HTMLElement[] const keys = keysArray.filter((c) => 'key' in c.dataset) const currentOctave = keys.filter((k) => { @@ -50,7 +72,7 @@ const reverseGetKeyFromPoint: ReverseGetKeyFromPoint = (baseElement, accidentalK } const { height: keyHeight } = key.getBoundingClientRect() return { - velocity: realTop / keyHeight, + velocity: touchTop / keyHeight, key: Number(key.dataset.key), } }