@@ -0,0 +1,11 @@ | |||
root = true | |||
[*] | |||
charset = utf-8 | |||
end_of_line = lf | |||
indent_size = 2 | |||
indent_style = space | |||
insert_final_newline = true | |||
max_line_length = 120 | |||
tab_width = 8 | |||
trim_trailing_whitespace = true |
@@ -0,0 +1,92 @@ | |||
.DS_Store | |||
.AppleDouble | |||
.LSOverride | |||
._* | |||
.DocumentRevisions-V100 | |||
.fseventsd | |||
.Spotlight-V100 | |||
.TemporaryItems | |||
.Trashes | |||
.VolumeIcon.icns | |||
.com.apple.timemachine.donotpresent | |||
.AppleDB | |||
.AppleDesktop | |||
Network Trash Folder | |||
Temporary Items | |||
.apdisk | |||
.idea/ | |||
cmake-build-*/ | |||
*.iws | |||
out/ | |||
.idea_modules/ | |||
atlassian-ide-plugin.xml | |||
com_crashlytics_export_strings.xml | |||
crashlytics.properties | |||
crashlytics-build.properties | |||
fabric.properties | |||
.vscode/ | |||
*.code-workspace | |||
.history/ | |||
Thumbs.db | |||
Thumbs.db:encryptable | |||
ehthumbs.db | |||
ehthumbs_vista.db | |||
*.stackdump | |||
[Dd]esktop.ini | |||
$RECYCLE.BIN/ | |||
*.cab | |||
*.msi | |||
*.msix | |||
*.msm | |||
*.msp | |||
*.lnk | |||
logs | |||
*.log | |||
npm-debug.log* | |||
yarn-debug.log* | |||
yarn-error.log* | |||
lerna-debug.log* | |||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json | |||
pids | |||
*.pid | |||
*.seed | |||
*.pid.lock | |||
lib-cov | |||
coverage | |||
*.lcov | |||
.nyc_output | |||
.grunt | |||
bower_components | |||
.lock-wscript | |||
build/Release | |||
node_modules/ | |||
jspm_packages/ | |||
web_modules/ | |||
*.tsbuildinfo | |||
.npm | |||
.eslintcache | |||
.rpt2_cache/ | |||
.rts2_cache_cjs/ | |||
.rts2_cache_es/ | |||
.rts2_cache_umd/ | |||
.node_repl_history | |||
*.tgz | |||
.yarn-integrity | |||
.env | |||
.env.test | |||
.cache | |||
.parcel-cache | |||
.next | |||
.nuxt | |||
dist | |||
.cache/ | |||
.vuepress/dist | |||
.serverless/ | |||
.fusebox/ | |||
.dynamodb/ | |||
.tern-port | |||
.vscode-test | |||
.yarn/cache | |||
.yarn/unplugged | |||
.yarn/build-state.yml | |||
.pnp.* |
@@ -0,0 +1,11 @@ | |||
{ | |||
"printWidth": 120, | |||
"semi": false, | |||
"singleQuote": true, | |||
"jsxSingleQuote": false, | |||
"trailingComma": "all", | |||
"arrowParens": "always", | |||
"jsxBracketSameLine": false, | |||
"quoteProps": "as-needed", | |||
"endOfLine": "lf" | |||
} |
@@ -0,0 +1,24 @@ | |||
module.exports = { | |||
stories: ['../src/**/*.stories.(ts|tsx)'], | |||
addons: ['@storybook/addon-actions', '@storybook/addon-links', '@storybook/addon-docs'], | |||
webpackFinal: async (config) => { | |||
config.module.rules.push({ | |||
test: /\.(ts|tsx)$/, | |||
use: [ | |||
{ | |||
loader: require.resolve('ts-loader'), | |||
options: { | |||
transpileOnly: true, | |||
}, | |||
}, | |||
{ | |||
loader: require.resolve('react-docgen-typescript-loader'), | |||
}, | |||
], | |||
}); | |||
config.resolve.extensions.push('.ts', '.tsx'); | |||
return config; | |||
}, | |||
}; |
@@ -0,0 +1,21 @@ | |||
MIT License | |||
Copyright (c) 2020 TheoryOfNekomata | |||
Permission is hereby granted, free of charge, to any person obtaining a copy | |||
of this software and associated documentation files (the "Software"), to deal | |||
in the Software without restriction, including without limitation the rights | |||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |||
copies of the Software, and to permit persons to whom the Software is | |||
furnished to do so, subject to the following conditions: | |||
The above copyright notice and this permission notice shall be included in all | |||
copies or substantial portions of the Software. | |||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||
SOFTWARE. |
@@ -0,0 +1,35 @@ | |||
# Musical Keyboard | |||
Musical keyboard component written in React. | |||
## Installation | |||
```shell script | |||
yarn add @theoryofnekomata/react-musical-keyboard | |||
``` | |||
## Usage | |||
```jsx harmony | |||
import * as React from 'react' | |||
import ReactDOM from 'react-dom' | |||
import Keyboard from '@theoryofnekomata/react-musical-keyboard' | |||
const App = () => { | |||
return ( | |||
<div> | |||
<Keyboard startKey={21} endKey={108}/> | |||
</div> | |||
) | |||
} | |||
const container = window.document.createElement('div') | |||
ReactDOM.render(<App />, container) | |||
window.document.body.appendChild(container) | |||
``` | |||
## License | |||
MIT. See [License file](./LICENSE) for details. |
@@ -0,0 +1,59 @@ | |||
{ | |||
"version": "0.0.0", | |||
"license": "MIT", | |||
"main": "dist/index.js", | |||
"typings": "dist/index.d.ts", | |||
"files": [ | |||
"dist", | |||
"src" | |||
], | |||
"engines": { | |||
"node": ">=10" | |||
}, | |||
"scripts": { | |||
"start": "tsdx watch", | |||
"build": "tsdx build", | |||
"test": "tsdx test", | |||
"lint": "tsdx lint", | |||
"prepare": "tsdx build", | |||
"storybook": "start-storybook -p 6006", | |||
"build-storybook": "build-storybook" | |||
}, | |||
"peerDependencies": { | |||
"react": ">=16" | |||
}, | |||
"husky": { | |||
"hooks": { | |||
"pre-commit": "tsdx lint" | |||
} | |||
}, | |||
"name": "@theoryofnekomata/react-musical-keyboard", | |||
"author": "TheoryOfNekomata <allan.crisostomo@outlook.com>", | |||
"module": "dist/react-musical-keyboard.esm.js", | |||
"devDependencies": { | |||
"@babel/core": "^7.11.1", | |||
"@storybook/addon-actions": "^5.3.19", | |||
"@storybook/addon-docs": "^5.3.19", | |||
"@storybook/addon-info": "^5.3.19", | |||
"@storybook/addon-links": "^5.3.19", | |||
"@storybook/addons": "^5.3.19", | |||
"@storybook/react": "^5.3.19", | |||
"@types/prop-types": "^15.7.3", | |||
"@types/react": "^16.9.44", | |||
"@types/react-dom": "^16.9.8", | |||
"@types/styled-components": "^5.1.2", | |||
"babel-loader": "^8.1.0", | |||
"fast-check": "^2.0.0", | |||
"husky": "^4.2.5", | |||
"prop-types": "^15.7.2", | |||
"react": "^16.13.1", | |||
"react-docgen-typescript-loader": "^3.7.2", | |||
"react-dom": "^16.13.1", | |||
"react-is": "^16.13.1", | |||
"styled-components": "^5.1.1", | |||
"ts-loader": "^8.0.2", | |||
"tsdx": "^0.13.2", | |||
"tslib": "^2.0.0", | |||
"typescript": "^3.9.7" | |||
} | |||
} |
@@ -0,0 +1,13 @@ | |||
import * as React from 'react' | |||
import * as PropTypes from 'prop-types' | |||
import Keyboard, { propTypes, } from './Keyboard' | |||
export default { | |||
title: 'Keyboard', | |||
} | |||
type Props = PropTypes.InferProps<typeof propTypes> | |||
// By passing optional props to this story, you can control the props of the component when | |||
// you consume the story in a test. | |||
export const Default = (props?: Partial<Props>) => <Keyboard {...props} />; |
@@ -0,0 +1,9 @@ | |||
import * as React from 'react' | |||
import * as ReactDOM from 'react-dom' | |||
import { Default as Keyboard } from './Keyboard.stories'; | |||
it('renders without crashing', () => { | |||
const div = document.createElement('div'); | |||
ReactDOM.render(<Keyboard startKey={21} endKey={108}/>, div); | |||
ReactDOM.unmountComponentAtNode(div); | |||
}); |
@@ -0,0 +1,141 @@ | |||
import * as React from 'react' | |||
import * as PropTypes from 'prop-types' | |||
import styled from 'styled-components' | |||
import isNaturalKey from '../services/isNaturalKey' | |||
import getKeyWidth from '../services/getKeyWidth' | |||
import getKeyLeft from '../services/getKeyLeft' | |||
import generateKeys from '../services/generateKeys' | |||
const Base = styled('div')({ | |||
position: 'relative', | |||
}) | |||
const Key = styled('div')({ | |||
position: 'absolute', | |||
border: '1px solid', | |||
boxSizing: 'border-box', | |||
top: 0, | |||
}) | |||
const NaturalKey = styled(Key)({ | |||
zIndex: 0, | |||
backgroundColor: 'transparent', | |||
}) | |||
const AccidentalKey = styled(Key)({ | |||
zIndex: 2, | |||
backgroundColor: 'currentColor', | |||
}) | |||
const Highlight = styled('div')({ | |||
width: '100%', | |||
height: '100%', | |||
}) | |||
export const propTypes = { | |||
startKey: PropTypes.number.isRequired, | |||
endKey: PropTypes.number.isRequired, | |||
//octaveDivision: PropTypes.number, | |||
accidentalKeyLengthRatio: PropTypes.number, | |||
keyChannels: PropTypes.arrayOf(PropTypes.shape({ | |||
channel: PropTypes.number.isRequired, | |||
key: PropTypes.number.isRequired, | |||
velocity: PropTypes.number.isRequired, | |||
})), | |||
channelColors: PropTypes.arrayOf(PropTypes.string), | |||
width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), | |||
height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), | |||
} | |||
const DEFAULT_CHANNEL_COLORS = [ | |||
'#ff5555', | |||
'#ffff00', | |||
'#00aa00', | |||
'#0055aa', | |||
'#aa00ff', | |||
'#aa0000', | |||
'#aa5500', | |||
'#ffaa00', | |||
'#00ff00', | |||
'#00aaaa', | |||
'#00ffff', | |||
'#ff00aa', | |||
'#aaaa00', | |||
'#555500', | |||
'#5500aa', | |||
'#ff55ff', | |||
] | |||
type Props = PropTypes.InferProps<typeof propTypes> | |||
const Keyboard: React.FC<Props> = ({ | |||
startKey, | |||
endKey, | |||
//octaveDivision = 12, | |||
accidentalKeyLengthRatio = 0.65, | |||
keyChannels = [], | |||
channelColors = DEFAULT_CHANNEL_COLORS, | |||
width = '100%', | |||
height = 64, | |||
}) => { | |||
const [keys, setKeys, ] = React.useState<number[]>([]) | |||
React.useEffect(() => { | |||
setKeys(generateKeys(startKey!, endKey!)) | |||
}, [startKey, endKey, ]) | |||
return ( | |||
<Base | |||
style={{ | |||
width: width!, | |||
height: height!, | |||
}} | |||
> | |||
{keys.map(k => { | |||
const isNatural = isNaturalKey(k) | |||
const Component = isNatural ? NaturalKey : AccidentalKey | |||
const width = getKeyWidth(startKey!, endKey!)(k) | |||
const height = isNatural ? 100 : 100 * accidentalKeyLengthRatio! | |||
const left = getKeyLeft(startKey!, endKey!)(k) | |||
const currentKeyChannels = ( | |||
Array.isArray(keyChannels!) | |||
? keyChannels.filter(kc => kc!.key === k) | |||
: null | |||
) | |||
return ( | |||
<Component | |||
style={{ | |||
width: width + '%', | |||
height: height + '%', | |||
left: left + '%', | |||
}} | |||
> | |||
{ | |||
Array.isArray(currentKeyChannels) | |||
&& currentKeyChannels.map(c => ( | |||
<Highlight | |||
key={c!.channel} | |||
style={{ | |||
backgroundColor: channelColors![c!.channel] as string, | |||
}} | |||
/> | |||
)) | |||
} | |||
</Component> | |||
) | |||
})} | |||
</Base> | |||
) | |||
} | |||
Keyboard.propTypes = propTypes | |||
export default Keyboard |
@@ -0,0 +1,3 @@ | |||
import Keyboard from './components/Keyboard' | |||
export default Keyboard |
@@ -0,0 +1,36 @@ | |||
/* | |||
4 | |||
+-----------------------------------+ | |||
3 | |||
+--------------------------+ | |||
* * * * * * * * * * ** | |||
+----+-----+----+-----+----+---+-----+----+-----+----+-----+---+ | |||
| | | | | | | | | | | | | | |||
| | | | | | | | | | | | | | |||
| | | | | | | | | | | | | | |||
| | | | | | | | | | | | | | |||
| | | | | | | | | | | | | | |||
| +---+-+ +-+---+ | +----++ +--+--+ ++---++ | | |||
| | | | | | | | | |||
| | | | | | | | | |||
| | | | | | | | | |||
+--------+--------+--------+--------+--------+--------+--------+ | |||
*/ | |||
export default [ | |||
0, // C | |||
3 / 7 / 5, // C# | |||
1 / 7, // D | |||
3 / 7 / 5 * 3, // D# | |||
2 / 7, // E | |||
3 / 7, // F | |||
(3 / 7) + (4 / 7 / 7), // F# | |||
4 / 7, // G | |||
(3 / 7) + (4 / 7 / 7 * 3), // G# | |||
5 / 7, // A | |||
(3 / 7) + (4 / 7 / 7 * 5), // A# | |||
6 / 7, // B | |||
] |
@@ -0,0 +1,11 @@ | |||
interface GenerateKeys { | |||
(startKey: number, endKey: number): number[], | |||
} | |||
const generateKeys: GenerateKeys = (startKey, endKey) => ( | |||
Array(endKey! - startKey! + 1) | |||
.fill(0) | |||
.map((_, i) => startKey! + i) | |||
) | |||
export default generateKeys |
@@ -0,0 +1,49 @@ | |||
import getKeyXOffset from './getKeyXOffset' | |||
import getOctaveCount from './getOctaveCount' | |||
import generateKeys from './generateKeys' | |||
import groupKeysIntoOctaves from './groupKeysIntoOctaves' | |||
import getOctaveCompleteness from './getOctaveCompleteness' | |||
interface GetKeyLeft { | |||
(k: number): number, | |||
} | |||
interface GetKeyLeftDecorator { | |||
(startKey: number, endKey: number): GetKeyLeft, | |||
} | |||
const getKeyLeftDecorator: GetKeyLeftDecorator = (startKey, endKey): GetKeyLeft => (k) => { | |||
const dummyKeys = generateKeys(startKey, endKey) | |||
const keysGroupedIntoOctaves = groupKeysIntoOctaves(dummyKeys) | |||
const octaveCompleteness = Object | |||
.entries(keysGroupedIntoOctaves) | |||
.map<number[]>(([octave, keys]) => [ | |||
octave as unknown as number, | |||
keys[0], | |||
keys.slice(-1)[0], | |||
]) | |||
.reduce<Record<number, number>>( | |||
(theOctaveCompleteness, [octave, firstKey, lastKey]) => ({ | |||
...theOctaveCompleteness, | |||
[octave]: getOctaveCompleteness(firstKey, lastKey), | |||
}), | |||
{} | |||
) | |||
const fractionalOctaveCount = Object | |||
.values(octaveCompleteness) | |||
.reduce( | |||
(a, b) => a + b, | |||
0 | |||
) | |||
const octaveCount = getOctaveCount(startKey, endKey) | |||
const startOctave = Math.floor(startKey! / 12) | |||
const octave = Math.floor(k / 12) | |||
const octaveOffset = (100 * octaveCount / fractionalOctaveCount) / octaveCount * (octave - startOctave) | |||
const theKeyOffset = octaveOffset + (100 * octaveCount / fractionalOctaveCount / octaveCount * getKeyXOffset(k)) | |||
const firstKeyOffset = (100 * octaveCount / fractionalOctaveCount / octaveCount * getKeyXOffset(startKey + 12)) | |||
return theKeyOffset - firstKeyOffset | |||
} | |||
export default getKeyLeftDecorator |
@@ -0,0 +1,46 @@ | |||
import isNaturalKey from './isNaturalKey' | |||
import groupKeysIntoOctaves from './groupKeysIntoOctaves' | |||
import getOctaveCompleteness from './getOctaveCompleteness' | |||
import getOctaveCount from './getOctaveCount' | |||
import generateKeys from './generateKeys' | |||
interface GetKeyWidth { | |||
(k: number): number, | |||
} | |||
interface GetKeyWidthDecorator { | |||
(startKey: number, endKey: number): GetKeyWidth, | |||
} | |||
const ACCIDENTAL_KEY_TO_NATURAL_KEY_WIDTH_RATIO = 18 / 36 | |||
const getKeyWidthDecorator: GetKeyWidthDecorator = (startKey, endKey): GetKeyWidth => (k) => { | |||
const dummyKeys = generateKeys(startKey, endKey) | |||
const keysGroupedIntoOctaves = groupKeysIntoOctaves(dummyKeys) | |||
const octaveCompleteness = Object | |||
.entries(keysGroupedIntoOctaves) | |||
.map<number[]>(([octave, keys]) => [ | |||
octave as unknown as number, | |||
keys[0], | |||
keys.slice(-1)[0], | |||
]) | |||
.reduce<Record<number, number>>( | |||
(theOctaveCompleteness, [octave, firstKey, lastKey]) => ({ | |||
...theOctaveCompleteness, | |||
[octave]: getOctaveCompleteness(firstKey, lastKey), | |||
}), | |||
{} | |||
) | |||
const fractionalOctaveCount = Object | |||
.values(octaveCompleteness) | |||
.reduce( | |||
(a, b) => a + b, | |||
0 | |||
) | |||
const octaveCount = getOctaveCount(startKey, endKey) | |||
const naturalKeyWidth = 100 * (octaveCount / fractionalOctaveCount) / (octaveCount * 7) | |||
return isNaturalKey(k) ? naturalKeyWidth : naturalKeyWidth * ACCIDENTAL_KEY_TO_NATURAL_KEY_WIDTH_RATIO // naturalKeyWidth * 13.7 / 23.5} | |||
} | |||
export default getKeyWidthDecorator |
@@ -0,0 +1,5 @@ | |||
import KEY_OFFSETS from './constants/keyOffsets' | |||
export default (k: number): number => { | |||
return KEY_OFFSETS[k % 12] | |||
} |
@@ -0,0 +1,10 @@ | |||
import getKeyXOffset from './getKeyXOffset' | |||
import isNaturalKey from './isNaturalKey' | |||
// expect firstKey and lastKey within the same octave | |||
export default (firstKey: number, lastKey: number) => ( | |||
// see if there are missing higher notes | |||
getKeyXOffset(lastKey) + (isNaturalKey(lastKey) ? 1 / 7 : 1 / 7 * 18/36) | |||
// see if there are missing lower notes | |||
- getKeyXOffset(firstKey) | |||
) |
@@ -0,0 +1,11 @@ | |||
interface GetOctaveCount { | |||
(startKey: number, endKey: number): number, | |||
} | |||
const getOctaveCount: GetOctaveCount = (startKey, endKey) => { | |||
const startOctave = Math.floor(startKey / 12) | |||
const endOctave = Math.floor(endKey / 12) | |||
return endOctave - startOctave + 1 | |||
} | |||
export default getOctaveCount |
@@ -0,0 +1,15 @@ | |||
export default (dummyKeys: number[]): Record<number, number[]> => ( | |||
dummyKeys | |||
.map(k => [k, Math.floor(k / 12)]) | |||
.reduce<Record<number, number[]>>( | |||
(theOctaves, [key, keyOctave, ]) => ({ | |||
...theOctaves, | |||
[keyOctave]: ( | |||
Array.isArray(theOctaves[keyOctave]) | |||
? [...theOctaves[keyOctave], key] | |||
: [key] | |||
) | |||
}), | |||
{} | |||
) | |||
) |
@@ -0,0 +1,76 @@ | |||
import * as fc from 'fast-check' | |||
import isNaturalKey from './isNaturalKey' | |||
it('should exist', () => { | |||
expect(isNaturalKey).toBeDefined() | |||
}) | |||
it('should be a callable', () => { | |||
expect(typeof isNaturalKey).toBe('function') | |||
}) | |||
it('should accept 1 parameter', () => { | |||
expect(isNaturalKey).toHaveLength(1) | |||
}) | |||
it('should throw TypeError upon passing invalid types', () => { | |||
fc.assert( | |||
fc.property( | |||
fc.anything().filter(anything => typeof anything !== 'number'), | |||
anything => { | |||
expect(() => isNaturalKey(anything as number)).toThrowError(TypeError) | |||
} | |||
) | |||
) | |||
}) | |||
it('should throw RangeError upon passing NaN', () => { | |||
expect(() => isNaturalKey(NaN)).toThrowError(RangeError) | |||
}) | |||
it('should throw RangeError upon passing negative numbers', () => { | |||
fc.assert( | |||
fc.property( | |||
fc.anything().filter(anything => ( | |||
typeof anything! === 'number' | |||
&& !isNaN(anything) | |||
&& anything < 0 | |||
)), | |||
negativeValue => { | |||
expect(() => isNaturalKey(negativeValue as number)).toThrowError(RangeError) | |||
} | |||
) | |||
) | |||
}) | |||
describe('upon passing a positive number or zero', () => { | |||
it('should not throw any error', () => { | |||
fc.assert( | |||
fc.property( | |||
fc.anything().filter(anything => ( | |||
typeof anything! === 'number' | |||
&& !isNaN(anything) | |||
&& anything >= 0 | |||
)), | |||
value => { | |||
expect(() => isNaturalKey(value as number)).not.toThrow() | |||
} | |||
) | |||
) | |||
}) | |||
it('should return a boolean', () => { | |||
fc.assert( | |||
fc.property( | |||
fc.anything().filter(anything => ( | |||
typeof anything! === 'number' | |||
&& !isNaN(anything) | |||
&& anything >= 0 | |||
)), | |||
value => { | |||
expect(typeof isNaturalKey(value as number)).toBe('boolean') | |||
} | |||
) | |||
) | |||
}) | |||
}) |
@@ -0,0 +1,15 @@ | |||
const NATURAL_KEYS = [0, 2, 4, 5, 7, 9, 11] | |||
export default (k: number): boolean => { | |||
const type = typeof (k as unknown) | |||
if (type as string !== 'number') { | |||
throw TypeError(`Invalid value type passed to isNaturalKey, expected 'number', got ${type}.`) | |||
} | |||
if (isNaN(k)) { | |||
throw RangeError('Value passed is NaN.') | |||
} | |||
if (k < 0) { | |||
throw RangeError('Value must be positive.') | |||
} | |||
return NATURAL_KEYS.includes(Math.floor(k) % 12) | |||
} |
@@ -0,0 +1,21 @@ | |||
{ | |||
"include": ["src", "types"], | |||
"compilerOptions": { | |||
"module": "esnext", | |||
"lib": ["dom", "esnext"], | |||
"importHelpers": true, | |||
"declaration": true, | |||
"sourceMap": true, | |||
"rootDir": "./src", | |||
"strict": true, | |||
"noUnusedLocals": true, | |||
"noUnusedParameters": true, | |||
"noImplicitReturns": true, | |||
"noFallthroughCasesInSwitch": true, | |||
"allowSyntheticDefaultImports": true, | |||
"moduleResolution": "node", | |||
"baseUrl": "./", | |||
"jsx": "react", | |||
"esModuleInterop": true | |||
} | |||
} |