@tremolo-ui/react 0.4.0 → 0.5.0
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/dist/index.cjs +1125 -1437
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +58 -93
- package/dist/index.d.cts +561 -243
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +561 -243
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1125 -1438
- package/dist/index.js.map +1 -1
- package/package.json +9 -10
- package/src/components/AnimationCanvas/index.tsx +84 -155
- package/src/components/Knob/ActiveLine.tsx +6 -6
- package/src/components/Knob/InactiveLine.tsx +9 -11
- package/src/components/Knob/SVGRoot.tsx +9 -18
- package/src/components/Knob/context.tsx +57 -89
- package/src/components/Knob/index.css +6 -2
- package/src/components/Knob/index.tsx +90 -61
- package/src/components/NumberInput/DecrementStepper.tsx +17 -50
- package/src/components/NumberInput/IncrementStepper.tsx +17 -50
- package/src/components/NumberInput/InputField.tsx +123 -0
- package/src/components/NumberInput/Stepper.tsx +90 -12
- package/src/components/NumberInput/context.tsx +74 -146
- package/src/components/NumberInput/index.css +38 -102
- package/src/components/NumberInput/index.tsx +252 -98
- package/src/components/NumberInput/stepperButton.tsx +90 -0
- package/src/components/Piano/index.tsx +272 -288
- package/src/components/Piano/keyboardShortcuts.ts +34 -4
- package/src/components/PointsEditor/Background.tsx +8 -2
- package/src/components/PointsEditor/Container.tsx +19 -9
- package/src/components/PointsEditor/Point.tsx +104 -24
- package/src/components/PointsEditor/context.tsx +28 -74
- package/src/components/PointsEditor/index.css +5 -0
- package/src/components/PointsEditor/index.tsx +77 -43
- package/src/components/Slider/{Scale.tsx → Marks.tsx} +8 -8
- package/src/components/Slider/{ScaleOption.tsx → MarksOption.tsx} +16 -18
- package/src/components/Slider/Thumb.tsx +55 -68
- package/src/components/Slider/Track.tsx +15 -15
- package/src/components/Slider/context.tsx +36 -51
- package/src/components/Slider/index.css +18 -5
- package/src/components/Slider/index.tsx +127 -146
- package/src/components/Slider/type.ts +4 -4
- package/src/components/XYPad/Area.tsx +16 -7
- package/src/components/XYPad/Thumb.tsx +62 -74
- package/src/components/XYPad/context.tsx +46 -0
- package/src/components/XYPad/index.css +12 -0
- package/src/components/XYPad/index.tsx +190 -230
- package/src/hooks/useDrag.ts +1 -1
- package/src/hooks/useDragValue.ts +149 -0
- package/src/hooks/useEventListener.ts +1 -1
- package/src/hooks/useWheel.ts +29 -9
- package/src/index.ts +15 -9
- package/src/components/AnimationCanvas/canvas.ts +0 -46
- package/src/components/NumberInput/InternalInput.tsx +0 -216
- package/src/components/NumberInput/type.ts +0 -58
- package/src/components/Piano/KeyLabel.tsx +0 -51
- package/src/components/Piano/context.tsx +0 -73
- package/src/components/Piano/key.tsx +0 -205
- package/src/components/_util/type.ts +0 -1
- package/src/hooks/useDragWithElement.ts +0 -100
- package/src/hooks/usePianoDrag.ts +0 -75
- package/src/hooks/useRefCallbackEvent.ts +0 -34
- /package/src/hooks/{useCallbackRef.ts → _internal/useCallbackRef.ts} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tremolo-ui/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "React component library for audio app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
"clean:sb": "rimraf ./storybook-static"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@storybook/addon-a11y": "10.
|
|
27
|
-
"@storybook/addon-links": "10.
|
|
28
|
-
"@storybook/react-vite": "10.
|
|
26
|
+
"@storybook/addon-a11y": "10.6.0",
|
|
27
|
+
"@storybook/addon-links": "10.6.0",
|
|
28
|
+
"@storybook/react-vite": "10.6.0",
|
|
29
29
|
"@testing-library/react": "16.3.3",
|
|
30
30
|
"@types/react": "19.2.18",
|
|
31
31
|
"@types/react-dom": "19.2.5",
|
|
@@ -33,19 +33,18 @@
|
|
|
33
33
|
"jotai": "2.20.3",
|
|
34
34
|
"react": "19.2.8",
|
|
35
35
|
"react-dom": "19.2.8",
|
|
36
|
-
"storybook": "10.
|
|
36
|
+
"storybook": "10.6.0",
|
|
37
37
|
"tone": "15.1.22",
|
|
38
|
-
"@storybook/addon-docs": "10.
|
|
38
|
+
"@storybook/addon-docs": "10.6.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"react": "^18 ||^19",
|
|
42
42
|
"react-dom": "^18 || ^19"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@tremolo-ui/dom": "^0.
|
|
46
|
-
"@tremolo-ui/functions": "^0.
|
|
47
|
-
"clsx": "^2.1.1"
|
|
48
|
-
"zustand": "^5.0.3"
|
|
45
|
+
"@tremolo-ui/dom": "^0.5.0",
|
|
46
|
+
"@tremolo-ui/functions": "^0.5.0",
|
|
47
|
+
"clsx": "^2.1.1"
|
|
49
48
|
},
|
|
50
49
|
"files": [
|
|
51
50
|
"dist",
|
|
@@ -2,18 +2,16 @@ import clsx from 'clsx'
|
|
|
2
2
|
import {
|
|
3
3
|
ComponentPropsWithoutRef,
|
|
4
4
|
ReactElement,
|
|
5
|
-
RefObject,
|
|
6
|
-
useCallback,
|
|
7
5
|
useEffect,
|
|
8
6
|
useRef,
|
|
7
|
+
useState,
|
|
9
8
|
} from 'react'
|
|
10
9
|
|
|
11
10
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
} from './canvas'
|
|
11
|
+
createAnimationCanvas,
|
|
12
|
+
type AnimationCanvasInstance,
|
|
13
|
+
type AnimationFrame,
|
|
14
|
+
} from '@tremolo-ui/dom'
|
|
17
15
|
|
|
18
16
|
export type InitFunction = (
|
|
19
17
|
context: CanvasRenderingContext2D,
|
|
@@ -27,20 +25,7 @@ export type InitFunction = (
|
|
|
27
25
|
|
|
28
26
|
export type DrawFunction = (
|
|
29
27
|
context: CanvasRenderingContext2D,
|
|
30
|
-
option:
|
|
31
|
-
/** current canvas width */
|
|
32
|
-
width: number
|
|
33
|
-
/** current canvas height */
|
|
34
|
-
height: number
|
|
35
|
-
/** frame count */
|
|
36
|
-
count: number
|
|
37
|
-
/** delta time (ms) */
|
|
38
|
-
deltaTime: number
|
|
39
|
-
/** elapsed time (ms) */
|
|
40
|
-
elapsedTime: number
|
|
41
|
-
/** frame per second */
|
|
42
|
-
fps: number
|
|
43
|
-
},
|
|
28
|
+
option: AnimationFrame,
|
|
44
29
|
) => void
|
|
45
30
|
|
|
46
31
|
export interface CommonProps {
|
|
@@ -48,6 +33,9 @@ export interface CommonProps {
|
|
|
48
33
|
init?: InitFunction
|
|
49
34
|
animate?: boolean
|
|
50
35
|
/**
|
|
36
|
+
* Read once, when the 2D context is created, so changing it later has no
|
|
37
|
+
* effect. Passing a fresh object on every render is therefore harmless.
|
|
38
|
+
*
|
|
51
39
|
* @see https://developer.mozilla.org/docs/Web/API/HTMLCanvasElement/getContext#contextattributes
|
|
52
40
|
*/
|
|
53
41
|
options?: CanvasRenderingContext2DSettings
|
|
@@ -87,10 +75,10 @@ export function AnimationCanvas({
|
|
|
87
75
|
animate = true,
|
|
88
76
|
options,
|
|
89
77
|
// absolute
|
|
90
|
-
width
|
|
91
|
-
height
|
|
78
|
+
width = 100,
|
|
79
|
+
height = 100,
|
|
92
80
|
// relative
|
|
93
|
-
relativeSize,
|
|
81
|
+
relativeSize = false,
|
|
94
82
|
reduceFlickering = true,
|
|
95
83
|
// canvas props
|
|
96
84
|
className,
|
|
@@ -101,142 +89,83 @@ export function AnimationCanvas({
|
|
|
101
89
|
ComponentPropsWithoutRef<'canvas'>,
|
|
102
90
|
keyof AnimationCanvasProps
|
|
103
91
|
>): ReactElement {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
deltaMemoRef.current = now
|
|
123
|
-
if (animate) {
|
|
124
|
-
reqIdRef.current = requestAnimationFrame(() =>
|
|
125
|
-
loop(context, width, height, count + 1),
|
|
126
|
-
)
|
|
127
|
-
}
|
|
128
|
-
draw(context, {
|
|
129
|
-
width: width.current,
|
|
130
|
-
height: height.current,
|
|
131
|
-
count: count + 1,
|
|
132
|
-
deltaTime,
|
|
133
|
-
elapsedTime,
|
|
134
|
-
fps: 1000 / deltaTime,
|
|
135
|
-
})
|
|
136
|
-
},
|
|
137
|
-
[draw, animate],
|
|
138
|
-
)
|
|
92
|
+
// See useDrag for why the node is held in state rather than a ref: an inline
|
|
93
|
+
// ref would be re-attached on every render and tear the instance down.
|
|
94
|
+
const [node, setNode] = useState<HTMLCanvasElement | null>(null)
|
|
95
|
+
|
|
96
|
+
// Read when the instance is created. The effect below keeps it current, and
|
|
97
|
+
// runs right after, so a stale handler is replaced within the same commit.
|
|
98
|
+
const latest = useRef({
|
|
99
|
+
draw,
|
|
100
|
+
init,
|
|
101
|
+
animate,
|
|
102
|
+
width,
|
|
103
|
+
height,
|
|
104
|
+
reduceFlickering,
|
|
105
|
+
options,
|
|
106
|
+
})
|
|
107
|
+
const instanceRef = useRef<AnimationCanvasInstance | null>(null)
|
|
108
|
+
/** Set while the instance below is newer than the effect that pushes into it. */
|
|
109
|
+
const justCreated = useRef(false)
|
|
139
110
|
|
|
140
111
|
useEffect(() => {
|
|
141
|
-
if (!
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
112
|
+
if (!node) return
|
|
113
|
+
|
|
114
|
+
const current = latest.current
|
|
115
|
+
const instance = createAnimationCanvas(node, {
|
|
116
|
+
draw: (context, frame) => latest.current.draw(context, frame),
|
|
117
|
+
init: (context, size) => latest.current.init?.(context, size),
|
|
118
|
+
animate: current.animate,
|
|
119
|
+
size: { width: current.width, height: current.height },
|
|
120
|
+
reduceFlickering: current.reduceFlickering,
|
|
121
|
+
relativeSize,
|
|
122
|
+
contextAttributes: current.options,
|
|
123
|
+
})
|
|
124
|
+
instanceRef.current = instance
|
|
125
|
+
justCreated.current = true
|
|
126
|
+
|
|
127
|
+
return () => {
|
|
128
|
+
instanceRef.current = null
|
|
129
|
+
instance.destroy()
|
|
157
130
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
setDprConfig(canvas, context, w, h, dpr)
|
|
184
|
-
widthRef.current = w
|
|
185
|
-
heightRef.current = h
|
|
186
|
-
|
|
187
|
-
if (
|
|
188
|
-
reduceFlickering &&
|
|
189
|
-
memoContext &&
|
|
190
|
-
memoCanvas &&
|
|
191
|
-
memoCanvas.width > 0 &&
|
|
192
|
-
memoCanvas.height > 0
|
|
193
|
-
) {
|
|
194
|
-
for (const prop of drawingState) {
|
|
195
|
-
;(context[prop] as DrawingStateValue) = contextMemo[prop]
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
context.drawImage(memoContext.canvas, 0, 0)
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
if (!animate) {
|
|
202
|
-
// re rendering
|
|
203
|
-
loop(context, widthRef, heightRef, -1)
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
})
|
|
207
|
-
ro.observe(parent)
|
|
208
|
-
|
|
209
|
-
const width = parent.clientWidth
|
|
210
|
-
const height = parent.clientHeight
|
|
211
|
-
firstRendering(width, height)
|
|
212
|
-
|
|
213
|
-
return () => {
|
|
214
|
-
if (reqIdRef.current) cancelAnimationFrame(reqIdRef.current)
|
|
215
|
-
ro.disconnect()
|
|
216
|
-
}
|
|
217
|
-
} else {
|
|
218
|
-
const { width, height } = canvas.getBoundingClientRect()
|
|
219
|
-
firstRendering(width, height)
|
|
220
|
-
|
|
221
|
-
return () => {
|
|
222
|
-
if (reqIdRef.current) cancelAnimationFrame(reqIdRef.current)
|
|
223
|
-
}
|
|
131
|
+
// Only `relativeSize` decides how the instance is wired, so it is the one
|
|
132
|
+
// setting that rebuilds it. `options` is read from the ref above rather
|
|
133
|
+
// than depended on: it is almost always written inline, and depending on
|
|
134
|
+
// it would tear the canvas down on every render.
|
|
135
|
+
}, [node, relativeSize])
|
|
136
|
+
|
|
137
|
+
// Runs after every render: the handlers come from props and are cheap to
|
|
138
|
+
// push, and updating in place keeps the frame count and elapsed time going.
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
latest.current = {
|
|
141
|
+
draw,
|
|
142
|
+
init,
|
|
143
|
+
animate,
|
|
144
|
+
width,
|
|
145
|
+
height,
|
|
146
|
+
reduceFlickering,
|
|
147
|
+
options,
|
|
148
|
+
}
|
|
149
|
+
// The instance was built from `latest` a moment ago, so there is nothing
|
|
150
|
+
// to push yet. Skipping matters with `animate` off, where `update()` draws
|
|
151
|
+
// a frame and would otherwise paint the same one twice on mount.
|
|
152
|
+
if (justCreated.current) {
|
|
153
|
+
justCreated.current = false
|
|
154
|
+
return
|
|
224
155
|
}
|
|
225
|
-
|
|
156
|
+
instanceRef.current?.update({
|
|
157
|
+
animate,
|
|
158
|
+
size: { width, height },
|
|
159
|
+
reduceFlickering,
|
|
160
|
+
})
|
|
161
|
+
})
|
|
226
162
|
|
|
227
163
|
return (
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
onContextMenu={onContextMenu}
|
|
235
|
-
{...props}
|
|
236
|
-
></canvas>
|
|
237
|
-
{relativeSize && reduceFlickering && (
|
|
238
|
-
<canvas style={{ display: 'none' }} ref={memoCanvasRef}></canvas>
|
|
239
|
-
)}
|
|
240
|
-
</>
|
|
164
|
+
<canvas
|
|
165
|
+
className={clsx('tremolo-animation-canvas', className)}
|
|
166
|
+
ref={setNode}
|
|
167
|
+
onContextMenu={onContextMenu}
|
|
168
|
+
{...props}
|
|
169
|
+
/>
|
|
241
170
|
)
|
|
242
171
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import clsx from 'clsx'
|
|
2
2
|
import { SVGProps } from 'react'
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { arcRadius, pointOnArc, useKnobContext } from './context'
|
|
5
5
|
|
|
6
6
|
export function ActiveLine({
|
|
7
7
|
stroke = 'currentColor',
|
|
@@ -11,15 +11,15 @@ export function ActiveLine({
|
|
|
11
11
|
}: Omit<SVGProps<SVGPathElement>, 'd'>) {
|
|
12
12
|
const r2 = useKnobContext((s) => s.r2)
|
|
13
13
|
const r3 = useKnobContext((s) => s.r3)
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const
|
|
14
|
+
|
|
15
|
+
const radius = arcRadius(strokeWidth)
|
|
16
|
+
const start = pointOnArc(r2, radius)
|
|
17
|
+
const end = pointOnArc(r3, radius)
|
|
18
18
|
|
|
19
19
|
return (
|
|
20
20
|
<path
|
|
21
21
|
className={clsx('tremolo-knob-active-line', className)}
|
|
22
|
-
d={`M ${
|
|
22
|
+
d={`M ${start.x} ${start.y} A ${radius} ${radius} -135 ${r3 - r2 > 180 ? 1 : 0} 1 ${end.x} ${end.y}`}
|
|
23
23
|
fill="none"
|
|
24
24
|
stroke={stroke}
|
|
25
25
|
strokeWidth={strokeWidth}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import clsx from 'clsx'
|
|
2
2
|
import { SVGProps } from 'react'
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { arcRadius, pointOnArc, useKnobContext } from './context'
|
|
5
5
|
|
|
6
6
|
export function InactiveLine({
|
|
7
7
|
stroke = 'currentColor',
|
|
@@ -17,21 +17,19 @@ export function InactiveLine({
|
|
|
17
17
|
const r2 = useKnobContext((s) => s.r2)
|
|
18
18
|
const r3 = useKnobContext((s) => s.r3)
|
|
19
19
|
const r4 = useKnobContext((s) => s.r4)
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const x4 = useKnobContext((s) => s.x4)
|
|
27
|
-
const y4 = useKnobContext((s) => s.y4)
|
|
20
|
+
|
|
21
|
+
const radius = arcRadius(strokeWidth)
|
|
22
|
+
const p1 = pointOnArc(r1, radius)
|
|
23
|
+
const p2 = pointOnArc(r2, radius)
|
|
24
|
+
const p3 = pointOnArc(r3, radius)
|
|
25
|
+
const p4 = pointOnArc(r4, radius)
|
|
28
26
|
|
|
29
27
|
return (
|
|
30
28
|
<>
|
|
31
29
|
{startValue > min && (
|
|
32
30
|
<path
|
|
33
31
|
className={clsx('tremolo-knob-inactive-line', className)}
|
|
34
|
-
d={`M ${
|
|
32
|
+
d={`M ${p1.x} ${p1.y} A ${radius} ${radius} -135 ${r2 - r1 > 180 ? 1 : 0} 1 ${p2.x} ${p2.y}`}
|
|
35
33
|
fill="none"
|
|
36
34
|
stroke={stroke}
|
|
37
35
|
strokeWidth={strokeWidth}
|
|
@@ -41,7 +39,7 @@ export function InactiveLine({
|
|
|
41
39
|
{startValue < max && (
|
|
42
40
|
<path
|
|
43
41
|
className={clsx('tremolo-knob-inactive-line', className)}
|
|
44
|
-
d={`M ${
|
|
42
|
+
d={`M ${p3.x} ${p3.y} A ${radius} ${radius} -135 ${r4 - r3 > 180 ? 1 : 0} 1 ${p4.x} ${p4.y}`}
|
|
45
43
|
fill="none"
|
|
46
44
|
stroke={stroke}
|
|
47
45
|
strokeWidth={strokeWidth}
|
|
@@ -1,39 +1,30 @@
|
|
|
1
|
-
import { SVGProps } from 'react'
|
|
1
|
+
import { ReactNode, SVGProps } from 'react'
|
|
2
2
|
|
|
3
|
-
import { Override } from '../_util/type'
|
|
4
|
-
|
|
5
|
-
import { ActiveLine } from './ActiveLine'
|
|
6
3
|
import { viewBoxSize } from './context'
|
|
7
|
-
import { InactiveLine } from './InactiveLine'
|
|
8
|
-
import { Thumb } from './Thumb'
|
|
9
4
|
|
|
10
|
-
interface
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
export interface SVGRootProps {
|
|
6
|
+
/**
|
|
7
|
+
* `<Knob.InactiveLine />`, `<Knob.ActiveLine />` and `<Knob.Thumb />` go
|
|
8
|
+
* here, in the order you want them painted.
|
|
9
|
+
*/
|
|
10
|
+
children: ReactNode
|
|
13
11
|
}
|
|
14
12
|
|
|
15
13
|
export function SVGRoot({
|
|
16
14
|
children,
|
|
17
15
|
style,
|
|
18
16
|
...props
|
|
19
|
-
}:
|
|
17
|
+
}: SVGRootProps & Omit<SVGProps<SVGSVGElement>, keyof SVGRootProps>) {
|
|
20
18
|
return (
|
|
21
19
|
<svg
|
|
22
20
|
viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
|
|
23
21
|
style={{
|
|
24
22
|
display: 'block',
|
|
25
|
-
overflow: 'visible',
|
|
26
23
|
...style,
|
|
27
24
|
}}
|
|
28
25
|
{...props}
|
|
29
26
|
>
|
|
30
|
-
{children
|
|
31
|
-
<>
|
|
32
|
-
<InactiveLine />
|
|
33
|
-
<ActiveLine />
|
|
34
|
-
<Thumb />
|
|
35
|
-
</>
|
|
36
|
-
)}
|
|
27
|
+
{children}
|
|
37
28
|
</svg>
|
|
38
29
|
)
|
|
39
30
|
}
|
|
@@ -1,120 +1,88 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createContext,
|
|
3
|
-
PropsWithChildren,
|
|
4
|
-
useContext,
|
|
5
|
-
useEffect,
|
|
6
|
-
useRef,
|
|
7
|
-
} from 'react'
|
|
8
|
-
import { createStore, useStore } from 'zustand'
|
|
1
|
+
import { createContext, useContext } from 'react'
|
|
9
2
|
|
|
10
|
-
import {
|
|
3
|
+
import { radian, type Scale } from '@tremolo-ui/functions'
|
|
11
4
|
|
|
12
5
|
export const viewBoxSize = 100
|
|
13
6
|
export const center = viewBoxSize / 2
|
|
14
7
|
|
|
15
|
-
type
|
|
8
|
+
export type KnobConfig = {
|
|
16
9
|
value: number
|
|
17
10
|
min: number
|
|
18
11
|
max: number
|
|
19
12
|
step: number
|
|
20
|
-
|
|
13
|
+
scale: Scale
|
|
21
14
|
startValue: number
|
|
15
|
+
/** angle range [degree] */
|
|
22
16
|
angleRange: number
|
|
23
17
|
}
|
|
24
18
|
|
|
25
|
-
type
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
y3: number
|
|
37
|
-
x4: number
|
|
38
|
-
y4: number
|
|
19
|
+
export type KnobContextValue = KnobConfig & {
|
|
20
|
+
/** normalized value */
|
|
21
|
+
p: number
|
|
22
|
+
/** ロータリー開始位置 */
|
|
23
|
+
r1: number
|
|
24
|
+
/** activeLine の開始位置 */
|
|
25
|
+
r2: number
|
|
26
|
+
/** activeLine の終了位置 */
|
|
27
|
+
r3: number
|
|
28
|
+
/** ロータリー終了位置 */
|
|
29
|
+
r4: number
|
|
39
30
|
}
|
|
40
31
|
|
|
41
|
-
|
|
32
|
+
/**
|
|
33
|
+
* 角度から円弧上の点を求める。
|
|
34
|
+
*
|
|
35
|
+
* 半径は線の太さの分だけ内側に取る必要があるが、太さは ActiveLine / InactiveLine が
|
|
36
|
+
* それぞれ持つため、座標はストアで先に計算せず各コンポーネント側で求める。
|
|
37
|
+
*/
|
|
38
|
+
export function pointOnArc(angle: number, radius: number) {
|
|
39
|
+
return {
|
|
40
|
+
x: center + radius * Math.cos(radian(angle - 90)),
|
|
41
|
+
y: center + radius * Math.sin(radian(angle - 90)),
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 線の太さが viewBox からはみ出さないようにした半径 */
|
|
46
|
+
export function arcRadius(strokeWidth: number | string | undefined) {
|
|
47
|
+
const width =
|
|
48
|
+
typeof strokeWidth === 'number'
|
|
49
|
+
? strokeWidth
|
|
50
|
+
: Number.parseFloat(String(strokeWidth))
|
|
51
|
+
return Number.isFinite(width) ? center - width / 2 : center
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 設定から描画に必要な角度を導出する。レンダー中に呼ぶ。 */
|
|
55
|
+
export function calcAngles({
|
|
42
56
|
value,
|
|
43
57
|
min,
|
|
44
58
|
max,
|
|
45
|
-
|
|
59
|
+
scale,
|
|
46
60
|
startValue,
|
|
47
61
|
angleRange,
|
|
48
|
-
}:
|
|
49
|
-
const p =
|
|
50
|
-
const s =
|
|
62
|
+
}: KnobConfig) {
|
|
63
|
+
const p = scale.normalize(value, min, max)
|
|
64
|
+
const s = scale.normalize(startValue, min, max)
|
|
51
65
|
|
|
52
66
|
const r1 = -angleRange / 2
|
|
53
67
|
const r2 = r1 + Math.min(p, s) * angleRange
|
|
54
68
|
const r3 = r1 + Math.max(p, s) * angleRange
|
|
55
69
|
const r4 = angleRange / 2
|
|
56
|
-
const x1 = center + center * Math.cos(radian(r1 - 90))
|
|
57
|
-
const y1 = center + center * Math.sin(radian(r1 - 90))
|
|
58
|
-
const x2 = center + center * Math.cos(radian(r2 - 90))
|
|
59
|
-
const y2 = center + center * Math.sin(radian(r2 - 90))
|
|
60
|
-
const x3 = center + center * Math.cos(radian(r3 - 90))
|
|
61
|
-
const y3 = center + center * Math.sin(radian(r3 - 90))
|
|
62
|
-
const x4 = center + center * Math.cos(radian(r4 - 90))
|
|
63
|
-
const y4 = center + center * Math.sin(radian(r4 - 90))
|
|
64
70
|
|
|
65
|
-
return {
|
|
66
|
-
p,
|
|
67
|
-
r1,
|
|
68
|
-
r2,
|
|
69
|
-
r3,
|
|
70
|
-
r4,
|
|
71
|
-
x1,
|
|
72
|
-
y1,
|
|
73
|
-
x2,
|
|
74
|
-
y2,
|
|
75
|
-
x3,
|
|
76
|
-
y3,
|
|
77
|
-
x4,
|
|
78
|
-
y4,
|
|
79
|
-
}
|
|
71
|
+
return { p, r1, r2, r3, r4 }
|
|
80
72
|
}
|
|
81
73
|
|
|
82
|
-
|
|
74
|
+
const KnobContext = createContext<KnobContextValue | null>(null)
|
|
83
75
|
|
|
84
|
-
const
|
|
85
|
-
return createStore<State & DrawingState>()(() => ({
|
|
86
|
-
...initProps,
|
|
87
|
-
...calcDrawingState(initProps),
|
|
88
|
-
}))
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const KnobContext = createContext<KnobStore | null>(null)
|
|
92
|
-
|
|
93
|
-
export function KnobProvider({ children, ...props }: PropsWithChildren<State>) {
|
|
94
|
-
const storeRef = useRef<KnobStore>(null)
|
|
95
|
-
if (!storeRef.current) {
|
|
96
|
-
storeRef.current = createKnobStore(props)
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
useEffect(() => {
|
|
100
|
-
if (storeRef.current) {
|
|
101
|
-
storeRef.current.setState({ ...props, ...calcDrawingState(props) })
|
|
102
|
-
} else {
|
|
103
|
-
storeRef.current = createKnobStore(props)
|
|
104
|
-
}
|
|
105
|
-
}, [props])
|
|
106
|
-
|
|
107
|
-
return (
|
|
108
|
-
<KnobContext.Provider value={storeRef.current}>
|
|
109
|
-
{children}
|
|
110
|
-
</KnobContext.Provider>
|
|
111
|
-
)
|
|
112
|
-
}
|
|
76
|
+
export const KnobProvider = KnobContext.Provider
|
|
113
77
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Everything here is derived during render, so there is no state to keep in
|
|
80
|
+
* sync: `value` comes from the props of `Root` and the rest follows from it.
|
|
81
|
+
*/
|
|
82
|
+
export function useKnobContext(): KnobContextValue
|
|
83
|
+
export function useKnobContext<T>(selector: (state: KnobContextValue) => T): T
|
|
84
|
+
export function useKnobContext<T>(selector?: (state: KnobContextValue) => T) {
|
|
85
|
+
const context = useContext(KnobContext)
|
|
86
|
+
if (!context) throw new Error('Missing KnobContext.Provider in the tree')
|
|
87
|
+
return selector ? selector(context) : context
|
|
120
88
|
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
.tremolo-knob {
|
|
2
|
+
/* The knob has no intrinsic size: the SVG inside scales to its box.
|
|
3
|
+
Override with the `size` prop, or by setting this variable. */
|
|
4
|
+
--knob-size: 50px;
|
|
5
|
+
|
|
2
6
|
display: inline-block;
|
|
7
|
+
width: var(--knob-size);
|
|
8
|
+
height: var(--knob-size);
|
|
3
9
|
cursor: grab;
|
|
4
|
-
overflow: visible;
|
|
5
10
|
outline: 0;
|
|
6
11
|
|
|
7
12
|
&[aria-readonly='true'] {
|
|
@@ -14,7 +19,6 @@
|
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
.tremolo-knob-active-line {
|
|
17
|
-
overflow: visible;
|
|
18
22
|
color: oklch(69.2% 0.17548 266.373);
|
|
19
23
|
|
|
20
24
|
:where(.tremolo-knob:focus, .tremolo-knob:hover) & {
|