Browse Source

Implement orientation

Orientation is limited to 90-degree intervals.
master
TheoryOfNekomata 4 years ago
parent
commit
833fd1768b
5 changed files with 186 additions and 102 deletions
  1. +24
    -1
      src/components/Keyboard/Keyboard.stories.tsx
  2. +49
    -24
      src/components/Keyboard/Keyboard.tsx
  3. +79
    -71
      src/components/KeyboardMap/KeyboardMap.tsx
  4. +6
    -0
      src/services/constants.ts
  5. +28
    -6
      src/services/reverseGetKeyFromPoint.ts

+ 24
- 1
src/components/Keyboard/Keyboard.stories.tsx View File

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

+ 49
- 24
src/components/Keyboard/Keyboard.tsx View File

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


+ 79
- 71
src/components/KeyboardMap/KeyboardMap.tsx View File

@@ -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}
/>
)


+ 6
- 0
src/services/constants.ts View File

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

+ 28
- 6
src/services/reverseGetKeyFromPoint.ts View File

@@ -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),
}
}


Loading…
Cancel
Save