@tremolo-ui/react 0.5.0 → 0.6.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 (76) hide show
  1. package/dist/compose-refs.cjs +72 -0
  2. package/dist/compose-refs.cjs.map +1 -0
  3. package/dist/compose-refs.d.cts +43 -0
  4. package/dist/compose-refs.d.cts.map +1 -0
  5. package/dist/compose-refs.d.ts +43 -0
  6. package/dist/compose-refs.d.ts.map +1 -0
  7. package/dist/compose-refs.js +70 -0
  8. package/dist/compose-refs.js.map +1 -0
  9. package/dist/index.cjs +1468 -534
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +1103 -435
  12. package/dist/index.d.cts.map +1 -1
  13. package/dist/index.d.ts +1103 -435
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1457 -534
  16. package/dist/index.js.map +1 -1
  17. package/package.json +28 -26
  18. package/src/components/AnimationCanvas/index.tsx +115 -76
  19. package/src/components/Knob/ActiveLine.tsx +7 -6
  20. package/src/components/Knob/InactiveLine.tsx +9 -10
  21. package/src/components/Knob/SVGRoot.tsx +5 -3
  22. package/src/components/Knob/Thumb.tsx +33 -11
  23. package/src/components/Knob/context.tsx +17 -1
  24. package/src/components/Knob/index.tsx +148 -55
  25. package/src/components/NumberInput/DecrementStepper.tsx +8 -4
  26. package/src/components/NumberInput/IncrementStepper.tsx +9 -4
  27. package/src/components/NumberInput/InputField.tsx +188 -105
  28. package/src/components/NumberInput/Stepper.tsx +123 -72
  29. package/src/components/NumberInput/{stepperButton.tsx → StepperButton.tsx} +27 -22
  30. package/src/components/NumberInput/context.tsx +35 -8
  31. package/src/components/NumberInput/index.tsx +272 -75
  32. package/src/components/Piano/index.tsx +354 -188
  33. package/src/components/PointsEditor/Background.tsx +11 -2
  34. package/src/components/PointsEditor/Container.tsx +87 -27
  35. package/src/components/PointsEditor/Point.tsx +239 -79
  36. package/src/components/PointsEditor/SelectionBox.tsx +54 -0
  37. package/src/components/PointsEditor/context.tsx +78 -10
  38. package/src/components/PointsEditor/index.tsx +440 -39
  39. package/src/components/Slider/Marks.tsx +48 -16
  40. package/src/components/Slider/MarksOption.tsx +57 -43
  41. package/src/components/Slider/Thumb.tsx +108 -50
  42. package/src/components/Slider/Track.tsx +69 -58
  43. package/src/components/Slider/context.tsx +4 -2
  44. package/src/components/Slider/index.tsx +131 -65
  45. package/src/components/Slider/type.ts +26 -8
  46. package/src/components/XYPad/Area.tsx +45 -35
  47. package/src/components/XYPad/Thumb.tsx +135 -62
  48. package/src/components/XYPad/context.tsx +4 -1
  49. package/src/components/XYPad/index.tsx +180 -48
  50. package/src/components/_util/Placement.tsx +68 -0
  51. package/src/components/_util/VisuallyHiddenRangeInput.tsx +24 -0
  52. package/src/components/_util/css-length.ts +13 -0
  53. package/src/{components/_util/composeRefs.tsx → compose-refs/index.tsx} +33 -5
  54. package/src/hooks/_internal/useCallbackRef.ts +3 -1
  55. package/src/hooks/_internal/useCheckSteps.ts +170 -0
  56. package/src/hooks/useAnimationFrame.ts +17 -7
  57. package/src/hooks/useDrag.ts +38 -8
  58. package/src/hooks/useDragValue.ts +54 -9
  59. package/src/hooks/useEventListener.ts +10 -6
  60. package/src/hooks/useLongPress.ts +42 -8
  61. package/src/hooks/useMIDIAccess.ts +82 -11
  62. package/src/hooks/useMIDIInput.ts +45 -13
  63. package/src/hooks/useMIDIMessage.ts +23 -5
  64. package/src/index.ts +38 -21
  65. package/src/input-event.ts +32 -0
  66. package/dist/index.css +0 -501
  67. package/src/Globals.d.ts +0 -3
  68. package/src/components/Knob/index.css +0 -81
  69. package/src/components/NumberInput/index.css +0 -121
  70. package/src/components/Piano/index.css +0 -90
  71. package/src/components/PointsEditor/index.css +0 -35
  72. package/src/components/Slider/index.css +0 -125
  73. package/src/components/XYPad/index.css +0 -60
  74. package/src/components/_util/index.ts +0 -28
  75. package/src/styles/global.css +0 -19
  76. /package/src/components/Piano/{keyboardShortcuts.ts → keyboard-shortcuts.ts} +0 -0
package/package.json CHANGED
@@ -1,22 +1,21 @@
1
1
  {
2
2
  "name": "@tremolo-ui/react",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "React component library for audio app",
5
5
  "type": "module",
6
- "sideEffects": [
7
- "*.css"
8
- ],
6
+ "sideEffects": false,
9
7
  "main": "dist/index.cjs",
10
8
  "module": "dist/index.js",
11
9
  "types": "dist/index.d.cts",
12
10
  "scripts": {
13
- "sb": "storybook dev -p 6006",
11
+ "sb": "storybook dev",
14
12
  "typecheck": "tsc",
15
- "build:sb": "storybook build",
13
+ "build:sb": "storybook build -o storybook-static/i/storybook-react && cp .storybook/_headers storybook-static/_headers",
16
14
  "serve": "http-server ./storybook-static",
17
15
  "lint": "eslint --fix --ext .ts,.tsx .",
18
16
  "format": "prettier --write .",
19
- "test": "jest",
17
+ "test": "vitest run",
18
+ "test:watch": "vitest",
20
19
  "build:package": "tsc && tsdown",
21
20
  "clean": "npm run clean:build && npm run clean:sb",
22
21
  "clean:build": "rimraf ./dist",
@@ -24,31 +23,32 @@
24
23
  },
25
24
  "devDependencies": {
26
25
  "@storybook/addon-a11y": "10.6.0",
26
+ "@storybook/addon-docs": "10.6.0",
27
27
  "@storybook/addon-links": "10.6.0",
28
28
  "@storybook/react-vite": "10.6.0",
29
+ "@testing-library/jest-dom": "7.0.1",
29
30
  "@testing-library/react": "16.3.3",
30
- "@types/react": "19.2.18",
31
- "@types/react-dom": "19.2.5",
32
- "jest-environment-jsdom": "29.7.0",
31
+ "@types/react": "19.3.0",
32
+ "@types/react-dom": "19.3.0",
33
33
  "jotai": "2.20.3",
34
- "react": "19.2.8",
35
- "react-dom": "19.2.8",
34
+ "react": "19.3.0",
35
+ "react-dom": "19.3.0",
36
36
  "storybook": "10.6.0",
37
- "tone": "15.1.22",
38
- "@storybook/addon-docs": "10.6.0"
37
+ "tone": "15.1.22"
39
38
  },
40
39
  "peerDependencies": {
41
40
  "react": "^18 ||^19",
42
41
  "react-dom": "^18 || ^19"
43
42
  },
44
43
  "dependencies": {
45
- "@tremolo-ui/dom": "^0.5.0",
46
- "@tremolo-ui/functions": "^0.5.0",
47
- "clsx": "^2.1.1"
44
+ "@tremolo-ui/dom": "^0.6.0",
45
+ "@tremolo-ui/functions": "^0.6.0"
48
46
  },
49
47
  "files": [
50
48
  "dist",
51
- "src"
49
+ "src",
50
+ "!src/**/*.test.{ts,tsx}",
51
+ "!src/**/*.stories.{ts,tsx}"
52
52
  ],
53
53
  "publishConfig": {
54
54
  "access": "public"
@@ -82,13 +82,15 @@
82
82
  "default": "./dist/index.js"
83
83
  }
84
84
  },
85
- "./styles/index.css": "./dist/index.css",
86
- "./styles/global.css": "./src/styles/global.css",
87
- "./styles/Knob.css": "./src/components/Knob/index.css",
88
- "./styles/NumberInput.css": "./src/components/NumberInput/index.css",
89
- "./styles/Piano.css": "./src/components/Piano/index.css",
90
- "./styles/PointsEditor.css": "./src/components/PointsEditor/index.css",
91
- "./styles/Slider.css": "./src/components/Slider/index.css",
92
- "./styles/XYPad.css": "./src/components/XYPad/index.css"
85
+ "./compose-refs": {
86
+ "require": {
87
+ "types": "./dist/compose-refs.d.cts",
88
+ "default": "./dist/compose-refs.cjs"
89
+ },
90
+ "import": {
91
+ "types": "./dist/compose-refs.d.ts",
92
+ "default": "./dist/compose-refs.js"
93
+ }
94
+ }
93
95
  }
94
96
  }
@@ -1,7 +1,6 @@
1
- import clsx from 'clsx'
2
1
  import {
3
2
  ComponentPropsWithoutRef,
4
- ReactElement,
3
+ forwardRef,
5
4
  useEffect,
6
5
  useRef,
7
6
  useState,
@@ -13,6 +12,8 @@ import {
13
12
  type AnimationFrame,
14
13
  } from '@tremolo-ui/dom'
15
14
 
15
+ import { useComposedRefs } from '../../compose-refs'
16
+
16
17
  export type InitFunction = (
17
18
  context: CanvasRenderingContext2D,
18
19
  option: {
@@ -28,9 +29,27 @@ export type DrawFunction = (
28
29
  option: AnimationFrame,
29
30
  ) => void
30
31
 
31
- export interface CommonProps {
32
+ export interface AnimationCanvasCommonProps {
33
+ /**
34
+ * Draw one frame. It is given the 2D context and the frame: the size in CSS
35
+ * pixels, `count`, `deltaTime`, `elapsedTime` and `fps`.
36
+ *
37
+ * A new function replaces the old one in place, so writing it inline does
38
+ * not restart anything.
39
+ */
32
40
  draw: DrawFunction
41
+ /**
42
+ * Called once before the first frame, with the context and the size in CSS
43
+ * pixels. Set up what every frame shares here.
44
+ */
33
45
  init?: InitFunction
46
+ /**
47
+ * Redraw on every animation frame. Turn it off to draw only when there is a
48
+ * reason to: when the canvas mounts, when it is resized, and when the
49
+ * component re-renders.
50
+ *
51
+ * @default true
52
+ */
34
53
  animate?: boolean
35
54
  /**
36
55
  * Read once, when the 2D context is created, so changing it later has no
@@ -39,59 +58,88 @@ export interface CommonProps {
39
58
  * @see https://developer.mozilla.org/docs/Web/API/HTMLCanvasElement/getContext#contextattributes
40
59
  */
41
60
  options?: CanvasRenderingContext2DSettings
61
+ /**
62
+ * Carry the drawing across a resize, so that the canvas does not blank for a
63
+ * frame while the new size is drawn. A fixed canvas is resized too, when its
64
+ * `width` or `height` changes.
65
+ *
66
+ * @default true
67
+ */
68
+ reduceFlickering?: boolean
42
69
  }
43
70
 
44
- export interface AbsoluteSizingProps {
71
+ /** A canvas of the size given in CSS pixels. This is the default. */
72
+ export interface AnimationCanvasFixedProps {
73
+ /** Leave it out, or pass `false`, for a canvas of the size below. */
74
+ resizable?: false
75
+ /**
76
+ * Width of the canvas in CSS pixels.
77
+ * @default 100
78
+ */
45
79
  width?: number
80
+ /**
81
+ * Height of the canvas in CSS pixels.
82
+ * @default 100
83
+ */
46
84
  height?: number
47
85
  }
48
86
 
49
- export interface RelativeSizingProps {
50
- relativeSize?: boolean
51
- reduceFlickering?: boolean
87
+ /**
88
+ * A canvas that follows the size of its parent element, so the parent needs a
89
+ * size of its own.
90
+ */
91
+ export interface AnimationCanvasResizableProps {
92
+ /**
93
+ * Follow the size of the parent element instead of `width` and `height`.
94
+ * Switching it rebuilds the canvas.
95
+ */
96
+ resizable: true
97
+ /** Not accepted: the size comes from the parent element. */
98
+ width?: never
99
+ /** Not accepted: the size comes from the parent element. */
100
+ height?: never
52
101
  }
53
102
 
54
- export type AnimationCanvasProps = CommonProps &
55
- AbsoluteSizingProps &
56
- RelativeSizingProps
103
+ export type AnimationCanvasProps = AnimationCanvasCommonProps &
104
+ (AnimationCanvasFixedProps | AnimationCanvasResizableProps)
105
+
106
+ type Props = AnimationCanvasProps &
107
+ Omit<
108
+ ComponentPropsWithoutRef<'canvas'>,
109
+ keyof AnimationCanvasCommonProps | 'resizable' | 'width' | 'height'
110
+ >
57
111
 
58
112
  /**
59
113
  * A simple animatable canvas with requestAnimationFrame()
60
114
  */
61
- export function AnimationCanvas(
62
- props: CommonProps &
63
- AbsoluteSizingProps &
64
- Omit<ComponentPropsWithoutRef<'canvas'>, keyof AnimationCanvasProps>,
65
- ): ReactElement
66
- export function AnimationCanvas(
67
- pros: CommonProps &
68
- RelativeSizingProps &
69
- Omit<ComponentPropsWithoutRef<'canvas'>, keyof AnimationCanvasProps>,
70
- ): ReactElement
71
- export function AnimationCanvas({
72
- // common
73
- draw,
74
- init,
75
- animate = true,
76
- options,
77
- // absolute
78
- width = 100,
79
- height = 100,
80
- // relative
81
- relativeSize = false,
82
- reduceFlickering = true,
83
- // canvas props
84
- className,
85
- onContextMenu = (event) => event.preventDefault(),
86
- ...props
87
- }: AnimationCanvasProps &
88
- Omit<
89
- ComponentPropsWithoutRef<'canvas'>,
90
- keyof AnimationCanvasProps
91
- >): ReactElement {
115
+ export const AnimationCanvas = /* @__PURE__ */ forwardRef<
116
+ HTMLCanvasElement,
117
+ Props
118
+ >(function AnimationCanvas(
119
+ {
120
+ // common
121
+ draw,
122
+ init,
123
+ animate = true,
124
+ options,
125
+ reduceFlickering = true,
126
+ // fixed
127
+ width = 100,
128
+ height = 100,
129
+ // resizable
130
+ resizable = false,
131
+ // canvas props
132
+ className,
133
+ onContextMenu = (event) => event.preventDefault(),
134
+ ...props
135
+ },
136
+ forwardedRef,
137
+ ) {
92
138
  // See useDrag for why the node is held in state rather than a ref: an inline
93
139
  // ref would be re-attached on every render and tear the instance down.
94
140
  const [node, setNode] = useState<HTMLCanvasElement | null>(null)
141
+ // The caller's ref gets the same element.
142
+ const composedRef = useComposedRefs<HTMLCanvasElement>(forwardedRef, setNode)
95
143
 
96
144
  // Read when the instance is created. The effect below keeps it current, and
97
145
  // runs right after, so a stale handler is replaced within the same commit.
@@ -105,8 +153,26 @@ export function AnimationCanvas({
105
153
  options,
106
154
  })
107
155
  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)
156
+
157
+ // Runs before the creation effect below. This keeps a rebuilt instance on
158
+ // the current render's size and handlers while updating an existing one in
159
+ // place on ordinary renders.
160
+ useEffect(() => {
161
+ latest.current = {
162
+ draw,
163
+ init,
164
+ animate,
165
+ width,
166
+ height,
167
+ reduceFlickering,
168
+ options,
169
+ }
170
+ instanceRef.current?.update({
171
+ animate,
172
+ size: { width, height },
173
+ reduceFlickering,
174
+ })
175
+ })
110
176
 
111
177
  useEffect(() => {
112
178
  if (!node) return
@@ -118,54 +184,27 @@ export function AnimationCanvas({
118
184
  animate: current.animate,
119
185
  size: { width: current.width, height: current.height },
120
186
  reduceFlickering: current.reduceFlickering,
121
- relativeSize,
187
+ resizable,
122
188
  contextAttributes: current.options,
123
189
  })
124
190
  instanceRef.current = instance
125
- justCreated.current = true
126
191
 
127
192
  return () => {
128
193
  instanceRef.current = null
129
194
  instance.destroy()
130
195
  }
131
- // Only `relativeSize` decides how the instance is wired, so it is the one
196
+ // Only `resizable` decides how the instance is wired, so it is the one
132
197
  // setting that rebuilds it. `options` is read from the ref above rather
133
198
  // than depended on: it is almost always written inline, and depending on
134
199
  // 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
155
- }
156
- instanceRef.current?.update({
157
- animate,
158
- size: { width, height },
159
- reduceFlickering,
160
- })
161
- })
200
+ }, [node, resizable])
162
201
 
163
202
  return (
164
203
  <canvas
165
- className={clsx('tremolo-animation-canvas', className)}
166
- ref={setNode}
204
+ className={className}
205
+ ref={composedRef}
167
206
  onContextMenu={onContextMenu}
168
207
  {...props}
169
208
  />
170
209
  )
171
- }
210
+ })
@@ -1,7 +1,8 @@
1
- import clsx from 'clsx'
2
1
  import { SVGProps } from 'react'
3
2
 
4
- import { arcRadius, pointOnArc, useKnobContext } from './context'
3
+ import { useCheckPlacement } from '../_util/Placement'
4
+
5
+ import { arcPath, arcRadius, useKnobContext } from './context'
5
6
 
6
7
  export function ActiveLine({
7
8
  stroke = 'currentColor',
@@ -9,17 +10,17 @@ export function ActiveLine({
9
10
  className,
10
11
  ...props
11
12
  }: Omit<SVGProps<SVGPathElement>, 'd'>) {
13
+ useCheckPlacement('Knob.ActiveLine', 'Knob.SVGRoot')
14
+
12
15
  const r2 = useKnobContext((s) => s.r2)
13
16
  const r3 = useKnobContext((s) => s.r3)
14
17
 
15
18
  const radius = arcRadius(strokeWidth)
16
- const start = pointOnArc(r2, radius)
17
- const end = pointOnArc(r3, radius)
18
19
 
19
20
  return (
20
21
  <path
21
- className={clsx('tremolo-knob-active-line', className)}
22
- d={`M ${start.x} ${start.y} A ${radius} ${radius} -135 ${r3 - r2 > 180 ? 1 : 0} 1 ${end.x} ${end.y}`}
22
+ className={className}
23
+ d={arcPath(r2, r3, radius)}
23
24
  fill="none"
24
25
  stroke={stroke}
25
26
  strokeWidth={strokeWidth}
@@ -1,7 +1,8 @@
1
- import clsx from 'clsx'
2
1
  import { SVGProps } from 'react'
3
2
 
4
- import { arcRadius, pointOnArc, useKnobContext } from './context'
3
+ import { useCheckPlacement } from '../_util/Placement'
4
+
5
+ import { arcPath, arcRadius, useKnobContext } from './context'
5
6
 
6
7
  export function InactiveLine({
7
8
  stroke = 'currentColor',
@@ -9,6 +10,8 @@ export function InactiveLine({
9
10
  className,
10
11
  ...props
11
12
  }: Omit<SVGProps<SVGPathElement>, 'd'>) {
13
+ useCheckPlacement('Knob.InactiveLine', 'Knob.SVGRoot')
14
+
12
15
  const min = useKnobContext((s) => s.min)
13
16
  const max = useKnobContext((s) => s.max)
14
17
  const startValue = useKnobContext((s) => s.startValue)
@@ -19,17 +22,13 @@ export function InactiveLine({
19
22
  const r4 = useKnobContext((s) => s.r4)
20
23
 
21
24
  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)
26
25
 
27
26
  return (
28
27
  <>
29
28
  {startValue > min && (
30
29
  <path
31
- className={clsx('tremolo-knob-inactive-line', className)}
32
- d={`M ${p1.x} ${p1.y} A ${radius} ${radius} -135 ${r2 - r1 > 180 ? 1 : 0} 1 ${p2.x} ${p2.y}`}
30
+ className={className}
31
+ d={arcPath(r1, r2, radius)}
33
32
  fill="none"
34
33
  stroke={stroke}
35
34
  strokeWidth={strokeWidth}
@@ -38,8 +37,8 @@ export function InactiveLine({
38
37
  )}
39
38
  {startValue < max && (
40
39
  <path
41
- className={clsx('tremolo-knob-inactive-line', className)}
42
- d={`M ${p3.x} ${p3.y} A ${radius} ${radius} -135 ${r4 - r3 > 180 ? 1 : 0} 1 ${p4.x} ${p4.y}`}
40
+ className={className}
41
+ d={arcPath(r3, r4, radius)}
43
42
  fill="none"
44
43
  stroke={stroke}
45
44
  strokeWidth={strokeWidth}
@@ -1,8 +1,10 @@
1
1
  import { ReactNode, SVGProps } from 'react'
2
2
 
3
+ import { Placement } from '../_util/Placement'
4
+
3
5
  import { viewBoxSize } from './context'
4
6
 
5
- export interface SVGRootProps {
7
+ export interface KnobSVGRootProps {
6
8
  /**
7
9
  * `<Knob.InactiveLine />`, `<Knob.ActiveLine />` and `<Knob.Thumb />` go
8
10
  * here, in the order you want them painted.
@@ -14,7 +16,7 @@ export function SVGRoot({
14
16
  children,
15
17
  style,
16
18
  ...props
17
- }: SVGRootProps & Omit<SVGProps<SVGSVGElement>, keyof SVGRootProps>) {
19
+ }: KnobSVGRootProps & Omit<SVGProps<SVGSVGElement>, keyof KnobSVGRootProps>) {
18
20
  return (
19
21
  <svg
20
22
  viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
@@ -24,7 +26,7 @@ export function SVGRoot({
24
26
  }}
25
27
  {...props}
26
28
  >
27
- {children}
29
+ <Placement name="Knob.SVGRoot">{children}</Placement>
28
30
  </svg>
29
31
  )
30
32
  }
@@ -1,24 +1,44 @@
1
- import clsx from 'clsx'
2
1
  import { SVGProps } from 'react'
3
2
 
4
3
  import { clamp } from '@tremolo-ui/functions'
5
4
 
5
+ import { useCheckPlacement } from '../_util/Placement'
6
+
6
7
  import { useKnobContext, viewBoxSize } from './context'
7
8
 
8
- interface Props {
9
- /** color */
9
+ export interface KnobThumbProps {
10
+ /**
11
+ * Fill colour of the circle.
12
+ * @default 'currentColor'
13
+ */
10
14
  thumb?: string
11
- /** color */
15
+ /**
16
+ * Colour of the line that points at the value.
17
+ * @default 'currentColor'
18
+ */
12
19
  thumbLine?: string
13
- /** percent (0-100) */
20
+ /**
21
+ * Diameter of the circle, as a percentage of the knob.
22
+ * @default 84
23
+ */
14
24
  thumbSize?: number
15
- /** percent (0-100) */
25
+ /**
26
+ * Thickness of the line, as a percentage of the knob.
27
+ * @default 6
28
+ */
16
29
  thumbLineWeight?: number
17
- /** percent (0-100) */
30
+ /**
31
+ * How far down the line reaches, as a percentage of the knob from its top.
32
+ * The line starts at the edge of the circle.
33
+ * @default 35
34
+ */
18
35
  thumbLineLength?: number
19
36
 
37
+ /**
38
+ * Classes for what the thumb draws inside itself: `thumbLine` is the line
39
+ * that points at the value.
40
+ */
20
41
  classes?: {
21
- thumb?: string
22
42
  thumbLine?: string
23
43
  }
24
44
  }
@@ -34,17 +54,19 @@ export function Thumb({
34
54
  classes,
35
55
 
36
56
  ...props
37
- }: Props & Omit<SVGProps<SVGSVGElement>, 'd' | keyof Props>) {
57
+ }: KnobThumbProps & Omit<SVGProps<SVGSVGElement>, 'd' | keyof KnobThumbProps>) {
58
+ useCheckPlacement('Knob.Thumb', 'Knob.SVGRoot')
59
+
38
60
  const angleRange = useKnobContext((s) => s.angleRange)
39
61
 
40
62
  const p = useKnobContext((s) => s.p)
41
63
  const r1 = useKnobContext((s) => s.r1)
42
64
 
43
65
  return (
44
- <svg className={clsx('tremolo-knob-thumb', classes?.thumb)} {...props}>
66
+ <svg className={className} {...props}>
45
67
  <circle cx="50%" cy="50%" r={`${thumbSize / 2}%`} fill={thumb} />
46
68
  <line
47
- className={clsx('tremolo-knob-thumb-line', classes?.thumbLine)}
69
+ className={classes?.thumbLine}
48
70
  x1="50%"
49
71
  y1={`${(viewBoxSize - clamp(thumbSize, 0, 100)) / 2}%`}
50
72
  x2="50%"
@@ -51,6 +51,22 @@ export function arcRadius(strokeWidth: number | string | undefined) {
51
51
  return Number.isFinite(width) ? center - width / 2 : center
52
52
  }
53
53
 
54
+ /** Build an SVG path for an arc, splitting full turns into drawable segments. */
55
+ export function arcPath(startAngle: number, endAngle: number, radius: number) {
56
+ const start = pointOnArc(startAngle, radius)
57
+ const sweep = endAngle - startAngle
58
+ const segmentCount = Math.max(1, Math.ceil(Math.abs(sweep) / 180))
59
+ const segmentSweep = sweep / segmentCount
60
+ let path = `M ${start.x} ${start.y}`
61
+
62
+ for (let i = 1; i <= segmentCount; i += 1) {
63
+ const end = pointOnArc(startAngle + segmentSweep * i, radius)
64
+ path += ` A ${radius} ${radius} 0 0 ${segmentSweep >= 0 ? 1 : 0} ${end.x} ${end.y}`
65
+ }
66
+
67
+ return path
68
+ }
69
+
54
70
  /** 設定から描画に必要な角度を導出する。レンダー中に呼ぶ。 */
55
71
  export function calcAngles({
56
72
  value,
@@ -71,7 +87,7 @@ export function calcAngles({
71
87
  return { p, r1, r2, r3, r4 }
72
88
  }
73
89
 
74
- const KnobContext = createContext<KnobContextValue | null>(null)
90
+ const KnobContext = /* @__PURE__ */ createContext<KnobContextValue | null>(null)
75
91
 
76
92
  export const KnobProvider = KnobContext.Provider
77
93