@tremolo-ui/react 0.7.0 → 0.9.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 (55) hide show
  1. package/dist/index.cjs +666 -1171
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +316 -315
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.ts +316 -315
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +643 -1123
  8. package/dist/index.js.map +1 -1
  9. package/package.json +9 -9
  10. package/src/components/AnimationCanvas/index.tsx +4 -18
  11. package/src/components/DropZone/index.tsx +2 -1
  12. package/src/components/FileInput/index.tsx +11 -13
  13. package/src/components/Knob/ActiveLine.tsx +5 -3
  14. package/src/components/Knob/InactiveLine.tsx +6 -4
  15. package/src/components/Knob/SVGRoot.tsx +3 -3
  16. package/src/components/Knob/Thumb.tsx +21 -20
  17. package/src/components/Knob/context.tsx +3 -74
  18. package/src/components/Knob/index.tsx +99 -52
  19. package/src/components/NumberInput/InputField.tsx +20 -33
  20. package/src/components/NumberInput/Stepper.tsx +76 -100
  21. package/src/components/NumberInput/StepperButton.tsx +15 -8
  22. package/src/components/NumberInput/context.tsx +26 -3
  23. package/src/components/NumberInput/index.tsx +175 -91
  24. package/src/components/Piano/index.tsx +54 -144
  25. package/src/components/PointsEditor/Container.tsx +15 -26
  26. package/src/components/PointsEditor/Point.tsx +94 -110
  27. package/src/components/PointsEditor/context.tsx +8 -60
  28. package/src/components/PointsEditor/index.tsx +41 -324
  29. package/src/components/Slider/Marks.tsx +5 -6
  30. package/src/components/Slider/MarksOption.tsx +9 -19
  31. package/src/components/Slider/Thumb.tsx +20 -11
  32. package/src/components/Slider/Track.tsx +16 -13
  33. package/src/components/Slider/context.tsx +3 -3
  34. package/src/components/Slider/index.tsx +110 -72
  35. package/src/components/XYPad/Area.tsx +27 -49
  36. package/src/components/XYPad/Thumb.tsx +20 -8
  37. package/src/components/XYPad/context.tsx +2 -4
  38. package/src/components/XYPad/index.tsx +102 -83
  39. package/src/components/_util/VisuallyHiddenRangeInput.tsx +1 -1
  40. package/src/hooks/_internal/useChangeGesture.ts +93 -0
  41. package/src/hooks/_internal/useCheckSteps.ts +9 -146
  42. package/src/hooks/useAnimationFrame.ts +37 -15
  43. package/src/hooks/useEventListener.ts +93 -22
  44. package/src/hooks/useLongPress.ts +35 -51
  45. package/src/hooks/useMIDIAccess.ts +0 -8
  46. package/src/hooks/useMIDIInput.ts +0 -3
  47. package/src/hooks/useWheel.ts +4 -4
  48. package/src/index.ts +11 -32
  49. package/src/components/Piano/keyboard-shortcuts.ts +0 -61
  50. package/src/components/Slider/type.ts +0 -46
  51. package/src/components/_util/css-length.ts +0 -13
  52. package/src/components/_util/decimal-digits.ts +0 -23
  53. package/src/components/_util/visually-hidden.ts +0 -25
  54. package/src/hooks/useInterval.ts +0 -27
  55. package/src/input-event.ts +0 -32
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
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, createAnimationCanvas, createChangeGesture, createDrag, createDragValue, createDropZone, createLongPress, createMIDIAccess, createMIDIInput, createMIDIMessage, createPianoInput, createPointsEditor, createWheel, elementMapping, knobAngles, knobArcPath, knobArcRadius, notePosition, relativeMapping, valuePercent } 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 { POINTS_EDITOR_DEFAULT_KEYBOARD, POINTS_EDITOR_DEFAULT_WHEEL, POINT_AXIS, applyDelta, arrowKeyDirection, arrowKeyMove, blackKeyWidth, caretAtDecimalOffset, caretDecimalOffset, checkSteps, clampPoint, commitNumberInputText, createStepperDrag, cssLength, fitWhiteKeyWidth, getNoteRangeArray, nudgeNumberInput, numberInputBounds, numberInputRanges, numberSpan, parseNumberText, partitionByAccept, pianoWidth, selectModifier, sliderMarks, toXY, visuallyHiddenStyle, wheelDirection, wheelMove } from "@tremolo-ui/dom/internal";
6
+ import { clamp, isWhiteKey, linearScale, noteKey } from "@tremolo-ui/functions";
6
7
  //#region src/components/AnimationCanvas/index.tsx
7
8
  /**
8
9
  * A simple animatable canvas with requestAnimationFrame()
@@ -168,31 +169,6 @@ const DropZone = { Root: /* @__PURE__ */ forwardRef(({ accept, multiple = false,
168
169
  });
169
170
  }) };
170
171
  //#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
172
  //#region src/components/FileInput/context.tsx
197
173
  const FileInputContext = /* @__PURE__ */ createContext(null);
198
174
  const FileInputProvider = FileInputContext.Provider;
@@ -212,14 +188,10 @@ const FileInput = {
212
188
  disabled
213
189
  }), [inputId, disabled]);
214
190
  function handleChange(event) {
215
- const picked = Array.from(event.target.files ?? []);
191
+ const { accepted, rejected } = partitionByAccept(Array.from(event.target.files ?? []), accept);
216
192
  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
193
  if (accepted.length > 0) onChange?.(accepted);
194
+ if (rejected.length > 0) onReject?.(rejected);
223
195
  }
224
196
  return /* @__PURE__ */ jsx(FileInputProvider, {
225
197
  value: context,
@@ -254,114 +226,75 @@ const FileInput = {
254
226
  })
255
227
  };
256
228
  //#endregion
257
- //#region src/hooks/_internal/useCheckSteps.ts
258
- /** Positions probed across the travel. The ends are left out so that the
259
- * clamp at `min` and `max` cannot be mistaken for a press that does nothing. */
260
- const PROBES = [
261
- .1,
262
- .2,
263
- .3,
264
- .4,
265
- .5,
266
- .6,
267
- .7,
268
- .8,
269
- .9
270
- ];
271
- const NONE = {
272
- shiftKey: false,
273
- altKey: false,
274
- ctrlKey: false,
275
- metaKey: false
276
- };
277
- const MODIFIER_STATE = {
278
- shift: {
279
- ...NONE,
280
- shiftKey: true
281
- },
282
- alt: {
283
- ...NONE,
284
- altKey: true
285
- },
286
- ctrl: {
287
- ...NONE,
288
- ctrlKey: true
289
- },
290
- meta: {
291
- ...NONE,
292
- metaKey: true
293
- }
294
- };
295
- /** Each entry of a modifier-aware input option, as a name and held keys. */
296
- function entries(options) {
297
- if (Array.isArray(options)) return [{
298
- name: "",
299
- modifiers: NONE
300
- }];
301
- return Object.keys(options).map((key) => ({
302
- name: key === "default" ? "" : key,
303
- modifiers: MODIFIER_STATE[key] ?? NONE
304
- }));
305
- }
229
+ //#region src/hooks/_internal/useChangeGesture.ts
306
230
  /**
307
- * Press every entry of `options` at nine points along the travel and report
308
- * whether anything came of it.
231
+ * The core's change gesture, reporting the control's value to
232
+ * `onChangeStart` / `onChangeEnd`. The start gets the value before the change,
233
+ * the end the last one reported.
309
234
  *
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.
235
+ * Internal
236
+ * @private
313
237
  */
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;
238
+ function useChangeGesture(value, { onChangeStart, onChangeEnd, changeEndDelay }, inactive) {
239
+ const [instance] = useState(() => createChangeGesture());
240
+ const latest = useRef({
241
+ value,
242
+ onChangeStart,
243
+ onChangeEnd
244
+ });
245
+ const lastValue = useRef(value);
246
+ useInsertionEffect(() => {
247
+ latest.current = {
248
+ value,
249
+ onChangeStart,
250
+ onChangeEnd
251
+ };
252
+ });
253
+ useEffect(() => {
254
+ instance.update({
255
+ endDelay: changeEndDelay,
256
+ onStart: (source) => {
257
+ lastValue.current = latest.current.value;
258
+ latest.current.onChangeStart?.(latest.current.value, source);
259
+ },
260
+ onEnd: (source) => latest.current.onChangeEnd?.(lastValue.current, source)
261
+ });
262
+ }, [instance, changeEndDelay]);
263
+ useEffect(() => {
264
+ if (inactive) instance.end();
265
+ }, [instance, inactive]);
266
+ useEffect(() => () => instance.end(), [instance]);
267
+ const changed = useCallback((next) => {
268
+ lastValue.current = next;
269
+ }, []);
270
+ return useMemo(() => ({
271
+ hold: instance.hold,
272
+ pulse: instance.pulse,
273
+ instant: instance.instant,
274
+ end: instance.end,
275
+ active: instance.active,
276
+ changed
277
+ }), [instance, changed]);
330
278
  }
279
+ //#endregion
280
+ //#region src/hooks/_internal/useCheckSteps.ts
331
281
  /**
332
282
  * 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.
283
+ * a change the user can see. The probing is `checkSteps` in the core.
346
284
  *
347
285
  * @internal
348
286
  */
349
- function useCheckSteps({ component, axis, range, keyboard, wheel, format }) {
287
+ function useCheckSteps(options) {
288
+ const { component, axis, range, keyboard, wheel } = options;
350
289
  const { min, max, step, scale } = range ?? {};
351
- const where = axis ? `${component} (${axis})` : component;
352
290
  useEffect(() => {
353
291
  try {
354
292
  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
- }
293
+ for (const warning of checkSteps(options)) console.warn(warning);
362
294
  } catch {}
363
295
  }, [
364
- where,
296
+ component,
297
+ axis,
365
298
  min,
366
299
  max,
367
300
  step,
@@ -458,7 +391,10 @@ function useWheel(onWheel, { requireFocus = false, target } = {}) {
458
391
  useEffect(() => {
459
392
  const element = target ? target.current : node;
460
393
  if (!element) return;
461
- const instance = createWheel(element, (event) => wheelHandler(event), { requireFocus });
394
+ const instance = createWheel(element, {
395
+ onWheel: (event) => wheelHandler(event),
396
+ requireFocus
397
+ });
462
398
  return () => instance.destroy();
463
399
  }, [
464
400
  node,
@@ -469,49 +405,6 @@ function useWheel(onWheel, { requireFocus = false, target } = {}) {
469
405
  return setNode;
470
406
  }
471
407
  //#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
408
  //#region src/components/_util/Placement.tsx
516
409
  /**
517
410
  * The subcomponent that established the current placement, if any.
@@ -553,49 +446,8 @@ function useCheckPlacement(child, parent) {
553
446
  parent
554
447
  ]);
555
448
  }
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
- }
449
+ //#endregion
450
+ //#region src/components/Knob/context.tsx
599
451
  const KnobContext = /* @__PURE__ */ createContext(null);
600
452
  const KnobProvider = KnobContext.Provider;
601
453
  function useKnobContext(selector) {
@@ -609,10 +461,10 @@ function ActiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...pr
609
461
  useCheckPlacement("Knob.ActiveLine", "Knob.SVGRoot");
610
462
  const r2 = useKnobContext((s) => s.r2);
611
463
  const r3 = useKnobContext((s) => s.r3);
612
- const radius = arcRadius(strokeWidth);
464
+ const radius = knobArcRadius(strokeWidth);
613
465
  return /* @__PURE__ */ jsx("path", {
614
466
  className,
615
- d: arcPath(r2, r3, radius),
467
+ d: knobArcPath(r2, r3, radius),
616
468
  fill: "none",
617
469
  stroke,
618
470
  strokeWidth,
@@ -630,17 +482,17 @@ function InactiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...
630
482
  const r2 = useKnobContext((s) => s.r2);
631
483
  const r3 = useKnobContext((s) => s.r3);
632
484
  const r4 = useKnobContext((s) => s.r4);
633
- const radius = arcRadius(strokeWidth);
485
+ const radius = knobArcRadius(strokeWidth);
634
486
  return /* @__PURE__ */ jsxs(Fragment, { children: [startValue > min && /* @__PURE__ */ jsx("path", {
635
487
  className,
636
- d: arcPath(r1, r2, radius),
488
+ d: knobArcPath(r1, r2, radius),
637
489
  fill: "none",
638
490
  stroke,
639
491
  strokeWidth,
640
492
  ...props
641
493
  }), startValue < max && /* @__PURE__ */ jsx("path", {
642
494
  className,
643
- d: arcPath(r3, r4, radius),
495
+ d: knobArcPath(r3, r4, radius),
644
496
  fill: "none",
645
497
  stroke,
646
498
  strokeWidth,
@@ -651,7 +503,7 @@ function InactiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...
651
503
  //#region src/components/Knob/SVGRoot.tsx
652
504
  function SVGRoot({ children, style, ...props }) {
653
505
  return /* @__PURE__ */ jsx("svg", {
654
- viewBox: `0 0 100 100`,
506
+ viewBox: `0 0 ${KNOB_VIEWBOX_SIZE} ${KNOB_VIEWBOX_SIZE}`,
655
507
  style: {
656
508
  display: "block",
657
509
  ...style
@@ -665,7 +517,7 @@ function SVGRoot({ children, style, ...props }) {
665
517
  }
666
518
  //#endregion
667
519
  //#region src/components/Knob/Thumb.tsx
668
- function Thumb$2({ className, thumb = "currentColor", thumbLine = "currentColor", thumbSize = 84, thumbLineWeight = 6, thumbLineLength = 35, classes, ...props }) {
520
+ function Thumb$2({ className, color = "currentColor", lineColor = "currentColor", size = 84, lineWeight = 6, lineLength = 35, classes, ...props }) {
669
521
  useCheckPlacement("Knob.Thumb", "Knob.SVGRoot");
670
522
  const angleRange = useKnobContext((s) => s.angleRange);
671
523
  const p = useKnobContext((s) => s.p);
@@ -676,16 +528,16 @@ function Thumb$2({ className, thumb = "currentColor", thumbLine = "currentColor"
676
528
  children: [/* @__PURE__ */ jsx("circle", {
677
529
  cx: "50%",
678
530
  cy: "50%",
679
- r: `${thumbSize / 2}%`,
680
- fill: thumb
531
+ r: `${size / 2}%`,
532
+ fill: color
681
533
  }), /* @__PURE__ */ jsx("line", {
682
- className: classes?.thumbLine,
534
+ className: classes?.line,
683
535
  x1: "50%",
684
- y1: `${(100 - clamp(thumbSize, 0, 100)) / 2}%`,
536
+ y1: `${(KNOB_VIEWBOX_SIZE - clamp(size, 0, 100)) / 2}%`,
685
537
  x2: "50%",
686
- y2: `${thumbLineLength}%`,
687
- stroke: thumbLine,
688
- strokeWidth: `${thumbLineWeight}%`,
538
+ y2: `${lineLength}%`,
539
+ stroke: lineColor,
540
+ strokeWidth: `${lineWeight}%`,
689
541
  style: {
690
542
  transform: `rotate(${r1 + p * angleRange}deg)`,
691
543
  transformOrigin: "50% 50%"
@@ -695,7 +547,6 @@ function Thumb$2({ className, thumb = "currentColor", thumbLine = "currentColor"
695
547
  }
696
548
  //#endregion
697
549
  //#region src/components/Knob/index.tsx
698
- const defaultExternalStyles$2 = { cursor: "grabbing" };
699
550
  /**
700
551
  * The wheel only acts while the focus is inside, so that scrolling a page past
701
552
  * the control does not change its value.
@@ -705,13 +556,9 @@ const WHEEL_OPTIONS$2 = { requireFocus: true };
705
556
  * Interactive rotary knob component implemented in SVG.
706
557
  */
707
558
  const Knob = {
708
- Root: /* @__PURE__ */ forwardRef(({ value, min, max, step = 1, scale = linearScale, defaultValue = min, startValue = min, size, externalStyles: _externalStyles, wheel = DEFAULT_WHEEL_OPTIONS, keyboard = DEFAULT_KEYBOARD_OPTIONS, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, pointerLock = false, enableDoubleClickDefault = true, disabled = false, readonly = false, angleRange = 270, onChange, onKeyDown, onPointerDown, onDoubleClick, className, style, children, ...props }, forwardedRef) => {
559
+ Root: /* @__PURE__ */ forwardRef(({ value, min, max, step = 1, scale = linearScale, resetValue, startValue = min, size, dragCursor = "grabbing", wheel = DEFAULT_WHEEL_OPTIONS, keyboard = DEFAULT_KEYBOARD_OPTIONS, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, pointerLock = false, disabled = false, readOnly = false, angleRange = 270, onChange, onChangeStart, onChangeEnd, changeEndDelay, onKeyDown, onPointerDown, onDoubleClick, className, style, children, actionsRef, ...props }, forwardedRef) => {
709
560
  const elmRef = useRef(null);
710
- const externalStyles = {
711
- ...defaultExternalStyles$2,
712
- ..._externalStyles
713
- };
714
- const inactive = disabled || readonly;
561
+ const inactive = disabled || readOnly;
715
562
  const range = useMemo(() => ({
716
563
  min,
717
564
  max,
@@ -729,24 +576,30 @@ const Knob = {
729
576
  keyboard,
730
577
  wheel
731
578
  });
579
+ const gesture = useChangeGesture(value, {
580
+ onChangeStart,
581
+ onChangeEnd,
582
+ changeEndDelay
583
+ }, inactive);
584
+ const change = useCallback((next) => {
585
+ gesture.changed(next);
586
+ onChange?.(next);
587
+ }, [gesture, onChange]);
732
588
  const handleKeyDown = useCallback((event) => {
733
589
  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
- }
590
+ const direction = arrowKeyDirection(event.key);
591
+ if (direction === null) return;
592
+ event.preventDefault();
593
+ gesture.pulse("keyboard");
594
+ change(applyDelta(value, direction, keyboard, range, event));
744
595
  }, [
745
596
  keyboard,
746
597
  onChange,
747
598
  inactive,
748
599
  value,
749
- range
600
+ range,
601
+ gesture,
602
+ change
750
603
  ]);
751
604
  const { refCallback: dragRefCallback, dragging } = useDragValue({
752
605
  axis: useMemo(() => [range, {
@@ -756,21 +609,28 @@ const Knob = {
756
609
  getValue: () => [value, value],
757
610
  sensitivity: (state) => selectModifier(dragSensitivity, state.event).value,
758
611
  threshold: 1,
759
- cursor: inactive ? void 0 : externalStyles.cursor,
612
+ cursor: inactive ? void 0 : dragCursor,
760
613
  pointerLock: inactive ? false : pointerLock,
761
614
  shouldStart: () => !inactive,
762
615
  onChange: (v) => {
763
616
  if (inactive) return;
764
- onChange?.(v[1]);
765
- }
617
+ change(v[1]);
618
+ },
619
+ onDragStart: () => {
620
+ if (!inactive) gesture.hold("pointer");
621
+ },
622
+ onDragEnd: () => gesture.end()
766
623
  });
767
624
  const wheelRefCallback = useWheel((event) => {
768
625
  if (!wheel || inactive) return;
626
+ const direction = wheelDirection(event);
627
+ if (direction === null) return;
769
628
  event.preventDefault();
770
- if (!onChange || event.deltaY === 0) return;
771
- onChange(applyDelta(value, -Math.sign(event.deltaY), wheel, range, event));
629
+ if (!onChange) return;
630
+ gesture.pulse("wheel");
631
+ change(applyDelta(value, direction, wheel, range, event));
772
632
  }, WHEEL_OPTIONS$2);
773
- const rootRefCallback = useComposedRefs(elmRef, dragRefCallback, wheelRefCallback);
633
+ const rootRefCallback = useComposedRefs(forwardedRef, elmRef, dragRefCallback, wheelRefCallback);
774
634
  const context = useMemo(() => {
775
635
  const config = {
776
636
  value,
@@ -783,7 +643,7 @@ const Knob = {
783
643
  };
784
644
  return {
785
645
  ...config,
786
- ...calcAngles(config)
646
+ ...knobAngles(config)
787
647
  };
788
648
  }, [
789
649
  value,
@@ -794,7 +654,7 @@ const Knob = {
794
654
  startValue,
795
655
  angleRange
796
656
  ]);
797
- useImperativeHandle(forwardedRef, () => {
657
+ useImperativeHandle(actionsRef, () => {
798
658
  return {
799
659
  focus() {
800
660
  if (!disabled) elmRef.current?.focus();
@@ -815,9 +675,9 @@ const Knob = {
815
675
  "aria-valuemin": min,
816
676
  "aria-valuemax": max,
817
677
  "aria-disabled": disabled,
818
- "aria-readonly": readonly,
678
+ "aria-readonly": readOnly,
819
679
  "data-disabled": disabled ? "" : void 0,
820
- "data-readonly": readonly ? "" : void 0,
680
+ "data-readonly": readOnly ? "" : void 0,
821
681
  "data-dragging": dragging ? "" : void 0,
822
682
  style: {
823
683
  "--knob-size": cssLength(size),
@@ -825,7 +685,7 @@ const Knob = {
825
685
  },
826
686
  onPointerDown,
827
687
  onDoubleClick: (event) => {
828
- if (!inactive && enableDoubleClickDefault && onChange) onChange(defaultValue);
688
+ if (!inactive && resetValue !== null && onChange) gesture.instant("doubleClick", () => change(resetValue ?? startValue));
829
689
  onDoubleClick?.(event);
830
690
  },
831
691
  onKeyDown: (event) => {
@@ -851,88 +711,47 @@ function useNumberInputContext(selector) {
851
711
  if (!context) throw new Error("Missing NumberInputContext.Provider in the tree");
852
712
  return selector ? selector(context) : context;
853
713
  }
714
+ const NumberInputGestureContext = /* @__PURE__ */ createContext(null);
715
+ const NumberInputGestureProvider = NumberInputGestureContext.Provider;
716
+ function useNumberInputGesture() {
717
+ return useContext(NumberInputGestureContext);
718
+ }
854
719
  const StepperContext = /* @__PURE__ */ createContext(null);
855
720
  const StepperProvider = StepperContext.Provider;
856
721
  function useStepperContext() {
857
722
  return useContext(StepperContext);
858
723
  }
859
724
  //#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);
725
+ //#region src/hooks/useLongPress.ts
726
+ /**
727
+ * Repeat `callback` while a pointer is held down: once on the press, then
728
+ * every `interval` after `initialDelay`. The repeat itself is
729
+ * `createLongPress` in `@tremolo-ui/dom`.
730
+ *
731
+ * Returns the function that starts a press, for an `onPointerDown`.
732
+ */
733
+ function useLongPress(callback, initialDelay = 500, interval = 40) {
734
+ const onPress = useCallbackRef(callback);
735
+ const instanceRef = useRef(null);
888
736
  useEffect(() => {
889
- savedCallback.current = callback;
890
- }, [callback]);
737
+ instanceRef.current?.update({
738
+ delay: initialDelay,
739
+ interval
740
+ });
741
+ });
891
742
  useEffect(() => {
892
- if (delay === null) return;
893
- const id = setInterval(() => {
894
- savedCallback.current();
895
- }, delay);
743
+ const instance = createLongPress({
744
+ onPress: () => onPress(),
745
+ delay: initialDelay,
746
+ interval
747
+ });
748
+ instanceRef.current = instance;
896
749
  return () => {
897
- clearInterval(id);
750
+ instanceRef.current = null;
751
+ instance.destroy();
898
752
  };
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]);
753
+ }, []);
754
+ return useCallback((event) => instanceRef.current?.start(event), []);
936
755
  }
937
756
  //#endregion
938
757
  //#region src/components/NumberInput/StepperButton.tsx
@@ -941,10 +760,11 @@ function useLongPress(callback, initialDelay = 500, interval = 40) {
941
760
  * in which way they move the value and which arrow they draw.
942
761
  */
943
762
  function StepperButton({ direction, variant, icon, className, style, children, onPointerDown, ...props }) {
944
- const { step, disabled, readonly, atMin, atMax, nudge } = useNumberInputContext();
763
+ const { step, disabled, readOnly, atMin, atMax, nudge } = useNumberInputContext();
945
764
  const stepper = useStepperContext();
765
+ const gesture = useNumberInputGesture();
946
766
  const press = useLongPress(() => {
947
- if (stepper?.draggingRef.current) return;
767
+ if (stepper?.moved()) return;
948
768
  nudge(direction, ["raw", step]);
949
769
  });
950
770
  return /* @__PURE__ */ jsx("div", {
@@ -954,10 +774,13 @@ function StepperButton({ direction, variant, icon, className, style, children, o
954
774
  "aria-label": direction > 0 ? "Increment" : "Decrement",
955
775
  "aria-disabled": disabled || (direction > 0 ? atMax : atMin),
956
776
  "data-disabled": disabled || (direction > 0 ? atMax : atMin) ? "" : void 0,
957
- "data-readonly": readonly ? "" : void 0,
777
+ "data-readonly": readOnly ? "" : void 0,
958
778
  style,
959
779
  onPointerDown: (event) => {
960
- if (!disabled && !readonly) press(event);
780
+ if (!disabled && !readOnly) {
781
+ gesture?.press();
782
+ press(event);
783
+ }
961
784
  onPointerDown?.(event);
962
785
  },
963
786
  ...props,
@@ -1003,21 +826,6 @@ function IncrementStepper(props) {
1003
826
  }
1004
827
  //#endregion
1005
828
  //#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
829
  /**
1022
830
  * The text field of a `NumberInput`, and the only place the value can be typed.
1023
831
  *
@@ -1027,8 +835,9 @@ const decimalAnchor = (text) => {
1027
835
  * How it behaves on focus, Enter and the arrow keys is set on `Root`
1028
836
  * (`selectOnFocus`, `unformatOnFocus`, `keepCaretOnStep`, `blurOnEnter`).
1029
837
  */
1030
- const InputField = /* @__PURE__ */ forwardRef(function InputField({ className, style, onFocus, onBlur, onKeyDown, ...props }, forwardedRef) {
1031
- const { value, min, max, step, disabled, readonly, keyboard, selectOnFocus, unformatOnFocus, keepCaretOnStep, blurOnEnter, text, editing, outOfRange, setDraft, commitDraft, nudge, inputRef } = useNumberInputContext();
838
+ const InputField = /* @__PURE__ */ forwardRef(function InputField({ className, style, onChange, onFocus, onBlur, onKeyDown, ...props }, forwardedRef) {
839
+ const { value, min, max, step, disabled, readOnly, keyboard, selectOnFocus, unformatOnFocus, keepCaretOnStep, blurOnEnter, text, editing, outOfRange, setDraft, commitDraft, nudge, inputRef } = useNumberInputContext();
840
+ const gesture = useNumberInputGesture();
1032
841
  const [focused, setFocused] = useState(false);
1033
842
  const shown = unformatOnFocus && focused && !editing ? String(value) : text;
1034
843
  useEffect(() => {
@@ -1036,7 +845,10 @@ const InputField = /* @__PURE__ */ forwardRef(function InputField({ className, s
1036
845
  const input = inputRef.current;
1037
846
  if (!input) return;
1038
847
  if (selectOnFocus === "all") input.setSelectionRange(0, input.value.length);
1039
- else input.setSelectionRange(0, input.value.match(NUMBER_PREFIX)?.[0].length ?? 0);
848
+ else {
849
+ const span = numberSpan(input.value);
850
+ if (span) input.setSelectionRange(span.start, span.end);
851
+ }
1040
852
  }, [
1041
853
  focused,
1042
854
  selectOnFocus,
@@ -1053,8 +865,7 @@ const InputField = /* @__PURE__ */ forwardRef(function InputField({ className, s
1053
865
  caretAfterStep.current = null;
1054
866
  const input = inputRef.current;
1055
867
  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)));
868
+ const caret = caretAtDecimalOffset(input.value, pending.offset);
1058
869
  input.setSelectionRange(caret, caret);
1059
870
  });
1060
871
  const composedRef = useComposedRefs(forwardedRef, inputRef);
@@ -1066,11 +877,11 @@ const InputField = /* @__PURE__ */ forwardRef(function InputField({ className, s
1066
877
  role: "spinbutton",
1067
878
  value: shown,
1068
879
  disabled,
1069
- readOnly: readonly,
880
+ readOnly,
1070
881
  "aria-disabled": disabled,
1071
- "aria-readonly": readonly,
882
+ "aria-readonly": readOnly,
1072
883
  "data-disabled": disabled ? "" : void 0,
1073
- "data-readonly": readonly ? "" : void 0,
884
+ "data-readonly": readOnly ? "" : void 0,
1074
885
  "aria-valuenow": value,
1075
886
  "aria-valuemin": min,
1076
887
  "aria-valuemax": max,
@@ -1081,6 +892,7 @@ const InputField = /* @__PURE__ */ forwardRef(function InputField({ className, s
1081
892
  onChange: (event) => {
1082
893
  caretAfterStep.current = null;
1083
894
  setDraft(event.currentTarget.value);
895
+ onChange?.(event);
1084
896
  },
1085
897
  onFocus: (event) => {
1086
898
  setFocused(true);
@@ -1096,13 +908,14 @@ const InputField = /* @__PURE__ */ forwardRef(function InputField({ className, s
1096
908
  if (key === "Enter") {
1097
909
  commitDraft();
1098
910
  if (blurOnEnter) event.currentTarget.blur();
1099
- } else if (keyboard && !disabled && !readonly && (key === "ArrowUp" || key === "ArrowDown") && !event.nativeEvent.isComposing) {
911
+ } else if (keyboard && !disabled && !readOnly && (key === "ArrowUp" || key === "ArrowDown") && !event.nativeEvent.isComposing) {
1100
912
  event.preventDefault();
1101
913
  const input = event.currentTarget;
1102
914
  if (keepCaretOnStep && input.selectionStart !== null) caretAfterStep.current = {
1103
- offset: input.selectionStart - decimalAnchor(input.value),
915
+ offset: caretDecimalOffset(input.value, input.selectionStart),
1104
916
  from: input.value
1105
917
  };
918
+ gesture?.key();
1106
919
  nudge(key === "ArrowUp" ? 1 : -1, keyboard, event);
1107
920
  }
1108
921
  onKeyDown?.(event);
@@ -1111,123 +924,66 @@ const InputField = /* @__PURE__ */ forwardRef(function InputField({ className, s
1111
924
  });
1112
925
  });
1113
926
  //#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
927
  //#region src/components/NumberInput/Stepper.tsx
1149
928
  /**
1150
929
  * 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.
930
+ * up and down moves the value one `step` every `drag` pixels. The counting is
931
+ * `createStepperDrag` in the core.
1152
932
  *
1153
933
  * The drag lives here rather than on `InputField` because `createDrag` turns
1154
934
  * off text selection on whatever element it is attached to.
1155
935
  */
1156
- const Stepper = /* @__PURE__ */ forwardRef(function Stepper({ className, style, children, ...props }, forwardedRef) {
1157
- const { value, step, disabled, readonly, drag, dragSensitivity, pointerLock, rawRange, changeValue } = useNumberInputContext();
1158
- const inactive = disabled || readonly;
1159
- /**
1160
- * The sensitivity as an amount per `drag` pixels, so that the drag goes
1161
- * through the same `applyDelta` the wheel and the arrow keys do. Carrying
1162
- * the modifier map over rather than resolving it here is what keeps `step`
1163
- * out of the pipeline for a modifier entry — naming one is a request to move
1164
- * off the grid.
1165
- */
1166
- const dragOptions = useMemo(() => mapModifier(dragSensitivity, (factor) => ["raw", step * factor]), [dragSensitivity, step]);
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
- }
936
+ const Stepper = /* @__PURE__ */ forwardRef(function Stepper({ className, style, children, onPointerDown, ...props }, forwardedRef) {
937
+ const { value, disabled, readOnly, drag, dragSensitivity, pointerLock, dragCursor, rawRange, changeValue } = useNumberInputContext();
938
+ const enabled = drag !== null && !disabled && !readOnly;
939
+ const [node, setNode] = useState(null);
940
+ const instanceRef = useRef(null);
941
+ const latest = useRef({
942
+ value,
943
+ changeValue
944
+ });
945
+ const options = {
946
+ range: rawRange,
947
+ pixels: drag ?? 1,
948
+ sensitivity: dragSensitivity,
949
+ pointerLock,
950
+ cursor: dragCursor
951
+ };
952
+ const optionsRef = useRef(options);
953
+ useEffect(() => {
954
+ latest.current = {
955
+ value,
956
+ changeValue
957
+ };
958
+ optionsRef.current = options;
959
+ instanceRef.current?.update(options);
1222
960
  });
1223
- const composedRef = useComposedRefs(forwardedRef, drag !== null && !inactive ? dragRefCallback : void 0);
1224
- const context = useMemo(() => ({ draggingRef }), []);
961
+ useEffect(() => {
962
+ if (!node || !enabled) return;
963
+ const instance = createStepperDrag(node, {
964
+ ...optionsRef.current,
965
+ getValue: () => latest.current.value,
966
+ onChange: (next) => latest.current.changeValue(next)
967
+ });
968
+ instanceRef.current = instance;
969
+ return () => {
970
+ instanceRef.current = null;
971
+ instance.destroy();
972
+ };
973
+ }, [node, enabled]);
974
+ const gesture = useNumberInputGesture();
975
+ const composedRef = useComposedRefs(forwardedRef, setNode);
976
+ const context = useMemo(() => ({ moved: () => instanceRef.current?.moved() ?? false }), []);
1225
977
  return /* @__PURE__ */ jsx(StepperProvider, {
1226
978
  value: context,
1227
979
  children: /* @__PURE__ */ jsx("div", {
1228
980
  ref: composedRef,
1229
981
  className,
1230
982
  style,
983
+ onPointerDown: (event) => {
984
+ if (enabled) gesture?.press();
985
+ onPointerDown?.(event);
986
+ },
1231
987
  ...props,
1232
988
  children
1233
989
  })
@@ -1235,48 +991,32 @@ const Stepper = /* @__PURE__ */ forwardRef(function Stepper({ className, style,
1235
991
  });
1236
992
  //#endregion
1237
993
  //#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
994
  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
995
  /**
1250
996
  * Input with some useful functions for entering numerical values.
1251
997
  */
1252
998
  const NumberInput = {
1253
- Root: /* @__PURE__ */ forwardRef(({ value, min, max, step = 1, scale = linearScale, format: formatProp, parse: parseProp, clampValue = true, wheel = DEFAULT_WHEEL_OPTIONS, keyboard = DEFAULT_KEYBOARD_OPTIONS, drag = 1, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, pointerLock = false, selectOnFocus = "none", unformatOnFocus = false, keepCaretOnStep = false, blurOnEnter = true, disabled = false, readonly = false, className, style, onChange, children, ...props }, forwardedRef) => {
999
+ Root: /* @__PURE__ */ forwardRef(({ value, min, max, step = 1, scale = linearScale, format: formatProp, parse: parseProp, clampValue = true, wheel = DEFAULT_WHEEL_OPTIONS, keyboard = DEFAULT_KEYBOARD_OPTIONS, drag = 1, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, pointerLock = false, dragCursor = "ns-resize", selectOnFocus = "none", unformatOnFocus = false, keepCaretOnStep = false, blurOnEnter = true, disabled = false, readOnly = false, className, style, onChange, onChangeStart, onChangeEnd, changeEndDelay, children, actionsRef, ...props }, forwardedRef) => {
1254
1000
  const inputRef = useRef(null);
1255
1001
  /**
1256
1002
  * The text being typed. The only state here: it is not a copy of `value`,
1257
1003
  * but the half-finished entry that has no value to be derived from yet.
1258
1004
  */
1259
1005
  const [draft, setDraft] = useState(null);
1260
- const inactive = disabled || readonly;
1006
+ const inactive = disabled || readOnly;
1007
+ const gesture = useChangeGesture(value, {
1008
+ onChangeStart,
1009
+ onChangeEnd,
1010
+ changeEndDelay
1011
+ }, inactive);
1261
1012
  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,
1013
+ const parse = parseProp ?? parseNumberText;
1014
+ const { normalized: range, raw: rawRange } = useMemo(() => numberInputRanges({
1270
1015
  min,
1271
1016
  max,
1272
1017
  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
1018
+ scale,
1019
+ clampValue
1280
1020
  }), [
1281
1021
  clampValue,
1282
1022
  min,
@@ -1284,6 +1024,10 @@ const NumberInput = {
1284
1024
  step,
1285
1025
  scale
1286
1026
  ]);
1027
+ const ranges = useMemo(() => ({
1028
+ normalized: range,
1029
+ raw: rawRange
1030
+ }), [range, rawRange]);
1287
1031
  useCheckSteps({
1288
1032
  component: "NumberInput",
1289
1033
  range: useMemo(() => min !== void 0 && max !== void 0 && min < max ? {
@@ -1303,37 +1047,56 @@ const NumberInput = {
1303
1047
  });
1304
1048
  const text = draft ?? format(value);
1305
1049
  const editing = draft !== null;
1306
- const outOfRange = !editing && (min !== void 0 && value < min || max !== void 0 && value > max);
1050
+ const bounds = numberInputBounds(value, {
1051
+ min,
1052
+ max,
1053
+ clampValue
1054
+ });
1055
+ const { atMin, atMax } = bounds;
1056
+ const outOfRange = !editing && bounds.outOfRange;
1307
1057
  const handleDraft = useCallback((next) => {
1308
1058
  if (inactive) return;
1309
1059
  setDraft(next);
1310
1060
  const parsed = parse(next);
1311
- if (Number.isFinite(parsed)) onChange?.(parsed);
1061
+ if (!Number.isFinite(parsed)) return;
1062
+ if (parsed !== value) {
1063
+ gesture.pulse("keyboard");
1064
+ gesture.changed(parsed);
1065
+ }
1066
+ onChange?.(parsed);
1312
1067
  }, [
1313
1068
  inactive,
1314
1069
  parse,
1315
- onChange
1070
+ onChange,
1071
+ value,
1072
+ gesture
1316
1073
  ]);
1317
1074
  const changeValue = useCallback((next) => {
1318
1075
  if (inactive) return;
1319
1076
  setDraft(null);
1320
- if (next !== value) onChange?.(next);
1077
+ if (next === value) return;
1078
+ gesture.changed(next);
1079
+ onChange?.(next);
1321
1080
  }, [
1322
1081
  inactive,
1323
1082
  value,
1324
- onChange
1083
+ onChange,
1084
+ gesture
1325
1085
  ]);
1326
1086
  const commitDraft = useCallback(() => {
1327
1087
  if (draft === null || inactive) return;
1328
- const parsed = parse(draft);
1329
- if (!Number.isFinite(parsed)) {
1088
+ const committed = commitNumberInputText(draft, parse, {
1089
+ min,
1090
+ max,
1091
+ clampValue
1092
+ });
1093
+ if (committed === null) {
1330
1094
  setDraft(null);
1331
1095
  return;
1332
1096
  }
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);
1097
+ if (committed !== value) gesture.pulse("keyboard");
1336
1098
  changeValue(committed);
1099
+ gesture.end();
1337
1100
  }, [
1338
1101
  draft,
1339
1102
  inactive,
@@ -1341,21 +1104,23 @@ const NumberInput = {
1341
1104
  clampValue,
1342
1105
  min,
1343
1106
  max,
1344
- changeValue
1107
+ changeValue,
1108
+ value,
1109
+ gesture
1345
1110
  ]);
1346
1111
  const nudge = useCallback((direction, option, modifiers) => {
1347
- const [mode] = selectModifier(option, modifiers).value;
1348
- changeValue(applyDelta(value, direction, option, mode === "raw" ? rawRange : range, modifiers));
1112
+ changeValue(nudgeNumberInput(value, direction, option, ranges, modifiers));
1349
1113
  }, [
1350
1114
  changeValue,
1351
1115
  value,
1352
- rawRange,
1353
- range
1116
+ ranges
1354
1117
  ]);
1355
1118
  const wheelRefCallback = useWheel((event) => {
1356
- if (!wheel || inactive || event.deltaY === 0) return;
1119
+ const direction = wheelDirection(event);
1120
+ if (!wheel || inactive || direction === null) return;
1357
1121
  event.preventDefault();
1358
- nudge(-Math.sign(event.deltaY), wheel, event);
1122
+ gesture.pulse("wheel");
1123
+ nudge(direction, wheel, event);
1359
1124
  }, { requireFocus: true });
1360
1125
  const context = useMemo(() => ({
1361
1126
  value,
@@ -1364,7 +1129,7 @@ const NumberInput = {
1364
1129
  step,
1365
1130
  scale,
1366
1131
  disabled,
1367
- readonly,
1132
+ readOnly,
1368
1133
  clampValue,
1369
1134
  range,
1370
1135
  rawRange,
@@ -1374,12 +1139,13 @@ const NumberInput = {
1374
1139
  outOfRange,
1375
1140
  dragSensitivity,
1376
1141
  pointerLock,
1142
+ dragCursor,
1377
1143
  selectOnFocus,
1378
1144
  unformatOnFocus,
1379
1145
  keepCaretOnStep,
1380
1146
  blurOnEnter,
1381
- atMin: clampValue && min !== void 0 && value <= min,
1382
- atMax: clampValue && max !== void 0 && value >= max,
1147
+ atMin,
1148
+ atMax,
1383
1149
  drag,
1384
1150
  format,
1385
1151
  parse,
@@ -1395,7 +1161,7 @@ const NumberInput = {
1395
1161
  step,
1396
1162
  scale,
1397
1163
  disabled,
1398
- readonly,
1164
+ readOnly,
1399
1165
  clampValue,
1400
1166
  range,
1401
1167
  rawRange,
@@ -1403,9 +1169,12 @@ const NumberInput = {
1403
1169
  text,
1404
1170
  editing,
1405
1171
  outOfRange,
1172
+ atMin,
1173
+ atMax,
1406
1174
  drag,
1407
1175
  dragSensitivity,
1408
1176
  pointerLock,
1177
+ dragCursor,
1409
1178
  selectOnFocus,
1410
1179
  unformatOnFocus,
1411
1180
  keepCaretOnStep,
@@ -1417,7 +1186,8 @@ const NumberInput = {
1417
1186
  changeValue,
1418
1187
  nudge
1419
1188
  ]);
1420
- useImperativeHandle(forwardedRef, () => {
1189
+ const rootRefCallback = useComposedRefs(forwardedRef, wheelRefCallback);
1190
+ useImperativeHandle(actionsRef, () => {
1421
1191
  return {
1422
1192
  focus() {
1423
1193
  if (!disabled) inputRef.current?.focus();
@@ -1427,16 +1197,39 @@ const NumberInput = {
1427
1197
  }
1428
1198
  };
1429
1199
  }, [disabled]);
1200
+ const pressed = useRef(false);
1201
+ const gestureContext = useMemo(() => ({
1202
+ press: () => {
1203
+ if (inactive) return;
1204
+ gesture.hold("pointer");
1205
+ if (pressed.current) return;
1206
+ pressed.current = true;
1207
+ const release = () => {
1208
+ pressed.current = false;
1209
+ gesture.end();
1210
+ window.removeEventListener("pointerup", release);
1211
+ window.removeEventListener("pointercancel", release);
1212
+ };
1213
+ window.addEventListener("pointerup", release);
1214
+ window.addEventListener("pointercancel", release);
1215
+ },
1216
+ key: () => {
1217
+ if (!inactive) gesture.pulse("keyboard");
1218
+ }
1219
+ }), [gesture, inactive]);
1430
1220
  return /* @__PURE__ */ jsx(NumberInputProvider, {
1431
1221
  value: context,
1432
- children: /* @__PURE__ */ jsx("div", {
1433
- ref: wheelRefCallback,
1434
- className,
1435
- "data-disabled": disabled ? "" : void 0,
1436
- "data-readonly": readonly ? "" : void 0,
1437
- style,
1438
- ...props,
1439
- children
1222
+ children: /* @__PURE__ */ jsx(NumberInputGestureProvider, {
1223
+ value: gestureContext,
1224
+ children: /* @__PURE__ */ jsx("div", {
1225
+ ref: rootRefCallback,
1226
+ className,
1227
+ "data-disabled": disabled ? "" : void 0,
1228
+ "data-readonly": readOnly ? "" : void 0,
1229
+ style,
1230
+ ...props,
1231
+ children
1232
+ })
1440
1233
  })
1441
1234
  });
1442
1235
  }),
@@ -1445,78 +1238,15 @@ const NumberInput = {
1445
1238
  IncrementStepper,
1446
1239
  DecrementStepper
1447
1240
  };
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
1241
  /**
1511
1242
  * Customizable piano component.
1512
1243
  */
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) {
1244
+ 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, actionsRef, ...props }, forwardedRef) {
1514
1245
  const [node, setNode] = useState(null);
1515
1246
  const [activeNotes, setActiveNotes] = useState([]);
1516
1247
  /** Set while `resizable` is on, measured from the parent. */
1517
1248
  const [resizedKeyWidth, setResizedKeyWidth] = useState(whiteKeyWidth);
1518
1249
  const notes = useMemo(() => getNoteRangeArray(noteRange), [noteRange]);
1519
- const whiteKeyCount = useMemo(() => notes.filter(isWhiteKey).length, [notes]);
1520
1250
  const layout = useMemo(() => ({
1521
1251
  noteRange,
1522
1252
  whiteKeyWidth: resizable ? resizedKeyWidth : whiteKeyWidth,
@@ -1536,21 +1266,20 @@ const Piano = { Root: /* @__PURE__ */ forwardRef(function Root({ noteRange, glis
1536
1266
  layout,
1537
1267
  glissando,
1538
1268
  midiMax,
1269
+ keyboardShortcuts,
1270
+ keyboardShortcutsScope,
1539
1271
  onPlayNote,
1540
1272
  onStopNote
1541
1273
  });
1542
1274
  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
1275
  useEffect(() => {
1549
1276
  if (!node) return;
1550
1277
  const instance = createPianoInput(node, {
1551
1278
  layout: latest.current.layout,
1552
1279
  glissando: latest.current.glissando,
1553
1280
  midiMax: latest.current.midiMax,
1281
+ keyboardShortcuts: latest.current.keyboardShortcuts,
1282
+ keyboardShortcutsScope: latest.current.keyboardShortcutsScope,
1554
1283
  onPlayNote: (note, velocity) => latest.current.onPlayNote?.(note, velocity),
1555
1284
  onStopNote: (note) => latest.current.onStopNote?.(note),
1556
1285
  onActiveNotesChange: setActiveNotes
@@ -1566,87 +1295,46 @@ const Piano = { Root: /* @__PURE__ */ forwardRef(function Root({ noteRange, glis
1566
1295
  layout,
1567
1296
  glissando,
1568
1297
  midiMax,
1298
+ keyboardShortcuts,
1299
+ keyboardShortcutsScope,
1569
1300
  onPlayNote,
1570
1301
  onStopNote
1571
1302
  };
1572
1303
  instanceRef.current?.update({
1573
1304
  layout,
1574
1305
  glissando,
1575
- midiMax
1306
+ midiMax,
1307
+ keyboardShortcuts,
1308
+ keyboardShortcutsScope
1576
1309
  });
1577
1310
  });
1311
+ const { first, last } = noteRange;
1578
1312
  useEffect(() => {
1579
1313
  if (!resizable || !node) return;
1580
1314
  const parent = node.parentElement;
1581
1315
  if (!parent) throw new Error("doesn't have a parent element.");
1582
1316
  const resizeObserver = new ResizeObserver(() => {
1583
- setResizedKeyWidth(node.clientWidth / Math.max(whiteKeyCount, 1) - keyGap);
1317
+ setResizedKeyWidth(fitWhiteKeyWidth(node.clientWidth, {
1318
+ noteRange: {
1319
+ first,
1320
+ last
1321
+ },
1322
+ keyGap,
1323
+ blackKeyWidthRatio
1324
+ }));
1584
1325
  });
1585
1326
  resizeObserver.observe(parent);
1586
1327
  return () => resizeObserver.disconnect();
1587
1328
  }, [
1588
1329
  resizable,
1589
1330
  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
1331
+ first,
1332
+ last,
1333
+ keyGap,
1334
+ blackKeyWidthRatio
1622
1335
  ]);
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
- useImperativeHandle(forwardedRef, () => ({
1336
+ const rootRefCallback = useComposedRefs(forwardedRef, setNode);
1337
+ useImperativeHandle(actionsRef, () => ({
1650
1338
  playNote: (note, velocity) => instanceRef.current?.noteOn(note, {
1651
1339
  source: "api",
1652
1340
  velocity
@@ -1654,14 +1342,13 @@ const Piano = { Root: /* @__PURE__ */ forwardRef(function Root({ noteRange, glis
1654
1342
  stopNote: (note) => instanceRef.current?.noteOff(note, { source: "api" })
1655
1343
  }), []);
1656
1344
  return /* @__PURE__ */ jsx("div", {
1657
- ref: setNode,
1345
+ ref: rootRefCallback,
1658
1346
  className,
1659
1347
  "data-resizable": resizable ? "" : void 0,
1660
1348
  role: "group",
1661
1349
  tabIndex: 0,
1662
1350
  style: {
1663
1351
  width: resizable ? "100%" : pianoWidth(layout),
1664
- "--height": cssLength(height),
1665
1352
  position: "relative",
1666
1353
  ...style
1667
1354
  },
@@ -1727,6 +1414,49 @@ function usePointsEditorContext(selector) {
1727
1414
  return selector ? selector(context) : context;
1728
1415
  }
1729
1416
  //#endregion
1417
+ //#region src/components/PointsEditor/Container.tsx
1418
+ const Container = /* @__PURE__ */ forwardRef(function Container({ children, className, style, ...props }, forwardedRef) {
1419
+ const { containerRef, disabled, selectable, editor } = usePointsEditorContext();
1420
+ const { refCallback: dragRefCallback } = useDragValue({
1421
+ axis: POINT_AXIS,
1422
+ baseElementRef: containerRef,
1423
+ shouldStart: (event) => !disabled && !editor.isPointElement(event.target),
1424
+ onDragStart: ([x, y], state) => {
1425
+ editor.beginSelectionBox({
1426
+ x,
1427
+ y
1428
+ }, state.event);
1429
+ },
1430
+ onChange: ([x, y]) => editor.moveSelectionBox({
1431
+ x,
1432
+ y
1433
+ }),
1434
+ onDragEnd: () => editor.endSelectionBox()
1435
+ });
1436
+ useWheel((event) => {
1437
+ const move = wheelMove(event);
1438
+ if (!move) return;
1439
+ const axis = move.axis === 0 ? "x" : "y";
1440
+ if (editor.nudgeFocusedPoint(axis, move.direction, event)) event.preventDefault();
1441
+ }, { target: containerRef });
1442
+ const composedRef = useComposedRefs(forwardedRef, containerRef, selectable && !disabled ? dragRefCallback : void 0);
1443
+ return /* @__PURE__ */ jsx("div", {
1444
+ ref: composedRef,
1445
+ className,
1446
+ style: {
1447
+ position: "absolute",
1448
+ inset: 0,
1449
+ zIndex: 10,
1450
+ ...style
1451
+ },
1452
+ ...props,
1453
+ children: /* @__PURE__ */ jsx(Placement, {
1454
+ name: "PointsEditor.Container",
1455
+ children
1456
+ })
1457
+ });
1458
+ });
1459
+ //#endregion
1730
1460
  //#region src/components/_util/VisuallyHiddenRangeInput.tsx
1731
1461
  const VisuallyHiddenRangeInput = /* @__PURE__ */ forwardRef((props, ref) => /* @__PURE__ */ jsx("input", {
1732
1462
  ...props,
@@ -1736,21 +1466,6 @@ const VisuallyHiddenRangeInput = /* @__PURE__ */ forwardRef((props, ref) => /* @
1736
1466
  }));
1737
1467
  //#endregion
1738
1468
  //#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
1469
  /**
1755
1470
  * One setting for both axes, or one per axis. The point holds a range input
1756
1471
  * for each, so anything that names or describes it comes in a pair.
@@ -1758,17 +1473,27 @@ const AXIS = {
1758
1473
  function perAxis(value, axis) {
1759
1474
  return typeof value === "string" ? value : value?.[axis];
1760
1475
  }
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();
1476
+ function Point({ value, children, id: idProp, min, max, color, disabled: _disabled, readOnly: _readOnly, wheel: _wheel, keyboard: _keyboard, "aria-label": ariaLabel, "aria-valuetext": ariaValuetext, onChange, onChangeStart, onChangeEnd, changeEndDelay, className, style, onPointerDown, onKeyDown, onFocus, ...props }) {
1477
+ const { containerRef, dragCursor, disabled: rootDisabled, readOnly: rootReadOnly, wheel: rootWheel, keyboard: rootKeyboard, dragSensitivity, selection, editor } = usePointsEditorContext();
1763
1478
  const generatedId = useId();
1764
1479
  const id = idProp ?? generatedId;
1765
1480
  const selected = selection.includes(id);
1766
1481
  const disabled = _disabled ?? rootDisabled;
1767
- const readonly = _readonly ?? rootReadonly;
1768
- const inactive = disabled || readonly;
1482
+ const readOnly = _readOnly ?? rootReadOnly;
1483
+ const inactive = disabled || readOnly;
1769
1484
  const wheel = _wheel === void 0 ? rootWheel : _wheel;
1770
1485
  const keyboard = _keyboard === void 0 ? rootKeyboard : _keyboard;
1771
1486
  useCheckPlacement("PointsEditor.Point", "PointsEditor.Container");
1487
+ const current = clampPoint(value, min, max);
1488
+ const gesture = useChangeGesture(current, {
1489
+ onChangeStart,
1490
+ onChangeEnd,
1491
+ changeEndDelay
1492
+ }, inactive);
1493
+ const handleChange = (next) => {
1494
+ gesture.changed(next);
1495
+ onChange?.(next);
1496
+ };
1772
1497
  const [element, setElement] = useState(null);
1773
1498
  const xInputRef = useRef(null);
1774
1499
  const yInputRef = useRef(null);
@@ -1777,9 +1502,10 @@ function Point({ value, children, id: idProp, min, max, size, width, height, col
1777
1502
  min,
1778
1503
  max,
1779
1504
  readonly: inactive,
1780
- onChange,
1505
+ onChange: onChange ? handleChange : void 0,
1781
1506
  element,
1782
- wheel
1507
+ wheel,
1508
+ beforeWheel: () => gesture.pulse("wheel")
1783
1509
  });
1784
1510
  useEffect(() => {
1785
1511
  registration.current = {
@@ -1787,82 +1513,62 @@ function Point({ value, children, id: idProp, min, max, size, width, height, col
1787
1513
  min,
1788
1514
  max,
1789
1515
  readonly: inactive,
1790
- onChange,
1516
+ onChange: onChange ? handleChange : void 0,
1791
1517
  element,
1792
- wheel
1518
+ wheel,
1519
+ beforeWheel: () => gesture.pulse("wheel")
1793
1520
  };
1794
1521
  });
1795
- useEffect(() => registerPoint(id, registration), [id, registerPoint]);
1522
+ useEffect(() => editor.registerPoint(id, () => registration.current), [id, editor]);
1796
1523
  /** Where the pointer was when the drag started, to measure the move from. */
1797
1524
  const pointerOrigin = useRef(null);
1798
1525
  const { refCallback: dragRefCallback, dragging } = useDragValue({
1799
- axis: AXIS,
1526
+ axis: POINT_AXIS,
1800
1527
  baseElementRef: containerRef,
1801
1528
  sensitivity: (state) => selectModifier(dragSensitivity, state.event).value,
1802
- cursor: inactive ? void 0 : externalStyles.cursor,
1529
+ cursor: inactive ? void 0 : dragCursor,
1803
1530
  shouldStart: () => !disabled,
1804
1531
  onChange: ([x, y]) => {
1805
1532
  const origin = pointerOrigin.current;
1806
1533
  if (!origin) return;
1807
- movePointDrag({
1534
+ editor.movePointDrag({
1808
1535
  x: x - origin.x,
1809
1536
  y: y - origin.y
1810
1537
  });
1811
1538
  },
1812
1539
  onDragStart: ([x, y], state) => {
1813
- beginPointDrag(id, state.event);
1540
+ editor.beginPointDrag(id, state.event);
1814
1541
  pointerOrigin.current = {
1815
1542
  x,
1816
1543
  y
1817
1544
  };
1818
1545
  xInputRef.current?.focus();
1819
- if (inactive) return;
1820
- onDragStart?.(clampPoint(value, min, max));
1546
+ if (!inactive) gesture.hold("pointer");
1821
1547
  },
1822
1548
  onDragEnd: () => {
1823
1549
  pointerOrigin.current = null;
1824
- if (inactive) return;
1825
- onDragEnd?.(clampPoint(value, min, max));
1550
+ gesture.end();
1826
1551
  }
1827
1552
  });
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
1553
  const refCallback = useComposedRefs(dragRefCallback, setElement);
1840
1554
  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";
1555
+ const move = arrowKeyMove(event.key);
1556
+ if (!move) return;
1849
1557
  event.preventDefault();
1850
1558
  if (!onChange || inactive || !keyboard) return;
1851
- nudge(axis, key === "ArrowLeft" || key === "ArrowUp" ? -1 : 1, keyboard, event);
1559
+ gesture.pulse("keyboard");
1560
+ editor.nudgePoint(id, move.axis === 0 ? "x" : "y", move.direction, keyboard, event);
1852
1561
  };
1853
- const current = clampPoint(value, min, max);
1854
1562
  return /* @__PURE__ */ jsxs("div", {
1855
1563
  ref: refCallback,
1856
1564
  className,
1857
1565
  "data-disabled": disabled ? "" : void 0,
1858
- "data-readonly": readonly ? "" : void 0,
1566
+ "data-readonly": readOnly ? "" : void 0,
1859
1567
  tabIndex: -1,
1860
1568
  "data-dragging": dragging ? "" : void 0,
1861
1569
  "data-selected": selected ? "" : void 0,
1862
1570
  style: {
1863
1571
  "--color": color,
1864
- "--width": cssLength(size ?? width),
1865
- "--height": cssLength(size ?? height),
1866
1572
  position: "absolute",
1867
1573
  translate: "var(--translate, -50% -50%)",
1868
1574
  ...style,
@@ -1887,17 +1593,17 @@ function Point({ value, children, id: idProp, min, max, size, width, height, col
1887
1593
  max: max?.[axis] ?? 1,
1888
1594
  step: "any",
1889
1595
  disabled,
1890
- "aria-readonly": readonly,
1596
+ "aria-readonly": readOnly,
1891
1597
  "aria-orientation": axis === "x" ? "horizontal" : "vertical",
1892
1598
  "aria-label": perAxis(ariaLabel, axis) ?? axis,
1893
1599
  "aria-valuetext": perAxis(ariaValuetext, axis),
1894
1600
  onChange: (event) => {
1895
- if (readonly) {
1601
+ if (readOnly) {
1896
1602
  event.currentTarget.value = String(current[axis]);
1897
1603
  return;
1898
1604
  }
1899
1605
  const delta = event.currentTarget.valueAsNumber - value[axis];
1900
- nudgeSelection(id, {
1606
+ editor.nudgeSelection(id, {
1901
1607
  x: axis === "x" ? delta : 0,
1902
1608
  y: axis === "y" ? delta : 0
1903
1609
  });
@@ -1906,50 +1612,6 @@ function Point({ value, children, id: idProp, min, max, size, width, height, col
1906
1612
  });
1907
1613
  }
1908
1614
  //#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
1615
  //#region src/components/PointsEditor/SelectionBox.tsx
1954
1616
  /**
1955
1617
  * The box a drag on empty space draws, and what it covers is selected.
@@ -1982,220 +1644,50 @@ function SelectionBox({ children, className, style, ...props }) {
1982
1644
  /** One array for every editor with selection turned off, so memos hold still. */
1983
1645
  const EMPTY = [];
1984
1646
  /**
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
1647
  * Multiple Point Controller
2023
1648
  */
2024
1649
  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) => {
1650
+ 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, dragCursor = "grabbing", style, className, children, ...props }, forwardedRef) => {
2026
1651
  const containerRef = useRef(null);
2027
- const { cursor = "grabbing" } = externalStyles ?? {};
2028
1652
  const controlled = selectionProp !== void 0;
2029
1653
  const [ownSelection, setOwnSelection] = useState(defaultSelection ?? []);
2030
1654
  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
1655
  const [selectionBox, setSelectionBox] = useState(null);
2128
- const selectionBoxRef = useRef(null);
2129
- selectionBoxRef.current ??= createSelectionBox({
2130
- *items() {
2131
- for (const [id, entry] of points.current) {
2132
- const { x, y } = entry.current.value;
2133
- yield [id, [x, y]];
2134
- }
2135
- },
2136
- onBoxChange: setSelectionBox
2137
- });
2138
- useEffect(() => {
2139
- selectionBoxRef.current?.update({ onSelectionChange: changeSelection });
2140
- }, [changeSelection]);
1656
+ const [editor] = useState(() => createPointsEditor({ onSelectionBoxChange: setSelectionBox }));
2141
1657
  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
1658
+ editor.update({
1659
+ selectable,
1660
+ selection,
1661
+ onSelectionChange: (next) => {
1662
+ if (!controlled) setOwnSelection(next);
1663
+ onSelectionChange?.(next);
1664
+ }
2150
1665
  });
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
- }, []);
1666
+ });
1667
+ useEffect(() => () => editor.destroy(), [editor]);
2160
1668
  const context = useMemo(() => ({
2161
1669
  disabled,
2162
- readonly,
1670
+ readOnly,
2163
1671
  wheel,
2164
1672
  keyboard,
2165
1673
  dragSensitivity,
2166
- externalStyles: { cursor },
1674
+ dragCursor,
2167
1675
  containerRef,
2168
1676
  selectable,
2169
1677
  selection,
2170
- registerPoint,
2171
- isPointElement,
2172
- beginPointDrag,
2173
- movePointDrag,
2174
- nudgeSelection,
2175
- nudgeFocusedPoint,
2176
- selectionBox,
2177
- beginSelectionBox,
2178
- moveSelectionBox,
2179
- endSelectionBox
1678
+ editor,
1679
+ selectionBox
2180
1680
  }), [
2181
1681
  disabled,
2182
- readonly,
1682
+ readOnly,
2183
1683
  wheel,
2184
1684
  keyboard,
2185
1685
  dragSensitivity,
2186
- cursor,
1686
+ dragCursor,
2187
1687
  selectable,
2188
1688
  selection,
2189
- registerPoint,
2190
- isPointElement,
2191
- beginPointDrag,
2192
- movePointDrag,
2193
- nudgeSelection,
2194
- nudgeFocusedPoint,
2195
- selectionBox,
2196
- beginSelectionBox,
2197
- moveSelectionBox,
2198
- endSelectionBox
1689
+ editor,
1690
+ selectionBox
2199
1691
  ]);
2200
1692
  return /* @__PURE__ */ jsx(PointsEditorProvider, {
2201
1693
  value: context,
@@ -2203,10 +1695,8 @@ const PointsEditor = {
2203
1695
  ref: forwardedRef,
2204
1696
  className,
2205
1697
  "data-disabled": disabled ? "" : void 0,
2206
- "data-readonly": readonly ? "" : void 0,
1698
+ "data-readonly": readOnly ? "" : void 0,
2207
1699
  style: {
2208
- "--width": cssLength(width),
2209
- "--height": cssLength(height),
2210
1700
  position: "relative",
2211
1701
  ...style
2212
1702
  },
@@ -2231,36 +1721,30 @@ function useSliderContext(selector) {
2231
1721
  }
2232
1722
  //#endregion
2233
1723
  //#region src/components/Slider/MarksOption.tsx
2234
- function MarksOption({ value, mark = true, label, thickness, length, gap, labelWidth, classes, styles, className, style, ...props }) {
1724
+ function MarksOption({ value, mark = true, label, thickness, length, gap, classes, styles, className, style, ...props }) {
2235
1725
  useCheckPlacement("Slider.MarksOption", "Slider.Marks");
2236
1726
  const min = useSliderContext((s) => s.min);
2237
1727
  const max = useSliderContext((s) => s.max);
2238
1728
  const scale = useSliderContext((s) => s.scale);
2239
- const vertical = useSliderContext((s) => s.vertical);
1729
+ const vertical = useSliderContext((s) => s.orientation === "vertical");
2240
1730
  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,
1731
+ const percent = valuePercent(value, {
2248
1732
  min,
1733
+ max,
2249
1734
  scale
2250
- ]);
1735
+ }, vertical !== reverse);
2251
1736
  return /* @__PURE__ */ jsxs("div", {
2252
1737
  className,
2253
1738
  style: {
2254
1739
  "--thickness": cssLength(thickness),
2255
1740
  "--length": cssLength(length),
2256
1741
  "--gap": cssLength(gap),
2257
- "--label-width": cssLength(labelWidth),
2258
1742
  position: "absolute",
2259
1743
  translate: vertical ? "var(--translate, 0 -50%)" : "var(--translate, -50% 0)",
2260
1744
  zIndex: 10,
2261
1745
  ...style,
2262
- left: !vertical ? `${calcPercent(value)}%` : void 0,
2263
- top: vertical ? `${calcPercent(value)}%` : void 0
1746
+ left: !vertical ? `${percent}%` : void 0,
1747
+ top: vertical ? `${percent}%` : void 0
2264
1748
  },
2265
1749
  "data-orientation": vertical ? "vertical" : "horizontal",
2266
1750
  ...props,
@@ -2276,52 +1760,14 @@ function MarksOption({ value, mark = true, label, thickness, length, gap, labelW
2276
1760
  });
2277
1761
  }
2278
1762
  //#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
1763
  //#region src/components/Slider/Marks.tsx
2318
1764
  function Marks({ gap, options, children, className, style, ...props }) {
2319
1765
  const min = useSliderContext((s) => s.min);
2320
1766
  const max = useSliderContext((s) => s.max);
2321
1767
  const step = useSliderContext((s) => s.step);
2322
- const vertical = useSliderContext((s) => s.vertical);
1768
+ const vertical = useSliderContext((s) => s.orientation === "vertical");
2323
1769
  const reverse = useSliderContext((s) => s.reverse);
2324
- const optionsList = options ? generateOptionsList(options, min, max, step) : [];
1770
+ const optionsList = options ? sliderMarks(options, min, max, step) : [];
2325
1771
  if (vertical !== reverse) optionsList.reverse();
2326
1772
  return /* @__PURE__ */ jsx("div", {
2327
1773
  className,
@@ -2346,9 +1792,9 @@ function Marks({ gap, options, children, className, style, ...props }) {
2346
1792
  }
2347
1793
  //#endregion
2348
1794
  //#region src/components/Slider/Thumb.tsx
2349
- const Thumb$1 = /* @__PURE__ */ forwardRef(function Thumb({ color, children, className, style, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, "aria-describedby": ariaDescribedby, "aria-valuetext": ariaValuetext, ...props }, forwardedRef) {
1795
+ const Thumb$1 = /* @__PURE__ */ forwardRef(function Thumb({ color, children, className, style, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, "aria-describedby": ariaDescribedby, "aria-valuetext": ariaValuetext, actionsRef, ...props }, forwardedRef) {
2350
1796
  const inputRef = useRef(null);
2351
- const { value, min, max, step, vertical, disabled, readonly, onChange, percent, thumbRef } = useSliderContext();
1797
+ const { value, min, max, step, orientation, disabled, readOnly, onChange, percent, thumbRef } = useSliderContext();
2352
1798
  useCheckPlacement("Slider.Thumb", "Slider.Track");
2353
1799
  const methods = () => ({
2354
1800
  focus() {
@@ -2358,12 +1804,13 @@ const Thumb$1 = /* @__PURE__ */ forwardRef(function Thumb({ color, children, cla
2358
1804
  inputRef.current?.blur();
2359
1805
  }
2360
1806
  });
2361
- useImperativeHandle(forwardedRef, methods, [disabled]);
1807
+ useImperativeHandle(actionsRef, methods, [disabled]);
2362
1808
  useImperativeHandle(thumbRef, methods, [disabled]);
2363
1809
  return /* @__PURE__ */ jsxs("div", {
1810
+ ref: forwardedRef,
2364
1811
  className,
2365
1812
  "data-disabled": disabled ? "" : void 0,
2366
- "data-readonly": readonly ? "" : void 0,
1813
+ "data-readonly": readOnly ? "" : void 0,
2367
1814
  ...props,
2368
1815
  style: {
2369
1816
  "--color": color,
@@ -2371,8 +1818,8 @@ const Thumb$1 = /* @__PURE__ */ forwardRef(function Thumb({ color, children, cla
2371
1818
  translate: "var(--translate, -50% -50%)",
2372
1819
  zIndex: 100,
2373
1820
  ...style,
2374
- top: vertical ? `${percent}%` : "50%",
2375
- left: !vertical ? `${percent}%` : "50%"
1821
+ top: orientation === "vertical" ? `${percent}%` : "50%",
1822
+ left: orientation === "vertical" ? "50%" : `${percent}%`
2376
1823
  },
2377
1824
  children: [/* @__PURE__ */ jsx(VisuallyHiddenRangeInput, {
2378
1825
  ref: inputRef,
@@ -2381,14 +1828,14 @@ const Thumb$1 = /* @__PURE__ */ forwardRef(function Thumb({ color, children, cla
2381
1828
  max,
2382
1829
  step,
2383
1830
  disabled,
2384
- "aria-readonly": readonly,
2385
- "aria-orientation": vertical ? "vertical" : "horizontal",
1831
+ "aria-readonly": readOnly,
1832
+ "aria-orientation": orientation,
2386
1833
  "aria-label": ariaLabel,
2387
1834
  "aria-labelledby": ariaLabelledby,
2388
1835
  "aria-describedby": ariaDescribedby,
2389
1836
  "aria-valuetext": ariaValuetext,
2390
1837
  onChange: (event) => {
2391
- if (readonly) {
1838
+ if (readOnly) {
2392
1839
  event.currentTarget.value = String(value);
2393
1840
  return;
2394
1841
  }
@@ -2399,18 +1846,18 @@ const Thumb$1 = /* @__PURE__ */ forwardRef(function Thumb({ color, children, cla
2399
1846
  });
2400
1847
  //#endregion
2401
1848
  //#region src/components/Slider/Track.tsx
2402
- const Track = /* @__PURE__ */ forwardRef(function Track({ length, thickness, active, inactive, children, className, style, ...props }, forwardedRef) {
2403
- const { vertical, reverse, disabled, percent, trackRef } = useSliderContext();
1849
+ const Track = /* @__PURE__ */ forwardRef(function Track({ length, thickness, activeColor, inactiveColor, children, className, style, ...props }, forwardedRef) {
1850
+ const { orientation, reverse, disabled, percent, trackRef } = useSliderContext();
2404
1851
  const composedRef = useComposedRefs(forwardedRef, trackRef);
2405
1852
  return /* @__PURE__ */ jsx("div", {
2406
1853
  ref: composedRef,
2407
1854
  className,
2408
1855
  "data-disabled": disabled ? "" : void 0,
2409
- "data-orientation": vertical ? "vertical" : "horizontal",
2410
- "data-flipped": vertical !== reverse ? "" : void 0,
1856
+ "data-orientation": orientation,
1857
+ "data-flipped": orientation === "vertical" !== reverse ? "" : void 0,
2411
1858
  style: {
2412
- "--active": active,
2413
- "--inactive": inactive,
1859
+ "--active-color": activeColor,
1860
+ "--inactive-color": inactiveColor,
2414
1861
  "--length": cssLength(length),
2415
1862
  "--thickness": cssLength(thickness),
2416
1863
  "--percent": `${percent}%`,
@@ -2426,7 +1873,6 @@ const Track = /* @__PURE__ */ forwardRef(function Track({ length, thickness, act
2426
1873
  });
2427
1874
  //#endregion
2428
1875
  //#region src/components/Slider/index.tsx
2429
- const defaultExternalStyles$1 = { cursor: "pointer" };
2430
1876
  /**
2431
1877
  * The wheel only acts while the focus is inside, so that scrolling a page past
2432
1878
  * the control does not change its value.
@@ -2436,18 +1882,17 @@ const WHEEL_OPTIONS$1 = { requireFocus: true };
2436
1882
  * Customizable slider
2437
1883
  */
2438
1884
  const Slider = {
2439
- Root: /* @__PURE__ */ forwardRef(({ value, min, max, step = 1, scale = linearScale, vertical = false, reverse = false, externalStyles: _externalStyles, wheel = DEFAULT_WHEEL_OPTIONS, keyboard = DEFAULT_KEYBOARD_OPTIONS, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, disabled = false, readonly = false, onChange, onDragStart, onDragEnd, className, style, children, onFocus, onBlur, onPointerDown, onKeyDown, ...props }, forwardedRef) => {
1885
+ Root: /* @__PURE__ */ forwardRef(({ value, min, max, step = 1, scale = linearScale, orientation = "horizontal", reverse = false, dragCursor = "pointer", wheel = DEFAULT_WHEEL_OPTIONS, keyboard = DEFAULT_KEYBOARD_OPTIONS, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, disabled = false, readOnly = false, onChange, onChangeStart, onChangeEnd, changeEndDelay, className, style, children, onFocus, onBlur, onPointerDown, onKeyDown, actionsRef, ...props }, forwardedRef) => {
2440
1886
  const trackRef = useRef(null);
2441
1887
  const thumbRef = useRef(null);
2442
- const externalStyles = {
2443
- ...defaultExternalStyles$1,
2444
- ..._externalStyles
2445
- };
2446
- const inactive = disabled || readonly;
2447
- const p = toFixed(scale.normalize(value, min, max) * 100);
2448
- const rev = toFixed(100 - p);
1888
+ const inactive = disabled || readOnly;
1889
+ const vertical = orientation === "vertical";
2449
1890
  const displayReversed = vertical !== reverse;
2450
- const percent = displayReversed ? rev : p;
1891
+ const percent = valuePercent(value, {
1892
+ min,
1893
+ max,
1894
+ scale
1895
+ }, displayReversed);
2451
1896
  const axis = useMemo(() => ({
2452
1897
  min,
2453
1898
  max,
@@ -2467,72 +1912,70 @@ const Slider = {
2467
1912
  keyboard,
2468
1913
  wheel
2469
1914
  });
1915
+ const gesture = useChangeGesture(value, {
1916
+ onChangeStart,
1917
+ onChangeEnd,
1918
+ changeEndDelay
1919
+ }, inactive);
1920
+ const change = useCallback((next) => {
1921
+ gesture.changed(next);
1922
+ onChange?.(next);
1923
+ }, [gesture, onChange]);
2470
1924
  const handleKeyDown = useCallback((event) => {
2471
- const key = event.key;
2472
- if (![
2473
- "ArrowRight",
2474
- "ArrowLeft",
2475
- "ArrowUp",
2476
- "ArrowDown"
2477
- ].includes(key)) return;
1925
+ const direction = arrowKeyDirection(event.key);
1926
+ if (direction === null) return;
2478
1927
  event.preventDefault();
2479
1928
  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));
1929
+ gesture.pulse("keyboard");
1930
+ change(applyDelta(value, reverse ? -direction : direction, keyboard, axis, event));
2483
1931
  }, [
2484
1932
  keyboard,
2485
1933
  onChange,
2486
1934
  inactive,
2487
1935
  reverse,
2488
1936
  value,
2489
- axis
1937
+ axis,
1938
+ gesture,
1939
+ change
2490
1940
  ]);
2491
1941
  const valueOf = (v) => v[vertical ? 1 : 0];
2492
1942
  const { refCallback: dragRefCallback } = useDragValue({
2493
1943
  axis,
2494
1944
  baseElementRef: trackRef,
2495
- cursor: inactive ? void 0 : externalStyles.cursor,
1945
+ cursor: inactive ? void 0 : dragCursor,
2496
1946
  shouldStart: () => !inactive,
2497
1947
  sensitivity: (state) => selectModifier(dragSensitivity, state.event).value,
2498
1948
  updateOnPointerDown: true,
2499
1949
  onChange: (v) => {
2500
1950
  if (inactive) return;
2501
- onChange?.(valueOf(v));
1951
+ change(valueOf(v));
2502
1952
  },
2503
- onDragStart: (v) => {
1953
+ onDragStart: () => {
2504
1954
  if (inactive) return;
1955
+ gesture.hold("pointer");
2505
1956
  thumbRef.current?.focus();
2506
- onDragStart?.(valueOf(v));
2507
1957
  },
2508
- onDragEnd: (v) => {
2509
- if (inactive) return;
2510
- onDragEnd?.(valueOf(v));
2511
- }
1958
+ onDragEnd: () => gesture.end()
2512
1959
  });
2513
1960
  const wheelRefCallback = useWheel((event) => {
2514
1961
  if (!wheel || !onChange || inactive) return;
1962
+ const direction = wheelDirection(event, { horizontal: !vertical });
1963
+ if (direction === null) return;
2515
1964
  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));
1965
+ gesture.pulse("wheel");
1966
+ change(applyDelta(value, reverse ? -direction : direction, wheel, axis, event));
2524
1967
  }, WHEEL_OPTIONS$1);
2525
- const rootRefCallback = useComposedRefs(dragRefCallback, wheelRefCallback);
1968
+ const rootRefCallback = useComposedRefs(forwardedRef, dragRefCallback, wheelRefCallback);
2526
1969
  const context = useMemo(() => ({
2527
1970
  value,
2528
1971
  min,
2529
1972
  max,
2530
1973
  step,
2531
1974
  scale,
2532
- vertical,
1975
+ orientation,
2533
1976
  reverse,
2534
1977
  disabled,
2535
- readonly,
1978
+ readOnly,
2536
1979
  onChange,
2537
1980
  percent,
2538
1981
  trackRef,
@@ -2543,14 +1986,14 @@ const Slider = {
2543
1986
  max,
2544
1987
  step,
2545
1988
  scale,
2546
- vertical,
1989
+ orientation,
2547
1990
  reverse,
2548
1991
  disabled,
2549
- readonly,
1992
+ readOnly,
2550
1993
  onChange,
2551
1994
  percent
2552
1995
  ]);
2553
- useImperativeHandle(forwardedRef, () => {
1996
+ useImperativeHandle(actionsRef, () => {
2554
1997
  return {
2555
1998
  focus() {
2556
1999
  if (!disabled) thumbRef.current?.focus();
@@ -2567,9 +2010,9 @@ const Slider = {
2567
2010
  ref: rootRefCallback,
2568
2011
  role: "group",
2569
2012
  tabIndex: -1,
2570
- "data-orientation": vertical ? "vertical" : "horizontal",
2013
+ "data-orientation": orientation,
2571
2014
  "data-disabled": disabled ? "" : void 0,
2572
- "data-readonly": readonly ? "" : void 0,
2015
+ "data-readonly": readOnly ? "" : void 0,
2573
2016
  style,
2574
2017
  onPointerDown,
2575
2018
  onKeyDown: (event) => {
@@ -2605,16 +2048,13 @@ function useXYPadContext(selector) {
2605
2048
  }
2606
2049
  //#endregion
2607
2050
  //#region src/components/XYPad/Area.tsx
2608
- const Area = /* @__PURE__ */ forwardRef(function Area({ width, height, color, children, className, style, ...props }, forwardedRef) {
2051
+ const Area = /* @__PURE__ */ forwardRef(function Area({ children, className, style, ...props }, forwardedRef) {
2609
2052
  const { areaRef } = useXYPadContext();
2610
2053
  const composedRef = useComposedRefs(forwardedRef, areaRef);
2611
2054
  return /* @__PURE__ */ jsx("div", {
2612
2055
  ref: composedRef,
2613
2056
  className,
2614
2057
  style: {
2615
- "--color": color,
2616
- "--width": cssLength(width),
2617
- "--height": cssLength(height),
2618
2058
  position: "relative",
2619
2059
  ...style
2620
2060
  },
@@ -2627,10 +2067,10 @@ const Area = /* @__PURE__ */ forwardRef(function Area({ width, height, color, ch
2627
2067
  });
2628
2068
  //#endregion
2629
2069
  //#region src/components/XYPad/Thumb.tsx
2630
- const Thumb = /* @__PURE__ */ forwardRef(function Thumb({ color, children, className, style, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, "aria-describedby": ariaDescribedby, "aria-valuetext": ariaValuetext, ...props }, forwardedRef) {
2070
+ const Thumb = /* @__PURE__ */ forwardRef(function Thumb({ color, children, className, style, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, "aria-describedby": ariaDescribedby, "aria-valuetext": ariaValuetext, actionsRef, ...props }, forwardedRef) {
2631
2071
  const xInputRef = useRef(null);
2632
2072
  const yInputRef = useRef(null);
2633
- const { value, min, max, step, disabled, readonly, onChange, percent, thumbRef } = useXYPadContext();
2073
+ const { value, min, max, step, disabled, readOnly, onChange, percent, thumbRef } = useXYPadContext();
2634
2074
  useCheckPlacement("XYPad.Thumb", "XYPad.Area");
2635
2075
  const labels = toXY(ariaLabel);
2636
2076
  const labelledby = toXY(ariaLabelledby);
@@ -2645,12 +2085,13 @@ const Thumb = /* @__PURE__ */ forwardRef(function Thumb({ color, children, class
2645
2085
  yInputRef.current?.blur();
2646
2086
  }
2647
2087
  });
2648
- useImperativeHandle(forwardedRef, methods, [disabled]);
2088
+ useImperativeHandle(actionsRef, methods, [disabled]);
2649
2089
  useImperativeHandle(thumbRef, methods, [disabled]);
2650
2090
  return /* @__PURE__ */ jsxs("div", {
2091
+ ref: forwardedRef,
2651
2092
  className,
2652
2093
  "data-disabled": disabled ? "" : void 0,
2653
- "data-readonly": readonly ? "" : void 0,
2094
+ "data-readonly": readOnly ? "" : void 0,
2654
2095
  ...props,
2655
2096
  style: {
2656
2097
  "--color": color,
@@ -2669,14 +2110,14 @@ const Thumb = /* @__PURE__ */ forwardRef(function Thumb({ color, children, class
2669
2110
  max: max[axis],
2670
2111
  step: step[axis],
2671
2112
  disabled,
2672
- "aria-readonly": readonly,
2113
+ "aria-readonly": readOnly,
2673
2114
  "aria-orientation": axis === 0 ? "horizontal" : "vertical",
2674
2115
  "aria-label": labels[axis] ?? (axis === 0 ? "x" : "y"),
2675
2116
  "aria-labelledby": labelledby[axis],
2676
2117
  "aria-describedby": describedby[axis],
2677
2118
  "aria-valuetext": valueText[axis],
2678
2119
  onChange: (event) => {
2679
- if (readonly) {
2120
+ if (readOnly) {
2680
2121
  event.currentTarget.value = String(value[axis]);
2681
2122
  return;
2682
2123
  }
@@ -2689,7 +2130,6 @@ const Thumb = /* @__PURE__ */ forwardRef(function Thumb({ color, children, class
2689
2130
  });
2690
2131
  //#endregion
2691
2132
  //#region src/components/XYPad/index.tsx
2692
- const defaultExternalStyles = { cursor: "pointer" };
2693
2133
  /**
2694
2134
  * The wheel only acts while the focus is inside, so that scrolling a page past
2695
2135
  * the control does not change its value.
@@ -2699,24 +2139,30 @@ const WHEEL_OPTIONS = { requireFocus: true };
2699
2139
  * Simple XYPad
2700
2140
  */
2701
2141
  const XYPad = {
2702
- Root: /* @__PURE__ */ forwardRef(({ value, min: _min, max: _max, step: _step = 1, scale: _scale = linearScale, reverse: _reverse = false, wheel = DEFAULT_WHEEL_OPTIONS, keyboard = DEFAULT_KEYBOARD_OPTIONS, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, className, style, externalStyles: _externalStyles, disabled = false, readonly = false, onChange, onDragStart, onDragEnd, onPointerDown, onKeyDown, onFocus, onBlur, children, ...props }, forwardedRef) => {
2142
+ Root: /* @__PURE__ */ forwardRef(({ value, min: _min, max: _max, step: _step = 1, scale: _scale = linearScale, reverse: _reverse = false, wheel = DEFAULT_WHEEL_OPTIONS, keyboard = DEFAULT_KEYBOARD_OPTIONS, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, className, style, dragCursor = "pointer", disabled = false, readOnly = false, onChange, onChangeStart, onChangeEnd, changeEndDelay, onPointerDown, onKeyDown, onFocus, onBlur, children, actionsRef, ...props }, forwardedRef) => {
2703
2143
  const rootRef = useRef(null);
2704
2144
  const areaRef = useRef(null);
2705
2145
  const thumbRef = useRef(null);
2706
- const externalStyles = {
2707
- ...defaultExternalStyles,
2708
- ..._externalStyles
2709
- };
2710
- const inactive = disabled || readonly;
2146
+ const inactive = disabled || readOnly;
2147
+ const gesture = useChangeGesture(value, {
2148
+ onChangeStart,
2149
+ onChangeEnd,
2150
+ changeEndDelay
2151
+ }, inactive);
2152
+ const change = useCallback((next) => {
2153
+ gesture.changed(next);
2154
+ onChange?.(next);
2155
+ }, [gesture, onChange]);
2711
2156
  const min = useMemo(() => toXY(_min), [_min]);
2712
2157
  const max = useMemo(() => toXY(_max), [_max]);
2713
2158
  const step = useMemo(() => toXY(_step), [_step]);
2714
2159
  const scale = useMemo(() => toXY(_scale), [_scale]);
2715
2160
  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
- }, [
2161
+ const percent = useMemo(() => [0, 1].map((i) => valuePercent(value[i], {
2162
+ min: min[i],
2163
+ max: max[i],
2164
+ scale: scale[i]
2165
+ }, reverse[i])), [
2720
2166
  value,
2721
2167
  min,
2722
2168
  max,
@@ -2758,61 +2204,50 @@ const XYPad = {
2758
2204
  withAxis
2759
2205
  ]);
2760
2206
  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;
2207
+ const move = arrowKeyMove(event.key);
2208
+ if (!move) return;
2769
2209
  event.preventDefault();
2770
2210
  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));
2211
+ const { axis: i, direction } = move;
2212
+ gesture.pulse("keyboard");
2213
+ change(nudge(i, reverse[i] ? -direction : direction, keyboard, event));
2775
2214
  }, [
2776
2215
  onChange,
2777
2216
  inactive,
2778
2217
  keyboard,
2779
2218
  reverse,
2780
- nudge
2219
+ nudge,
2220
+ gesture,
2221
+ change
2781
2222
  ]);
2782
2223
  const { refCallback: dragRefCallback } = useDragValue({
2783
2224
  axis,
2784
2225
  baseElementRef: areaRef,
2785
2226
  sensitivity: (state) => selectModifier(dragSensitivity, state.event).value,
2786
2227
  updateOnPointerDown: true,
2787
- cursor: inactive ? void 0 : externalStyles.cursor,
2228
+ cursor: inactive ? void 0 : dragCursor,
2788
2229
  shouldStart: () => !inactive,
2789
2230
  onChange: (v) => {
2790
2231
  if (inactive) return;
2791
- onChange?.(v);
2232
+ change(v);
2792
2233
  },
2793
- onDragStart: (v) => {
2234
+ onDragStart: () => {
2794
2235
  if (inactive) return;
2236
+ gesture.hold("pointer");
2795
2237
  thumbRef.current?.focus();
2796
- onDragStart?.(v);
2797
2238
  },
2798
- onDragEnd: (v) => {
2799
- if (inactive) return;
2800
- onDragEnd?.(v);
2801
- }
2239
+ onDragEnd: () => gesture.end()
2802
2240
  });
2803
2241
  const wheelRefCallback = useWheel((event) => {
2804
2242
  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;
2243
+ const move = wheelMove(event);
2244
+ if (!move) return;
2809
2245
  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));
2246
+ const { axis: i, direction } = move;
2247
+ gesture.pulse("wheel");
2248
+ change(nudge(i, reverse[i] ? -direction : direction, wheel, event));
2814
2249
  }, WHEEL_OPTIONS);
2815
- const rootRefCallback = useComposedRefs(rootRef, dragRefCallback, wheelRefCallback);
2250
+ const rootRefCallback = useComposedRefs(forwardedRef, rootRef, dragRefCallback, wheelRefCallback);
2816
2251
  const context = useMemo(() => ({
2817
2252
  value,
2818
2253
  min,
@@ -2821,7 +2256,7 @@ const XYPad = {
2821
2256
  scale,
2822
2257
  reverse,
2823
2258
  disabled,
2824
- readonly,
2259
+ readOnly,
2825
2260
  onChange,
2826
2261
  percent,
2827
2262
  areaRef,
@@ -2834,19 +2269,18 @@ const XYPad = {
2834
2269
  scale,
2835
2270
  reverse,
2836
2271
  disabled,
2837
- readonly,
2272
+ readOnly,
2838
2273
  onChange,
2839
2274
  percent
2840
2275
  ]);
2841
- useImperativeHandle(forwardedRef, () => {
2276
+ useImperativeHandle(actionsRef, () => {
2842
2277
  return {
2843
2278
  focus() {
2844
2279
  if (!disabled) thumbRef.current?.focus();
2845
2280
  },
2846
2281
  blur() {
2847
2282
  thumbRef.current?.blur();
2848
- },
2849
- original: rootRef
2283
+ }
2850
2284
  };
2851
2285
  }, [disabled]);
2852
2286
  return /* @__PURE__ */ jsx(XYPadProvider, {
@@ -2857,7 +2291,7 @@ const XYPad = {
2857
2291
  role: "group",
2858
2292
  tabIndex: -1,
2859
2293
  "data-disabled": disabled ? "" : void 0,
2860
- "data-readonly": readonly ? "" : void 0,
2294
+ "data-readonly": readOnly ? "" : void 0,
2861
2295
  style,
2862
2296
  onPointerDown,
2863
2297
  onKeyDown: (event) => {
@@ -2882,17 +2316,103 @@ const XYPad = {
2882
2316
  };
2883
2317
  //#endregion
2884
2318
  //#region src/hooks/useAnimationFrame.ts
2885
- function useAnimationFrame(callback = () => {}, deps = []) {
2886
- const reqIdRef = useRef(-1);
2319
+ /**
2320
+ * Call `callback` on every animation frame for as long as the component is
2321
+ * mounted.
2322
+ *
2323
+ * The callback is read on every frame, so it can be written inline and see
2324
+ * the latest render without restarting the loop.
2325
+ *
2326
+ * @param callback receives the frame's timestamp and the milliseconds since
2327
+ * the previous frame, which is `0` on the first one
2328
+ */
2329
+ function useAnimationFrame(callback, { disabled = false } = {}) {
2887
2330
  const runCallback = useCallbackRef(callback);
2888
2331
  useEffect(() => {
2889
- const loop = () => {
2890
- reqIdRef.current = requestAnimationFrame(loop);
2891
- runCallback();
2332
+ if (disabled) return;
2333
+ let frameId = 0;
2334
+ let previous;
2335
+ const loop = (timestamp) => {
2336
+ frameId = requestAnimationFrame(loop);
2337
+ runCallback(timestamp, previous === void 0 ? 0 : timestamp - previous);
2338
+ previous = timestamp;
2892
2339
  };
2893
- reqIdRef.current = requestAnimationFrame(loop);
2894
- return () => cancelAnimationFrame(reqIdRef.current);
2895
- }, [runCallback, ...deps]);
2340
+ frameId = requestAnimationFrame(loop);
2341
+ return () => cancelAnimationFrame(frameId);
2342
+ }, [runCallback, disabled]);
2343
+ }
2344
+ //#endregion
2345
+ //#region src/hooks/useDrag.ts
2346
+ /**
2347
+ * Track a pointer drag on an element.
2348
+ *
2349
+ * @returns a ref callback to attach to the element being dragged
2350
+ */
2351
+ function useDrag({ threshold = 1, cursor, pointerLock, onDrag, onDragStart, onDragEnd }) {
2352
+ const dragHandler = useCallbackRef(onDrag);
2353
+ const dragStartHandler = useCallbackRef(onDragStart);
2354
+ const dragEndHandler = useCallbackRef(onDragEnd);
2355
+ const [node, setNode] = useState(null);
2356
+ useEffect(() => {
2357
+ if (!node) return;
2358
+ const instance = createDrag(node, {
2359
+ threshold,
2360
+ cursor,
2361
+ pointerLock,
2362
+ onDragStart: (state) => dragStartHandler(state),
2363
+ onDrag: (state) => dragHandler(state.x, state.y, state.deltaX, state.deltaY, state),
2364
+ onDragEnd: (state) => dragEndHandler(state)
2365
+ });
2366
+ return () => instance.destroy();
2367
+ }, [
2368
+ node,
2369
+ threshold,
2370
+ cursor,
2371
+ pointerLock,
2372
+ dragHandler,
2373
+ dragStartHandler,
2374
+ dragEndHandler
2375
+ ]);
2376
+ return setNode;
2377
+ }
2378
+ //#endregion
2379
+ //#region src/hooks/useEventListener.ts
2380
+ function useEventListener(target, event, handler, options) {
2381
+ const listener = useCallbackRef(handler);
2382
+ const registrationRef = useRef(null);
2383
+ const { capture = false, once, passive } = typeof options === "boolean" ? { capture: options } : options ?? {};
2384
+ useEffect(() => {
2385
+ const node = typeof target === "function" ? target() : target;
2386
+ const current = registrationRef.current;
2387
+ if (current && current.node === node && current.event === event && current.capture === capture && current.once === once && current.passive === passive) return;
2388
+ current?.remove();
2389
+ registrationRef.current = null;
2390
+ if (!node) return;
2391
+ const listenerOptions = {
2392
+ capture,
2393
+ once,
2394
+ passive
2395
+ };
2396
+ node.addEventListener(event, listener, listenerOptions);
2397
+ registrationRef.current = {
2398
+ node,
2399
+ event,
2400
+ capture,
2401
+ once,
2402
+ passive,
2403
+ remove: () => node.removeEventListener(event, listener, listenerOptions)
2404
+ };
2405
+ });
2406
+ useEffect(() => () => {
2407
+ registrationRef.current?.remove();
2408
+ registrationRef.current = null;
2409
+ }, []);
2410
+ return useCallback(() => {
2411
+ const current = registrationRef.current;
2412
+ if (!current) return;
2413
+ current.remove();
2414
+ current.remove = () => {};
2415
+ }, []);
2896
2416
  }
2897
2417
  //#endregion
2898
2418
  //#region src/hooks/useMIDIAccess.ts
@@ -3031,6 +2551,6 @@ function useMIDIMessage(midiAccess, onMIDIMessage) {
3031
2551
  });
3032
2552
  }
3033
2553
  //#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 };
2554
+ export { AnimationCanvas, DropZone, FileInput, Knob, NumberInput, Piano, PointsEditor, Slider, XYPad, useAnimationFrame, useDrag, useDragValue, useDropZone, useEventListener, useFileInputContext, useKnobContext, useLongPress, useMIDIAccess, useMIDIInput, useMIDIMessage, useNumberInputContext, usePointsEditorContext, useSliderContext, useWheel, useXYPadContext };
3035
2555
 
3036
2556
  //# sourceMappingURL=index.js.map