@tremolo-ui/react 0.4.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 (90) 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 +2272 -1650
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +1461 -475
  12. package/dist/index.d.cts.map +1 -1
  13. package/dist/index.d.ts +1461 -475
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2258 -1648
  16. package/dist/index.js.map +1 -1
  17. package/package.json +32 -31
  18. package/src/components/AnimationCanvas/index.tsx +162 -194
  19. package/src/components/Knob/ActiveLine.tsx +9 -8
  20. package/src/components/Knob/InactiveLine.tsx +11 -14
  21. package/src/components/Knob/SVGRoot.tsx +10 -17
  22. package/src/components/Knob/Thumb.tsx +33 -11
  23. package/src/components/Knob/context.tsx +73 -89
  24. package/src/components/Knob/index.tsx +227 -105
  25. package/src/components/NumberInput/DecrementStepper.tsx +21 -50
  26. package/src/components/NumberInput/IncrementStepper.tsx +22 -50
  27. package/src/components/NumberInput/InputField.tsx +206 -0
  28. package/src/components/NumberInput/Stepper.tsx +150 -21
  29. package/src/components/NumberInput/StepperButton.tsx +95 -0
  30. package/src/components/NumberInput/context.tsx +104 -149
  31. package/src/components/NumberInput/index.tsx +461 -110
  32. package/src/components/Piano/index.tsx +413 -263
  33. package/src/components/Piano/keyboard-shortcuts.ts +61 -0
  34. package/src/components/PointsEditor/Background.tsx +19 -4
  35. package/src/components/PointsEditor/Container.tsx +92 -22
  36. package/src/components/PointsEditor/Point.tsx +293 -53
  37. package/src/components/PointsEditor/SelectionBox.tsx +54 -0
  38. package/src/components/PointsEditor/context.tsx +97 -75
  39. package/src/components/PointsEditor/index.tsx +497 -62
  40. package/src/components/Slider/Marks.tsx +84 -0
  41. package/src/components/Slider/MarksOption.tsx +115 -0
  42. package/src/components/Slider/Thumb.tsx +99 -54
  43. package/src/components/Slider/Track.tsx +73 -62
  44. package/src/components/Slider/context.tsx +40 -53
  45. package/src/components/Slider/index.tsx +237 -190
  46. package/src/components/Slider/type.ts +27 -9
  47. package/src/components/XYPad/Area.tsx +52 -33
  48. package/src/components/XYPad/Thumb.tsx +121 -60
  49. package/src/components/XYPad/context.tsx +49 -0
  50. package/src/components/XYPad/index.tsx +338 -246
  51. package/src/components/_util/Placement.tsx +68 -0
  52. package/src/components/_util/VisuallyHiddenRangeInput.tsx +24 -0
  53. package/src/components/_util/css-length.ts +13 -0
  54. package/src/{components/_util/composeRefs.tsx → compose-refs/index.tsx} +33 -5
  55. package/src/hooks/{useCallbackRef.ts → _internal/useCallbackRef.ts} +3 -1
  56. package/src/hooks/_internal/useCheckSteps.ts +170 -0
  57. package/src/hooks/useAnimationFrame.ts +17 -7
  58. package/src/hooks/useDrag.ts +39 -9
  59. package/src/hooks/useDragValue.ts +194 -0
  60. package/src/hooks/useEventListener.ts +11 -7
  61. package/src/hooks/useLongPress.ts +42 -8
  62. package/src/hooks/useMIDIAccess.ts +82 -11
  63. package/src/hooks/useMIDIInput.ts +45 -13
  64. package/src/hooks/useMIDIMessage.ts +23 -5
  65. package/src/hooks/useWheel.ts +29 -9
  66. package/src/index.ts +50 -27
  67. package/src/input-event.ts +32 -0
  68. package/dist/index.css +0 -536
  69. package/src/Globals.d.ts +0 -3
  70. package/src/components/AnimationCanvas/canvas.ts +0 -46
  71. package/src/components/Knob/index.css +0 -77
  72. package/src/components/NumberInput/InternalInput.tsx +0 -216
  73. package/src/components/NumberInput/index.css +0 -185
  74. package/src/components/NumberInput/type.ts +0 -58
  75. package/src/components/Piano/KeyLabel.tsx +0 -51
  76. package/src/components/Piano/context.tsx +0 -73
  77. package/src/components/Piano/index.css +0 -90
  78. package/src/components/Piano/key.tsx +0 -205
  79. package/src/components/Piano/keyboardShortcuts.ts +0 -31
  80. package/src/components/PointsEditor/index.css +0 -30
  81. package/src/components/Slider/Scale.tsx +0 -52
  82. package/src/components/Slider/ScaleOption.tsx +0 -103
  83. package/src/components/Slider/index.css +0 -112
  84. package/src/components/XYPad/index.css +0 -48
  85. package/src/components/_util/index.ts +0 -28
  86. package/src/components/_util/type.ts +0 -1
  87. package/src/hooks/useDragWithElement.ts +0 -100
  88. package/src/hooks/usePianoDrag.ts +0 -75
  89. package/src/hooks/useRefCallbackEvent.ts +0 -34
  90. package/src/styles/global.css +0 -19
package/dist/index.js CHANGED
@@ -1,146 +1,191 @@
1
- import clsx from "clsx";
2
- import React, { createContext, createRef, forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useInsertionEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
1
+ import { useComposedRefs } from "./compose-refs.js";
2
+ import { createContext, forwardRef, useCallback, useContext, useEffect, useId, useImperativeHandle, useInsertionEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
3
+ import { NOT_SUPPORTED, PERMISSION_DENIED, PITCH_BEND_CENTER, UNAVAILABLE, createAnimationCanvas, createDrag, createDragValue, createMIDIAccess, createMIDIInput, createMIDIMessage, createPianoInput, createSelectionBox, createWheel, elementMapping, relativeMapping, toXY } from "@tremolo-ui/dom";
3
4
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
- import { clamp, decimalPart, isBlackKey, isWhiteKey, normalizeValue, noteKey, noteKeys, radian, rawValue, stepValue, styleHelper, toFixed, xor } from "@tremolo-ui/functions";
5
- import { NOT_SUPPORTED, PERMISSION_DENIED, createDrag, createMIDIAccess, createMIDIInput, createMIDIMessage, createWheel } from "@tremolo-ui/dom";
6
- import { createStore, useStore } from "zustand";
7
- //#region src/components/AnimationCanvas/canvas.ts
8
- const drawingState = [
9
- "strokeStyle",
10
- "fillStyle",
11
- "globalAlpha",
12
- "lineWidth",
13
- "lineCap",
14
- "lineJoin",
15
- "miterLimit",
16
- "lineDashOffset",
17
- "shadowOffsetX",
18
- "shadowOffsetY",
19
- "shadowBlur",
20
- "shadowColor",
21
- "globalCompositeOperation",
22
- "font",
23
- "textAlign",
24
- "textBaseline",
25
- "direction",
26
- "imageSmoothingEnabled"
27
- ];
28
- function setDprConfig(canvas, context, width, height, dpr) {
29
- canvas.width = width * dpr;
30
- canvas.height = height * dpr;
31
- context.setTransform(1, 0, 0, 1, 0, 0);
32
- context.scale(dpr, dpr);
33
- canvas.style.width = `${width}px`;
34
- canvas.style.height = `${height}px`;
35
- }
36
- //#endregion
5
+ import { applyDelta, blackKeyWidth, clamp, decimalPart, getNoteRangeArray, isWhiteKey, linearScale, mapModifier, noteKey, notePosition, pianoWidth, radian, selectInputEvent, selectModifier, toFixed, toPrecision, xor } from "@tremolo-ui/functions";
37
6
  //#region src/components/AnimationCanvas/index.tsx
38
- function AnimationCanvas({ draw, init, animate = true, options, width: _width = 100, height: _height = 100, relativeSize, reduceFlickering = true, className, onContextMenu = (event) => event.preventDefault(), ...props }) {
39
- const canvasRef = useRef(null);
40
- const memoCanvasRef = useRef(null);
41
- const reqIdRef = useRef(-1);
42
- const widthRef = useRef(0);
43
- const heightRef = useRef(0);
44
- const deltaMemoRef = useRef(-1);
45
- const startTimeRef = useRef(-1);
46
- const loop = useCallback((context, width, height, count) => {
47
- const now = performance.now();
48
- const deltaTime = now - deltaMemoRef.current;
49
- const elapsedTime = now - startTimeRef.current;
50
- deltaMemoRef.current = now;
51
- if (animate) reqIdRef.current = requestAnimationFrame(() => loop(context, width, height, count + 1));
52
- draw(context, {
53
- width: width.current,
54
- height: height.current,
55
- count: count + 1,
56
- deltaTime,
57
- elapsedTime,
58
- fps: 1e3 / deltaTime
59
- });
60
- }, [draw, animate]);
7
+ /**
8
+ * A simple animatable canvas with requestAnimationFrame()
9
+ */
10
+ const AnimationCanvas = /* @__PURE__ */ forwardRef(function AnimationCanvas({ draw, init, animate = true, options, reduceFlickering = true, width = 100, height = 100, resizable = false, className, onContextMenu = (event) => event.preventDefault(), ...props }, forwardedRef) {
11
+ const [node, setNode] = useState(null);
12
+ const composedRef = useComposedRefs(forwardedRef, setNode);
13
+ const latest = useRef({
14
+ draw,
15
+ init,
16
+ animate,
17
+ width,
18
+ height,
19
+ reduceFlickering,
20
+ options
21
+ });
22
+ const instanceRef = useRef(null);
61
23
  useEffect(() => {
62
- if (!canvasRef.current) return;
63
- const canvas = canvasRef.current;
64
- const context = canvas.getContext("2d", options);
65
- if (!context) throw new Error("Cannot get canvas context.");
66
- const dpr = globalThis.devicePixelRatio;
67
- const firstRendering = (width, height) => {
68
- setDprConfig(canvas, context, width, height, dpr);
69
- widthRef.current = width;
70
- heightRef.current = height;
71
- if (init) init(context, {
24
+ latest.current = {
25
+ draw,
26
+ init,
27
+ animate,
28
+ width,
29
+ height,
30
+ reduceFlickering,
31
+ options
32
+ };
33
+ instanceRef.current?.update({
34
+ animate,
35
+ size: {
72
36
  width,
73
37
  height
74
- });
75
- const now = performance.now();
76
- deltaMemoRef.current = now;
77
- startTimeRef.current = now;
78
- loop(context, widthRef, heightRef, -1);
38
+ },
39
+ reduceFlickering
40
+ });
41
+ });
42
+ useEffect(() => {
43
+ if (!node) return;
44
+ const current = latest.current;
45
+ const instance = createAnimationCanvas(node, {
46
+ draw: (context, frame) => latest.current.draw(context, frame),
47
+ init: (context, size) => latest.current.init?.(context, size),
48
+ animate: current.animate,
49
+ size: {
50
+ width: current.width,
51
+ height: current.height
52
+ },
53
+ reduceFlickering: current.reduceFlickering,
54
+ resizable,
55
+ contextAttributes: current.options
56
+ });
57
+ instanceRef.current = instance;
58
+ return () => {
59
+ instanceRef.current = null;
60
+ instance.destroy();
79
61
  };
80
- if (relativeSize) {
81
- const parent = canvas.parentElement;
82
- if (!parent) throw new Error("Canvas doesn't have a parent element.");
83
- const memoCanvas = memoCanvasRef.current;
84
- const memoContext = memoCanvas?.getContext("2d", options);
85
- const ro = new ResizeObserver((entries) => {
86
- for (const entry of entries) {
87
- const w = entry.contentRect.width;
88
- const h = entry.contentRect.height;
89
- const contextMemo = {};
90
- if (reduceFlickering && memoCanvas && memoContext) {
91
- for (const prop of drawingState) contextMemo[prop] = context[prop];
92
- memoCanvas.width = w * dpr;
93
- memoCanvas.height = h * dpr;
94
- memoContext.scale(1 / dpr, 1 / dpr);
95
- if (canvas.width > 0 && canvas.height > 0) memoContext.drawImage(canvas, 0, 0);
96
- }
97
- setDprConfig(canvas, context, w, h, dpr);
98
- widthRef.current = w;
99
- heightRef.current = h;
100
- if (reduceFlickering && memoContext && memoCanvas && memoCanvas.width > 0 && memoCanvas.height > 0) {
101
- for (const prop of drawingState) context[prop] = contextMemo[prop];
102
- context.drawImage(memoContext.canvas, 0, 0);
103
- }
104
- if (!animate) loop(context, widthRef, heightRef, -1);
105
- }
106
- });
107
- ro.observe(parent);
108
- const width = parent.clientWidth;
109
- const height = parent.clientHeight;
110
- firstRendering(width, height);
111
- return () => {
112
- if (reqIdRef.current) cancelAnimationFrame(reqIdRef.current);
113
- ro.disconnect();
114
- };
115
- } else {
116
- const { width, height } = canvas.getBoundingClientRect();
117
- firstRendering(width, height);
118
- return () => {
119
- if (reqIdRef.current) cancelAnimationFrame(reqIdRef.current);
120
- };
121
- }
122
- }, [
123
- loop,
124
- init,
125
- options,
126
- reduceFlickering,
127
- relativeSize,
128
- animate
129
- ]);
130
- return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("canvas", {
131
- className: clsx("tremolo-animation-canvas", className),
132
- width: relativeSize ? 0 : _width,
133
- height: relativeSize ? 0 : _height,
134
- ref: canvasRef,
62
+ }, [node, resizable]);
63
+ return /* @__PURE__ */ jsx("canvas", {
64
+ className,
65
+ ref: composedRef,
135
66
  onContextMenu,
136
67
  ...props
137
- }), relativeSize && reduceFlickering && /* @__PURE__ */ jsx("canvas", {
138
- style: { display: "none" },
139
- ref: memoCanvasRef
140
- })] });
68
+ });
69
+ });
70
+ //#endregion
71
+ //#region src/hooks/_internal/useCheckSteps.ts
72
+ /** Positions probed across the travel. The ends are left out so that the
73
+ * clamp at `min` and `max` cannot be mistaken for a press that does nothing. */
74
+ const PROBES = [
75
+ .1,
76
+ .2,
77
+ .3,
78
+ .4,
79
+ .5,
80
+ .6,
81
+ .7,
82
+ .8,
83
+ .9
84
+ ];
85
+ const NONE = {
86
+ shiftKey: false,
87
+ altKey: false,
88
+ ctrlKey: false,
89
+ metaKey: false
90
+ };
91
+ const MODIFIER_STATE = {
92
+ shift: {
93
+ ...NONE,
94
+ shiftKey: true
95
+ },
96
+ alt: {
97
+ ...NONE,
98
+ altKey: true
99
+ },
100
+ ctrl: {
101
+ ...NONE,
102
+ ctrlKey: true
103
+ },
104
+ meta: {
105
+ ...NONE,
106
+ metaKey: true
107
+ }
108
+ };
109
+ /** Each entry of a modifier-aware input option, as a name and held keys. */
110
+ function entries(options) {
111
+ if (Array.isArray(options)) return [{
112
+ name: "",
113
+ modifiers: NONE
114
+ }];
115
+ return Object.keys(options).map((key) => ({
116
+ name: key === "default" ? "" : key,
117
+ modifiers: MODIFIER_STATE[key] ?? NONE
118
+ }));
119
+ }
120
+ /**
121
+ * Press every entry of `options` at nine points along the travel and report
122
+ * whether anything came of it.
123
+ *
124
+ * Run against `applyDelta` itself rather than against a reading of `step`:
125
+ * the whole point is that the amount, the step and the scale interact, and
126
+ * the pipeline is the only thing that knows how.
127
+ */
128
+ function probe(options, range, format) {
129
+ const { min, max, scale = linearScale } = range;
130
+ const outcome = {
131
+ moved: false,
132
+ visible: false
133
+ };
134
+ for (const { modifiers } of entries(options)) for (const position of PROBES) {
135
+ const value = scale.denormalize(position, min, max);
136
+ const up = applyDelta(value, 1, options, range, modifiers);
137
+ const down = applyDelta(value, -1, options, range, modifiers);
138
+ if (up !== value || down !== value) outcome.moved = true;
139
+ if (!format) continue;
140
+ const shown = format(value);
141
+ if (format(up) !== shown || format(down) !== shown) outcome.visible = true;
142
+ }
143
+ return outcome;
144
+ }
145
+ /**
146
+ * Warn, in development only, when a key press or a wheel notch cannot produce
147
+ * a change the user can see.
148
+ *
149
+ * Two settings that are each fine on their own can cancel out, and nothing
150
+ * fails when they do — the control simply sits there:
151
+ *
152
+ * - **`step` coarser than the amount.** `keyboard={['raw', 0.1]}` with
153
+ * `step={1}` rounds every press straight back to where it started
154
+ * - **the display coarser than the amount.** A `format` showing two decimals
155
+ * of a kHz value cannot show a press worth 1 Hz
156
+ *
157
+ * The second is only reported when the press is invisible at *every* point
158
+ * along the travel. A display that rounds is a deliberate choice and is
159
+ * normally right — it is being too coarse everywhere that makes it a mistake.
160
+ *
161
+ * @internal
162
+ */
163
+ function useCheckSteps({ component, axis, range, keyboard, wheel, format }) {
164
+ const { min, max, step, scale } = range ?? {};
165
+ const where = axis ? `${component} (${axis})` : component;
166
+ useEffect(() => {
167
+ try {
168
+ if (process.env.NODE_ENV === "production") return;
169
+ if (!range || min === void 0 || max === void 0 || !(min < max)) return;
170
+ for (const [name, options] of [["keyboard", keyboard], ["wheel", wheel]]) {
171
+ if (!options) continue;
172
+ const { moved, visible } = probe(options, range, format);
173
+ if (!moved) console.warn(`[tremolo-ui] ${where}: \`${name}\` cannot move the value.` + (step !== void 0 ? ` Each press is smaller than \`step\` (${step}), so it rounds` : " Each press rounds") + " straight back to where it started.");
174
+ else if (format && !visible) console.warn(`[tremolo-ui] ${where}: \`${name}\` moves the value, but \`format\` shows the same text before and after, everywhere in the range. The display is too coarse for it to be seen.`);
175
+ }
176
+ } catch {}
177
+ }, [
178
+ where,
179
+ min,
180
+ max,
181
+ step,
182
+ scale,
183
+ keyboard,
184
+ wheel
185
+ ]);
141
186
  }
142
187
  //#endregion
143
- //#region src/hooks/useCallbackRef.ts
188
+ //#region src/hooks/_internal/useCallbackRef.ts
144
189
  /**
145
190
  * Internal
146
191
  * @private
@@ -155,36 +200,77 @@ function useCallbackRef(callback, deps = []) {
155
200
  return useCallback((...args) => callbackRef.current?.(...args), deps);
156
201
  }
157
202
  //#endregion
158
- //#region src/hooks/useDrag.ts
203
+ //#region src/hooks/useDragValue.ts
159
204
  /**
160
- * Track a pointer drag on an element.
205
+ * Drive a value with a pointer drag.
161
206
  *
162
- * @returns a ref callback to attach to the element being dragged
207
+ * @returns a ref callback for the element that starts the drag, and whether a drag is in progress
163
208
  */
164
- function useDrag({ threshold = 1, cursor, onDrag, onDragStart, onDragEnd }) {
165
- const dragHandler = useCallbackRef(onDrag);
166
- const dragStartHandler = useCallbackRef(onDragStart);
167
- const dragEndHandler = useCallbackRef(onDragEnd);
209
+ function useDragValue(options) {
210
+ const { axis, baseElementRef, pixelRange, updateOnPointerDown, threshold, cursor, pointerLock } = options;
211
+ if (!baseElementRef && !options.getValue) throw new Error("useDragValue: give either baseElementRef or getValue, so that the drag has something to move");
212
+ const [dragging, setDragging] = useState(false);
213
+ const valueGetter = useCallbackRef(options.getValue);
214
+ const changeHandler = useCallbackRef(options.onChange);
215
+ const dragStartHandler = useCallbackRef(options.onDragStart);
216
+ const dragEndHandler = useCallbackRef(options.onDragEnd);
217
+ const latest = useRef(options);
218
+ const instanceRef = useRef(null);
168
219
  const [node, setNode] = useState(null);
220
+ const [pixelRangeX, pixelRangeY] = Array.isArray(pixelRange) ? pixelRange : [pixelRange, pixelRange];
169
221
  useEffect(() => {
170
222
  if (!node) return;
171
- const instance = createDrag(node, {
172
- threshold,
173
- cursor,
174
- onDragStart: () => dragStartHandler(),
175
- onDrag: ({ x, y, deltaX, deltaY }) => dragHandler(x, y, deltaX, deltaY),
176
- onDragEnd: () => dragEndHandler()
223
+ const instance = createDragValue(node, {
224
+ axis: latest.current.axis,
225
+ mapping: baseElementRef ? elementMapping(() => baseElementRef.current, { sensitivity: (state) => latest.current.sensitivity?.(state) ?? 1 }) : relativeMapping({
226
+ pixelRange: [pixelRangeX ?? 100, pixelRangeY ?? 100],
227
+ sensitivity: (state) => latest.current.sensitivity?.(state) ?? 1
228
+ }),
229
+ getValue: () => valueGetter() ?? [0, 0],
230
+ updateOnPointerDown: latest.current.updateOnPointerDown,
231
+ threshold: latest.current.threshold,
232
+ cursor: latest.current.cursor,
233
+ pointerLock: latest.current.pointerLock,
234
+ shouldStart: (event) => latest.current.shouldStart?.(event) ?? true,
235
+ onChange: (value, state) => changeHandler(value, state),
236
+ onDragStart: (value, state) => {
237
+ setDragging(true);
238
+ dragStartHandler(value, state);
239
+ },
240
+ onDragEnd: (value, state) => {
241
+ setDragging(false);
242
+ dragEndHandler(value, state);
243
+ }
177
244
  });
178
- return () => instance.destroy();
245
+ instanceRef.current = instance;
246
+ return () => {
247
+ instanceRef.current = null;
248
+ instance.destroy();
249
+ };
179
250
  }, [
180
251
  node,
181
- threshold,
182
- cursor,
183
- dragHandler,
252
+ baseElementRef,
253
+ pixelRangeX,
254
+ pixelRangeY,
255
+ valueGetter,
256
+ changeHandler,
184
257
  dragStartHandler,
185
258
  dragEndHandler
186
259
  ]);
187
- return setNode;
260
+ useEffect(() => {
261
+ latest.current = options;
262
+ instanceRef.current?.update({
263
+ axis,
264
+ updateOnPointerDown,
265
+ threshold,
266
+ cursor,
267
+ pointerLock
268
+ });
269
+ });
270
+ return {
271
+ refCallback: setNode,
272
+ dragging
273
+ };
188
274
  }
189
275
  //#endregion
190
276
  //#region src/hooks/useWheel.ts
@@ -193,88 +279,169 @@ function useDrag({ threshold = 1, cursor, onDrag, onDragStart, onDragEnd }) {
193
279
  *
194
280
  * The listener is not passive, so the handler may call `preventDefault()`.
195
281
  *
196
- * @returns a ref callback to attach to the element
282
+ * @returns a ref callback to attach to the element, unused when `target` is given
197
283
  */
198
- function useWheel(onWheel) {
284
+ function useWheel(onWheel, { requireFocus = false, target } = {}) {
199
285
  const wheelHandler = useCallbackRef(onWheel);
200
286
  const [node, setNode] = useState(null);
201
287
  useEffect(() => {
202
- if (!node) return;
203
- const instance = createWheel(node, (event) => wheelHandler(event));
288
+ const element = target ? target.current : node;
289
+ if (!element) return;
290
+ const instance = createWheel(element, (event) => wheelHandler(event), { requireFocus });
204
291
  return () => instance.destroy();
205
- }, [node, wheelHandler]);
292
+ }, [
293
+ node,
294
+ target,
295
+ wheelHandler,
296
+ requireFocus
297
+ ]);
206
298
  return setNode;
207
299
  }
208
300
  //#endregion
209
- //#region src/components/_util/index.ts
210
- function addUserSelectNone() {
211
- window.document.body.classList.add("tremolo-user-select-none");
301
+ //#region src/input-event.ts
302
+ /**
303
+ * The keyboard amount used by Knob, NumberInput, Slider, and XYPad by default:
304
+ * 1 per press in the units of the value, and 0.1 with shift held. That is one
305
+ * `step` only while `step` is 1; a coarser `step` rounds 1 straight back, which
306
+ * `useCheckSteps` warns about.
307
+ *
308
+ * A modifier entry is not snapped to `step`, which is what lets the finer
309
+ * amount move at all — see `applyDelta` in `@tremolo-ui/functions`.
310
+ */
311
+ const DEFAULT_KEYBOARD_OPTIONS = {
312
+ default: ["raw", 1],
313
+ shift: ["raw", .1]
314
+ };
315
+ /**
316
+ * The wheel amount used by Knob, NumberInput, Slider, and XYPad by default.
317
+ *
318
+ * Browsers turn shift+wheel into horizontal scrolling, which empties `deltaY`
319
+ * and fills `deltaX`, so no modifier is bound here.
320
+ */
321
+ const DEFAULT_WHEEL_OPTIONS = ["raw", 1];
322
+ /**
323
+ * The drag sensitivity used by value controls by default. Shift makes the
324
+ * same movement cover a tenth of the range, matching the arrow keys.
325
+ */
326
+ const DEFAULT_DRAG_SENSITIVITY = {
327
+ default: 1,
328
+ shift: .1
329
+ };
330
+ //#endregion
331
+ //#region src/components/_util/css-length.ts
332
+ /**
333
+ * A number as a CSS length, for a custom property.
334
+ *
335
+ * React appends `px` to a bare number for the properties it knows take a
336
+ * length, and a custom property is never one of them: `--size: 50` would come
337
+ * out as the invalid `50`. Anything already a string is passed through, so
338
+ * `'3rem'` and `'100%'` still work.
339
+ */
340
+ function cssLength(value) {
341
+ return typeof value === "number" ? `${value}px` : value;
342
+ }
343
+ //#endregion
344
+ //#region src/components/_util/Placement.tsx
345
+ /**
346
+ * The subcomponent that established the current placement, if any.
347
+ *
348
+ * @internal
349
+ */
350
+ const PlacementContext = /* @__PURE__ */ createContext(null);
351
+ /**
352
+ * Marks its children as being inside `name`, for {@link useCheckPlacement}.
353
+ *
354
+ * @internal
355
+ */
356
+ function Placement({ name, children }) {
357
+ return /* @__PURE__ */ jsx(PlacementContext.Provider, {
358
+ value: name,
359
+ children
360
+ });
361
+ }
362
+ /**
363
+ * Warn, in development only, when `child` is not rendered inside `parent`.
364
+ *
365
+ * A compound component renders its children exactly as they are composed, so a
366
+ * subcomponent put at the wrong level still renders — it only loses whatever
367
+ * its layout depended on, and does so silently. `Slider.Thumb` outside
368
+ * `Slider.Track` keeps its `position: absolute` but resolves it against the
369
+ * page rather than the track, and neither the build nor the tests notice.
370
+ *
371
+ * @internal
372
+ */
373
+ function useCheckPlacement(child, parent) {
374
+ const found = useContext(PlacementContext);
375
+ useEffect(() => {
376
+ try {
377
+ if (process.env.NODE_ENV !== "production" && found !== parent) console.warn(`[tremolo-ui] ${child} has to be rendered inside ${parent}` + (found === null ? "." : `, but it is inside ${found}.`) + " It renders either way, so nothing fails — its position just comes out wrong.");
378
+ } catch {}
379
+ }, [
380
+ found,
381
+ child,
382
+ parent
383
+ ]);
384
+ }
385
+ /**
386
+ * 角度から円弧上の点を求める。
387
+ *
388
+ * 半径は線の太さの分だけ内側に取る必要があるが、太さは ActiveLine / InactiveLine が
389
+ * それぞれ持つため、座標はストアで先に計算せず各コンポーネント側で求める。
390
+ */
391
+ function pointOnArc(angle, radius) {
392
+ return {
393
+ x: 50 + radius * Math.cos(radian(angle - 90)),
394
+ y: 50 + radius * Math.sin(radian(angle - 90))
395
+ };
396
+ }
397
+ /** 線の太さが viewBox からはみ出さないようにした半径 */
398
+ function arcRadius(strokeWidth) {
399
+ const width = typeof strokeWidth === "number" ? strokeWidth : Number.parseFloat(String(strokeWidth));
400
+ return Number.isFinite(width) ? 50 - width / 2 : 50;
212
401
  }
213
- function removeUserSelectNone() {
214
- window.document.body.classList.remove("tremolo-user-select-none");
402
+ /** Build an SVG path for an arc, splitting full turns into drawable segments. */
403
+ function arcPath(startAngle, endAngle, radius) {
404
+ const start = pointOnArc(startAngle, radius);
405
+ const sweep = endAngle - startAngle;
406
+ const segmentCount = Math.max(1, Math.ceil(Math.abs(sweep) / 180));
407
+ const segmentSweep = sweep / segmentCount;
408
+ let path = `M ${start.x} ${start.y}`;
409
+ for (let i = 1; i <= segmentCount; i += 1) {
410
+ const end = pointOnArc(startAngle + segmentSweep * i, radius);
411
+ path += ` A ${radius} ${radius} 0 0 ${segmentSweep >= 0 ? 1 : 0} ${end.x} ${end.y}`;
412
+ }
413
+ return path;
215
414
  }
216
- function calcDrawingState({ value, min, max, skew, startValue, angleRange }) {
217
- const p = normalizeValue(value, min, max, skew);
218
- const s = normalizeValue(startValue, min, max, skew);
415
+ /** 設定から描画に必要な角度を導出する。レンダー中に呼ぶ。 */
416
+ function calcAngles({ value, min, max, scale, startValue, angleRange }) {
417
+ const p = scale.normalize(value, min, max);
418
+ const s = scale.normalize(startValue, min, max);
219
419
  const r1 = -angleRange / 2;
220
- const r2 = r1 + Math.min(p, s) * angleRange;
221
- const r3 = r1 + Math.max(p, s) * angleRange;
222
- const r4 = angleRange / 2;
223
420
  return {
224
421
  p,
225
422
  r1,
226
- r2,
227
- r3,
228
- r4,
229
- x1: 50 + 50 * Math.cos(radian(r1 - 90)),
230
- y1: 50 + 50 * Math.sin(radian(r1 - 90)),
231
- x2: 50 + 50 * Math.cos(radian(r2 - 90)),
232
- y2: 50 + 50 * Math.sin(radian(r2 - 90)),
233
- x3: 50 + 50 * Math.cos(radian(r3 - 90)),
234
- y3: 50 + 50 * Math.sin(radian(r3 - 90)),
235
- x4: 50 + 50 * Math.cos(radian(r4 - 90)),
236
- y4: 50 + 50 * Math.sin(radian(r4 - 90))
423
+ r2: r1 + Math.min(p, s) * angleRange,
424
+ r3: r1 + Math.max(p, s) * angleRange,
425
+ r4: angleRange / 2
237
426
  };
238
427
  }
239
- const createKnobStore = (initProps) => {
240
- return createStore()(() => ({
241
- ...initProps,
242
- ...calcDrawingState(initProps)
243
- }));
244
- };
245
- const KnobContext = createContext(null);
246
- function KnobProvider({ children, ...props }) {
247
- const storeRef = useRef(null);
248
- if (!storeRef.current) storeRef.current = createKnobStore(props);
249
- useEffect(() => {
250
- if (storeRef.current) storeRef.current.setState({
251
- ...props,
252
- ...calcDrawingState(props)
253
- });
254
- else storeRef.current = createKnobStore(props);
255
- }, [props]);
256
- return /* @__PURE__ */ jsx(KnobContext.Provider, {
257
- value: storeRef.current,
258
- children
259
- });
260
- }
428
+ const KnobContext = /* @__PURE__ */ createContext(null);
429
+ const KnobProvider = KnobContext.Provider;
261
430
  function useKnobContext(selector) {
262
- const store = useContext(KnobContext);
263
- if (!store) throw new Error("Missing KnobContext.Provider in the tree");
264
- return useStore(store, selector);
431
+ const context = useContext(KnobContext);
432
+ if (!context) throw new Error("Missing KnobContext.Provider in the tree");
433
+ return selector ? selector(context) : context;
265
434
  }
266
435
  //#endregion
267
436
  //#region src/components/Knob/ActiveLine.tsx
268
437
  function ActiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...props }) {
438
+ useCheckPlacement("Knob.ActiveLine", "Knob.SVGRoot");
269
439
  const r2 = useKnobContext((s) => s.r2);
270
440
  const r3 = useKnobContext((s) => s.r3);
271
- const x2 = useKnobContext((s) => s.x2);
272
- const y2 = useKnobContext((s) => s.y2);
273
- const x3 = useKnobContext((s) => s.x3);
274
- const y3 = useKnobContext((s) => s.y3);
441
+ const radius = arcRadius(strokeWidth);
275
442
  return /* @__PURE__ */ jsx("path", {
276
- className: clsx("tremolo-knob-active-line", className),
277
- d: `M ${x2} ${y2} A 50 50 -135 ${r3 - r2 > 180 ? 1 : 0} 1 ${x3} ${y3}`,
443
+ className,
444
+ d: arcPath(r2, r3, radius),
278
445
  fill: "none",
279
446
  stroke,
280
447
  strokeWidth,
@@ -284,6 +451,7 @@ function ActiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...pr
284
451
  //#endregion
285
452
  //#region src/components/Knob/InactiveLine.tsx
286
453
  function InactiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...props }) {
454
+ useCheckPlacement("Knob.InactiveLine", "Knob.SVGRoot");
287
455
  const min = useKnobContext((s) => s.min);
288
456
  const max = useKnobContext((s) => s.max);
289
457
  const startValue = useKnobContext((s) => s.startValue);
@@ -291,24 +459,17 @@ function InactiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...
291
459
  const r2 = useKnobContext((s) => s.r2);
292
460
  const r3 = useKnobContext((s) => s.r3);
293
461
  const r4 = useKnobContext((s) => s.r4);
294
- const x1 = useKnobContext((s) => s.x1);
295
- const y1 = useKnobContext((s) => s.y1);
296
- const x2 = useKnobContext((s) => s.x2);
297
- const y2 = useKnobContext((s) => s.y2);
298
- const x3 = useKnobContext((s) => s.x3);
299
- const y3 = useKnobContext((s) => s.y3);
300
- const x4 = useKnobContext((s) => s.x4);
301
- const y4 = useKnobContext((s) => s.y4);
462
+ const radius = arcRadius(strokeWidth);
302
463
  return /* @__PURE__ */ jsxs(Fragment, { children: [startValue > min && /* @__PURE__ */ jsx("path", {
303
- className: clsx("tremolo-knob-inactive-line", className),
304
- d: `M ${x1} ${y1} A 50 50 -135 ${r2 - r1 > 180 ? 1 : 0} 1 ${x2} ${y2}`,
464
+ className,
465
+ d: arcPath(r1, r2, radius),
305
466
  fill: "none",
306
467
  stroke,
307
468
  strokeWidth,
308
469
  ...props
309
470
  }), startValue < max && /* @__PURE__ */ jsx("path", {
310
- className: clsx("tremolo-knob-inactive-line", className),
311
- d: `M ${x3} ${y3} A 50 50 -135 ${r4 - r3 > 180 ? 1 : 0} 1 ${x4} ${y4}`,
471
+ className,
472
+ d: arcPath(r3, r4, radius),
312
473
  fill: "none",
313
474
  stroke,
314
475
  strokeWidth,
@@ -316,13 +477,30 @@ function InactiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...
316
477
  })] });
317
478
  }
318
479
  //#endregion
480
+ //#region src/components/Knob/SVGRoot.tsx
481
+ function SVGRoot({ children, style, ...props }) {
482
+ return /* @__PURE__ */ jsx("svg", {
483
+ viewBox: `0 0 100 100`,
484
+ style: {
485
+ display: "block",
486
+ ...style
487
+ },
488
+ ...props,
489
+ children: /* @__PURE__ */ jsx(Placement, {
490
+ name: "Knob.SVGRoot",
491
+ children
492
+ })
493
+ });
494
+ }
495
+ //#endregion
319
496
  //#region src/components/Knob/Thumb.tsx
320
497
  function Thumb$2({ className, thumb = "currentColor", thumbLine = "currentColor", thumbSize = 84, thumbLineWeight = 6, thumbLineLength = 35, classes, ...props }) {
498
+ useCheckPlacement("Knob.Thumb", "Knob.SVGRoot");
321
499
  const angleRange = useKnobContext((s) => s.angleRange);
322
500
  const p = useKnobContext((s) => s.p);
323
501
  const r1 = useKnobContext((s) => s.r1);
324
502
  return /* @__PURE__ */ jsxs("svg", {
325
- className: clsx("tremolo-knob-thumb", classes?.thumb),
503
+ className,
326
504
  ...props,
327
505
  children: [/* @__PURE__ */ jsx("circle", {
328
506
  cx: "50%",
@@ -330,7 +508,7 @@ function Thumb$2({ className, thumb = "currentColor", thumbLine = "currentColor"
330
508
  r: `${thumbSize / 2}%`,
331
509
  fill: thumb
332
510
  }), /* @__PURE__ */ jsx("line", {
333
- className: clsx("tremolo-knob-thumb-line", classes?.thumbLine),
511
+ className: classes?.thumbLine,
334
512
  x1: "50%",
335
513
  y1: `${(100 - clamp(thumbSize, 0, 100)) / 2}%`,
336
514
  x2: "50%",
@@ -345,66 +523,43 @@ function Thumb$2({ className, thumb = "currentColor", thumbLine = "currentColor"
345
523
  });
346
524
  }
347
525
  //#endregion
348
- //#region src/components/Knob/SVGRoot.tsx
349
- function SVGRoot({ children, style, ...props }) {
350
- return /* @__PURE__ */ jsx("svg", {
351
- viewBox: `0 0 100 100`,
352
- style: {
353
- display: "block",
354
- overflow: "visible",
355
- ...style
356
- },
357
- ...props,
358
- children: children || /* @__PURE__ */ jsxs(Fragment, { children: [
359
- /* @__PURE__ */ jsx(InactiveLine, {}),
360
- /* @__PURE__ */ jsx(ActiveLine, {}),
361
- /* @__PURE__ */ jsx(Thumb$2, {})
362
- ] })
363
- });
364
- }
365
- //#endregion
366
526
  //#region src/components/Knob/index.tsx
367
- const defaultExternalStyles$3 = {
368
- userSelectNone: true,
369
- cursor: "grabbing"
370
- };
527
+ const defaultExternalStyles$2 = { cursor: "grabbing" };
528
+ /**
529
+ * The wheel only acts while the focus is inside, so that scrolling a page past
530
+ * the control does not change its value.
531
+ */
532
+ const WHEEL_OPTIONS$2 = { requireFocus: true };
371
533
  /**
372
534
  * Interactive rotary knob component implemented in SVG.
373
535
  */
374
536
  const Knob = {
375
- Root: forwardRef(({ value, min, max, step = 1, skew = 1, defaultValue = min, startValue = min, size, externalStyles: _externalStyles, wheel = ["raw", 1], keyboard = ["raw", 1], enableDoubleClickDefault = true, disabled = false, readonly = false, angleRange = 270, onChange, onKeyDown, onPointerDown, onDoubleClick, className, style, children, ...props }, forwardedRef) => {
376
- const [dragging, setDragging] = useState(false);
377
- const valueRef = useRef(0);
537
+ Root: /* @__PURE__ */ forwardRef(({ value, min, max, step = 1, scale = linearScale, defaultValue = min, startValue = min, size, externalStyles: _externalStyles, wheel = DEFAULT_WHEEL_OPTIONS, keyboard = DEFAULT_KEYBOARD_OPTIONS, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, pointerLock = false, enableDoubleClickDefault = true, disabled = false, readonly = false, angleRange = 270, onChange, onKeyDown, onPointerDown, onDoubleClick, className, style, children, ...props }, forwardedRef) => {
378
538
  const elmRef = useRef(null);
379
539
  const externalStyles = {
380
- ...defaultExternalStyles$3,
540
+ ...defaultExternalStyles$2,
381
541
  ..._externalStyles
382
542
  };
383
- const onDrag = useCallback((_x, y) => {
384
- if (!onChange || readonly) return;
385
- onChange(clamp(stepValue(rawValue(valueRef.current - y / 100, min, max, skew), step), min, max));
386
- }, [
387
- max,
543
+ const inactive = disabled || readonly;
544
+ const range = useMemo(() => ({
388
545
  min,
389
- onChange,
390
- readonly,
391
- skew,
392
- step
393
- ]);
394
- const updateValueByEvent = useCallback((eventType, x) => {
395
- let newValue;
396
- if (eventType == "normalized") newValue = rawValue(normalizeValue(value, min, max, skew) + x, min, max, skew);
397
- else newValue = value + x;
398
- return clamp(stepValue(newValue, step), min, max);
399
- }, [
400
546
  max,
547
+ step,
548
+ scale
549
+ }), [
401
550
  min,
402
- skew,
551
+ max,
403
552
  step,
404
- value
553
+ scale
405
554
  ]);
555
+ useCheckSteps({
556
+ component: "Knob",
557
+ range,
558
+ keyboard,
559
+ wheel
560
+ });
406
561
  const handleKeyDown = useCallback((event) => {
407
- if (!keyboard || !onChange || readonly) return;
562
+ if (!keyboard || !onChange || inactive) return;
408
563
  const key = event.key;
409
564
  if ([
410
565
  "ArrowRight",
@@ -413,76 +568,93 @@ const Knob = {
413
568
  "ArrowDown"
414
569
  ].includes(key)) {
415
570
  event.preventDefault();
416
- const x = key == "ArrowRight" || key == "ArrowUp" ? keyboard[1] : -keyboard[1];
417
- onChange(updateValueByEvent(keyboard[0], x));
571
+ onChange(applyDelta(value, key === "ArrowRight" || key === "ArrowUp" ? 1 : -1, keyboard, range, event));
418
572
  }
419
573
  }, [
420
574
  keyboard,
421
575
  onChange,
422
- readonly,
423
- updateValueByEvent
576
+ inactive,
577
+ value,
578
+ range
424
579
  ]);
425
- const dragRefCallback = useDrag({
426
- cursor: readonly ? void 0 : externalStyles.cursor,
427
- onDrag,
428
- onDragStart: () => {
429
- setDragging(true);
430
- valueRef.current = normalizeValue(value, min, max, skew);
431
- if (externalStyles.userSelectNone) addUserSelectNone();
432
- },
433
- onDragEnd: () => {
434
- setDragging(false);
435
- if (externalStyles.userSelectNone) removeUserSelectNone();
580
+ const { refCallback: dragRefCallback, dragging } = useDragValue({
581
+ axis: useMemo(() => [range, {
582
+ ...range,
583
+ reverse: true
584
+ }], [range]),
585
+ getValue: () => [value, value],
586
+ sensitivity: (state) => selectModifier(dragSensitivity, state.event).value,
587
+ threshold: 1,
588
+ cursor: inactive ? void 0 : externalStyles.cursor,
589
+ pointerLock: inactive ? false : pointerLock,
590
+ shouldStart: () => !inactive,
591
+ onChange: (v) => {
592
+ if (inactive) return;
593
+ onChange?.(v[1]);
436
594
  }
437
595
  });
438
596
  const wheelRefCallback = useWheel((event) => {
439
- if (!wheel || readonly) return;
597
+ if (!wheel || inactive) return;
440
598
  event.preventDefault();
441
- if (!onChange || event.deltaY == 0) return;
442
- const x = Math.sign(event.deltaY) * -wheel[1];
443
- onChange(updateValueByEvent(wheel[0], x));
444
- });
599
+ if (!onChange || event.deltaY === 0) return;
600
+ onChange(applyDelta(value, -Math.sign(event.deltaY), wheel, range, event));
601
+ }, WHEEL_OPTIONS$2);
602
+ const rootRefCallback = useComposedRefs(elmRef, dragRefCallback, wheelRefCallback);
603
+ const context = useMemo(() => {
604
+ const config = {
605
+ value,
606
+ min,
607
+ max,
608
+ step,
609
+ scale,
610
+ startValue,
611
+ angleRange
612
+ };
613
+ return {
614
+ ...config,
615
+ ...calcAngles(config)
616
+ };
617
+ }, [
618
+ value,
619
+ min,
620
+ max,
621
+ step,
622
+ scale,
623
+ startValue,
624
+ angleRange
625
+ ]);
445
626
  useImperativeHandle(forwardedRef, () => {
446
627
  return {
447
628
  focus() {
448
- elmRef.current?.focus();
629
+ if (!disabled) elmRef.current?.focus();
449
630
  },
450
631
  blur() {
451
632
  elmRef.current?.blur();
452
633
  }
453
634
  };
454
- }, []);
635
+ }, [disabled]);
455
636
  return /* @__PURE__ */ jsx(KnobProvider, {
456
- value,
457
- min,
458
- max,
459
- step,
460
- skew,
461
- startValue,
462
- angleRange,
637
+ value: context,
463
638
  children: /* @__PURE__ */ jsx("div", {
464
- ref: (elm) => {
465
- elmRef.current = elm;
466
- wheelRefCallback(elm);
467
- dragRefCallback(elm);
468
- },
469
- className: clsx("tremolo-knob", className),
470
- tabIndex: 0,
639
+ ref: rootRefCallback,
640
+ className,
641
+ tabIndex: disabled ? -1 : 0,
471
642
  role: "slider",
472
643
  "aria-valuenow": value,
473
644
  "aria-valuemin": min,
474
645
  "aria-valuemax": max,
475
646
  "aria-disabled": disabled,
476
647
  "aria-readonly": readonly,
477
- "data-dragging": dragging,
648
+ "data-disabled": disabled ? "" : void 0,
649
+ "data-readonly": readonly ? "" : void 0,
650
+ "data-dragging": dragging ? "" : void 0,
478
651
  style: {
479
- width: size,
480
- height: size,
652
+ "--knob-size": cssLength(size),
481
653
  ...style
482
654
  },
483
655
  onPointerDown,
484
656
  onDoubleClick: (event) => {
485
- if (enableDoubleClickDefault && onChange) onChange(defaultValue);
657
+ if (!inactive && enableDoubleClickDefault && onChange) onChange(defaultValue);
486
658
  onDoubleClick?.(event);
487
659
  },
488
660
  onKeyDown: (event) => {
@@ -490,7 +662,7 @@ const Knob = {
490
662
  onKeyDown?.(event);
491
663
  },
492
664
  ...props,
493
- children: children || /* @__PURE__ */ jsx(SVGRoot, {})
665
+ children
494
666
  })
495
667
  });
496
668
  }),
@@ -500,142 +672,43 @@ const Knob = {
500
672
  Thumb: Thumb$2
501
673
  };
502
674
  //#endregion
503
- //#region src/components/NumberInput/type.ts
504
- function selectUnit(units, value) {
505
- let i = 0;
506
- for (; i < units.length; i++) if (Math.abs(units[i][1]) > Math.abs(value)) break;
507
- return units[Math.max(0, i - 1)];
508
- }
509
- function parseValue(inputString, units, digit) {
510
- const str = inputString.trim();
511
- if (!units || typeof units == "string") {
512
- const m = str.match(/-?\d+(\.\d+)?/);
513
- let v = Number(m?.[0] ?? "0");
514
- v = isNaN(v) ? 0 : v;
515
- return {
516
- rawValue: v,
517
- formatValue: (digit != void 0 ? v.toFixed(digit) : v) + (units ?? ""),
518
- unit: units ?? ""
519
- };
520
- }
521
- const unitList = units.map(([u, _s]) => u);
522
- const scaleList = units.map(([_u, s]) => s);
523
- let rawValue = 0;
524
- let unit = unitList[0];
525
- let formatValue = (digit != void 0 ? rawValue.toFixed(digit) : rawValue) + unit;
526
- const m = str.match(/^(-?\d+(\.\d+)?)\s*(\w*)$/);
527
- if (m) {
528
- rawValue = Number(m[1]) || 0;
529
- const uIndex = unitList.indexOf(m[3]);
530
- rawValue *= uIndex != -1 ? scaleList[uIndex] : 1;
531
- const [u, scale] = selectUnit(units, rawValue);
532
- const v = rawValue / scale;
533
- formatValue = (digit != void 0 ? v.toFixed(digit) : v) + u;
534
- unit = u;
535
- }
536
- return {
537
- rawValue,
538
- formatValue,
539
- unit
540
- };
541
- }
542
- //#endregion
543
675
  //#region src/components/NumberInput/context.tsx
544
- function safeClamp(value, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER) {
545
- return clamp(value, min, max);
546
- }
547
- const createNumberInputStore = (initProps) => {
548
- const DEFAULT_PROPS = {
549
- value: "",
550
- valueAsNumber: 0,
551
- min: 0,
552
- max: 0,
553
- step: 1,
554
- readonly: false,
555
- keepWithinRange: true,
556
- onChange: (_v, _t) => {}
557
- };
558
- return createStore()((set) => ({
559
- ...DEFAULT_PROPS,
560
- ...initProps,
561
- value: parseValue(String(initProps?.value || ""), initProps?.units, initProps?.digit).formatValue,
562
- valueAsNumber: parseValue(String(initProps?.value || ""), initProps?.units, initProps?.digit).rawValue,
563
- increment: () => set((state) => {
564
- if (state.readonly) return {};
565
- let next = parseValue(state.value, state.units, state.digit).rawValue + (state.step ?? 1);
566
- if (state.keepWithinRange) next = safeClamp(next, state.min, state.max);
567
- const v = parseValue(String(next), state.units, state.digit).formatValue;
568
- state?.onChange?.(next, v);
569
- return {
570
- value: v,
571
- valueAsNumber: next
572
- };
573
- }),
574
- decrement: () => set((state) => {
575
- if (state.readonly) return {};
576
- let next = parseValue(state.value, state.units, state.digit).rawValue - (state.step ?? 1);
577
- if (state.keepWithinRange) next = safeClamp(next, state.min, state.max);
578
- const v = parseValue(String(next), state.units, state.digit).formatValue;
579
- state?.onChange?.(next, v);
580
- return {
581
- value: v,
582
- valueAsNumber: next
583
- };
584
- }),
585
- change: (value) => set((state) => {
586
- if (state.readonly) return {};
587
- const v = value ?? "";
588
- return {
589
- value: v,
590
- valueAsNumber: parseValue(v, state.units, state.digit).rawValue
591
- };
592
- })
593
- }));
594
- };
595
- const NumberInputContext = createContext(null);
596
- function NumberInputProvider({ children, ...props }) {
597
- const storeRef = useRef(null);
598
- if (!storeRef.current) storeRef.current = createNumberInputStore(props);
599
- useEffect(() => {
600
- if (storeRef.current) {
601
- const parsed = parseValue(String(props?.value || ""), props?.units, props?.digit);
602
- storeRef.current.setState({
603
- ...props,
604
- value: parsed.formatValue,
605
- valueAsNumber: parsed.rawValue
606
- });
607
- } else storeRef.current = createNumberInputStore(props);
608
- }, [props]);
609
- return /* @__PURE__ */ jsx(NumberInputContext.Provider, {
610
- value: storeRef.current,
611
- children
612
- });
613
- }
676
+ const NumberInputContext = /* @__PURE__ */ createContext(null);
677
+ const NumberInputProvider = NumberInputContext.Provider;
614
678
  function useNumberInputContext(selector) {
615
- const store = useContext(NumberInputContext);
616
- if (!store) throw new Error("Missing NumberInputContext.Provider in the tree");
617
- return useStore(store, selector);
679
+ const context = useContext(NumberInputContext);
680
+ if (!context) throw new Error("Missing NumberInputContext.Provider in the tree");
681
+ return selector ? selector(context) : context;
682
+ }
683
+ const StepperContext = /* @__PURE__ */ createContext(null);
684
+ const StepperProvider = StepperContext.Provider;
685
+ function useStepperContext() {
686
+ return useContext(StepperContext);
618
687
  }
619
688
  //#endregion
620
689
  //#region src/hooks/useEventListener.ts
621
690
  function useEventListener(target, event, handler, options) {
622
691
  const listener = useCallbackRef(handler);
692
+ const cleanupRef = useRef(() => {});
623
693
  useEffect(() => {
624
694
  const node = typeof target === "function" ? target() : target ?? document;
625
695
  if (!node) return;
626
696
  node.addEventListener(event, listener, options);
627
- return () => {
697
+ const cleanup = () => {
628
698
  node.removeEventListener(event, listener, options);
629
699
  };
700
+ cleanupRef.current = cleanup;
701
+ return () => {
702
+ cleanup();
703
+ if (cleanupRef.current === cleanup) cleanupRef.current = () => {};
704
+ };
630
705
  }, [
631
706
  event,
632
707
  target,
633
708
  options,
634
709
  listener
635
710
  ]);
636
- return () => {
637
- (typeof target === "function" ? target() : target ?? document)?.removeEventListener(event, listener, options);
638
- };
711
+ return useCallback(() => cleanupRef.current(), []);
639
712
  }
640
713
  //#endregion
641
714
  //#region src/hooks/useInterval.ts
@@ -659,783 +732,851 @@ function useInterval(callback, delay) {
659
732
  function useLongPress(callback, initialDelay = 500, interval = 40) {
660
733
  const [pressed, setPressed] = useState(false);
661
734
  const [delay, setDelay] = useState(initialDelay);
735
+ const activePointerId = useRef(null);
736
+ const isPressed = useRef(false);
662
737
  useInterval(() => {
663
738
  callback();
664
739
  setDelay(interval);
665
740
  }, pressed ? delay : null);
666
- useEventListener(globalThis.window, "pointerup", () => {
741
+ const stop = useCallback((pointerId) => {
742
+ if (!isPressed.current) return;
743
+ if (pointerId !== void 0 && activePointerId.current !== null && activePointerId.current !== pointerId) return;
744
+ isPressed.current = false;
745
+ activePointerId.current = null;
667
746
  setPressed(false);
668
747
  setDelay(initialDelay);
748
+ }, [initialDelay]);
749
+ useEventListener(globalThis.window, "pointerup", (event) => {
750
+ stop(event.pointerId);
751
+ });
752
+ useEventListener(globalThis.window, "pointercancel", (event) => {
753
+ stop(event.pointerId);
754
+ });
755
+ useEventListener(globalThis.window, "blur", () => {
756
+ stop();
669
757
  });
670
- return useCallback(() => {
758
+ return useCallback((event) => {
759
+ if (isPressed.current || event && event.button !== 0) return;
760
+ isPressed.current = true;
761
+ activePointerId.current = event?.pointerId ?? null;
671
762
  callback();
672
763
  setPressed(true);
673
764
  }, [callback]);
674
765
  }
675
766
  //#endregion
676
- //#region src/components/NumberInput/DecrementStepper.tsx
677
- function DecrementStepper({ size = 12, children, className, ...props }) {
678
- const min = useNumberInputContext((s) => s.min);
679
- const valueAsNumber = useNumberInputContext((s) => s.valueAsNumber);
680
- const readonly = useNumberInputContext((s) => s.readonly);
681
- const keepWithinRange = useNumberInputContext((s) => s.keepWithinRange);
682
- const press = useLongPress(useNumberInputContext((s) => s.decrement));
767
+ //#region src/components/NumberInput/StepperButton.tsx
768
+ /**
769
+ * Shared body of `IncrementStepper` and `DecrementStepper`: the two differ only
770
+ * in which way they move the value and which arrow they draw.
771
+ */
772
+ function StepperButton({ direction, variant, icon, className, style, children, onPointerDown, ...props }) {
773
+ const { step, disabled, readonly, atMin, atMax, nudge } = useNumberInputContext();
774
+ const stepper = useStepperContext();
775
+ const press = useLongPress(() => {
776
+ if (stepper?.draggingRef.current) return;
777
+ nudge(direction, ["raw", step]);
778
+ });
683
779
  return /* @__PURE__ */ jsx("div", {
684
- className: clsx("tremolo-number-input-decrement-stepper", className),
780
+ className,
685
781
  role: "button",
686
782
  tabIndex: -1,
687
- "aria-disabled": keepWithinRange && valueAsNumber <= (min ?? Number.MIN_SAFE_INTEGER),
688
- "aria-readonly": readonly,
689
- onPointerDown: press,
783
+ "aria-label": direction > 0 ? "Increment" : "Decrement",
784
+ "aria-disabled": disabled || (direction > 0 ? atMax : atMin),
785
+ "data-disabled": disabled || (direction > 0 ? atMax : atMin) ? "" : void 0,
786
+ "data-readonly": readonly ? "" : void 0,
787
+ style,
788
+ onPointerDown: (event) => {
789
+ if (!disabled && !readonly) press(event);
790
+ onPointerDown?.(event);
791
+ },
690
792
  ...props,
691
- children: children || /* @__PURE__ */ jsx("svg", {
692
- width: size,
693
- height: size,
694
- viewBox: "0 0 24 24",
695
- fill: "none",
696
- stroke: "currentColor",
697
- strokeWidth: "2",
698
- strokeLinecap: "round",
699
- strokeLinejoin: "round",
700
- children: /* @__PURE__ */ jsx("polyline", { points: "6 9 12 15 18 9" })
701
- })
793
+ children: children ?? icon
702
794
  });
703
795
  }
704
- //#endregion
796
+ function StepperArrow({ up }) {
797
+ return /* @__PURE__ */ jsx("svg", {
798
+ style: {
799
+ width: "var(--stepper-icon-size)",
800
+ height: "var(--stepper-icon-size)"
801
+ },
802
+ viewBox: "0 0 24 24",
803
+ fill: "none",
804
+ stroke: "currentColor",
805
+ strokeWidth: "2",
806
+ strokeLinecap: "round",
807
+ strokeLinejoin: "round",
808
+ children: /* @__PURE__ */ jsx("polyline", { points: up ? "18 15 12 9 6 15" : "6 9 12 15 18 9" })
809
+ });
810
+ }
811
+ //#endregion
812
+ //#region src/components/NumberInput/DecrementStepper.tsx
813
+ /** Lowers the value by one `step`, repeating while held. */
814
+ function DecrementStepper(props) {
815
+ return /* @__PURE__ */ jsx(StepperButton, {
816
+ direction: -1,
817
+ variant: "decrement",
818
+ icon: /* @__PURE__ */ jsx(StepperArrow, { up: false }),
819
+ ...props
820
+ });
821
+ }
822
+ //#endregion
705
823
  //#region src/components/NumberInput/IncrementStepper.tsx
706
- function IncrementStepper({ size = 12, children, className, ...props }) {
707
- const max = useNumberInputContext((s) => s.max);
708
- const valueAsNumber = useNumberInputContext((s) => s.valueAsNumber);
709
- const readonly = useNumberInputContext((s) => s.readonly);
710
- const keepWithinRange = useNumberInputContext((s) => s.keepWithinRange);
711
- const press = useLongPress(useNumberInputContext((s) => s.increment));
712
- return /* @__PURE__ */ jsx("div", {
713
- className: clsx("tremolo-number-input-increment-stepper", className),
714
- role: "button",
715
- tabIndex: -1,
716
- "aria-disabled": keepWithinRange && valueAsNumber >= (max ?? Number.MAX_SAFE_INTEGER),
717
- "aria-readonly": readonly,
718
- onPointerDown: press,
719
- ...props,
720
- children: children || /* @__PURE__ */ jsx("svg", {
721
- width: size,
722
- height: size,
723
- viewBox: "0 0 24 24",
724
- fill: "none",
725
- stroke: "currentColor",
726
- strokeWidth: "2",
727
- strokeLinecap: "round",
728
- strokeLinejoin: "round",
729
- children: /* @__PURE__ */ jsx("polyline", { points: "18 15 12 9 6 15" })
730
- })
824
+ /** Raises the value by one `step`, repeating while held. */
825
+ function IncrementStepper(props) {
826
+ return /* @__PURE__ */ jsx(StepperButton, {
827
+ direction: 1,
828
+ variant: "increment",
829
+ icon: /* @__PURE__ */ jsx(StepperArrow, { up: true }),
830
+ ...props
731
831
  });
732
832
  }
733
833
  //#endregion
734
- //#region src/components/NumberInput/InternalInput.tsx
735
- const InternalInput = forwardRef(({ disabled, readonly, selectWithFocus, blurOnEnter, clampValueOnBlur, wheel, keyboard, className, onChange, onFocus, onBlur, onKeyDown, children: _children, ...props }, ref) => {
736
- const elmRef = useRef(null);
737
- const [editing, setEditing] = useState(false);
738
- const value = useNumberInputContext((s) => s.value);
739
- const valueAsNumber = useNumberInputContext((s) => s.valueAsNumber);
740
- const min = useNumberInputContext((s) => s.min);
741
- const max = useNumberInputContext((s) => s.max);
742
- const step = useNumberInputContext((s) => s.step);
743
- const units = useNumberInputContext((s) => s.units);
744
- const digit = useNumberInputContext((s) => s.digit);
745
- const change = useNumberInputContext((s) => s.change);
746
- const error = useMemo(() => {
747
- if (editing) return false;
748
- const v = valueAsNumber;
749
- return (min ?? -Infinity) > v || v > (max ?? Infinity);
750
- }, [
751
- editing,
752
- max,
753
- min,
754
- valueAsNumber
755
- ]);
756
- const updateValueByEvent = useCallback((eventType, x) => {
757
- let newValue;
758
- if (eventType == "normalized") {
759
- if (!min || !max) throw new Error("[NumberInput] \"min\" and \"max\" are required when InputEventOption[0] is set to \"normalized\".");
760
- newValue = rawValue(normalizeValue(valueAsNumber, min, max) + x, min, max);
761
- return clamp(stepValue(newValue, step), min, max);
762
- } else {
763
- newValue = valueAsNumber + x;
764
- return stepValue(newValue, step);
765
- }
766
- }, [
767
- max,
768
- min,
769
- step,
770
- valueAsNumber
771
- ]);
772
- const handleKeyDown = useCallback((event) => {
773
- if (!keyboard || !onChange || readonly) return;
774
- const key = event.key;
775
- if (!["ArrowUp", "ArrowDown"].includes(key)) return;
776
- event.preventDefault();
777
- const x = key == "ArrowUp" ? keyboard[1] : -keyboard[1];
778
- const parsed = parseValue(String(updateValueByEvent(keyboard[0], x)));
779
- onChange?.(parsed.rawValue, parsed.formatValue);
834
+ //#region src/components/NumberInput/InputField.tsx
835
+ /** The leading number of the displayed text, whatever the format put around it. */
836
+ const NUMBER_PREFIX = /^\s*-?[\d.,]*/;
837
+ const numberEnd = (text) => text.match(NUMBER_PREFIX)?.[0].length ?? text.length;
838
+ /**
839
+ * The index the caret is measured against: the decimal point, or where one
840
+ * would go if the number has none.
841
+ *
842
+ * Measuring from an end instead would slide the caret across a digit whenever
843
+ * the number changed length — `9.9` to `10.0` gains a character in front, `10`
844
+ * to `9` loses one — which is exactly what stepping does.
845
+ */
846
+ const decimalAnchor = (text) => {
847
+ const dot = text.indexOf(".");
848
+ return dot === -1 ? numberEnd(text) : dot;
849
+ };
850
+ /**
851
+ * The text field of a `NumberInput`, and the only place the value can be typed.
852
+ *
853
+ * While the user types, their own text stands rather than `format(value)`, so
854
+ * that a half-finished entry is not rewritten under the caret.
855
+ *
856
+ * How it behaves on focus, Enter and the arrow keys is set on `Root`
857
+ * (`selectOnFocus`, `unformatOnFocus`, `keepCaretOnStep`, `blurOnEnter`).
858
+ */
859
+ const InputField = /* @__PURE__ */ forwardRef(function InputField({ className, style, onFocus, onBlur, onKeyDown, ...props }, forwardedRef) {
860
+ const { value, min, max, step, disabled, readonly, keyboard, selectOnFocus, unformatOnFocus, keepCaretOnStep, blurOnEnter, text, editing, outOfRange, setDraft, commitDraft, nudge, inputRef } = useNumberInputContext();
861
+ const [focused, setFocused] = useState(false);
862
+ const shown = unformatOnFocus && focused && !editing ? String(value) : text;
863
+ useEffect(() => {
864
+ if (!focused || selectOnFocus === "none") return;
865
+ const input = inputRef.current;
866
+ if (!input) return;
867
+ if (selectOnFocus === "all") input.setSelectionRange(0, input.value.length);
868
+ else input.setSelectionRange(0, input.value.match(NUMBER_PREFIX)?.[0].length ?? 0);
780
869
  }, [
781
- keyboard,
782
- readonly,
783
- onChange,
784
- updateValueByEvent
870
+ focused,
871
+ selectOnFocus,
872
+ inputRef
785
873
  ]);
786
- const wheelRefCallback = useWheel((event) => {
787
- if (!wheel || readonly) return;
788
- event.preventDefault();
789
- if (!onChange || event.deltaY == 0) return;
790
- const x = Math.sign(event.deltaY) * -wheel[1];
791
- const parsed = parseValue(String(updateValueByEvent(wheel[0], x)));
792
- onChange?.(parsed.rawValue, parsed.formatValue);
874
+ /**
875
+ * Where to put the caret once the stepped value has been rendered, and the
876
+ * text it was measured against.
877
+ */
878
+ const caretAfterStep = useRef(null);
879
+ useLayoutEffect(() => {
880
+ const pending = caretAfterStep.current;
881
+ if (!pending) return;
882
+ caretAfterStep.current = null;
883
+ const input = inputRef.current;
884
+ if (!input || input.value === pending.from) return;
885
+ const place = decimalAnchor(input.value) + pending.offset;
886
+ const caret = Math.max(0, Math.min(place, numberEnd(input.value)));
887
+ input.setSelectionRange(caret, caret);
793
888
  });
794
- useImperativeHandle(ref, () => {
795
- return {
796
- focus() {
797
- elmRef.current?.focus();
798
- },
799
- blur() {
800
- elmRef.current?.blur();
801
- }
802
- };
803
- }, []);
889
+ const composedRef = useComposedRefs(forwardedRef, inputRef);
804
890
  return /* @__PURE__ */ jsx("input", {
805
- ref: (input) => {
806
- elmRef.current = input;
807
- wheelRefCallback(input);
808
- },
809
- className: clsx("tremolo-number-input", className),
810
- value,
811
- min,
812
- max,
813
- step,
891
+ ref: composedRef,
892
+ className,
814
893
  type: "text",
894
+ inputMode: "decimal",
895
+ role: "spinbutton",
896
+ value: shown,
897
+ disabled,
898
+ readOnly: readonly,
815
899
  "aria-disabled": disabled,
816
900
  "aria-readonly": readonly,
817
- tabIndex: -1,
818
- "data-error": error,
901
+ "data-disabled": disabled ? "" : void 0,
902
+ "data-readonly": readonly ? "" : void 0,
903
+ "aria-valuenow": value,
904
+ "aria-valuemin": min,
905
+ "aria-valuemax": max,
906
+ "aria-valuetext": text,
907
+ step,
908
+ "data-out-of-range": outOfRange ? "" : void 0,
909
+ style,
819
910
  onChange: (event) => {
820
- if (readonly) return;
821
- const v = event.currentTarget.value;
822
- const cursorPos = event.target.selectionStart;
823
- change(v);
824
- const valueAsNumber = parseValue(v, units, digit).rawValue;
825
- onChange?.(valueAsNumber, v);
826
- if (cursorPos) {
827
- event.target.selectionStart = cursorPos;
828
- event.target.selectionEnd = cursorPos;
829
- }
911
+ caretAfterStep.current = null;
912
+ setDraft(event.currentTarget.value);
830
913
  },
831
914
  onFocus: (event) => {
832
- setEditing(true);
833
- const v = event.currentTarget.value;
834
- const parsed = parseValue(v, units, digit);
835
- if (selectWithFocus == "all") event.currentTarget.setSelectionRange(0, v.length);
836
- else if (selectWithFocus == "number") event.currentTarget.setSelectionRange(0, parsed.formatValue.length - parsed.unit.length);
837
- onFocus?.(parsed.rawValue, parsed.formatValue, event);
915
+ setFocused(true);
916
+ onFocus?.(event);
838
917
  },
839
918
  onBlur: (event) => {
840
- setEditing(false);
841
- let v = parseValue(event.currentTarget.value, units, digit).rawValue;
842
- if (clampValueOnBlur) v = safeClamp(v, min, max);
843
- const parsed = parseValue(String(v), units, digit);
844
- change(parsed.formatValue);
845
- if (clampValueOnBlur) onChange?.(parsed.rawValue, parsed.formatValue);
846
- onBlur?.(parsed.rawValue, parsed.formatValue, event);
919
+ setFocused(false);
920
+ commitDraft();
921
+ onBlur?.(event);
847
922
  },
848
923
  onKeyDown: (event) => {
849
- if (blurOnEnter && event.key == "Enter") event.currentTarget.blur();
850
- handleKeyDown(event);
924
+ const key = event.key;
925
+ if (key === "Enter") {
926
+ commitDraft();
927
+ if (blurOnEnter) event.currentTarget.blur();
928
+ } else if (keyboard && !disabled && !readonly && (key === "ArrowUp" || key === "ArrowDown") && !event.nativeEvent.isComposing) {
929
+ event.preventDefault();
930
+ const input = event.currentTarget;
931
+ if (keepCaretOnStep && input.selectionStart !== null) caretAfterStep.current = {
932
+ offset: input.selectionStart - decimalAnchor(input.value),
933
+ from: input.value
934
+ };
935
+ nudge(key === "ArrowUp" ? 1 : -1, keyboard, event);
936
+ }
851
937
  onKeyDown?.(event);
852
938
  },
853
939
  ...props
854
940
  });
855
941
  });
856
942
  //#endregion
857
- //#region src/components/NumberInput/Stepper.tsx
858
- function Stepper({ dynamic = true, children, className, ...props }) {
859
- return /* @__PURE__ */ jsx("div", {
860
- className: clsx("tremolo-number-input-stepper", className),
861
- "data-dynamic": dynamic,
862
- ...props,
863
- children
864
- });
865
- }
943
+ //#region src/hooks/useDrag.ts
866
944
  /**
867
- * Input with some useful functions for entering numerical values.
945
+ * Track a pointer drag on an element.
946
+ *
947
+ * @returns a ref callback to attach to the element being dragged
868
948
  */
869
- const NumberInput = {
870
- Root: forwardRef(({ value, min, max, step = 1, units, readonly = false, disabled = false, digit, variant = "outline", selectWithFocus = "none", blurOnEnter = true, keepWithinRange = true, clampValueOnBlur = true, wheel = ["raw", 1], keyboard = ["raw", 1], activeColor, wrapperClassName, className, onChange, onFocus, onBlur, children, ...props }, forwardedRef) => {
871
- const colors = { "--active-color": activeColor };
872
- return /* @__PURE__ */ jsx(NumberInputProvider, {
873
- value: String(value),
874
- min,
875
- max,
876
- step,
877
- units,
878
- digit,
879
- readonly,
880
- keepWithinRange,
881
- onChange,
882
- children: /* @__PURE__ */ jsxs("div", {
883
- className: clsx("tremolo-number-input-wrapper", wrapperClassName),
884
- tabIndex: 0,
885
- style: { ...colors },
886
- "data-stepper": !!children,
887
- "data-variant": variant,
888
- children: [/* @__PURE__ */ jsx(InternalInput, {
889
- ref: forwardedRef,
890
- readonly,
891
- disabled,
892
- selectWithFocus,
893
- blurOnEnter,
894
- clampValueOnBlur,
895
- wheel,
896
- keyboard,
897
- className,
898
- onChange,
899
- onFocus,
900
- onBlur,
901
- ...props
902
- }), children]
903
- })
949
+ function useDrag({ threshold = 1, cursor, pointerLock, onDrag, onDragStart, onDragEnd }) {
950
+ const dragHandler = useCallbackRef(onDrag);
951
+ const dragStartHandler = useCallbackRef(onDragStart);
952
+ const dragEndHandler = useCallbackRef(onDragEnd);
953
+ const [node, setNode] = useState(null);
954
+ useEffect(() => {
955
+ if (!node) return;
956
+ const instance = createDrag(node, {
957
+ threshold,
958
+ cursor,
959
+ pointerLock,
960
+ onDragStart: (state) => dragStartHandler(state),
961
+ onDrag: (state) => dragHandler(state.x, state.y, state.deltaX, state.deltaY, state),
962
+ onDragEnd: (state) => dragEndHandler(state)
904
963
  });
905
- }),
906
- Stepper,
907
- IncrementStepper,
908
- DecrementStepper
909
- };
910
- //#endregion
911
- //#region src/hooks/useRefCallbackEvent.ts
912
- function useRefCallbackEvent(event, handler, options, deps = []) {
913
- return useMemo(() => {
914
- let cleanup;
915
- return (node) => {
916
- if (!node) {
917
- cleanup?.();
918
- return;
919
- }
920
- node.addEventListener(event, handler, options);
921
- cleanup = () => {
922
- node.removeEventListener(event, handler, options);
923
- };
924
- };
925
- }, deps);
964
+ return () => instance.destroy();
965
+ }, [
966
+ node,
967
+ threshold,
968
+ cursor,
969
+ pointerLock,
970
+ dragHandler,
971
+ dragStartHandler,
972
+ dragEndHandler
973
+ ]);
974
+ return setNode;
926
975
  }
927
976
  //#endregion
928
- //#region src/hooks/usePianoDrag.ts
977
+ //#region src/components/NumberInput/Stepper.tsx
929
978
  /**
930
- * Internal
931
- * @returns [refCallback, pointerDownHandler]
932
- * @private
979
+ * The area the steppers sit in, and a drag handle in its own right: dragging it
980
+ * up and down moves the value one `step` every `drag` pixels.
981
+ *
982
+ * The drag lives here rather than on `InputField` because `createDrag` turns
983
+ * off text selection on whatever element it is attached to.
933
984
  */
934
- function usePianoDrag({ baseElementRef, onDrag, onDragStart, onDragEnd }) {
935
- const dragged = useRef(false);
936
- const normalizedX = useRef(0);
937
- const normalizedY = useRef(0);
938
- const handleDrag = useCallback((event) => {
939
- if (!baseElementRef.current || !dragged.current) return;
940
- const isTouch = event instanceof TouchEvent;
941
- if (isTouch && event.cancelable) event.preventDefault();
942
- const { left, top, right, bottom } = baseElementRef.current.getBoundingClientRect();
943
- const mouseX = isTouch ? event.touches[0].clientX : event.clientX;
944
- const mouseY = isTouch ? event.touches[0].clientY : event.clientY;
945
- const nx = normalizeValue(mouseX, left, right);
946
- const ny = normalizeValue(mouseY, top, bottom);
947
- normalizedX.current = nx;
948
- normalizedY.current = ny;
949
- onDrag(nx, ny);
950
- }, [onDrag, baseElementRef]);
951
- const refHandler = useRefCallbackEvent("touchmove", handleDrag, { passive: false }, [onDrag]);
952
- const pointerDownHandler = useCallback((event) => {
953
- dragged.current = true;
954
- handleDrag(event);
955
- onDragStart?.(normalizedX.current, normalizedY.current);
956
- }, [handleDrag, onDragStart]);
957
- useEventListener(globalThis.window, "pointermove", handleDrag);
958
- useEventListener(globalThis.window, "pointerup", () => {
959
- if (!dragged.current) return;
960
- dragged.current = false;
961
- onDragEnd?.(normalizedX.current, normalizedY.current);
962
- });
963
- return {
964
- refHandler,
965
- pointerDownHandler
966
- };
967
- }
968
- //#endregion
969
- //#region src/components/Piano/context.tsx
970
- const createPianoStore = (initProps) => {
971
- const DEFAULT_PROPS = {
972
- noteRange: {
973
- first: 0,
974
- last: 127
985
+ const Stepper = /* @__PURE__ */ forwardRef(function Stepper({ className, style, children, ...props }, forwardedRef) {
986
+ const { value, step, disabled, readonly, drag, dragSensitivity, pointerLock, rawRange, changeValue } = useNumberInputContext();
987
+ const inactive = disabled || readonly;
988
+ /**
989
+ * The sensitivity as an amount per `drag` pixels, so that the drag goes
990
+ * through the same `applyDelta` the wheel and the arrow keys do. Carrying
991
+ * the modifier map over rather than resolving it here is what keeps `step`
992
+ * out of the pipeline for a modifier entry — naming one is a request to move
993
+ * off the grid.
994
+ */
995
+ const dragOptions = useMemo(() => mapModifier(dragSensitivity, (factor) => ["raw", step * factor]), [dragSensitivity, step]);
996
+ /**
997
+ * Set once the drag has actually moved the value, so that the press-and-hold
998
+ * repeat of a stepper stands down and leaves the value to the drag. A ref,
999
+ * because it is read from inside the repeat rather than rendered.
1000
+ */
1001
+ const draggingRef = useRef(false);
1002
+ /**
1003
+ * Where the drag started, taken on the first move rather than on pointer
1004
+ * down: the steppers act on pointer down, so by then the value may already
1005
+ * have been nudged once, and the drag should carry on from there.
1006
+ */
1007
+ const originRef = useRef(null);
1008
+ /**
1009
+ * Which modifier the drag is currently counting at, and where the previous
1010
+ * event was — a key produces no pointer event of its own, so a change is
1011
+ * only seen on the next move and has to be dated back to the one before it.
1012
+ */
1013
+ const factorRef = useRef(1);
1014
+ const previousYRef = useRef(0);
1015
+ const dragRefCallback = useDrag({
1016
+ threshold: 1,
1017
+ cursor: inactive ? void 0 : "ns-resize",
1018
+ pointerLock: inactive ? false : pointerLock,
1019
+ onDragStart: (state) => {
1020
+ originRef.current = null;
1021
+ draggingRef.current = false;
1022
+ factorRef.current = selectModifier(dragSensitivity, state.event).value;
975
1023
  },
976
- glissando: true,
977
- midiMax: 127,
978
- fill: false,
979
- label: () => void 0,
980
- onPlayNote: () => {},
981
- onStopNote: () => {}
982
- };
983
- return createStore()(() => ({
984
- ...DEFAULT_PROPS,
985
- ...initProps,
986
- notePosition: initProps.notePosition
987
- }));
988
- };
989
- const PianoContext = createContext(null);
990
- function PianoProvider({ children, ...props }) {
991
- const storeRef = useRef(null);
992
- if (!storeRef.current) storeRef.current = createPianoStore(props);
993
- useEffect(() => {
994
- if (storeRef.current) storeRef.current.setState(props);
995
- else storeRef.current = createPianoStore(props);
996
- }, [props]);
997
- return /* @__PURE__ */ jsx(PianoContext.Provider, {
998
- value: storeRef.current,
999
- children
1024
+ onDrag: (_x, y, _dx, _dy, state) => {
1025
+ if (inactive || drag === null) return;
1026
+ if (!originRef.current) {
1027
+ originRef.current = {
1028
+ y,
1029
+ value
1030
+ };
1031
+ previousYRef.current = y;
1032
+ return;
1033
+ }
1034
+ const factor = selectModifier(dragSensitivity, state.event).value;
1035
+ if (factor !== factorRef.current) {
1036
+ originRef.current = {
1037
+ y: previousYRef.current,
1038
+ value
1039
+ };
1040
+ factorRef.current = factor;
1041
+ }
1042
+ previousYRef.current = y;
1043
+ const steps = Math.round(-(y - originRef.current.y) / drag);
1044
+ if (steps === 0) return;
1045
+ draggingRef.current = true;
1046
+ changeValue(applyDelta(originRef.current.value, steps, dragOptions, rawRange, state.event));
1047
+ },
1048
+ onDragEnd: () => {
1049
+ draggingRef.current = false;
1050
+ }
1000
1051
  });
1001
- }
1002
- function usePianoContext(selector) {
1003
- const store = useContext(PianoContext);
1004
- if (!store) throw new Error("Missing PianoContext.Provider in the tree");
1005
- return useStore(store, selector);
1006
- }
1007
- //#endregion
1008
- //#region src/components/Piano/KeyLabel.tsx
1009
- function KeyLabel({ label, className, wrapperClassName, wrapperStyle, __note, __label, ...props }) {
1010
- const index = getNoteRangeArray(usePianoContext((s) => s.noteRange)).indexOf(__note);
1011
- const content = label ? label(__note, index) : __label && __label(__note, index);
1012
- return (content != void 0 || content != null) && /* @__PURE__ */ jsx("div", {
1013
- className: clsx("tremolo-piano-key-label-wrapper", wrapperClassName),
1014
- style: wrapperStyle,
1052
+ const composedRef = useComposedRefs(forwardedRef, drag !== null && !inactive ? dragRefCallback : void 0);
1053
+ const context = useMemo(() => ({ draggingRef }), []);
1054
+ return /* @__PURE__ */ jsx(StepperProvider, {
1055
+ value: context,
1015
1056
  children: /* @__PURE__ */ jsx("div", {
1016
- className: clsx("tremolo-piano-key-label", className),
1057
+ ref: composedRef,
1058
+ className,
1059
+ style,
1017
1060
  ...props,
1018
- children: content
1019
- })
1020
- });
1021
- }
1022
- const KeyImpl = forwardRef(({ keyType, noteNumber, width, height, bg, color, activeBg, activeColor, className, style, children, __width, ...props }, ref) => {
1023
- const [played, setPlayed] = useState(false);
1024
- const midiMax = usePianoContext((s) => s.midiMax);
1025
- const fill = usePianoContext((s) => s.fill);
1026
- const onPlayNote = usePianoContext((s) => s.onPlayNote);
1027
- const onStopNote = usePianoContext((s) => s.onStopNote);
1028
- const label = usePianoContext((s) => s.label);
1029
- const position = usePianoContext((s) => s.notePosition)(noteNumber);
1030
- const disabled = noteNumber > midiMax;
1031
- const colors = {
1032
- "--bg": bg,
1033
- "--color": color,
1034
- "--active-bg": activeBg,
1035
- "--active-color": activeColor
1036
- };
1037
- useImperativeHandle(ref, () => {
1038
- return {
1039
- play(velocity) {
1040
- if (disabled) return;
1041
- setPlayed(true);
1042
- if (onPlayNote) onPlayNote(noteNumber, velocity);
1043
- },
1044
- stop() {
1045
- setPlayed(false);
1046
- if (onStopNote) onStopNote(noteNumber);
1047
- },
1048
- played() {
1049
- return played;
1050
- }
1051
- };
1052
- }, [
1053
- disabled,
1054
- noteNumber,
1055
- onPlayNote,
1056
- onStopNote,
1057
- played
1058
- ]);
1059
- let keyLabelProps = {};
1060
- if (children != void 0) React.Children.forEach(children, (child) => {
1061
- if (React.isValidElement(child)) if (child.type == KeyLabel) keyLabelProps = child.props;
1062
- else throw new Error("only <KeyLabel>");
1063
- else throw new Error("children is an invalid element.");
1064
- });
1065
- return /* @__PURE__ */ jsx("div", {
1066
- className: clsx(`tremolo-piano-${keyType}-key`, className),
1067
- "aria-disabled": disabled,
1068
- "data-active": played,
1069
- style: {
1070
- ...colors,
1071
- left: position,
1072
- width: fill ? __width : width,
1073
- height,
1074
- ...style
1075
- },
1076
- ...props,
1077
- children: /* @__PURE__ */ jsx(KeyLabel, {
1078
- label,
1079
- __note: noteNumber,
1080
- ...keyLabelProps
1061
+ children
1081
1062
  })
1082
1063
  });
1083
1064
  });
1084
- const WhiteKey = forwardRef(({ width = 40, height = "100%", onContextMenu = (e) => e.preventDefault(), ...props }, ref) => {
1085
- return /* @__PURE__ */ jsx(KeyImpl, {
1086
- ref,
1087
- keyType: "white",
1088
- width,
1089
- height,
1090
- onContextMenu,
1091
- ...props
1092
- });
1093
- });
1094
- const BlackKey = forwardRef(({ width = 26, height = "60%", onContextMenu = (e) => e.preventDefault(), ...props }, ref) => {
1095
- return /* @__PURE__ */ jsx(KeyImpl, {
1096
- ref,
1097
- keyType: "black",
1098
- width,
1099
- height,
1100
- onContextMenu,
1101
- ...props
1102
- });
1103
- });
1104
1065
  //#endregion
1105
- //#region src/components/Piano/keyboardShortcuts.ts
1106
- const SHORTCUTS = { HOME_ROW: { keys: [
1107
- "a",
1108
- "w",
1109
- "s",
1110
- "e",
1111
- "d",
1112
- "f",
1113
- "t",
1114
- "g",
1115
- "y",
1116
- "h",
1117
- "u",
1118
- "j",
1119
- "k",
1120
- "o",
1121
- "l",
1122
- "p",
1123
- ";"
1124
- ] } };
1125
- //#endregion
1126
- //#region src/components/Piano/index.tsx
1066
+ //#region src/components/NumberInput/index.tsx
1127
1067
  /**
1128
- * [noteRange.first, noteRange.first + 1 ..., noteRange.last]
1068
+ * A number at the start of the text, and nothing read after it. A half-typed
1069
+ * entry still yields the number in front of it, but text with no number is
1070
+ * `NaN` rather than 0, so that "unreadable" and "the user typed 0" stay apart.
1129
1071
  */
1130
- function getNoteRangeArray(noteRange) {
1131
- return Array.from({ length: noteRange.last - noteRange.first + 1 }, (_, i) => i + noteRange.first);
1132
- }
1133
- const blackPerWhiteWidth = 26 / 40;
1072
+ const LEADING_NUMBER = /^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)/;
1073
+ const defaultFormat = (value) => String(value);
1074
+ const defaultParse = (text) => {
1075
+ const match = text.match(LEADING_NUMBER);
1076
+ return match ? Number(match[1]) : NaN;
1077
+ };
1134
1078
  /**
1135
- * Customizable piano component.
1079
+ * Input with some useful functions for entering numerical values.
1136
1080
  */
1137
- const Piano = {
1138
- Root: forwardRef(({ noteRange, glissando = true, midiMax = 127, keyboardShortcuts, fill = false, height = fill ? "100%" : 160, whiteNoteWidth: _whiteNoteWidth = 40, style, className, onPlayNote, onStopNote, label, children, onPointerDown, ...props }, forwardedRef) => {
1139
- const [whiteNoteWidth, setWhiteNoteWidth] = useState(_whiteNoteWidth);
1140
- const keyRefs = useRef([]);
1141
- for (let i = 0; i < noteRange.last - noteRange.first + 1; i++) keyRefs.current[i] = createRef();
1142
- const pianoRef = useRef(null);
1143
- const hitKeyIndex = useRef(-1);
1144
- const noteRangeArray = getNoteRangeArray(noteRange);
1145
- const whiteNotes = noteRangeArray.filter((v) => isWhiteKey(v));
1146
- const blackNotes = noteRangeArray.filter((v) => isBlackKey(v));
1147
- const whiteNoteCount = whiteNotes.length;
1148
- const padding = 1;
1149
- const staticWidth = (whiteNoteWidth + padding) * whiteNoteCount;
1150
- const childrenWithProps = useMemo(() => {
1151
- return children && React.Children.map(children, (child, index) => {
1152
- if (React.isValidElement(child)) {
1153
- const __width = child.type == WhiteKey ? whiteNoteWidth : whiteNoteWidth * .65;
1154
- const props = {
1155
- ref: keyRefs.current[index],
1156
- __width
1157
- };
1158
- return React.cloneElement(child, props);
1159
- }
1160
- return child;
1161
- });
1162
- }, [children, whiteNoteWidth]);
1163
- const notePosition = useCallback((note) => {
1164
- const pitchPositions = {
1165
- C: 0,
1166
- "C#": 1,
1167
- D: 1,
1168
- "D#": 2,
1169
- E: 2,
1170
- F: 3,
1171
- "F#": 4,
1172
- G: 4,
1173
- "G#": 5,
1174
- A: 5,
1175
- "A#": 6,
1176
- B: 6
1177
- };
1178
- const blackNoteWidth = whiteNoteWidth * blackPerWhiteWidth;
1179
- const padding = 1;
1180
- const targetNoteKey = noteKey(note);
1181
- const firstNoteKey = noteKey(noteRange.first);
1182
- const octave = Math.floor((note - noteRange.first) / 12);
1183
- const octaveOffset = noteKeys.indexOf(firstNoteKey) > noteKeys.indexOf(targetNoteKey) ? 1 : 0;
1184
- const w = whiteNoteWidth + padding;
1185
- const pos = pitchPositions[targetNoteKey] - pitchPositions[firstNoteKey];
1186
- const blackKeyOffset = isBlackKey(note) ? blackNoteWidth / 2 : 0;
1187
- return pos * w + (octave + octaveOffset) * 7 * w - blackKeyOffset;
1188
- }, [noteRange.first, whiteNoteWidth]);
1189
- const getHitKeyIndex = useCallback((x, y) => {
1190
- if (!pianoRef.current) return -1;
1191
- const containerHeight = pianoRef.current.clientHeight;
1192
- const notes = [...blackNotes, ...whiteNotes];
1193
- for (let i = 0; i < notes.length; i++) {
1194
- const note = notes[i];
1195
- const pos = notePosition(note);
1196
- const w = isWhiteKey(note) ? whiteNoteWidth : whiteNoteWidth * blackPerWhiteWidth;
1197
- const h = isWhiteKey(note) ? containerHeight : containerHeight * .6;
1198
- if (pos <= x && x < pos + w && 0 <= y && y < h) return note;
1199
- }
1200
- return -1;
1081
+ const NumberInput = {
1082
+ Root: /* @__PURE__ */ forwardRef(({ value, min, max, step = 1, scale = linearScale, format: formatProp, parse: parseProp, clampValue = true, wheel = DEFAULT_WHEEL_OPTIONS, keyboard = DEFAULT_KEYBOARD_OPTIONS, drag = 1, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, pointerLock = false, selectOnFocus = "none", unformatOnFocus = false, keepCaretOnStep = false, blurOnEnter = true, disabled = false, readonly = false, className, style, onChange, children, ...props }, forwardedRef) => {
1083
+ const inputRef = useRef(null);
1084
+ /**
1085
+ * The text being typed. The only state here: it is not a copy of `value`,
1086
+ * but the half-finished entry that has no value to be derived from yet.
1087
+ */
1088
+ const [draft, setDraft] = useState(null);
1089
+ const inactive = disabled || readonly;
1090
+ const format = formatProp ?? defaultFormat;
1091
+ const parse = parseProp ?? defaultParse;
1092
+ const range = useMemo(() => ({
1093
+ min: (clampValue ? min : void 0) ?? Number.MIN_SAFE_INTEGER,
1094
+ max: (clampValue ? max : void 0) ?? Number.MAX_SAFE_INTEGER,
1095
+ step,
1096
+ scale
1097
+ }), [
1098
+ clampValue,
1099
+ min,
1100
+ max,
1101
+ step,
1102
+ scale
1103
+ ]);
1104
+ const rawRange = useMemo(() => ({
1105
+ min: (clampValue ? min : void 0) ?? -Number.MAX_VALUE,
1106
+ max: (clampValue ? max : void 0) ?? Number.MAX_VALUE,
1107
+ step,
1108
+ scale
1109
+ }), [
1110
+ clampValue,
1111
+ min,
1112
+ max,
1113
+ step,
1114
+ scale
1115
+ ]);
1116
+ useCheckSteps({
1117
+ component: "NumberInput",
1118
+ range: useMemo(() => min !== void 0 && max !== void 0 && min < max ? {
1119
+ min,
1120
+ max,
1121
+ step,
1122
+ scale
1123
+ } : null, [
1124
+ min,
1125
+ max,
1126
+ step,
1127
+ scale
1128
+ ]),
1129
+ keyboard,
1130
+ wheel,
1131
+ format
1132
+ });
1133
+ const text = draft ?? format(value);
1134
+ const editing = draft !== null;
1135
+ const outOfRange = !editing && (min !== void 0 && value < min || max !== void 0 && value > max);
1136
+ const handleDraft = useCallback((next) => {
1137
+ if (inactive) return;
1138
+ setDraft(next);
1139
+ const parsed = parse(next);
1140
+ if (Number.isFinite(parsed)) onChange?.(parsed);
1201
1141
  }, [
1202
- blackNotes,
1203
- notePosition,
1204
- whiteNoteWidth,
1205
- whiteNotes
1142
+ inactive,
1143
+ parse,
1144
+ onChange
1206
1145
  ]);
1207
- const onDrag = useCallback((perX, perY) => {
1208
- if (!pianoRef.current) return;
1209
- const note = getHitKeyIndex(perX * staticWidth, perY * pianoRef.current.clientHeight);
1210
- const index = noteRangeArray.indexOf(note);
1211
- if (index == -1) return;
1212
- if (hitKeyIndex.current != index) {
1213
- keyRefs.current[hitKeyIndex.current]?.current?.stop();
1214
- keyRefs.current[index]?.current?.play();
1215
- hitKeyIndex.current = index;
1146
+ const changeValue = useCallback((next) => {
1147
+ if (inactive) return;
1148
+ setDraft(null);
1149
+ if (next !== value) onChange?.(next);
1150
+ }, [
1151
+ inactive,
1152
+ value,
1153
+ onChange
1154
+ ]);
1155
+ const commitDraft = useCallback(() => {
1156
+ if (draft === null || inactive) return;
1157
+ const parsed = parse(draft);
1158
+ if (!Number.isFinite(parsed)) {
1159
+ setDraft(null);
1160
+ return;
1216
1161
  }
1162
+ let committed = parsed;
1163
+ if (clampValue && min !== void 0) committed = Math.max(committed, min);
1164
+ if (clampValue && max !== void 0) committed = Math.min(committed, max);
1165
+ changeValue(committed);
1217
1166
  }, [
1218
- getHitKeyIndex,
1219
- noteRangeArray,
1220
- staticWidth
1167
+ draft,
1168
+ inactive,
1169
+ parse,
1170
+ clampValue,
1171
+ min,
1172
+ max,
1173
+ changeValue
1221
1174
  ]);
1222
- const onDragEnd = useCallback(() => {
1223
- if (keyRefs.current[hitKeyIndex.current]?.current?.played()) keyRefs.current[hitKeyIndex.current]?.current?.stop();
1224
- hitKeyIndex.current = -1;
1225
- }, [keyRefs]);
1226
- useEffect(() => {
1227
- if (fill && pianoRef.current) {
1228
- const parent = pianoRef.current.parentElement;
1229
- if (!parent) throw new Error("doesn't have a parent element.");
1230
- const resizeObserver = new ResizeObserver(() => {
1231
- const w = pianoRef.current.clientWidth;
1232
- setWhiteNoteWidth(w / whiteNoteCount - padding);
1233
- });
1234
- resizeObserver.observe(parent);
1235
- return () => {
1236
- resizeObserver.unobserve(parent);
1237
- };
1238
- } else setWhiteNoteWidth(_whiteNoteWidth);
1175
+ const nudge = useCallback((direction, option, modifiers) => {
1176
+ const [mode] = selectInputEvent(option, modifiers).option;
1177
+ changeValue(applyDelta(value, direction, option, mode === "raw" ? rawRange : range, modifiers));
1239
1178
  }, [
1240
- fill,
1241
- _whiteNoteWidth,
1242
- whiteNoteCount
1179
+ changeValue,
1180
+ value,
1181
+ rawRange,
1182
+ range
1183
+ ]);
1184
+ const wheelRefCallback = useWheel((event) => {
1185
+ if (!wheel || inactive || event.deltaY === 0) return;
1186
+ event.preventDefault();
1187
+ nudge(-Math.sign(event.deltaY), wheel, event);
1188
+ }, { requireFocus: true });
1189
+ const context = useMemo(() => ({
1190
+ value,
1191
+ min,
1192
+ max,
1193
+ step,
1194
+ scale,
1195
+ disabled,
1196
+ readonly,
1197
+ clampValue,
1198
+ range,
1199
+ rawRange,
1200
+ keyboard,
1201
+ text,
1202
+ editing,
1203
+ outOfRange,
1204
+ dragSensitivity,
1205
+ pointerLock,
1206
+ selectOnFocus,
1207
+ unformatOnFocus,
1208
+ keepCaretOnStep,
1209
+ blurOnEnter,
1210
+ atMin: clampValue && min !== void 0 && value <= min,
1211
+ atMax: clampValue && max !== void 0 && value >= max,
1212
+ drag,
1213
+ format,
1214
+ parse,
1215
+ setDraft: handleDraft,
1216
+ commitDraft,
1217
+ changeValue,
1218
+ nudge,
1219
+ inputRef
1220
+ }), [
1221
+ value,
1222
+ min,
1223
+ max,
1224
+ step,
1225
+ scale,
1226
+ disabled,
1227
+ readonly,
1228
+ clampValue,
1229
+ range,
1230
+ rawRange,
1231
+ keyboard,
1232
+ text,
1233
+ editing,
1234
+ outOfRange,
1235
+ drag,
1236
+ dragSensitivity,
1237
+ pointerLock,
1238
+ selectOnFocus,
1239
+ unformatOnFocus,
1240
+ keepCaretOnStep,
1241
+ blurOnEnter,
1242
+ format,
1243
+ parse,
1244
+ handleDraft,
1245
+ commitDraft,
1246
+ changeValue,
1247
+ nudge
1243
1248
  ]);
1244
- const { refHandler: touchMoveRefCallback, pointerDownHandler } = usePianoDrag({
1245
- baseElementRef: pianoRef,
1246
- onDrag,
1247
- onDragEnd
1248
- });
1249
- useEventListener(globalThis.window, "keydown", (e) => {
1250
- if (e.repeat) return;
1251
- if (!keyboardShortcuts) return;
1252
- const index = keyboardShortcuts.keys.indexOf(e.key);
1253
- if (index != -1) {
1254
- if (!keyRefs.current[index]?.current?.played()) keyRefs.current[index]?.current?.play();
1255
- }
1256
- });
1257
- useEventListener(globalThis.window, "keyup", (e) => {
1258
- if (e.repeat) return;
1259
- if (!keyboardShortcuts) return;
1260
- const index = keyboardShortcuts.keys.indexOf(e.key);
1261
- if (index != -1) keyRefs.current[index]?.current?.stop();
1262
- });
1263
1249
  useImperativeHandle(forwardedRef, () => {
1264
1250
  return {
1265
- playNote(note, velocity) {
1266
- const index = noteRangeArray.indexOf(note);
1267
- if (index != -1) {
1268
- if (!keyRefs.current[index]?.current?.played()) keyRefs.current[index]?.current?.play(velocity);
1269
- } else onPlayNote?.(note, velocity);
1251
+ focus() {
1252
+ if (!disabled) inputRef.current?.focus();
1270
1253
  },
1271
- stopNote(note) {
1272
- const index = noteRangeArray.indexOf(note);
1273
- if (index != -1) keyRefs.current[index]?.current?.stop();
1274
- else onStopNote?.(note);
1254
+ blur() {
1255
+ inputRef.current?.blur();
1275
1256
  }
1276
1257
  };
1277
- }, [
1278
- noteRangeArray,
1279
- onPlayNote,
1280
- onStopNote
1281
- ]);
1282
- return /* @__PURE__ */ jsx(PianoProvider, {
1283
- notePosition,
1284
- noteRange,
1285
- glissando,
1286
- midiMax,
1287
- fill,
1288
- onPlayNote,
1289
- onStopNote,
1290
- label,
1258
+ }, [disabled]);
1259
+ return /* @__PURE__ */ jsx(NumberInputProvider, {
1260
+ value: context,
1291
1261
  children: /* @__PURE__ */ jsx("div", {
1292
- ref: (div) => {
1293
- pianoRef.current = div;
1294
- touchMoveRefCallback(div);
1295
- },
1296
- className: clsx("tremolo-piano", className),
1297
- style: {
1298
- width: fill ? "100%" : staticWidth,
1299
- height,
1300
- ...style
1301
- },
1302
- onPointerDown: (event) => {
1303
- pointerDownHandler(event);
1304
- onPointerDown?.(event);
1305
- },
1262
+ ref: wheelRefCallback,
1263
+ className,
1264
+ "data-disabled": disabled ? "" : void 0,
1265
+ "data-readonly": readonly ? "" : void 0,
1266
+ style,
1306
1267
  ...props,
1307
- children: childrenWithProps || noteRangeArray.map((note, index) => isWhiteKey(note) ? /* @__PURE__ */ jsx(WhiteKey, {
1308
- ref: keyRefs.current[index],
1309
- noteNumber: note,
1310
- __width: whiteNoteWidth
1311
- }, note) : /* @__PURE__ */ jsx(BlackKey, {
1312
- ref: keyRefs.current[index],
1313
- noteNumber: note,
1314
- __width: whiteNoteWidth * blackPerWhiteWidth
1315
- }, note))
1268
+ children
1316
1269
  })
1317
1270
  });
1318
1271
  }),
1319
- WhiteKey,
1320
- BlackKey,
1321
- KeyLabel
1272
+ InputField,
1273
+ Stepper,
1274
+ IncrementStepper,
1275
+ DecrementStepper
1322
1276
  };
1323
1277
  //#endregion
1324
- //#region src/components/PointsEditor/Background.tsx
1325
- function Background({ className, children, ...props }) {
1326
- return /* @__PURE__ */ jsx("div", {
1327
- className: clsx("tremolo-points-editor-background", className),
1328
- ...props,
1329
- children
1330
- });
1331
- }
1332
- //#endregion
1333
- //#region src/components/PointsEditor/context.tsx
1334
- const createPointsEditorStore = (initProps) => {
1335
- const DEFAULT_PROPS = {
1336
- containerElementRef: createRef(),
1337
- disabled: false,
1338
- readonly: false,
1339
- externalStyles: {
1340
- userSelectNone: true,
1341
- cursor: "grabbing"
1342
- }
1343
- };
1344
- return createStore()((set) => ({
1345
- ...DEFAULT_PROPS,
1346
- ...initProps,
1347
- setContainerElementRef: (containerElementRef) => {
1348
- set(() => ({ containerElementRef }));
1349
- }
1350
- }));
1278
+ //#region src/components/Piano/keyboard-shortcuts.ts
1279
+ /**
1280
+ * Ready-made keyboard layouts. Both assume `noteRange.first` is a C.
1281
+ */
1282
+ const SHORTCUTS = {
1283
+ /** Every semitone from C, over the two rows of a QWERTY keyboard. */
1284
+ HOME_ROW: { keys: [
1285
+ "a",
1286
+ "w",
1287
+ "s",
1288
+ "e",
1289
+ "d",
1290
+ "f",
1291
+ "t",
1292
+ "g",
1293
+ "y",
1294
+ "h",
1295
+ "u",
1296
+ "j",
1297
+ "k",
1298
+ "o",
1299
+ "l",
1300
+ "p",
1301
+ ";"
1302
+ ] },
1303
+ /** The white keys only, on the home row. Black keys have no shortcut. */
1304
+ HOME_ROW_NATURAL: { keys: [
1305
+ "a",
1306
+ "",
1307
+ "s",
1308
+ "",
1309
+ "d",
1310
+ "f",
1311
+ "",
1312
+ "g",
1313
+ "",
1314
+ "h",
1315
+ "",
1316
+ "j",
1317
+ "k",
1318
+ "",
1319
+ "l",
1320
+ "",
1321
+ ";"
1322
+ ] }
1351
1323
  };
1352
- const PointsEditorContext = createContext(null);
1353
- function PointsEditorProvider({ children, ...props }) {
1354
- const storeRef = useRef(null);
1355
- if (!storeRef.current) storeRef.current = createPointsEditorStore(props);
1356
- useEffect(() => {
1357
- if (storeRef.current) storeRef.current.setState(props);
1358
- else storeRef.current = createPointsEditorStore(props);
1359
- }, [props]);
1360
- return /* @__PURE__ */ jsx(PointsEditorContext.Provider, {
1361
- value: storeRef.current,
1362
- children
1363
- });
1364
- }
1365
- function usePointsEditorContext(selector) {
1366
- const store = useContext(PointsEditorContext);
1367
- if (!store) throw new Error("Missing PointsEditorContext.Provider in the tree");
1368
- return useStore(store, selector);
1369
- }
1370
1324
  //#endregion
1371
- //#region src/components/PointsEditor/Container.tsx
1372
- function Container({ className, children, ...props }) {
1373
- const containerElementRef = useRef(null);
1374
- usePointsEditorContext((s) => s.setContainerElementRef)(containerElementRef);
1375
- return /* @__PURE__ */ jsx("div", {
1376
- ref: containerElementRef,
1377
- className: clsx("tremolo-points-editor-container", className),
1378
- ...props,
1379
- children
1380
- });
1325
+ //#region src/components/Piano/index.tsx
1326
+ function isEditableTarget(target) {
1327
+ if (!(target instanceof HTMLElement)) return false;
1328
+ if (target.matches("input, textarea, select")) return true;
1329
+ for (let element = target; element;) {
1330
+ const contentEditable = element.getAttribute("contenteditable");
1331
+ if (contentEditable !== null) return contentEditable.toLowerCase() !== "false";
1332
+ element = element.parentElement;
1333
+ }
1334
+ return false;
1335
+ }
1336
+ function shortcutKey(event) {
1337
+ return event.code || event.key;
1381
1338
  }
1382
- //#endregion
1383
- //#region src/hooks/useDragWithElement.ts
1384
1339
  /**
1385
- * Track a pointer drag, reporting the position normalized against
1386
- * the bounding rect of `baseElementRef`.
1387
- *
1388
- * @returns a ref callback for the element that starts the drag, and whether a drag is in progress
1340
+ * Customizable piano component.
1389
1341
  */
1390
- function useDragWithElement({ baseElementRef, updateOnPointerDown = false, cursor, onDrag, onDragStart, onDragEnd }) {
1391
- const [dragging, setDragging] = useState(false);
1392
- const normalizedX = useRef(0);
1393
- const normalizedY = useRef(0);
1394
- const dragHandler = useCallbackRef(onDrag);
1395
- const dragStartHandler = useCallbackRef(onDragStart);
1396
- const dragEndHandler = useCallbackRef(onDragEnd);
1397
- const update = useCallback((clientX, clientY) => {
1398
- const base = baseElementRef.current;
1399
- if (!base) return false;
1400
- const { left, top, right, bottom } = base.getBoundingClientRect();
1401
- normalizedX.current = normalizeValue(clientX, left, right);
1402
- normalizedY.current = normalizeValue(clientY, top, bottom);
1403
- return true;
1404
- }, [baseElementRef]);
1342
+ const Piano = { Root: /* @__PURE__ */ forwardRef(function Root({ noteRange, glissando = true, midiMax = 127, keyboardShortcuts, keyboardShortcutsScope = "root", resizable = false, whiteKeyWidth = 40, keyGap = 1, blackKeyWidthRatio = .65, blackKeyHeightRatio = .6, height, style, className, label, keyProps, classes, onPlayNote, onStopNote, ...props }, forwardedRef) {
1405
1343
  const [node, setNode] = useState(null);
1344
+ const [activeNotes, setActiveNotes] = useState([]);
1345
+ /** Set while `resizable` is on, measured from the parent. */
1346
+ const [resizedKeyWidth, setResizedKeyWidth] = useState(whiteKeyWidth);
1347
+ const notes = useMemo(() => getNoteRangeArray(noteRange), [noteRange]);
1348
+ const whiteKeyCount = useMemo(() => notes.filter(isWhiteKey).length, [notes]);
1349
+ const layout = useMemo(() => ({
1350
+ noteRange,
1351
+ whiteKeyWidth: resizable ? resizedKeyWidth : whiteKeyWidth,
1352
+ keyGap,
1353
+ blackKeyWidthRatio,
1354
+ blackKeyHeightRatio
1355
+ }), [
1356
+ noteRange,
1357
+ resizable,
1358
+ resizedKeyWidth,
1359
+ whiteKeyWidth,
1360
+ keyGap,
1361
+ blackKeyWidthRatio,
1362
+ blackKeyHeightRatio
1363
+ ]);
1364
+ const latest = useRef({
1365
+ layout,
1366
+ glissando,
1367
+ midiMax,
1368
+ onPlayNote,
1369
+ onStopNote
1370
+ });
1371
+ const instanceRef = useRef(null);
1372
+ const shortcutNotes = useRef(/* @__PURE__ */ new Map());
1373
+ const releaseShortcutNotes = useCallback(() => {
1374
+ for (const { note, source } of shortcutNotes.current.values()) instanceRef.current?.noteOff(note, { source });
1375
+ shortcutNotes.current.clear();
1376
+ }, []);
1406
1377
  useEffect(() => {
1407
1378
  if (!node) return;
1408
- const instance = createDrag(node, {
1409
- cursor,
1410
- onDragStart: ({ clientX, clientY }) => {
1411
- setDragging(true);
1412
- if (update(clientX, clientY) && updateOnPointerDown) dragHandler(normalizedX.current, normalizedY.current);
1413
- dragStartHandler(normalizedX.current, normalizedY.current);
1414
- },
1415
- onDrag: ({ clientX, clientY }) => {
1416
- if (update(clientX, clientY)) dragHandler(normalizedX.current, normalizedY.current);
1417
- },
1418
- onDragEnd: () => {
1419
- setDragging(false);
1420
- dragEndHandler(normalizedX.current, normalizedY.current);
1421
- }
1379
+ const instance = createPianoInput(node, {
1380
+ layout: latest.current.layout,
1381
+ glissando: latest.current.glissando,
1382
+ midiMax: latest.current.midiMax,
1383
+ onPlayNote: (note, velocity) => latest.current.onPlayNote?.(note, velocity),
1384
+ onStopNote: (note) => latest.current.onStopNote?.(note),
1385
+ onActiveNotesChange: setActiveNotes
1422
1386
  });
1423
- return () => instance.destroy();
1387
+ instanceRef.current = instance;
1388
+ return () => {
1389
+ instanceRef.current = null;
1390
+ instance.destroy();
1391
+ };
1392
+ }, [node]);
1393
+ useEffect(() => {
1394
+ latest.current = {
1395
+ layout,
1396
+ glissando,
1397
+ midiMax,
1398
+ onPlayNote,
1399
+ onStopNote
1400
+ };
1401
+ instanceRef.current?.update({
1402
+ layout,
1403
+ glissando,
1404
+ midiMax
1405
+ });
1406
+ });
1407
+ useEffect(() => {
1408
+ if (!resizable || !node) return;
1409
+ const parent = node.parentElement;
1410
+ if (!parent) throw new Error("doesn't have a parent element.");
1411
+ const resizeObserver = new ResizeObserver(() => {
1412
+ setResizedKeyWidth(node.clientWidth / Math.max(whiteKeyCount, 1) - keyGap);
1413
+ });
1414
+ resizeObserver.observe(parent);
1415
+ return () => resizeObserver.disconnect();
1424
1416
  }, [
1417
+ resizable,
1425
1418
  node,
1426
- update,
1427
- updateOnPointerDown,
1428
- cursor,
1429
- dragHandler,
1430
- dragStartHandler,
1431
- dragEndHandler
1419
+ whiteKeyCount,
1420
+ keyGap
1432
1421
  ]);
1433
- return {
1434
- refCallback: setNode,
1435
- dragging
1436
- };
1422
+ const shortcutKeys = keyboardShortcuts?.keys;
1423
+ const hasShortcuts = shortcutKeys !== void 0;
1424
+ const shortcutMapping = shortcutKeys?.join("\0");
1425
+ useEffect(() => releaseShortcutNotes, [
1426
+ releaseShortcutNotes,
1427
+ shortcutMapping,
1428
+ keyboardShortcutsScope,
1429
+ noteRange.first,
1430
+ noteRange.last
1431
+ ]);
1432
+ /** The note a shortcut key plays, or null when it has none. */
1433
+ function shortcutNote(key) {
1434
+ if (!shortcutKeys || key === "") return null;
1435
+ const index = shortcutKeys.indexOf(key);
1436
+ const note = noteRange.first + index;
1437
+ return index === -1 || note > noteRange.last ? null : note;
1438
+ }
1439
+ const shortcutTarget = useMemo(() => () => {
1440
+ if (!hasShortcuts) return null;
1441
+ return keyboardShortcutsScope === "window" ? globalThis.window : node;
1442
+ }, [
1443
+ hasShortcuts,
1444
+ keyboardShortcutsScope,
1445
+ node
1446
+ ]);
1447
+ const focusOutTarget = useMemo(() => () => hasShortcuts && keyboardShortcutsScope === "root" ? node : null, [
1448
+ hasShortcuts,
1449
+ keyboardShortcutsScope,
1450
+ node
1451
+ ]);
1452
+ const windowBlurTarget = useMemo(() => () => hasShortcuts ? globalThis.window : null, [hasShortcuts]);
1453
+ useEventListener(shortcutTarget, "keydown", (e) => {
1454
+ const key = shortcutKey(e);
1455
+ if (e.repeat || shortcutNotes.current.has(key)) return;
1456
+ if (isEditableTarget(e.target)) return;
1457
+ const note = shortcutNote(e.key);
1458
+ if (note === null) return;
1459
+ const source = `keyboard:${key}`;
1460
+ shortcutNotes.current.set(key, {
1461
+ note,
1462
+ source
1463
+ });
1464
+ instanceRef.current?.noteOn(note, { source });
1465
+ });
1466
+ useEventListener(shortcutTarget, "keyup", (e) => {
1467
+ const key = shortcutKey(e);
1468
+ const shortcut = shortcutNotes.current.get(key);
1469
+ if (!shortcut) return;
1470
+ shortcutNotes.current.delete(key);
1471
+ instanceRef.current?.noteOff(shortcut.note, { source: shortcut.source });
1472
+ });
1473
+ useEventListener(focusOutTarget, "focusout", (e) => {
1474
+ if (e.relatedTarget instanceof Node && node?.contains(e.relatedTarget)) return;
1475
+ releaseShortcutNotes();
1476
+ });
1477
+ useEventListener(windowBlurTarget, "blur", releaseShortcutNotes);
1478
+ useImperativeHandle(forwardedRef, () => ({
1479
+ playNote: (note, velocity) => instanceRef.current?.noteOn(note, {
1480
+ source: "api",
1481
+ velocity
1482
+ }),
1483
+ stopNote: (note) => instanceRef.current?.noteOff(note, { source: "api" })
1484
+ }), []);
1485
+ return /* @__PURE__ */ jsx("div", {
1486
+ ref: setNode,
1487
+ className,
1488
+ "data-resizable": resizable ? "" : void 0,
1489
+ role: "group",
1490
+ tabIndex: 0,
1491
+ style: {
1492
+ width: resizable ? "100%" : pianoWidth(layout),
1493
+ "--height": cssLength(height),
1494
+ position: "relative",
1495
+ ...style
1496
+ },
1497
+ ...props,
1498
+ children: notes.map((note, index) => {
1499
+ const keyType = isWhiteKey(note) ? "white" : "black";
1500
+ const state = {
1501
+ index,
1502
+ keyType,
1503
+ active: activeNotes.includes(note),
1504
+ disabled: note > midiMax
1505
+ };
1506
+ const { className: keyClassName, style: keyStyle, ...rest } = keyProps?.(note, state) ?? {};
1507
+ const content = label?.(note, state);
1508
+ return /* @__PURE__ */ jsx("div", {
1509
+ className: keyClassName,
1510
+ "data-note": note,
1511
+ "data-note-key": noteKey(note),
1512
+ "data-active": state.active ? "" : void 0,
1513
+ "data-disabled": state.disabled ? "" : void 0,
1514
+ ...rest,
1515
+ style: {
1516
+ position: "absolute",
1517
+ zIndex: keyType === "white" ? 1 : 2,
1518
+ ...keyStyle,
1519
+ left: notePosition(note, layout),
1520
+ width: keyType === "white" ? layout.whiteKeyWidth : blackKeyWidth(layout),
1521
+ height: keyType === "white" ? "100%" : `${blackKeyHeightRatio * 100}%`
1522
+ },
1523
+ children: content !== "" && content !== null && content !== void 0 && /* @__PURE__ */ jsx("div", {
1524
+ className: classes?.keyLabelWrapper,
1525
+ children: /* @__PURE__ */ jsx("div", {
1526
+ className: classes?.keyLabel,
1527
+ children: content
1528
+ })
1529
+ })
1530
+ }, note);
1531
+ })
1532
+ });
1533
+ }) };
1534
+ //#endregion
1535
+ //#region src/components/PointsEditor/Background.tsx
1536
+ function Background({ className, children, style, ...props }) {
1537
+ return /* @__PURE__ */ jsx("div", {
1538
+ className,
1539
+ style: {
1540
+ position: "absolute",
1541
+ inset: 0,
1542
+ zIndex: 0,
1543
+ ...style
1544
+ },
1545
+ ...props,
1546
+ children
1547
+ });
1548
+ }
1549
+ //#endregion
1550
+ //#region src/components/PointsEditor/context.tsx
1551
+ const PointsEditorContext = /* @__PURE__ */ createContext(null);
1552
+ const PointsEditorProvider = PointsEditorContext.Provider;
1553
+ function usePointsEditorContext(selector) {
1554
+ const context = useContext(PointsEditorContext);
1555
+ if (!context) throw new Error("Missing PointsEditorContext.Provider in the tree");
1556
+ return selector ? selector(context) : context;
1437
1557
  }
1438
1558
  //#endregion
1559
+ //#region src/components/_util/VisuallyHiddenRangeInput.tsx
1560
+ const hiddenStyle = {
1561
+ position: "absolute",
1562
+ width: 1,
1563
+ height: 1,
1564
+ padding: 0,
1565
+ margin: -1,
1566
+ overflow: "hidden",
1567
+ clip: "rect(0, 0, 0, 0)",
1568
+ clipPath: "inset(50%)",
1569
+ whiteSpace: "nowrap",
1570
+ border: 0,
1571
+ pointerEvents: "none"
1572
+ };
1573
+ const VisuallyHiddenRangeInput = /* @__PURE__ */ forwardRef((props, ref) => /* @__PURE__ */ jsx("input", {
1574
+ ...props,
1575
+ ref,
1576
+ type: "range",
1577
+ style: hiddenStyle
1578
+ }));
1579
+ //#endregion
1439
1580
  //#region src/components/PointsEditor/Point.tsx
1440
1581
  function clampPoint(point, min, max) {
1441
1582
  const { x, y } = point;
@@ -1444,82 +1585,471 @@ function clampPoint(point, min, max) {
1444
1585
  y: clamp(y, min?.y ?? 0, max?.y ?? 1)
1445
1586
  };
1446
1587
  }
1447
- function Point({ value, min, max, size, width = 16, height = 16, color, disabled, readonly, onChange, onDragStart, onDragEnd, className, style, onPointerDown, ...props }) {
1448
- const containerElementRef = usePointsEditorContext((s) => s.containerElementRef);
1449
- const externalStyles = usePointsEditorContext((s) => s.externalStyles);
1450
- const __disabled = usePointsEditorContext((s) => s.disabled);
1451
- const __readonly = usePointsEditorContext((s) => s.readonly);
1452
- const { refCallback: dragRefCallback, dragging } = useDragWithElement({
1453
- baseElementRef: containerElementRef,
1454
- cursor: readonly ? void 0 : externalStyles.cursor,
1455
- onDrag: (x, y) => {
1456
- if (readonly) return;
1457
- onChange?.(clampPoint({
1458
- x,
1459
- y
1460
- }, min, max));
1588
+ /**
1589
+ * A point is placed by its position within the container, so its value is a
1590
+ * position: 0..1 on each axis, with y growing downwards.
1591
+ */
1592
+ const AXIS = {
1593
+ min: 0,
1594
+ max: 1
1595
+ };
1596
+ /**
1597
+ * One setting for both axes, or one per axis. The point holds a range input
1598
+ * for each, so anything that names or describes it comes in a pair.
1599
+ */
1600
+ function perAxis(value, axis) {
1601
+ return typeof value === "string" ? value : value?.[axis];
1602
+ }
1603
+ function Point({ value, children, id: idProp, min, max, size, width, height, color, disabled: _disabled, readonly: _readonly, wheel: _wheel, keyboard: _keyboard, "aria-label": ariaLabel, "aria-valuetext": ariaValuetext, onChange, onDragStart, onDragEnd, className, style, onPointerDown, onKeyDown, onFocus, ...props }) {
1604
+ const { containerRef, externalStyles, disabled: rootDisabled, readonly: rootReadonly, wheel: rootWheel, keyboard: rootKeyboard, dragSensitivity, selection, registerPoint, beginPointDrag, movePointDrag, nudgeSelection } = usePointsEditorContext();
1605
+ const generatedId = useId();
1606
+ const id = idProp ?? generatedId;
1607
+ const selected = selection.includes(id);
1608
+ const disabled = _disabled ?? rootDisabled;
1609
+ const readonly = _readonly ?? rootReadonly;
1610
+ const inactive = disabled || readonly;
1611
+ const wheel = _wheel === void 0 ? rootWheel : _wheel;
1612
+ const keyboard = _keyboard === void 0 ? rootKeyboard : _keyboard;
1613
+ useCheckPlacement("PointsEditor.Point", "PointsEditor.Container");
1614
+ const [element, setElement] = useState(null);
1615
+ const xInputRef = useRef(null);
1616
+ const yInputRef = useRef(null);
1617
+ const registration = useRef({
1618
+ value,
1619
+ min,
1620
+ max,
1621
+ readonly: inactive,
1622
+ onChange,
1623
+ element,
1624
+ wheel
1625
+ });
1626
+ useEffect(() => {
1627
+ registration.current = {
1628
+ value,
1629
+ min,
1630
+ max,
1631
+ readonly: inactive,
1632
+ onChange,
1633
+ element,
1634
+ wheel
1635
+ };
1636
+ });
1637
+ useEffect(() => registerPoint(id, registration), [id, registerPoint]);
1638
+ /** Where the pointer was when the drag started, to measure the move from. */
1639
+ const pointerOrigin = useRef(null);
1640
+ const { refCallback: dragRefCallback, dragging } = useDragValue({
1641
+ axis: AXIS,
1642
+ baseElementRef: containerRef,
1643
+ sensitivity: (state) => selectModifier(dragSensitivity, state.event).value,
1644
+ cursor: inactive ? void 0 : externalStyles.cursor,
1645
+ shouldStart: () => !disabled,
1646
+ onChange: ([x, y]) => {
1647
+ const origin = pointerOrigin.current;
1648
+ if (!origin) return;
1649
+ movePointDrag({
1650
+ x: x - origin.x,
1651
+ y: y - origin.y
1652
+ });
1461
1653
  },
1462
- onDragStart: (x, y) => {
1463
- if (readonly) return;
1464
- if (externalStyles.userSelectNone) addUserSelectNone();
1465
- onDragStart?.(clampPoint({
1654
+ onDragStart: ([x, y], state) => {
1655
+ beginPointDrag(id, state.event);
1656
+ pointerOrigin.current = {
1466
1657
  x,
1467
1658
  y
1468
- }, min, max));
1659
+ };
1660
+ xInputRef.current?.focus();
1661
+ if (inactive) return;
1662
+ onDragStart?.(clampPoint(value, min, max));
1663
+ },
1664
+ onDragEnd: () => {
1665
+ pointerOrigin.current = null;
1666
+ if (inactive) return;
1667
+ onDragEnd?.(clampPoint(value, min, max));
1668
+ }
1669
+ });
1670
+ const nudge = useCallback((axis, direction, option, modifiers) => {
1671
+ const next = applyDelta(value[axis], direction, option, AXIS, modifiers);
1672
+ nudgeSelection(id, {
1673
+ x: axis === "x" ? next - value.x : 0,
1674
+ y: axis === "y" ? next - value.y : 0
1675
+ });
1676
+ }, [
1677
+ value,
1678
+ id,
1679
+ nudgeSelection
1680
+ ]);
1681
+ const refCallback = useComposedRefs(dragRefCallback, setElement);
1682
+ const handleKeyDown = (event) => {
1683
+ const key = event.key;
1684
+ if (![
1685
+ "ArrowRight",
1686
+ "ArrowLeft",
1687
+ "ArrowUp",
1688
+ "ArrowDown"
1689
+ ].includes(key)) return;
1690
+ const axis = key === "ArrowRight" || key === "ArrowLeft" ? "x" : "y";
1691
+ event.preventDefault();
1692
+ if (!onChange || inactive || !keyboard) return;
1693
+ nudge(axis, key === "ArrowLeft" || key === "ArrowUp" ? -1 : 1, keyboard, event);
1694
+ };
1695
+ const current = clampPoint(value, min, max);
1696
+ return /* @__PURE__ */ jsxs("div", {
1697
+ ref: refCallback,
1698
+ className,
1699
+ "data-disabled": disabled ? "" : void 0,
1700
+ "data-readonly": readonly ? "" : void 0,
1701
+ tabIndex: -1,
1702
+ "data-dragging": dragging ? "" : void 0,
1703
+ "data-selected": selected ? "" : void 0,
1704
+ style: {
1705
+ "--color": color,
1706
+ "--width": cssLength(size ?? width),
1707
+ "--height": cssLength(size ?? height),
1708
+ position: "absolute",
1709
+ translate: "var(--translate, -50% -50%)",
1710
+ ...style,
1711
+ left: `${value.x * 100}%`,
1712
+ top: `${value.y * 100}%`
1713
+ },
1714
+ onPointerDown,
1715
+ onFocus: (event) => {
1716
+ if (!disabled && event.target === event.currentTarget) xInputRef.current?.focus();
1717
+ onFocus?.(event);
1469
1718
  },
1470
- onDragEnd: (x, y) => {
1471
- if (readonly) return;
1472
- if (externalStyles.userSelectNone) removeUserSelectNone();
1473
- onDragEnd?.(clampPoint({
1719
+ onKeyDown: (event) => {
1720
+ handleKeyDown(event);
1721
+ onKeyDown?.(event);
1722
+ },
1723
+ ...props,
1724
+ children: [["x", "y"].map((axis) => /* @__PURE__ */ jsx(VisuallyHiddenRangeInput, {
1725
+ ref: axis === "x" ? xInputRef : yInputRef,
1726
+ "data-axis": axis,
1727
+ value: current[axis],
1728
+ min: min?.[axis] ?? 0,
1729
+ max: max?.[axis] ?? 1,
1730
+ step: "any",
1731
+ disabled,
1732
+ "aria-readonly": readonly,
1733
+ "aria-orientation": axis === "x" ? "horizontal" : "vertical",
1734
+ "aria-label": perAxis(ariaLabel, axis) ?? axis,
1735
+ "aria-valuetext": perAxis(ariaValuetext, axis),
1736
+ onChange: (event) => {
1737
+ if (readonly) {
1738
+ event.currentTarget.value = String(current[axis]);
1739
+ return;
1740
+ }
1741
+ const delta = event.currentTarget.valueAsNumber - value[axis];
1742
+ nudgeSelection(id, {
1743
+ x: axis === "x" ? delta : 0,
1744
+ y: axis === "y" ? delta : 0
1745
+ });
1746
+ }
1747
+ }, axis)), children]
1748
+ });
1749
+ }
1750
+ //#endregion
1751
+ //#region src/components/PointsEditor/Container.tsx
1752
+ const Container = /* @__PURE__ */ forwardRef(function Container({ children, className, style, ...props }, forwardedRef) {
1753
+ const { containerRef, disabled, selectable, isPointElement, nudgeFocusedPoint, beginSelectionBox, moveSelectionBox, endSelectionBox } = usePointsEditorContext();
1754
+ const { refCallback: dragRefCallback } = useDragValue({
1755
+ axis: AXIS,
1756
+ baseElementRef: containerRef,
1757
+ shouldStart: (event) => !disabled && !isPointElement(event.target),
1758
+ onDragStart: ([x, y], state) => {
1759
+ beginSelectionBox({
1474
1760
  x,
1475
1761
  y
1476
- }, min, max));
1477
- }
1762
+ }, state.event);
1763
+ },
1764
+ onChange: ([x, y]) => moveSelectionBox({
1765
+ x,
1766
+ y
1767
+ }),
1768
+ onDragEnd: endSelectionBox
1478
1769
  });
1479
- const colors = { "--color": color };
1770
+ useWheel((event) => {
1771
+ const horizontal = event.deltaX !== 0;
1772
+ const delta = horizontal ? event.deltaX : event.deltaY;
1773
+ if (delta === 0) return;
1774
+ const axis = horizontal || event.shiftKey ? "x" : "y";
1775
+ if (nudgeFocusedPoint(axis, delta < 0 ? -1 : 1, event)) event.preventDefault();
1776
+ }, { target: containerRef });
1777
+ const composedRef = useComposedRefs(forwardedRef, containerRef, selectable && !disabled ? dragRefCallback : void 0);
1480
1778
  return /* @__PURE__ */ jsx("div", {
1481
- ref: dragRefCallback,
1482
- className: clsx("tremolo-points-editor-point", className),
1483
- tabIndex: 0,
1484
- "aria-disabled": disabled != void 0 ? disabled : __disabled,
1485
- "aria-readonly": readonly != void 0 ? readonly : __readonly,
1486
- "data-dragging": dragging,
1779
+ ref: composedRef,
1780
+ className,
1487
1781
  style: {
1488
- ...colors,
1489
- width: size ?? width,
1490
- height: size ?? height,
1491
- left: `${value.x * 100}%`,
1492
- top: `${value.y * 100}%`,
1782
+ position: "absolute",
1783
+ inset: 0,
1784
+ zIndex: 10,
1493
1785
  ...style
1494
1786
  },
1495
- onPointerDown,
1496
- ...props
1787
+ ...props,
1788
+ children: /* @__PURE__ */ jsx(Placement, {
1789
+ name: "PointsEditor.Container",
1790
+ children
1791
+ })
1792
+ });
1793
+ });
1794
+ //#endregion
1795
+ //#region src/components/PointsEditor/SelectionBox.tsx
1796
+ /**
1797
+ * The box a drag on empty space draws, and what it covers is selected.
1798
+ *
1799
+ * Nothing is rendered while no drag is running, and leaving it out leaves the
1800
+ * editor without one — the selection still works, it just cannot be seen.
1801
+ */
1802
+ function SelectionBox({ children, className, style, ...props }) {
1803
+ useCheckPlacement("PointsEditor.SelectionBox", "PointsEditor.Container");
1804
+ const selectionBox = usePointsEditorContext((s) => s.selectionBox);
1805
+ if (!selectionBox) return null;
1806
+ return /* @__PURE__ */ jsx("div", {
1807
+ className,
1808
+ ...props,
1809
+ style: {
1810
+ position: "absolute",
1811
+ zIndex: 20,
1812
+ pointerEvents: "none",
1813
+ ...style,
1814
+ left: `${selectionBox.x * 100}%`,
1815
+ top: `${selectionBox.y * 100}%`,
1816
+ width: `${selectionBox.width * 100}%`,
1817
+ height: `${selectionBox.height * 100}%`
1818
+ },
1819
+ children
1497
1820
  });
1498
1821
  }
1499
1822
  //#endregion
1500
1823
  //#region src/components/PointsEditor/index.tsx
1501
- const defaultExternalStyles$2 = {
1502
- userSelectNone: true,
1503
- cursor: "grabbing"
1824
+ /** One array for every editor with selection turned off, so memos hold still. */
1825
+ const EMPTY = [];
1826
+ /**
1827
+ * How far a point may move before something in the selection leaves its range.
1828
+ *
1829
+ * Clamping each point on its own would break the shape of the selection: the
1830
+ * one that reached the edge would stop while the rest carried on. One amount
1831
+ * for all of them means the whole selection stops together.
1832
+ */
1833
+ function allowedDelta(delta, entries) {
1834
+ let loX = -Infinity;
1835
+ let hiX = Infinity;
1836
+ let loY = -Infinity;
1837
+ let hiY = Infinity;
1838
+ for (const { start, registration } of entries) {
1839
+ loX = Math.max(loX, (registration.min?.x ?? 0) - start.x);
1840
+ hiX = Math.min(hiX, (registration.max?.x ?? 1) - start.x);
1841
+ loY = Math.max(loY, (registration.min?.y ?? 0) - start.y);
1842
+ hiY = Math.min(hiY, (registration.max?.y ?? 1) - start.y);
1843
+ }
1844
+ return {
1845
+ x: hiX < loX ? 0 : clamp(delta.x, loX, hiX),
1846
+ y: hiY < loY ? 0 : clamp(delta.y, loY, hiY)
1847
+ };
1848
+ }
1849
+ /**
1850
+ * A point moves over 0..1 in both axes, so a nudge of 0.01 crosses the editor
1851
+ * in a hundred steps whatever its pixel size.
1852
+ */
1853
+ const DEFAULT_WHEEL = ["normalized", .01];
1854
+ /**
1855
+ * Shift is the fine-adjustment key everywhere else, so it is bound here too
1856
+ * — but only on the keyboard. On the wheel it already means the x axis, and
1857
+ * browsers hand shift+wheel over as horizontal scrolling anyway.
1858
+ */
1859
+ const DEFAULT_KEYBOARD = {
1860
+ default: ["normalized", .01],
1861
+ shift: ["normalized", .001]
1504
1862
  };
1505
1863
  /**
1506
1864
  * Multiple Point Controller
1507
1865
  */
1508
1866
  const PointsEditor = {
1509
- Root: forwardRef(({ width = 200, height = 100, disabled = false, readonly = false, externalStyles: _externalStyles, style, className, children, ...props }, forwardedRef) => {
1867
+ Root: /* @__PURE__ */ forwardRef(({ width, height, disabled = false, readonly = false, wheel = DEFAULT_WHEEL, keyboard = DEFAULT_KEYBOARD, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, selectable = false, selection: selectionProp, defaultSelection, onSelectionChange, externalStyles, style, className, children, ...props }, forwardedRef) => {
1868
+ const containerRef = useRef(null);
1869
+ const { cursor = "grabbing" } = externalStyles ?? {};
1870
+ const controlled = selectionProp !== void 0;
1871
+ const [ownSelection, setOwnSelection] = useState(defaultSelection ?? []);
1872
+ const selection = selectable ? selectionProp ?? ownSelection : EMPTY;
1873
+ const selectionRef = useRef(selection);
1874
+ const changeHandlerRef = useRef(onSelectionChange);
1875
+ useEffect(() => {
1876
+ selectionRef.current = selection;
1877
+ changeHandlerRef.current = onSelectionChange;
1878
+ });
1879
+ const changeSelection = useCallback((next) => {
1880
+ selectionRef.current = next;
1881
+ if (!controlled) setOwnSelection(next);
1882
+ changeHandlerRef.current?.(next);
1883
+ }, [controlled]);
1884
+ /**
1885
+ * Every mounted point, by id. A registration is a ref rather than a value:
1886
+ * the value inside changes on every frame of a drag, and a registry keyed
1887
+ * on it would be rebuilt just as often.
1888
+ */
1889
+ const points = useRef(/* @__PURE__ */ new Map());
1890
+ const registerPoint = useCallback((id, entry) => {
1891
+ points.current.set(id, entry);
1892
+ return () => {
1893
+ points.current.delete(id);
1894
+ };
1895
+ }, []);
1896
+ /** What the current drag picked up, and where those points started. */
1897
+ const dragRef = useRef([]);
1898
+ const snapshot = useCallback((ids) => {
1899
+ return ids.flatMap((id) => {
1900
+ const registration = points.current.get(id)?.current;
1901
+ return registration ? [{
1902
+ id,
1903
+ start: { ...registration.value }
1904
+ }] : [];
1905
+ });
1906
+ }, []);
1907
+ const applyDeltaTo = useCallback((entries, delta) => {
1908
+ const withRegistration = entries.flatMap((entry) => {
1909
+ const registration = points.current.get(entry.id)?.current;
1910
+ return registration ? [{
1911
+ ...entry,
1912
+ registration
1913
+ }] : [];
1914
+ });
1915
+ const allowed = allowedDelta(delta, withRegistration);
1916
+ for (const { start, registration } of withRegistration) {
1917
+ if (registration.readonly) continue;
1918
+ registration.onChange?.({
1919
+ x: toPrecision(start.x + allowed.x),
1920
+ y: toPrecision(start.y + allowed.y)
1921
+ });
1922
+ }
1923
+ }, []);
1924
+ const beginPointDrag = useCallback((id, modifiers) => {
1925
+ if (!selectable) {
1926
+ dragRef.current = snapshot([id]);
1927
+ return;
1928
+ }
1929
+ const current = selectionRef.current;
1930
+ const additive = modifiers.ctrlKey || modifiers.metaKey;
1931
+ let next;
1932
+ if (additive) next = current.includes(id) ? current.filter((x) => x !== id) : [...current, id];
1933
+ else if (current.includes(id)) next = current;
1934
+ else next = [id];
1935
+ changeSelection([...next]);
1936
+ dragRef.current = next.includes(id) ? snapshot(next) : [];
1937
+ }, [
1938
+ selectable,
1939
+ changeSelection,
1940
+ snapshot
1941
+ ]);
1942
+ const movePointDrag = useCallback((delta) => applyDeltaTo(dragRef.current, delta), [applyDeltaTo]);
1943
+ const nudgeSelection = useCallback((id, delta) => {
1944
+ const ids = selectionRef.current.includes(id) ? selectionRef.current : [id];
1945
+ applyDeltaTo(snapshot(ids), delta);
1946
+ }, [applyDeltaTo, snapshot]);
1947
+ const isPointElement = useCallback((element) => {
1948
+ if (!element) return false;
1949
+ for (const [, entry] of points.current) if (entry.current.element?.contains(element)) return true;
1950
+ return false;
1951
+ }, []);
1952
+ const nudgeFocusedPoint = useCallback((axis, direction, modifiers) => {
1953
+ const container = containerRef.current;
1954
+ const active = container?.ownerDocument.activeElement;
1955
+ if (!container || !active || !container.contains(active)) return false;
1956
+ for (const [id, entry] of points.current) {
1957
+ const { element, wheel, readonly, onChange, value } = entry.current;
1958
+ if (!element?.contains(active)) continue;
1959
+ if (!wheel || readonly || !onChange) return false;
1960
+ const next = applyDelta(value[axis], direction, wheel, AXIS, modifiers);
1961
+ nudgeSelection(id, {
1962
+ x: axis === "x" ? next - value.x : 0,
1963
+ y: axis === "y" ? next - value.y : 0
1964
+ });
1965
+ return true;
1966
+ }
1967
+ return false;
1968
+ }, [nudgeSelection]);
1969
+ const [selectionBox, setSelectionBox] = useState(null);
1970
+ const selectionBoxRef = useRef(null);
1971
+ selectionBoxRef.current ??= createSelectionBox({
1972
+ *items() {
1973
+ for (const [id, entry] of points.current) {
1974
+ const { x, y } = entry.current.value;
1975
+ yield [id, [x, y]];
1976
+ }
1977
+ },
1978
+ onBoxChange: setSelectionBox
1979
+ });
1980
+ useEffect(() => {
1981
+ selectionBoxRef.current?.update({ onSelectionChange: changeSelection });
1982
+ }, [changeSelection]);
1983
+ useEffect(() => {
1984
+ const box = selectionBoxRef.current;
1985
+ return () => box?.destroy();
1986
+ }, []);
1987
+ const beginSelectionBox = useCallback((at, modifiers) => {
1988
+ if (!selectable) return;
1989
+ selectionBoxRef.current?.begin([at.x, at.y], {
1990
+ additive: modifiers.ctrlKey || modifiers.metaKey,
1991
+ selection: selectionRef.current
1992
+ });
1993
+ }, [selectable]);
1994
+ const moveSelectionBox = useCallback((to) => {
1995
+ selectionBoxRef.current?.move([to.x, to.y]);
1996
+ }, []);
1997
+ const endSelectionBox = useCallback(() => {
1998
+ if (!selectionBoxRef.current?.end()) return;
1999
+ const [first] = selectionRef.current;
2000
+ if (first) points.current.get(first)?.current.element?.focus();
2001
+ }, []);
2002
+ const context = useMemo(() => ({
2003
+ disabled,
2004
+ readonly,
2005
+ wheel,
2006
+ keyboard,
2007
+ dragSensitivity,
2008
+ externalStyles: { cursor },
2009
+ containerRef,
2010
+ selectable,
2011
+ selection,
2012
+ registerPoint,
2013
+ isPointElement,
2014
+ beginPointDrag,
2015
+ movePointDrag,
2016
+ nudgeSelection,
2017
+ nudgeFocusedPoint,
2018
+ selectionBox,
2019
+ beginSelectionBox,
2020
+ moveSelectionBox,
2021
+ endSelectionBox
2022
+ }), [
2023
+ disabled,
2024
+ readonly,
2025
+ wheel,
2026
+ keyboard,
2027
+ dragSensitivity,
2028
+ cursor,
2029
+ selectable,
2030
+ selection,
2031
+ registerPoint,
2032
+ isPointElement,
2033
+ beginPointDrag,
2034
+ movePointDrag,
2035
+ nudgeSelection,
2036
+ nudgeFocusedPoint,
2037
+ selectionBox,
2038
+ beginSelectionBox,
2039
+ moveSelectionBox,
2040
+ endSelectionBox
2041
+ ]);
1510
2042
  return /* @__PURE__ */ jsx(PointsEditorProvider, {
1511
- disabled,
1512
- readonly,
1513
- externalStyles: {
1514
- ...defaultExternalStyles$2,
1515
- ..._externalStyles
1516
- },
2043
+ value: context,
1517
2044
  children: /* @__PURE__ */ jsx("div", {
1518
2045
  ref: forwardedRef,
1519
- className: clsx("tremolo-points-editor", className),
2046
+ className,
2047
+ "data-disabled": disabled ? "" : void 0,
2048
+ "data-readonly": readonly ? "" : void 0,
1520
2049
  style: {
1521
- width,
1522
- height,
2050
+ "--width": cssLength(width),
2051
+ "--height": cssLength(height),
2052
+ position: "relative",
1523
2053
  ...style
1524
2054
  },
1525
2055
  ...props,
@@ -1529,108 +2059,84 @@ const PointsEditor = {
1529
2059
  }),
1530
2060
  Background,
1531
2061
  Container,
1532
- Point
2062
+ Point,
2063
+ SelectionBox
1533
2064
  };
1534
2065
  //#endregion
1535
2066
  //#region src/components/Slider/context.tsx
1536
- const createSliderStore = (initProps) => {
1537
- const DEFAULT_PROPS = {
1538
- min: 0,
1539
- max: 0,
1540
- step: 0,
1541
- skew: 0,
1542
- vertical: false,
1543
- reverse: false,
1544
- disabled: false,
1545
- readonly: false
1546
- };
1547
- return createStore()(() => ({
1548
- ...DEFAULT_PROPS,
1549
- ...initProps
1550
- }));
1551
- };
1552
- const SliderContext = createContext(null);
1553
- function SliderProvider({ children, ...props }) {
1554
- const storeRef = useRef(null);
1555
- if (!storeRef.current) storeRef.current = createSliderStore(props);
1556
- useEffect(() => {
1557
- if (storeRef.current) storeRef.current.setState(props);
1558
- else storeRef.current = createSliderStore(props);
1559
- }, [props]);
1560
- return /* @__PURE__ */ jsx(SliderContext.Provider, {
1561
- value: storeRef.current,
1562
- children
1563
- });
1564
- }
2067
+ const SliderContext = /* @__PURE__ */ createContext(null);
2068
+ const SliderProvider = SliderContext.Provider;
1565
2069
  function useSliderContext(selector) {
1566
- const store = useContext(SliderContext);
1567
- if (!store) throw new Error("Missing SliderContext.Provider in the tree");
1568
- return useStore(store, selector);
2070
+ const context = useContext(SliderContext);
2071
+ if (!context) throw new Error("Missing SliderContext.Provider in the tree");
2072
+ return selector ? selector(context) : context;
1569
2073
  }
1570
2074
  //#endregion
1571
- //#region src/components/Slider/ScaleOption.tsx
1572
- function ScaleOption({ value, type = "mark-number", label, thickness = 1, length = "0.5rem", gap = 2, labelWidth, classes, styles, className, style, ...props }) {
2075
+ //#region src/components/Slider/MarksOption.tsx
2076
+ function MarksOption({ value, mark = true, label, thickness, length, gap, labelWidth, classes, styles, className, style, ...props }) {
2077
+ useCheckPlacement("Slider.MarksOption", "Slider.Marks");
1573
2078
  const min = useSliderContext((s) => s.min);
1574
2079
  const max = useSliderContext((s) => s.max);
1575
- const skew = useSliderContext((s) => s.skew);
2080
+ const scale = useSliderContext((s) => s.scale);
1576
2081
  const vertical = useSliderContext((s) => s.vertical);
1577
2082
  const reverse = useSliderContext((s) => s.reverse);
1578
- const calcPercent = useCallback((rawValue) => {
1579
- return toFixed(xor(vertical, reverse) ? 100 - normalizeValue(rawValue, min, max, skew) * 100 : normalizeValue(rawValue, min, max, skew) * 100);
2083
+ const calcPercent = useCallback((value) => {
2084
+ const percent = scale.normalize(value, min, max) * 100;
2085
+ return toFixed(xor(vertical, reverse) ? 100 - percent : percent);
1580
2086
  }, [
1581
2087
  vertical,
1582
2088
  reverse,
1583
2089
  max,
1584
2090
  min,
1585
- skew
2091
+ scale
1586
2092
  ]);
1587
2093
  return /* @__PURE__ */ jsxs("div", {
1588
- className: clsx("tremolo-slider-scale-option", className),
2094
+ className,
1589
2095
  style: {
2096
+ "--thickness": cssLength(thickness),
2097
+ "--length": cssLength(length),
2098
+ "--gap": cssLength(gap),
2099
+ "--label-width": cssLength(labelWidth),
2100
+ position: "absolute",
2101
+ translate: vertical ? "var(--translate, 0 -50%)" : "var(--translate, -50% 0)",
2102
+ zIndex: 10,
2103
+ ...style,
1590
2104
  left: !vertical ? `${calcPercent(value)}%` : void 0,
1591
- top: vertical ? `${calcPercent(value)}%` : void 0,
1592
- ...style
2105
+ top: vertical ? `${calcPercent(value)}%` : void 0
1593
2106
  },
1594
- "data-vertical": vertical,
2107
+ "data-orientation": vertical ? "vertical" : "horizontal",
1595
2108
  ...props,
1596
- children: [type != "number" && /* @__PURE__ */ jsx("div", {
1597
- className: clsx("tremolo-slider-scale-option-mark", classes?.mark),
1598
- style: {
1599
- width: !vertical ? thickness : length,
1600
- height: vertical ? thickness : length,
1601
- marginBottom: !vertical ? gap : void 0,
1602
- marginRight: vertical ? gap : void 0,
1603
- ...styles?.mark
1604
- },
1605
- "data-vertical": vertical
1606
- }), type != "mark" && /* @__PURE__ */ jsx("div", {
1607
- className: clsx("tremolo-slider-scale-option-label", classes?.label),
1608
- style: {
1609
- width: labelWidth,
1610
- ...styles?.label
1611
- },
1612
- children: label || value
2109
+ children: [mark && /* @__PURE__ */ jsx("div", {
2110
+ className: classes?.mark,
2111
+ style: styles?.mark,
2112
+ "data-orientation": vertical ? "vertical" : "horizontal"
2113
+ }), label !== null && /* @__PURE__ */ jsx("div", {
2114
+ className: classes?.label,
2115
+ style: styles?.label,
2116
+ children: label ?? value
1613
2117
  })]
1614
2118
  });
1615
2119
  }
1616
2120
  //#endregion
1617
2121
  //#region src/components/Slider/type.ts
1618
2122
  function generateOptionsList(options, min, max, step) {
2123
+ const { per, mark = true, label = true } = typeof options === "object" ? options : { per: options };
1619
2124
  const optionsList = [];
1620
- const per = options[0] == "step" ? step : options[0];
1621
- const count = Math.floor(max / per) - Math.ceil(min / per) + 1;
2125
+ const interval = per === "step" ? step : per;
2126
+ const count = Math.floor(max / interval) - Math.ceil(min / interval) + 1;
1622
2127
  for (let i = 0; i < count; i++) {
1623
- const value = toFixed(per * (Math.ceil(min / per) + i), decimalPart(per)?.length);
2128
+ const value = toFixed(interval * (Math.ceil(min / interval) + i), decimalPart(interval)?.length);
1624
2129
  optionsList.push({
1625
2130
  value,
1626
- type: options[1]
2131
+ mark,
2132
+ label
1627
2133
  });
1628
2134
  }
1629
2135
  return optionsList;
1630
2136
  }
1631
2137
  //#endregion
1632
- //#region src/components/Slider/Scale.tsx
1633
- function Scale({ gap = 6, options, children, className, style, ...props }) {
2138
+ //#region src/components/Slider/Marks.tsx
2139
+ function Marks({ gap, options, children, className, style, ...props }) {
1634
2140
  const min = useSliderContext((s) => s.min);
1635
2141
  const max = useSliderContext((s) => s.max);
1636
2142
  const step = useSliderContext((s) => s.step);
@@ -1639,233 +2145,260 @@ function Scale({ gap = 6, options, children, className, style, ...props }) {
1639
2145
  const optionsList = options ? generateOptionsList(options, min, max, step) : [];
1640
2146
  if (xor(vertical, reverse)) optionsList.reverse();
1641
2147
  return /* @__PURE__ */ jsx("div", {
1642
- className: clsx("tremolo-slider-scale", className),
2148
+ className,
2149
+ "data-orientation": vertical ? "vertical" : "horizontal",
1643
2150
  style: {
1644
- marginLeft: vertical ? gap : void 0,
1645
- marginTop: !vertical ? gap : void 0,
2151
+ "--gap": cssLength(gap),
2152
+ position: "relative",
1646
2153
  ...style
1647
2154
  },
1648
2155
  ...props,
1649
- children: options ? optionsList.map(({ value, type }, index) => {
1650
- return /* @__PURE__ */ jsx(ScaleOption, {
1651
- value,
1652
- type
1653
- }, index);
1654
- }) : children
2156
+ children: /* @__PURE__ */ jsx(Placement, {
2157
+ name: "Slider.Marks",
2158
+ children: options ? optionsList.map(({ value, mark, label }, index) => {
2159
+ return /* @__PURE__ */ jsx(MarksOption, {
2160
+ value,
2161
+ mark,
2162
+ label: label ? void 0 : null
2163
+ }, index);
2164
+ }) : children
2165
+ })
1655
2166
  });
1656
2167
  }
1657
- const Thumb$1 = forwardRef(({ size, width = 22, height = 22, color, children, className, style, __percent }, ref) => {
1658
- const wrapperRef = useRef(null);
1659
- const vertical = useSliderContext((s) => s.vertical);
1660
- const disabled = useSliderContext((s) => s.disabled);
1661
- const readonly = useSliderContext((s) => s.readonly);
1662
- useImperativeHandle(ref, () => {
1663
- return {
1664
- focus() {
1665
- wrapperRef.current?.focus();
1666
- },
1667
- blur() {
1668
- wrapperRef.current?.blur();
1669
- }
1670
- };
1671
- }, []);
1672
- return /* @__PURE__ */ jsx("div", {
1673
- className: "tremolo-slider-thumb-wrapper",
2168
+ //#endregion
2169
+ //#region src/components/Slider/Thumb.tsx
2170
+ const Thumb$1 = /* @__PURE__ */ forwardRef(function Thumb({ color, children, className, style, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, "aria-describedby": ariaDescribedby, "aria-valuetext": ariaValuetext, ...props }, forwardedRef) {
2171
+ const inputRef = useRef(null);
2172
+ const { value, min, max, step, vertical, disabled, readonly, onChange, percent, thumbRef } = useSliderContext();
2173
+ useCheckPlacement("Slider.Thumb", "Slider.Track");
2174
+ const methods = () => ({
2175
+ focus() {
2176
+ if (!disabled) inputRef.current?.focus();
2177
+ },
2178
+ blur() {
2179
+ inputRef.current?.blur();
2180
+ }
2181
+ });
2182
+ useImperativeHandle(forwardedRef, methods, [disabled]);
2183
+ useImperativeHandle(thumbRef, methods, [disabled]);
2184
+ return /* @__PURE__ */ jsxs("div", {
2185
+ className,
2186
+ "data-disabled": disabled ? "" : void 0,
2187
+ "data-readonly": readonly ? "" : void 0,
2188
+ ...props,
1674
2189
  style: {
1675
- top: vertical ? `${__percent}%` : "50%",
1676
- left: !vertical ? `${__percent}%` : "50%"
2190
+ "--color": color,
2191
+ position: "absolute",
2192
+ translate: "var(--translate, -50% -50%)",
2193
+ zIndex: 100,
2194
+ ...style,
2195
+ top: vertical ? `${percent}%` : "50%",
2196
+ left: !vertical ? `${percent}%` : "50%"
1677
2197
  },
1678
- children: children ? children : /* @__PURE__ */ jsx("div", {
1679
- ref: wrapperRef,
1680
- className: clsx("tremolo-slider-thumb", className),
1681
- tabIndex: 0,
1682
- "aria-disabled": disabled,
2198
+ children: [/* @__PURE__ */ jsx(VisuallyHiddenRangeInput, {
2199
+ ref: inputRef,
2200
+ value,
2201
+ min,
2202
+ max,
2203
+ step,
2204
+ disabled,
1683
2205
  "aria-readonly": readonly,
1684
- style: {
1685
- "--color": color,
1686
- width: size ?? width,
1687
- height: size ?? height,
1688
- ...style
2206
+ "aria-orientation": vertical ? "vertical" : "horizontal",
2207
+ "aria-label": ariaLabel,
2208
+ "aria-labelledby": ariaLabelledby,
2209
+ "aria-describedby": ariaDescribedby,
2210
+ "aria-valuetext": ariaValuetext,
2211
+ onChange: (event) => {
2212
+ if (readonly) {
2213
+ event.currentTarget.value = String(value);
2214
+ return;
2215
+ }
2216
+ onChange?.(event.currentTarget.valueAsNumber);
1689
2217
  }
1690
- })
2218
+ }), children]
1691
2219
  });
1692
2220
  });
1693
- function Track({ length = 140, thickness = 10, active, inactive, children, className, style, defaultStyle = true, __thumb, __percent = 0, ...props }) {
1694
- const vertical = useSliderContext((s) => s.vertical);
1695
- const reverse = useSliderContext((s) => s.reverse);
1696
- const disabled = useSliderContext((s) => s.disabled);
1697
- const direction = vertical ? "bottom" : "right";
1698
- const colors = {
1699
- "--active": active,
1700
- "--inactive": inactive
1701
- };
1702
- return /* @__PURE__ */ jsxs("div", {
1703
- className: clsx("tremolo-slider-track", className),
1704
- "aria-disabled": disabled,
1705
- "data-vertical": vertical,
1706
- style: !defaultStyle ? style : {
1707
- ...colors,
1708
- background: xor(vertical, reverse) ? `linear-gradient(to ${direction}, var(--inactive) ${__percent}%, var(--active) ${__percent}%)` : `linear-gradient(to ${direction}, var(--active) ${__percent}%, var(--inactive) ${__percent}%)`,
1709
- borderRadius: styleHelper(thickness, "/", 2),
1710
- width: !vertical ? length : thickness,
1711
- height: vertical ? length : thickness,
2221
+ //#endregion
2222
+ //#region src/components/Slider/Track.tsx
2223
+ const Track = /* @__PURE__ */ forwardRef(function Track({ length, thickness, active, inactive, children, className, style, ...props }, forwardedRef) {
2224
+ const { vertical, reverse, disabled, percent, trackRef } = useSliderContext();
2225
+ const composedRef = useComposedRefs(forwardedRef, trackRef);
2226
+ return /* @__PURE__ */ jsx("div", {
2227
+ ref: composedRef,
2228
+ className,
2229
+ "data-disabled": disabled ? "" : void 0,
2230
+ "data-orientation": vertical ? "vertical" : "horizontal",
2231
+ "data-flipped": xor(vertical, reverse) ? "" : void 0,
2232
+ style: {
2233
+ "--active": active,
2234
+ "--inactive": inactive,
2235
+ "--length": cssLength(length),
2236
+ "--thickness": cssLength(thickness),
2237
+ "--percent": `${percent}%`,
2238
+ position: "relative",
1712
2239
  ...style
1713
2240
  },
1714
2241
  ...props,
1715
- children: [children, __thumb]
2242
+ children: /* @__PURE__ */ jsx(Placement, {
2243
+ name: "Slider.Track",
2244
+ children
2245
+ })
1716
2246
  });
1717
- }
2247
+ });
1718
2248
  //#endregion
1719
2249
  //#region src/components/Slider/index.tsx
1720
- const defaultExternalStyles$1 = {
1721
- userSelectNone: true,
1722
- cursor: "pointer"
1723
- };
2250
+ const defaultExternalStyles$1 = { cursor: "pointer" };
2251
+ /**
2252
+ * The wheel only acts while the focus is inside, so that scrolling a page past
2253
+ * the control does not change its value.
2254
+ */
2255
+ const WHEEL_OPTIONS$1 = { requireFocus: true };
1724
2256
  /**
1725
2257
  * Customizable slider
1726
2258
  */
1727
2259
  const Slider = {
1728
- Root: forwardRef(({ value, min, max, step = 1, skew = 1, vertical = false, reverse = false, externalStyles: _externalStyles, wheel = ["raw", 1], keyboard = ["raw", 1], disabled = false, readonly = false, onChange, onDragStart, onDragEnd, className, style, children, onFocus, onBlur, onPointerDown, onKeyDown, ...props }, forwardedRef) => {
1729
- const trackElementRef = useRef(null);
2260
+ Root: /* @__PURE__ */ forwardRef(({ value, min, max, step = 1, scale = linearScale, vertical = false, reverse = false, externalStyles: _externalStyles, wheel = DEFAULT_WHEEL_OPTIONS, keyboard = DEFAULT_KEYBOARD_OPTIONS, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, disabled = false, readonly = false, onChange, onDragStart, onDragEnd, className, style, children, onFocus, onBlur, onPointerDown, onKeyDown, ...props }, forwardedRef) => {
2261
+ const trackRef = useRef(null);
1730
2262
  const thumbRef = useRef(null);
1731
2263
  const externalStyles = {
1732
2264
  ...defaultExternalStyles$1,
1733
2265
  ..._externalStyles
1734
2266
  };
1735
- const p = toFixed(normalizeValue(value, min, max, skew) * 100);
2267
+ const inactive = disabled || readonly;
2268
+ const p = toFixed(scale.normalize(value, min, max) * 100);
1736
2269
  const rev = toFixed(100 - p);
1737
2270
  const displayReversed = useMemo(() => xor(vertical, reverse), [vertical, reverse]);
1738
2271
  const percent = displayReversed ? rev : p;
1739
- let trackProps = {};
1740
- let thumbProps = {};
1741
- let scaleComponent = void 0;
1742
- if (children != void 0) React.Children.forEach(children, (child) => {
1743
- if (React.isValidElement(child)) if (child.type == Thumb$1) thumbProps = child.props;
1744
- else if (child.type == Track) trackProps = child.props;
1745
- else if (child.type == Scale) scaleComponent = child;
1746
- else throw new Error("only <SliderThumb> or <SliderTrack>");
1747
- else throw new Error("children is an invalid element.");
1748
- });
1749
- const onDrag = useCallback((nx, ny) => {
1750
- if (!onChange || readonly) return;
1751
- const n = vertical ? ny : nx;
1752
- onChange(clamp(stepValue(rawValue(displayReversed ? 1 - n : n, min, max, skew), step), min, max));
1753
- }, [
1754
- displayReversed,
1755
- max,
2272
+ const axis = useMemo(() => ({
1756
2273
  min,
1757
- onChange,
1758
- readonly,
1759
- skew,
1760
- step,
1761
- vertical
1762
- ]);
1763
- const updateValueByEvent = useCallback((eventType, x) => {
1764
- let newValue;
1765
- if (eventType == "normalized") newValue = rawValue(normalizeValue(value, min, max, skew) + x, min, max, skew);
1766
- else newValue = value + x;
1767
- return clamp(stepValue(newValue, step), min, max);
1768
- }, [
1769
2274
  max,
2275
+ step,
2276
+ scale,
2277
+ reverse: displayReversed
2278
+ }), [
1770
2279
  min,
1771
- skew,
2280
+ max,
1772
2281
  step,
1773
- value
2282
+ scale,
2283
+ displayReversed
1774
2284
  ]);
2285
+ useCheckSteps({
2286
+ component: "Slider",
2287
+ range: axis,
2288
+ keyboard,
2289
+ wheel
2290
+ });
1775
2291
  const handleKeyDown = useCallback((event) => {
1776
- if (!keyboard || !onChange || readonly) return;
1777
2292
  const key = event.key;
1778
- if ([
2293
+ if (![
1779
2294
  "ArrowRight",
1780
2295
  "ArrowLeft",
1781
2296
  "ArrowUp",
1782
2297
  "ArrowDown"
1783
- ].includes(key)) {
1784
- event.preventDefault();
1785
- let x = key == "ArrowRight" || key == "ArrowUp" ? keyboard[1] : -keyboard[1];
1786
- if (reverse) x *= -1;
1787
- onChange(updateValueByEvent(keyboard[0], x));
1788
- }
2298
+ ].includes(key)) return;
2299
+ event.preventDefault();
2300
+ if (!keyboard || !onChange || inactive) return;
2301
+ let direction = key === "ArrowRight" || key === "ArrowUp" ? 1 : -1;
2302
+ if (reverse) direction *= -1;
2303
+ onChange(applyDelta(value, direction, keyboard, axis, event));
1789
2304
  }, [
1790
2305
  keyboard,
1791
2306
  onChange,
1792
- readonly,
2307
+ inactive,
1793
2308
  reverse,
1794
- updateValueByEvent
2309
+ value,
2310
+ axis
1795
2311
  ]);
1796
- const { refCallback: dragRefCallback } = useDragWithElement({
1797
- baseElementRef: trackElementRef,
1798
- cursor: readonly ? void 0 : externalStyles.cursor,
2312
+ const valueOf = (v) => v[vertical ? 1 : 0];
2313
+ const { refCallback: dragRefCallback } = useDragValue({
2314
+ axis,
2315
+ baseElementRef: trackRef,
2316
+ cursor: inactive ? void 0 : externalStyles.cursor,
2317
+ shouldStart: () => !inactive,
2318
+ sensitivity: (state) => selectModifier(dragSensitivity, state.event).value,
1799
2319
  updateOnPointerDown: true,
1800
- onDrag,
1801
- onDragStart: (nx, ny) => {
1802
- if (readonly) return;
1803
- if (externalStyles.userSelectNone) addUserSelectNone();
2320
+ onChange: (v) => {
2321
+ if (inactive) return;
2322
+ onChange?.(valueOf(v));
2323
+ },
2324
+ onDragStart: (v) => {
2325
+ if (inactive) return;
1804
2326
  thumbRef.current?.focus();
1805
- onDragStart?.(clamp(stepValue(rawValue(vertical ? ny : nx, min, max, skew), step), min, max));
2327
+ onDragStart?.(valueOf(v));
1806
2328
  },
1807
- onDragEnd: (nx, ny) => {
1808
- if (readonly) return;
1809
- if (externalStyles.userSelectNone) removeUserSelectNone();
1810
- onDragEnd?.(clamp(stepValue(rawValue(vertical ? ny : nx, min, max, skew), step), min, max));
2329
+ onDragEnd: (v) => {
2330
+ if (inactive) return;
2331
+ onDragEnd?.(valueOf(v));
1811
2332
  }
1812
2333
  });
1813
2334
  const wheelRefCallback = useWheel((event) => {
1814
- if (!wheel || !onChange || readonly) return;
2335
+ if (!wheel || !onChange || inactive) return;
1815
2336
  event.preventDefault();
1816
- let x;
1817
- if (!vertical && event.deltaX != 0) x = event.deltaX > 0 ? wheel[1] : -wheel[1];
2337
+ let direction;
2338
+ if (!vertical && event.deltaX !== 0) direction = event.deltaX > 0 ? 1 : -1;
1818
2339
  else {
1819
- if (event.deltaY == 0) return;
1820
- x = event.deltaY > 0 ? -wheel[1] : wheel[1];
2340
+ if (event.deltaY === 0) return;
2341
+ direction = event.deltaY > 0 ? -1 : 1;
1821
2342
  }
1822
- if (vertical && reverse) x *= -1;
1823
- onChange(updateValueByEvent(wheel[0], x));
1824
- });
2343
+ if (reverse) direction *= -1;
2344
+ onChange(applyDelta(value, direction, wheel, axis, event));
2345
+ }, WHEEL_OPTIONS$1);
2346
+ const rootRefCallback = useComposedRefs(dragRefCallback, wheelRefCallback);
2347
+ const context = useMemo(() => ({
2348
+ value,
2349
+ min,
2350
+ max,
2351
+ step,
2352
+ scale,
2353
+ vertical,
2354
+ reverse,
2355
+ disabled,
2356
+ readonly,
2357
+ onChange,
2358
+ percent,
2359
+ trackRef,
2360
+ thumbRef
2361
+ }), [
2362
+ value,
2363
+ min,
2364
+ max,
2365
+ step,
2366
+ scale,
2367
+ vertical,
2368
+ reverse,
2369
+ disabled,
2370
+ readonly,
2371
+ onChange,
2372
+ percent
2373
+ ]);
1825
2374
  useImperativeHandle(forwardedRef, () => {
1826
2375
  return {
1827
2376
  focus() {
1828
- thumbRef.current?.focus();
2377
+ if (!disabled) thumbRef.current?.focus();
1829
2378
  },
1830
2379
  blur() {
1831
2380
  thumbRef.current?.blur();
1832
2381
  }
1833
2382
  };
1834
- }, []);
2383
+ }, [disabled]);
1835
2384
  return /* @__PURE__ */ jsx(SliderProvider, {
1836
- min,
1837
- max,
1838
- step,
1839
- skew,
1840
- vertical,
1841
- reverse,
1842
- disabled,
1843
- readonly,
2385
+ value: context,
1844
2386
  children: /* @__PURE__ */ jsx("div", {
1845
- className: clsx("tremolo-slider", className),
1846
- ref: (div) => {
1847
- wheelRefCallback(div);
1848
- dragRefCallback(div);
1849
- },
2387
+ className,
2388
+ ref: rootRefCallback,
2389
+ role: "group",
1850
2390
  tabIndex: -1,
1851
- role: "slider",
1852
- "aria-valuenow": value,
1853
- "aria-valuemin": min,
1854
- "aria-valuemax": max,
1855
- "aria-orientation": vertical ? "vertical" : "horizontal",
1856
- "aria-disabled": disabled,
1857
- "aria-readonly": readonly,
1858
- style: {
1859
- margin: `calc(${styleHelper(thumbProps?.size ?? 22)} / 2)`,
1860
- ...style
1861
- },
2391
+ "data-orientation": vertical ? "vertical" : "horizontal",
2392
+ "data-disabled": disabled ? "" : void 0,
2393
+ "data-readonly": readonly ? "" : void 0,
2394
+ style,
1862
2395
  onPointerDown,
1863
2396
  onKeyDown: (event) => {
1864
2397
  handleKeyDown(event);
1865
2398
  onKeyDown?.(event);
1866
2399
  },
1867
2400
  onFocus: (event) => {
1868
- thumbRef.current?.focus();
2401
+ if (!disabled) thumbRef.current?.focus();
1869
2402
  onFocus?.(event);
1870
2403
  },
1871
2404
  onBlur: (event) => {
@@ -1873,304 +2406,295 @@ const Slider = {
1873
2406
  onBlur?.(event);
1874
2407
  },
1875
2408
  ...props,
1876
- children: /* @__PURE__ */ jsxs("div", {
1877
- style: {
1878
- display: "flex",
1879
- flexDirection: vertical ? "row" : "column",
1880
- width: !vertical ? "100%" : void 0,
1881
- height: vertical ? "100%" : void 0
1882
- },
1883
- children: [/* @__PURE__ */ jsx("div", {
1884
- className: "tremolo-slider-track-wrapper",
1885
- ref: trackElementRef,
1886
- children: /* @__PURE__ */ jsx(Track, {
1887
- __percent: percent,
1888
- __thumb: /* @__PURE__ */ jsx(Thumb$1, {
1889
- ref: thumbRef,
1890
- __percent: percent,
1891
- ...thumbProps
1892
- }),
1893
- ...trackProps
1894
- })
1895
- }), scaleComponent]
1896
- })
2409
+ children
1897
2410
  })
1898
2411
  });
1899
2412
  }),
1900
2413
  Thumb: Thumb$1,
1901
2414
  Track,
1902
- Scale,
1903
- ScaleOption
2415
+ Marks,
2416
+ MarksOption
1904
2417
  };
1905
2418
  //#endregion
1906
- //#region src/components/_util/composeRefs.tsx
1907
- /**
1908
- * Set a given ref to a given value
1909
- * This utility takes care of different types of refs: callback refs and RefObject(s)
1910
- */
1911
- function setRef(ref, value) {
1912
- if (typeof ref === "function") return ref(value);
1913
- else if (ref !== null && ref !== void 0) ref.current = value;
1914
- }
1915
- /**
1916
- * A utility to compose multiple refs together
1917
- * Accepts callback refs and RefObject(s)
1918
- */
1919
- function composeRefs(...refs) {
1920
- return (node) => {
1921
- let hasCleanup = false;
1922
- const cleanups = refs.map((ref) => {
1923
- const cleanup = setRef(ref, node);
1924
- if (!hasCleanup && typeof cleanup == "function") hasCleanup = true;
1925
- return cleanup;
1926
- });
1927
- if (hasCleanup) return () => {
1928
- for (let i = 0; i < cleanups.length; i++) {
1929
- const cleanup = cleanups[i];
1930
- if (typeof cleanup == "function") cleanup();
1931
- else setRef(refs[i], null);
1932
- }
1933
- };
1934
- };
2419
+ //#region src/components/XYPad/context.tsx
2420
+ const XYPadContext = /* @__PURE__ */ createContext(null);
2421
+ const XYPadProvider = XYPadContext.Provider;
2422
+ function useXYPadContext(selector) {
2423
+ const context = useContext(XYPadContext);
2424
+ if (!context) throw new Error("Missing XYPadContext.Provider in the tree");
2425
+ return selector ? selector(context) : context;
1935
2426
  }
1936
2427
  //#endregion
1937
2428
  //#region src/components/XYPad/Area.tsx
1938
- function Area({ width = 120, height = 120, color, children, className, style, __thumb, ...props }) {
1939
- return /* @__PURE__ */ jsxs("div", {
1940
- className: clsx("tremolo-xy-pad-area", className),
2429
+ const Area = /* @__PURE__ */ forwardRef(function Area({ width, height, color, children, className, style, ...props }, forwardedRef) {
2430
+ const { areaRef } = useXYPadContext();
2431
+ const composedRef = useComposedRefs(forwardedRef, areaRef);
2432
+ return /* @__PURE__ */ jsx("div", {
2433
+ ref: composedRef,
2434
+ className,
1941
2435
  style: {
1942
2436
  "--color": color,
1943
- width,
1944
- height,
2437
+ "--width": cssLength(width),
2438
+ "--height": cssLength(height),
2439
+ position: "relative",
1945
2440
  ...style
1946
2441
  },
1947
2442
  ...props,
1948
- children: [children, __thumb]
2443
+ children: /* @__PURE__ */ jsx(Placement, {
2444
+ name: "XYPad.Area",
2445
+ children
2446
+ })
1949
2447
  });
1950
- }
2448
+ });
1951
2449
  //#endregion
1952
2450
  //#region src/components/XYPad/Thumb.tsx
1953
- const Thumb = forwardRef(({ size, width = 22, height = 22, color, children, wrapperClassName, wrapperStyle, className, style, __disabled, __readonly, __css, ...props }, ref) => {
1954
- const wrapperRef = useRef(null);
1955
- useImperativeHandle(ref, () => {
1956
- return {
1957
- focus() {
1958
- wrapperRef.current?.focus();
1959
- },
1960
- blur() {
1961
- wrapperRef.current?.blur();
1962
- }
1963
- };
1964
- }, []);
1965
- return /* @__PURE__ */ jsx("div", {
1966
- className: clsx("tremolo-xy-pad-thumb-wrapper", wrapperClassName),
1967
- style: {
1968
- ...__css,
1969
- ...wrapperStyle
2451
+ const Thumb = /* @__PURE__ */ forwardRef(function Thumb({ color, children, className, style, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, "aria-describedby": ariaDescribedby, "aria-valuetext": ariaValuetext, ...props }, forwardedRef) {
2452
+ const xInputRef = useRef(null);
2453
+ const yInputRef = useRef(null);
2454
+ const { value, min, max, step, disabled, readonly, onChange, percent, thumbRef } = useXYPadContext();
2455
+ useCheckPlacement("XYPad.Thumb", "XYPad.Area");
2456
+ const labels = toXY(ariaLabel);
2457
+ const labelledby = toXY(ariaLabelledby);
2458
+ const describedby = toXY(ariaDescribedby);
2459
+ const valueText = toXY(ariaValuetext);
2460
+ const methods = () => ({
2461
+ focus() {
2462
+ if (!disabled) xInputRef.current?.focus();
1970
2463
  },
2464
+ blur() {
2465
+ xInputRef.current?.blur();
2466
+ yInputRef.current?.blur();
2467
+ }
2468
+ });
2469
+ useImperativeHandle(forwardedRef, methods, [disabled]);
2470
+ useImperativeHandle(thumbRef, methods, [disabled]);
2471
+ return /* @__PURE__ */ jsxs("div", {
2472
+ className,
2473
+ "data-disabled": disabled ? "" : void 0,
2474
+ "data-readonly": readonly ? "" : void 0,
1971
2475
  ...props,
1972
- children: children ? children : /* @__PURE__ */ jsx("div", {
1973
- ref: wrapperRef,
1974
- className: clsx("tremolo-xy-pad-thumb", className),
1975
- tabIndex: 0,
1976
- "aria-disabled": __disabled,
1977
- "aria-readonly": __readonly,
1978
- style: {
1979
- "--color": color,
1980
- width: size ?? width,
1981
- height: size ?? height,
1982
- ...style
2476
+ style: {
2477
+ "--color": color,
2478
+ position: "absolute",
2479
+ translate: "var(--translate, -50% -50%)",
2480
+ zIndex: 100,
2481
+ ...style,
2482
+ left: `${percent[0]}%`,
2483
+ top: `${percent[1]}%`
2484
+ },
2485
+ children: [[0, 1].map((axis) => /* @__PURE__ */ jsx(VisuallyHiddenRangeInput, {
2486
+ ref: axis === 0 ? xInputRef : yInputRef,
2487
+ "data-axis": axis === 0 ? "x" : "y",
2488
+ value: value[axis],
2489
+ min: min[axis],
2490
+ max: max[axis],
2491
+ step: step[axis],
2492
+ disabled,
2493
+ "aria-readonly": readonly,
2494
+ "aria-orientation": axis === 0 ? "horizontal" : "vertical",
2495
+ "aria-label": labels[axis] ?? (axis === 0 ? "x" : "y"),
2496
+ "aria-labelledby": labelledby[axis],
2497
+ "aria-describedby": describedby[axis],
2498
+ "aria-valuetext": valueText[axis],
2499
+ onChange: (event) => {
2500
+ if (readonly) {
2501
+ event.currentTarget.value = String(value[axis]);
2502
+ return;
2503
+ }
2504
+ const next = [...value];
2505
+ next[axis] = event.currentTarget.valueAsNumber;
2506
+ onChange?.(next);
1983
2507
  }
1984
- })
2508
+ }, axis)), children]
1985
2509
  });
1986
2510
  });
1987
2511
  //#endregion
1988
2512
  //#region src/components/XYPad/index.tsx
1989
- const defaultValueOptions = {
1990
- skew: 1,
1991
- step: 1,
1992
- reverse: false,
1993
- wheel: ["raw", 1],
1994
- keyboard: ["raw", 1]
1995
- };
1996
- const defaultExternalStyles = {
1997
- userSelectNone: true,
1998
- cursor: "pointer"
1999
- };
2513
+ const defaultExternalStyles = { cursor: "pointer" };
2514
+ /**
2515
+ * The wheel only acts while the focus is inside, so that scrolling a page past
2516
+ * the control does not change its value.
2517
+ */
2518
+ const WHEEL_OPTIONS = { requireFocus: true };
2000
2519
  /**
2001
2520
  * Simple XYPad
2002
2521
  */
2003
2522
  const XYPad = {
2004
- Root: forwardRef(({ x: _x, y: _y, className, style, externalStyles: _externalStyles, disabled = false, readonly = false, onChange, onDragStart, onDragEnd, onPointerDown, onKeyDown, onFocus, onBlur, children, ...props }, forwardedRef) => {
2005
- const x = useMemo(() => {
2006
- return {
2007
- ...defaultValueOptions,
2008
- ..._x
2009
- };
2010
- }, [_x]);
2011
- const y = useMemo(() => {
2012
- return {
2013
- ...defaultValueOptions,
2014
- ..._y
2015
- };
2016
- }, [_y]);
2523
+ Root: /* @__PURE__ */ forwardRef(({ value, min: _min, max: _max, step: _step = 1, scale: _scale = linearScale, reverse: _reverse = false, wheel = DEFAULT_WHEEL_OPTIONS, keyboard = DEFAULT_KEYBOARD_OPTIONS, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, className, style, externalStyles: _externalStyles, disabled = false, readonly = false, onChange, onDragStart, onDragEnd, onPointerDown, onKeyDown, onFocus, onBlur, children, ...props }, forwardedRef) => {
2017
2524
  const rootRef = useRef(null);
2018
- const areaElementRef = useRef(null);
2525
+ const areaRef = useRef(null);
2019
2526
  const thumbRef = useRef(null);
2020
2527
  const externalStyles = {
2021
2528
  ...defaultExternalStyles,
2022
2529
  ..._externalStyles
2023
2530
  };
2024
- const nx = normalizeValue(x.value, x.min, x.max, x.skew);
2025
- const ny = normalizeValue(y.value, y.min, y.max, y.skew);
2026
- const percentX = toFixed((x.reverse ? 1 - nx : nx) * 100);
2027
- const percentY = toFixed((y.reverse ? 1 - ny : ny) * 100);
2028
- let areaProps = {};
2029
- let thumbProps = {};
2030
- if (children != void 0) React.Children.forEach(children, (child) => {
2031
- if (React.isValidElement(child)) if (child.type == Thumb) thumbProps = child.props;
2032
- else if (child.type == Area) areaProps = child.props;
2033
- else throw new Error("only <XYPadThumb> or <XYPadArea>");
2034
- else throw new Error("children is an invalid element.");
2035
- });
2036
- const onDrag = useCallback((nx, ny) => {
2037
- if (!onChange || readonly) return;
2038
- const vx = rawValue(x.reverse ? 1 - nx : nx, x.min, x.max, x.skew);
2039
- const vy = rawValue(y.reverse ? 1 - ny : ny, y.min, y.max, y.skew);
2040
- onChange(clamp(stepValue(vx, x.step), x.min, x.max), clamp(stepValue(vy, y.step), y.min, y.max));
2531
+ const inactive = disabled || readonly;
2532
+ const min = useMemo(() => toXY(_min), [_min]);
2533
+ const max = useMemo(() => toXY(_max), [_max]);
2534
+ const step = useMemo(() => toXY(_step), [_step]);
2535
+ const scale = useMemo(() => toXY(_scale), [_scale]);
2536
+ const reverse = useMemo(() => toXY(_reverse), [_reverse]);
2537
+ const percent = useMemo(() => {
2538
+ const normalized = [0, 1].map((i) => scale[i].normalize(value[i], min[i], max[i]));
2539
+ return [0, 1].map((i) => toFixed((reverse[i] ? 1 - normalized[i] : normalized[i]) * 100));
2041
2540
  }, [
2042
- onChange,
2043
- readonly,
2044
- x.max,
2045
- x.min,
2046
- x.reverse,
2047
- x.skew,
2048
- x.step,
2049
- y.max,
2050
- y.min,
2051
- y.reverse,
2052
- y.skew,
2053
- y.step
2541
+ value,
2542
+ min,
2543
+ max,
2544
+ scale,
2545
+ reverse
2546
+ ]);
2547
+ const axis = useMemo(() => [0, 1].map((i) => ({
2548
+ min: min[i],
2549
+ max: max[i],
2550
+ step: step[i],
2551
+ scale: scale[i],
2552
+ reverse: reverse[i]
2553
+ })), [
2554
+ min,
2555
+ max,
2556
+ step,
2557
+ scale,
2558
+ reverse
2559
+ ]);
2560
+ useCheckSteps({
2561
+ component: "XYPad",
2562
+ axis: "x",
2563
+ range: axis[0],
2564
+ keyboard,
2565
+ wheel
2566
+ });
2567
+ useCheckSteps({
2568
+ component: "XYPad",
2569
+ axis: "y",
2570
+ range: axis[1],
2571
+ keyboard,
2572
+ wheel
2573
+ });
2574
+ const withAxis = useCallback((axis, next) => axis === 0 ? [next, value[1]] : [value[0], next], [value]);
2575
+ /** @param i 0 = x, 1 = y */
2576
+ const nudge = useCallback((i, direction, option, modifiers) => withAxis(i, applyDelta(value[i], direction, option, axis[i], modifiers)), [
2577
+ value,
2578
+ axis,
2579
+ withAxis
2054
2580
  ]);
2055
- const updateValueByEvent = useCallback((eventType, target, x) => {
2056
- let v;
2057
- if (eventType == "normalized") v = rawValue(normalizeValue(target.value, target.min, target.max, target.skew) + x, target.min, target.max, target.skew);
2058
- else v = target.value + x;
2059
- return clamp(stepValue(v, target.step), target.min, target.max);
2060
- }, []);
2061
2581
  const handleKeyDown = useCallback((event) => {
2062
- if (!onChange || readonly) return;
2063
2582
  const key = event.key;
2064
- if ([
2583
+ if (![
2065
2584
  "ArrowRight",
2066
2585
  "ArrowLeft",
2067
2586
  "ArrowUp",
2068
2587
  "ArrowDown"
2069
- ].includes(key)) {
2070
- const isHorizontal = key == "ArrowRight" || key == "ArrowLeft";
2071
- const target = isHorizontal ? x : y;
2072
- if (!target.keyboard) return;
2073
- event.preventDefault();
2074
- let delta = target.keyboard[1];
2075
- if (key == "ArrowLeft" || key == "ArrowUp") delta *= -1;
2076
- if (target.reverse) delta *= -1;
2077
- const newValue = updateValueByEvent(target.keyboard[0], target, delta);
2078
- if (isHorizontal) onChange(newValue, y.value);
2079
- else onChange(x.value, newValue);
2080
- }
2588
+ ].includes(key)) return;
2589
+ const i = key === "ArrowRight" || key === "ArrowLeft" ? 0 : 1;
2590
+ event.preventDefault();
2591
+ if (!onChange || inactive || !keyboard) return;
2592
+ let direction = 1;
2593
+ if (key === "ArrowLeft" || key === "ArrowUp") direction *= -1;
2594
+ if (reverse[i]) direction *= -1;
2595
+ onChange(nudge(i, direction, keyboard, event));
2081
2596
  }, [
2082
2597
  onChange,
2083
- readonly,
2084
- x,
2085
- y,
2086
- updateValueByEvent
2598
+ inactive,
2599
+ keyboard,
2600
+ reverse,
2601
+ nudge
2087
2602
  ]);
2088
- const { refCallback: dragRefCallback } = useDragWithElement({
2089
- baseElementRef: areaElementRef,
2090
- cursor: readonly ? void 0 : externalStyles.cursor,
2603
+ const { refCallback: dragRefCallback } = useDragValue({
2604
+ axis,
2605
+ baseElementRef: areaRef,
2606
+ sensitivity: (state) => selectModifier(dragSensitivity, state.event).value,
2091
2607
  updateOnPointerDown: true,
2092
- onDrag,
2093
- onDragStart: (nx, ny) => {
2094
- if (readonly) return;
2095
- if (externalStyles.userSelectNone) addUserSelectNone();
2608
+ cursor: inactive ? void 0 : externalStyles.cursor,
2609
+ shouldStart: () => !inactive,
2610
+ onChange: (v) => {
2611
+ if (inactive) return;
2612
+ onChange?.(v);
2613
+ },
2614
+ onDragStart: (v) => {
2615
+ if (inactive) return;
2096
2616
  thumbRef.current?.focus();
2097
- const valueX = clamp(stepValue(rawValue(nx, x.min, x.max, x.skew), x.step), x.min, x.max);
2098
- const valueY = clamp(stepValue(rawValue(ny, y.min, y.max, y.skew), y.step), y.min, y.max);
2099
- onDragStart?.(valueX, valueY);
2617
+ onDragStart?.(v);
2100
2618
  },
2101
- onDragEnd: (nx, ny) => {
2102
- if (readonly) return;
2103
- if (externalStyles.userSelectNone) removeUserSelectNone();
2104
- const valueX = clamp(stepValue(rawValue(nx, x.min, x.max, x.skew), x.step), x.min, x.max);
2105
- const valueY = clamp(stepValue(rawValue(ny, y.min, y.max, y.skew), y.step), y.min, y.max);
2106
- onDragEnd?.(valueX, valueY);
2619
+ onDragEnd: (v) => {
2620
+ if (inactive) return;
2621
+ onDragEnd?.(v);
2107
2622
  }
2108
2623
  });
2109
2624
  const wheelRefCallback = useWheel((event) => {
2110
- if (!onChange || readonly) return;
2111
- const target = event.shiftKey ? x : y;
2112
- if (!target.wheel) return;
2625
+ if (!onChange || inactive || !wheel) return;
2626
+ const horizontal = event.deltaX !== 0;
2627
+ const delta = horizontal ? event.deltaX : event.deltaY;
2628
+ if (delta === 0) return;
2629
+ const i = horizontal || event.shiftKey ? 0 : 1;
2113
2630
  event.preventDefault();
2114
- let delta = target.wheel[1];
2115
- if (event.deltaY < 0) delta *= -1;
2116
- if (target.reverse) delta *= -1;
2117
- const newValue = updateValueByEvent(target.wheel[0], target, delta);
2118
- if (event.shiftKey) onChange(newValue, y.value);
2119
- else onChange(x.value, newValue);
2120
- });
2631
+ let direction = 1;
2632
+ if (delta < 0) direction *= -1;
2633
+ if (reverse[i]) direction *= -1;
2634
+ onChange(nudge(i, direction, wheel, event));
2635
+ }, WHEEL_OPTIONS);
2636
+ const rootRefCallback = useComposedRefs(rootRef, dragRefCallback, wheelRefCallback);
2637
+ const context = useMemo(() => ({
2638
+ value,
2639
+ min,
2640
+ max,
2641
+ step,
2642
+ scale,
2643
+ reverse,
2644
+ disabled,
2645
+ readonly,
2646
+ onChange,
2647
+ percent,
2648
+ areaRef,
2649
+ thumbRef
2650
+ }), [
2651
+ value,
2652
+ min,
2653
+ max,
2654
+ step,
2655
+ scale,
2656
+ reverse,
2657
+ disabled,
2658
+ readonly,
2659
+ onChange,
2660
+ percent
2661
+ ]);
2121
2662
  useImperativeHandle(forwardedRef, () => {
2122
2663
  return {
2123
2664
  focus() {
2124
- thumbRef.current?.focus();
2665
+ if (!disabled) thumbRef.current?.focus();
2125
2666
  },
2126
2667
  blur() {
2127
2668
  thumbRef.current?.blur();
2128
2669
  },
2129
2670
  original: rootRef
2130
2671
  };
2131
- }, []);
2132
- return /* @__PURE__ */ jsx("div", {
2133
- className: clsx("tremolo-xy-pad", className),
2134
- ref: composeRefs(rootRef, wheelRefCallback, dragRefCallback),
2135
- tabIndex: -1,
2136
- "aria-disabled": disabled,
2137
- "aria-readonly": readonly,
2138
- style: {
2139
- margin: `calc(${styleHelper(thumbProps?.size ?? 22)} / 2)`,
2140
- cursor: readonly ? "not-allowed" : "pointer",
2141
- WebkitTapHighlightColor: "transparent",
2142
- ...style
2143
- },
2144
- onPointerDown,
2145
- onKeyDown: (event) => {
2146
- handleKeyDown(event);
2147
- onKeyDown?.(event);
2148
- },
2149
- onFocus: (event) => {
2150
- thumbRef.current?.focus();
2151
- onFocus?.(event);
2152
- },
2153
- onBlur: (event) => {
2154
- thumbRef.current?.blur();
2155
- onBlur?.(event);
2156
- },
2157
- ...props,
2672
+ }, [disabled]);
2673
+ return /* @__PURE__ */ jsx(XYPadProvider, {
2674
+ value: context,
2158
2675
  children: /* @__PURE__ */ jsx("div", {
2159
- className: "tremolo-xy-pad-area-wrapper",
2160
- ref: areaElementRef,
2161
- children: /* @__PURE__ */ jsx(Area, {
2162
- __thumb: /* @__PURE__ */ jsx(Thumb, {
2163
- ref: thumbRef,
2164
- __disabled: disabled,
2165
- __readonly: readonly,
2166
- __css: {
2167
- top: `${percentY}%`,
2168
- left: `${percentX}%`
2169
- },
2170
- ...thumbProps
2171
- }),
2172
- ...areaProps
2173
- })
2676
+ className,
2677
+ ref: rootRefCallback,
2678
+ role: "group",
2679
+ tabIndex: -1,
2680
+ "data-disabled": disabled ? "" : void 0,
2681
+ "data-readonly": readonly ? "" : void 0,
2682
+ style,
2683
+ onPointerDown,
2684
+ onKeyDown: (event) => {
2685
+ handleKeyDown(event);
2686
+ onKeyDown?.(event);
2687
+ },
2688
+ onFocus: (event) => {
2689
+ if (!disabled) thumbRef.current?.focus();
2690
+ onFocus?.(event);
2691
+ },
2692
+ onBlur: (event) => {
2693
+ thumbRef.current?.blur();
2694
+ onBlur?.(event);
2695
+ },
2696
+ ...props,
2697
+ children
2174
2698
  })
2175
2699
  });
2176
2700
  }),
@@ -2181,67 +2705,153 @@ const XYPad = {
2181
2705
  //#region src/hooks/useAnimationFrame.ts
2182
2706
  function useAnimationFrame(callback = () => {}, deps = []) {
2183
2707
  const reqIdRef = useRef(-1);
2184
- const loop = useCallback(() => {
2185
- reqIdRef.current = requestAnimationFrame(loop);
2186
- callback();
2187
- }, [callback, ...deps]);
2708
+ const runCallback = useCallbackRef(callback);
2188
2709
  useEffect(() => {
2710
+ const loop = () => {
2711
+ reqIdRef.current = requestAnimationFrame(loop);
2712
+ runCallback();
2713
+ };
2189
2714
  reqIdRef.current = requestAnimationFrame(loop);
2190
2715
  return () => cancelAnimationFrame(reqIdRef.current);
2191
- }, [loop]);
2716
+ }, [runCallback, ...deps]);
2192
2717
  }
2193
2718
  //#endregion
2194
2719
  //#region src/hooks/useMIDIAccess.ts
2720
+ const INITIAL_STATE = {
2721
+ midiAccess: null,
2722
+ error: null,
2723
+ inputs: []
2724
+ };
2725
+ function createMIDIAccessStore() {
2726
+ let instance = null;
2727
+ let unsubscribe = null;
2728
+ let state = INITIAL_STATE;
2729
+ const listeners = /* @__PURE__ */ new Set();
2730
+ function emit() {
2731
+ for (const listener of listeners) listener();
2732
+ }
2733
+ function connect() {
2734
+ const nextInstance = createMIDIAccess();
2735
+ instance = nextInstance;
2736
+ state = nextInstance.getState();
2737
+ unsubscribe = nextInstance.subscribe(() => {
2738
+ state = nextInstance.getState();
2739
+ emit();
2740
+ });
2741
+ emit();
2742
+ return nextInstance;
2743
+ }
2744
+ function disconnect(currentInstance) {
2745
+ if (instance !== currentInstance) return;
2746
+ unsubscribe?.();
2747
+ unsubscribe = null;
2748
+ instance = null;
2749
+ state = INITIAL_STATE;
2750
+ currentInstance.destroy();
2751
+ emit();
2752
+ }
2753
+ function request(options) {
2754
+ instance?.request(options);
2755
+ }
2756
+ return {
2757
+ connect,
2758
+ disconnect,
2759
+ request,
2760
+ getState: () => state,
2761
+ getServerState: () => INITIAL_STATE,
2762
+ subscribe: (listener) => {
2763
+ listeners.add(listener);
2764
+ return () => listeners.delete(listener);
2765
+ }
2766
+ };
2767
+ }
2195
2768
  /**
2196
2769
  * Hooks for requesting MIDI access in the browser. The first argument allows you to choose whether to request access on mount.
2770
+ *
2771
+ * `inputs` follows the devices: it changes as one is plugged in or unplugged,
2772
+ * so a list of devices needs no `statechange` listener of its own.
2773
+ *
2774
+ * `request` takes `{ sysex: true }` when the app needs system exclusive
2775
+ * messages. Browsers treat that as a separate, more sensitive permission, so
2776
+ * ask for it only when it is actually used.
2197
2777
  */
2198
2778
  function useMIDIAccess(requestOnMount = true) {
2199
- const instance = useMemo(() => createMIDIAccess(), []);
2200
- const { midiAccess, error } = useSyncExternalStore(instance.subscribe, instance.getState, instance.getServerState);
2779
+ const store = useMemo(() => createMIDIAccessStore(), []);
2780
+ const { midiAccess, error, inputs } = useSyncExternalStore(store.subscribe, store.getState, store.getServerState);
2201
2781
  useEffect(() => {
2202
- if (requestOnMount) instance.request();
2203
- }, [instance, requestOnMount]);
2782
+ const instance = store.connect();
2783
+ return () => store.disconnect(instance);
2784
+ }, [store]);
2204
2785
  useEffect(() => {
2205
- return () => instance.destroy();
2206
- }, [instance]);
2786
+ if (requestOnMount) store.request();
2787
+ }, [store, requestOnMount]);
2207
2788
  return {
2208
- request: instance.request,
2789
+ request: store.request,
2209
2790
  midiAccess,
2210
- error
2791
+ error,
2792
+ inputs
2211
2793
  };
2212
2794
  }
2213
2795
  //#endregion
2214
2796
  //#region src/hooks/useMIDIInput.ts
2215
2797
  /**
2216
- * Hooks for handling note on/off events. To be used with useMIDIAccess. Internally uses useMIDIMessage.
2798
+ * Handle MIDI input events. To be used with {@link useMIDIAccess}.
2799
+ *
2800
+ * The handlers are read fresh on every event, so writing them inline is fine:
2801
+ * the listeners are attached once per `midiAccess` and stay attached. Devices
2802
+ * plugged in later are picked up without anything having to be rebuilt.
2803
+ *
2804
+ * @example
2805
+ * const { midiAccess } = useMIDIAccess()
2806
+ * useMIDIInput(midiAccess, {
2807
+ * onNoteOnEvent: (note, velocity) => play(note, velocity / 127),
2808
+ * onNoteOffEvent: (note) => stop(note),
2809
+ * onControlChangeEvent: (controller, value) => {
2810
+ * if (controller === 1) setModulation(value / 127)
2811
+ * },
2812
+ * })
2217
2813
  */
2218
- function useMIDIInput(midiAccess, onNoteOnEvent, onNoteOffEvent, onPitchBendEvent) {
2814
+ function useMIDIInput(midiAccess, handlers) {
2815
+ const latest = useRef(handlers);
2816
+ const instanceRef = useRef(null);
2219
2817
  useEffect(() => {
2220
- const instance = createMIDIInput(midiAccess, {
2221
- onNoteOnEvent,
2222
- onNoteOffEvent,
2223
- onPitchBendEvent
2224
- });
2225
- return () => instance.destroy();
2226
- }, [
2227
- midiAccess,
2228
- onNoteOnEvent,
2229
- onNoteOffEvent,
2230
- onPitchBendEvent
2231
- ]);
2818
+ const instance = createMIDIInput(midiAccess, latest.current);
2819
+ instanceRef.current = instance;
2820
+ return () => {
2821
+ instanceRef.current = null;
2822
+ instance.destroy();
2823
+ };
2824
+ }, [midiAccess]);
2825
+ useEffect(() => {
2826
+ latest.current = handlers;
2827
+ instanceRef.current?.update(handlers);
2828
+ });
2232
2829
  }
2233
2830
  //#endregion
2234
2831
  //#region src/hooks/useMIDIMessage.ts
2235
2832
  /**
2236
2833
  * Hooks for when you want to process MIDI events in more detail than useMIDIInput.
2834
+ *
2835
+ * The handler is read fresh on every event, so writing it inline is fine: the
2836
+ * listeners are attached once per `midiAccess` and stay attached, and devices
2837
+ * plugged in later are picked up.
2237
2838
  */
2238
2839
  function useMIDIMessage(midiAccess, onMIDIMessage) {
2840
+ const latest = useRef(onMIDIMessage);
2841
+ const instanceRef = useRef(null);
2239
2842
  useEffect(() => {
2240
- const instance = createMIDIMessage(midiAccess, onMIDIMessage);
2241
- return () => instance.destroy();
2242
- }, [midiAccess, onMIDIMessage]);
2843
+ const instance = createMIDIMessage(midiAccess, (event) => latest.current(event));
2844
+ instanceRef.current = instance;
2845
+ return () => {
2846
+ instanceRef.current = null;
2847
+ instance.destroy();
2848
+ };
2849
+ }, [midiAccess]);
2850
+ useEffect(() => {
2851
+ latest.current = onMIDIMessage;
2852
+ });
2243
2853
  }
2244
2854
  //#endregion
2245
- export { AnimationCanvas, Knob, NOT_SUPPORTED, NumberInput, PERMISSION_DENIED, Piano, PointsEditor, SHORTCUTS, Slider, XYPad, clampPoint, getNoteRangeArray, useAnimationFrame, useDrag, useDragWithElement, useEventListener, useInterval, useLongPress, useMIDIAccess, useMIDIInput, useMIDIMessage, useSliderContext, useWheel };
2855
+ export { AnimationCanvas, DEFAULT_DRAG_SENSITIVITY, DEFAULT_KEYBOARD_OPTIONS, DEFAULT_WHEEL_OPTIONS, Knob, NOT_SUPPORTED, NumberInput, PERMISSION_DENIED, PITCH_BEND_CENTER, Piano, PointsEditor, SHORTCUTS, Slider, UNAVAILABLE, XYPad, clampPoint, useAnimationFrame, useDrag, useDragValue, useEventListener, useInterval, useKnobContext, useLongPress, useMIDIAccess, useMIDIInput, useMIDIMessage, useNumberInputContext, usePointsEditorContext, useSliderContext, useWheel, useXYPadContext };
2246
2856
 
2247
2857
  //# sourceMappingURL=index.js.map