zds-pickers 4.0.5 → 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.es.js +3 -1
- package/dist/types/index.d.ts +266 -2
- package/dist/types/pickers/CCPicker.d.ts +2 -1
- package/dist/types/pickers/ChannelMappingPicker.d.ts +2 -1
- package/dist/types/pickers/ChannelPicker.d.ts +5 -3
- package/dist/types/pickers/LatchPicker.d.ts +5 -3
- package/dist/types/pickers/MappingPicker.d.ts +2 -1
- package/dist/types/pickers/NotePicker.d.ts +2 -1
- package/dist/types/pickers/PolarityPicker.d.ts +6 -4
- package/dist/types/pickers/ResponseCurve.d.ts +14 -5
- package/dist/types/pickers/ResponseCurvePicker.d.ts +6 -4
- package/dist/types/pickers/StatusPicker.d.ts +2 -1
- package/dist/types/pickers/ValuePicker.d.ts +2 -1
- 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 +2 -6
- 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.d.ts +0 -244
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
const useStateWithDynamicDefault = <T>(defaultVal: T) => {
|
|
4
|
+
const [state, setState] = useState<T>(defaultVal)
|
|
5
|
+
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
setState(defaultVal)
|
|
8
|
+
}, [defaultVal])
|
|
9
|
+
|
|
10
|
+
return [state, setState] as const
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default useStateWithDynamicDefault
|
package/lib/main.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Pickers
|
|
2
|
+
export { default as CCPicker } from './pickers/CCPicker'
|
|
3
|
+
export { default as ChannelMappingPicker } from './pickers/ChannelMappingPicker'
|
|
4
|
+
export { default as ChannelPicker } from './pickers/ChannelPicker'
|
|
5
|
+
export { default as Knob } from './pickers/Knob'
|
|
6
|
+
export { default as KnobPicker } from './pickers/KnobPicker'
|
|
7
|
+
export { default as LatchPicker } from './pickers/LatchPicker'
|
|
8
|
+
export { default as MappingPicker } from './pickers/MappingPicker'
|
|
9
|
+
export { default as NotePicker } from './pickers/NotePicker'
|
|
10
|
+
export { default as OctavePlayer } from './other/OctavePlayer'
|
|
11
|
+
export { default as PianoPicker } from './pickers/PianoPicker'
|
|
12
|
+
export { default as PolarityPicker } from './pickers/PolarityPicker'
|
|
13
|
+
export { default as ResponseCurvePicker } from './pickers/ResponseCurvePicker'
|
|
14
|
+
export { default as Select } from './pickers/Select'
|
|
15
|
+
export { default as StatusPicker } from './pickers/StatusPicker'
|
|
16
|
+
export { default as SVGText } from './other/SVGText'
|
|
17
|
+
export { default as ValuePicker } from './pickers/ValuePicker'
|
|
18
|
+
|
|
19
|
+
// MIDI
|
|
20
|
+
export {
|
|
21
|
+
combineStatusWithChannel,
|
|
22
|
+
extractStatusAndChannel,
|
|
23
|
+
getStatusLabel,
|
|
24
|
+
MASK_CHANNEL,
|
|
25
|
+
MASK_STATUS,
|
|
26
|
+
Statuses,
|
|
27
|
+
} from './midi/export'
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
default as ResponseCurve,
|
|
31
|
+
RESPONSE_CURVES,
|
|
32
|
+
} from './pickers/ResponseCurve'
|
|
33
|
+
|
|
34
|
+
export { default as ccValues } from './midi/ccValues'
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { Option } from '../pickers/Select'
|
|
2
|
+
|
|
3
|
+
const CC_NAMES = [
|
|
4
|
+
'Bank Select',
|
|
5
|
+
'Modulation Wheel',
|
|
6
|
+
'Breath Controller',
|
|
7
|
+
'',
|
|
8
|
+
'Foot Control',
|
|
9
|
+
'Portamento Time',
|
|
10
|
+
'Data Entry Slider',
|
|
11
|
+
'Volume',
|
|
12
|
+
'Balance',
|
|
13
|
+
'',
|
|
14
|
+
'Pan',
|
|
15
|
+
'Expression',
|
|
16
|
+
'Effect Control 1',
|
|
17
|
+
'Effect Control 2',
|
|
18
|
+
'',
|
|
19
|
+
'',
|
|
20
|
+
'',
|
|
21
|
+
'',
|
|
22
|
+
'',
|
|
23
|
+
'',
|
|
24
|
+
'',
|
|
25
|
+
'',
|
|
26
|
+
'',
|
|
27
|
+
'',
|
|
28
|
+
'',
|
|
29
|
+
'',
|
|
30
|
+
'',
|
|
31
|
+
'',
|
|
32
|
+
'',
|
|
33
|
+
'',
|
|
34
|
+
'',
|
|
35
|
+
'',
|
|
36
|
+
'Controller 0',
|
|
37
|
+
'Controller 1',
|
|
38
|
+
'Controller 2',
|
|
39
|
+
'Controller 3',
|
|
40
|
+
'Controller 4',
|
|
41
|
+
'Controller 5',
|
|
42
|
+
'Controller 6',
|
|
43
|
+
'Controller 7',
|
|
44
|
+
'Controller 8',
|
|
45
|
+
'Controller 9',
|
|
46
|
+
'Controller 10',
|
|
47
|
+
'Controller 11',
|
|
48
|
+
'Controller 12',
|
|
49
|
+
'Controller 13',
|
|
50
|
+
'Controller 14',
|
|
51
|
+
'Controller 15',
|
|
52
|
+
'Controller 16',
|
|
53
|
+
'Controller 17',
|
|
54
|
+
'Controller 18',
|
|
55
|
+
'Controller 19',
|
|
56
|
+
'Controller 20',
|
|
57
|
+
'Controller 21',
|
|
58
|
+
'Controller 22',
|
|
59
|
+
'Controller 23',
|
|
60
|
+
'Controller 24',
|
|
61
|
+
'Controller 25',
|
|
62
|
+
'Controller 26',
|
|
63
|
+
'Controller 27',
|
|
64
|
+
'Controller 28',
|
|
65
|
+
'Controller 29',
|
|
66
|
+
'Controller 30',
|
|
67
|
+
'Controller 31',
|
|
68
|
+
'Sustain',
|
|
69
|
+
'Portamento',
|
|
70
|
+
'Sostenuto',
|
|
71
|
+
'Soft Pedal',
|
|
72
|
+
'Legato Pedal',
|
|
73
|
+
'Sustain 2',
|
|
74
|
+
'Sound Variation',
|
|
75
|
+
'Resonance/Timbre',
|
|
76
|
+
'Sound Release Time',
|
|
77
|
+
'Sound Attack Time',
|
|
78
|
+
'Frequency Cutoff',
|
|
79
|
+
'Sound Control 6',
|
|
80
|
+
'Sound Control 7',
|
|
81
|
+
'Sound Control 8',
|
|
82
|
+
'Sound Control 9',
|
|
83
|
+
'Sound Control 10',
|
|
84
|
+
'Decay or General 1',
|
|
85
|
+
'Hi-Pass or General 2',
|
|
86
|
+
'General 3',
|
|
87
|
+
'General 4',
|
|
88
|
+
'Portamento Amount',
|
|
89
|
+
'',
|
|
90
|
+
'',
|
|
91
|
+
'',
|
|
92
|
+
'',
|
|
93
|
+
'',
|
|
94
|
+
'',
|
|
95
|
+
'Reverb Level',
|
|
96
|
+
'Tremolo Depth',
|
|
97
|
+
'Chorus Level',
|
|
98
|
+
'Detune',
|
|
99
|
+
'Phaser Depth',
|
|
100
|
+
'Data Entry +1',
|
|
101
|
+
'Data Entry -1',
|
|
102
|
+
'Non-registered LSB',
|
|
103
|
+
'Non-registered MSB',
|
|
104
|
+
'Registered msb',
|
|
105
|
+
'Registered lsb',
|
|
106
|
+
'',
|
|
107
|
+
'',
|
|
108
|
+
'',
|
|
109
|
+
'',
|
|
110
|
+
'',
|
|
111
|
+
'',
|
|
112
|
+
'',
|
|
113
|
+
'',
|
|
114
|
+
'Shift 1',
|
|
115
|
+
'Shift 2',
|
|
116
|
+
'Shift 3',
|
|
117
|
+
'Shift 4',
|
|
118
|
+
'Shift 5',
|
|
119
|
+
'Shift 6',
|
|
120
|
+
'',
|
|
121
|
+
'',
|
|
122
|
+
'',
|
|
123
|
+
'',
|
|
124
|
+
'All Sound Off',
|
|
125
|
+
'All Controllers Off',
|
|
126
|
+
'Local Mode',
|
|
127
|
+
'All Notes Off',
|
|
128
|
+
'Omni Mode Off',
|
|
129
|
+
'Omni Mode On',
|
|
130
|
+
'Mono Mode On',
|
|
131
|
+
'Poly Mode',
|
|
132
|
+
].map(
|
|
133
|
+
(label, value): Option<number> => ({
|
|
134
|
+
label: String(label).length ? `${value} - ${label}` : String(value),
|
|
135
|
+
value,
|
|
136
|
+
}),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
export default CC_NAMES
|
|
@@ -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 }
|