@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.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { useComposedRefs } from "./compose-refs.js";
2
2
  import { createContext, forwardRef, useCallback, useContext, useEffect, useId, useImperativeHandle, useInsertionEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
3
- import { NOT_SUPPORTED, PERMISSION_DENIED, PITCH_BEND_CENTER, UNAVAILABLE, applyDelta, blackKeyWidth, createAnimationCanvas, createDrag, createDragValue, createDropZone, createMIDIAccess, createMIDIInput, createMIDIMessage, createPianoInput, createSelectionBox, createWheel, elementMapping, getNoteRangeArray, mapModifier, matchesAccept, notePosition, pianoWidth, relativeMapping, selectModifier, toXY } from "@tremolo-ui/dom";
3
+ import { DEFAULT_DRAG_SENSITIVITY, DEFAULT_KEYBOARD_OPTIONS, DEFAULT_WHEEL_OPTIONS, KNOB_VIEWBOX_SIZE, POINTS_EDITOR_DEFAULT_KEYBOARD, POINTS_EDITOR_DEFAULT_WHEEL, POINT_AXIS, applyDelta, arrowKeyDirection, arrowKeyMove, blackKeyWidth, caretAtDecimalOffset, caretDecimalOffset, checkSteps, clampPoint, commitNumberInputText, createAnimationCanvas, createDrag, createDragValue, createDropZone, createLongPress, createMIDIAccess, createMIDIInput, createMIDIMessage, createPianoInput, createPointsEditor, createStepperDrag, createWheel, cssLength, elementMapping, fitWhiteKeyWidth, getNoteRangeArray, knobAngles, knobArcPath, knobArcRadius, notePosition, nudgeNumberInput, numberInputBounds, numberInputRanges, numberSpan, parseNumberText, partitionByAccept, pianoWidth, relativeMapping, selectModifier, sliderMarks, toXY, valuePercent, visuallyHiddenStyle, wheelDirection, wheelMove } from "@tremolo-ui/dom";
4
4
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
- import { clamp, isWhiteKey, linearScale, noteKey, radian, toFixed, toPrecision } from "@tremolo-ui/functions";
5
+ import { clamp, isWhiteKey, linearScale, noteKey } from "@tremolo-ui/functions";
6
6
  //#region src/components/AnimationCanvas/index.tsx
7
7
  /**
8
8
  * A simple animatable canvas with requestAnimationFrame()
@@ -168,31 +168,6 @@ const DropZone = { Root: /* @__PURE__ */ forwardRef(({ accept, multiple = false,
168
168
  });
169
169
  }) };
170
170
  //#endregion
171
- //#region src/components/_util/visually-hidden.ts
172
- /**
173
- * Take an element out of sight while leaving it in the accessibility tree and
174
- * in the tab order.
175
- *
176
- * `display: none` and `visibility: hidden` would remove it from both, and the
177
- * native control underneath a headless component is what carries the ARIA and
178
- * the keyboard behaviour. `pointer-events: none` is safe because nothing is
179
- * ever clicked here directly: a `<label>` forwards its click, and a drag is
180
- * handled by the part that is visible.
181
- */
182
- const visuallyHiddenStyle = {
183
- position: "absolute",
184
- width: 1,
185
- height: 1,
186
- padding: 0,
187
- margin: -1,
188
- overflow: "hidden",
189
- clip: "rect(0, 0, 0, 0)",
190
- clipPath: "inset(50%)",
191
- whiteSpace: "nowrap",
192
- border: 0,
193
- pointerEvents: "none"
194
- };
195
- //#endregion
196
171
  //#region src/components/FileInput/context.tsx
197
172
  const FileInputContext = /* @__PURE__ */ createContext(null);
198
173
  const FileInputProvider = FileInputContext.Provider;
@@ -212,14 +187,10 @@ const FileInput = {
212
187
  disabled
213
188
  }), [inputId, disabled]);
214
189
  function handleChange(event) {
215
- const picked = Array.from(event.target.files ?? []);
190
+ const { accepted, rejected } = partitionByAccept(Array.from(event.target.files ?? []), accept);
216
191
  event.target.value = "";
217
- const accepted = [];
218
- const rejected = [];
219
- for (const file of picked) if (matchesAccept(file, accept)) accepted.push(file);
220
- else rejected.push(file);
221
- if (rejected.length > 0) onReject?.(rejected);
222
192
  if (accepted.length > 0) onChange?.(accepted);
193
+ if (rejected.length > 0) onReject?.(rejected);
223
194
  }
224
195
  return /* @__PURE__ */ jsx(FileInputProvider, {
225
196
  value: context,
@@ -255,113 +226,23 @@ const FileInput = {
255
226
  };
256
227
  //#endregion
257
228
  //#region src/hooks/_internal/useCheckSteps.ts
258
- /** Positions probed across the travel. The ends are left out so that the
259
- * clamp at `min` and `max` cannot be mistaken for a press that does nothing. */
260
- const PROBES = [
261
- .1,
262
- .2,
263
- .3,
264
- .4,
265
- .5,
266
- .6,
267
- .7,
268
- .8,
269
- .9
270
- ];
271
- const NONE = {
272
- shiftKey: false,
273
- altKey: false,
274
- ctrlKey: false,
275
- metaKey: false
276
- };
277
- const MODIFIER_STATE = {
278
- shift: {
279
- ...NONE,
280
- shiftKey: true
281
- },
282
- alt: {
283
- ...NONE,
284
- altKey: true
285
- },
286
- ctrl: {
287
- ...NONE,
288
- ctrlKey: true
289
- },
290
- meta: {
291
- ...NONE,
292
- metaKey: true
293
- }
294
- };
295
- /** Each entry of a modifier-aware input option, as a name and held keys. */
296
- function entries(options) {
297
- if (Array.isArray(options)) return [{
298
- name: "",
299
- modifiers: NONE
300
- }];
301
- return Object.keys(options).map((key) => ({
302
- name: key === "default" ? "" : key,
303
- modifiers: MODIFIER_STATE[key] ?? NONE
304
- }));
305
- }
306
- /**
307
- * Press every entry of `options` at nine points along the travel and report
308
- * whether anything came of it.
309
- *
310
- * Run against `applyDelta` itself rather than against a reading of `step`:
311
- * the whole point is that the amount, the step and the scale interact, and
312
- * the pipeline is the only thing that knows how.
313
- */
314
- function probe(options, range, format) {
315
- const { min, max, scale = linearScale } = range;
316
- const outcome = {
317
- moved: false,
318
- visible: false
319
- };
320
- for (const { modifiers } of entries(options)) for (const position of PROBES) {
321
- const value = scale.denormalize(position, min, max);
322
- const up = applyDelta(value, 1, options, range, modifiers);
323
- const down = applyDelta(value, -1, options, range, modifiers);
324
- if (up !== value || down !== value) outcome.moved = true;
325
- if (!format) continue;
326
- const shown = format(value);
327
- if (format(up) !== shown || format(down) !== shown) outcome.visible = true;
328
- }
329
- return outcome;
330
- }
331
229
  /**
332
230
  * Warn, in development only, when a key press or a wheel notch cannot produce
333
- * a change the user can see.
334
- *
335
- * Two settings that are each fine on their own can cancel out, and nothing
336
- * fails when they do — the control simply sits there:
337
- *
338
- * - **`step` coarser than the amount.** `keyboard={['raw', 0.1]}` with
339
- * `step={1}` rounds every press straight back to where it started
340
- * - **the display coarser than the amount.** A `format` showing two decimals
341
- * of a kHz value cannot show a press worth 1 Hz
342
- *
343
- * The second is only reported when the press is invisible at *every* point
344
- * along the travel. A display that rounds is a deliberate choice and is
345
- * normally right — it is being too coarse everywhere that makes it a mistake.
231
+ * a change the user can see. The probing is `checkSteps` in the core.
346
232
  *
347
233
  * @internal
348
234
  */
349
- function useCheckSteps({ component, axis, range, keyboard, wheel, format }) {
235
+ function useCheckSteps(options) {
236
+ const { component, axis, range, keyboard, wheel } = options;
350
237
  const { min, max, step, scale } = range ?? {};
351
- const where = axis ? `${component} (${axis})` : component;
352
238
  useEffect(() => {
353
239
  try {
354
240
  if (process.env.NODE_ENV === "production") return;
355
- if (!range || min === void 0 || max === void 0 || !(min < max)) return;
356
- for (const [name, options] of [["keyboard", keyboard], ["wheel", wheel]]) {
357
- if (!options) continue;
358
- const { moved, visible } = probe(options, range, format);
359
- if (!moved) console.warn(`[tremolo-ui] ${where}: \`${name}\` cannot move the value.` + (step !== void 0 ? ` Each press is smaller than \`step\` (${step}), so it rounds` : " Each press rounds") + " straight back to where it started.");
360
- else if (format && !visible) console.warn(`[tremolo-ui] ${where}: \`${name}\` moves the value, but \`format\` shows the same text before and after, everywhere in the range. The display is too coarse for it to be seen.`);
361
- }
241
+ for (const warning of checkSteps(options)) console.warn(warning);
362
242
  } catch {}
363
243
  }, [
364
- where,
244
+ component,
245
+ axis,
365
246
  min,
366
247
  max,
367
248
  step,
@@ -469,49 +350,6 @@ function useWheel(onWheel, { requireFocus = false, target } = {}) {
469
350
  return setNode;
470
351
  }
471
352
  //#endregion
472
- //#region src/input-event.ts
473
- /**
474
- * The keyboard amount used by Knob, NumberInput, Slider, and XYPad by default:
475
- * 1 per press in the units of the value, and 0.1 with shift held. That is one
476
- * `step` only while `step` is 1; a coarser `step` rounds 1 straight back, which
477
- * `useCheckSteps` warns about.
478
- *
479
- * A modifier entry is not snapped to `step`, which is what lets the finer
480
- * amount move at all — see `applyDelta` in `@tremolo-ui/dom`.
481
- */
482
- const DEFAULT_KEYBOARD_OPTIONS = {
483
- default: ["raw", 1],
484
- shift: ["raw", .1]
485
- };
486
- /**
487
- * The wheel amount used by Knob, NumberInput, Slider, and XYPad by default.
488
- *
489
- * Browsers turn shift+wheel into horizontal scrolling, which empties `deltaY`
490
- * and fills `deltaX`, so no modifier is bound here.
491
- */
492
- const DEFAULT_WHEEL_OPTIONS = ["raw", 1];
493
- /**
494
- * The drag sensitivity used by value controls by default. Shift makes the
495
- * same movement cover a tenth of the range, matching the arrow keys.
496
- */
497
- const DEFAULT_DRAG_SENSITIVITY = {
498
- default: 1,
499
- shift: .1
500
- };
501
- //#endregion
502
- //#region src/components/_util/css-length.ts
503
- /**
504
- * A number as a CSS length, for a custom property.
505
- *
506
- * React appends `px` to a bare number for the properties it knows take a
507
- * length, and a custom property is never one of them: `--size: 50` would come
508
- * out as the invalid `50`. Anything already a string is passed through, so
509
- * `'3rem'` and `'100%'` still work.
510
- */
511
- function cssLength(value) {
512
- return typeof value === "number" ? `${value}px` : value;
513
- }
514
- //#endregion
515
353
  //#region src/components/_util/Placement.tsx
516
354
  /**
517
355
  * The subcomponent that established the current placement, if any.
@@ -553,49 +391,8 @@ function useCheckPlacement(child, parent) {
553
391
  parent
554
392
  ]);
555
393
  }
556
- /**
557
- * 角度から円弧上の点を求める。
558
- *
559
- * 半径は線の太さの分だけ内側に取る必要があるが、太さは ActiveLine / InactiveLine が
560
- * それぞれ持つため、座標はストアで先に計算せず各コンポーネント側で求める。
561
- */
562
- function pointOnArc(angle, radius) {
563
- return {
564
- x: 50 + radius * Math.cos(radian(angle - 90)),
565
- y: 50 + radius * Math.sin(radian(angle - 90))
566
- };
567
- }
568
- /** 線の太さが viewBox からはみ出さないようにした半径 */
569
- function arcRadius(strokeWidth) {
570
- const width = typeof strokeWidth === "number" ? strokeWidth : Number.parseFloat(String(strokeWidth));
571
- return Number.isFinite(width) ? 50 - width / 2 : 50;
572
- }
573
- /** Build an SVG path for an arc, splitting full turns into drawable segments. */
574
- function arcPath(startAngle, endAngle, radius) {
575
- const start = pointOnArc(startAngle, radius);
576
- const sweep = endAngle - startAngle;
577
- const segmentCount = Math.max(1, Math.ceil(Math.abs(sweep) / 180));
578
- const segmentSweep = sweep / segmentCount;
579
- let path = `M ${start.x} ${start.y}`;
580
- for (let i = 1; i <= segmentCount; i += 1) {
581
- const end = pointOnArc(startAngle + segmentSweep * i, radius);
582
- path += ` A ${radius} ${radius} 0 0 ${segmentSweep >= 0 ? 1 : 0} ${end.x} ${end.y}`;
583
- }
584
- return path;
585
- }
586
- /** 設定から描画に必要な角度を導出する。レンダー中に呼ぶ。 */
587
- function calcAngles({ value, min, max, scale, startValue, angleRange }) {
588
- const p = scale.normalize(value, min, max);
589
- const s = scale.normalize(startValue, min, max);
590
- const r1 = -angleRange / 2;
591
- return {
592
- p,
593
- r1,
594
- r2: r1 + Math.min(p, s) * angleRange,
595
- r3: r1 + Math.max(p, s) * angleRange,
596
- r4: angleRange / 2
597
- };
598
- }
394
+ //#endregion
395
+ //#region src/components/Knob/context.tsx
599
396
  const KnobContext = /* @__PURE__ */ createContext(null);
600
397
  const KnobProvider = KnobContext.Provider;
601
398
  function useKnobContext(selector) {
@@ -609,10 +406,10 @@ function ActiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...pr
609
406
  useCheckPlacement("Knob.ActiveLine", "Knob.SVGRoot");
610
407
  const r2 = useKnobContext((s) => s.r2);
611
408
  const r3 = useKnobContext((s) => s.r3);
612
- const radius = arcRadius(strokeWidth);
409
+ const radius = knobArcRadius(strokeWidth);
613
410
  return /* @__PURE__ */ jsx("path", {
614
411
  className,
615
- d: arcPath(r2, r3, radius),
412
+ d: knobArcPath(r2, r3, radius),
616
413
  fill: "none",
617
414
  stroke,
618
415
  strokeWidth,
@@ -630,17 +427,17 @@ function InactiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...
630
427
  const r2 = useKnobContext((s) => s.r2);
631
428
  const r3 = useKnobContext((s) => s.r3);
632
429
  const r4 = useKnobContext((s) => s.r4);
633
- const radius = arcRadius(strokeWidth);
430
+ const radius = knobArcRadius(strokeWidth);
634
431
  return /* @__PURE__ */ jsxs(Fragment, { children: [startValue > min && /* @__PURE__ */ jsx("path", {
635
432
  className,
636
- d: arcPath(r1, r2, radius),
433
+ d: knobArcPath(r1, r2, radius),
637
434
  fill: "none",
638
435
  stroke,
639
436
  strokeWidth,
640
437
  ...props
641
438
  }), startValue < max && /* @__PURE__ */ jsx("path", {
642
439
  className,
643
- d: arcPath(r3, r4, radius),
440
+ d: knobArcPath(r3, r4, radius),
644
441
  fill: "none",
645
442
  stroke,
646
443
  strokeWidth,
@@ -651,7 +448,7 @@ function InactiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...
651
448
  //#region src/components/Knob/SVGRoot.tsx
652
449
  function SVGRoot({ children, style, ...props }) {
653
450
  return /* @__PURE__ */ jsx("svg", {
654
- viewBox: `0 0 100 100`,
451
+ viewBox: `0 0 ${KNOB_VIEWBOX_SIZE} ${KNOB_VIEWBOX_SIZE}`,
655
452
  style: {
656
453
  display: "block",
657
454
  ...style
@@ -681,7 +478,7 @@ function Thumb$2({ className, thumb = "currentColor", thumbLine = "currentColor"
681
478
  }), /* @__PURE__ */ jsx("line", {
682
479
  className: classes?.thumbLine,
683
480
  x1: "50%",
684
- y1: `${(100 - clamp(thumbSize, 0, 100)) / 2}%`,
481
+ y1: `${(KNOB_VIEWBOX_SIZE - clamp(thumbSize, 0, 100)) / 2}%`,
685
482
  x2: "50%",
686
483
  y2: `${thumbLineLength}%`,
687
484
  stroke: thumbLine,
@@ -731,16 +528,10 @@ const Knob = {
731
528
  });
732
529
  const handleKeyDown = useCallback((event) => {
733
530
  if (!keyboard || !onChange || inactive) return;
734
- const key = event.key;
735
- if ([
736
- "ArrowRight",
737
- "ArrowLeft",
738
- "ArrowUp",
739
- "ArrowDown"
740
- ].includes(key)) {
741
- event.preventDefault();
742
- onChange(applyDelta(value, key === "ArrowRight" || key === "ArrowUp" ? 1 : -1, keyboard, range, event));
743
- }
531
+ const direction = arrowKeyDirection(event.key);
532
+ if (direction === null) return;
533
+ event.preventDefault();
534
+ onChange(applyDelta(value, direction, keyboard, range, event));
744
535
  }, [
745
536
  keyboard,
746
537
  onChange,
@@ -766,9 +557,11 @@ const Knob = {
766
557
  });
767
558
  const wheelRefCallback = useWheel((event) => {
768
559
  if (!wheel || inactive) return;
560
+ const direction = wheelDirection(event);
561
+ if (direction === null) return;
769
562
  event.preventDefault();
770
- if (!onChange || event.deltaY === 0) return;
771
- onChange(applyDelta(value, -Math.sign(event.deltaY), wheel, range, event));
563
+ if (!onChange) return;
564
+ onChange(applyDelta(value, direction, wheel, range, event));
772
565
  }, WHEEL_OPTIONS$2);
773
566
  const rootRefCallback = useComposedRefs(elmRef, dragRefCallback, wheelRefCallback);
774
567
  const context = useMemo(() => {
@@ -783,7 +576,7 @@ const Knob = {
783
576
  };
784
577
  return {
785
578
  ...config,
786
- ...calcAngles(config)
579
+ ...knobAngles(config)
787
580
  };
788
581
  }, [
789
582
  value,
@@ -857,82 +650,36 @@ function useStepperContext() {
857
650
  return useContext(StepperContext);
858
651
  }
859
652
  //#endregion
860
- //#region src/hooks/useEventListener.ts
861
- function useEventListener(target, event, handler, options) {
862
- const listener = useCallbackRef(handler);
863
- const cleanupRef = useRef(() => {});
864
- useEffect(() => {
865
- const node = typeof target === "function" ? target() : target ?? document;
866
- if (!node) return;
867
- node.addEventListener(event, listener, options);
868
- const cleanup = () => {
869
- node.removeEventListener(event, listener, options);
870
- };
871
- cleanupRef.current = cleanup;
872
- return () => {
873
- cleanup();
874
- if (cleanupRef.current === cleanup) cleanupRef.current = () => {};
875
- };
876
- }, [
877
- event,
878
- target,
879
- options,
880
- listener
881
- ]);
882
- return useCallback(() => cleanupRef.current(), []);
883
- }
884
- //#endregion
885
- //#region src/hooks/useInterval.ts
886
- function useInterval(callback, delay) {
887
- const savedCallback = useRef(callback);
653
+ //#region src/hooks/useLongPress.ts
654
+ /**
655
+ * Repeat `callback` while a pointer is held down: once on the press, then
656
+ * every `interval` after `initialDelay`. The repeat itself is
657
+ * `createLongPress` in `@tremolo-ui/dom`.
658
+ *
659
+ * Returns the function that starts a press, for an `onPointerDown`.
660
+ */
661
+ function useLongPress(callback, initialDelay = 500, interval = 40) {
662
+ const onPress = useCallbackRef(callback);
663
+ const instanceRef = useRef(null);
888
664
  useEffect(() => {
889
- savedCallback.current = callback;
890
- }, [callback]);
665
+ instanceRef.current?.update({
666
+ delay: initialDelay,
667
+ interval
668
+ });
669
+ });
891
670
  useEffect(() => {
892
- if (delay === null) return;
893
- const id = setInterval(() => {
894
- savedCallback.current();
895
- }, delay);
671
+ const instance = createLongPress({
672
+ onPress: () => onPress(),
673
+ delay: initialDelay,
674
+ interval
675
+ });
676
+ instanceRef.current = instance;
896
677
  return () => {
897
- clearInterval(id);
678
+ instanceRef.current = null;
679
+ instance.destroy();
898
680
  };
899
- }, [delay]);
900
- }
901
- //#endregion
902
- //#region src/hooks/useLongPress.ts
903
- function useLongPress(callback, initialDelay = 500, interval = 40) {
904
- const [pressed, setPressed] = useState(false);
905
- const [delay, setDelay] = useState(initialDelay);
906
- const activePointerId = useRef(null);
907
- const isPressed = useRef(false);
908
- useInterval(() => {
909
- callback();
910
- setDelay(interval);
911
- }, pressed ? delay : null);
912
- const stop = useCallback((pointerId) => {
913
- if (!isPressed.current) return;
914
- if (pointerId !== void 0 && activePointerId.current !== null && activePointerId.current !== pointerId) return;
915
- isPressed.current = false;
916
- activePointerId.current = null;
917
- setPressed(false);
918
- setDelay(initialDelay);
919
- }, [initialDelay]);
920
- useEventListener(globalThis.window, "pointerup", (event) => {
921
- stop(event.pointerId);
922
- });
923
- useEventListener(globalThis.window, "pointercancel", (event) => {
924
- stop(event.pointerId);
925
- });
926
- useEventListener(globalThis.window, "blur", () => {
927
- stop();
928
- });
929
- return useCallback((event) => {
930
- if (isPressed.current || event && event.button !== 0) return;
931
- isPressed.current = true;
932
- activePointerId.current = event?.pointerId ?? null;
933
- callback();
934
- setPressed(true);
935
- }, [callback]);
681
+ }, []);
682
+ return useCallback((event) => instanceRef.current?.start(event), []);
936
683
  }
937
684
  //#endregion
938
685
  //#region src/components/NumberInput/StepperButton.tsx
@@ -944,7 +691,7 @@ function StepperButton({ direction, variant, icon, className, style, children, o
944
691
  const { step, disabled, readonly, atMin, atMax, nudge } = useNumberInputContext();
945
692
  const stepper = useStepperContext();
946
693
  const press = useLongPress(() => {
947
- if (stepper?.draggingRef.current) return;
694
+ if (stepper?.moved()) return;
948
695
  nudge(direction, ["raw", step]);
949
696
  });
950
697
  return /* @__PURE__ */ jsx("div", {
@@ -1003,21 +750,6 @@ function IncrementStepper(props) {
1003
750
  }
1004
751
  //#endregion
1005
752
  //#region src/components/NumberInput/InputField.tsx
1006
- /** The leading number of the displayed text, whatever the format put around it. */
1007
- const NUMBER_PREFIX = /^\s*-?[\d.,]*/;
1008
- const numberEnd = (text) => text.match(NUMBER_PREFIX)?.[0].length ?? text.length;
1009
- /**
1010
- * The index the caret is measured against: the decimal point, or where one
1011
- * would go if the number has none.
1012
- *
1013
- * Measuring from an end instead would slide the caret across a digit whenever
1014
- * the number changed length — `9.9` to `10.0` gains a character in front, `10`
1015
- * to `9` loses one — which is exactly what stepping does.
1016
- */
1017
- const decimalAnchor = (text) => {
1018
- const dot = text.indexOf(".");
1019
- return dot === -1 ? numberEnd(text) : dot;
1020
- };
1021
753
  /**
1022
754
  * The text field of a `NumberInput`, and the only place the value can be typed.
1023
755
  *
@@ -1027,7 +759,7 @@ const decimalAnchor = (text) => {
1027
759
  * How it behaves on focus, Enter and the arrow keys is set on `Root`
1028
760
  * (`selectOnFocus`, `unformatOnFocus`, `keepCaretOnStep`, `blurOnEnter`).
1029
761
  */
1030
- const InputField = /* @__PURE__ */ forwardRef(function InputField({ className, style, onFocus, onBlur, onKeyDown, ...props }, forwardedRef) {
762
+ const InputField = /* @__PURE__ */ forwardRef(function InputField({ className, style, onChange, onFocus, onBlur, onKeyDown, ...props }, forwardedRef) {
1031
763
  const { value, min, max, step, disabled, readonly, keyboard, selectOnFocus, unformatOnFocus, keepCaretOnStep, blurOnEnter, text, editing, outOfRange, setDraft, commitDraft, nudge, inputRef } = useNumberInputContext();
1032
764
  const [focused, setFocused] = useState(false);
1033
765
  const shown = unformatOnFocus && focused && !editing ? String(value) : text;
@@ -1036,7 +768,10 @@ const InputField = /* @__PURE__ */ forwardRef(function InputField({ className, s
1036
768
  const input = inputRef.current;
1037
769
  if (!input) return;
1038
770
  if (selectOnFocus === "all") input.setSelectionRange(0, input.value.length);
1039
- else input.setSelectionRange(0, input.value.match(NUMBER_PREFIX)?.[0].length ?? 0);
771
+ else {
772
+ const span = numberSpan(input.value);
773
+ if (span) input.setSelectionRange(span.start, span.end);
774
+ }
1040
775
  }, [
1041
776
  focused,
1042
777
  selectOnFocus,
@@ -1053,8 +788,7 @@ const InputField = /* @__PURE__ */ forwardRef(function InputField({ className, s
1053
788
  caretAfterStep.current = null;
1054
789
  const input = inputRef.current;
1055
790
  if (!input || input.value === pending.from) return;
1056
- const place = decimalAnchor(input.value) + pending.offset;
1057
- const caret = Math.max(0, Math.min(place, numberEnd(input.value)));
791
+ const caret = caretAtDecimalOffset(input.value, pending.offset);
1058
792
  input.setSelectionRange(caret, caret);
1059
793
  });
1060
794
  const composedRef = useComposedRefs(forwardedRef, inputRef);
@@ -1081,6 +815,7 @@ const InputField = /* @__PURE__ */ forwardRef(function InputField({ className, s
1081
815
  onChange: (event) => {
1082
816
  caretAfterStep.current = null;
1083
817
  setDraft(event.currentTarget.value);
818
+ onChange?.(event);
1084
819
  },
1085
820
  onFocus: (event) => {
1086
821
  setFocused(true);
@@ -1100,7 +835,7 @@ const InputField = /* @__PURE__ */ forwardRef(function InputField({ className, s
1100
835
  event.preventDefault();
1101
836
  const input = event.currentTarget;
1102
837
  if (keepCaretOnStep && input.selectionStart !== null) caretAfterStep.current = {
1103
- offset: input.selectionStart - decimalAnchor(input.value),
838
+ offset: caretDecimalOffset(input.value, input.selectionStart),
1104
839
  from: input.value
1105
840
  };
1106
841
  nudge(key === "ArrowUp" ? 1 : -1, keyboard, event);
@@ -1111,117 +846,54 @@ const InputField = /* @__PURE__ */ forwardRef(function InputField({ className, s
1111
846
  });
1112
847
  });
1113
848
  //#endregion
1114
- //#region src/hooks/useDrag.ts
1115
- /**
1116
- * Track a pointer drag on an element.
1117
- *
1118
- * @returns a ref callback to attach to the element being dragged
1119
- */
1120
- function useDrag({ threshold = 1, cursor, pointerLock, onDrag, onDragStart, onDragEnd }) {
1121
- const dragHandler = useCallbackRef(onDrag);
1122
- const dragStartHandler = useCallbackRef(onDragStart);
1123
- const dragEndHandler = useCallbackRef(onDragEnd);
1124
- const [node, setNode] = useState(null);
1125
- useEffect(() => {
1126
- if (!node) return;
1127
- const instance = createDrag(node, {
1128
- threshold,
1129
- cursor,
1130
- pointerLock,
1131
- onDragStart: (state) => dragStartHandler(state),
1132
- onDrag: (state) => dragHandler(state.x, state.y, state.deltaX, state.deltaY, state),
1133
- onDragEnd: (state) => dragEndHandler(state)
1134
- });
1135
- return () => instance.destroy();
1136
- }, [
1137
- node,
1138
- threshold,
1139
- cursor,
1140
- pointerLock,
1141
- dragHandler,
1142
- dragStartHandler,
1143
- dragEndHandler
1144
- ]);
1145
- return setNode;
1146
- }
1147
- //#endregion
1148
849
  //#region src/components/NumberInput/Stepper.tsx
1149
850
  /**
1150
851
  * The area the steppers sit in, and a drag handle in its own right: dragging it
1151
- * up and down moves the value one `step` every `drag` pixels.
852
+ * up and down moves the value one `step` every `drag` pixels. The counting is
853
+ * `createStepperDrag` in the core.
1152
854
  *
1153
855
  * The drag lives here rather than on `InputField` because `createDrag` turns
1154
856
  * off text selection on whatever element it is attached to.
1155
857
  */
1156
858
  const Stepper = /* @__PURE__ */ forwardRef(function Stepper({ className, style, children, ...props }, forwardedRef) {
1157
- const { value, step, disabled, readonly, drag, dragSensitivity, pointerLock, rawRange, changeValue } = useNumberInputContext();
1158
- const inactive = disabled || readonly;
1159
- /**
1160
- * The sensitivity as an amount per `drag` pixels, so that the drag goes
1161
- * through the same `applyDelta` the wheel and the arrow keys do. Carrying
1162
- * the modifier map over rather than resolving it here is what keeps `step`
1163
- * out of the pipeline for a modifier entry — naming one is a request to move
1164
- * off the grid.
1165
- */
1166
- const dragOptions = useMemo(() => mapModifier(dragSensitivity, (factor) => ["raw", step * factor]), [dragSensitivity, step]);
1167
- /**
1168
- * Set once the drag has actually moved the value, so that the press-and-hold
1169
- * repeat of a stepper stands down and leaves the value to the drag. A ref,
1170
- * because it is read from inside the repeat rather than rendered.
1171
- */
1172
- const draggingRef = useRef(false);
1173
- /**
1174
- * Where the drag started, taken on the first move rather than on pointer
1175
- * down: the steppers act on pointer down, so by then the value may already
1176
- * have been nudged once, and the drag should carry on from there.
1177
- */
1178
- const originRef = useRef(null);
1179
- /**
1180
- * Which modifier the drag is currently counting at, and where the previous
1181
- * event was — a key produces no pointer event of its own, so a change is
1182
- * only seen on the next move and has to be dated back to the one before it.
1183
- */
1184
- const factorRef = useRef(1);
1185
- const previousYRef = useRef(0);
1186
- const dragRefCallback = useDrag({
1187
- threshold: 1,
1188
- cursor: inactive ? void 0 : "ns-resize",
1189
- pointerLock: inactive ? false : pointerLock,
1190
- onDragStart: (state) => {
1191
- originRef.current = null;
1192
- draggingRef.current = false;
1193
- factorRef.current = selectModifier(dragSensitivity, state.event).value;
1194
- },
1195
- onDrag: (_x, y, _dx, _dy, state) => {
1196
- if (inactive || drag === null) return;
1197
- if (!originRef.current) {
1198
- originRef.current = {
1199
- y,
1200
- value
1201
- };
1202
- previousYRef.current = y;
1203
- return;
1204
- }
1205
- const factor = selectModifier(dragSensitivity, state.event).value;
1206
- if (factor !== factorRef.current) {
1207
- originRef.current = {
1208
- y: previousYRef.current,
1209
- value
1210
- };
1211
- factorRef.current = factor;
1212
- }
1213
- previousYRef.current = y;
1214
- const steps = Math.round(-(y - originRef.current.y) / drag);
1215
- if (steps === 0) return;
1216
- draggingRef.current = true;
1217
- changeValue(applyDelta(originRef.current.value, steps, dragOptions, rawRange, state.event));
1218
- },
1219
- onDragEnd: () => {
1220
- draggingRef.current = false;
1221
- }
859
+ const { value, disabled, readonly, drag, dragSensitivity, pointerLock, rawRange, changeValue } = useNumberInputContext();
860
+ const enabled = drag !== null && !disabled && !readonly;
861
+ const [node, setNode] = useState(null);
862
+ const instanceRef = useRef(null);
863
+ const latest = useRef({
864
+ value,
865
+ changeValue
866
+ });
867
+ const options = {
868
+ range: rawRange,
869
+ pixels: drag ?? 1,
870
+ sensitivity: dragSensitivity,
871
+ pointerLock
872
+ };
873
+ const optionsRef = useRef(options);
874
+ useEffect(() => {
875
+ latest.current = {
876
+ value,
877
+ changeValue
878
+ };
879
+ optionsRef.current = options;
880
+ instanceRef.current?.update(options);
1222
881
  });
1223
- const composedRef = useComposedRefs(forwardedRef, drag !== null && !inactive ? dragRefCallback : void 0);
1224
- const context = useMemo(() => ({ draggingRef }), []);
882
+ useEffect(() => {
883
+ if (!node || !enabled) return;
884
+ const instance = createStepperDrag(node, {
885
+ ...optionsRef.current,
886
+ getValue: () => latest.current.value,
887
+ onChange: (next) => latest.current.changeValue(next)
888
+ });
889
+ instanceRef.current = instance;
890
+ return () => {
891
+ instanceRef.current = null;
892
+ instance.destroy();
893
+ };
894
+ }, [node, enabled]);
895
+ const composedRef = useComposedRefs(forwardedRef, setNode);
896
+ const context = useMemo(() => ({ moved: () => instanceRef.current?.moved() ?? false }), []);
1225
897
  return /* @__PURE__ */ jsx(StepperProvider, {
1226
898
  value: context,
1227
899
  children: /* @__PURE__ */ jsx("div", {
@@ -1235,17 +907,7 @@ const Stepper = /* @__PURE__ */ forwardRef(function Stepper({ className, style,
1235
907
  });
1236
908
  //#endregion
1237
909
  //#region src/components/NumberInput/index.tsx
1238
- /**
1239
- * A number at the start of the text, and nothing read after it. A half-typed
1240
- * entry still yields the number in front of it, but text with no number is
1241
- * `NaN` rather than 0, so that "unreadable" and "the user typed 0" stay apart.
1242
- */
1243
- const LEADING_NUMBER = /^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)/;
1244
910
  const defaultFormat = (value) => String(value);
1245
- const defaultParse = (text) => {
1246
- const match = text.match(LEADING_NUMBER);
1247
- return match ? Number(match[1]) : NaN;
1248
- };
1249
911
  /**
1250
912
  * Input with some useful functions for entering numerical values.
1251
913
  */
@@ -1259,24 +921,13 @@ const NumberInput = {
1259
921
  const [draft, setDraft] = useState(null);
1260
922
  const inactive = disabled || readonly;
1261
923
  const format = formatProp ?? defaultFormat;
1262
- const parse = parseProp ?? defaultParse;
1263
- const range = useMemo(() => ({
1264
- min: (clampValue ? min : void 0) ?? Number.MIN_SAFE_INTEGER,
1265
- max: (clampValue ? max : void 0) ?? Number.MAX_SAFE_INTEGER,
1266
- step,
1267
- scale
1268
- }), [
1269
- clampValue,
924
+ const parse = parseProp ?? parseNumberText;
925
+ const { normalized: range, raw: rawRange } = useMemo(() => numberInputRanges({
1270
926
  min,
1271
927
  max,
1272
928
  step,
1273
- scale
1274
- ]);
1275
- const rawRange = useMemo(() => ({
1276
- min: (clampValue ? min : void 0) ?? -Number.MAX_VALUE,
1277
- max: (clampValue ? max : void 0) ?? Number.MAX_VALUE,
1278
- step,
1279
- scale
929
+ scale,
930
+ clampValue
1280
931
  }), [
1281
932
  clampValue,
1282
933
  min,
@@ -1284,6 +935,10 @@ const NumberInput = {
1284
935
  step,
1285
936
  scale
1286
937
  ]);
938
+ const ranges = useMemo(() => ({
939
+ normalized: range,
940
+ raw: rawRange
941
+ }), [range, rawRange]);
1287
942
  useCheckSteps({
1288
943
  component: "NumberInput",
1289
944
  range: useMemo(() => min !== void 0 && max !== void 0 && min < max ? {
@@ -1303,7 +958,13 @@ const NumberInput = {
1303
958
  });
1304
959
  const text = draft ?? format(value);
1305
960
  const editing = draft !== null;
1306
- const outOfRange = !editing && (min !== void 0 && value < min || max !== void 0 && value > max);
961
+ const bounds = numberInputBounds(value, {
962
+ min,
963
+ max,
964
+ clampValue
965
+ });
966
+ const { atMin, atMax } = bounds;
967
+ const outOfRange = !editing && bounds.outOfRange;
1307
968
  const handleDraft = useCallback((next) => {
1308
969
  if (inactive) return;
1309
970
  setDraft(next);
@@ -1325,15 +986,13 @@ const NumberInput = {
1325
986
  ]);
1326
987
  const commitDraft = useCallback(() => {
1327
988
  if (draft === null || inactive) return;
1328
- const parsed = parse(draft);
1329
- if (!Number.isFinite(parsed)) {
1330
- setDraft(null);
1331
- return;
1332
- }
1333
- let committed = parsed;
1334
- if (clampValue && min !== void 0) committed = Math.max(committed, min);
1335
- if (clampValue && max !== void 0) committed = Math.min(committed, max);
1336
- changeValue(committed);
989
+ const committed = commitNumberInputText(draft, parse, {
990
+ min,
991
+ max,
992
+ clampValue
993
+ });
994
+ if (committed === null) setDraft(null);
995
+ else changeValue(committed);
1337
996
  }, [
1338
997
  draft,
1339
998
  inactive,
@@ -1344,18 +1003,17 @@ const NumberInput = {
1344
1003
  changeValue
1345
1004
  ]);
1346
1005
  const nudge = useCallback((direction, option, modifiers) => {
1347
- const [mode] = selectModifier(option, modifiers).value;
1348
- changeValue(applyDelta(value, direction, option, mode === "raw" ? rawRange : range, modifiers));
1006
+ changeValue(nudgeNumberInput(value, direction, option, ranges, modifiers));
1349
1007
  }, [
1350
1008
  changeValue,
1351
1009
  value,
1352
- rawRange,
1353
- range
1010
+ ranges
1354
1011
  ]);
1355
1012
  const wheelRefCallback = useWheel((event) => {
1356
- if (!wheel || inactive || event.deltaY === 0) return;
1013
+ const direction = wheelDirection(event);
1014
+ if (!wheel || inactive || direction === null) return;
1357
1015
  event.preventDefault();
1358
- nudge(-Math.sign(event.deltaY), wheel, event);
1016
+ nudge(direction, wheel, event);
1359
1017
  }, { requireFocus: true });
1360
1018
  const context = useMemo(() => ({
1361
1019
  value,
@@ -1378,8 +1036,8 @@ const NumberInput = {
1378
1036
  unformatOnFocus,
1379
1037
  keepCaretOnStep,
1380
1038
  blurOnEnter,
1381
- atMin: clampValue && min !== void 0 && value <= min,
1382
- atMax: clampValue && max !== void 0 && value >= max,
1039
+ atMin,
1040
+ atMax,
1383
1041
  drag,
1384
1042
  format,
1385
1043
  parse,
@@ -1403,6 +1061,8 @@ const NumberInput = {
1403
1061
  text,
1404
1062
  editing,
1405
1063
  outOfRange,
1064
+ atMin,
1065
+ atMax,
1406
1066
  drag,
1407
1067
  dragSensitivity,
1408
1068
  pointerLock,
@@ -1445,78 +1105,15 @@ const NumberInput = {
1445
1105
  IncrementStepper,
1446
1106
  DecrementStepper
1447
1107
  };
1448
- //#endregion
1449
- //#region src/components/Piano/keyboard-shortcuts.ts
1450
- /**
1451
- * Ready-made keyboard layouts. Both assume `noteRange.first` is a C.
1452
- */
1453
- const SHORTCUTS = {
1454
- /** Every semitone from C, over the two rows of a QWERTY keyboard. */
1455
- HOME_ROW: { keys: [
1456
- "a",
1457
- "w",
1458
- "s",
1459
- "e",
1460
- "d",
1461
- "f",
1462
- "t",
1463
- "g",
1464
- "y",
1465
- "h",
1466
- "u",
1467
- "j",
1468
- "k",
1469
- "o",
1470
- "l",
1471
- "p",
1472
- ";"
1473
- ] },
1474
- /** The white keys only, on the home row. Black keys have no shortcut. */
1475
- HOME_ROW_NATURAL: { keys: [
1476
- "a",
1477
- "",
1478
- "s",
1479
- "",
1480
- "d",
1481
- "f",
1482
- "",
1483
- "g",
1484
- "",
1485
- "h",
1486
- "",
1487
- "j",
1488
- "k",
1489
- "",
1490
- "l",
1491
- "",
1492
- ";"
1493
- ] }
1494
- };
1495
- //#endregion
1496
- //#region src/components/Piano/index.tsx
1497
- function isEditableTarget(target) {
1498
- if (!(target instanceof HTMLElement)) return false;
1499
- if (target.matches("input, textarea, select")) return true;
1500
- for (let element = target; element;) {
1501
- const contentEditable = element.getAttribute("contenteditable");
1502
- if (contentEditable !== null) return contentEditable.toLowerCase() !== "false";
1503
- element = element.parentElement;
1504
- }
1505
- return false;
1506
- }
1507
- function shortcutKey(event) {
1508
- return event.code || event.key;
1509
- }
1510
1108
  /**
1511
1109
  * Customizable piano component.
1512
1110
  */
1513
- const Piano = { Root: /* @__PURE__ */ forwardRef(function Root({ noteRange, glissando = true, midiMax = 127, keyboardShortcuts, keyboardShortcutsScope = "root", resizable = false, whiteKeyWidth = 40, keyGap = 1, blackKeyWidthRatio = .65, blackKeyHeightRatio = .6, height, style, className, label, keyProps, classes, onPlayNote, onStopNote, ...props }, forwardedRef) {
1111
+ 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, style, className, label, keyProps, classes, onPlayNote, onStopNote, ...props }, forwardedRef) {
1514
1112
  const [node, setNode] = useState(null);
1515
1113
  const [activeNotes, setActiveNotes] = useState([]);
1516
1114
  /** Set while `resizable` is on, measured from the parent. */
1517
1115
  const [resizedKeyWidth, setResizedKeyWidth] = useState(whiteKeyWidth);
1518
1116
  const notes = useMemo(() => getNoteRangeArray(noteRange), [noteRange]);
1519
- const whiteKeyCount = useMemo(() => notes.filter(isWhiteKey).length, [notes]);
1520
1117
  const layout = useMemo(() => ({
1521
1118
  noteRange,
1522
1119
  whiteKeyWidth: resizable ? resizedKeyWidth : whiteKeyWidth,
@@ -1536,21 +1133,20 @@ const Piano = { Root: /* @__PURE__ */ forwardRef(function Root({ noteRange, glis
1536
1133
  layout,
1537
1134
  glissando,
1538
1135
  midiMax,
1136
+ keyboardShortcuts,
1137
+ keyboardShortcutsScope,
1539
1138
  onPlayNote,
1540
1139
  onStopNote
1541
1140
  });
1542
1141
  const instanceRef = useRef(null);
1543
- const shortcutNotes = useRef(/* @__PURE__ */ new Map());
1544
- const releaseShortcutNotes = useCallback(() => {
1545
- for (const { note, source } of shortcutNotes.current.values()) instanceRef.current?.noteOff(note, { source });
1546
- shortcutNotes.current.clear();
1547
- }, []);
1548
1142
  useEffect(() => {
1549
1143
  if (!node) return;
1550
1144
  const instance = createPianoInput(node, {
1551
1145
  layout: latest.current.layout,
1552
1146
  glissando: latest.current.glissando,
1553
1147
  midiMax: latest.current.midiMax,
1148
+ keyboardShortcuts: latest.current.keyboardShortcuts,
1149
+ keyboardShortcutsScope: latest.current.keyboardShortcutsScope,
1554
1150
  onPlayNote: (note, velocity) => latest.current.onPlayNote?.(note, velocity),
1555
1151
  onStopNote: (note) => latest.current.onStopNote?.(note),
1556
1152
  onActiveNotesChange: setActiveNotes
@@ -1566,86 +1162,44 @@ const Piano = { Root: /* @__PURE__ */ forwardRef(function Root({ noteRange, glis
1566
1162
  layout,
1567
1163
  glissando,
1568
1164
  midiMax,
1165
+ keyboardShortcuts,
1166
+ keyboardShortcutsScope,
1569
1167
  onPlayNote,
1570
1168
  onStopNote
1571
1169
  };
1572
1170
  instanceRef.current?.update({
1573
1171
  layout,
1574
1172
  glissando,
1575
- midiMax
1173
+ midiMax,
1174
+ keyboardShortcuts,
1175
+ keyboardShortcutsScope
1576
1176
  });
1577
1177
  });
1178
+ const { first, last } = noteRange;
1578
1179
  useEffect(() => {
1579
1180
  if (!resizable || !node) return;
1580
1181
  const parent = node.parentElement;
1581
1182
  if (!parent) throw new Error("doesn't have a parent element.");
1582
1183
  const resizeObserver = new ResizeObserver(() => {
1583
- setResizedKeyWidth(node.clientWidth / Math.max(whiteKeyCount, 1) - keyGap);
1184
+ setResizedKeyWidth(fitWhiteKeyWidth(node.clientWidth, {
1185
+ noteRange: {
1186
+ first,
1187
+ last
1188
+ },
1189
+ keyGap,
1190
+ blackKeyWidthRatio
1191
+ }));
1584
1192
  });
1585
1193
  resizeObserver.observe(parent);
1586
1194
  return () => resizeObserver.disconnect();
1587
1195
  }, [
1588
1196
  resizable,
1589
1197
  node,
1590
- whiteKeyCount,
1591
- keyGap
1592
- ]);
1593
- const shortcutKeys = keyboardShortcuts?.keys;
1594
- const hasShortcuts = shortcutKeys !== void 0;
1595
- const shortcutMapping = shortcutKeys?.join("\0");
1596
- useEffect(() => releaseShortcutNotes, [
1597
- releaseShortcutNotes,
1598
- shortcutMapping,
1599
- keyboardShortcutsScope,
1600
- noteRange.first,
1601
- noteRange.last
1602
- ]);
1603
- /** The note a shortcut key plays, or null when it has none. */
1604
- function shortcutNote(key) {
1605
- if (!shortcutKeys || key === "") return null;
1606
- const index = shortcutKeys.indexOf(key);
1607
- const note = noteRange.first + index;
1608
- return index === -1 || note > noteRange.last ? null : note;
1609
- }
1610
- const shortcutTarget = useMemo(() => () => {
1611
- if (!hasShortcuts) return null;
1612
- return keyboardShortcutsScope === "window" ? globalThis.window : node;
1613
- }, [
1614
- hasShortcuts,
1615
- keyboardShortcutsScope,
1616
- node
1617
- ]);
1618
- const focusOutTarget = useMemo(() => () => hasShortcuts && keyboardShortcutsScope === "root" ? node : null, [
1619
- hasShortcuts,
1620
- keyboardShortcutsScope,
1621
- node
1198
+ first,
1199
+ last,
1200
+ keyGap,
1201
+ blackKeyWidthRatio
1622
1202
  ]);
1623
- const windowBlurTarget = useMemo(() => () => hasShortcuts ? globalThis.window : null, [hasShortcuts]);
1624
- useEventListener(shortcutTarget, "keydown", (e) => {
1625
- const key = shortcutKey(e);
1626
- if (e.repeat || shortcutNotes.current.has(key)) return;
1627
- if (isEditableTarget(e.target)) return;
1628
- const note = shortcutNote(e.key);
1629
- if (note === null) return;
1630
- const source = `keyboard:${key}`;
1631
- shortcutNotes.current.set(key, {
1632
- note,
1633
- source
1634
- });
1635
- instanceRef.current?.noteOn(note, { source });
1636
- });
1637
- useEventListener(shortcutTarget, "keyup", (e) => {
1638
- const key = shortcutKey(e);
1639
- const shortcut = shortcutNotes.current.get(key);
1640
- if (!shortcut) return;
1641
- shortcutNotes.current.delete(key);
1642
- instanceRef.current?.noteOff(shortcut.note, { source: shortcut.source });
1643
- });
1644
- useEventListener(focusOutTarget, "focusout", (e) => {
1645
- if (e.relatedTarget instanceof Node && node?.contains(e.relatedTarget)) return;
1646
- releaseShortcutNotes();
1647
- });
1648
- useEventListener(windowBlurTarget, "blur", releaseShortcutNotes);
1649
1203
  useImperativeHandle(forwardedRef, () => ({
1650
1204
  playNote: (note, velocity) => instanceRef.current?.noteOn(note, {
1651
1205
  source: "api",
@@ -1661,7 +1215,6 @@ const Piano = { Root: /* @__PURE__ */ forwardRef(function Root({ noteRange, glis
1661
1215
  tabIndex: 0,
1662
1216
  style: {
1663
1217
  width: resizable ? "100%" : pianoWidth(layout),
1664
- "--height": cssLength(height),
1665
1218
  position: "relative",
1666
1219
  ...style
1667
1220
  },
@@ -1727,6 +1280,49 @@ function usePointsEditorContext(selector) {
1727
1280
  return selector ? selector(context) : context;
1728
1281
  }
1729
1282
  //#endregion
1283
+ //#region src/components/PointsEditor/Container.tsx
1284
+ const Container = /* @__PURE__ */ forwardRef(function Container({ children, className, style, ...props }, forwardedRef) {
1285
+ const { containerRef, disabled, selectable, editor } = usePointsEditorContext();
1286
+ const { refCallback: dragRefCallback } = useDragValue({
1287
+ axis: POINT_AXIS,
1288
+ baseElementRef: containerRef,
1289
+ shouldStart: (event) => !disabled && !editor.isPointElement(event.target),
1290
+ onDragStart: ([x, y], state) => {
1291
+ editor.beginSelectionBox({
1292
+ x,
1293
+ y
1294
+ }, state.event);
1295
+ },
1296
+ onChange: ([x, y]) => editor.moveSelectionBox({
1297
+ x,
1298
+ y
1299
+ }),
1300
+ onDragEnd: () => editor.endSelectionBox()
1301
+ });
1302
+ useWheel((event) => {
1303
+ const move = wheelMove(event);
1304
+ if (!move) return;
1305
+ const axis = move.axis === 0 ? "x" : "y";
1306
+ if (editor.nudgeFocusedPoint(axis, move.direction, event)) event.preventDefault();
1307
+ }, { target: containerRef });
1308
+ const composedRef = useComposedRefs(forwardedRef, containerRef, selectable && !disabled ? dragRefCallback : void 0);
1309
+ return /* @__PURE__ */ jsx("div", {
1310
+ ref: composedRef,
1311
+ className,
1312
+ style: {
1313
+ position: "absolute",
1314
+ inset: 0,
1315
+ zIndex: 10,
1316
+ ...style
1317
+ },
1318
+ ...props,
1319
+ children: /* @__PURE__ */ jsx(Placement, {
1320
+ name: "PointsEditor.Container",
1321
+ children
1322
+ })
1323
+ });
1324
+ });
1325
+ //#endregion
1730
1326
  //#region src/components/_util/VisuallyHiddenRangeInput.tsx
1731
1327
  const VisuallyHiddenRangeInput = /* @__PURE__ */ forwardRef((props, ref) => /* @__PURE__ */ jsx("input", {
1732
1328
  ...props,
@@ -1736,21 +1332,6 @@ const VisuallyHiddenRangeInput = /* @__PURE__ */ forwardRef((props, ref) => /* @
1736
1332
  }));
1737
1333
  //#endregion
1738
1334
  //#region src/components/PointsEditor/Point.tsx
1739
- function clampPoint(point, min, max) {
1740
- const { x, y } = point;
1741
- return {
1742
- x: clamp(x, min?.x ?? 0, max?.x ?? 1),
1743
- y: clamp(y, min?.y ?? 0, max?.y ?? 1)
1744
- };
1745
- }
1746
- /**
1747
- * A point is placed by its position within the container, so its value is a
1748
- * position: 0..1 on each axis, with y growing downwards.
1749
- */
1750
- const AXIS = {
1751
- min: 0,
1752
- max: 1
1753
- };
1754
1335
  /**
1755
1336
  * One setting for both axes, or one per axis. The point holds a range input
1756
1337
  * for each, so anything that names or describes it comes in a pair.
@@ -1758,8 +1339,8 @@ const AXIS = {
1758
1339
  function perAxis(value, axis) {
1759
1340
  return typeof value === "string" ? value : value?.[axis];
1760
1341
  }
1761
- function Point({ value, children, id: idProp, min, max, size, width, height, color, disabled: _disabled, readonly: _readonly, wheel: _wheel, keyboard: _keyboard, "aria-label": ariaLabel, "aria-valuetext": ariaValuetext, onChange, onDragStart, onDragEnd, className, style, onPointerDown, onKeyDown, onFocus, ...props }) {
1762
- const { containerRef, externalStyles, disabled: rootDisabled, readonly: rootReadonly, wheel: rootWheel, keyboard: rootKeyboard, dragSensitivity, selection, registerPoint, beginPointDrag, movePointDrag, nudgeSelection } = usePointsEditorContext();
1342
+ 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 }) {
1343
+ const { containerRef, externalStyles, disabled: rootDisabled, readonly: rootReadonly, wheel: rootWheel, keyboard: rootKeyboard, dragSensitivity, selection, editor } = usePointsEditorContext();
1763
1344
  const generatedId = useId();
1764
1345
  const id = idProp ?? generatedId;
1765
1346
  const selected = selection.includes(id);
@@ -1792,11 +1373,11 @@ function Point({ value, children, id: idProp, min, max, size, width, height, col
1792
1373
  wheel
1793
1374
  };
1794
1375
  });
1795
- useEffect(() => registerPoint(id, registration), [id, registerPoint]);
1376
+ useEffect(() => editor.registerPoint(id, () => registration.current), [id, editor]);
1796
1377
  /** Where the pointer was when the drag started, to measure the move from. */
1797
1378
  const pointerOrigin = useRef(null);
1798
1379
  const { refCallback: dragRefCallback, dragging } = useDragValue({
1799
- axis: AXIS,
1380
+ axis: POINT_AXIS,
1800
1381
  baseElementRef: containerRef,
1801
1382
  sensitivity: (state) => selectModifier(dragSensitivity, state.event).value,
1802
1383
  cursor: inactive ? void 0 : externalStyles.cursor,
@@ -1804,13 +1385,13 @@ function Point({ value, children, id: idProp, min, max, size, width, height, col
1804
1385
  onChange: ([x, y]) => {
1805
1386
  const origin = pointerOrigin.current;
1806
1387
  if (!origin) return;
1807
- movePointDrag({
1388
+ editor.movePointDrag({
1808
1389
  x: x - origin.x,
1809
1390
  y: y - origin.y
1810
1391
  });
1811
1392
  },
1812
1393
  onDragStart: ([x, y], state) => {
1813
- beginPointDrag(id, state.event);
1394
+ editor.beginPointDrag(id, state.event);
1814
1395
  pointerOrigin.current = {
1815
1396
  x,
1816
1397
  y
@@ -1825,30 +1406,13 @@ function Point({ value, children, id: idProp, min, max, size, width, height, col
1825
1406
  onDragEnd?.(clampPoint(value, min, max));
1826
1407
  }
1827
1408
  });
1828
- const nudge = useCallback((axis, direction, option, modifiers) => {
1829
- const next = applyDelta(value[axis], direction, option, AXIS, modifiers);
1830
- nudgeSelection(id, {
1831
- x: axis === "x" ? next - value.x : 0,
1832
- y: axis === "y" ? next - value.y : 0
1833
- });
1834
- }, [
1835
- value,
1836
- id,
1837
- nudgeSelection
1838
- ]);
1839
1409
  const refCallback = useComposedRefs(dragRefCallback, setElement);
1840
1410
  const handleKeyDown = (event) => {
1841
- const key = event.key;
1842
- if (![
1843
- "ArrowRight",
1844
- "ArrowLeft",
1845
- "ArrowUp",
1846
- "ArrowDown"
1847
- ].includes(key)) return;
1848
- const axis = key === "ArrowRight" || key === "ArrowLeft" ? "x" : "y";
1411
+ const move = arrowKeyMove(event.key);
1412
+ if (!move) return;
1849
1413
  event.preventDefault();
1850
1414
  if (!onChange || inactive || !keyboard) return;
1851
- nudge(axis, key === "ArrowLeft" || key === "ArrowUp" ? -1 : 1, keyboard, event);
1415
+ editor.nudgePoint(id, move.axis === 0 ? "x" : "y", move.direction, keyboard, event);
1852
1416
  };
1853
1417
  const current = clampPoint(value, min, max);
1854
1418
  return /* @__PURE__ */ jsxs("div", {
@@ -1861,8 +1425,6 @@ function Point({ value, children, id: idProp, min, max, size, width, height, col
1861
1425
  "data-selected": selected ? "" : void 0,
1862
1426
  style: {
1863
1427
  "--color": color,
1864
- "--width": cssLength(size ?? width),
1865
- "--height": cssLength(size ?? height),
1866
1428
  position: "absolute",
1867
1429
  translate: "var(--translate, -50% -50%)",
1868
1430
  ...style,
@@ -1897,7 +1459,7 @@ function Point({ value, children, id: idProp, min, max, size, width, height, col
1897
1459
  return;
1898
1460
  }
1899
1461
  const delta = event.currentTarget.valueAsNumber - value[axis];
1900
- nudgeSelection(id, {
1462
+ editor.nudgeSelection(id, {
1901
1463
  x: axis === "x" ? delta : 0,
1902
1464
  y: axis === "y" ? delta : 0
1903
1465
  });
@@ -1906,50 +1468,6 @@ function Point({ value, children, id: idProp, min, max, size, width, height, col
1906
1468
  });
1907
1469
  }
1908
1470
  //#endregion
1909
- //#region src/components/PointsEditor/Container.tsx
1910
- const Container = /* @__PURE__ */ forwardRef(function Container({ children, className, style, ...props }, forwardedRef) {
1911
- const { containerRef, disabled, selectable, isPointElement, nudgeFocusedPoint, beginSelectionBox, moveSelectionBox, endSelectionBox } = usePointsEditorContext();
1912
- const { refCallback: dragRefCallback } = useDragValue({
1913
- axis: AXIS,
1914
- baseElementRef: containerRef,
1915
- shouldStart: (event) => !disabled && !isPointElement(event.target),
1916
- onDragStart: ([x, y], state) => {
1917
- beginSelectionBox({
1918
- x,
1919
- y
1920
- }, state.event);
1921
- },
1922
- onChange: ([x, y]) => moveSelectionBox({
1923
- x,
1924
- y
1925
- }),
1926
- onDragEnd: endSelectionBox
1927
- });
1928
- useWheel((event) => {
1929
- const horizontal = event.deltaX !== 0;
1930
- const delta = horizontal ? event.deltaX : event.deltaY;
1931
- if (delta === 0) return;
1932
- const axis = horizontal || event.shiftKey ? "x" : "y";
1933
- if (nudgeFocusedPoint(axis, delta < 0 ? -1 : 1, event)) event.preventDefault();
1934
- }, { target: containerRef });
1935
- const composedRef = useComposedRefs(forwardedRef, containerRef, selectable && !disabled ? dragRefCallback : void 0);
1936
- return /* @__PURE__ */ jsx("div", {
1937
- ref: composedRef,
1938
- className,
1939
- style: {
1940
- position: "absolute",
1941
- inset: 0,
1942
- zIndex: 10,
1943
- ...style
1944
- },
1945
- ...props,
1946
- children: /* @__PURE__ */ jsx(Placement, {
1947
- name: "PointsEditor.Container",
1948
- children
1949
- })
1950
- });
1951
- });
1952
- //#endregion
1953
1471
  //#region src/components/PointsEditor/SelectionBox.tsx
1954
1472
  /**
1955
1473
  * The box a drag on empty space draws, and what it covers is selected.
@@ -1982,181 +1500,28 @@ function SelectionBox({ children, className, style, ...props }) {
1982
1500
  /** One array for every editor with selection turned off, so memos hold still. */
1983
1501
  const EMPTY = [];
1984
1502
  /**
1985
- * How far a point may move before something in the selection leaves its range.
1986
- *
1987
- * Clamping each point on its own would break the shape of the selection: the
1988
- * one that reached the edge would stop while the rest carried on. One amount
1989
- * for all of them means the whole selection stops together.
1990
- */
1991
- function allowedDelta(delta, entries) {
1992
- let loX = -Infinity;
1993
- let hiX = Infinity;
1994
- let loY = -Infinity;
1995
- let hiY = Infinity;
1996
- for (const { start, registration } of entries) {
1997
- loX = Math.max(loX, (registration.min?.x ?? 0) - start.x);
1998
- hiX = Math.min(hiX, (registration.max?.x ?? 1) - start.x);
1999
- loY = Math.max(loY, (registration.min?.y ?? 0) - start.y);
2000
- hiY = Math.min(hiY, (registration.max?.y ?? 1) - start.y);
2001
- }
2002
- return {
2003
- x: hiX < loX ? 0 : clamp(delta.x, loX, hiX),
2004
- y: hiY < loY ? 0 : clamp(delta.y, loY, hiY)
2005
- };
2006
- }
2007
- /**
2008
- * A point moves over 0..1 in both axes, so a nudge of 0.01 crosses the editor
2009
- * in a hundred steps whatever its pixel size.
2010
- */
2011
- const DEFAULT_WHEEL = ["normalized", .01];
2012
- /**
2013
- * Shift is the fine-adjustment key everywhere else, so it is bound here too
2014
- * — but only on the keyboard. On the wheel it already means the x axis, and
2015
- * browsers hand shift+wheel over as horizontal scrolling anyway.
2016
- */
2017
- const DEFAULT_KEYBOARD = {
2018
- default: ["normalized", .01],
2019
- shift: ["normalized", .001]
2020
- };
2021
- /**
2022
1503
  * Multiple Point Controller
2023
1504
  */
2024
1505
  const PointsEditor = {
2025
- Root: /* @__PURE__ */ forwardRef(({ width, height, disabled = false, readonly = false, wheel = DEFAULT_WHEEL, keyboard = DEFAULT_KEYBOARD, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, selectable = false, selection: selectionProp, defaultSelection, onSelectionChange, externalStyles, style, className, children, ...props }, forwardedRef) => {
1506
+ Root: /* @__PURE__ */ forwardRef(({ disabled = false, readonly = false, wheel = POINTS_EDITOR_DEFAULT_WHEEL, keyboard = POINTS_EDITOR_DEFAULT_KEYBOARD, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, selectable = false, selection: selectionProp, defaultSelection, onSelectionChange, externalStyles, style, className, children, ...props }, forwardedRef) => {
2026
1507
  const containerRef = useRef(null);
2027
1508
  const { cursor = "grabbing" } = externalStyles ?? {};
2028
1509
  const controlled = selectionProp !== void 0;
2029
1510
  const [ownSelection, setOwnSelection] = useState(defaultSelection ?? []);
2030
1511
  const selection = selectable ? selectionProp ?? ownSelection : EMPTY;
2031
- const selectionRef = useRef(selection);
2032
- const changeHandlerRef = useRef(onSelectionChange);
2033
- useEffect(() => {
2034
- selectionRef.current = selection;
2035
- changeHandlerRef.current = onSelectionChange;
2036
- });
2037
- const changeSelection = useCallback((next) => {
2038
- selectionRef.current = next;
2039
- if (!controlled) setOwnSelection(next);
2040
- changeHandlerRef.current?.(next);
2041
- }, [controlled]);
2042
- /**
2043
- * Every mounted point, by id. A registration is a ref rather than a value:
2044
- * the value inside changes on every frame of a drag, and a registry keyed
2045
- * on it would be rebuilt just as often.
2046
- */
2047
- const points = useRef(/* @__PURE__ */ new Map());
2048
- const registerPoint = useCallback((id, entry) => {
2049
- points.current.set(id, entry);
2050
- return () => {
2051
- points.current.delete(id);
2052
- };
2053
- }, []);
2054
- /** What the current drag picked up, and where those points started. */
2055
- const dragRef = useRef([]);
2056
- const snapshot = useCallback((ids) => {
2057
- return ids.flatMap((id) => {
2058
- const registration = points.current.get(id)?.current;
2059
- return registration ? [{
2060
- id,
2061
- start: { ...registration.value }
2062
- }] : [];
2063
- });
2064
- }, []);
2065
- const applyDeltaTo = useCallback((entries, delta) => {
2066
- const withRegistration = entries.flatMap((entry) => {
2067
- const registration = points.current.get(entry.id)?.current;
2068
- return registration ? [{
2069
- ...entry,
2070
- registration
2071
- }] : [];
2072
- });
2073
- const allowed = allowedDelta(delta, withRegistration);
2074
- for (const { start, registration } of withRegistration) {
2075
- if (registration.readonly) continue;
2076
- registration.onChange?.({
2077
- x: toPrecision(start.x + allowed.x),
2078
- y: toPrecision(start.y + allowed.y)
2079
- });
2080
- }
2081
- }, []);
2082
- const beginPointDrag = useCallback((id, modifiers) => {
2083
- if (!selectable) {
2084
- dragRef.current = snapshot([id]);
2085
- return;
2086
- }
2087
- const current = selectionRef.current;
2088
- const additive = modifiers.ctrlKey || modifiers.metaKey;
2089
- let next;
2090
- if (additive) next = current.includes(id) ? current.filter((x) => x !== id) : [...current, id];
2091
- else if (current.includes(id)) next = current;
2092
- else next = [id];
2093
- changeSelection([...next]);
2094
- dragRef.current = next.includes(id) ? snapshot(next) : [];
2095
- }, [
2096
- selectable,
2097
- changeSelection,
2098
- snapshot
2099
- ]);
2100
- const movePointDrag = useCallback((delta) => applyDeltaTo(dragRef.current, delta), [applyDeltaTo]);
2101
- const nudgeSelection = useCallback((id, delta) => {
2102
- const ids = selectionRef.current.includes(id) ? selectionRef.current : [id];
2103
- applyDeltaTo(snapshot(ids), delta);
2104
- }, [applyDeltaTo, snapshot]);
2105
- const isPointElement = useCallback((element) => {
2106
- if (!element) return false;
2107
- for (const [, entry] of points.current) if (entry.current.element?.contains(element)) return true;
2108
- return false;
2109
- }, []);
2110
- const nudgeFocusedPoint = useCallback((axis, direction, modifiers) => {
2111
- const container = containerRef.current;
2112
- const active = container?.ownerDocument.activeElement;
2113
- if (!container || !active || !container.contains(active)) return false;
2114
- for (const [id, entry] of points.current) {
2115
- const { element, wheel, readonly, onChange, value } = entry.current;
2116
- if (!element?.contains(active)) continue;
2117
- if (!wheel || readonly || !onChange) return false;
2118
- const next = applyDelta(value[axis], direction, wheel, AXIS, modifiers);
2119
- nudgeSelection(id, {
2120
- x: axis === "x" ? next - value.x : 0,
2121
- y: axis === "y" ? next - value.y : 0
2122
- });
2123
- return true;
2124
- }
2125
- return false;
2126
- }, [nudgeSelection]);
2127
1512
  const [selectionBox, setSelectionBox] = useState(null);
2128
- const selectionBoxRef = useRef(null);
2129
- selectionBoxRef.current ??= createSelectionBox({
2130
- *items() {
2131
- for (const [id, entry] of points.current) {
2132
- const { x, y } = entry.current.value;
2133
- yield [id, [x, y]];
2134
- }
2135
- },
2136
- onBoxChange: setSelectionBox
2137
- });
1513
+ const [editor] = useState(() => createPointsEditor({ onSelectionBoxChange: setSelectionBox }));
2138
1514
  useEffect(() => {
2139
- selectionBoxRef.current?.update({ onSelectionChange: changeSelection });
2140
- }, [changeSelection]);
2141
- useEffect(() => {
2142
- const box = selectionBoxRef.current;
2143
- return () => box?.destroy();
2144
- }, []);
2145
- const beginSelectionBox = useCallback((at, modifiers) => {
2146
- if (!selectable) return;
2147
- selectionBoxRef.current?.begin([at.x, at.y], {
2148
- additive: modifiers.ctrlKey || modifiers.metaKey,
2149
- selection: selectionRef.current
1515
+ editor.update({
1516
+ selectable,
1517
+ selection,
1518
+ onSelectionChange: (next) => {
1519
+ if (!controlled) setOwnSelection(next);
1520
+ onSelectionChange?.(next);
1521
+ }
2150
1522
  });
2151
- }, [selectable]);
2152
- const moveSelectionBox = useCallback((to) => {
2153
- selectionBoxRef.current?.move([to.x, to.y]);
2154
- }, []);
2155
- const endSelectionBox = useCallback(() => {
2156
- if (!selectionBoxRef.current?.end()) return;
2157
- const [first] = selectionRef.current;
2158
- if (first) points.current.get(first)?.current.element?.focus();
2159
- }, []);
1523
+ });
1524
+ useEffect(() => () => editor.destroy(), [editor]);
2160
1525
  const context = useMemo(() => ({
2161
1526
  disabled,
2162
1527
  readonly,
@@ -2167,16 +1532,8 @@ const PointsEditor = {
2167
1532
  containerRef,
2168
1533
  selectable,
2169
1534
  selection,
2170
- registerPoint,
2171
- isPointElement,
2172
- beginPointDrag,
2173
- movePointDrag,
2174
- nudgeSelection,
2175
- nudgeFocusedPoint,
2176
- selectionBox,
2177
- beginSelectionBox,
2178
- moveSelectionBox,
2179
- endSelectionBox
1535
+ editor,
1536
+ selectionBox
2180
1537
  }), [
2181
1538
  disabled,
2182
1539
  readonly,
@@ -2186,16 +1543,8 @@ const PointsEditor = {
2186
1543
  cursor,
2187
1544
  selectable,
2188
1545
  selection,
2189
- registerPoint,
2190
- isPointElement,
2191
- beginPointDrag,
2192
- movePointDrag,
2193
- nudgeSelection,
2194
- nudgeFocusedPoint,
2195
- selectionBox,
2196
- beginSelectionBox,
2197
- moveSelectionBox,
2198
- endSelectionBox
1546
+ editor,
1547
+ selectionBox
2199
1548
  ]);
2200
1549
  return /* @__PURE__ */ jsx(PointsEditorProvider, {
2201
1550
  value: context,
@@ -2205,8 +1554,6 @@ const PointsEditor = {
2205
1554
  "data-disabled": disabled ? "" : void 0,
2206
1555
  "data-readonly": readonly ? "" : void 0,
2207
1556
  style: {
2208
- "--width": cssLength(width),
2209
- "--height": cssLength(height),
2210
1557
  position: "relative",
2211
1558
  ...style
2212
1559
  },
@@ -2231,36 +1578,30 @@ function useSliderContext(selector) {
2231
1578
  }
2232
1579
  //#endregion
2233
1580
  //#region src/components/Slider/MarksOption.tsx
2234
- function MarksOption({ value, mark = true, label, thickness, length, gap, labelWidth, classes, styles, className, style, ...props }) {
1581
+ function MarksOption({ value, mark = true, label, thickness, length, gap, classes, styles, className, style, ...props }) {
2235
1582
  useCheckPlacement("Slider.MarksOption", "Slider.Marks");
2236
1583
  const min = useSliderContext((s) => s.min);
2237
1584
  const max = useSliderContext((s) => s.max);
2238
1585
  const scale = useSliderContext((s) => s.scale);
2239
1586
  const vertical = useSliderContext((s) => s.vertical);
2240
1587
  const reverse = useSliderContext((s) => s.reverse);
2241
- const calcPercent = useCallback((value) => {
2242
- const percent = scale.normalize(value, min, max) * 100;
2243
- return toFixed(vertical !== reverse ? 100 - percent : percent);
2244
- }, [
2245
- vertical,
2246
- reverse,
2247
- max,
1588
+ const percent = valuePercent(value, {
2248
1589
  min,
1590
+ max,
2249
1591
  scale
2250
- ]);
1592
+ }, vertical !== reverse);
2251
1593
  return /* @__PURE__ */ jsxs("div", {
2252
1594
  className,
2253
1595
  style: {
2254
1596
  "--thickness": cssLength(thickness),
2255
1597
  "--length": cssLength(length),
2256
1598
  "--gap": cssLength(gap),
2257
- "--label-width": cssLength(labelWidth),
2258
1599
  position: "absolute",
2259
1600
  translate: vertical ? "var(--translate, 0 -50%)" : "var(--translate, -50% 0)",
2260
1601
  zIndex: 10,
2261
1602
  ...style,
2262
- left: !vertical ? `${calcPercent(value)}%` : void 0,
2263
- top: vertical ? `${calcPercent(value)}%` : void 0
1603
+ left: !vertical ? `${percent}%` : void 0,
1604
+ top: vertical ? `${percent}%` : void 0
2264
1605
  },
2265
1606
  "data-orientation": vertical ? "vertical" : "horizontal",
2266
1607
  ...props,
@@ -2276,44 +1617,6 @@ function MarksOption({ value, mark = true, label, thickness, length, gap, labelW
2276
1617
  });
2277
1618
  }
2278
1619
  //#endregion
2279
- //#region src/components/_util/decimal-digits.ts
2280
- /**
2281
- * How many digits a number carries after the decimal point.
2282
- *
2283
- * `Slider.Marks` builds its labels by multiplying an interval, which leaves
2284
- * binary debris in the last digits (`0.1 * 3` is `0.30000000000000004`). The
2285
- * count is what tells `toFixed` how far to round that back.
2286
- *
2287
- * The exponent form has to be handled separately: `String(1e-7)` is `'1e-7'`,
2288
- * which has no decimal point at all, so reading the text after the point
2289
- * reports no digits and the interval rounds away to whole numbers.
2290
- */
2291
- function decimalDigits(x) {
2292
- if (!Number.isFinite(x)) return 0;
2293
- const text = String(x);
2294
- const e = text.indexOf("e");
2295
- if (e === -1) return text.split(".")[1]?.length ?? 0;
2296
- const fraction = text.slice(0, e).split(".")[1]?.length ?? 0;
2297
- return Math.max(0, fraction - Number(text.slice(e + 1)));
2298
- }
2299
- //#endregion
2300
- //#region src/components/Slider/type.ts
2301
- function generateOptionsList(options, min, max, step) {
2302
- const { per, mark = true, label = true } = typeof options === "object" ? options : { per: options };
2303
- const optionsList = [];
2304
- const interval = per === "step" ? step : per;
2305
- const count = Math.floor(max / interval) - Math.ceil(min / interval) + 1;
2306
- for (let i = 0; i < count; i++) {
2307
- const value = toFixed(interval * (Math.ceil(min / interval) + i), decimalDigits(interval));
2308
- optionsList.push({
2309
- value,
2310
- mark,
2311
- label
2312
- });
2313
- }
2314
- return optionsList;
2315
- }
2316
- //#endregion
2317
1620
  //#region src/components/Slider/Marks.tsx
2318
1621
  function Marks({ gap, options, children, className, style, ...props }) {
2319
1622
  const min = useSliderContext((s) => s.min);
@@ -2321,7 +1624,7 @@ function Marks({ gap, options, children, className, style, ...props }) {
2321
1624
  const step = useSliderContext((s) => s.step);
2322
1625
  const vertical = useSliderContext((s) => s.vertical);
2323
1626
  const reverse = useSliderContext((s) => s.reverse);
2324
- const optionsList = options ? generateOptionsList(options, min, max, step) : [];
1627
+ const optionsList = options ? sliderMarks(options, min, max, step) : [];
2325
1628
  if (vertical !== reverse) optionsList.reverse();
2326
1629
  return /* @__PURE__ */ jsx("div", {
2327
1630
  className,
@@ -2444,10 +1747,12 @@ const Slider = {
2444
1747
  ..._externalStyles
2445
1748
  };
2446
1749
  const inactive = disabled || readonly;
2447
- const p = toFixed(scale.normalize(value, min, max) * 100);
2448
- const rev = toFixed(100 - p);
2449
1750
  const displayReversed = vertical !== reverse;
2450
- const percent = displayReversed ? rev : p;
1751
+ const percent = valuePercent(value, {
1752
+ min,
1753
+ max,
1754
+ scale
1755
+ }, displayReversed);
2451
1756
  const axis = useMemo(() => ({
2452
1757
  min,
2453
1758
  max,
@@ -2468,18 +1773,11 @@ const Slider = {
2468
1773
  wheel
2469
1774
  });
2470
1775
  const handleKeyDown = useCallback((event) => {
2471
- const key = event.key;
2472
- if (![
2473
- "ArrowRight",
2474
- "ArrowLeft",
2475
- "ArrowUp",
2476
- "ArrowDown"
2477
- ].includes(key)) return;
1776
+ const direction = arrowKeyDirection(event.key);
1777
+ if (direction === null) return;
2478
1778
  event.preventDefault();
2479
1779
  if (!keyboard || !onChange || inactive) return;
2480
- let direction = key === "ArrowRight" || key === "ArrowUp" ? 1 : -1;
2481
- if (reverse) direction *= -1;
2482
- onChange(applyDelta(value, direction, keyboard, axis, event));
1780
+ onChange(applyDelta(value, reverse ? -direction : direction, keyboard, axis, event));
2483
1781
  }, [
2484
1782
  keyboard,
2485
1783
  onChange,
@@ -2512,15 +1810,10 @@ const Slider = {
2512
1810
  });
2513
1811
  const wheelRefCallback = useWheel((event) => {
2514
1812
  if (!wheel || !onChange || inactive) return;
1813
+ const direction = wheelDirection(event, { horizontal: !vertical });
1814
+ if (direction === null) return;
2515
1815
  event.preventDefault();
2516
- let direction;
2517
- if (!vertical && event.deltaX !== 0) direction = event.deltaX > 0 ? 1 : -1;
2518
- else {
2519
- if (event.deltaY === 0) return;
2520
- direction = event.deltaY > 0 ? -1 : 1;
2521
- }
2522
- if (reverse) direction *= -1;
2523
- onChange(applyDelta(value, direction, wheel, axis, event));
1816
+ onChange(applyDelta(value, reverse ? -direction : direction, wheel, axis, event));
2524
1817
  }, WHEEL_OPTIONS$1);
2525
1818
  const rootRefCallback = useComposedRefs(dragRefCallback, wheelRefCallback);
2526
1819
  const context = useMemo(() => ({
@@ -2605,16 +1898,13 @@ function useXYPadContext(selector) {
2605
1898
  }
2606
1899
  //#endregion
2607
1900
  //#region src/components/XYPad/Area.tsx
2608
- const Area = /* @__PURE__ */ forwardRef(function Area({ width, height, color, children, className, style, ...props }, forwardedRef) {
1901
+ const Area = /* @__PURE__ */ forwardRef(function Area({ children, className, style, ...props }, forwardedRef) {
2609
1902
  const { areaRef } = useXYPadContext();
2610
1903
  const composedRef = useComposedRefs(forwardedRef, areaRef);
2611
1904
  return /* @__PURE__ */ jsx("div", {
2612
1905
  ref: composedRef,
2613
1906
  className,
2614
1907
  style: {
2615
- "--color": color,
2616
- "--width": cssLength(width),
2617
- "--height": cssLength(height),
2618
1908
  position: "relative",
2619
1909
  ...style
2620
1910
  },
@@ -2713,10 +2003,11 @@ const XYPad = {
2713
2003
  const step = useMemo(() => toXY(_step), [_step]);
2714
2004
  const scale = useMemo(() => toXY(_scale), [_scale]);
2715
2005
  const reverse = useMemo(() => toXY(_reverse), [_reverse]);
2716
- const percent = useMemo(() => {
2717
- const normalized = [0, 1].map((i) => scale[i].normalize(value[i], min[i], max[i]));
2718
- return [0, 1].map((i) => toFixed((reverse[i] ? 1 - normalized[i] : normalized[i]) * 100));
2719
- }, [
2006
+ const percent = useMemo(() => [0, 1].map((i) => valuePercent(value[i], {
2007
+ min: min[i],
2008
+ max: max[i],
2009
+ scale: scale[i]
2010
+ }, reverse[i])), [
2720
2011
  value,
2721
2012
  min,
2722
2013
  max,
@@ -2758,20 +2049,12 @@ const XYPad = {
2758
2049
  withAxis
2759
2050
  ]);
2760
2051
  const handleKeyDown = useCallback((event) => {
2761
- const key = event.key;
2762
- if (![
2763
- "ArrowRight",
2764
- "ArrowLeft",
2765
- "ArrowUp",
2766
- "ArrowDown"
2767
- ].includes(key)) return;
2768
- const i = key === "ArrowRight" || key === "ArrowLeft" ? 0 : 1;
2052
+ const move = arrowKeyMove(event.key);
2053
+ if (!move) return;
2769
2054
  event.preventDefault();
2770
2055
  if (!onChange || inactive || !keyboard) return;
2771
- let direction = 1;
2772
- if (key === "ArrowLeft" || key === "ArrowUp") direction *= -1;
2773
- if (reverse[i]) direction *= -1;
2774
- onChange(nudge(i, direction, keyboard, event));
2056
+ const { axis: i, direction } = move;
2057
+ onChange(nudge(i, reverse[i] ? -direction : direction, keyboard, event));
2775
2058
  }, [
2776
2059
  onChange,
2777
2060
  inactive,
@@ -2802,15 +2085,11 @@ const XYPad = {
2802
2085
  });
2803
2086
  const wheelRefCallback = useWheel((event) => {
2804
2087
  if (!onChange || inactive || !wheel) return;
2805
- const horizontal = event.deltaX !== 0;
2806
- const delta = horizontal ? event.deltaX : event.deltaY;
2807
- if (delta === 0) return;
2808
- const i = horizontal || event.shiftKey ? 0 : 1;
2088
+ const move = wheelMove(event);
2089
+ if (!move) return;
2809
2090
  event.preventDefault();
2810
- let direction = 1;
2811
- if (delta < 0) direction *= -1;
2812
- if (reverse[i]) direction *= -1;
2813
- onChange(nudge(i, direction, wheel, event));
2091
+ const { axis: i, direction } = move;
2092
+ onChange(nudge(i, reverse[i] ? -direction : direction, wheel, event));
2814
2093
  }, WHEEL_OPTIONS);
2815
2094
  const rootRefCallback = useComposedRefs(rootRef, dragRefCallback, wheelRefCallback);
2816
2095
  const context = useMemo(() => ({
@@ -2895,6 +2174,82 @@ function useAnimationFrame(callback = () => {}, deps = []) {
2895
2174
  }, [runCallback, ...deps]);
2896
2175
  }
2897
2176
  //#endregion
2177
+ //#region src/hooks/useDrag.ts
2178
+ /**
2179
+ * Track a pointer drag on an element.
2180
+ *
2181
+ * @returns a ref callback to attach to the element being dragged
2182
+ */
2183
+ function useDrag({ threshold = 1, cursor, pointerLock, onDrag, onDragStart, onDragEnd }) {
2184
+ const dragHandler = useCallbackRef(onDrag);
2185
+ const dragStartHandler = useCallbackRef(onDragStart);
2186
+ const dragEndHandler = useCallbackRef(onDragEnd);
2187
+ const [node, setNode] = useState(null);
2188
+ useEffect(() => {
2189
+ if (!node) return;
2190
+ const instance = createDrag(node, {
2191
+ threshold,
2192
+ cursor,
2193
+ pointerLock,
2194
+ onDragStart: (state) => dragStartHandler(state),
2195
+ onDrag: (state) => dragHandler(state.x, state.y, state.deltaX, state.deltaY, state),
2196
+ onDragEnd: (state) => dragEndHandler(state)
2197
+ });
2198
+ return () => instance.destroy();
2199
+ }, [
2200
+ node,
2201
+ threshold,
2202
+ cursor,
2203
+ pointerLock,
2204
+ dragHandler,
2205
+ dragStartHandler,
2206
+ dragEndHandler
2207
+ ]);
2208
+ return setNode;
2209
+ }
2210
+ //#endregion
2211
+ //#region src/hooks/useEventListener.ts
2212
+ function useEventListener(target, event, handler, options) {
2213
+ const listener = useCallbackRef(handler);
2214
+ const cleanupRef = useRef(() => {});
2215
+ useEffect(() => {
2216
+ const node = typeof target === "function" ? target() : target ?? document;
2217
+ if (!node) return;
2218
+ node.addEventListener(event, listener, options);
2219
+ const cleanup = () => {
2220
+ node.removeEventListener(event, listener, options);
2221
+ };
2222
+ cleanupRef.current = cleanup;
2223
+ return () => {
2224
+ cleanup();
2225
+ if (cleanupRef.current === cleanup) cleanupRef.current = () => {};
2226
+ };
2227
+ }, [
2228
+ event,
2229
+ target,
2230
+ options,
2231
+ listener
2232
+ ]);
2233
+ return useCallback(() => cleanupRef.current(), []);
2234
+ }
2235
+ //#endregion
2236
+ //#region src/hooks/useInterval.ts
2237
+ function useInterval(callback, delay) {
2238
+ const savedCallback = useRef(callback);
2239
+ useEffect(() => {
2240
+ savedCallback.current = callback;
2241
+ }, [callback]);
2242
+ useEffect(() => {
2243
+ if (delay === null) return;
2244
+ const id = setInterval(() => {
2245
+ savedCallback.current();
2246
+ }, delay);
2247
+ return () => {
2248
+ clearInterval(id);
2249
+ };
2250
+ }, [delay]);
2251
+ }
2252
+ //#endregion
2898
2253
  //#region src/hooks/useMIDIAccess.ts
2899
2254
  const INITIAL_STATE = {
2900
2255
  midiAccess: null,
@@ -3031,6 +2386,6 @@ function useMIDIMessage(midiAccess, onMIDIMessage) {
3031
2386
  });
3032
2387
  }
3033
2388
  //#endregion
3034
- export { AnimationCanvas, DEFAULT_DRAG_SENSITIVITY, DEFAULT_KEYBOARD_OPTIONS, DEFAULT_WHEEL_OPTIONS, DropZone, FileInput, Knob, NOT_SUPPORTED, NumberInput, PERMISSION_DENIED, PITCH_BEND_CENTER, Piano, PointsEditor, SHORTCUTS, Slider, UNAVAILABLE, XYPad, clampPoint, useAnimationFrame, useDrag, useDragValue, useDropZone, useEventListener, useFileInputContext, useInterval, useKnobContext, useLongPress, useMIDIAccess, useMIDIInput, useMIDIMessage, useNumberInputContext, usePointsEditorContext, useSliderContext, useWheel, useXYPadContext };
2389
+ export { AnimationCanvas, DropZone, FileInput, Knob, NumberInput, Piano, PointsEditor, Slider, XYPad, useAnimationFrame, useDrag, useDragValue, useDropZone, useEventListener, useFileInputContext, useInterval, useKnobContext, useLongPress, useMIDIAccess, useMIDIInput, useMIDIMessage, useNumberInputContext, usePointsEditorContext, useSliderContext, useWheel, useXYPadContext };
3035
2390
 
3036
2391
  //# sourceMappingURL=index.js.map