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