@tremolo-ui/react 0.7.0 → 0.8.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 (47) hide show
  1. package/dist/index.cjs +346 -1015
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +76 -221
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.ts +76 -221
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +333 -978
  8. package/dist/index.js.map +1 -1
  9. package/package.json +3 -3
  10. package/src/components/DropZone/index.tsx +2 -1
  11. package/src/components/FileInput/index.tsx +8 -13
  12. package/src/components/Knob/ActiveLine.tsx +5 -3
  13. package/src/components/Knob/InactiveLine.tsx +6 -4
  14. package/src/components/Knob/SVGRoot.tsx +3 -3
  15. package/src/components/Knob/Thumb.tsx +3 -2
  16. package/src/components/Knob/context.tsx +3 -74
  17. package/src/components/Knob/index.tsx +19 -16
  18. package/src/components/NumberInput/InputField.tsx +12 -27
  19. package/src/components/NumberInput/Stepper.tsx +49 -97
  20. package/src/components/NumberInput/StepperButton.tsx +3 -4
  21. package/src/components/NumberInput/context.tsx +2 -1
  22. package/src/components/NumberInput/index.tsx +40 -71
  23. package/src/components/Piano/index.tsx +35 -142
  24. package/src/components/PointsEditor/Container.tsx +15 -26
  25. package/src/components/PointsEditor/Point.tsx +38 -85
  26. package/src/components/PointsEditor/context.tsx +5 -56
  27. package/src/components/PointsEditor/index.tsx +29 -311
  28. package/src/components/Slider/Marks.tsx +3 -5
  29. package/src/components/Slider/MarksOption.tsx +7 -18
  30. package/src/components/Slider/Track.tsx +2 -1
  31. package/src/components/Slider/index.tsx +29 -28
  32. package/src/components/XYPad/Area.tsx +27 -49
  33. package/src/components/XYPad/Thumb.tsx +3 -1
  34. package/src/components/XYPad/context.tsx +1 -3
  35. package/src/components/XYPad/index.tsx +33 -46
  36. package/src/components/_util/VisuallyHiddenRangeInput.tsx +1 -1
  37. package/src/hooks/_internal/useCheckSteps.ts +9 -146
  38. package/src/hooks/useLongPress.ts +35 -51
  39. package/src/hooks/useMIDIAccess.ts +0 -8
  40. package/src/hooks/useMIDIInput.ts +0 -3
  41. package/src/index.ts +2 -27
  42. package/src/components/Piano/keyboard-shortcuts.ts +0 -61
  43. package/src/components/Slider/type.ts +0 -46
  44. package/src/components/_util/css-length.ts +0 -13
  45. package/src/components/_util/decimal-digits.ts +0 -23
  46. package/src/components/_util/visually-hidden.ts +0 -25
  47. package/src/input-event.ts +0 -32
package/dist/index.cjs CHANGED
@@ -192,31 +192,6 @@ const DropZone = { Root: /* @__PURE__ */ (0, react.forwardRef)(({ accept, multip
192
192
  });
193
193
  }) };
194
194
  //#endregion
195
- //#region src/components/_util/visually-hidden.ts
196
- /**
197
- * Take an element out of sight while leaving it in the accessibility tree and
198
- * in the tab order.
199
- *
200
- * `display: none` and `visibility: hidden` would remove it from both, and the
201
- * native control underneath a headless component is what carries the ARIA and
202
- * the keyboard behaviour. `pointer-events: none` is safe because nothing is
203
- * ever clicked here directly: a `<label>` forwards its click, and a drag is
204
- * handled by the part that is visible.
205
- */
206
- const visuallyHiddenStyle = {
207
- position: "absolute",
208
- width: 1,
209
- height: 1,
210
- padding: 0,
211
- margin: -1,
212
- overflow: "hidden",
213
- clip: "rect(0, 0, 0, 0)",
214
- clipPath: "inset(50%)",
215
- whiteSpace: "nowrap",
216
- border: 0,
217
- pointerEvents: "none"
218
- };
219
- //#endregion
220
195
  //#region src/components/FileInput/context.tsx
221
196
  const FileInputContext = /* @__PURE__ */ (0, react.createContext)(null);
222
197
  const FileInputProvider = FileInputContext.Provider;
@@ -256,14 +231,10 @@ const FileInput = {
256
231
  disabled
257
232
  }), [inputId, disabled]);
258
233
  function handleChange(event) {
259
- const picked = Array.from(event.target.files ?? []);
234
+ const { accepted, rejected } = (0, _tremolo_ui_dom.partitionByAccept)(Array.from(event.target.files ?? []), accept);
260
235
  event.target.value = "";
261
- const accepted = [];
262
- const rejected = [];
263
- for (const file of picked) if ((0, _tremolo_ui_dom.matchesAccept)(file, accept)) accepted.push(file);
264
- else rejected.push(file);
265
- if (rejected.length > 0) onReject?.(rejected);
266
236
  if (accepted.length > 0) onChange?.(accepted);
237
+ if (rejected.length > 0) onReject?.(rejected);
267
238
  }
268
239
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FileInputProvider, {
269
240
  value: context,
@@ -277,7 +248,7 @@ const FileInput = {
277
248
  accept,
278
249
  multiple,
279
250
  disabled,
280
- style: visuallyHiddenStyle,
251
+ style: _tremolo_ui_dom.visuallyHiddenStyle,
281
252
  "aria-label": ariaLabel,
282
253
  "aria-labelledby": ariaLabelledby,
283
254
  "aria-describedby": ariaDescribedby,
@@ -290,113 +261,23 @@ const FileInput = {
290
261
  };
291
262
  //#endregion
292
263
  //#region src/hooks/_internal/useCheckSteps.ts
293
- /** Positions probed across the travel. The ends are left out so that the
294
- * clamp at `min` and `max` cannot be mistaken for a press that does nothing. */
295
- const PROBES = [
296
- .1,
297
- .2,
298
- .3,
299
- .4,
300
- .5,
301
- .6,
302
- .7,
303
- .8,
304
- .9
305
- ];
306
- const NONE = {
307
- shiftKey: false,
308
- altKey: false,
309
- ctrlKey: false,
310
- metaKey: false
311
- };
312
- const MODIFIER_STATE = {
313
- shift: {
314
- ...NONE,
315
- shiftKey: true
316
- },
317
- alt: {
318
- ...NONE,
319
- altKey: true
320
- },
321
- ctrl: {
322
- ...NONE,
323
- ctrlKey: true
324
- },
325
- meta: {
326
- ...NONE,
327
- metaKey: true
328
- }
329
- };
330
- /** Each entry of a modifier-aware input option, as a name and held keys. */
331
- function entries(options) {
332
- if (Array.isArray(options)) return [{
333
- name: "",
334
- modifiers: NONE
335
- }];
336
- return Object.keys(options).map((key) => ({
337
- name: key === "default" ? "" : key,
338
- modifiers: MODIFIER_STATE[key] ?? NONE
339
- }));
340
- }
341
- /**
342
- * Press every entry of `options` at nine points along the travel and report
343
- * whether anything came of it.
344
- *
345
- * Run against `applyDelta` itself rather than against a reading of `step`:
346
- * the whole point is that the amount, the step and the scale interact, and
347
- * the pipeline is the only thing that knows how.
348
- */
349
- function probe(options, range, format) {
350
- const { min, max, scale = _tremolo_ui_functions.linearScale } = range;
351
- const outcome = {
352
- moved: false,
353
- visible: false
354
- };
355
- for (const { modifiers } of entries(options)) for (const position of PROBES) {
356
- const value = scale.denormalize(position, min, max);
357
- const up = (0, _tremolo_ui_dom.applyDelta)(value, 1, options, range, modifiers);
358
- const down = (0, _tremolo_ui_dom.applyDelta)(value, -1, options, range, modifiers);
359
- if (up !== value || down !== value) outcome.moved = true;
360
- if (!format) continue;
361
- const shown = format(value);
362
- if (format(up) !== shown || format(down) !== shown) outcome.visible = true;
363
- }
364
- return outcome;
365
- }
366
264
  /**
367
265
  * Warn, in development only, when a key press or a wheel notch cannot produce
368
- * a change the user can see.
369
- *
370
- * Two settings that are each fine on their own can cancel out, and nothing
371
- * fails when they do — the control simply sits there:
372
- *
373
- * - **`step` coarser than the amount.** `keyboard={['raw', 0.1]}` with
374
- * `step={1}` rounds every press straight back to where it started
375
- * - **the display coarser than the amount.** A `format` showing two decimals
376
- * of a kHz value cannot show a press worth 1 Hz
377
- *
378
- * The second is only reported when the press is invisible at *every* point
379
- * along the travel. A display that rounds is a deliberate choice and is
380
- * normally right — it is being too coarse everywhere that makes it a mistake.
266
+ * a change the user can see. The probing is `checkSteps` in the core.
381
267
  *
382
268
  * @internal
383
269
  */
384
- function useCheckSteps({ component, axis, range, keyboard, wheel, format }) {
270
+ function useCheckSteps(options) {
271
+ const { component, axis, range, keyboard, wheel } = options;
385
272
  const { min, max, step, scale } = range ?? {};
386
- const where = axis ? `${component} (${axis})` : component;
387
273
  (0, react.useEffect)(() => {
388
274
  try {
389
275
  if (process.env.NODE_ENV === "production") return;
390
- if (!range || min === void 0 || max === void 0 || !(min < max)) return;
391
- for (const [name, options] of [["keyboard", keyboard], ["wheel", wheel]]) {
392
- if (!options) continue;
393
- const { moved, visible } = probe(options, range, format);
394
- 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.");
395
- 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.`);
396
- }
276
+ for (const warning of (0, _tremolo_ui_dom.checkSteps)(options)) console.warn(warning);
397
277
  } catch {}
398
278
  }, [
399
- where,
279
+ component,
280
+ axis,
400
281
  min,
401
282
  max,
402
283
  step,
@@ -504,49 +385,6 @@ function useWheel(onWheel, { requireFocus = false, target } = {}) {
504
385
  return setNode;
505
386
  }
506
387
  //#endregion
507
- //#region src/input-event.ts
508
- /**
509
- * The keyboard amount used by Knob, NumberInput, Slider, and XYPad by default:
510
- * 1 per press in the units of the value, and 0.1 with shift held. That is one
511
- * `step` only while `step` is 1; a coarser `step` rounds 1 straight back, which
512
- * `useCheckSteps` warns about.
513
- *
514
- * A modifier entry is not snapped to `step`, which is what lets the finer
515
- * amount move at all — see `applyDelta` in `@tremolo-ui/dom`.
516
- */
517
- const DEFAULT_KEYBOARD_OPTIONS = {
518
- default: ["raw", 1],
519
- shift: ["raw", .1]
520
- };
521
- /**
522
- * The wheel amount used by Knob, NumberInput, Slider, and XYPad by default.
523
- *
524
- * Browsers turn shift+wheel into horizontal scrolling, which empties `deltaY`
525
- * and fills `deltaX`, so no modifier is bound here.
526
- */
527
- const DEFAULT_WHEEL_OPTIONS = ["raw", 1];
528
- /**
529
- * The drag sensitivity used by value controls by default. Shift makes the
530
- * same movement cover a tenth of the range, matching the arrow keys.
531
- */
532
- const DEFAULT_DRAG_SENSITIVITY = {
533
- default: 1,
534
- shift: .1
535
- };
536
- //#endregion
537
- //#region src/components/_util/css-length.ts
538
- /**
539
- * A number as a CSS length, for a custom property.
540
- *
541
- * React appends `px` to a bare number for the properties it knows take a
542
- * length, and a custom property is never one of them: `--size: 50` would come
543
- * out as the invalid `50`. Anything already a string is passed through, so
544
- * `'3rem'` and `'100%'` still work.
545
- */
546
- function cssLength(value) {
547
- return typeof value === "number" ? `${value}px` : value;
548
- }
549
- //#endregion
550
388
  //#region src/components/_util/Placement.tsx
551
389
  /**
552
390
  * The subcomponent that established the current placement, if any.
@@ -588,49 +426,8 @@ function useCheckPlacement(child, parent) {
588
426
  parent
589
427
  ]);
590
428
  }
591
- /**
592
- * 角度から円弧上の点を求める。
593
- *
594
- * 半径は線の太さの分だけ内側に取る必要があるが、太さは ActiveLine / InactiveLine が
595
- * それぞれ持つため、座標はストアで先に計算せず各コンポーネント側で求める。
596
- */
597
- function pointOnArc(angle, radius) {
598
- return {
599
- x: 50 + radius * Math.cos((0, _tremolo_ui_functions.radian)(angle - 90)),
600
- y: 50 + radius * Math.sin((0, _tremolo_ui_functions.radian)(angle - 90))
601
- };
602
- }
603
- /** 線の太さが viewBox からはみ出さないようにした半径 */
604
- function arcRadius(strokeWidth) {
605
- const width = typeof strokeWidth === "number" ? strokeWidth : Number.parseFloat(String(strokeWidth));
606
- return Number.isFinite(width) ? 50 - width / 2 : 50;
607
- }
608
- /** Build an SVG path for an arc, splitting full turns into drawable segments. */
609
- function arcPath(startAngle, endAngle, radius) {
610
- const start = pointOnArc(startAngle, radius);
611
- const sweep = endAngle - startAngle;
612
- const segmentCount = Math.max(1, Math.ceil(Math.abs(sweep) / 180));
613
- const segmentSweep = sweep / segmentCount;
614
- let path = `M ${start.x} ${start.y}`;
615
- for (let i = 1; i <= segmentCount; i += 1) {
616
- const end = pointOnArc(startAngle + segmentSweep * i, radius);
617
- path += ` A ${radius} ${radius} 0 0 ${segmentSweep >= 0 ? 1 : 0} ${end.x} ${end.y}`;
618
- }
619
- return path;
620
- }
621
- /** 設定から描画に必要な角度を導出する。レンダー中に呼ぶ。 */
622
- function calcAngles({ value, min, max, scale, startValue, angleRange }) {
623
- const p = scale.normalize(value, min, max);
624
- const s = scale.normalize(startValue, min, max);
625
- const r1 = -angleRange / 2;
626
- return {
627
- p,
628
- r1,
629
- r2: r1 + Math.min(p, s) * angleRange,
630
- r3: r1 + Math.max(p, s) * angleRange,
631
- r4: angleRange / 2
632
- };
633
- }
429
+ //#endregion
430
+ //#region src/components/Knob/context.tsx
634
431
  const KnobContext = /* @__PURE__ */ (0, react.createContext)(null);
635
432
  const KnobProvider = KnobContext.Provider;
636
433
  function useKnobContext(selector) {
@@ -644,10 +441,10 @@ function ActiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...pr
644
441
  useCheckPlacement("Knob.ActiveLine", "Knob.SVGRoot");
645
442
  const r2 = useKnobContext((s) => s.r2);
646
443
  const r3 = useKnobContext((s) => s.r3);
647
- const radius = arcRadius(strokeWidth);
444
+ const radius = (0, _tremolo_ui_dom.knobArcRadius)(strokeWidth);
648
445
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
649
446
  className,
650
- d: arcPath(r2, r3, radius),
447
+ d: (0, _tremolo_ui_dom.knobArcPath)(r2, r3, radius),
651
448
  fill: "none",
652
449
  stroke,
653
450
  strokeWidth,
@@ -665,17 +462,17 @@ function InactiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...
665
462
  const r2 = useKnobContext((s) => s.r2);
666
463
  const r3 = useKnobContext((s) => s.r3);
667
464
  const r4 = useKnobContext((s) => s.r4);
668
- const radius = arcRadius(strokeWidth);
465
+ const radius = (0, _tremolo_ui_dom.knobArcRadius)(strokeWidth);
669
466
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [startValue > min && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
670
467
  className,
671
- d: arcPath(r1, r2, radius),
468
+ d: (0, _tremolo_ui_dom.knobArcPath)(r1, r2, radius),
672
469
  fill: "none",
673
470
  stroke,
674
471
  strokeWidth,
675
472
  ...props
676
473
  }), startValue < max && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
677
474
  className,
678
- d: arcPath(r3, r4, radius),
475
+ d: (0, _tremolo_ui_dom.knobArcPath)(r3, r4, radius),
679
476
  fill: "none",
680
477
  stroke,
681
478
  strokeWidth,
@@ -686,7 +483,7 @@ function InactiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...
686
483
  //#region src/components/Knob/SVGRoot.tsx
687
484
  function SVGRoot({ children, style, ...props }) {
688
485
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
689
- viewBox: `0 0 100 100`,
486
+ viewBox: `0 0 ${_tremolo_ui_dom.KNOB_VIEWBOX_SIZE} ${_tremolo_ui_dom.KNOB_VIEWBOX_SIZE}`,
690
487
  style: {
691
488
  display: "block",
692
489
  ...style
@@ -716,7 +513,7 @@ function Thumb$2({ className, thumb = "currentColor", thumbLine = "currentColor"
716
513
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("line", {
717
514
  className: classes?.thumbLine,
718
515
  x1: "50%",
719
- y1: `${(100 - (0, _tremolo_ui_functions.clamp)(thumbSize, 0, 100)) / 2}%`,
516
+ y1: `${(_tremolo_ui_dom.KNOB_VIEWBOX_SIZE - (0, _tremolo_ui_functions.clamp)(thumbSize, 0, 100)) / 2}%`,
720
517
  x2: "50%",
721
518
  y2: `${thumbLineLength}%`,
722
519
  stroke: thumbLine,
@@ -740,7 +537,7 @@ const WHEEL_OPTIONS$2 = { requireFocus: true };
740
537
  * Interactive rotary knob component implemented in SVG.
741
538
  */
742
539
  const Knob = {
743
- Root: /* @__PURE__ */ (0, react.forwardRef)(({ value, min, max, step = 1, scale = _tremolo_ui_functions.linearScale, defaultValue = min, startValue = min, size, externalStyles: _externalStyles, wheel = DEFAULT_WHEEL_OPTIONS, keyboard = DEFAULT_KEYBOARD_OPTIONS, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, pointerLock = false, enableDoubleClickDefault = true, disabled = false, readonly = false, angleRange = 270, onChange, onKeyDown, onPointerDown, onDoubleClick, className, style, children, ...props }, forwardedRef) => {
540
+ Root: /* @__PURE__ */ (0, react.forwardRef)(({ value, min, max, step = 1, scale = _tremolo_ui_functions.linearScale, defaultValue = min, startValue = min, size, externalStyles: _externalStyles, wheel = _tremolo_ui_dom.DEFAULT_WHEEL_OPTIONS, keyboard = _tremolo_ui_dom.DEFAULT_KEYBOARD_OPTIONS, dragSensitivity = _tremolo_ui_dom.DEFAULT_DRAG_SENSITIVITY, pointerLock = false, enableDoubleClickDefault = true, disabled = false, readonly = false, angleRange = 270, onChange, onKeyDown, onPointerDown, onDoubleClick, className, style, children, ...props }, forwardedRef) => {
744
541
  const elmRef = (0, react.useRef)(null);
745
542
  const externalStyles = {
746
543
  ...defaultExternalStyles$2,
@@ -766,16 +563,10 @@ const Knob = {
766
563
  });
767
564
  const handleKeyDown = (0, react.useCallback)((event) => {
768
565
  if (!keyboard || !onChange || inactive) return;
769
- const key = event.key;
770
- if ([
771
- "ArrowRight",
772
- "ArrowLeft",
773
- "ArrowUp",
774
- "ArrowDown"
775
- ].includes(key)) {
776
- event.preventDefault();
777
- onChange((0, _tremolo_ui_dom.applyDelta)(value, key === "ArrowRight" || key === "ArrowUp" ? 1 : -1, keyboard, range, event));
778
- }
566
+ const direction = (0, _tremolo_ui_dom.arrowKeyDirection)(event.key);
567
+ if (direction === null) return;
568
+ event.preventDefault();
569
+ onChange((0, _tremolo_ui_dom.applyDelta)(value, direction, keyboard, range, event));
779
570
  }, [
780
571
  keyboard,
781
572
  onChange,
@@ -801,9 +592,11 @@ const Knob = {
801
592
  });
802
593
  const wheelRefCallback = useWheel((event) => {
803
594
  if (!wheel || inactive) return;
595
+ const direction = (0, _tremolo_ui_dom.wheelDirection)(event);
596
+ if (direction === null) return;
804
597
  event.preventDefault();
805
- if (!onChange || event.deltaY === 0) return;
806
- onChange((0, _tremolo_ui_dom.applyDelta)(value, -Math.sign(event.deltaY), wheel, range, event));
598
+ if (!onChange) return;
599
+ onChange((0, _tremolo_ui_dom.applyDelta)(value, direction, wheel, range, event));
807
600
  }, WHEEL_OPTIONS$2);
808
601
  const rootRefCallback = require_compose_refs.useComposedRefs(elmRef, dragRefCallback, wheelRefCallback);
809
602
  const context = (0, react.useMemo)(() => {
@@ -818,7 +611,7 @@ const Knob = {
818
611
  };
819
612
  return {
820
613
  ...config,
821
- ...calcAngles(config)
614
+ ...(0, _tremolo_ui_dom.knobAngles)(config)
822
615
  };
823
616
  }, [
824
617
  value,
@@ -855,7 +648,7 @@ const Knob = {
855
648
  "data-readonly": readonly ? "" : void 0,
856
649
  "data-dragging": dragging ? "" : void 0,
857
650
  style: {
858
- "--knob-size": cssLength(size),
651
+ "--knob-size": (0, _tremolo_ui_dom.cssLength)(size),
859
652
  ...style
860
653
  },
861
654
  onPointerDown,
@@ -892,82 +685,36 @@ function useStepperContext() {
892
685
  return (0, react.useContext)(StepperContext);
893
686
  }
894
687
  //#endregion
895
- //#region src/hooks/useEventListener.ts
896
- function useEventListener(target, event, handler, options) {
897
- const listener = useCallbackRef(handler);
898
- const cleanupRef = (0, react.useRef)(() => {});
899
- (0, react.useEffect)(() => {
900
- const node = typeof target === "function" ? target() : target ?? document;
901
- if (!node) return;
902
- node.addEventListener(event, listener, options);
903
- const cleanup = () => {
904
- node.removeEventListener(event, listener, options);
905
- };
906
- cleanupRef.current = cleanup;
907
- return () => {
908
- cleanup();
909
- if (cleanupRef.current === cleanup) cleanupRef.current = () => {};
910
- };
911
- }, [
912
- event,
913
- target,
914
- options,
915
- listener
916
- ]);
917
- return (0, react.useCallback)(() => cleanupRef.current(), []);
918
- }
919
- //#endregion
920
- //#region src/hooks/useInterval.ts
921
- function useInterval(callback, delay) {
922
- const savedCallback = (0, react.useRef)(callback);
688
+ //#region src/hooks/useLongPress.ts
689
+ /**
690
+ * Repeat `callback` while a pointer is held down: once on the press, then
691
+ * every `interval` after `initialDelay`. The repeat itself is
692
+ * `createLongPress` in `@tremolo-ui/dom`.
693
+ *
694
+ * Returns the function that starts a press, for an `onPointerDown`.
695
+ */
696
+ function useLongPress(callback, initialDelay = 500, interval = 40) {
697
+ const onPress = useCallbackRef(callback);
698
+ const instanceRef = (0, react.useRef)(null);
923
699
  (0, react.useEffect)(() => {
924
- savedCallback.current = callback;
925
- }, [callback]);
700
+ instanceRef.current?.update({
701
+ delay: initialDelay,
702
+ interval
703
+ });
704
+ });
926
705
  (0, react.useEffect)(() => {
927
- if (delay === null) return;
928
- const id = setInterval(() => {
929
- savedCallback.current();
930
- }, delay);
706
+ const instance = (0, _tremolo_ui_dom.createLongPress)({
707
+ onPress: () => onPress(),
708
+ delay: initialDelay,
709
+ interval
710
+ });
711
+ instanceRef.current = instance;
931
712
  return () => {
932
- clearInterval(id);
713
+ instanceRef.current = null;
714
+ instance.destroy();
933
715
  };
934
- }, [delay]);
935
- }
936
- //#endregion
937
- //#region src/hooks/useLongPress.ts
938
- function useLongPress(callback, initialDelay = 500, interval = 40) {
939
- const [pressed, setPressed] = (0, react.useState)(false);
940
- const [delay, setDelay] = (0, react.useState)(initialDelay);
941
- const activePointerId = (0, react.useRef)(null);
942
- const isPressed = (0, react.useRef)(false);
943
- useInterval(() => {
944
- callback();
945
- setDelay(interval);
946
- }, pressed ? delay : null);
947
- const stop = (0, react.useCallback)((pointerId) => {
948
- if (!isPressed.current) return;
949
- if (pointerId !== void 0 && activePointerId.current !== null && activePointerId.current !== pointerId) return;
950
- isPressed.current = false;
951
- activePointerId.current = null;
952
- setPressed(false);
953
- setDelay(initialDelay);
954
- }, [initialDelay]);
955
- useEventListener(globalThis.window, "pointerup", (event) => {
956
- stop(event.pointerId);
957
- });
958
- useEventListener(globalThis.window, "pointercancel", (event) => {
959
- stop(event.pointerId);
960
- });
961
- useEventListener(globalThis.window, "blur", () => {
962
- stop();
963
- });
964
- return (0, react.useCallback)((event) => {
965
- if (isPressed.current || event && event.button !== 0) return;
966
- isPressed.current = true;
967
- activePointerId.current = event?.pointerId ?? null;
968
- callback();
969
- setPressed(true);
970
- }, [callback]);
716
+ }, []);
717
+ return (0, react.useCallback)((event) => instanceRef.current?.start(event), []);
971
718
  }
972
719
  //#endregion
973
720
  //#region src/components/NumberInput/StepperButton.tsx
@@ -979,7 +726,7 @@ function StepperButton({ direction, variant, icon, className, style, children, o
979
726
  const { step, disabled, readonly, atMin, atMax, nudge } = useNumberInputContext();
980
727
  const stepper = useStepperContext();
981
728
  const press = useLongPress(() => {
982
- if (stepper?.draggingRef.current) return;
729
+ if (stepper?.moved()) return;
983
730
  nudge(direction, ["raw", step]);
984
731
  });
985
732
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -1038,21 +785,6 @@ function IncrementStepper(props) {
1038
785
  }
1039
786
  //#endregion
1040
787
  //#region src/components/NumberInput/InputField.tsx
1041
- /** The leading number of the displayed text, whatever the format put around it. */
1042
- const NUMBER_PREFIX = /^\s*-?[\d.,]*/;
1043
- const numberEnd = (text) => text.match(NUMBER_PREFIX)?.[0].length ?? text.length;
1044
- /**
1045
- * The index the caret is measured against: the decimal point, or where one
1046
- * would go if the number has none.
1047
- *
1048
- * Measuring from an end instead would slide the caret across a digit whenever
1049
- * the number changed length — `9.9` to `10.0` gains a character in front, `10`
1050
- * to `9` loses one — which is exactly what stepping does.
1051
- */
1052
- const decimalAnchor = (text) => {
1053
- const dot = text.indexOf(".");
1054
- return dot === -1 ? numberEnd(text) : dot;
1055
- };
1056
788
  /**
1057
789
  * The text field of a `NumberInput`, and the only place the value can be typed.
1058
790
  *
@@ -1062,7 +794,7 @@ const decimalAnchor = (text) => {
1062
794
  * How it behaves on focus, Enter and the arrow keys is set on `Root`
1063
795
  * (`selectOnFocus`, `unformatOnFocus`, `keepCaretOnStep`, `blurOnEnter`).
1064
796
  */
1065
- const InputField = /* @__PURE__ */ (0, react.forwardRef)(function InputField({ className, style, onFocus, onBlur, onKeyDown, ...props }, forwardedRef) {
797
+ const InputField = /* @__PURE__ */ (0, react.forwardRef)(function InputField({ className, style, onChange, onFocus, onBlur, onKeyDown, ...props }, forwardedRef) {
1066
798
  const { value, min, max, step, disabled, readonly, keyboard, selectOnFocus, unformatOnFocus, keepCaretOnStep, blurOnEnter, text, editing, outOfRange, setDraft, commitDraft, nudge, inputRef } = useNumberInputContext();
1067
799
  const [focused, setFocused] = (0, react.useState)(false);
1068
800
  const shown = unformatOnFocus && focused && !editing ? String(value) : text;
@@ -1071,7 +803,10 @@ const InputField = /* @__PURE__ */ (0, react.forwardRef)(function InputField({ c
1071
803
  const input = inputRef.current;
1072
804
  if (!input) return;
1073
805
  if (selectOnFocus === "all") input.setSelectionRange(0, input.value.length);
1074
- else input.setSelectionRange(0, input.value.match(NUMBER_PREFIX)?.[0].length ?? 0);
806
+ else {
807
+ const span = (0, _tremolo_ui_dom.numberSpan)(input.value);
808
+ if (span) input.setSelectionRange(span.start, span.end);
809
+ }
1075
810
  }, [
1076
811
  focused,
1077
812
  selectOnFocus,
@@ -1088,8 +823,7 @@ const InputField = /* @__PURE__ */ (0, react.forwardRef)(function InputField({ c
1088
823
  caretAfterStep.current = null;
1089
824
  const input = inputRef.current;
1090
825
  if (!input || input.value === pending.from) return;
1091
- const place = decimalAnchor(input.value) + pending.offset;
1092
- const caret = Math.max(0, Math.min(place, numberEnd(input.value)));
826
+ const caret = (0, _tremolo_ui_dom.caretAtDecimalOffset)(input.value, pending.offset);
1093
827
  input.setSelectionRange(caret, caret);
1094
828
  });
1095
829
  const composedRef = require_compose_refs.useComposedRefs(forwardedRef, inputRef);
@@ -1116,6 +850,7 @@ const InputField = /* @__PURE__ */ (0, react.forwardRef)(function InputField({ c
1116
850
  onChange: (event) => {
1117
851
  caretAfterStep.current = null;
1118
852
  setDraft(event.currentTarget.value);
853
+ onChange?.(event);
1119
854
  },
1120
855
  onFocus: (event) => {
1121
856
  setFocused(true);
@@ -1135,7 +870,7 @@ const InputField = /* @__PURE__ */ (0, react.forwardRef)(function InputField({ c
1135
870
  event.preventDefault();
1136
871
  const input = event.currentTarget;
1137
872
  if (keepCaretOnStep && input.selectionStart !== null) caretAfterStep.current = {
1138
- offset: input.selectionStart - decimalAnchor(input.value),
873
+ offset: (0, _tremolo_ui_dom.caretDecimalOffset)(input.value, input.selectionStart),
1139
874
  from: input.value
1140
875
  };
1141
876
  nudge(key === "ArrowUp" ? 1 : -1, keyboard, event);
@@ -1146,117 +881,54 @@ const InputField = /* @__PURE__ */ (0, react.forwardRef)(function InputField({ c
1146
881
  });
1147
882
  });
1148
883
  //#endregion
1149
- //#region src/hooks/useDrag.ts
1150
- /**
1151
- * Track a pointer drag on an element.
1152
- *
1153
- * @returns a ref callback to attach to the element being dragged
1154
- */
1155
- function useDrag({ threshold = 1, cursor, pointerLock, onDrag, onDragStart, onDragEnd }) {
1156
- const dragHandler = useCallbackRef(onDrag);
1157
- const dragStartHandler = useCallbackRef(onDragStart);
1158
- const dragEndHandler = useCallbackRef(onDragEnd);
1159
- const [node, setNode] = (0, react.useState)(null);
1160
- (0, react.useEffect)(() => {
1161
- if (!node) return;
1162
- const instance = (0, _tremolo_ui_dom.createDrag)(node, {
1163
- threshold,
1164
- cursor,
1165
- pointerLock,
1166
- onDragStart: (state) => dragStartHandler(state),
1167
- onDrag: (state) => dragHandler(state.x, state.y, state.deltaX, state.deltaY, state),
1168
- onDragEnd: (state) => dragEndHandler(state)
1169
- });
1170
- return () => instance.destroy();
1171
- }, [
1172
- node,
1173
- threshold,
1174
- cursor,
1175
- pointerLock,
1176
- dragHandler,
1177
- dragStartHandler,
1178
- dragEndHandler
1179
- ]);
1180
- return setNode;
1181
- }
1182
- //#endregion
1183
884
  //#region src/components/NumberInput/Stepper.tsx
1184
885
  /**
1185
886
  * The area the steppers sit in, and a drag handle in its own right: dragging it
1186
- * up and down moves the value one `step` every `drag` pixels.
887
+ * up and down moves the value one `step` every `drag` pixels. The counting is
888
+ * `createStepperDrag` in the core.
1187
889
  *
1188
890
  * The drag lives here rather than on `InputField` because `createDrag` turns
1189
891
  * off text selection on whatever element it is attached to.
1190
892
  */
1191
893
  const Stepper = /* @__PURE__ */ (0, react.forwardRef)(function Stepper({ className, style, children, ...props }, forwardedRef) {
1192
- const { value, step, disabled, readonly, drag, dragSensitivity, pointerLock, rawRange, changeValue } = useNumberInputContext();
1193
- const inactive = disabled || readonly;
1194
- /**
1195
- * The sensitivity as an amount per `drag` pixels, so that the drag goes
1196
- * through the same `applyDelta` the wheel and the arrow keys do. Carrying
1197
- * the modifier map over rather than resolving it here is what keeps `step`
1198
- * out of the pipeline for a modifier entry — naming one is a request to move
1199
- * off the grid.
1200
- */
1201
- const dragOptions = (0, react.useMemo)(() => (0, _tremolo_ui_dom.mapModifier)(dragSensitivity, (factor) => ["raw", step * factor]), [dragSensitivity, step]);
1202
- /**
1203
- * Set once the drag has actually moved the value, so that the press-and-hold
1204
- * repeat of a stepper stands down and leaves the value to the drag. A ref,
1205
- * because it is read from inside the repeat rather than rendered.
1206
- */
1207
- const draggingRef = (0, react.useRef)(false);
1208
- /**
1209
- * Where the drag started, taken on the first move rather than on pointer
1210
- * down: the steppers act on pointer down, so by then the value may already
1211
- * have been nudged once, and the drag should carry on from there.
1212
- */
1213
- const originRef = (0, react.useRef)(null);
1214
- /**
1215
- * Which modifier the drag is currently counting at, and where the previous
1216
- * event was — a key produces no pointer event of its own, so a change is
1217
- * only seen on the next move and has to be dated back to the one before it.
1218
- */
1219
- const factorRef = (0, react.useRef)(1);
1220
- const previousYRef = (0, react.useRef)(0);
1221
- const dragRefCallback = useDrag({
1222
- threshold: 1,
1223
- cursor: inactive ? void 0 : "ns-resize",
1224
- pointerLock: inactive ? false : pointerLock,
1225
- onDragStart: (state) => {
1226
- originRef.current = null;
1227
- draggingRef.current = false;
1228
- factorRef.current = (0, _tremolo_ui_dom.selectModifier)(dragSensitivity, state.event).value;
1229
- },
1230
- onDrag: (_x, y, _dx, _dy, state) => {
1231
- if (inactive || drag === null) return;
1232
- if (!originRef.current) {
1233
- originRef.current = {
1234
- y,
1235
- value
1236
- };
1237
- previousYRef.current = y;
1238
- return;
1239
- }
1240
- const factor = (0, _tremolo_ui_dom.selectModifier)(dragSensitivity, state.event).value;
1241
- if (factor !== factorRef.current) {
1242
- originRef.current = {
1243
- y: previousYRef.current,
1244
- value
1245
- };
1246
- factorRef.current = factor;
1247
- }
1248
- previousYRef.current = y;
1249
- const steps = Math.round(-(y - originRef.current.y) / drag);
1250
- if (steps === 0) return;
1251
- draggingRef.current = true;
1252
- changeValue((0, _tremolo_ui_dom.applyDelta)(originRef.current.value, steps, dragOptions, rawRange, state.event));
1253
- },
1254
- onDragEnd: () => {
1255
- draggingRef.current = false;
1256
- }
894
+ const { value, disabled, readonly, drag, dragSensitivity, pointerLock, rawRange, changeValue } = useNumberInputContext();
895
+ const enabled = drag !== null && !disabled && !readonly;
896
+ const [node, setNode] = (0, react.useState)(null);
897
+ const instanceRef = (0, react.useRef)(null);
898
+ const latest = (0, react.useRef)({
899
+ value,
900
+ changeValue
901
+ });
902
+ const options = {
903
+ range: rawRange,
904
+ pixels: drag ?? 1,
905
+ sensitivity: dragSensitivity,
906
+ pointerLock
907
+ };
908
+ const optionsRef = (0, react.useRef)(options);
909
+ (0, react.useEffect)(() => {
910
+ latest.current = {
911
+ value,
912
+ changeValue
913
+ };
914
+ optionsRef.current = options;
915
+ instanceRef.current?.update(options);
1257
916
  });
1258
- const composedRef = require_compose_refs.useComposedRefs(forwardedRef, drag !== null && !inactive ? dragRefCallback : void 0);
1259
- const context = (0, react.useMemo)(() => ({ draggingRef }), []);
917
+ (0, react.useEffect)(() => {
918
+ if (!node || !enabled) return;
919
+ const instance = (0, _tremolo_ui_dom.createStepperDrag)(node, {
920
+ ...optionsRef.current,
921
+ getValue: () => latest.current.value,
922
+ onChange: (next) => latest.current.changeValue(next)
923
+ });
924
+ instanceRef.current = instance;
925
+ return () => {
926
+ instanceRef.current = null;
927
+ instance.destroy();
928
+ };
929
+ }, [node, enabled]);
930
+ const composedRef = require_compose_refs.useComposedRefs(forwardedRef, setNode);
931
+ const context = (0, react.useMemo)(() => ({ moved: () => instanceRef.current?.moved() ?? false }), []);
1260
932
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StepperProvider, {
1261
933
  value: context,
1262
934
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -1270,22 +942,12 @@ const Stepper = /* @__PURE__ */ (0, react.forwardRef)(function Stepper({ classNa
1270
942
  });
1271
943
  //#endregion
1272
944
  //#region src/components/NumberInput/index.tsx
1273
- /**
1274
- * A number at the start of the text, and nothing read after it. A half-typed
1275
- * entry still yields the number in front of it, but text with no number is
1276
- * `NaN` rather than 0, so that "unreadable" and "the user typed 0" stay apart.
1277
- */
1278
- const LEADING_NUMBER = /^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)/;
1279
945
  const defaultFormat = (value) => String(value);
1280
- const defaultParse = (text) => {
1281
- const match = text.match(LEADING_NUMBER);
1282
- return match ? Number(match[1]) : NaN;
1283
- };
1284
946
  /**
1285
947
  * Input with some useful functions for entering numerical values.
1286
948
  */
1287
949
  const NumberInput = {
1288
- Root: /* @__PURE__ */ (0, react.forwardRef)(({ value, min, max, step = 1, scale = _tremolo_ui_functions.linearScale, format: formatProp, parse: parseProp, clampValue = true, wheel = DEFAULT_WHEEL_OPTIONS, keyboard = DEFAULT_KEYBOARD_OPTIONS, drag = 1, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, pointerLock = false, selectOnFocus = "none", unformatOnFocus = false, keepCaretOnStep = false, blurOnEnter = true, disabled = false, readonly = false, className, style, onChange, children, ...props }, forwardedRef) => {
950
+ Root: /* @__PURE__ */ (0, react.forwardRef)(({ value, min, max, step = 1, scale = _tremolo_ui_functions.linearScale, format: formatProp, parse: parseProp, clampValue = true, wheel = _tremolo_ui_dom.DEFAULT_WHEEL_OPTIONS, keyboard = _tremolo_ui_dom.DEFAULT_KEYBOARD_OPTIONS, drag = 1, dragSensitivity = _tremolo_ui_dom.DEFAULT_DRAG_SENSITIVITY, pointerLock = false, selectOnFocus = "none", unformatOnFocus = false, keepCaretOnStep = false, blurOnEnter = true, disabled = false, readonly = false, className, style, onChange, children, ...props }, forwardedRef) => {
1289
951
  const inputRef = (0, react.useRef)(null);
1290
952
  /**
1291
953
  * The text being typed. The only state here: it is not a copy of `value`,
@@ -1294,24 +956,13 @@ const NumberInput = {
1294
956
  const [draft, setDraft] = (0, react.useState)(null);
1295
957
  const inactive = disabled || readonly;
1296
958
  const format = formatProp ?? defaultFormat;
1297
- const parse = parseProp ?? defaultParse;
1298
- const range = (0, react.useMemo)(() => ({
1299
- min: (clampValue ? min : void 0) ?? Number.MIN_SAFE_INTEGER,
1300
- max: (clampValue ? max : void 0) ?? Number.MAX_SAFE_INTEGER,
1301
- step,
1302
- scale
1303
- }), [
1304
- clampValue,
959
+ const parse = parseProp ?? _tremolo_ui_dom.parseNumberText;
960
+ const { normalized: range, raw: rawRange } = (0, react.useMemo)(() => (0, _tremolo_ui_dom.numberInputRanges)({
1305
961
  min,
1306
962
  max,
1307
963
  step,
1308
- scale
1309
- ]);
1310
- const rawRange = (0, react.useMemo)(() => ({
1311
- min: (clampValue ? min : void 0) ?? -Number.MAX_VALUE,
1312
- max: (clampValue ? max : void 0) ?? Number.MAX_VALUE,
1313
- step,
1314
- scale
964
+ scale,
965
+ clampValue
1315
966
  }), [
1316
967
  clampValue,
1317
968
  min,
@@ -1319,6 +970,10 @@ const NumberInput = {
1319
970
  step,
1320
971
  scale
1321
972
  ]);
973
+ const ranges = (0, react.useMemo)(() => ({
974
+ normalized: range,
975
+ raw: rawRange
976
+ }), [range, rawRange]);
1322
977
  useCheckSteps({
1323
978
  component: "NumberInput",
1324
979
  range: (0, react.useMemo)(() => min !== void 0 && max !== void 0 && min < max ? {
@@ -1338,7 +993,13 @@ const NumberInput = {
1338
993
  });
1339
994
  const text = draft ?? format(value);
1340
995
  const editing = draft !== null;
1341
- const outOfRange = !editing && (min !== void 0 && value < min || max !== void 0 && value > max);
996
+ const bounds = (0, _tremolo_ui_dom.numberInputBounds)(value, {
997
+ min,
998
+ max,
999
+ clampValue
1000
+ });
1001
+ const { atMin, atMax } = bounds;
1002
+ const outOfRange = !editing && bounds.outOfRange;
1342
1003
  const handleDraft = (0, react.useCallback)((next) => {
1343
1004
  if (inactive) return;
1344
1005
  setDraft(next);
@@ -1360,15 +1021,13 @@ const NumberInput = {
1360
1021
  ]);
1361
1022
  const commitDraft = (0, react.useCallback)(() => {
1362
1023
  if (draft === null || inactive) return;
1363
- const parsed = parse(draft);
1364
- if (!Number.isFinite(parsed)) {
1365
- setDraft(null);
1366
- return;
1367
- }
1368
- let committed = parsed;
1369
- if (clampValue && min !== void 0) committed = Math.max(committed, min);
1370
- if (clampValue && max !== void 0) committed = Math.min(committed, max);
1371
- changeValue(committed);
1024
+ const committed = (0, _tremolo_ui_dom.commitNumberInputText)(draft, parse, {
1025
+ min,
1026
+ max,
1027
+ clampValue
1028
+ });
1029
+ if (committed === null) setDraft(null);
1030
+ else changeValue(committed);
1372
1031
  }, [
1373
1032
  draft,
1374
1033
  inactive,
@@ -1379,18 +1038,17 @@ const NumberInput = {
1379
1038
  changeValue
1380
1039
  ]);
1381
1040
  const nudge = (0, react.useCallback)((direction, option, modifiers) => {
1382
- const [mode] = (0, _tremolo_ui_dom.selectModifier)(option, modifiers).value;
1383
- changeValue((0, _tremolo_ui_dom.applyDelta)(value, direction, option, mode === "raw" ? rawRange : range, modifiers));
1041
+ changeValue((0, _tremolo_ui_dom.nudgeNumberInput)(value, direction, option, ranges, modifiers));
1384
1042
  }, [
1385
1043
  changeValue,
1386
1044
  value,
1387
- rawRange,
1388
- range
1045
+ ranges
1389
1046
  ]);
1390
1047
  const wheelRefCallback = useWheel((event) => {
1391
- if (!wheel || inactive || event.deltaY === 0) return;
1048
+ const direction = (0, _tremolo_ui_dom.wheelDirection)(event);
1049
+ if (!wheel || inactive || direction === null) return;
1392
1050
  event.preventDefault();
1393
- nudge(-Math.sign(event.deltaY), wheel, event);
1051
+ nudge(direction, wheel, event);
1394
1052
  }, { requireFocus: true });
1395
1053
  const context = (0, react.useMemo)(() => ({
1396
1054
  value,
@@ -1413,8 +1071,8 @@ const NumberInput = {
1413
1071
  unformatOnFocus,
1414
1072
  keepCaretOnStep,
1415
1073
  blurOnEnter,
1416
- atMin: clampValue && min !== void 0 && value <= min,
1417
- atMax: clampValue && max !== void 0 && value >= max,
1074
+ atMin,
1075
+ atMax,
1418
1076
  drag,
1419
1077
  format,
1420
1078
  parse,
@@ -1438,6 +1096,8 @@ const NumberInput = {
1438
1096
  text,
1439
1097
  editing,
1440
1098
  outOfRange,
1099
+ atMin,
1100
+ atMax,
1441
1101
  drag,
1442
1102
  dragSensitivity,
1443
1103
  pointerLock,
@@ -1480,78 +1140,15 @@ const NumberInput = {
1480
1140
  IncrementStepper,
1481
1141
  DecrementStepper
1482
1142
  };
1483
- //#endregion
1484
- //#region src/components/Piano/keyboard-shortcuts.ts
1485
- /**
1486
- * Ready-made keyboard layouts. Both assume `noteRange.first` is a C.
1487
- */
1488
- const SHORTCUTS = {
1489
- /** Every semitone from C, over the two rows of a QWERTY keyboard. */
1490
- HOME_ROW: { keys: [
1491
- "a",
1492
- "w",
1493
- "s",
1494
- "e",
1495
- "d",
1496
- "f",
1497
- "t",
1498
- "g",
1499
- "y",
1500
- "h",
1501
- "u",
1502
- "j",
1503
- "k",
1504
- "o",
1505
- "l",
1506
- "p",
1507
- ";"
1508
- ] },
1509
- /** The white keys only, on the home row. Black keys have no shortcut. */
1510
- HOME_ROW_NATURAL: { keys: [
1511
- "a",
1512
- "",
1513
- "s",
1514
- "",
1515
- "d",
1516
- "f",
1517
- "",
1518
- "g",
1519
- "",
1520
- "h",
1521
- "",
1522
- "j",
1523
- "k",
1524
- "",
1525
- "l",
1526
- "",
1527
- ";"
1528
- ] }
1529
- };
1530
- //#endregion
1531
- //#region src/components/Piano/index.tsx
1532
- function isEditableTarget(target) {
1533
- if (!(target instanceof HTMLElement)) return false;
1534
- if (target.matches("input, textarea, select")) return true;
1535
- for (let element = target; element;) {
1536
- const contentEditable = element.getAttribute("contenteditable");
1537
- if (contentEditable !== null) return contentEditable.toLowerCase() !== "false";
1538
- element = element.parentElement;
1539
- }
1540
- return false;
1541
- }
1542
- function shortcutKey(event) {
1543
- return event.code || event.key;
1544
- }
1545
1143
  /**
1546
1144
  * Customizable piano component.
1547
1145
  */
1548
- const Piano = { Root: /* @__PURE__ */ (0, react.forwardRef)(function Root({ noteRange, glissando = true, midiMax = 127, keyboardShortcuts, keyboardShortcutsScope = "root", resizable = false, whiteKeyWidth = 40, keyGap = 1, blackKeyWidthRatio = .65, blackKeyHeightRatio = .6, height, style, className, label, keyProps, classes, onPlayNote, onStopNote, ...props }, forwardedRef) {
1146
+ const Piano = { Root: /* @__PURE__ */ (0, react.forwardRef)(function Root({ noteRange, glissando = true, midiMax = 127, keyboardShortcuts, keyboardShortcutsScope = "root", resizable = false, whiteKeyWidth = 40, keyGap = 1, blackKeyWidthRatio = .65, blackKeyHeightRatio = .6, style, className, label, keyProps, classes, onPlayNote, onStopNote, ...props }, forwardedRef) {
1549
1147
  const [node, setNode] = (0, react.useState)(null);
1550
1148
  const [activeNotes, setActiveNotes] = (0, react.useState)([]);
1551
1149
  /** Set while `resizable` is on, measured from the parent. */
1552
1150
  const [resizedKeyWidth, setResizedKeyWidth] = (0, react.useState)(whiteKeyWidth);
1553
1151
  const notes = (0, react.useMemo)(() => (0, _tremolo_ui_dom.getNoteRangeArray)(noteRange), [noteRange]);
1554
- const whiteKeyCount = (0, react.useMemo)(() => notes.filter(_tremolo_ui_functions.isWhiteKey).length, [notes]);
1555
1152
  const layout = (0, react.useMemo)(() => ({
1556
1153
  noteRange,
1557
1154
  whiteKeyWidth: resizable ? resizedKeyWidth : whiteKeyWidth,
@@ -1571,21 +1168,20 @@ const Piano = { Root: /* @__PURE__ */ (0, react.forwardRef)(function Root({ note
1571
1168
  layout,
1572
1169
  glissando,
1573
1170
  midiMax,
1171
+ keyboardShortcuts,
1172
+ keyboardShortcutsScope,
1574
1173
  onPlayNote,
1575
1174
  onStopNote
1576
1175
  });
1577
1176
  const instanceRef = (0, react.useRef)(null);
1578
- const shortcutNotes = (0, react.useRef)(/* @__PURE__ */ new Map());
1579
- const releaseShortcutNotes = (0, react.useCallback)(() => {
1580
- for (const { note, source } of shortcutNotes.current.values()) instanceRef.current?.noteOff(note, { source });
1581
- shortcutNotes.current.clear();
1582
- }, []);
1583
1177
  (0, react.useEffect)(() => {
1584
1178
  if (!node) return;
1585
1179
  const instance = (0, _tremolo_ui_dom.createPianoInput)(node, {
1586
1180
  layout: latest.current.layout,
1587
1181
  glissando: latest.current.glissando,
1588
1182
  midiMax: latest.current.midiMax,
1183
+ keyboardShortcuts: latest.current.keyboardShortcuts,
1184
+ keyboardShortcutsScope: latest.current.keyboardShortcutsScope,
1589
1185
  onPlayNote: (note, velocity) => latest.current.onPlayNote?.(note, velocity),
1590
1186
  onStopNote: (note) => latest.current.onStopNote?.(note),
1591
1187
  onActiveNotesChange: setActiveNotes
@@ -1601,86 +1197,44 @@ const Piano = { Root: /* @__PURE__ */ (0, react.forwardRef)(function Root({ note
1601
1197
  layout,
1602
1198
  glissando,
1603
1199
  midiMax,
1200
+ keyboardShortcuts,
1201
+ keyboardShortcutsScope,
1604
1202
  onPlayNote,
1605
1203
  onStopNote
1606
1204
  };
1607
1205
  instanceRef.current?.update({
1608
1206
  layout,
1609
1207
  glissando,
1610
- midiMax
1208
+ midiMax,
1209
+ keyboardShortcuts,
1210
+ keyboardShortcutsScope
1611
1211
  });
1612
1212
  });
1213
+ const { first, last } = noteRange;
1613
1214
  (0, react.useEffect)(() => {
1614
1215
  if (!resizable || !node) return;
1615
1216
  const parent = node.parentElement;
1616
1217
  if (!parent) throw new Error("doesn't have a parent element.");
1617
1218
  const resizeObserver = new ResizeObserver(() => {
1618
- setResizedKeyWidth(node.clientWidth / Math.max(whiteKeyCount, 1) - keyGap);
1219
+ setResizedKeyWidth((0, _tremolo_ui_dom.fitWhiteKeyWidth)(node.clientWidth, {
1220
+ noteRange: {
1221
+ first,
1222
+ last
1223
+ },
1224
+ keyGap,
1225
+ blackKeyWidthRatio
1226
+ }));
1619
1227
  });
1620
1228
  resizeObserver.observe(parent);
1621
1229
  return () => resizeObserver.disconnect();
1622
1230
  }, [
1623
1231
  resizable,
1624
1232
  node,
1625
- whiteKeyCount,
1626
- keyGap
1627
- ]);
1628
- const shortcutKeys = keyboardShortcuts?.keys;
1629
- const hasShortcuts = shortcutKeys !== void 0;
1630
- const shortcutMapping = shortcutKeys?.join("\0");
1631
- (0, react.useEffect)(() => releaseShortcutNotes, [
1632
- releaseShortcutNotes,
1633
- shortcutMapping,
1634
- keyboardShortcutsScope,
1635
- noteRange.first,
1636
- noteRange.last
1637
- ]);
1638
- /** The note a shortcut key plays, or null when it has none. */
1639
- function shortcutNote(key) {
1640
- if (!shortcutKeys || key === "") return null;
1641
- const index = shortcutKeys.indexOf(key);
1642
- const note = noteRange.first + index;
1643
- return index === -1 || note > noteRange.last ? null : note;
1644
- }
1645
- const shortcutTarget = (0, react.useMemo)(() => () => {
1646
- if (!hasShortcuts) return null;
1647
- return keyboardShortcutsScope === "window" ? globalThis.window : node;
1648
- }, [
1649
- hasShortcuts,
1650
- keyboardShortcutsScope,
1651
- node
1652
- ]);
1653
- const focusOutTarget = (0, react.useMemo)(() => () => hasShortcuts && keyboardShortcutsScope === "root" ? node : null, [
1654
- hasShortcuts,
1655
- keyboardShortcutsScope,
1656
- node
1233
+ first,
1234
+ last,
1235
+ keyGap,
1236
+ blackKeyWidthRatio
1657
1237
  ]);
1658
- const windowBlurTarget = (0, react.useMemo)(() => () => hasShortcuts ? globalThis.window : null, [hasShortcuts]);
1659
- useEventListener(shortcutTarget, "keydown", (e) => {
1660
- const key = shortcutKey(e);
1661
- if (e.repeat || shortcutNotes.current.has(key)) return;
1662
- if (isEditableTarget(e.target)) return;
1663
- const note = shortcutNote(e.key);
1664
- if (note === null) return;
1665
- const source = `keyboard:${key}`;
1666
- shortcutNotes.current.set(key, {
1667
- note,
1668
- source
1669
- });
1670
- instanceRef.current?.noteOn(note, { source });
1671
- });
1672
- useEventListener(shortcutTarget, "keyup", (e) => {
1673
- const key = shortcutKey(e);
1674
- const shortcut = shortcutNotes.current.get(key);
1675
- if (!shortcut) return;
1676
- shortcutNotes.current.delete(key);
1677
- instanceRef.current?.noteOff(shortcut.note, { source: shortcut.source });
1678
- });
1679
- useEventListener(focusOutTarget, "focusout", (e) => {
1680
- if (e.relatedTarget instanceof Node && node?.contains(e.relatedTarget)) return;
1681
- releaseShortcutNotes();
1682
- });
1683
- useEventListener(windowBlurTarget, "blur", releaseShortcutNotes);
1684
1238
  (0, react.useImperativeHandle)(forwardedRef, () => ({
1685
1239
  playNote: (note, velocity) => instanceRef.current?.noteOn(note, {
1686
1240
  source: "api",
@@ -1696,7 +1250,6 @@ const Piano = { Root: /* @__PURE__ */ (0, react.forwardRef)(function Root({ note
1696
1250
  tabIndex: 0,
1697
1251
  style: {
1698
1252
  width: resizable ? "100%" : (0, _tremolo_ui_dom.pianoWidth)(layout),
1699
- "--height": cssLength(height),
1700
1253
  position: "relative",
1701
1254
  ...style
1702
1255
  },
@@ -1762,30 +1315,58 @@ function usePointsEditorContext(selector) {
1762
1315
  return selector ? selector(context) : context;
1763
1316
  }
1764
1317
  //#endregion
1318
+ //#region src/components/PointsEditor/Container.tsx
1319
+ const Container = /* @__PURE__ */ (0, react.forwardRef)(function Container({ children, className, style, ...props }, forwardedRef) {
1320
+ const { containerRef, disabled, selectable, editor } = usePointsEditorContext();
1321
+ const { refCallback: dragRefCallback } = useDragValue({
1322
+ axis: _tremolo_ui_dom.POINT_AXIS,
1323
+ baseElementRef: containerRef,
1324
+ shouldStart: (event) => !disabled && !editor.isPointElement(event.target),
1325
+ onDragStart: ([x, y], state) => {
1326
+ editor.beginSelectionBox({
1327
+ x,
1328
+ y
1329
+ }, state.event);
1330
+ },
1331
+ onChange: ([x, y]) => editor.moveSelectionBox({
1332
+ x,
1333
+ y
1334
+ }),
1335
+ onDragEnd: () => editor.endSelectionBox()
1336
+ });
1337
+ useWheel((event) => {
1338
+ const move = (0, _tremolo_ui_dom.wheelMove)(event);
1339
+ if (!move) return;
1340
+ const axis = move.axis === 0 ? "x" : "y";
1341
+ if (editor.nudgeFocusedPoint(axis, move.direction, event)) event.preventDefault();
1342
+ }, { target: containerRef });
1343
+ const composedRef = require_compose_refs.useComposedRefs(forwardedRef, containerRef, selectable && !disabled ? dragRefCallback : void 0);
1344
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1345
+ ref: composedRef,
1346
+ className,
1347
+ style: {
1348
+ position: "absolute",
1349
+ inset: 0,
1350
+ zIndex: 10,
1351
+ ...style
1352
+ },
1353
+ ...props,
1354
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Placement, {
1355
+ name: "PointsEditor.Container",
1356
+ children
1357
+ })
1358
+ });
1359
+ });
1360
+ //#endregion
1765
1361
  //#region src/components/_util/VisuallyHiddenRangeInput.tsx
1766
1362
  const VisuallyHiddenRangeInput = /* @__PURE__ */ (0, react.forwardRef)((props, ref) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1767
1363
  ...props,
1768
1364
  ref,
1769
1365
  type: "range",
1770
- style: visuallyHiddenStyle
1366
+ style: _tremolo_ui_dom.visuallyHiddenStyle
1771
1367
  }));
1772
1368
  //#endregion
1773
1369
  //#region src/components/PointsEditor/Point.tsx
1774
- function clampPoint(point, min, max) {
1775
- const { x, y } = point;
1776
- return {
1777
- x: (0, _tremolo_ui_functions.clamp)(x, min?.x ?? 0, max?.x ?? 1),
1778
- y: (0, _tremolo_ui_functions.clamp)(y, min?.y ?? 0, max?.y ?? 1)
1779
- };
1780
- }
1781
- /**
1782
- * A point is placed by its position within the container, so its value is a
1783
- * position: 0..1 on each axis, with y growing downwards.
1784
- */
1785
- const AXIS = {
1786
- min: 0,
1787
- max: 1
1788
- };
1789
1370
  /**
1790
1371
  * One setting for both axes, or one per axis. The point holds a range input
1791
1372
  * for each, so anything that names or describes it comes in a pair.
@@ -1793,8 +1374,8 @@ const AXIS = {
1793
1374
  function perAxis(value, axis) {
1794
1375
  return typeof value === "string" ? value : value?.[axis];
1795
1376
  }
1796
- 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 }) {
1797
- const { containerRef, externalStyles, disabled: rootDisabled, readonly: rootReadonly, wheel: rootWheel, keyboard: rootKeyboard, dragSensitivity, selection, registerPoint, beginPointDrag, movePointDrag, nudgeSelection } = usePointsEditorContext();
1377
+ function Point({ value, children, id: idProp, min, max, color, disabled: _disabled, readonly: _readonly, wheel: _wheel, keyboard: _keyboard, "aria-label": ariaLabel, "aria-valuetext": ariaValuetext, onChange, onDragStart, onDragEnd, className, style, onPointerDown, onKeyDown, onFocus, ...props }) {
1378
+ const { containerRef, externalStyles, disabled: rootDisabled, readonly: rootReadonly, wheel: rootWheel, keyboard: rootKeyboard, dragSensitivity, selection, editor } = usePointsEditorContext();
1798
1379
  const generatedId = (0, react.useId)();
1799
1380
  const id = idProp ?? generatedId;
1800
1381
  const selected = selection.includes(id);
@@ -1827,11 +1408,11 @@ function Point({ value, children, id: idProp, min, max, size, width, height, col
1827
1408
  wheel
1828
1409
  };
1829
1410
  });
1830
- (0, react.useEffect)(() => registerPoint(id, registration), [id, registerPoint]);
1411
+ (0, react.useEffect)(() => editor.registerPoint(id, () => registration.current), [id, editor]);
1831
1412
  /** Where the pointer was when the drag started, to measure the move from. */
1832
1413
  const pointerOrigin = (0, react.useRef)(null);
1833
1414
  const { refCallback: dragRefCallback, dragging } = useDragValue({
1834
- axis: AXIS,
1415
+ axis: _tremolo_ui_dom.POINT_AXIS,
1835
1416
  baseElementRef: containerRef,
1836
1417
  sensitivity: (state) => (0, _tremolo_ui_dom.selectModifier)(dragSensitivity, state.event).value,
1837
1418
  cursor: inactive ? void 0 : externalStyles.cursor,
@@ -1839,53 +1420,36 @@ function Point({ value, children, id: idProp, min, max, size, width, height, col
1839
1420
  onChange: ([x, y]) => {
1840
1421
  const origin = pointerOrigin.current;
1841
1422
  if (!origin) return;
1842
- movePointDrag({
1423
+ editor.movePointDrag({
1843
1424
  x: x - origin.x,
1844
1425
  y: y - origin.y
1845
1426
  });
1846
1427
  },
1847
1428
  onDragStart: ([x, y], state) => {
1848
- beginPointDrag(id, state.event);
1429
+ editor.beginPointDrag(id, state.event);
1849
1430
  pointerOrigin.current = {
1850
1431
  x,
1851
1432
  y
1852
1433
  };
1853
1434
  xInputRef.current?.focus();
1854
1435
  if (inactive) return;
1855
- onDragStart?.(clampPoint(value, min, max));
1436
+ onDragStart?.((0, _tremolo_ui_dom.clampPoint)(value, min, max));
1856
1437
  },
1857
1438
  onDragEnd: () => {
1858
1439
  pointerOrigin.current = null;
1859
1440
  if (inactive) return;
1860
- onDragEnd?.(clampPoint(value, min, max));
1441
+ onDragEnd?.((0, _tremolo_ui_dom.clampPoint)(value, min, max));
1861
1442
  }
1862
1443
  });
1863
- const nudge = (0, react.useCallback)((axis, direction, option, modifiers) => {
1864
- const next = (0, _tremolo_ui_dom.applyDelta)(value[axis], direction, option, AXIS, modifiers);
1865
- nudgeSelection(id, {
1866
- x: axis === "x" ? next - value.x : 0,
1867
- y: axis === "y" ? next - value.y : 0
1868
- });
1869
- }, [
1870
- value,
1871
- id,
1872
- nudgeSelection
1873
- ]);
1874
1444
  const refCallback = require_compose_refs.useComposedRefs(dragRefCallback, setElement);
1875
1445
  const handleKeyDown = (event) => {
1876
- const key = event.key;
1877
- if (![
1878
- "ArrowRight",
1879
- "ArrowLeft",
1880
- "ArrowUp",
1881
- "ArrowDown"
1882
- ].includes(key)) return;
1883
- const axis = key === "ArrowRight" || key === "ArrowLeft" ? "x" : "y";
1446
+ const move = (0, _tremolo_ui_dom.arrowKeyMove)(event.key);
1447
+ if (!move) return;
1884
1448
  event.preventDefault();
1885
1449
  if (!onChange || inactive || !keyboard) return;
1886
- nudge(axis, key === "ArrowLeft" || key === "ArrowUp" ? -1 : 1, keyboard, event);
1450
+ editor.nudgePoint(id, move.axis === 0 ? "x" : "y", move.direction, keyboard, event);
1887
1451
  };
1888
- const current = clampPoint(value, min, max);
1452
+ const current = (0, _tremolo_ui_dom.clampPoint)(value, min, max);
1889
1453
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1890
1454
  ref: refCallback,
1891
1455
  className,
@@ -1896,8 +1460,6 @@ function Point({ value, children, id: idProp, min, max, size, width, height, col
1896
1460
  "data-selected": selected ? "" : void 0,
1897
1461
  style: {
1898
1462
  "--color": color,
1899
- "--width": cssLength(size ?? width),
1900
- "--height": cssLength(size ?? height),
1901
1463
  position: "absolute",
1902
1464
  translate: "var(--translate, -50% -50%)",
1903
1465
  ...style,
@@ -1932,7 +1494,7 @@ function Point({ value, children, id: idProp, min, max, size, width, height, col
1932
1494
  return;
1933
1495
  }
1934
1496
  const delta = event.currentTarget.valueAsNumber - value[axis];
1935
- nudgeSelection(id, {
1497
+ editor.nudgeSelection(id, {
1936
1498
  x: axis === "x" ? delta : 0,
1937
1499
  y: axis === "y" ? delta : 0
1938
1500
  });
@@ -1941,50 +1503,6 @@ function Point({ value, children, id: idProp, min, max, size, width, height, col
1941
1503
  });
1942
1504
  }
1943
1505
  //#endregion
1944
- //#region src/components/PointsEditor/Container.tsx
1945
- const Container = /* @__PURE__ */ (0, react.forwardRef)(function Container({ children, className, style, ...props }, forwardedRef) {
1946
- const { containerRef, disabled, selectable, isPointElement, nudgeFocusedPoint, beginSelectionBox, moveSelectionBox, endSelectionBox } = usePointsEditorContext();
1947
- const { refCallback: dragRefCallback } = useDragValue({
1948
- axis: AXIS,
1949
- baseElementRef: containerRef,
1950
- shouldStart: (event) => !disabled && !isPointElement(event.target),
1951
- onDragStart: ([x, y], state) => {
1952
- beginSelectionBox({
1953
- x,
1954
- y
1955
- }, state.event);
1956
- },
1957
- onChange: ([x, y]) => moveSelectionBox({
1958
- x,
1959
- y
1960
- }),
1961
- onDragEnd: endSelectionBox
1962
- });
1963
- useWheel((event) => {
1964
- const horizontal = event.deltaX !== 0;
1965
- const delta = horizontal ? event.deltaX : event.deltaY;
1966
- if (delta === 0) return;
1967
- const axis = horizontal || event.shiftKey ? "x" : "y";
1968
- if (nudgeFocusedPoint(axis, delta < 0 ? -1 : 1, event)) event.preventDefault();
1969
- }, { target: containerRef });
1970
- const composedRef = require_compose_refs.useComposedRefs(forwardedRef, containerRef, selectable && !disabled ? dragRefCallback : void 0);
1971
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1972
- ref: composedRef,
1973
- className,
1974
- style: {
1975
- position: "absolute",
1976
- inset: 0,
1977
- zIndex: 10,
1978
- ...style
1979
- },
1980
- ...props,
1981
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Placement, {
1982
- name: "PointsEditor.Container",
1983
- children
1984
- })
1985
- });
1986
- });
1987
- //#endregion
1988
1506
  //#region src/components/PointsEditor/SelectionBox.tsx
1989
1507
  /**
1990
1508
  * The box a drag on empty space draws, and what it covers is selected.
@@ -2017,181 +1535,28 @@ function SelectionBox({ children, className, style, ...props }) {
2017
1535
  /** One array for every editor with selection turned off, so memos hold still. */
2018
1536
  const EMPTY = [];
2019
1537
  /**
2020
- * How far a point may move before something in the selection leaves its range.
2021
- *
2022
- * Clamping each point on its own would break the shape of the selection: the
2023
- * one that reached the edge would stop while the rest carried on. One amount
2024
- * for all of them means the whole selection stops together.
2025
- */
2026
- function allowedDelta(delta, entries) {
2027
- let loX = -Infinity;
2028
- let hiX = Infinity;
2029
- let loY = -Infinity;
2030
- let hiY = Infinity;
2031
- for (const { start, registration } of entries) {
2032
- loX = Math.max(loX, (registration.min?.x ?? 0) - start.x);
2033
- hiX = Math.min(hiX, (registration.max?.x ?? 1) - start.x);
2034
- loY = Math.max(loY, (registration.min?.y ?? 0) - start.y);
2035
- hiY = Math.min(hiY, (registration.max?.y ?? 1) - start.y);
2036
- }
2037
- return {
2038
- x: hiX < loX ? 0 : (0, _tremolo_ui_functions.clamp)(delta.x, loX, hiX),
2039
- y: hiY < loY ? 0 : (0, _tremolo_ui_functions.clamp)(delta.y, loY, hiY)
2040
- };
2041
- }
2042
- /**
2043
- * A point moves over 0..1 in both axes, so a nudge of 0.01 crosses the editor
2044
- * in a hundred steps whatever its pixel size.
2045
- */
2046
- const DEFAULT_WHEEL = ["normalized", .01];
2047
- /**
2048
- * Shift is the fine-adjustment key everywhere else, so it is bound here too
2049
- * — but only on the keyboard. On the wheel it already means the x axis, and
2050
- * browsers hand shift+wheel over as horizontal scrolling anyway.
2051
- */
2052
- const DEFAULT_KEYBOARD = {
2053
- default: ["normalized", .01],
2054
- shift: ["normalized", .001]
2055
- };
2056
- /**
2057
1538
  * Multiple Point Controller
2058
1539
  */
2059
1540
  const PointsEditor = {
2060
- Root: /* @__PURE__ */ (0, react.forwardRef)(({ width, height, disabled = false, readonly = false, wheel = DEFAULT_WHEEL, keyboard = DEFAULT_KEYBOARD, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, selectable = false, selection: selectionProp, defaultSelection, onSelectionChange, externalStyles, style, className, children, ...props }, forwardedRef) => {
1541
+ Root: /* @__PURE__ */ (0, react.forwardRef)(({ disabled = false, readonly = false, wheel = _tremolo_ui_dom.POINTS_EDITOR_DEFAULT_WHEEL, keyboard = _tremolo_ui_dom.POINTS_EDITOR_DEFAULT_KEYBOARD, dragSensitivity = _tremolo_ui_dom.DEFAULT_DRAG_SENSITIVITY, selectable = false, selection: selectionProp, defaultSelection, onSelectionChange, externalStyles, style, className, children, ...props }, forwardedRef) => {
2061
1542
  const containerRef = (0, react.useRef)(null);
2062
1543
  const { cursor = "grabbing" } = externalStyles ?? {};
2063
1544
  const controlled = selectionProp !== void 0;
2064
1545
  const [ownSelection, setOwnSelection] = (0, react.useState)(defaultSelection ?? []);
2065
1546
  const selection = selectable ? selectionProp ?? ownSelection : EMPTY;
2066
- const selectionRef = (0, react.useRef)(selection);
2067
- const changeHandlerRef = (0, react.useRef)(onSelectionChange);
2068
- (0, react.useEffect)(() => {
2069
- selectionRef.current = selection;
2070
- changeHandlerRef.current = onSelectionChange;
2071
- });
2072
- const changeSelection = (0, react.useCallback)((next) => {
2073
- selectionRef.current = next;
2074
- if (!controlled) setOwnSelection(next);
2075
- changeHandlerRef.current?.(next);
2076
- }, [controlled]);
2077
- /**
2078
- * Every mounted point, by id. A registration is a ref rather than a value:
2079
- * the value inside changes on every frame of a drag, and a registry keyed
2080
- * on it would be rebuilt just as often.
2081
- */
2082
- const points = (0, react.useRef)(/* @__PURE__ */ new Map());
2083
- const registerPoint = (0, react.useCallback)((id, entry) => {
2084
- points.current.set(id, entry);
2085
- return () => {
2086
- points.current.delete(id);
2087
- };
2088
- }, []);
2089
- /** What the current drag picked up, and where those points started. */
2090
- const dragRef = (0, react.useRef)([]);
2091
- const snapshot = (0, react.useCallback)((ids) => {
2092
- return ids.flatMap((id) => {
2093
- const registration = points.current.get(id)?.current;
2094
- return registration ? [{
2095
- id,
2096
- start: { ...registration.value }
2097
- }] : [];
2098
- });
2099
- }, []);
2100
- const applyDeltaTo = (0, react.useCallback)((entries, delta) => {
2101
- const withRegistration = entries.flatMap((entry) => {
2102
- const registration = points.current.get(entry.id)?.current;
2103
- return registration ? [{
2104
- ...entry,
2105
- registration
2106
- }] : [];
2107
- });
2108
- const allowed = allowedDelta(delta, withRegistration);
2109
- for (const { start, registration } of withRegistration) {
2110
- if (registration.readonly) continue;
2111
- registration.onChange?.({
2112
- x: (0, _tremolo_ui_functions.toPrecision)(start.x + allowed.x),
2113
- y: (0, _tremolo_ui_functions.toPrecision)(start.y + allowed.y)
2114
- });
2115
- }
2116
- }, []);
2117
- const beginPointDrag = (0, react.useCallback)((id, modifiers) => {
2118
- if (!selectable) {
2119
- dragRef.current = snapshot([id]);
2120
- return;
2121
- }
2122
- const current = selectionRef.current;
2123
- const additive = modifiers.ctrlKey || modifiers.metaKey;
2124
- let next;
2125
- if (additive) next = current.includes(id) ? current.filter((x) => x !== id) : [...current, id];
2126
- else if (current.includes(id)) next = current;
2127
- else next = [id];
2128
- changeSelection([...next]);
2129
- dragRef.current = next.includes(id) ? snapshot(next) : [];
2130
- }, [
2131
- selectable,
2132
- changeSelection,
2133
- snapshot
2134
- ]);
2135
- const movePointDrag = (0, react.useCallback)((delta) => applyDeltaTo(dragRef.current, delta), [applyDeltaTo]);
2136
- const nudgeSelection = (0, react.useCallback)((id, delta) => {
2137
- const ids = selectionRef.current.includes(id) ? selectionRef.current : [id];
2138
- applyDeltaTo(snapshot(ids), delta);
2139
- }, [applyDeltaTo, snapshot]);
2140
- const isPointElement = (0, react.useCallback)((element) => {
2141
- if (!element) return false;
2142
- for (const [, entry] of points.current) if (entry.current.element?.contains(element)) return true;
2143
- return false;
2144
- }, []);
2145
- const nudgeFocusedPoint = (0, react.useCallback)((axis, direction, modifiers) => {
2146
- const container = containerRef.current;
2147
- const active = container?.ownerDocument.activeElement;
2148
- if (!container || !active || !container.contains(active)) return false;
2149
- for (const [id, entry] of points.current) {
2150
- const { element, wheel, readonly, onChange, value } = entry.current;
2151
- if (!element?.contains(active)) continue;
2152
- if (!wheel || readonly || !onChange) return false;
2153
- const next = (0, _tremolo_ui_dom.applyDelta)(value[axis], direction, wheel, AXIS, modifiers);
2154
- nudgeSelection(id, {
2155
- x: axis === "x" ? next - value.x : 0,
2156
- y: axis === "y" ? next - value.y : 0
2157
- });
2158
- return true;
2159
- }
2160
- return false;
2161
- }, [nudgeSelection]);
2162
1547
  const [selectionBox, setSelectionBox] = (0, react.useState)(null);
2163
- const selectionBoxRef = (0, react.useRef)(null);
2164
- selectionBoxRef.current ??= (0, _tremolo_ui_dom.createSelectionBox)({
2165
- *items() {
2166
- for (const [id, entry] of points.current) {
2167
- const { x, y } = entry.current.value;
2168
- yield [id, [x, y]];
2169
- }
2170
- },
2171
- onBoxChange: setSelectionBox
2172
- });
1548
+ const [editor] = (0, react.useState)(() => (0, _tremolo_ui_dom.createPointsEditor)({ onSelectionBoxChange: setSelectionBox }));
2173
1549
  (0, react.useEffect)(() => {
2174
- selectionBoxRef.current?.update({ onSelectionChange: changeSelection });
2175
- }, [changeSelection]);
2176
- (0, react.useEffect)(() => {
2177
- const box = selectionBoxRef.current;
2178
- return () => box?.destroy();
2179
- }, []);
2180
- const beginSelectionBox = (0, react.useCallback)((at, modifiers) => {
2181
- if (!selectable) return;
2182
- selectionBoxRef.current?.begin([at.x, at.y], {
2183
- additive: modifiers.ctrlKey || modifiers.metaKey,
2184
- selection: selectionRef.current
1550
+ editor.update({
1551
+ selectable,
1552
+ selection,
1553
+ onSelectionChange: (next) => {
1554
+ if (!controlled) setOwnSelection(next);
1555
+ onSelectionChange?.(next);
1556
+ }
2185
1557
  });
2186
- }, [selectable]);
2187
- const moveSelectionBox = (0, react.useCallback)((to) => {
2188
- selectionBoxRef.current?.move([to.x, to.y]);
2189
- }, []);
2190
- const endSelectionBox = (0, react.useCallback)(() => {
2191
- if (!selectionBoxRef.current?.end()) return;
2192
- const [first] = selectionRef.current;
2193
- if (first) points.current.get(first)?.current.element?.focus();
2194
- }, []);
1558
+ });
1559
+ (0, react.useEffect)(() => () => editor.destroy(), [editor]);
2195
1560
  const context = (0, react.useMemo)(() => ({
2196
1561
  disabled,
2197
1562
  readonly,
@@ -2202,16 +1567,8 @@ const PointsEditor = {
2202
1567
  containerRef,
2203
1568
  selectable,
2204
1569
  selection,
2205
- registerPoint,
2206
- isPointElement,
2207
- beginPointDrag,
2208
- movePointDrag,
2209
- nudgeSelection,
2210
- nudgeFocusedPoint,
2211
- selectionBox,
2212
- beginSelectionBox,
2213
- moveSelectionBox,
2214
- endSelectionBox
1570
+ editor,
1571
+ selectionBox
2215
1572
  }), [
2216
1573
  disabled,
2217
1574
  readonly,
@@ -2221,16 +1578,8 @@ const PointsEditor = {
2221
1578
  cursor,
2222
1579
  selectable,
2223
1580
  selection,
2224
- registerPoint,
2225
- isPointElement,
2226
- beginPointDrag,
2227
- movePointDrag,
2228
- nudgeSelection,
2229
- nudgeFocusedPoint,
2230
- selectionBox,
2231
- beginSelectionBox,
2232
- moveSelectionBox,
2233
- endSelectionBox
1581
+ editor,
1582
+ selectionBox
2234
1583
  ]);
2235
1584
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PointsEditorProvider, {
2236
1585
  value: context,
@@ -2240,8 +1589,6 @@ const PointsEditor = {
2240
1589
  "data-disabled": disabled ? "" : void 0,
2241
1590
  "data-readonly": readonly ? "" : void 0,
2242
1591
  style: {
2243
- "--width": cssLength(width),
2244
- "--height": cssLength(height),
2245
1592
  position: "relative",
2246
1593
  ...style
2247
1594
  },
@@ -2266,36 +1613,30 @@ function useSliderContext(selector) {
2266
1613
  }
2267
1614
  //#endregion
2268
1615
  //#region src/components/Slider/MarksOption.tsx
2269
- function MarksOption({ value, mark = true, label, thickness, length, gap, labelWidth, classes, styles, className, style, ...props }) {
1616
+ function MarksOption({ value, mark = true, label, thickness, length, gap, classes, styles, className, style, ...props }) {
2270
1617
  useCheckPlacement("Slider.MarksOption", "Slider.Marks");
2271
1618
  const min = useSliderContext((s) => s.min);
2272
1619
  const max = useSliderContext((s) => s.max);
2273
1620
  const scale = useSliderContext((s) => s.scale);
2274
1621
  const vertical = useSliderContext((s) => s.vertical);
2275
1622
  const reverse = useSliderContext((s) => s.reverse);
2276
- const calcPercent = (0, react.useCallback)((value) => {
2277
- const percent = scale.normalize(value, min, max) * 100;
2278
- return (0, _tremolo_ui_functions.toFixed)(vertical !== reverse ? 100 - percent : percent);
2279
- }, [
2280
- vertical,
2281
- reverse,
2282
- max,
1623
+ const percent = (0, _tremolo_ui_dom.valuePercent)(value, {
2283
1624
  min,
1625
+ max,
2284
1626
  scale
2285
- ]);
1627
+ }, vertical !== reverse);
2286
1628
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2287
1629
  className,
2288
1630
  style: {
2289
- "--thickness": cssLength(thickness),
2290
- "--length": cssLength(length),
2291
- "--gap": cssLength(gap),
2292
- "--label-width": cssLength(labelWidth),
1631
+ "--thickness": (0, _tremolo_ui_dom.cssLength)(thickness),
1632
+ "--length": (0, _tremolo_ui_dom.cssLength)(length),
1633
+ "--gap": (0, _tremolo_ui_dom.cssLength)(gap),
2293
1634
  position: "absolute",
2294
1635
  translate: vertical ? "var(--translate, 0 -50%)" : "var(--translate, -50% 0)",
2295
1636
  zIndex: 10,
2296
1637
  ...style,
2297
- left: !vertical ? `${calcPercent(value)}%` : void 0,
2298
- top: vertical ? `${calcPercent(value)}%` : void 0
1638
+ left: !vertical ? `${percent}%` : void 0,
1639
+ top: vertical ? `${percent}%` : void 0
2299
1640
  },
2300
1641
  "data-orientation": vertical ? "vertical" : "horizontal",
2301
1642
  ...props,
@@ -2311,44 +1652,6 @@ function MarksOption({ value, mark = true, label, thickness, length, gap, labelW
2311
1652
  });
2312
1653
  }
2313
1654
  //#endregion
2314
- //#region src/components/_util/decimal-digits.ts
2315
- /**
2316
- * How many digits a number carries after the decimal point.
2317
- *
2318
- * `Slider.Marks` builds its labels by multiplying an interval, which leaves
2319
- * binary debris in the last digits (`0.1 * 3` is `0.30000000000000004`). The
2320
- * count is what tells `toFixed` how far to round that back.
2321
- *
2322
- * The exponent form has to be handled separately: `String(1e-7)` is `'1e-7'`,
2323
- * which has no decimal point at all, so reading the text after the point
2324
- * reports no digits and the interval rounds away to whole numbers.
2325
- */
2326
- function decimalDigits(x) {
2327
- if (!Number.isFinite(x)) return 0;
2328
- const text = String(x);
2329
- const e = text.indexOf("e");
2330
- if (e === -1) return text.split(".")[1]?.length ?? 0;
2331
- const fraction = text.slice(0, e).split(".")[1]?.length ?? 0;
2332
- return Math.max(0, fraction - Number(text.slice(e + 1)));
2333
- }
2334
- //#endregion
2335
- //#region src/components/Slider/type.ts
2336
- function generateOptionsList(options, min, max, step) {
2337
- const { per, mark = true, label = true } = typeof options === "object" ? options : { per: options };
2338
- const optionsList = [];
2339
- const interval = per === "step" ? step : per;
2340
- const count = Math.floor(max / interval) - Math.ceil(min / interval) + 1;
2341
- for (let i = 0; i < count; i++) {
2342
- const value = (0, _tremolo_ui_functions.toFixed)(interval * (Math.ceil(min / interval) + i), decimalDigits(interval));
2343
- optionsList.push({
2344
- value,
2345
- mark,
2346
- label
2347
- });
2348
- }
2349
- return optionsList;
2350
- }
2351
- //#endregion
2352
1655
  //#region src/components/Slider/Marks.tsx
2353
1656
  function Marks({ gap, options, children, className, style, ...props }) {
2354
1657
  const min = useSliderContext((s) => s.min);
@@ -2356,13 +1659,13 @@ function Marks({ gap, options, children, className, style, ...props }) {
2356
1659
  const step = useSliderContext((s) => s.step);
2357
1660
  const vertical = useSliderContext((s) => s.vertical);
2358
1661
  const reverse = useSliderContext((s) => s.reverse);
2359
- const optionsList = options ? generateOptionsList(options, min, max, step) : [];
1662
+ const optionsList = options ? (0, _tremolo_ui_dom.sliderMarks)(options, min, max, step) : [];
2360
1663
  if (vertical !== reverse) optionsList.reverse();
2361
1664
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2362
1665
  className,
2363
1666
  "data-orientation": vertical ? "vertical" : "horizontal",
2364
1667
  style: {
2365
- "--gap": cssLength(gap),
1668
+ "--gap": (0, _tremolo_ui_dom.cssLength)(gap),
2366
1669
  position: "relative",
2367
1670
  ...style
2368
1671
  },
@@ -2446,8 +1749,8 @@ const Track = /* @__PURE__ */ (0, react.forwardRef)(function Track({ length, thi
2446
1749
  style: {
2447
1750
  "--active": active,
2448
1751
  "--inactive": inactive,
2449
- "--length": cssLength(length),
2450
- "--thickness": cssLength(thickness),
1752
+ "--length": (0, _tremolo_ui_dom.cssLength)(length),
1753
+ "--thickness": (0, _tremolo_ui_dom.cssLength)(thickness),
2451
1754
  "--percent": `${percent}%`,
2452
1755
  position: "relative",
2453
1756
  ...style
@@ -2471,7 +1774,7 @@ const WHEEL_OPTIONS$1 = { requireFocus: true };
2471
1774
  * Customizable slider
2472
1775
  */
2473
1776
  const Slider = {
2474
- Root: /* @__PURE__ */ (0, react.forwardRef)(({ value, min, max, step = 1, scale = _tremolo_ui_functions.linearScale, vertical = false, reverse = false, externalStyles: _externalStyles, wheel = DEFAULT_WHEEL_OPTIONS, keyboard = DEFAULT_KEYBOARD_OPTIONS, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, disabled = false, readonly = false, onChange, onDragStart, onDragEnd, className, style, children, onFocus, onBlur, onPointerDown, onKeyDown, ...props }, forwardedRef) => {
1777
+ Root: /* @__PURE__ */ (0, react.forwardRef)(({ value, min, max, step = 1, scale = _tremolo_ui_functions.linearScale, vertical = false, reverse = false, externalStyles: _externalStyles, wheel = _tremolo_ui_dom.DEFAULT_WHEEL_OPTIONS, keyboard = _tremolo_ui_dom.DEFAULT_KEYBOARD_OPTIONS, dragSensitivity = _tremolo_ui_dom.DEFAULT_DRAG_SENSITIVITY, disabled = false, readonly = false, onChange, onDragStart, onDragEnd, className, style, children, onFocus, onBlur, onPointerDown, onKeyDown, ...props }, forwardedRef) => {
2475
1778
  const trackRef = (0, react.useRef)(null);
2476
1779
  const thumbRef = (0, react.useRef)(null);
2477
1780
  const externalStyles = {
@@ -2479,10 +1782,12 @@ const Slider = {
2479
1782
  ..._externalStyles
2480
1783
  };
2481
1784
  const inactive = disabled || readonly;
2482
- const p = (0, _tremolo_ui_functions.toFixed)(scale.normalize(value, min, max) * 100);
2483
- const rev = (0, _tremolo_ui_functions.toFixed)(100 - p);
2484
1785
  const displayReversed = vertical !== reverse;
2485
- const percent = displayReversed ? rev : p;
1786
+ const percent = (0, _tremolo_ui_dom.valuePercent)(value, {
1787
+ min,
1788
+ max,
1789
+ scale
1790
+ }, displayReversed);
2486
1791
  const axis = (0, react.useMemo)(() => ({
2487
1792
  min,
2488
1793
  max,
@@ -2503,18 +1808,11 @@ const Slider = {
2503
1808
  wheel
2504
1809
  });
2505
1810
  const handleKeyDown = (0, react.useCallback)((event) => {
2506
- const key = event.key;
2507
- if (![
2508
- "ArrowRight",
2509
- "ArrowLeft",
2510
- "ArrowUp",
2511
- "ArrowDown"
2512
- ].includes(key)) return;
1811
+ const direction = (0, _tremolo_ui_dom.arrowKeyDirection)(event.key);
1812
+ if (direction === null) return;
2513
1813
  event.preventDefault();
2514
1814
  if (!keyboard || !onChange || inactive) return;
2515
- let direction = key === "ArrowRight" || key === "ArrowUp" ? 1 : -1;
2516
- if (reverse) direction *= -1;
2517
- onChange((0, _tremolo_ui_dom.applyDelta)(value, direction, keyboard, axis, event));
1815
+ onChange((0, _tremolo_ui_dom.applyDelta)(value, reverse ? -direction : direction, keyboard, axis, event));
2518
1816
  }, [
2519
1817
  keyboard,
2520
1818
  onChange,
@@ -2547,15 +1845,10 @@ const Slider = {
2547
1845
  });
2548
1846
  const wheelRefCallback = useWheel((event) => {
2549
1847
  if (!wheel || !onChange || inactive) return;
1848
+ const direction = (0, _tremolo_ui_dom.wheelDirection)(event, { horizontal: !vertical });
1849
+ if (direction === null) return;
2550
1850
  event.preventDefault();
2551
- let direction;
2552
- if (!vertical && event.deltaX !== 0) direction = event.deltaX > 0 ? 1 : -1;
2553
- else {
2554
- if (event.deltaY === 0) return;
2555
- direction = event.deltaY > 0 ? -1 : 1;
2556
- }
2557
- if (reverse) direction *= -1;
2558
- onChange((0, _tremolo_ui_dom.applyDelta)(value, direction, wheel, axis, event));
1851
+ onChange((0, _tremolo_ui_dom.applyDelta)(value, reverse ? -direction : direction, wheel, axis, event));
2559
1852
  }, WHEEL_OPTIONS$1);
2560
1853
  const rootRefCallback = require_compose_refs.useComposedRefs(dragRefCallback, wheelRefCallback);
2561
1854
  const context = (0, react.useMemo)(() => ({
@@ -2640,16 +1933,13 @@ function useXYPadContext(selector) {
2640
1933
  }
2641
1934
  //#endregion
2642
1935
  //#region src/components/XYPad/Area.tsx
2643
- const Area = /* @__PURE__ */ (0, react.forwardRef)(function Area({ width, height, color, children, className, style, ...props }, forwardedRef) {
1936
+ const Area = /* @__PURE__ */ (0, react.forwardRef)(function Area({ children, className, style, ...props }, forwardedRef) {
2644
1937
  const { areaRef } = useXYPadContext();
2645
1938
  const composedRef = require_compose_refs.useComposedRefs(forwardedRef, areaRef);
2646
1939
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2647
1940
  ref: composedRef,
2648
1941
  className,
2649
1942
  style: {
2650
- "--color": color,
2651
- "--width": cssLength(width),
2652
- "--height": cssLength(height),
2653
1943
  position: "relative",
2654
1944
  ...style
2655
1945
  },
@@ -2734,7 +2024,7 @@ const WHEEL_OPTIONS = { requireFocus: true };
2734
2024
  * Simple XYPad
2735
2025
  */
2736
2026
  const XYPad = {
2737
- Root: /* @__PURE__ */ (0, react.forwardRef)(({ value, min: _min, max: _max, step: _step = 1, scale: _scale = _tremolo_ui_functions.linearScale, reverse: _reverse = false, wheel = DEFAULT_WHEEL_OPTIONS, keyboard = DEFAULT_KEYBOARD_OPTIONS, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, className, style, externalStyles: _externalStyles, disabled = false, readonly = false, onChange, onDragStart, onDragEnd, onPointerDown, onKeyDown, onFocus, onBlur, children, ...props }, forwardedRef) => {
2027
+ Root: /* @__PURE__ */ (0, react.forwardRef)(({ value, min: _min, max: _max, step: _step = 1, scale: _scale = _tremolo_ui_functions.linearScale, reverse: _reverse = false, wheel = _tremolo_ui_dom.DEFAULT_WHEEL_OPTIONS, keyboard = _tremolo_ui_dom.DEFAULT_KEYBOARD_OPTIONS, dragSensitivity = _tremolo_ui_dom.DEFAULT_DRAG_SENSITIVITY, className, style, externalStyles: _externalStyles, disabled = false, readonly = false, onChange, onDragStart, onDragEnd, onPointerDown, onKeyDown, onFocus, onBlur, children, ...props }, forwardedRef) => {
2738
2028
  const rootRef = (0, react.useRef)(null);
2739
2029
  const areaRef = (0, react.useRef)(null);
2740
2030
  const thumbRef = (0, react.useRef)(null);
@@ -2748,10 +2038,11 @@ const XYPad = {
2748
2038
  const step = (0, react.useMemo)(() => (0, _tremolo_ui_dom.toXY)(_step), [_step]);
2749
2039
  const scale = (0, react.useMemo)(() => (0, _tremolo_ui_dom.toXY)(_scale), [_scale]);
2750
2040
  const reverse = (0, react.useMemo)(() => (0, _tremolo_ui_dom.toXY)(_reverse), [_reverse]);
2751
- const percent = (0, react.useMemo)(() => {
2752
- const normalized = [0, 1].map((i) => scale[i].normalize(value[i], min[i], max[i]));
2753
- return [0, 1].map((i) => (0, _tremolo_ui_functions.toFixed)((reverse[i] ? 1 - normalized[i] : normalized[i]) * 100));
2754
- }, [
2041
+ const percent = (0, react.useMemo)(() => [0, 1].map((i) => (0, _tremolo_ui_dom.valuePercent)(value[i], {
2042
+ min: min[i],
2043
+ max: max[i],
2044
+ scale: scale[i]
2045
+ }, reverse[i])), [
2755
2046
  value,
2756
2047
  min,
2757
2048
  max,
@@ -2793,20 +2084,12 @@ const XYPad = {
2793
2084
  withAxis
2794
2085
  ]);
2795
2086
  const handleKeyDown = (0, react.useCallback)((event) => {
2796
- const key = event.key;
2797
- if (![
2798
- "ArrowRight",
2799
- "ArrowLeft",
2800
- "ArrowUp",
2801
- "ArrowDown"
2802
- ].includes(key)) return;
2803
- const i = key === "ArrowRight" || key === "ArrowLeft" ? 0 : 1;
2087
+ const move = (0, _tremolo_ui_dom.arrowKeyMove)(event.key);
2088
+ if (!move) return;
2804
2089
  event.preventDefault();
2805
2090
  if (!onChange || inactive || !keyboard) return;
2806
- let direction = 1;
2807
- if (key === "ArrowLeft" || key === "ArrowUp") direction *= -1;
2808
- if (reverse[i]) direction *= -1;
2809
- onChange(nudge(i, direction, keyboard, event));
2091
+ const { axis: i, direction } = move;
2092
+ onChange(nudge(i, reverse[i] ? -direction : direction, keyboard, event));
2810
2093
  }, [
2811
2094
  onChange,
2812
2095
  inactive,
@@ -2837,15 +2120,11 @@ const XYPad = {
2837
2120
  });
2838
2121
  const wheelRefCallback = useWheel((event) => {
2839
2122
  if (!onChange || inactive || !wheel) return;
2840
- const horizontal = event.deltaX !== 0;
2841
- const delta = horizontal ? event.deltaX : event.deltaY;
2842
- if (delta === 0) return;
2843
- const i = horizontal || event.shiftKey ? 0 : 1;
2123
+ const move = (0, _tremolo_ui_dom.wheelMove)(event);
2124
+ if (!move) return;
2844
2125
  event.preventDefault();
2845
- let direction = 1;
2846
- if (delta < 0) direction *= -1;
2847
- if (reverse[i]) direction *= -1;
2848
- onChange(nudge(i, direction, wheel, event));
2126
+ const { axis: i, direction } = move;
2127
+ onChange(nudge(i, reverse[i] ? -direction : direction, wheel, event));
2849
2128
  }, WHEEL_OPTIONS);
2850
2129
  const rootRefCallback = require_compose_refs.useComposedRefs(rootRef, dragRefCallback, wheelRefCallback);
2851
2130
  const context = (0, react.useMemo)(() => ({
@@ -2930,6 +2209,82 @@ function useAnimationFrame(callback = () => {}, deps = []) {
2930
2209
  }, [runCallback, ...deps]);
2931
2210
  }
2932
2211
  //#endregion
2212
+ //#region src/hooks/useDrag.ts
2213
+ /**
2214
+ * Track a pointer drag on an element.
2215
+ *
2216
+ * @returns a ref callback to attach to the element being dragged
2217
+ */
2218
+ function useDrag({ threshold = 1, cursor, pointerLock, onDrag, onDragStart, onDragEnd }) {
2219
+ const dragHandler = useCallbackRef(onDrag);
2220
+ const dragStartHandler = useCallbackRef(onDragStart);
2221
+ const dragEndHandler = useCallbackRef(onDragEnd);
2222
+ const [node, setNode] = (0, react.useState)(null);
2223
+ (0, react.useEffect)(() => {
2224
+ if (!node) return;
2225
+ const instance = (0, _tremolo_ui_dom.createDrag)(node, {
2226
+ threshold,
2227
+ cursor,
2228
+ pointerLock,
2229
+ onDragStart: (state) => dragStartHandler(state),
2230
+ onDrag: (state) => dragHandler(state.x, state.y, state.deltaX, state.deltaY, state),
2231
+ onDragEnd: (state) => dragEndHandler(state)
2232
+ });
2233
+ return () => instance.destroy();
2234
+ }, [
2235
+ node,
2236
+ threshold,
2237
+ cursor,
2238
+ pointerLock,
2239
+ dragHandler,
2240
+ dragStartHandler,
2241
+ dragEndHandler
2242
+ ]);
2243
+ return setNode;
2244
+ }
2245
+ //#endregion
2246
+ //#region src/hooks/useEventListener.ts
2247
+ function useEventListener(target, event, handler, options) {
2248
+ const listener = useCallbackRef(handler);
2249
+ const cleanupRef = (0, react.useRef)(() => {});
2250
+ (0, react.useEffect)(() => {
2251
+ const node = typeof target === "function" ? target() : target ?? document;
2252
+ if (!node) return;
2253
+ node.addEventListener(event, listener, options);
2254
+ const cleanup = () => {
2255
+ node.removeEventListener(event, listener, options);
2256
+ };
2257
+ cleanupRef.current = cleanup;
2258
+ return () => {
2259
+ cleanup();
2260
+ if (cleanupRef.current === cleanup) cleanupRef.current = () => {};
2261
+ };
2262
+ }, [
2263
+ event,
2264
+ target,
2265
+ options,
2266
+ listener
2267
+ ]);
2268
+ return (0, react.useCallback)(() => cleanupRef.current(), []);
2269
+ }
2270
+ //#endregion
2271
+ //#region src/hooks/useInterval.ts
2272
+ function useInterval(callback, delay) {
2273
+ const savedCallback = (0, react.useRef)(callback);
2274
+ (0, react.useEffect)(() => {
2275
+ savedCallback.current = callback;
2276
+ }, [callback]);
2277
+ (0, react.useEffect)(() => {
2278
+ if (delay === null) return;
2279
+ const id = setInterval(() => {
2280
+ savedCallback.current();
2281
+ }, delay);
2282
+ return () => {
2283
+ clearInterval(id);
2284
+ };
2285
+ }, [delay]);
2286
+ }
2287
+ //#endregion
2933
2288
  //#region src/hooks/useMIDIAccess.ts
2934
2289
  const INITIAL_STATE = {
2935
2290
  midiAccess: null,
@@ -3067,38 +2422,14 @@ function useMIDIMessage(midiAccess, onMIDIMessage) {
3067
2422
  }
3068
2423
  //#endregion
3069
2424
  exports.AnimationCanvas = AnimationCanvas;
3070
- exports.DEFAULT_DRAG_SENSITIVITY = DEFAULT_DRAG_SENSITIVITY;
3071
- exports.DEFAULT_KEYBOARD_OPTIONS = DEFAULT_KEYBOARD_OPTIONS;
3072
- exports.DEFAULT_WHEEL_OPTIONS = DEFAULT_WHEEL_OPTIONS;
3073
2425
  exports.DropZone = DropZone;
3074
2426
  exports.FileInput = FileInput;
3075
2427
  exports.Knob = Knob;
3076
- Object.defineProperty(exports, "NOT_SUPPORTED", {
3077
- enumerable: true,
3078
- get: function() {
3079
- return _tremolo_ui_dom.NOT_SUPPORTED;
3080
- }
3081
- });
3082
2428
  exports.NumberInput = NumberInput;
3083
- Object.defineProperty(exports, "PERMISSION_DENIED", {
3084
- enumerable: true,
3085
- get: function() {
3086
- return _tremolo_ui_dom.PERMISSION_DENIED;
3087
- }
3088
- });
3089
- exports.PITCH_BEND_CENTER = _tremolo_ui_dom.PITCH_BEND_CENTER;
3090
2429
  exports.Piano = Piano;
3091
2430
  exports.PointsEditor = PointsEditor;
3092
- exports.SHORTCUTS = SHORTCUTS;
3093
2431
  exports.Slider = Slider;
3094
- Object.defineProperty(exports, "UNAVAILABLE", {
3095
- enumerable: true,
3096
- get: function() {
3097
- return _tremolo_ui_dom.UNAVAILABLE;
3098
- }
3099
- });
3100
2432
  exports.XYPad = XYPad;
3101
- exports.clampPoint = clampPoint;
3102
2433
  exports.useAnimationFrame = useAnimationFrame;
3103
2434
  exports.useDrag = useDrag;
3104
2435
  exports.useDragValue = useDragValue;