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.
 
 
 
 

238 lines
7.3 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. const BEHAVIOR = ['link', 'checkbox', 'radio'] as const
  12. export 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. * Does the component have a clickable map?
  23. */
  24. hasMap: PropTypes.bool,
  25. //octaveDivision: PropTypes.number,
  26. /**
  27. * Ratio of the length of the accidental keys to the natural keys.
  28. */
  29. accidentalKeyLengthRatio: PropTypes.number,
  30. /**
  31. * Current active keys and their channel assignments.
  32. */
  33. keyChannels: PropTypes.arrayOf(
  34. PropTypes.shape({
  35. key: PropTypes.number.isRequired,
  36. velocity: PropTypes.number.isRequired,
  37. }),
  38. ),
  39. /**
  40. * Components to use for each kind of key.
  41. */
  42. keyComponents: PropTypes.shape({
  43. natural: PropTypes.elementType,
  44. accidental: PropTypes.elementType,
  45. }),
  46. /**
  47. * Width of the component.
  48. */
  49. width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
  50. /**
  51. * Height of the component.
  52. */
  53. height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
  54. /**
  55. * Event handler triggered upon change in activated keys in the component.
  56. */
  57. onChange: PropTypes.func,
  58. /**
  59. * Map from key code to key number.
  60. */
  61. keyboardMapping: PropTypes.object,
  62. /**
  63. * Behavior of the component when clicking.
  64. */
  65. behavior: PropTypes.oneOf(BEHAVIOR),
  66. /**
  67. * Name of the component used for forms.
  68. */
  69. name: PropTypes.string,
  70. /**
  71. * Destination of the component upon clicking a key, if behavior is set to 'link'.
  72. */
  73. href: PropTypes.func,
  74. }
  75. type Props = PropTypes.InferProps<typeof propTypes>
  76. /**
  77. * Component for displaying musical notes in the form of a piano keyboard.
  78. * @param startKey - MIDI note of the first key.
  79. * @param endKey - MIDI note of the last key.
  80. * @param hasMap - The component's clickable map component.
  81. * @param accidentalKeyLengthRatio - Ratio of the length of the accidental keys to the natural keys.
  82. * @param keyChannels - Current active keys and their channel assignments.
  83. * @param width - Width of the component.
  84. * @param keyComponents - Components to use for each kind of key.
  85. * @param height - Height of the component.
  86. * @param name - Name of the component used for forms.
  87. * @param href - Destination of the component upon clicking a key, if behavior is set to 'link'.
  88. * @param behavior - Behavior of the component when clicking.
  89. */
  90. const Keyboard: React.FC<Props> = ({
  91. startKey,
  92. endKey,
  93. //octaveDivision = 12,
  94. accidentalKeyLengthRatio = 0.65,
  95. keyChannels = [],
  96. width = '100%',
  97. keyComponents = {},
  98. height = 80,
  99. onChange,
  100. keyboardMapping,
  101. behavior,
  102. name,
  103. href,
  104. }) => {
  105. const [clientSide, setClientSide] = React.useState(false)
  106. const [clientSideKeys, setClientSideKeys] = React.useState<number[]>([])
  107. const { natural: NaturalKey = DefaultNaturalKey, accidental: AccidentalKey = DefaultAccidentalKey } = keyComponents!
  108. const getKeyWidth = React.useCallback((k) => getKeyWidthUnmemoized(startKey, endKey)(k), [startKey, endKey])
  109. const getKeyLeft = React.useCallback((k) => getKeyLeftUnmemoized(startKey, endKey)(k), [startKey, endKey])
  110. const isNaturalKey = React.useCallback((k) => isNaturalKeyUnmemoized(k), [])
  111. const baseRef = React.useRef<HTMLDivElement>(null)
  112. React.useEffect(() => {
  113. setClientSide(true)
  114. }, [])
  115. React.useEffect(() => {
  116. setClientSideKeys(generateKeys(startKey!, endKey!))
  117. }, [startKey, endKey])
  118. const keys = clientSide ? clientSideKeys : generateKeys(startKey, endKey)
  119. return (
  120. <React.Fragment>
  121. <style>{`
  122. .ReactMusicalKeyboard-checkbox:checked + * {
  123. --opacity-highlight: 1,
  124. }
  125. `}</style>
  126. <div
  127. style={{
  128. width: width!,
  129. height: height!,
  130. position: 'relative',
  131. backgroundColor: 'currentColor',
  132. overflow: 'hidden',
  133. }}
  134. role="presentation"
  135. ref={baseRef}
  136. >
  137. {keys.map((key) => {
  138. const isNatural = isNaturalKey(key)
  139. const Component: any = isNatural ? NaturalKey! : AccidentalKey!
  140. const [currentKey = null] = Array.isArray(keyChannels!) ? keyChannels.filter((kc) => kc!.key === key) : []
  141. const width = getKeyWidth(key)
  142. const left = getKeyLeft(key)
  143. const { left: leftBounds, right: rightBounds } = getKeyBounds(
  144. startKey,
  145. endKey,
  146. getKeyLeft,
  147. getKeyWidth,
  148. )(key, left, width)
  149. const octaveStart = Math.floor(key / 12) * 12
  150. const octaveEnd = octaveStart + 11
  151. const octaveLeftBounds = getKeyLeft(octaveStart)
  152. const octaveRightBounds = getKeyLeft(octaveEnd) + getKeyWidth(octaveEnd)
  153. const components: Record<string, string> = {
  154. link: 'a',
  155. checkbox: 'label',
  156. radio: 'label',
  157. }
  158. const { [behavior!]: component = 'div' } = components
  159. const KeyComponent = component as React.ElementType
  160. return (
  161. <KeyComponent
  162. key={key}
  163. href={behavior === 'link' ? href!(key) : undefined}
  164. data-key={key}
  165. data-octave-left-bounds={octaveLeftBounds}
  166. data-octave-right-bounds={octaveRightBounds}
  167. data-left-bounds={leftBounds}
  168. data-right-bounds={rightBounds}
  169. data-left-full-bounds={isNatural ? left : undefined}
  170. data-right-full-bounds={isNatural ? left + width : undefined}
  171. style={{
  172. zIndex: isNatural ? 0 : 2,
  173. width: width + '%',
  174. height: (isNatural ? 100 : 100 * accidentalKeyLengthRatio!) + '%',
  175. left: left + '%',
  176. position: 'absolute',
  177. top: 0,
  178. cursor: onChange || behavior ? 'pointer' : undefined,
  179. color: 'inherit',
  180. '--opacity-highlight': currentKey !== null ? 1 : 0,
  181. }}
  182. >
  183. {(behavior! === 'checkbox' || behavior === 'radio') && (
  184. <input
  185. type={behavior}
  186. className="ReactMusicalKeyboard-checkbox"
  187. name={name!}
  188. value={key}
  189. defaultChecked={currentKey !== null}
  190. style={{
  191. position: 'absolute',
  192. left: -999999,
  193. width: 1,
  194. height: 1,
  195. }}
  196. />
  197. )}
  198. <Component />
  199. </KeyComponent>
  200. )
  201. })}
  202. {clientSide && (
  203. <KeyboardMap
  204. accidentalKeyLengthRatio={accidentalKeyLengthRatio}
  205. onChange={onChange}
  206. keyboardMapping={keyboardMapping}
  207. />
  208. )}
  209. </div>
  210. </React.Fragment>
  211. )
  212. }
  213. Keyboard.propTypes = propTypes
  214. export default Keyboard