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