@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.
Files changed (62) hide show
  1. package/dist/index.cjs +1125 -1437
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +58 -93
  4. package/dist/index.d.cts +561 -243
  5. package/dist/index.d.cts.map +1 -1
  6. package/dist/index.d.ts +561 -243
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1125 -1438
  9. package/dist/index.js.map +1 -1
  10. package/package.json +9 -10
  11. package/src/components/AnimationCanvas/index.tsx +84 -155
  12. package/src/components/Knob/ActiveLine.tsx +6 -6
  13. package/src/components/Knob/InactiveLine.tsx +9 -11
  14. package/src/components/Knob/SVGRoot.tsx +9 -18
  15. package/src/components/Knob/context.tsx +57 -89
  16. package/src/components/Knob/index.css +6 -2
  17. package/src/components/Knob/index.tsx +90 -61
  18. package/src/components/NumberInput/DecrementStepper.tsx +17 -50
  19. package/src/components/NumberInput/IncrementStepper.tsx +17 -50
  20. package/src/components/NumberInput/InputField.tsx +123 -0
  21. package/src/components/NumberInput/Stepper.tsx +90 -12
  22. package/src/components/NumberInput/context.tsx +74 -146
  23. package/src/components/NumberInput/index.css +38 -102
  24. package/src/components/NumberInput/index.tsx +252 -98
  25. package/src/components/NumberInput/stepperButton.tsx +90 -0
  26. package/src/components/Piano/index.tsx +272 -288
  27. package/src/components/Piano/keyboardShortcuts.ts +34 -4
  28. package/src/components/PointsEditor/Background.tsx +8 -2
  29. package/src/components/PointsEditor/Container.tsx +19 -9
  30. package/src/components/PointsEditor/Point.tsx +104 -24
  31. package/src/components/PointsEditor/context.tsx +28 -74
  32. package/src/components/PointsEditor/index.css +5 -0
  33. package/src/components/PointsEditor/index.tsx +77 -43
  34. package/src/components/Slider/{Scale.tsx → Marks.tsx} +8 -8
  35. package/src/components/Slider/{ScaleOption.tsx → MarksOption.tsx} +16 -18
  36. package/src/components/Slider/Thumb.tsx +55 -68
  37. package/src/components/Slider/Track.tsx +15 -15
  38. package/src/components/Slider/context.tsx +36 -51
  39. package/src/components/Slider/index.css +18 -5
  40. package/src/components/Slider/index.tsx +127 -146
  41. package/src/components/Slider/type.ts +4 -4
  42. package/src/components/XYPad/Area.tsx +16 -7
  43. package/src/components/XYPad/Thumb.tsx +62 -74
  44. package/src/components/XYPad/context.tsx +46 -0
  45. package/src/components/XYPad/index.css +12 -0
  46. package/src/components/XYPad/index.tsx +190 -230
  47. package/src/hooks/useDrag.ts +1 -1
  48. package/src/hooks/useDragValue.ts +149 -0
  49. package/src/hooks/useEventListener.ts +1 -1
  50. package/src/hooks/useWheel.ts +29 -9
  51. package/src/index.ts +15 -9
  52. package/src/components/AnimationCanvas/canvas.ts +0 -46
  53. package/src/components/NumberInput/InternalInput.tsx +0 -216
  54. package/src/components/NumberInput/type.ts +0 -58
  55. package/src/components/Piano/KeyLabel.tsx +0 -51
  56. package/src/components/Piano/context.tsx +0 -73
  57. package/src/components/Piano/key.tsx +0 -205
  58. package/src/components/_util/type.ts +0 -1
  59. package/src/hooks/useDragWithElement.ts +0 -100
  60. package/src/hooks/usePianoDrag.ts +0 -75
  61. package/src/hooks/useRefCallbackEvent.ts +0 -34
  62. /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.4.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.5.10",
27
- "@storybook/addon-links": "10.5.10",
28
- "@storybook/react-vite": "10.5.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.5.10",
36
+ "storybook": "10.6.0",
37
37
  "tone": "15.1.22",
38
- "@storybook/addon-docs": "10.5.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.4.0",
46
- "@tremolo-ui/functions": "^0.4.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
- DrawingContext,
13
- drawingState,
14
- DrawingStateValue,
15
- setDprConfig,
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: _width = 100,
91
- height: _height = 100,
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
- const canvasRef = useRef<HTMLCanvasElement>(null)
105
- const memoCanvasRef = useRef<HTMLCanvasElement>(null)
106
- const reqIdRef = useRef(-1)
107
- const widthRef = useRef(0)
108
- const heightRef = useRef(0)
109
- const deltaMemoRef = useRef(-1)
110
- const startTimeRef = useRef(-1)
111
-
112
- const loop = useCallback(
113
- (
114
- context: CanvasRenderingContext2D,
115
- width: RefObject<number>,
116
- height: RefObject<number>,
117
- count: number,
118
- ) => {
119
- const now = performance.now()
120
- const deltaTime = now - deltaMemoRef.current
121
- const elapsedTime = now - startTimeRef.current
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 (!canvasRef.current) return
142
- const canvas = canvasRef.current
143
- const context = canvas.getContext('2d', options)
144
- if (!context) throw new Error('Cannot get canvas context.')
145
- const dpr = globalThis.devicePixelRatio
146
-
147
- const firstRendering = (width: number, height: number) => {
148
- setDprConfig(canvas, context, width, height, dpr)
149
- widthRef.current = width
150
- heightRef.current = height
151
-
152
- if (init) init(context, { width, height })
153
- const now = performance.now()
154
- deltaMemoRef.current = now
155
- startTimeRef.current = now
156
- loop(context, widthRef, heightRef, -1)
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
- if (relativeSize) {
160
- const parent = canvas.parentElement
161
- if (!parent) throw new Error("Canvas doesn't have a parent element.")
162
- const memoCanvas = memoCanvasRef.current
163
- const memoContext = memoCanvas?.getContext('2d', options)
164
-
165
- const ro = new ResizeObserver((entries) => {
166
- for (const entry of entries) {
167
- const w = entry.contentRect.width
168
- const h = entry.contentRect.height
169
- const contextMemo = {} as DrawingContext
170
- if (reduceFlickering && memoCanvas && memoContext) {
171
- // Prevents loss of some context when the canvas is resized
172
- for (const prop of drawingState) {
173
- ;(contextMemo[prop] as DrawingStateValue) = context[prop]
174
- }
175
- memoCanvas.width = w * dpr
176
- memoCanvas.height = h * dpr
177
- memoContext.scale(1 / dpr, 1 / dpr)
178
- if (canvas.width > 0 && canvas.height > 0) {
179
- memoContext.drawImage(canvas, 0, 0)
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
- }, [loop, init, options, reduceFlickering, relativeSize, animate])
156
+ instanceRef.current?.update({
157
+ animate,
158
+ size: { width, height },
159
+ reduceFlickering,
160
+ })
161
+ })
226
162
 
227
163
  return (
228
- <>
229
- <canvas
230
- className={clsx('tremolo-animation-canvas', className)}
231
- width={relativeSize ? 0 : _width}
232
- height={relativeSize ? 0 : _height}
233
- ref={canvasRef}
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 { center, useKnobContext } from './context'
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
- const x2 = useKnobContext((s) => s.x2)
15
- const y2 = useKnobContext((s) => s.y2)
16
- const x3 = useKnobContext((s) => s.x3)
17
- const y3 = useKnobContext((s) => s.y3)
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 ${x2} ${y2} A ${center} ${center} -135 ${r3 - r2 > 180 ? 1 : 0} 1 ${x3} ${y3}`}
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 { center, useKnobContext } from './context'
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
- const x1 = useKnobContext((s) => s.x1)
21
- const y1 = useKnobContext((s) => s.y1)
22
- const x2 = useKnobContext((s) => s.x2)
23
- const y2 = useKnobContext((s) => s.y2)
24
- const x3 = useKnobContext((s) => s.x3)
25
- const y3 = useKnobContext((s) => s.y3)
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 ${x1} ${y1} A ${center} ${center} -135 ${r2 - r1 > 180 ? 1 : 0} 1 ${x2} ${y2}`}
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 ${x3} ${y3} A ${center} ${center} -135 ${r4 - r3 > 180 ? 1 : 0} 1 ${x4} ${y4}`}
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 SVGRoot {
11
- block?: boolean
12
- overflowVisible?: boolean
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
- }: Override<SVGRoot, SVGProps<SVGSVGElement>>) {
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 { normalizeValue, radian } from '@tremolo-ui/functions'
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 State = {
8
+ export type KnobConfig = {
16
9
  value: number
17
10
  min: number
18
11
  max: number
19
12
  step: number
20
- skew: number // | SkewFunction // TODO
13
+ scale: Scale
21
14
  startValue: number
15
+ /** angle range [degree] */
22
16
  angleRange: number
23
17
  }
24
18
 
25
- type DrawingState = {
26
- p: number // normalized value
27
- r1: number // ロータリー開始位置
28
- r2: number // activeLineの開始位置
29
- r3: number // activeLineの終了位置
30
- r4: number // ロータリー終了位置
31
- x1: number
32
- y1: number
33
- x2: number
34
- y2: number
35
- x3: number
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
- function calcDrawingState({
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
- skew,
59
+ scale,
46
60
  startValue,
47
61
  angleRange,
48
- }: State) {
49
- const p = normalizeValue(value, min, max, skew)
50
- const s = normalizeValue(startValue, min, max, skew)
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
- type KnobStore = ReturnType<typeof createKnobStore>
74
+ const KnobContext = createContext<KnobContextValue | null>(null)
83
75
 
84
- const createKnobStore = (initProps: State) => {
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
- export function useKnobContext<T>(
115
- selector: (state: State & DrawingState) => T,
116
- ): T {
117
- const store = useContext(KnobContext)
118
- if (!store) throw new Error('Missing KnobContext.Provider in the tree')
119
- return useStore(store, selector)
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) & {