Musical keyboard component written in React.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

291 lines
8.5 KiB

  1. import * as React from 'react'
  2. import * as PropTypes from 'prop-types'
  3. import isNaturalKeyUnmemoized from '../../services/isNaturalKey'
  4. import getKeyWidthUnmemoized from '../../services/getKeyWidth'
  5. import getKeyLeftUnmemoized from '../../services/getKeyLeft'
  6. import generateKeys from '../../services/generateKeys'
  7. import DefaultAccidentalKey from '../AccidentalKey/AccidentalKey'
  8. import DefaultNaturalKey from '../NaturalKey/NaturalKey'
  9. import KeyboardMap from '../KeyboardMap/KeyboardMap'
  10. import getKeyBounds from '../../services/getKeyBounds'
  11. import { BEHAVIORS, OCTAVE_DIVISIONS, ORIENTATIONS, COMPONENTS } from '../../services/constants'
  12. const propTypes = {
  13. /**
  14. * MIDI note of the first key.
  15. */
  16. startKey: PropTypes.number.isRequired,
  17. /**
  18. * MIDI note of the last key.
  19. */
  20. endKey: PropTypes.number.isRequired,
  21. /**
  22. * Equal parts of an octave.
  23. */
  24. octaveDivision: PropTypes.oneOf(OCTAVE_DIVISIONS),
  25. /**
  26. * Ratio of the length of the accidental keys to the natural keys.
  27. */
  28. accidentalKeyLengthRatio: PropTypes.number,
  29. /**
  30. * Current active keys and their channel assignments.
  31. */
  32. keysOn: PropTypes.arrayOf(
  33. PropTypes.shape({
  34. key: PropTypes.number.isRequired,
  35. velocity: PropTypes.number.isRequired,
  36. }),
  37. ),
  38. /**
  39. * Components to use for each kind of key.
  40. */
  41. keyComponents: PropTypes.shape({
  42. natural: PropTypes.elementType,
  43. accidental: PropTypes.elementType,
  44. }),
  45. /**
  46. * Width of the component.
  47. */
  48. width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
  49. /**
  50. * Height of the component.
  51. */
  52. height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
  53. /**
  54. * Event handler triggered upon change in activated keys in the component.
  55. */
  56. onChange: PropTypes.func,
  57. /**
  58. * Map from key code to key number, used to activate the component from the keyboard.
  59. */
  60. keyboardMapping: PropTypes.object,
  61. /**
  62. * Behavior of the component when clicking.
  63. */
  64. fallbackBehavior: PropTypes.oneOf(BEHAVIORS),
  65. /**
  66. * Name of the component used for forms.
  67. */
  68. name: PropTypes.string,
  69. /**
  70. * Destination of the component upon clicking a key, if fallbackBehavior is set to 'link'.
  71. */
  72. href: PropTypes.func,
  73. /**
  74. * MIDI input for sending MIDI messages to the component.
  75. */
  76. midiInput: PropTypes.shape({
  77. addEventListener: PropTypes.func.isRequired,
  78. removeEventListener: PropTypes.func.isRequired,
  79. }),
  80. /**
  81. * Received velocity when activating the component through the keyboard.
  82. */
  83. keyboardVelocity: PropTypes.number,
  84. /**
  85. * Orientation of the component.
  86. */
  87. orientation: PropTypes.oneOf(ORIENTATIONS),
  88. /**
  89. * Is the component mirrored?
  90. */
  91. mirrored: PropTypes.bool,
  92. /**
  93. * Function returning the label of each key.
  94. */
  95. keyLabels: PropTypes.func,
  96. }
  97. export type Props = PropTypes.InferProps<typeof propTypes>
  98. /**
  99. * Component for displaying musical notes in the form of a piano keyboard.
  100. */
  101. const Keyboard: React.FC<Props> = ({
  102. startKey,
  103. endKey,
  104. octaveDivision = 12,
  105. accidentalKeyLengthRatio = 0.65,
  106. keysOn = [],
  107. width = '100%',
  108. keyComponents = {},
  109. height = 80,
  110. onChange,
  111. keyboardMapping,
  112. fallbackBehavior,
  113. name,
  114. href,
  115. midiInput,
  116. keyboardVelocity,
  117. orientation = 0,
  118. mirrored = false,
  119. keyLabels,
  120. }) => {
  121. const [clientSide, setClientSide] = React.useState(false)
  122. const [clientSideKeys, setClientSideKeys] = React.useState<number[]>([])
  123. const { natural: NaturalKey = DefaultNaturalKey, accidental: AccidentalKey = DefaultAccidentalKey } = keyComponents!
  124. const getKeyWidth = React.useCallback((k) => getKeyWidthUnmemoized(startKey, endKey)(k), [startKey, endKey])
  125. const getKeyLeft = React.useCallback((k) => getKeyLeftUnmemoized(startKey, endKey)(k), [startKey, endKey])
  126. const isNaturalKey = React.useCallback((k) => isNaturalKeyUnmemoized(k), [])
  127. const baseRef = React.useRef<HTMLDivElement>(null)
  128. React.useEffect(() => {
  129. setClientSide(true)
  130. }, [])
  131. React.useEffect(() => {
  132. setClientSideKeys(generateKeys(startKey!, endKey!))
  133. }, [startKey, endKey])
  134. const keys = clientSide ? clientSideKeys : generateKeys(startKey, endKey)
  135. const widthDimension = orientation === 90 || orientation === 270 ? 'height' : 'width'
  136. const heightDimension = orientation === 90 || orientation === 270 ? 'width' : 'height'
  137. let leftDirection: string
  138. let topDirection: string
  139. switch (orientation) {
  140. default:
  141. case 0:
  142. leftDirection = 'left'
  143. topDirection = 'top'
  144. break
  145. case 90:
  146. leftDirection = 'bottom'
  147. topDirection = 'left'
  148. break
  149. case 180:
  150. leftDirection = 'right'
  151. topDirection = 'bottom'
  152. break
  153. case 270:
  154. leftDirection = 'top'
  155. topDirection = 'right'
  156. break
  157. }
  158. return (
  159. <React.Fragment>
  160. <style>{`
  161. .ReactMusicalKeyboard-checkbox:checked + * {
  162. --opacity-highlight: 1,
  163. }
  164. `}</style>
  165. <div
  166. style={{
  167. width: width!,
  168. height: height!,
  169. position: 'relative',
  170. backgroundColor: 'currentColor',
  171. overflow: 'hidden',
  172. }}
  173. role="presentation"
  174. ref={baseRef}
  175. >
  176. {keys.map((key) => {
  177. const isNatural = isNaturalKey(key)
  178. const Component: any = isNatural ? NaturalKey! : AccidentalKey!
  179. const [currentKey = null] = Array.isArray(keysOn!) ? keysOn.filter((kc) => kc!.key === key) : []
  180. const width = getKeyWidth(key)
  181. const left = getKeyLeft(key)
  182. const { left: leftBounds, right: rightBounds } = getKeyBounds(
  183. startKey,
  184. endKey,
  185. getKeyLeft,
  186. getKeyWidth,
  187. )(key, left, width)
  188. const octaveStart = Math.floor(key / 12) * 12
  189. // TODO implement xenharmonic keyboards
  190. const theOctaveDivision = (octaveDivision as number) !== 12 ? 12 : octaveDivision
  191. const octaveEnd = octaveStart + 12 * (1 - 1 / theOctaveDivision!)
  192. const octaveLeftBounds = getKeyLeft(octaveStart)
  193. const octaveRightBounds = getKeyLeft(octaveEnd) + getKeyWidth(octaveEnd)
  194. const { [fallbackBehavior!]: component = 'div' } = COMPONENTS
  195. const KeyComponent = component as React.ElementType
  196. return (
  197. <KeyComponent
  198. key={key}
  199. href={fallbackBehavior === 'link' ? href!(key) : undefined}
  200. data-key={key}
  201. data-octave-left-bounds={octaveLeftBounds}
  202. data-octave-right-bounds={octaveRightBounds}
  203. data-left-bounds={leftBounds}
  204. data-right-bounds={rightBounds}
  205. data-left-full-bounds={isNatural ? left : undefined}
  206. data-right-full-bounds={isNatural ? left + width : undefined}
  207. style={{
  208. zIndex: isNatural ? 0 : 2,
  209. [widthDimension]: width + '%',
  210. [heightDimension]: (isNatural ? 100 : 100 * accidentalKeyLengthRatio!) + '%',
  211. [leftDirection]: (mirrored ? 100 - width - left : left) + '%',
  212. position: 'absolute',
  213. [topDirection]: 0,
  214. cursor: onChange || fallbackBehavior ? 'pointer' : undefined,
  215. color: 'inherit',
  216. '--opacity-highlight': currentKey !== null ? 1 : 0,
  217. }}
  218. >
  219. {(fallbackBehavior! === 'checkbox' || fallbackBehavior === 'radio') && (
  220. <input
  221. type={fallbackBehavior}
  222. className="ReactMusicalKeyboard-checkbox"
  223. name={name!}
  224. value={key}
  225. defaultChecked={currentKey !== null}
  226. style={{
  227. position: 'absolute',
  228. left: -999999,
  229. width: 1,
  230. height: 1,
  231. }}
  232. />
  233. )}
  234. <Component
  235. label={typeof keyLabels! === 'function' ? keyLabels(key) : null}
  236. orientation={orientation}
  237. mirrored={mirrored}
  238. />
  239. </KeyComponent>
  240. )
  241. })}
  242. {clientSide && (
  243. <KeyboardMap
  244. accidentalKeyLengthRatio={accidentalKeyLengthRatio}
  245. onChange={onChange}
  246. keyboardMapping={keyboardMapping}
  247. midiInput={midiInput}
  248. keyboardVelocity={keyboardVelocity}
  249. orientation={orientation}
  250. mirrored={mirrored}
  251. />
  252. )}
  253. </div>
  254. </React.Fragment>
  255. )
  256. }
  257. Keyboard.propTypes = propTypes
  258. export default Keyboard