zds-pickers 4.0.3 → 4.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.editorconfig +19 -0
- package/.github/workflows/codeql-analysis.yml +67 -0
- package/.nvmrc +1 -0
- package/.prettierignore +0 -0
- package/.prettierrc +13 -0
- package/.storybook/main.ts +42 -0
- package/.storybook/manager.ts +12 -0
- package/.storybook/preview.tsx +26 -0
- package/.vscode/extensions.json +32 -0
- package/.vscode/javascript.code-snippets +229 -0
- package/.vscode/settings.json +358 -0
- package/biome.jsonc +158 -0
- package/dist/index.cjs.js +15 -32
- package/dist/index.es.js +4144 -10129
- package/dist/types/hooks/useComponentSize.d.ts +5 -0
- package/dist/types/hooks/useStateWithDynamicDefault.d.ts +2 -0
- package/dist/types/index.d.ts +266 -2
- package/dist/types/main.d.ts +19 -0
- package/dist/types/midi/ccValues.d.ts +3 -0
- package/dist/types/midi/export.d.ts +22 -0
- package/dist/types/other/DefaultTooltip.d.ts +10 -0
- package/dist/types/other/OctavePlayer.d.ts +20 -0
- package/dist/types/other/SVGText.d.ts +17 -0
- package/dist/types/other/SoundFontProvider.d.ts +17 -0
- package/dist/types/pickers/CCPicker.d.ts +7 -0
- package/dist/types/pickers/ChannelMappingPicker.d.ts +14 -0
- package/dist/types/pickers/ChannelPicker.d.ts +8 -0
- package/dist/types/pickers/Knob.d.ts +12 -0
- package/dist/types/pickers/KnobPicker.d.ts +27 -0
- package/dist/types/pickers/LatchPicker.d.ts +8 -0
- package/dist/types/pickers/MappingPicker.d.ts +11 -0
- package/dist/types/pickers/NotePicker.d.ts +20 -0
- package/dist/types/pickers/PianoPicker.d.ts +9 -0
- package/dist/types/pickers/PolarityPicker.d.ts +14 -0
- package/dist/types/pickers/ResponseCurve.d.ts +40 -0
- package/dist/types/pickers/ResponseCurvePicker.d.ts +15 -0
- package/dist/types/pickers/Select.d.ts +29 -0
- package/dist/types/pickers/StatusPicker.d.ts +12 -0
- package/dist/types/pickers/ValuePicker.d.ts +15 -0
- package/dist/types/pickers/knobSkin10.d.ts +14 -0
- package/dist/types/rotaryKnob/InternalInput.d.ts +18 -0
- package/dist/types/rotaryKnob/Types.d.ts +5 -0
- package/dist/types/rotaryKnob/helpers/DrawCircle.d.ts +18 -0
- package/dist/types/rotaryKnob/helpers/DrawLine.d.ts +15 -0
- package/dist/types/rotaryKnob/helpers/HelpersOverlay.d.ts +11 -0
- package/dist/types/rotaryKnob/helpers/KnobVisualHelpers.d.ts +30 -0
- package/dist/types/rotaryKnob/index.d.ts +49 -0
- package/dist/types/rotaryKnob/knobdefaultskin.d.ts +6 -0
- package/dist/types/rotaryKnob/utils.d.ts +49 -0
- package/dist/types/utils.d.ts +9 -0
- package/lib/@types/nebiru-react-rotary-knob.d.ts +20 -0
- package/lib/@types/react-svgmt.d.ts +74 -0
- package/lib/@types/zds-mappings.d.ts +55 -0
- package/lib/@types/zds-react-piano.d.ts +17 -0
- package/lib/hooks/useComponentSize.ts +52 -0
- package/lib/hooks/useStateWithDynamicDefault.ts +13 -0
- package/lib/main.ts +34 -0
- package/lib/midi/ccValues.ts +139 -0
- package/lib/midi/export.ts +54 -0
- package/lib/other/DefaultTooltip.ts +14 -0
- package/lib/other/OctavePlayer.tsx +141 -0
- package/lib/other/SVGText.tsx +221 -0
- package/lib/other/SoundFontProvider.tsx +130 -0
- package/lib/pickers/CCPicker.tsx +28 -0
- package/lib/pickers/ChannelMappingPicker.tsx +135 -0
- package/lib/pickers/ChannelPicker.tsx +76 -0
- package/lib/pickers/Knob.tsx +93 -0
- package/lib/pickers/KnobPicker.tsx +93 -0
- package/lib/pickers/LatchPicker.tsx +33 -0
- package/lib/pickers/MappingPicker.tsx +68 -0
- package/lib/pickers/NotePicker.tsx +95 -0
- package/lib/pickers/PianoPicker.tsx +77 -0
- package/lib/pickers/PolarityPicker.tsx +39 -0
- package/lib/pickers/ResponseCurve.tsx +304 -0
- package/lib/pickers/ResponseCurvePicker.tsx +74 -0
- package/lib/pickers/Select.tsx +248 -0
- package/lib/pickers/StatusPicker.tsx +28 -0
- package/lib/pickers/ValuePicker.tsx +67 -0
- package/lib/pickers/knobSkin10.tsx +94 -0
- package/lib/rotaryKnob/InternalInput.tsx +43 -0
- package/lib/rotaryKnob/Types.ts +6 -0
- package/lib/rotaryKnob/helpers/DrawCircle.tsx +41 -0
- package/lib/rotaryKnob/helpers/DrawLine.tsx +32 -0
- package/lib/rotaryKnob/helpers/HelpersOverlay.tsx +43 -0
- package/lib/rotaryKnob/helpers/KnobVisualHelpers.tsx +138 -0
- package/lib/rotaryKnob/index.tsx +417 -0
- package/lib/rotaryKnob/knobdefaultskin.ts +56 -0
- package/lib/rotaryKnob/utils.ts +112 -0
- package/lib/temp/acoustic_grand_piano-mp3.js +94 -0
- package/lib/utils.ts +41 -0
- package/package.json +3 -7
- package/stories/assets/sampleStyles.css +349 -0
- package/stories/assets/utils.js +7 -0
- package/stories/picker.stories.tsx +313 -0
- package/tsconfig.app.json +26 -0
- package/tsconfig.json +34 -0
- package/tsconfig.node.json +24 -0
- package/vite.config.ts +45 -0
- package/dist/index.cjs.js.map +0 -1
- package/dist/index.d.ts +0 -244
- package/dist/index.es.js.map +0 -1
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Option } from 'lib/pickers/Select'
|
|
2
|
+
|
|
3
|
+
const Statuses = {
|
|
4
|
+
noteOff: 8, // 1000
|
|
5
|
+
noteOn: 9, // 1001
|
|
6
|
+
aftertouch: 10, // 1010
|
|
7
|
+
controlChange: 11, // 1011
|
|
8
|
+
programChange: 12, // 1100
|
|
9
|
+
channelPressure: 13, // 1101
|
|
10
|
+
pitchWheel: 14, // 1110
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type Status = (typeof Statuses)[keyof typeof Statuses]
|
|
14
|
+
|
|
15
|
+
const MASK_CHANNEL = 15 // 00001111
|
|
16
|
+
const MASK_STATUS = 240 // 01110000
|
|
17
|
+
|
|
18
|
+
const statusOptions: Option<Status>[] = [
|
|
19
|
+
{ value: Statuses.noteOn, label: 'Note On' },
|
|
20
|
+
{ value: Statuses.noteOff, label: 'Note Stack' },
|
|
21
|
+
{ value: Statuses.controlChange, label: 'Control Change' },
|
|
22
|
+
{ value: Statuses.programChange, label: 'Program Change' },
|
|
23
|
+
{ value: Statuses.aftertouch, label: 'Aftertouch' },
|
|
24
|
+
{ value: Statuses.channelPressure, label: 'Channel Pressure' },
|
|
25
|
+
{ value: Statuses.pitchWheel, label: 'Pitch Wheel' },
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
const getStatusLabel = (val: Status) =>
|
|
29
|
+
statusOptions.filter(({ value }) => value === val)[0]?.label || '???'
|
|
30
|
+
|
|
31
|
+
const combineStatusWithChannel = (channel: number, status: Status) =>
|
|
32
|
+
channel | (status << 4)
|
|
33
|
+
|
|
34
|
+
const extractStatusAndChannel = (
|
|
35
|
+
status: number,
|
|
36
|
+
): {
|
|
37
|
+
status: Status
|
|
38
|
+
channel: number
|
|
39
|
+
} => ({
|
|
40
|
+
status: (((status & MASK_STATUS) >> 4) | 8) as Status,
|
|
41
|
+
channel: status & MASK_CHANNEL,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
export {
|
|
45
|
+
combineStatusWithChannel,
|
|
46
|
+
extractStatusAndChannel,
|
|
47
|
+
getStatusLabel,
|
|
48
|
+
MASK_CHANNEL,
|
|
49
|
+
MASK_STATUS,
|
|
50
|
+
statusOptions,
|
|
51
|
+
Statuses,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type { Status }
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Fragment, createElement } from 'react'
|
|
2
|
+
|
|
3
|
+
type TooltipProps = {
|
|
4
|
+
children?: React.ReactNode
|
|
5
|
+
placement?: 'left' | 'right' | 'top' | 'bottom'
|
|
6
|
+
title: React.ReactNode
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const DefaultTooltip = ({ children }: React.PropsWithoutRef<TooltipProps>) =>
|
|
10
|
+
createElement(Fragment, null, children)
|
|
11
|
+
|
|
12
|
+
export default DefaultTooltip
|
|
13
|
+
|
|
14
|
+
export type { TooltipProps }
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type Soundfont from 'soundfont-player'
|
|
2
|
+
import { /* Midi, */ Note } from 'tonal'
|
|
3
|
+
import { Piano as ReactPiano } from 'zds-react-piano'
|
|
4
|
+
import type { TooltipProps } from './DefaultTooltip'
|
|
5
|
+
import SoundfontProvider from './SoundFontProvider'
|
|
6
|
+
|
|
7
|
+
const baseNotes = [
|
|
8
|
+
'C',
|
|
9
|
+
'C#',
|
|
10
|
+
'D',
|
|
11
|
+
'D#',
|
|
12
|
+
'E',
|
|
13
|
+
'F',
|
|
14
|
+
'F#',
|
|
15
|
+
'G',
|
|
16
|
+
'G#',
|
|
17
|
+
'A',
|
|
18
|
+
'A#',
|
|
19
|
+
'B',
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
// const { midiToFreq, midiToNoteName } = Midi
|
|
23
|
+
|
|
24
|
+
// console.log(Note.names(), Note.midi('C4'))
|
|
25
|
+
|
|
26
|
+
// const firstNote = 21
|
|
27
|
+
// const lastNote = 108
|
|
28
|
+
const numNotes = 12
|
|
29
|
+
|
|
30
|
+
const whiteNotes = numNotes * 0.7 // approx
|
|
31
|
+
|
|
32
|
+
const audioContext = new window.AudioContext()
|
|
33
|
+
|
|
34
|
+
type OctavePlayerProps = {
|
|
35
|
+
disabled?: boolean
|
|
36
|
+
height: number
|
|
37
|
+
octave: number
|
|
38
|
+
width: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const OctavePlayer = ({
|
|
42
|
+
octave,
|
|
43
|
+
height,
|
|
44
|
+
width,
|
|
45
|
+
...rest
|
|
46
|
+
}: OctavePlayerProps) => {
|
|
47
|
+
const keyWidth = width / whiteNotes + 2
|
|
48
|
+
const keyWidthToHeight = keyWidth / height
|
|
49
|
+
|
|
50
|
+
const firstNote = Note.midi(baseNotes[0] + String(octave)) || 0
|
|
51
|
+
const lastNote =
|
|
52
|
+
Note.midi(baseNotes[baseNotes.length - 1] + String(octave)) || 0
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div>
|
|
56
|
+
<ReactPiano
|
|
57
|
+
keyWidthToHeight={keyWidthToHeight}
|
|
58
|
+
noteRange={{ first: firstNote, last: lastNote }}
|
|
59
|
+
width={width}
|
|
60
|
+
{...rest}
|
|
61
|
+
/>
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type WithProviderProps = {
|
|
67
|
+
format?: 'mp3' | 'ogg'
|
|
68
|
+
height: number
|
|
69
|
+
hostname?: string
|
|
70
|
+
instrumentName: Soundfont.InstrumentName
|
|
71
|
+
octave?: number
|
|
72
|
+
onChange?: (note: number) => void
|
|
73
|
+
onClick?: (note: number) => void
|
|
74
|
+
onDoubleClick?: (note: number) => void
|
|
75
|
+
onKeyMouseEnter?: (note: number) => void
|
|
76
|
+
onKeyMouseLeave?: (note: number) => void
|
|
77
|
+
soundfont?: 'MusyngKite' | 'FluidR3_GM'
|
|
78
|
+
Tooltip?: TooltipProps
|
|
79
|
+
width: number
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const WithProvider = ({
|
|
83
|
+
format,
|
|
84
|
+
height,
|
|
85
|
+
hostname,
|
|
86
|
+
instrumentName,
|
|
87
|
+
octave = 4,
|
|
88
|
+
onChange,
|
|
89
|
+
onClick,
|
|
90
|
+
onDoubleClick,
|
|
91
|
+
onKeyMouseEnter,
|
|
92
|
+
onKeyMouseLeave,
|
|
93
|
+
soundfont,
|
|
94
|
+
Tooltip,
|
|
95
|
+
width,
|
|
96
|
+
}: WithProviderProps) => (
|
|
97
|
+
<SoundfontProvider
|
|
98
|
+
{...{
|
|
99
|
+
audioContext,
|
|
100
|
+
format,
|
|
101
|
+
hostname,
|
|
102
|
+
instrumentName,
|
|
103
|
+
onChange,
|
|
104
|
+
soundfont,
|
|
105
|
+
}}
|
|
106
|
+
render={({
|
|
107
|
+
isLoading,
|
|
108
|
+
playNote,
|
|
109
|
+
stopNote,
|
|
110
|
+
}: {
|
|
111
|
+
isLoading: boolean
|
|
112
|
+
playNote: (midiNumber: number) => void
|
|
113
|
+
stopNote: (midiNumber: number) => void
|
|
114
|
+
}) => (
|
|
115
|
+
<>
|
|
116
|
+
<div>
|
|
117
|
+
<h2>Octave: {octave}</h2>
|
|
118
|
+
</div>
|
|
119
|
+
<OctavePlayer
|
|
120
|
+
disabled={isLoading}
|
|
121
|
+
{...{
|
|
122
|
+
height,
|
|
123
|
+
octave,
|
|
124
|
+
onClick,
|
|
125
|
+
onDoubleClick,
|
|
126
|
+
onKeyMouseEnter,
|
|
127
|
+
onKeyMouseLeave,
|
|
128
|
+
playNote,
|
|
129
|
+
stopNote,
|
|
130
|
+
Tooltip,
|
|
131
|
+
width,
|
|
132
|
+
}}
|
|
133
|
+
/>
|
|
134
|
+
</>
|
|
135
|
+
)}
|
|
136
|
+
/>
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
export default WithProvider
|
|
140
|
+
|
|
141
|
+
export type { WithProviderProps }
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
const MEASUREMENT_ELEMENT_ID = '__react_svg_text_measurement_id'
|
|
4
|
+
|
|
5
|
+
type WordsByLine = {
|
|
6
|
+
words: string[]
|
|
7
|
+
width: number
|
|
8
|
+
showLine: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const calculateWordsByLines = (
|
|
12
|
+
text: string,
|
|
13
|
+
wordWidths: WordWidths,
|
|
14
|
+
maxWidth: number,
|
|
15
|
+
maxHeight: number,
|
|
16
|
+
) => {
|
|
17
|
+
const { lineHeight, spaceWidth, wordsWithComputedWidth } = wordWidths
|
|
18
|
+
|
|
19
|
+
return text.split(/\s+/).reduce((result: WordsByLine[], word) => {
|
|
20
|
+
const wordWidth = wordsWithComputedWidth[word]
|
|
21
|
+
const currentLine = result[result.length - 1]
|
|
22
|
+
if (currentLine && currentLine.width + wordWidth + spaceWidth < maxWidth) {
|
|
23
|
+
// Word can be added to an existing line
|
|
24
|
+
currentLine.words.push(word)
|
|
25
|
+
currentLine.width += wordWidth + spaceWidth
|
|
26
|
+
} else {
|
|
27
|
+
// Add first word to line or word is too long to scaleToFit on existing line
|
|
28
|
+
const newLine: WordsByLine = {
|
|
29
|
+
words: [word],
|
|
30
|
+
width: wordWidth,
|
|
31
|
+
showLine: maxHeight
|
|
32
|
+
? lineHeight * (result.length + 1) < maxHeight
|
|
33
|
+
: true,
|
|
34
|
+
}
|
|
35
|
+
result.push(newLine)
|
|
36
|
+
}
|
|
37
|
+
return result
|
|
38
|
+
}, [])
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type WordWidths = {
|
|
42
|
+
wordsWithComputedWidth: Record<string, number>
|
|
43
|
+
spaceWidth: number
|
|
44
|
+
lineHeight: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const calculateWordWidths = (
|
|
48
|
+
style?: CSSStyleDeclaration,
|
|
49
|
+
textNode?: SVGGraphicsElement | null,
|
|
50
|
+
text?: string,
|
|
51
|
+
): WordWidths | undefined => {
|
|
52
|
+
if (style && textNode) {
|
|
53
|
+
// biome-ignore lint/complexity/noForEach: <explanation>
|
|
54
|
+
Array.from(style).forEach(key =>
|
|
55
|
+
textNode.style.setProperty(
|
|
56
|
+
key,
|
|
57
|
+
style.getPropertyValue(key),
|
|
58
|
+
style.getPropertyPriority(key),
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
const wordArray = [...new Set(String(text).split(/\s+/))]
|
|
62
|
+
const wordsWithComputedWidth = wordArray.reduce((wordMap, word) => {
|
|
63
|
+
textNode.textContent = word
|
|
64
|
+
// biome-ignore lint/performance/noAccumulatingSpread: <explanation>
|
|
65
|
+
return { ...wordMap, [word]: textNode.getBBox().width }
|
|
66
|
+
}, {})
|
|
67
|
+
|
|
68
|
+
textNode.textContent = '\u00A0'
|
|
69
|
+
|
|
70
|
+
const spaceWidth =
|
|
71
|
+
(textNode as SVGTextContentElement)?.getComputedTextLength?.() || 8
|
|
72
|
+
const lineHeight = textNode.getBBox().height
|
|
73
|
+
|
|
74
|
+
textNode.setAttribute('style', '')
|
|
75
|
+
return { wordsWithComputedWidth, spaceWidth, lineHeight }
|
|
76
|
+
}
|
|
77
|
+
return undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type TextProps = Partial<HTMLOrSVGElement> & {
|
|
81
|
+
className?: string
|
|
82
|
+
dx?: number
|
|
83
|
+
dy?: number
|
|
84
|
+
maxHeight?: number
|
|
85
|
+
maxWidth?: number
|
|
86
|
+
transform?: string
|
|
87
|
+
text: string
|
|
88
|
+
textAnchor?: React.CSSProperties['textAnchor']
|
|
89
|
+
verticalAnchor?: 'start' | 'middle' | 'end'
|
|
90
|
+
x?: number
|
|
91
|
+
y?: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const Text = (props: TextProps) => {
|
|
95
|
+
const {
|
|
96
|
+
className = '',
|
|
97
|
+
dx = 0,
|
|
98
|
+
dy = 0,
|
|
99
|
+
maxHeight = 1000,
|
|
100
|
+
maxWidth = 1000,
|
|
101
|
+
// textAnchor = 'start',
|
|
102
|
+
text = '',
|
|
103
|
+
verticalAnchor = 'start',
|
|
104
|
+
x = 0,
|
|
105
|
+
y = 0,
|
|
106
|
+
transform = '',
|
|
107
|
+
...rest
|
|
108
|
+
} = props
|
|
109
|
+
const [wordWidths, setWordWidths] = useState<WordWidths>()
|
|
110
|
+
const [textLines, setTextLines] = useState<WordsByLine[]>([])
|
|
111
|
+
const [style, setComputedStyle] = useState<CSSStyleDeclaration>()
|
|
112
|
+
const measureRef = useRef<SVGSVGElement | HTMLElement>({} as HTMLElement)
|
|
113
|
+
|
|
114
|
+
const displayedLines: WordsByLine[] = useMemo(() => {
|
|
115
|
+
const result = textLines.filter(({ showLine }) => showLine)
|
|
116
|
+
return result.length
|
|
117
|
+
? result
|
|
118
|
+
: text
|
|
119
|
+
.split(/\n/)
|
|
120
|
+
.map(line => ({ words: [line], width: 0, showLine: true }))
|
|
121
|
+
}, [textLines, text])
|
|
122
|
+
|
|
123
|
+
const ref: React.LegacyRef<SVGTextElement> = useCallback(
|
|
124
|
+
(node: SVGTextElement | null) => {
|
|
125
|
+
setComputedStyle(
|
|
126
|
+
node !== null ? window.getComputedStyle(node) : undefined,
|
|
127
|
+
)
|
|
128
|
+
},
|
|
129
|
+
[],
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
const el = document.getElementById(MEASUREMENT_ELEMENT_ID)
|
|
134
|
+
if (el === null) {
|
|
135
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
136
|
+
svg.setAttribute('id', MEASUREMENT_ELEMENT_ID)
|
|
137
|
+
document.body.appendChild(svg)
|
|
138
|
+
svg.appendChild(
|
|
139
|
+
document.createElementNS('http://www.w3.org/2000/svg', 'text'),
|
|
140
|
+
)
|
|
141
|
+
measureRef.current = svg
|
|
142
|
+
return () => {
|
|
143
|
+
document.body.removeChild(svg)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
measureRef.current = el
|
|
147
|
+
}, [])
|
|
148
|
+
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
const wordWithWidths = calculateWordWidths(
|
|
151
|
+
style,
|
|
152
|
+
measureRef.current as SVGGraphicsElement | null,
|
|
153
|
+
text,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
setWordWidths(wordWithWidths)
|
|
157
|
+
}, [text, style])
|
|
158
|
+
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (maxWidth && wordWidths) {
|
|
161
|
+
const lines = text
|
|
162
|
+
.split(/\n/)
|
|
163
|
+
.flatMap(line =>
|
|
164
|
+
calculateWordsByLines(line, wordWidths, maxWidth, maxHeight),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
setTextLines(lines)
|
|
168
|
+
}
|
|
169
|
+
}, [maxHeight, maxWidth, wordWidths, text])
|
|
170
|
+
|
|
171
|
+
const startDy = useMemo(() => {
|
|
172
|
+
if (wordWidths?.lineHeight) {
|
|
173
|
+
switch (verticalAnchor) {
|
|
174
|
+
case 'start':
|
|
175
|
+
return wordWidths.lineHeight
|
|
176
|
+
|
|
177
|
+
case 'middle':
|
|
178
|
+
return -(((displayedLines.length - 1) * wordWidths.lineHeight) / 2)
|
|
179
|
+
|
|
180
|
+
default:
|
|
181
|
+
return -(displayedLines.length - 1) * wordWidths.lineHeight
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return 0
|
|
185
|
+
}, [displayedLines.length, verticalAnchor, wordWidths?.lineHeight])
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<text
|
|
189
|
+
className={className}
|
|
190
|
+
ref={ref}
|
|
191
|
+
dx={dx}
|
|
192
|
+
dy={dy}
|
|
193
|
+
x={x}
|
|
194
|
+
y={y}
|
|
195
|
+
// textAnchor={textAnchor}
|
|
196
|
+
transform={transform}
|
|
197
|
+
{...rest}>
|
|
198
|
+
<>
|
|
199
|
+
{displayedLines.map((line, idx) => (
|
|
200
|
+
<tspan
|
|
201
|
+
dx={x + dx}
|
|
202
|
+
dy={idx === 0 ? startDy + dy : wordWidths?.lineHeight || 0}
|
|
203
|
+
key={idx}
|
|
204
|
+
x={0}>
|
|
205
|
+
{line.words.join(' ')}
|
|
206
|
+
</tspan>
|
|
207
|
+
))}
|
|
208
|
+
{textLines.length && displayedLines.length !== textLines.length && (
|
|
209
|
+
<>
|
|
210
|
+
<tspan>...</tspan>
|
|
211
|
+
<title>{text}</title>
|
|
212
|
+
</>
|
|
213
|
+
)}
|
|
214
|
+
</>
|
|
215
|
+
</text>
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export default Text
|
|
220
|
+
|
|
221
|
+
export { MEASUREMENT_ELEMENT_ID }
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// See https://github.com/danigb/soundfont-player
|
|
2
|
+
// for more documentation on prop options.
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
4
|
+
import Soundfont from 'soundfont-player'
|
|
5
|
+
// import '../temp/acoustic_grand_piano-mp3'
|
|
6
|
+
|
|
7
|
+
const isDefined = <T,>(item: T) =>
|
|
8
|
+
item !== undefined && item !== null && !Number.isNaN(item)
|
|
9
|
+
|
|
10
|
+
type SoundfontProviderProps = {
|
|
11
|
+
audioContext: AudioContext
|
|
12
|
+
format?: 'mp3' | 'ogg'
|
|
13
|
+
hostname?: string
|
|
14
|
+
instrumentName: Soundfont.InstrumentName
|
|
15
|
+
onChange?: (midiNumber: number) => void
|
|
16
|
+
render: (props: {
|
|
17
|
+
isLoading: boolean
|
|
18
|
+
playNote: (midiNumber: number) => void
|
|
19
|
+
stopNote: (midiNumber: number) => void
|
|
20
|
+
stopAllNotes: () => void
|
|
21
|
+
}) => React.ReactNode
|
|
22
|
+
soundfont?: 'MusyngKite' | 'FluidR3_GM'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SoundfontProvider = (props: SoundfontProviderProps) => {
|
|
26
|
+
const {
|
|
27
|
+
audioContext,
|
|
28
|
+
format,
|
|
29
|
+
hostname,
|
|
30
|
+
instrumentName,
|
|
31
|
+
onChange,
|
|
32
|
+
render,
|
|
33
|
+
soundfont,
|
|
34
|
+
} = props
|
|
35
|
+
|
|
36
|
+
const [isLoading, setIsLoading] = useState(true)
|
|
37
|
+
const [activeAudioNodes, setActiveAudioNodes] = useState<
|
|
38
|
+
Record<number, Soundfont.Player>
|
|
39
|
+
>({})
|
|
40
|
+
const [instrument, setInstrument] = useState<Soundfont.Player | null>(null)
|
|
41
|
+
|
|
42
|
+
const loadInstrument = useCallback(
|
|
43
|
+
(nm: Soundfont.InstrumentName) => {
|
|
44
|
+
// Re-trigger loading state
|
|
45
|
+
setInstrument(null)
|
|
46
|
+
|
|
47
|
+
if (isDefined(hostname)) {
|
|
48
|
+
// pull from an url
|
|
49
|
+
Soundfont.instrument(audioContext, nm, {
|
|
50
|
+
format,
|
|
51
|
+
soundfont,
|
|
52
|
+
nameToUrl: (name: string, sf: string, fmt: string) =>
|
|
53
|
+
`${hostname}/${sf}/${name}-${fmt}.js`,
|
|
54
|
+
}).then(newInstrument => {
|
|
55
|
+
setInstrument(newInstrument)
|
|
56
|
+
})
|
|
57
|
+
} else if (isDefined(instrumentName)) {
|
|
58
|
+
// the host app has already injected the soundfont into global space
|
|
59
|
+
Soundfont.instrument(audioContext, instrumentName).then(
|
|
60
|
+
newInstrument => {
|
|
61
|
+
setInstrument(newInstrument)
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
} else {
|
|
65
|
+
// sound must be disabled
|
|
66
|
+
setIsLoading(false)
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
[audioContext, format, hostname, instrumentName, soundfont],
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
loadInstrument(instrumentName)
|
|
74
|
+
}, [instrumentName, loadInstrument])
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (isDefined(instrumentName)) {
|
|
78
|
+
setIsLoading(!instrument)
|
|
79
|
+
}
|
|
80
|
+
}, [instrument, instrumentName])
|
|
81
|
+
|
|
82
|
+
const playNote = (midiNumber: number) => {
|
|
83
|
+
if (instrument) {
|
|
84
|
+
audioContext.resume().then(() => {
|
|
85
|
+
const audioNode = instrument.play(String(midiNumber))
|
|
86
|
+
setActiveAudioNodes(prev => ({ ...prev, [midiNumber]: audioNode }))
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const stopNote = (midiNumber: number) => {
|
|
92
|
+
audioContext.resume().then(() => {
|
|
93
|
+
if (!activeAudioNodes[midiNumber]) {
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
const audioNode = activeAudioNodes[midiNumber]
|
|
97
|
+
audioNode.stop()
|
|
98
|
+
setActiveAudioNodes(prev => {
|
|
99
|
+
const { [midiNumber]: _, ...rest } = prev
|
|
100
|
+
return rest
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
onChange?.(midiNumber)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Clear any residual notes that don't get called with stopNote
|
|
107
|
+
const stopAllNotes = () => {
|
|
108
|
+
audioContext.resume().then(() => {
|
|
109
|
+
const nodes = Object.values(activeAudioNodes)
|
|
110
|
+
for (const node of nodes) {
|
|
111
|
+
if (node) {
|
|
112
|
+
node.stop()
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
setActiveAudioNodes(nodes)
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return render({
|
|
120
|
+
isLoading,
|
|
121
|
+
playNote,
|
|
122
|
+
stopNote,
|
|
123
|
+
stopAllNotes,
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// https://gleitz.github.io/midi-js-soundfonts/MusyngKite/acoustic_grand_piano-mp3.js
|
|
128
|
+
// https://gleitz.github.io/midi-js-soundfonts/FluidR3_GM/.js
|
|
129
|
+
|
|
130
|
+
export default SoundfontProvider
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { forwardRef } from 'react'
|
|
2
|
+
import type { GroupBase, SelectInstance } from 'react-select'
|
|
3
|
+
import ccValues from '../midi/ccValues'
|
|
4
|
+
import Select from './Select'
|
|
5
|
+
import type { Option, SelectProps } from './Select'
|
|
6
|
+
|
|
7
|
+
type CCPickerProps = SelectProps<number>
|
|
8
|
+
|
|
9
|
+
type CCPickerRef = SelectInstance<
|
|
10
|
+
Option<number>,
|
|
11
|
+
false,
|
|
12
|
+
GroupBase<Option<number>>
|
|
13
|
+
>
|
|
14
|
+
|
|
15
|
+
const CCPicker = forwardRef<
|
|
16
|
+
SelectInstance<Option<number>, false, GroupBase<Option<number>>>,
|
|
17
|
+
CCPickerProps
|
|
18
|
+
>((props, ref) => (
|
|
19
|
+
<Select
|
|
20
|
+
{...props}
|
|
21
|
+
options={ccValues}
|
|
22
|
+
ref={ref}
|
|
23
|
+
/>
|
|
24
|
+
))
|
|
25
|
+
|
|
26
|
+
export default CCPicker
|
|
27
|
+
|
|
28
|
+
export type { CCPickerProps, CCPickerRef }
|