@tremolo-ui/react 0.5.0 → 0.6.0

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