Orientation is limited to 90-degree intervals.master
@@ -184,7 +184,7 @@ export const DarkStyled = (props?: Partial<Props>) => ( | |||
</div> | |||
) | |||
const HasMapComponent = () => { | |||
const HasMapComponent = (props: any) => { | |||
const [keyChannels, setKeyChannels] = React.useState<{ key: number; velocity: number; channel: number }[]>([]) | |||
const midiAccess = React.useRef<any>(undefined) | |||
@@ -223,6 +223,7 @@ const HasMapComponent = () => { | |||
return ( | |||
<Wrapper> | |||
<Keyboard | |||
{...props} | |||
startKey={21} | |||
endKey={108} | |||
keyChannels={keyChannels} | |||
@@ -274,6 +275,8 @@ const HasMapComponent = () => { | |||
export const HasMap = () => <HasMapComponent /> | |||
export const Mirrored = () => <HasMapComponent mirrored /> | |||
export const Checkbox = (props?: Partial<Props>) => ( | |||
<Wrapper> | |||
<Keyboard {...props} startKey={21} endKey={108} behavior="checkbox" name="checkbox" /> | |||
@@ -291,3 +294,23 @@ export const Link = (props?: Partial<Props>) => ( | |||
<Keyboard {...props} startKey={21} endKey={108} behavior="link" href={(key) => `?key=${key}`} /> | |||
</Wrapper> | |||
) | |||
export const Rotated90 = (props?: Partial<Props>) => ( | |||
<HasMapComponent {...props} orientation={90} width={80} height={600} /> | |||
) | |||
export const Rotated180 = (props?: Partial<Props>) => <HasMapComponent {...props} orientation={180} /> | |||
export const Rotated270 = (props?: Partial<Props>) => ( | |||
<HasMapComponent {...props} orientation={270} width={80} height={600} /> | |||
) | |||
export const Rotated90Mirrored = (props?: Partial<Props>) => ( | |||
<HasMapComponent {...props} orientation={90} width={80} height={600} mirrored /> | |||
) | |||
export const Rotated180Mirrored = (props?: Partial<Props>) => <HasMapComponent {...props} orientation={180} mirrored /> | |||
export const Rotated270Mirrored = (props?: Partial<Props>) => ( | |||
<HasMapComponent {...props} orientation={270} width={80} height={600} mirrored /> | |||
) |
@@ -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<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 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<Props> = ({ | |||
startKey, | |||
endKey, | |||
//octaveDivision = 12, | |||
octaveDivision = 12, | |||
accidentalKeyLengthRatio = 0.65, | |||
keyChannels = [], | |||
width = '100%', | |||
@@ -124,6 +120,8 @@ const Keyboard: React.FC<Props> = ({ | |||
href, | |||
midiInput, | |||
keyboardVelocity, | |||
orientation = 0, | |||
mirrored = false, | |||
}) => { | |||
const [clientSide, setClientSide] = React.useState(false) | |||
const [clientSideKeys, setClientSideKeys] = React.useState<number[]>([]) | |||
@@ -144,6 +142,30 @@ const Keyboard: React.FC<Props> = ({ | |||
}, [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 ( | |||
<React.Fragment> | |||
@@ -176,7 +198,8 @@ const Keyboard: React.FC<Props> = ({ | |||
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<string, string> = { | |||
@@ -202,11 +225,11 @@ const Keyboard: React.FC<Props> = ({ | |||
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<Props> = ({ | |||
keyboardMapping={keyboardMapping} | |||
midiInput={midiInput} | |||
keyboardVelocity={keyboardVelocity} | |||
orientation={orientation} | |||
mirrored={mirrored} | |||
/> | |||
)} | |||
</div> | |||
@@ -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<typeof propTypes> | |||
/** | |||
* 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<Props> = ({ | |||
accidentalKeyLengthRatio, | |||
@@ -45,6 +49,8 @@ const KeyboardMap: React.FC<Props> = ({ | |||
keyboardMapping = {}, | |||
midiInput, | |||
keyboardVelocity = 0.75, | |||
orientation = 0, | |||
mirrored = false, | |||
}) => { | |||
const baseRef = React.useRef<HTMLDivElement>(null) | |||
const keysOnRef = React.useRef<any[]>([]) | |||
@@ -54,46 +60,66 @@ const KeyboardMap: React.FC<Props> = ({ | |||
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<Props> = ({ | |||
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<Props> = ({ | |||
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<Props> = ({ | |||
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<Props> = ({ | |||
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<Props> = ({ | |||
} | |||
}) | |||
} | |||
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<Props> = ({ | |||
return () => { | |||
window.removeEventListener('mouseup', handleMouseUp) | |||
} | |||
}, [accidentalKeyLengthRatio, onChange]) | |||
}, [onChange]) | |||
React.useEffect(() => { | |||
const baseRefComponent = baseRef.current | |||
@@ -295,11 +305,9 @@ const KeyboardMap: React.FC<Props> = ({ | |||
} | |||
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<Props> = ({ | |||
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<Props> = ({ | |||
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<Props> = ({ | |||
}} | |||
onContextMenu={preventDefault} | |||
onDragStart={preventDefault} | |||
onMouseDown={handleMouseDown} | |||
tabIndex={0} | |||
/> | |||
) | |||
@@ -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 |
@@ -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), | |||
} | |||
} | |||