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