@tremolo-ui/react 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/index.cjs +522 -997
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +601 -329
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.ts +601 -329
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +487 -953
  8. package/dist/index.js.map +1 -1
  9. package/package.json +4 -4
  10. package/src/components/DropZone/index.tsx +101 -0
  11. package/src/components/FileInput/Trigger.tsx +39 -0
  12. package/src/components/FileInput/context.tsx +24 -0
  13. package/src/components/FileInput/index.tsx +139 -0
  14. package/src/components/Knob/ActiveLine.tsx +5 -3
  15. package/src/components/Knob/InactiveLine.tsx +6 -4
  16. package/src/components/Knob/SVGRoot.tsx +3 -3
  17. package/src/components/Knob/Thumb.tsx +3 -2
  18. package/src/components/Knob/context.tsx +3 -74
  19. package/src/components/Knob/index.tsx +27 -21
  20. package/src/components/NumberInput/DecrementStepper.tsx +2 -13
  21. package/src/components/NumberInput/IncrementStepper.tsx +2 -14
  22. package/src/components/NumberInput/InputField.tsx +17 -38
  23. package/src/components/NumberInput/Stepper.tsx +44 -107
  24. package/src/components/NumberInput/StepperButton.tsx +16 -12
  25. package/src/components/NumberInput/context.tsx +8 -8
  26. package/src/components/NumberInput/index.tsx +41 -81
  27. package/src/components/Piano/index.tsx +39 -149
  28. package/src/components/PointsEditor/Background.tsx +2 -8
  29. package/src/components/PointsEditor/Container.tsx +17 -34
  30. package/src/components/PointsEditor/Point.tsx +41 -86
  31. package/src/components/PointsEditor/SelectionBox.tsx +2 -8
  32. package/src/components/PointsEditor/context.tsx +10 -61
  33. package/src/components/PointsEditor/index.tsx +31 -315
  34. package/src/components/Slider/Marks.tsx +8 -13
  35. package/src/components/Slider/MarksOption.tsx +11 -17
  36. package/src/components/Slider/Thumb.tsx +9 -15
  37. package/src/components/Slider/Track.tsx +7 -13
  38. package/src/components/Slider/index.tsx +33 -39
  39. package/src/components/XYPad/Area.tsx +26 -53
  40. package/src/components/XYPad/Thumb.tsx +8 -12
  41. package/src/components/XYPad/context.tsx +1 -3
  42. package/src/components/XYPad/index.tsx +35 -50
  43. package/src/components/_util/VisuallyHiddenRangeInput.tsx +3 -15
  44. package/src/css-variables.ts +23 -0
  45. package/src/hooks/_internal/useCheckSteps.ts +9 -147
  46. package/src/hooks/useDrag.ts +13 -4
  47. package/src/hooks/useDragValue.ts +17 -2
  48. package/src/hooks/useDropZone.ts +78 -0
  49. package/src/hooks/useLongPress.ts +35 -51
  50. package/src/hooks/useMIDIAccess.ts +0 -8
  51. package/src/hooks/useMIDIInput.ts +0 -3
  52. package/src/hooks/useWheel.ts +4 -1
  53. package/src/index.ts +14 -36
  54. package/src/components/Piano/keyboard-shortcuts.ts +0 -61
  55. package/src/components/Slider/type.ts +0 -44
  56. package/src/components/_util/css-length.ts +0 -13
  57. package/src/input-event.ts +0 -32
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { useComposedRefs } from "./compose-refs.js";
2
2
  import { createContext, forwardRef, useCallback, useContext, useEffect, useId, useImperativeHandle, useInsertionEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
3
- import { NOT_SUPPORTED, PERMISSION_DENIED, PITCH_BEND_CENTER, UNAVAILABLE, createAnimationCanvas, createDrag, createDragValue, createMIDIAccess, createMIDIInput, createMIDIMessage, createPianoInput, createSelectionBox, createWheel, elementMapping, relativeMapping, toXY } from "@tremolo-ui/dom";
3
+ import { DEFAULT_DRAG_SENSITIVITY, DEFAULT_KEYBOARD_OPTIONS, DEFAULT_WHEEL_OPTIONS, KNOB_VIEWBOX_SIZE, POINTS_EDITOR_DEFAULT_KEYBOARD, POINTS_EDITOR_DEFAULT_WHEEL, POINT_AXIS, applyDelta, arrowKeyDirection, arrowKeyMove, blackKeyWidth, caretAtDecimalOffset, caretDecimalOffset, checkSteps, clampPoint, commitNumberInputText, createAnimationCanvas, createDrag, createDragValue, createDropZone, createLongPress, createMIDIAccess, createMIDIInput, createMIDIMessage, createPianoInput, createPointsEditor, createStepperDrag, createWheel, cssLength, elementMapping, fitWhiteKeyWidth, getNoteRangeArray, knobAngles, knobArcPath, knobArcRadius, notePosition, nudgeNumberInput, numberInputBounds, numberInputRanges, numberSpan, parseNumberText, partitionByAccept, pianoWidth, relativeMapping, selectModifier, sliderMarks, toXY, valuePercent, visuallyHiddenStyle, wheelDirection, wheelMove } from "@tremolo-ui/dom";
4
4
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
- import { applyDelta, blackKeyWidth, clamp, decimalPart, getNoteRangeArray, isWhiteKey, linearScale, mapModifier, noteKey, notePosition, pianoWidth, radian, selectInputEvent, selectModifier, toFixed, toPrecision, xor } from "@tremolo-ui/functions";
5
+ import { clamp, isWhiteKey, linearScale, noteKey } from "@tremolo-ui/functions";
6
6
  //#region src/components/AnimationCanvas/index.tsx
7
7
  /**
8
8
  * A simple animatable canvas with requestAnimationFrame()
@@ -68,114 +68,181 @@ const AnimationCanvas = /* @__PURE__ */ forwardRef(function AnimationCanvas({ dr
68
68
  });
69
69
  });
70
70
  //#endregion
71
- //#region src/hooks/_internal/useCheckSteps.ts
72
- /** Positions probed across the travel. The ends are left out so that the
73
- * clamp at `min` and `max` cannot be mistaken for a press that does nothing. */
74
- const PROBES = [
75
- .1,
76
- .2,
77
- .3,
78
- .4,
79
- .5,
80
- .6,
81
- .7,
82
- .8,
83
- .9
84
- ];
85
- const NONE = {
86
- shiftKey: false,
87
- altKey: false,
88
- ctrlKey: false,
89
- metaKey: false
90
- };
91
- const MODIFIER_STATE = {
92
- shift: {
93
- ...NONE,
94
- shiftKey: true
95
- },
96
- alt: {
97
- ...NONE,
98
- altKey: true
99
- },
100
- ctrl: {
101
- ...NONE,
102
- ctrlKey: true
103
- },
104
- meta: {
105
- ...NONE,
106
- metaKey: true
107
- }
108
- };
109
- /** Each entry of a modifier-aware input option, as a name and held keys. */
110
- function entries(options) {
111
- if (Array.isArray(options)) return [{
112
- name: "",
113
- modifiers: NONE
114
- }];
115
- return Object.keys(options).map((key) => ({
116
- name: key === "default" ? "" : key,
117
- modifiers: MODIFIER_STATE[key] ?? NONE
118
- }));
71
+ //#region src/hooks/_internal/useCallbackRef.ts
72
+ /**
73
+ * Internal
74
+ * @private
75
+ */
76
+ function useCallbackRef(callback, deps = []) {
77
+ const callbackRef = useRef(() => {
78
+ throw new Error("Cannot call an event handler while rendering.");
79
+ });
80
+ useInsertionEffect(() => {
81
+ callbackRef.current = callback;
82
+ });
83
+ return useCallback((...args) => callbackRef.current?.(...args), deps);
119
84
  }
85
+ //#endregion
86
+ //#region src/hooks/useDropZone.ts
120
87
  /**
121
- * Press every entry of `options` at nine points along the travel and report
122
- * whether anything came of it.
88
+ * Take files dropped onto an element.
123
89
  *
124
- * Run against `applyDelta` itself rather than against a reading of `step`:
125
- * the whole point is that the amount, the step and the scale interact, and
126
- * the pipeline is the only thing that knows how.
90
+ * Any element can become a drop target this way — a waveform display, a track
91
+ * lane, a canvas — without wrapping it in anything.
92
+ *
93
+ * @returns a ref callback for the element, and whether a drag is over it
127
94
  */
128
- function probe(options, range, format) {
129
- const { min, max, scale = linearScale } = range;
130
- const outcome = {
131
- moved: false,
132
- visible: false
95
+ function useDropZone({ accept, multiple, disabled, onDrop, onReject } = {}) {
96
+ const dropHandler = useCallbackRef(onDrop);
97
+ const rejectHandler = useCallbackRef(onReject);
98
+ const [state, setState] = useState({
99
+ over: false,
100
+ invalid: false
101
+ });
102
+ const [node, setNode] = useState(null);
103
+ const instanceRef = useRef(null);
104
+ const latest = useRef({
105
+ accept,
106
+ multiple,
107
+ disabled
108
+ });
109
+ useEffect(() => {
110
+ if (!node) return;
111
+ const instance = createDropZone(node, {
112
+ ...latest.current,
113
+ onDrop: (files, event) => dropHandler(files, event),
114
+ onReject: (files, event) => rejectHandler(files, event),
115
+ onStateChange: (next) => setState(next)
116
+ });
117
+ instanceRef.current = instance;
118
+ return () => {
119
+ instanceRef.current = null;
120
+ instance.destroy();
121
+ setState({
122
+ over: false,
123
+ invalid: false
124
+ });
125
+ };
126
+ }, [
127
+ node,
128
+ dropHandler,
129
+ rejectHandler
130
+ ]);
131
+ useEffect(() => {
132
+ latest.current = {
133
+ accept,
134
+ multiple,
135
+ disabled
136
+ };
137
+ instanceRef.current?.update({
138
+ accept,
139
+ multiple,
140
+ disabled
141
+ });
142
+ });
143
+ return {
144
+ refCallback: setNode,
145
+ over: state.over,
146
+ invalid: state.invalid
133
147
  };
134
- for (const { modifiers } of entries(options)) for (const position of PROBES) {
135
- const value = scale.denormalize(position, min, max);
136
- const up = applyDelta(value, 1, options, range, modifiers);
137
- const down = applyDelta(value, -1, options, range, modifiers);
138
- if (up !== value || down !== value) outcome.moved = true;
139
- if (!format) continue;
140
- const shown = format(value);
141
- if (format(up) !== shown || format(down) !== shown) outcome.visible = true;
142
- }
143
- return outcome;
144
148
  }
145
149
  /**
150
+ * An area that takes files dropped onto it.
151
+ */
152
+ const DropZone = { Root: /* @__PURE__ */ forwardRef(({ accept, multiple = false, disabled = false, onDrop, onReject, children, ...props }, ref) => {
153
+ const { refCallback, over, invalid } = useDropZone({
154
+ accept,
155
+ multiple,
156
+ disabled,
157
+ onDrop,
158
+ onReject
159
+ });
160
+ const composedRef = useComposedRefs(ref, refCallback);
161
+ return /* @__PURE__ */ jsx("div", {
162
+ ref: composedRef,
163
+ "data-dragover": over ? "" : void 0,
164
+ "data-invalid": invalid ? "" : void 0,
165
+ "data-disabled": disabled ? "" : void 0,
166
+ ...props,
167
+ children
168
+ });
169
+ }) };
170
+ //#endregion
171
+ //#region src/components/FileInput/context.tsx
172
+ const FileInputContext = /* @__PURE__ */ createContext(null);
173
+ const FileInputProvider = FileInputContext.Provider;
174
+ function useFileInputContext(selector) {
175
+ const context = useContext(FileInputContext);
176
+ if (!context) throw new Error("Missing FileInputContext.Provider in the tree");
177
+ return selector ? selector(context) : context;
178
+ }
179
+ /**
180
+ * Picks files, and hands them over as `File`s.
181
+ */
182
+ const FileInput = {
183
+ Root: /* @__PURE__ */ forwardRef(({ accept, multiple = false, disabled = false, onChange, onReject, children, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, "aria-describedby": ariaDescribedby, ...props }, ref) => {
184
+ const inputId = useId();
185
+ const context = useMemo(() => ({
186
+ inputId,
187
+ disabled
188
+ }), [inputId, disabled]);
189
+ function handleChange(event) {
190
+ const { accepted, rejected } = partitionByAccept(Array.from(event.target.files ?? []), accept);
191
+ event.target.value = "";
192
+ if (accepted.length > 0) onChange?.(accepted);
193
+ if (rejected.length > 0) onReject?.(rejected);
194
+ }
195
+ return /* @__PURE__ */ jsx(FileInputProvider, {
196
+ value: context,
197
+ children: /* @__PURE__ */ jsxs("div", {
198
+ ref,
199
+ "data-disabled": disabled ? "" : void 0,
200
+ ...props,
201
+ children: [/* @__PURE__ */ jsx("input", {
202
+ id: inputId,
203
+ type: "file",
204
+ accept,
205
+ multiple,
206
+ disabled,
207
+ style: visuallyHiddenStyle,
208
+ "aria-label": ariaLabel,
209
+ "aria-labelledby": ariaLabelledby,
210
+ "aria-describedby": ariaDescribedby,
211
+ onChange: handleChange
212
+ }), children]
213
+ })
214
+ });
215
+ }),
216
+ Trigger: /* @__PURE__ */ forwardRef(({ children, ...props }, ref) => {
217
+ const { inputId, disabled } = useFileInputContext();
218
+ return /* @__PURE__ */ jsx("label", {
219
+ ref,
220
+ htmlFor: inputId,
221
+ "data-disabled": disabled ? "" : void 0,
222
+ ...props,
223
+ children
224
+ });
225
+ })
226
+ };
227
+ //#endregion
228
+ //#region src/hooks/_internal/useCheckSteps.ts
229
+ /**
146
230
  * Warn, in development only, when a key press or a wheel notch cannot produce
147
- * a change the user can see.
148
- *
149
- * Two settings that are each fine on their own can cancel out, and nothing
150
- * fails when they do — the control simply sits there:
151
- *
152
- * - **`step` coarser than the amount.** `keyboard={['raw', 0.1]}` with
153
- * `step={1}` rounds every press straight back to where it started
154
- * - **the display coarser than the amount.** A `format` showing two decimals
155
- * of a kHz value cannot show a press worth 1 Hz
156
- *
157
- * The second is only reported when the press is invisible at *every* point
158
- * along the travel. A display that rounds is a deliberate choice and is
159
- * normally right — it is being too coarse everywhere that makes it a mistake.
231
+ * a change the user can see. The probing is `checkSteps` in the core.
160
232
  *
161
233
  * @internal
162
234
  */
163
- function useCheckSteps({ component, axis, range, keyboard, wheel, format }) {
235
+ function useCheckSteps(options) {
236
+ const { component, axis, range, keyboard, wheel } = options;
164
237
  const { min, max, step, scale } = range ?? {};
165
- const where = axis ? `${component} (${axis})` : component;
166
238
  useEffect(() => {
167
239
  try {
168
240
  if (process.env.NODE_ENV === "production") return;
169
- if (!range || min === void 0 || max === void 0 || !(min < max)) return;
170
- for (const [name, options] of [["keyboard", keyboard], ["wheel", wheel]]) {
171
- if (!options) continue;
172
- const { moved, visible } = probe(options, range, format);
173
- if (!moved) console.warn(`[tremolo-ui] ${where}: \`${name}\` cannot move the value.` + (step !== void 0 ? ` Each press is smaller than \`step\` (${step}), so it rounds` : " Each press rounds") + " straight back to where it started.");
174
- else if (format && !visible) console.warn(`[tremolo-ui] ${where}: \`${name}\` moves the value, but \`format\` shows the same text before and after, everywhere in the range. The display is too coarse for it to be seen.`);
175
- }
241
+ for (const warning of checkSteps(options)) console.warn(warning);
176
242
  } catch {}
177
243
  }, [
178
- where,
244
+ component,
245
+ axis,
179
246
  min,
180
247
  max,
181
248
  step,
@@ -185,21 +252,6 @@ function useCheckSteps({ component, axis, range, keyboard, wheel, format }) {
185
252
  ]);
186
253
  }
187
254
  //#endregion
188
- //#region src/hooks/_internal/useCallbackRef.ts
189
- /**
190
- * Internal
191
- * @private
192
- */
193
- function useCallbackRef(callback, deps = []) {
194
- const callbackRef = useRef(() => {
195
- throw new Error("Cannot call an event handler while rendering.");
196
- });
197
- useInsertionEffect(() => {
198
- callbackRef.current = callback;
199
- });
200
- return useCallback((...args) => callbackRef.current?.(...args), deps);
201
- }
202
- //#endregion
203
255
  //#region src/hooks/useDragValue.ts
204
256
  /**
205
257
  * Drive a value with a pointer drag.
@@ -298,49 +350,6 @@ function useWheel(onWheel, { requireFocus = false, target } = {}) {
298
350
  return setNode;
299
351
  }
300
352
  //#endregion
301
- //#region src/input-event.ts
302
- /**
303
- * The keyboard amount used by Knob, NumberInput, Slider, and XYPad by default:
304
- * 1 per press in the units of the value, and 0.1 with shift held. That is one
305
- * `step` only while `step` is 1; a coarser `step` rounds 1 straight back, which
306
- * `useCheckSteps` warns about.
307
- *
308
- * A modifier entry is not snapped to `step`, which is what lets the finer
309
- * amount move at all — see `applyDelta` in `@tremolo-ui/functions`.
310
- */
311
- const DEFAULT_KEYBOARD_OPTIONS = {
312
- default: ["raw", 1],
313
- shift: ["raw", .1]
314
- };
315
- /**
316
- * The wheel amount used by Knob, NumberInput, Slider, and XYPad by default.
317
- *
318
- * Browsers turn shift+wheel into horizontal scrolling, which empties `deltaY`
319
- * and fills `deltaX`, so no modifier is bound here.
320
- */
321
- const DEFAULT_WHEEL_OPTIONS = ["raw", 1];
322
- /**
323
- * The drag sensitivity used by value controls by default. Shift makes the
324
- * same movement cover a tenth of the range, matching the arrow keys.
325
- */
326
- const DEFAULT_DRAG_SENSITIVITY = {
327
- default: 1,
328
- shift: .1
329
- };
330
- //#endregion
331
- //#region src/components/_util/css-length.ts
332
- /**
333
- * A number as a CSS length, for a custom property.
334
- *
335
- * React appends `px` to a bare number for the properties it knows take a
336
- * length, and a custom property is never one of them: `--size: 50` would come
337
- * out as the invalid `50`. Anything already a string is passed through, so
338
- * `'3rem'` and `'100%'` still work.
339
- */
340
- function cssLength(value) {
341
- return typeof value === "number" ? `${value}px` : value;
342
- }
343
- //#endregion
344
353
  //#region src/components/_util/Placement.tsx
345
354
  /**
346
355
  * The subcomponent that established the current placement, if any.
@@ -382,49 +391,8 @@ function useCheckPlacement(child, parent) {
382
391
  parent
383
392
  ]);
384
393
  }
385
- /**
386
- * 角度から円弧上の点を求める。
387
- *
388
- * 半径は線の太さの分だけ内側に取る必要があるが、太さは ActiveLine / InactiveLine が
389
- * それぞれ持つため、座標はストアで先に計算せず各コンポーネント側で求める。
390
- */
391
- function pointOnArc(angle, radius) {
392
- return {
393
- x: 50 + radius * Math.cos(radian(angle - 90)),
394
- y: 50 + radius * Math.sin(radian(angle - 90))
395
- };
396
- }
397
- /** 線の太さが viewBox からはみ出さないようにした半径 */
398
- function arcRadius(strokeWidth) {
399
- const width = typeof strokeWidth === "number" ? strokeWidth : Number.parseFloat(String(strokeWidth));
400
- return Number.isFinite(width) ? 50 - width / 2 : 50;
401
- }
402
- /** Build an SVG path for an arc, splitting full turns into drawable segments. */
403
- function arcPath(startAngle, endAngle, radius) {
404
- const start = pointOnArc(startAngle, radius);
405
- const sweep = endAngle - startAngle;
406
- const segmentCount = Math.max(1, Math.ceil(Math.abs(sweep) / 180));
407
- const segmentSweep = sweep / segmentCount;
408
- let path = `M ${start.x} ${start.y}`;
409
- for (let i = 1; i <= segmentCount; i += 1) {
410
- const end = pointOnArc(startAngle + segmentSweep * i, radius);
411
- path += ` A ${radius} ${radius} 0 0 ${segmentSweep >= 0 ? 1 : 0} ${end.x} ${end.y}`;
412
- }
413
- return path;
414
- }
415
- /** 設定から描画に必要な角度を導出する。レンダー中に呼ぶ。 */
416
- function calcAngles({ value, min, max, scale, startValue, angleRange }) {
417
- const p = scale.normalize(value, min, max);
418
- const s = scale.normalize(startValue, min, max);
419
- const r1 = -angleRange / 2;
420
- return {
421
- p,
422
- r1,
423
- r2: r1 + Math.min(p, s) * angleRange,
424
- r3: r1 + Math.max(p, s) * angleRange,
425
- r4: angleRange / 2
426
- };
427
- }
394
+ //#endregion
395
+ //#region src/components/Knob/context.tsx
428
396
  const KnobContext = /* @__PURE__ */ createContext(null);
429
397
  const KnobProvider = KnobContext.Provider;
430
398
  function useKnobContext(selector) {
@@ -438,10 +406,10 @@ function ActiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...pr
438
406
  useCheckPlacement("Knob.ActiveLine", "Knob.SVGRoot");
439
407
  const r2 = useKnobContext((s) => s.r2);
440
408
  const r3 = useKnobContext((s) => s.r3);
441
- const radius = arcRadius(strokeWidth);
409
+ const radius = knobArcRadius(strokeWidth);
442
410
  return /* @__PURE__ */ jsx("path", {
443
411
  className,
444
- d: arcPath(r2, r3, radius),
412
+ d: knobArcPath(r2, r3, radius),
445
413
  fill: "none",
446
414
  stroke,
447
415
  strokeWidth,
@@ -459,17 +427,17 @@ function InactiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...
459
427
  const r2 = useKnobContext((s) => s.r2);
460
428
  const r3 = useKnobContext((s) => s.r3);
461
429
  const r4 = useKnobContext((s) => s.r4);
462
- const radius = arcRadius(strokeWidth);
430
+ const radius = knobArcRadius(strokeWidth);
463
431
  return /* @__PURE__ */ jsxs(Fragment, { children: [startValue > min && /* @__PURE__ */ jsx("path", {
464
432
  className,
465
- d: arcPath(r1, r2, radius),
433
+ d: knobArcPath(r1, r2, radius),
466
434
  fill: "none",
467
435
  stroke,
468
436
  strokeWidth,
469
437
  ...props
470
438
  }), startValue < max && /* @__PURE__ */ jsx("path", {
471
439
  className,
472
- d: arcPath(r3, r4, radius),
440
+ d: knobArcPath(r3, r4, radius),
473
441
  fill: "none",
474
442
  stroke,
475
443
  strokeWidth,
@@ -480,7 +448,7 @@ function InactiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...
480
448
  //#region src/components/Knob/SVGRoot.tsx
481
449
  function SVGRoot({ children, style, ...props }) {
482
450
  return /* @__PURE__ */ jsx("svg", {
483
- viewBox: `0 0 100 100`,
451
+ viewBox: `0 0 ${KNOB_VIEWBOX_SIZE} ${KNOB_VIEWBOX_SIZE}`,
484
452
  style: {
485
453
  display: "block",
486
454
  ...style
@@ -510,7 +478,7 @@ function Thumb$2({ className, thumb = "currentColor", thumbLine = "currentColor"
510
478
  }), /* @__PURE__ */ jsx("line", {
511
479
  className: classes?.thumbLine,
512
480
  x1: "50%",
513
- y1: `${(100 - clamp(thumbSize, 0, 100)) / 2}%`,
481
+ y1: `${(KNOB_VIEWBOX_SIZE - clamp(thumbSize, 0, 100)) / 2}%`,
514
482
  x2: "50%",
515
483
  y2: `${thumbLineLength}%`,
516
484
  stroke: thumbLine,
@@ -560,16 +528,10 @@ const Knob = {
560
528
  });
561
529
  const handleKeyDown = useCallback((event) => {
562
530
  if (!keyboard || !onChange || inactive) return;
563
- const key = event.key;
564
- if ([
565
- "ArrowRight",
566
- "ArrowLeft",
567
- "ArrowUp",
568
- "ArrowDown"
569
- ].includes(key)) {
570
- event.preventDefault();
571
- onChange(applyDelta(value, key === "ArrowRight" || key === "ArrowUp" ? 1 : -1, keyboard, range, event));
572
- }
531
+ const direction = arrowKeyDirection(event.key);
532
+ if (direction === null) return;
533
+ event.preventDefault();
534
+ onChange(applyDelta(value, direction, keyboard, range, event));
573
535
  }, [
574
536
  keyboard,
575
537
  onChange,
@@ -595,9 +557,11 @@ const Knob = {
595
557
  });
596
558
  const wheelRefCallback = useWheel((event) => {
597
559
  if (!wheel || inactive) return;
560
+ const direction = wheelDirection(event);
561
+ if (direction === null) return;
598
562
  event.preventDefault();
599
- if (!onChange || event.deltaY === 0) return;
600
- onChange(applyDelta(value, -Math.sign(event.deltaY), wheel, range, event));
563
+ if (!onChange) return;
564
+ onChange(applyDelta(value, direction, wheel, range, event));
601
565
  }, WHEEL_OPTIONS$2);
602
566
  const rootRefCallback = useComposedRefs(elmRef, dragRefCallback, wheelRefCallback);
603
567
  const context = useMemo(() => {
@@ -612,7 +576,7 @@ const Knob = {
612
576
  };
613
577
  return {
614
578
  ...config,
615
- ...calcAngles(config)
579
+ ...knobAngles(config)
616
580
  };
617
581
  }, [
618
582
  value,
@@ -686,82 +650,36 @@ function useStepperContext() {
686
650
  return useContext(StepperContext);
687
651
  }
688
652
  //#endregion
689
- //#region src/hooks/useEventListener.ts
690
- function useEventListener(target, event, handler, options) {
691
- const listener = useCallbackRef(handler);
692
- const cleanupRef = useRef(() => {});
693
- useEffect(() => {
694
- const node = typeof target === "function" ? target() : target ?? document;
695
- if (!node) return;
696
- node.addEventListener(event, listener, options);
697
- const cleanup = () => {
698
- node.removeEventListener(event, listener, options);
699
- };
700
- cleanupRef.current = cleanup;
701
- return () => {
702
- cleanup();
703
- if (cleanupRef.current === cleanup) cleanupRef.current = () => {};
704
- };
705
- }, [
706
- event,
707
- target,
708
- options,
709
- listener
710
- ]);
711
- return useCallback(() => cleanupRef.current(), []);
712
- }
713
- //#endregion
714
- //#region src/hooks/useInterval.ts
715
- function useInterval(callback, delay) {
716
- const savedCallback = useRef(callback);
653
+ //#region src/hooks/useLongPress.ts
654
+ /**
655
+ * Repeat `callback` while a pointer is held down: once on the press, then
656
+ * every `interval` after `initialDelay`. The repeat itself is
657
+ * `createLongPress` in `@tremolo-ui/dom`.
658
+ *
659
+ * Returns the function that starts a press, for an `onPointerDown`.
660
+ */
661
+ function useLongPress(callback, initialDelay = 500, interval = 40) {
662
+ const onPress = useCallbackRef(callback);
663
+ const instanceRef = useRef(null);
717
664
  useEffect(() => {
718
- savedCallback.current = callback;
719
- }, [callback]);
665
+ instanceRef.current?.update({
666
+ delay: initialDelay,
667
+ interval
668
+ });
669
+ });
720
670
  useEffect(() => {
721
- if (delay === null) return;
722
- const id = setInterval(() => {
723
- savedCallback.current();
724
- }, delay);
671
+ const instance = createLongPress({
672
+ onPress: () => onPress(),
673
+ delay: initialDelay,
674
+ interval
675
+ });
676
+ instanceRef.current = instance;
725
677
  return () => {
726
- clearInterval(id);
678
+ instanceRef.current = null;
679
+ instance.destroy();
727
680
  };
728
- }, [delay]);
729
- }
730
- //#endregion
731
- //#region src/hooks/useLongPress.ts
732
- function useLongPress(callback, initialDelay = 500, interval = 40) {
733
- const [pressed, setPressed] = useState(false);
734
- const [delay, setDelay] = useState(initialDelay);
735
- const activePointerId = useRef(null);
736
- const isPressed = useRef(false);
737
- useInterval(() => {
738
- callback();
739
- setDelay(interval);
740
- }, pressed ? delay : null);
741
- const stop = useCallback((pointerId) => {
742
- if (!isPressed.current) return;
743
- if (pointerId !== void 0 && activePointerId.current !== null && activePointerId.current !== pointerId) return;
744
- isPressed.current = false;
745
- activePointerId.current = null;
746
- setPressed(false);
747
- setDelay(initialDelay);
748
- }, [initialDelay]);
749
- useEventListener(globalThis.window, "pointerup", (event) => {
750
- stop(event.pointerId);
751
- });
752
- useEventListener(globalThis.window, "pointercancel", (event) => {
753
- stop(event.pointerId);
754
- });
755
- useEventListener(globalThis.window, "blur", () => {
756
- stop();
757
- });
758
- return useCallback((event) => {
759
- if (isPressed.current || event && event.button !== 0) return;
760
- isPressed.current = true;
761
- activePointerId.current = event?.pointerId ?? null;
762
- callback();
763
- setPressed(true);
764
- }, [callback]);
681
+ }, []);
682
+ return useCallback((event) => instanceRef.current?.start(event), []);
765
683
  }
766
684
  //#endregion
767
685
  //#region src/components/NumberInput/StepperButton.tsx
@@ -773,7 +691,7 @@ function StepperButton({ direction, variant, icon, className, style, children, o
773
691
  const { step, disabled, readonly, atMin, atMax, nudge } = useNumberInputContext();
774
692
  const stepper = useStepperContext();
775
693
  const press = useLongPress(() => {
776
- if (stepper?.draggingRef.current) return;
694
+ if (stepper?.moved()) return;
777
695
  nudge(direction, ["raw", step]);
778
696
  });
779
697
  return /* @__PURE__ */ jsx("div", {
@@ -832,21 +750,6 @@ function IncrementStepper(props) {
832
750
  }
833
751
  //#endregion
834
752
  //#region src/components/NumberInput/InputField.tsx
835
- /** The leading number of the displayed text, whatever the format put around it. */
836
- const NUMBER_PREFIX = /^\s*-?[\d.,]*/;
837
- const numberEnd = (text) => text.match(NUMBER_PREFIX)?.[0].length ?? text.length;
838
- /**
839
- * The index the caret is measured against: the decimal point, or where one
840
- * would go if the number has none.
841
- *
842
- * Measuring from an end instead would slide the caret across a digit whenever
843
- * the number changed length — `9.9` to `10.0` gains a character in front, `10`
844
- * to `9` loses one — which is exactly what stepping does.
845
- */
846
- const decimalAnchor = (text) => {
847
- const dot = text.indexOf(".");
848
- return dot === -1 ? numberEnd(text) : dot;
849
- };
850
753
  /**
851
754
  * The text field of a `NumberInput`, and the only place the value can be typed.
852
755
  *
@@ -856,7 +759,7 @@ const decimalAnchor = (text) => {
856
759
  * How it behaves on focus, Enter and the arrow keys is set on `Root`
857
760
  * (`selectOnFocus`, `unformatOnFocus`, `keepCaretOnStep`, `blurOnEnter`).
858
761
  */
859
- const InputField = /* @__PURE__ */ forwardRef(function InputField({ className, style, onFocus, onBlur, onKeyDown, ...props }, forwardedRef) {
762
+ const InputField = /* @__PURE__ */ forwardRef(function InputField({ className, style, onChange, onFocus, onBlur, onKeyDown, ...props }, forwardedRef) {
860
763
  const { value, min, max, step, disabled, readonly, keyboard, selectOnFocus, unformatOnFocus, keepCaretOnStep, blurOnEnter, text, editing, outOfRange, setDraft, commitDraft, nudge, inputRef } = useNumberInputContext();
861
764
  const [focused, setFocused] = useState(false);
862
765
  const shown = unformatOnFocus && focused && !editing ? String(value) : text;
@@ -865,7 +768,10 @@ const InputField = /* @__PURE__ */ forwardRef(function InputField({ className, s
865
768
  const input = inputRef.current;
866
769
  if (!input) return;
867
770
  if (selectOnFocus === "all") input.setSelectionRange(0, input.value.length);
868
- else input.setSelectionRange(0, input.value.match(NUMBER_PREFIX)?.[0].length ?? 0);
771
+ else {
772
+ const span = numberSpan(input.value);
773
+ if (span) input.setSelectionRange(span.start, span.end);
774
+ }
869
775
  }, [
870
776
  focused,
871
777
  selectOnFocus,
@@ -882,8 +788,7 @@ const InputField = /* @__PURE__ */ forwardRef(function InputField({ className, s
882
788
  caretAfterStep.current = null;
883
789
  const input = inputRef.current;
884
790
  if (!input || input.value === pending.from) return;
885
- const place = decimalAnchor(input.value) + pending.offset;
886
- const caret = Math.max(0, Math.min(place, numberEnd(input.value)));
791
+ const caret = caretAtDecimalOffset(input.value, pending.offset);
887
792
  input.setSelectionRange(caret, caret);
888
793
  });
889
794
  const composedRef = useComposedRefs(forwardedRef, inputRef);
@@ -910,6 +815,7 @@ const InputField = /* @__PURE__ */ forwardRef(function InputField({ className, s
910
815
  onChange: (event) => {
911
816
  caretAfterStep.current = null;
912
817
  setDraft(event.currentTarget.value);
818
+ onChange?.(event);
913
819
  },
914
820
  onFocus: (event) => {
915
821
  setFocused(true);
@@ -929,7 +835,7 @@ const InputField = /* @__PURE__ */ forwardRef(function InputField({ className, s
929
835
  event.preventDefault();
930
836
  const input = event.currentTarget;
931
837
  if (keepCaretOnStep && input.selectionStart !== null) caretAfterStep.current = {
932
- offset: input.selectionStart - decimalAnchor(input.value),
838
+ offset: caretDecimalOffset(input.value, input.selectionStart),
933
839
  from: input.value
934
840
  };
935
841
  nudge(key === "ArrowUp" ? 1 : -1, keyboard, event);
@@ -940,117 +846,54 @@ const InputField = /* @__PURE__ */ forwardRef(function InputField({ className, s
940
846
  });
941
847
  });
942
848
  //#endregion
943
- //#region src/hooks/useDrag.ts
944
- /**
945
- * Track a pointer drag on an element.
946
- *
947
- * @returns a ref callback to attach to the element being dragged
948
- */
949
- function useDrag({ threshold = 1, cursor, pointerLock, onDrag, onDragStart, onDragEnd }) {
950
- const dragHandler = useCallbackRef(onDrag);
951
- const dragStartHandler = useCallbackRef(onDragStart);
952
- const dragEndHandler = useCallbackRef(onDragEnd);
953
- const [node, setNode] = useState(null);
954
- useEffect(() => {
955
- if (!node) return;
956
- const instance = createDrag(node, {
957
- threshold,
958
- cursor,
959
- pointerLock,
960
- onDragStart: (state) => dragStartHandler(state),
961
- onDrag: (state) => dragHandler(state.x, state.y, state.deltaX, state.deltaY, state),
962
- onDragEnd: (state) => dragEndHandler(state)
963
- });
964
- return () => instance.destroy();
965
- }, [
966
- node,
967
- threshold,
968
- cursor,
969
- pointerLock,
970
- dragHandler,
971
- dragStartHandler,
972
- dragEndHandler
973
- ]);
974
- return setNode;
975
- }
976
- //#endregion
977
849
  //#region src/components/NumberInput/Stepper.tsx
978
850
  /**
979
851
  * The area the steppers sit in, and a drag handle in its own right: dragging it
980
- * up and down moves the value one `step` every `drag` pixels.
852
+ * up and down moves the value one `step` every `drag` pixels. The counting is
853
+ * `createStepperDrag` in the core.
981
854
  *
982
855
  * The drag lives here rather than on `InputField` because `createDrag` turns
983
856
  * off text selection on whatever element it is attached to.
984
857
  */
985
858
  const Stepper = /* @__PURE__ */ forwardRef(function Stepper({ className, style, children, ...props }, forwardedRef) {
986
- const { value, step, disabled, readonly, drag, dragSensitivity, pointerLock, rawRange, changeValue } = useNumberInputContext();
987
- const inactive = disabled || readonly;
988
- /**
989
- * The sensitivity as an amount per `drag` pixels, so that the drag goes
990
- * through the same `applyDelta` the wheel and the arrow keys do. Carrying
991
- * the modifier map over rather than resolving it here is what keeps `step`
992
- * out of the pipeline for a modifier entry — naming one is a request to move
993
- * off the grid.
994
- */
995
- const dragOptions = useMemo(() => mapModifier(dragSensitivity, (factor) => ["raw", step * factor]), [dragSensitivity, step]);
996
- /**
997
- * Set once the drag has actually moved the value, so that the press-and-hold
998
- * repeat of a stepper stands down and leaves the value to the drag. A ref,
999
- * because it is read from inside the repeat rather than rendered.
1000
- */
1001
- const draggingRef = useRef(false);
1002
- /**
1003
- * Where the drag started, taken on the first move rather than on pointer
1004
- * down: the steppers act on pointer down, so by then the value may already
1005
- * have been nudged once, and the drag should carry on from there.
1006
- */
1007
- const originRef = useRef(null);
1008
- /**
1009
- * Which modifier the drag is currently counting at, and where the previous
1010
- * event was — a key produces no pointer event of its own, so a change is
1011
- * only seen on the next move and has to be dated back to the one before it.
1012
- */
1013
- const factorRef = useRef(1);
1014
- const previousYRef = useRef(0);
1015
- const dragRefCallback = useDrag({
1016
- threshold: 1,
1017
- cursor: inactive ? void 0 : "ns-resize",
1018
- pointerLock: inactive ? false : pointerLock,
1019
- onDragStart: (state) => {
1020
- originRef.current = null;
1021
- draggingRef.current = false;
1022
- factorRef.current = selectModifier(dragSensitivity, state.event).value;
1023
- },
1024
- onDrag: (_x, y, _dx, _dy, state) => {
1025
- if (inactive || drag === null) return;
1026
- if (!originRef.current) {
1027
- originRef.current = {
1028
- y,
1029
- value
1030
- };
1031
- previousYRef.current = y;
1032
- return;
1033
- }
1034
- const factor = selectModifier(dragSensitivity, state.event).value;
1035
- if (factor !== factorRef.current) {
1036
- originRef.current = {
1037
- y: previousYRef.current,
1038
- value
1039
- };
1040
- factorRef.current = factor;
1041
- }
1042
- previousYRef.current = y;
1043
- const steps = Math.round(-(y - originRef.current.y) / drag);
1044
- if (steps === 0) return;
1045
- draggingRef.current = true;
1046
- changeValue(applyDelta(originRef.current.value, steps, dragOptions, rawRange, state.event));
1047
- },
1048
- onDragEnd: () => {
1049
- draggingRef.current = false;
1050
- }
859
+ const { value, disabled, readonly, drag, dragSensitivity, pointerLock, rawRange, changeValue } = useNumberInputContext();
860
+ const enabled = drag !== null && !disabled && !readonly;
861
+ const [node, setNode] = useState(null);
862
+ const instanceRef = useRef(null);
863
+ const latest = useRef({
864
+ value,
865
+ changeValue
866
+ });
867
+ const options = {
868
+ range: rawRange,
869
+ pixels: drag ?? 1,
870
+ sensitivity: dragSensitivity,
871
+ pointerLock
872
+ };
873
+ const optionsRef = useRef(options);
874
+ useEffect(() => {
875
+ latest.current = {
876
+ value,
877
+ changeValue
878
+ };
879
+ optionsRef.current = options;
880
+ instanceRef.current?.update(options);
1051
881
  });
1052
- const composedRef = useComposedRefs(forwardedRef, drag !== null && !inactive ? dragRefCallback : void 0);
1053
- const context = useMemo(() => ({ draggingRef }), []);
882
+ useEffect(() => {
883
+ if (!node || !enabled) return;
884
+ const instance = createStepperDrag(node, {
885
+ ...optionsRef.current,
886
+ getValue: () => latest.current.value,
887
+ onChange: (next) => latest.current.changeValue(next)
888
+ });
889
+ instanceRef.current = instance;
890
+ return () => {
891
+ instanceRef.current = null;
892
+ instance.destroy();
893
+ };
894
+ }, [node, enabled]);
895
+ const composedRef = useComposedRefs(forwardedRef, setNode);
896
+ const context = useMemo(() => ({ moved: () => instanceRef.current?.moved() ?? false }), []);
1054
897
  return /* @__PURE__ */ jsx(StepperProvider, {
1055
898
  value: context,
1056
899
  children: /* @__PURE__ */ jsx("div", {
@@ -1064,17 +907,7 @@ const Stepper = /* @__PURE__ */ forwardRef(function Stepper({ className, style,
1064
907
  });
1065
908
  //#endregion
1066
909
  //#region src/components/NumberInput/index.tsx
1067
- /**
1068
- * A number at the start of the text, and nothing read after it. A half-typed
1069
- * entry still yields the number in front of it, but text with no number is
1070
- * `NaN` rather than 0, so that "unreadable" and "the user typed 0" stay apart.
1071
- */
1072
- const LEADING_NUMBER = /^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)/;
1073
910
  const defaultFormat = (value) => String(value);
1074
- const defaultParse = (text) => {
1075
- const match = text.match(LEADING_NUMBER);
1076
- return match ? Number(match[1]) : NaN;
1077
- };
1078
911
  /**
1079
912
  * Input with some useful functions for entering numerical values.
1080
913
  */
@@ -1088,24 +921,13 @@ const NumberInput = {
1088
921
  const [draft, setDraft] = useState(null);
1089
922
  const inactive = disabled || readonly;
1090
923
  const format = formatProp ?? defaultFormat;
1091
- const parse = parseProp ?? defaultParse;
1092
- const range = useMemo(() => ({
1093
- min: (clampValue ? min : void 0) ?? Number.MIN_SAFE_INTEGER,
1094
- max: (clampValue ? max : void 0) ?? Number.MAX_SAFE_INTEGER,
1095
- step,
1096
- scale
1097
- }), [
1098
- clampValue,
924
+ const parse = parseProp ?? parseNumberText;
925
+ const { normalized: range, raw: rawRange } = useMemo(() => numberInputRanges({
1099
926
  min,
1100
927
  max,
1101
928
  step,
1102
- scale
1103
- ]);
1104
- const rawRange = useMemo(() => ({
1105
- min: (clampValue ? min : void 0) ?? -Number.MAX_VALUE,
1106
- max: (clampValue ? max : void 0) ?? Number.MAX_VALUE,
1107
- step,
1108
- scale
929
+ scale,
930
+ clampValue
1109
931
  }), [
1110
932
  clampValue,
1111
933
  min,
@@ -1113,6 +935,10 @@ const NumberInput = {
1113
935
  step,
1114
936
  scale
1115
937
  ]);
938
+ const ranges = useMemo(() => ({
939
+ normalized: range,
940
+ raw: rawRange
941
+ }), [range, rawRange]);
1116
942
  useCheckSteps({
1117
943
  component: "NumberInput",
1118
944
  range: useMemo(() => min !== void 0 && max !== void 0 && min < max ? {
@@ -1132,7 +958,13 @@ const NumberInput = {
1132
958
  });
1133
959
  const text = draft ?? format(value);
1134
960
  const editing = draft !== null;
1135
- const outOfRange = !editing && (min !== void 0 && value < min || max !== void 0 && value > max);
961
+ const bounds = numberInputBounds(value, {
962
+ min,
963
+ max,
964
+ clampValue
965
+ });
966
+ const { atMin, atMax } = bounds;
967
+ const outOfRange = !editing && bounds.outOfRange;
1136
968
  const handleDraft = useCallback((next) => {
1137
969
  if (inactive) return;
1138
970
  setDraft(next);
@@ -1154,15 +986,13 @@ const NumberInput = {
1154
986
  ]);
1155
987
  const commitDraft = useCallback(() => {
1156
988
  if (draft === null || inactive) return;
1157
- const parsed = parse(draft);
1158
- if (!Number.isFinite(parsed)) {
1159
- setDraft(null);
1160
- return;
1161
- }
1162
- let committed = parsed;
1163
- if (clampValue && min !== void 0) committed = Math.max(committed, min);
1164
- if (clampValue && max !== void 0) committed = Math.min(committed, max);
1165
- changeValue(committed);
989
+ const committed = commitNumberInputText(draft, parse, {
990
+ min,
991
+ max,
992
+ clampValue
993
+ });
994
+ if (committed === null) setDraft(null);
995
+ else changeValue(committed);
1166
996
  }, [
1167
997
  draft,
1168
998
  inactive,
@@ -1173,18 +1003,17 @@ const NumberInput = {
1173
1003
  changeValue
1174
1004
  ]);
1175
1005
  const nudge = useCallback((direction, option, modifiers) => {
1176
- const [mode] = selectInputEvent(option, modifiers).option;
1177
- changeValue(applyDelta(value, direction, option, mode === "raw" ? rawRange : range, modifiers));
1006
+ changeValue(nudgeNumberInput(value, direction, option, ranges, modifiers));
1178
1007
  }, [
1179
1008
  changeValue,
1180
1009
  value,
1181
- rawRange,
1182
- range
1010
+ ranges
1183
1011
  ]);
1184
1012
  const wheelRefCallback = useWheel((event) => {
1185
- if (!wheel || inactive || event.deltaY === 0) return;
1013
+ const direction = wheelDirection(event);
1014
+ if (!wheel || inactive || direction === null) return;
1186
1015
  event.preventDefault();
1187
- nudge(-Math.sign(event.deltaY), wheel, event);
1016
+ nudge(direction, wheel, event);
1188
1017
  }, { requireFocus: true });
1189
1018
  const context = useMemo(() => ({
1190
1019
  value,
@@ -1207,8 +1036,8 @@ const NumberInput = {
1207
1036
  unformatOnFocus,
1208
1037
  keepCaretOnStep,
1209
1038
  blurOnEnter,
1210
- atMin: clampValue && min !== void 0 && value <= min,
1211
- atMax: clampValue && max !== void 0 && value >= max,
1039
+ atMin,
1040
+ atMax,
1212
1041
  drag,
1213
1042
  format,
1214
1043
  parse,
@@ -1232,6 +1061,8 @@ const NumberInput = {
1232
1061
  text,
1233
1062
  editing,
1234
1063
  outOfRange,
1064
+ atMin,
1065
+ atMax,
1235
1066
  drag,
1236
1067
  dragSensitivity,
1237
1068
  pointerLock,
@@ -1274,78 +1105,15 @@ const NumberInput = {
1274
1105
  IncrementStepper,
1275
1106
  DecrementStepper
1276
1107
  };
1277
- //#endregion
1278
- //#region src/components/Piano/keyboard-shortcuts.ts
1279
- /**
1280
- * Ready-made keyboard layouts. Both assume `noteRange.first` is a C.
1281
- */
1282
- const SHORTCUTS = {
1283
- /** Every semitone from C, over the two rows of a QWERTY keyboard. */
1284
- HOME_ROW: { keys: [
1285
- "a",
1286
- "w",
1287
- "s",
1288
- "e",
1289
- "d",
1290
- "f",
1291
- "t",
1292
- "g",
1293
- "y",
1294
- "h",
1295
- "u",
1296
- "j",
1297
- "k",
1298
- "o",
1299
- "l",
1300
- "p",
1301
- ";"
1302
- ] },
1303
- /** The white keys only, on the home row. Black keys have no shortcut. */
1304
- HOME_ROW_NATURAL: { keys: [
1305
- "a",
1306
- "",
1307
- "s",
1308
- "",
1309
- "d",
1310
- "f",
1311
- "",
1312
- "g",
1313
- "",
1314
- "h",
1315
- "",
1316
- "j",
1317
- "k",
1318
- "",
1319
- "l",
1320
- "",
1321
- ";"
1322
- ] }
1323
- };
1324
- //#endregion
1325
- //#region src/components/Piano/index.tsx
1326
- function isEditableTarget(target) {
1327
- if (!(target instanceof HTMLElement)) return false;
1328
- if (target.matches("input, textarea, select")) return true;
1329
- for (let element = target; element;) {
1330
- const contentEditable = element.getAttribute("contenteditable");
1331
- if (contentEditable !== null) return contentEditable.toLowerCase() !== "false";
1332
- element = element.parentElement;
1333
- }
1334
- return false;
1335
- }
1336
- function shortcutKey(event) {
1337
- return event.code || event.key;
1338
- }
1339
1108
  /**
1340
1109
  * Customizable piano component.
1341
1110
  */
1342
- const Piano = { Root: /* @__PURE__ */ forwardRef(function Root({ noteRange, glissando = true, midiMax = 127, keyboardShortcuts, keyboardShortcutsScope = "root", resizable = false, whiteKeyWidth = 40, keyGap = 1, blackKeyWidthRatio = .65, blackKeyHeightRatio = .6, height, style, className, label, keyProps, classes, onPlayNote, onStopNote, ...props }, forwardedRef) {
1111
+ const Piano = { Root: /* @__PURE__ */ forwardRef(function Root({ noteRange, glissando = true, midiMax = 127, keyboardShortcuts, keyboardShortcutsScope = "root", resizable = false, whiteKeyWidth = 40, keyGap = 1, blackKeyWidthRatio = .65, blackKeyHeightRatio = .6, style, className, label, keyProps, classes, onPlayNote, onStopNote, ...props }, forwardedRef) {
1343
1112
  const [node, setNode] = useState(null);
1344
1113
  const [activeNotes, setActiveNotes] = useState([]);
1345
1114
  /** Set while `resizable` is on, measured from the parent. */
1346
1115
  const [resizedKeyWidth, setResizedKeyWidth] = useState(whiteKeyWidth);
1347
1116
  const notes = useMemo(() => getNoteRangeArray(noteRange), [noteRange]);
1348
- const whiteKeyCount = useMemo(() => notes.filter(isWhiteKey).length, [notes]);
1349
1117
  const layout = useMemo(() => ({
1350
1118
  noteRange,
1351
1119
  whiteKeyWidth: resizable ? resizedKeyWidth : whiteKeyWidth,
@@ -1365,21 +1133,20 @@ const Piano = { Root: /* @__PURE__ */ forwardRef(function Root({ noteRange, glis
1365
1133
  layout,
1366
1134
  glissando,
1367
1135
  midiMax,
1136
+ keyboardShortcuts,
1137
+ keyboardShortcutsScope,
1368
1138
  onPlayNote,
1369
1139
  onStopNote
1370
1140
  });
1371
1141
  const instanceRef = useRef(null);
1372
- const shortcutNotes = useRef(/* @__PURE__ */ new Map());
1373
- const releaseShortcutNotes = useCallback(() => {
1374
- for (const { note, source } of shortcutNotes.current.values()) instanceRef.current?.noteOff(note, { source });
1375
- shortcutNotes.current.clear();
1376
- }, []);
1377
1142
  useEffect(() => {
1378
1143
  if (!node) return;
1379
1144
  const instance = createPianoInput(node, {
1380
1145
  layout: latest.current.layout,
1381
1146
  glissando: latest.current.glissando,
1382
1147
  midiMax: latest.current.midiMax,
1148
+ keyboardShortcuts: latest.current.keyboardShortcuts,
1149
+ keyboardShortcutsScope: latest.current.keyboardShortcutsScope,
1383
1150
  onPlayNote: (note, velocity) => latest.current.onPlayNote?.(note, velocity),
1384
1151
  onStopNote: (note) => latest.current.onStopNote?.(note),
1385
1152
  onActiveNotesChange: setActiveNotes
@@ -1395,86 +1162,44 @@ const Piano = { Root: /* @__PURE__ */ forwardRef(function Root({ noteRange, glis
1395
1162
  layout,
1396
1163
  glissando,
1397
1164
  midiMax,
1165
+ keyboardShortcuts,
1166
+ keyboardShortcutsScope,
1398
1167
  onPlayNote,
1399
1168
  onStopNote
1400
1169
  };
1401
1170
  instanceRef.current?.update({
1402
1171
  layout,
1403
1172
  glissando,
1404
- midiMax
1173
+ midiMax,
1174
+ keyboardShortcuts,
1175
+ keyboardShortcutsScope
1405
1176
  });
1406
1177
  });
1178
+ const { first, last } = noteRange;
1407
1179
  useEffect(() => {
1408
1180
  if (!resizable || !node) return;
1409
1181
  const parent = node.parentElement;
1410
1182
  if (!parent) throw new Error("doesn't have a parent element.");
1411
1183
  const resizeObserver = new ResizeObserver(() => {
1412
- setResizedKeyWidth(node.clientWidth / Math.max(whiteKeyCount, 1) - keyGap);
1184
+ setResizedKeyWidth(fitWhiteKeyWidth(node.clientWidth, {
1185
+ noteRange: {
1186
+ first,
1187
+ last
1188
+ },
1189
+ keyGap,
1190
+ blackKeyWidthRatio
1191
+ }));
1413
1192
  });
1414
1193
  resizeObserver.observe(parent);
1415
1194
  return () => resizeObserver.disconnect();
1416
1195
  }, [
1417
1196
  resizable,
1418
1197
  node,
1419
- whiteKeyCount,
1420
- keyGap
1421
- ]);
1422
- const shortcutKeys = keyboardShortcuts?.keys;
1423
- const hasShortcuts = shortcutKeys !== void 0;
1424
- const shortcutMapping = shortcutKeys?.join("\0");
1425
- useEffect(() => releaseShortcutNotes, [
1426
- releaseShortcutNotes,
1427
- shortcutMapping,
1428
- keyboardShortcutsScope,
1429
- noteRange.first,
1430
- noteRange.last
1431
- ]);
1432
- /** The note a shortcut key plays, or null when it has none. */
1433
- function shortcutNote(key) {
1434
- if (!shortcutKeys || key === "") return null;
1435
- const index = shortcutKeys.indexOf(key);
1436
- const note = noteRange.first + index;
1437
- return index === -1 || note > noteRange.last ? null : note;
1438
- }
1439
- const shortcutTarget = useMemo(() => () => {
1440
- if (!hasShortcuts) return null;
1441
- return keyboardShortcutsScope === "window" ? globalThis.window : node;
1442
- }, [
1443
- hasShortcuts,
1444
- keyboardShortcutsScope,
1445
- node
1446
- ]);
1447
- const focusOutTarget = useMemo(() => () => hasShortcuts && keyboardShortcutsScope === "root" ? node : null, [
1448
- hasShortcuts,
1449
- keyboardShortcutsScope,
1450
- node
1198
+ first,
1199
+ last,
1200
+ keyGap,
1201
+ blackKeyWidthRatio
1451
1202
  ]);
1452
- const windowBlurTarget = useMemo(() => () => hasShortcuts ? globalThis.window : null, [hasShortcuts]);
1453
- useEventListener(shortcutTarget, "keydown", (e) => {
1454
- const key = shortcutKey(e);
1455
- if (e.repeat || shortcutNotes.current.has(key)) return;
1456
- if (isEditableTarget(e.target)) return;
1457
- const note = shortcutNote(e.key);
1458
- if (note === null) return;
1459
- const source = `keyboard:${key}`;
1460
- shortcutNotes.current.set(key, {
1461
- note,
1462
- source
1463
- });
1464
- instanceRef.current?.noteOn(note, { source });
1465
- });
1466
- useEventListener(shortcutTarget, "keyup", (e) => {
1467
- const key = shortcutKey(e);
1468
- const shortcut = shortcutNotes.current.get(key);
1469
- if (!shortcut) return;
1470
- shortcutNotes.current.delete(key);
1471
- instanceRef.current?.noteOff(shortcut.note, { source: shortcut.source });
1472
- });
1473
- useEventListener(focusOutTarget, "focusout", (e) => {
1474
- if (e.relatedTarget instanceof Node && node?.contains(e.relatedTarget)) return;
1475
- releaseShortcutNotes();
1476
- });
1477
- useEventListener(windowBlurTarget, "blur", releaseShortcutNotes);
1478
1203
  useImperativeHandle(forwardedRef, () => ({
1479
1204
  playNote: (note, velocity) => instanceRef.current?.noteOn(note, {
1480
1205
  source: "api",
@@ -1490,7 +1215,6 @@ const Piano = { Root: /* @__PURE__ */ forwardRef(function Root({ noteRange, glis
1490
1215
  tabIndex: 0,
1491
1216
  style: {
1492
1217
  width: resizable ? "100%" : pianoWidth(layout),
1493
- "--height": cssLength(height),
1494
1218
  position: "relative",
1495
1219
  ...style
1496
1220
  },
@@ -1556,43 +1280,58 @@ function usePointsEditorContext(selector) {
1556
1280
  return selector ? selector(context) : context;
1557
1281
  }
1558
1282
  //#endregion
1283
+ //#region src/components/PointsEditor/Container.tsx
1284
+ const Container = /* @__PURE__ */ forwardRef(function Container({ children, className, style, ...props }, forwardedRef) {
1285
+ const { containerRef, disabled, selectable, editor } = usePointsEditorContext();
1286
+ const { refCallback: dragRefCallback } = useDragValue({
1287
+ axis: POINT_AXIS,
1288
+ baseElementRef: containerRef,
1289
+ shouldStart: (event) => !disabled && !editor.isPointElement(event.target),
1290
+ onDragStart: ([x, y], state) => {
1291
+ editor.beginSelectionBox({
1292
+ x,
1293
+ y
1294
+ }, state.event);
1295
+ },
1296
+ onChange: ([x, y]) => editor.moveSelectionBox({
1297
+ x,
1298
+ y
1299
+ }),
1300
+ onDragEnd: () => editor.endSelectionBox()
1301
+ });
1302
+ useWheel((event) => {
1303
+ const move = wheelMove(event);
1304
+ if (!move) return;
1305
+ const axis = move.axis === 0 ? "x" : "y";
1306
+ if (editor.nudgeFocusedPoint(axis, move.direction, event)) event.preventDefault();
1307
+ }, { target: containerRef });
1308
+ const composedRef = useComposedRefs(forwardedRef, containerRef, selectable && !disabled ? dragRefCallback : void 0);
1309
+ return /* @__PURE__ */ jsx("div", {
1310
+ ref: composedRef,
1311
+ className,
1312
+ style: {
1313
+ position: "absolute",
1314
+ inset: 0,
1315
+ zIndex: 10,
1316
+ ...style
1317
+ },
1318
+ ...props,
1319
+ children: /* @__PURE__ */ jsx(Placement, {
1320
+ name: "PointsEditor.Container",
1321
+ children
1322
+ })
1323
+ });
1324
+ });
1325
+ //#endregion
1559
1326
  //#region src/components/_util/VisuallyHiddenRangeInput.tsx
1560
- const hiddenStyle = {
1561
- position: "absolute",
1562
- width: 1,
1563
- height: 1,
1564
- padding: 0,
1565
- margin: -1,
1566
- overflow: "hidden",
1567
- clip: "rect(0, 0, 0, 0)",
1568
- clipPath: "inset(50%)",
1569
- whiteSpace: "nowrap",
1570
- border: 0,
1571
- pointerEvents: "none"
1572
- };
1573
1327
  const VisuallyHiddenRangeInput = /* @__PURE__ */ forwardRef((props, ref) => /* @__PURE__ */ jsx("input", {
1574
1328
  ...props,
1575
1329
  ref,
1576
1330
  type: "range",
1577
- style: hiddenStyle
1331
+ style: visuallyHiddenStyle
1578
1332
  }));
1579
1333
  //#endregion
1580
1334
  //#region src/components/PointsEditor/Point.tsx
1581
- function clampPoint(point, min, max) {
1582
- const { x, y } = point;
1583
- return {
1584
- x: clamp(x, min?.x ?? 0, max?.x ?? 1),
1585
- y: clamp(y, min?.y ?? 0, max?.y ?? 1)
1586
- };
1587
- }
1588
- /**
1589
- * A point is placed by its position within the container, so its value is a
1590
- * position: 0..1 on each axis, with y growing downwards.
1591
- */
1592
- const AXIS = {
1593
- min: 0,
1594
- max: 1
1595
- };
1596
1335
  /**
1597
1336
  * One setting for both axes, or one per axis. The point holds a range input
1598
1337
  * for each, so anything that names or describes it comes in a pair.
@@ -1600,8 +1339,8 @@ const AXIS = {
1600
1339
  function perAxis(value, axis) {
1601
1340
  return typeof value === "string" ? value : value?.[axis];
1602
1341
  }
1603
- function Point({ value, children, id: idProp, min, max, size, width, height, color, disabled: _disabled, readonly: _readonly, wheel: _wheel, keyboard: _keyboard, "aria-label": ariaLabel, "aria-valuetext": ariaValuetext, onChange, onDragStart, onDragEnd, className, style, onPointerDown, onKeyDown, onFocus, ...props }) {
1604
- const { containerRef, externalStyles, disabled: rootDisabled, readonly: rootReadonly, wheel: rootWheel, keyboard: rootKeyboard, dragSensitivity, selection, registerPoint, beginPointDrag, movePointDrag, nudgeSelection } = usePointsEditorContext();
1342
+ function Point({ value, children, id: idProp, min, max, color, disabled: _disabled, readonly: _readonly, wheel: _wheel, keyboard: _keyboard, "aria-label": ariaLabel, "aria-valuetext": ariaValuetext, onChange, onDragStart, onDragEnd, className, style, onPointerDown, onKeyDown, onFocus, ...props }) {
1343
+ const { containerRef, externalStyles, disabled: rootDisabled, readonly: rootReadonly, wheel: rootWheel, keyboard: rootKeyboard, dragSensitivity, selection, editor } = usePointsEditorContext();
1605
1344
  const generatedId = useId();
1606
1345
  const id = idProp ?? generatedId;
1607
1346
  const selected = selection.includes(id);
@@ -1634,11 +1373,11 @@ function Point({ value, children, id: idProp, min, max, size, width, height, col
1634
1373
  wheel
1635
1374
  };
1636
1375
  });
1637
- useEffect(() => registerPoint(id, registration), [id, registerPoint]);
1376
+ useEffect(() => editor.registerPoint(id, () => registration.current), [id, editor]);
1638
1377
  /** Where the pointer was when the drag started, to measure the move from. */
1639
1378
  const pointerOrigin = useRef(null);
1640
1379
  const { refCallback: dragRefCallback, dragging } = useDragValue({
1641
- axis: AXIS,
1380
+ axis: POINT_AXIS,
1642
1381
  baseElementRef: containerRef,
1643
1382
  sensitivity: (state) => selectModifier(dragSensitivity, state.event).value,
1644
1383
  cursor: inactive ? void 0 : externalStyles.cursor,
@@ -1646,13 +1385,13 @@ function Point({ value, children, id: idProp, min, max, size, width, height, col
1646
1385
  onChange: ([x, y]) => {
1647
1386
  const origin = pointerOrigin.current;
1648
1387
  if (!origin) return;
1649
- movePointDrag({
1388
+ editor.movePointDrag({
1650
1389
  x: x - origin.x,
1651
1390
  y: y - origin.y
1652
1391
  });
1653
1392
  },
1654
1393
  onDragStart: ([x, y], state) => {
1655
- beginPointDrag(id, state.event);
1394
+ editor.beginPointDrag(id, state.event);
1656
1395
  pointerOrigin.current = {
1657
1396
  x,
1658
1397
  y
@@ -1667,30 +1406,13 @@ function Point({ value, children, id: idProp, min, max, size, width, height, col
1667
1406
  onDragEnd?.(clampPoint(value, min, max));
1668
1407
  }
1669
1408
  });
1670
- const nudge = useCallback((axis, direction, option, modifiers) => {
1671
- const next = applyDelta(value[axis], direction, option, AXIS, modifiers);
1672
- nudgeSelection(id, {
1673
- x: axis === "x" ? next - value.x : 0,
1674
- y: axis === "y" ? next - value.y : 0
1675
- });
1676
- }, [
1677
- value,
1678
- id,
1679
- nudgeSelection
1680
- ]);
1681
1409
  const refCallback = useComposedRefs(dragRefCallback, setElement);
1682
1410
  const handleKeyDown = (event) => {
1683
- const key = event.key;
1684
- if (![
1685
- "ArrowRight",
1686
- "ArrowLeft",
1687
- "ArrowUp",
1688
- "ArrowDown"
1689
- ].includes(key)) return;
1690
- const axis = key === "ArrowRight" || key === "ArrowLeft" ? "x" : "y";
1411
+ const move = arrowKeyMove(event.key);
1412
+ if (!move) return;
1691
1413
  event.preventDefault();
1692
1414
  if (!onChange || inactive || !keyboard) return;
1693
- nudge(axis, key === "ArrowLeft" || key === "ArrowUp" ? -1 : 1, keyboard, event);
1415
+ editor.nudgePoint(id, move.axis === 0 ? "x" : "y", move.direction, keyboard, event);
1694
1416
  };
1695
1417
  const current = clampPoint(value, min, max);
1696
1418
  return /* @__PURE__ */ jsxs("div", {
@@ -1703,8 +1425,6 @@ function Point({ value, children, id: idProp, min, max, size, width, height, col
1703
1425
  "data-selected": selected ? "" : void 0,
1704
1426
  style: {
1705
1427
  "--color": color,
1706
- "--width": cssLength(size ?? width),
1707
- "--height": cssLength(size ?? height),
1708
1428
  position: "absolute",
1709
1429
  translate: "var(--translate, -50% -50%)",
1710
1430
  ...style,
@@ -1739,7 +1459,7 @@ function Point({ value, children, id: idProp, min, max, size, width, height, col
1739
1459
  return;
1740
1460
  }
1741
1461
  const delta = event.currentTarget.valueAsNumber - value[axis];
1742
- nudgeSelection(id, {
1462
+ editor.nudgeSelection(id, {
1743
1463
  x: axis === "x" ? delta : 0,
1744
1464
  y: axis === "y" ? delta : 0
1745
1465
  });
@@ -1748,50 +1468,6 @@ function Point({ value, children, id: idProp, min, max, size, width, height, col
1748
1468
  });
1749
1469
  }
1750
1470
  //#endregion
1751
- //#region src/components/PointsEditor/Container.tsx
1752
- const Container = /* @__PURE__ */ forwardRef(function Container({ children, className, style, ...props }, forwardedRef) {
1753
- const { containerRef, disabled, selectable, isPointElement, nudgeFocusedPoint, beginSelectionBox, moveSelectionBox, endSelectionBox } = usePointsEditorContext();
1754
- const { refCallback: dragRefCallback } = useDragValue({
1755
- axis: AXIS,
1756
- baseElementRef: containerRef,
1757
- shouldStart: (event) => !disabled && !isPointElement(event.target),
1758
- onDragStart: ([x, y], state) => {
1759
- beginSelectionBox({
1760
- x,
1761
- y
1762
- }, state.event);
1763
- },
1764
- onChange: ([x, y]) => moveSelectionBox({
1765
- x,
1766
- y
1767
- }),
1768
- onDragEnd: endSelectionBox
1769
- });
1770
- useWheel((event) => {
1771
- const horizontal = event.deltaX !== 0;
1772
- const delta = horizontal ? event.deltaX : event.deltaY;
1773
- if (delta === 0) return;
1774
- const axis = horizontal || event.shiftKey ? "x" : "y";
1775
- if (nudgeFocusedPoint(axis, delta < 0 ? -1 : 1, event)) event.preventDefault();
1776
- }, { target: containerRef });
1777
- const composedRef = useComposedRefs(forwardedRef, containerRef, selectable && !disabled ? dragRefCallback : void 0);
1778
- return /* @__PURE__ */ jsx("div", {
1779
- ref: composedRef,
1780
- className,
1781
- style: {
1782
- position: "absolute",
1783
- inset: 0,
1784
- zIndex: 10,
1785
- ...style
1786
- },
1787
- ...props,
1788
- children: /* @__PURE__ */ jsx(Placement, {
1789
- name: "PointsEditor.Container",
1790
- children
1791
- })
1792
- });
1793
- });
1794
- //#endregion
1795
1471
  //#region src/components/PointsEditor/SelectionBox.tsx
1796
1472
  /**
1797
1473
  * The box a drag on empty space draws, and what it covers is selected.
@@ -1824,181 +1500,28 @@ function SelectionBox({ children, className, style, ...props }) {
1824
1500
  /** One array for every editor with selection turned off, so memos hold still. */
1825
1501
  const EMPTY = [];
1826
1502
  /**
1827
- * How far a point may move before something in the selection leaves its range.
1828
- *
1829
- * Clamping each point on its own would break the shape of the selection: the
1830
- * one that reached the edge would stop while the rest carried on. One amount
1831
- * for all of them means the whole selection stops together.
1832
- */
1833
- function allowedDelta(delta, entries) {
1834
- let loX = -Infinity;
1835
- let hiX = Infinity;
1836
- let loY = -Infinity;
1837
- let hiY = Infinity;
1838
- for (const { start, registration } of entries) {
1839
- loX = Math.max(loX, (registration.min?.x ?? 0) - start.x);
1840
- hiX = Math.min(hiX, (registration.max?.x ?? 1) - start.x);
1841
- loY = Math.max(loY, (registration.min?.y ?? 0) - start.y);
1842
- hiY = Math.min(hiY, (registration.max?.y ?? 1) - start.y);
1843
- }
1844
- return {
1845
- x: hiX < loX ? 0 : clamp(delta.x, loX, hiX),
1846
- y: hiY < loY ? 0 : clamp(delta.y, loY, hiY)
1847
- };
1848
- }
1849
- /**
1850
- * A point moves over 0..1 in both axes, so a nudge of 0.01 crosses the editor
1851
- * in a hundred steps whatever its pixel size.
1852
- */
1853
- const DEFAULT_WHEEL = ["normalized", .01];
1854
- /**
1855
- * Shift is the fine-adjustment key everywhere else, so it is bound here too
1856
- * — but only on the keyboard. On the wheel it already means the x axis, and
1857
- * browsers hand shift+wheel over as horizontal scrolling anyway.
1858
- */
1859
- const DEFAULT_KEYBOARD = {
1860
- default: ["normalized", .01],
1861
- shift: ["normalized", .001]
1862
- };
1863
- /**
1864
1503
  * Multiple Point Controller
1865
1504
  */
1866
1505
  const PointsEditor = {
1867
- Root: /* @__PURE__ */ forwardRef(({ width, height, disabled = false, readonly = false, wheel = DEFAULT_WHEEL, keyboard = DEFAULT_KEYBOARD, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, selectable = false, selection: selectionProp, defaultSelection, onSelectionChange, externalStyles, style, className, children, ...props }, forwardedRef) => {
1506
+ Root: /* @__PURE__ */ forwardRef(({ disabled = false, readonly = false, wheel = POINTS_EDITOR_DEFAULT_WHEEL, keyboard = POINTS_EDITOR_DEFAULT_KEYBOARD, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, selectable = false, selection: selectionProp, defaultSelection, onSelectionChange, externalStyles, style, className, children, ...props }, forwardedRef) => {
1868
1507
  const containerRef = useRef(null);
1869
1508
  const { cursor = "grabbing" } = externalStyles ?? {};
1870
1509
  const controlled = selectionProp !== void 0;
1871
1510
  const [ownSelection, setOwnSelection] = useState(defaultSelection ?? []);
1872
1511
  const selection = selectable ? selectionProp ?? ownSelection : EMPTY;
1873
- const selectionRef = useRef(selection);
1874
- const changeHandlerRef = useRef(onSelectionChange);
1875
- useEffect(() => {
1876
- selectionRef.current = selection;
1877
- changeHandlerRef.current = onSelectionChange;
1878
- });
1879
- const changeSelection = useCallback((next) => {
1880
- selectionRef.current = next;
1881
- if (!controlled) setOwnSelection(next);
1882
- changeHandlerRef.current?.(next);
1883
- }, [controlled]);
1884
- /**
1885
- * Every mounted point, by id. A registration is a ref rather than a value:
1886
- * the value inside changes on every frame of a drag, and a registry keyed
1887
- * on it would be rebuilt just as often.
1888
- */
1889
- const points = useRef(/* @__PURE__ */ new Map());
1890
- const registerPoint = useCallback((id, entry) => {
1891
- points.current.set(id, entry);
1892
- return () => {
1893
- points.current.delete(id);
1894
- };
1895
- }, []);
1896
- /** What the current drag picked up, and where those points started. */
1897
- const dragRef = useRef([]);
1898
- const snapshot = useCallback((ids) => {
1899
- return ids.flatMap((id) => {
1900
- const registration = points.current.get(id)?.current;
1901
- return registration ? [{
1902
- id,
1903
- start: { ...registration.value }
1904
- }] : [];
1905
- });
1906
- }, []);
1907
- const applyDeltaTo = useCallback((entries, delta) => {
1908
- const withRegistration = entries.flatMap((entry) => {
1909
- const registration = points.current.get(entry.id)?.current;
1910
- return registration ? [{
1911
- ...entry,
1912
- registration
1913
- }] : [];
1914
- });
1915
- const allowed = allowedDelta(delta, withRegistration);
1916
- for (const { start, registration } of withRegistration) {
1917
- if (registration.readonly) continue;
1918
- registration.onChange?.({
1919
- x: toPrecision(start.x + allowed.x),
1920
- y: toPrecision(start.y + allowed.y)
1921
- });
1922
- }
1923
- }, []);
1924
- const beginPointDrag = useCallback((id, modifiers) => {
1925
- if (!selectable) {
1926
- dragRef.current = snapshot([id]);
1927
- return;
1928
- }
1929
- const current = selectionRef.current;
1930
- const additive = modifiers.ctrlKey || modifiers.metaKey;
1931
- let next;
1932
- if (additive) next = current.includes(id) ? current.filter((x) => x !== id) : [...current, id];
1933
- else if (current.includes(id)) next = current;
1934
- else next = [id];
1935
- changeSelection([...next]);
1936
- dragRef.current = next.includes(id) ? snapshot(next) : [];
1937
- }, [
1938
- selectable,
1939
- changeSelection,
1940
- snapshot
1941
- ]);
1942
- const movePointDrag = useCallback((delta) => applyDeltaTo(dragRef.current, delta), [applyDeltaTo]);
1943
- const nudgeSelection = useCallback((id, delta) => {
1944
- const ids = selectionRef.current.includes(id) ? selectionRef.current : [id];
1945
- applyDeltaTo(snapshot(ids), delta);
1946
- }, [applyDeltaTo, snapshot]);
1947
- const isPointElement = useCallback((element) => {
1948
- if (!element) return false;
1949
- for (const [, entry] of points.current) if (entry.current.element?.contains(element)) return true;
1950
- return false;
1951
- }, []);
1952
- const nudgeFocusedPoint = useCallback((axis, direction, modifiers) => {
1953
- const container = containerRef.current;
1954
- const active = container?.ownerDocument.activeElement;
1955
- if (!container || !active || !container.contains(active)) return false;
1956
- for (const [id, entry] of points.current) {
1957
- const { element, wheel, readonly, onChange, value } = entry.current;
1958
- if (!element?.contains(active)) continue;
1959
- if (!wheel || readonly || !onChange) return false;
1960
- const next = applyDelta(value[axis], direction, wheel, AXIS, modifiers);
1961
- nudgeSelection(id, {
1962
- x: axis === "x" ? next - value.x : 0,
1963
- y: axis === "y" ? next - value.y : 0
1964
- });
1965
- return true;
1966
- }
1967
- return false;
1968
- }, [nudgeSelection]);
1969
1512
  const [selectionBox, setSelectionBox] = useState(null);
1970
- const selectionBoxRef = useRef(null);
1971
- selectionBoxRef.current ??= createSelectionBox({
1972
- *items() {
1973
- for (const [id, entry] of points.current) {
1974
- const { x, y } = entry.current.value;
1975
- yield [id, [x, y]];
1976
- }
1977
- },
1978
- onBoxChange: setSelectionBox
1979
- });
1513
+ const [editor] = useState(() => createPointsEditor({ onSelectionBoxChange: setSelectionBox }));
1980
1514
  useEffect(() => {
1981
- selectionBoxRef.current?.update({ onSelectionChange: changeSelection });
1982
- }, [changeSelection]);
1983
- useEffect(() => {
1984
- const box = selectionBoxRef.current;
1985
- return () => box?.destroy();
1986
- }, []);
1987
- const beginSelectionBox = useCallback((at, modifiers) => {
1988
- if (!selectable) return;
1989
- selectionBoxRef.current?.begin([at.x, at.y], {
1990
- additive: modifiers.ctrlKey || modifiers.metaKey,
1991
- selection: selectionRef.current
1515
+ editor.update({
1516
+ selectable,
1517
+ selection,
1518
+ onSelectionChange: (next) => {
1519
+ if (!controlled) setOwnSelection(next);
1520
+ onSelectionChange?.(next);
1521
+ }
1992
1522
  });
1993
- }, [selectable]);
1994
- const moveSelectionBox = useCallback((to) => {
1995
- selectionBoxRef.current?.move([to.x, to.y]);
1996
- }, []);
1997
- const endSelectionBox = useCallback(() => {
1998
- if (!selectionBoxRef.current?.end()) return;
1999
- const [first] = selectionRef.current;
2000
- if (first) points.current.get(first)?.current.element?.focus();
2001
- }, []);
1523
+ });
1524
+ useEffect(() => () => editor.destroy(), [editor]);
2002
1525
  const context = useMemo(() => ({
2003
1526
  disabled,
2004
1527
  readonly,
@@ -2009,16 +1532,8 @@ const PointsEditor = {
2009
1532
  containerRef,
2010
1533
  selectable,
2011
1534
  selection,
2012
- registerPoint,
2013
- isPointElement,
2014
- beginPointDrag,
2015
- movePointDrag,
2016
- nudgeSelection,
2017
- nudgeFocusedPoint,
2018
- selectionBox,
2019
- beginSelectionBox,
2020
- moveSelectionBox,
2021
- endSelectionBox
1535
+ editor,
1536
+ selectionBox
2022
1537
  }), [
2023
1538
  disabled,
2024
1539
  readonly,
@@ -2028,16 +1543,8 @@ const PointsEditor = {
2028
1543
  cursor,
2029
1544
  selectable,
2030
1545
  selection,
2031
- registerPoint,
2032
- isPointElement,
2033
- beginPointDrag,
2034
- movePointDrag,
2035
- nudgeSelection,
2036
- nudgeFocusedPoint,
2037
- selectionBox,
2038
- beginSelectionBox,
2039
- moveSelectionBox,
2040
- endSelectionBox
1546
+ editor,
1547
+ selectionBox
2041
1548
  ]);
2042
1549
  return /* @__PURE__ */ jsx(PointsEditorProvider, {
2043
1550
  value: context,
@@ -2047,8 +1554,6 @@ const PointsEditor = {
2047
1554
  "data-disabled": disabled ? "" : void 0,
2048
1555
  "data-readonly": readonly ? "" : void 0,
2049
1556
  style: {
2050
- "--width": cssLength(width),
2051
- "--height": cssLength(height),
2052
1557
  position: "relative",
2053
1558
  ...style
2054
1559
  },
@@ -2073,36 +1578,30 @@ function useSliderContext(selector) {
2073
1578
  }
2074
1579
  //#endregion
2075
1580
  //#region src/components/Slider/MarksOption.tsx
2076
- function MarksOption({ value, mark = true, label, thickness, length, gap, labelWidth, classes, styles, className, style, ...props }) {
1581
+ function MarksOption({ value, mark = true, label, thickness, length, gap, classes, styles, className, style, ...props }) {
2077
1582
  useCheckPlacement("Slider.MarksOption", "Slider.Marks");
2078
1583
  const min = useSliderContext((s) => s.min);
2079
1584
  const max = useSliderContext((s) => s.max);
2080
1585
  const scale = useSliderContext((s) => s.scale);
2081
1586
  const vertical = useSliderContext((s) => s.vertical);
2082
1587
  const reverse = useSliderContext((s) => s.reverse);
2083
- const calcPercent = useCallback((value) => {
2084
- const percent = scale.normalize(value, min, max) * 100;
2085
- return toFixed(xor(vertical, reverse) ? 100 - percent : percent);
2086
- }, [
2087
- vertical,
2088
- reverse,
2089
- max,
1588
+ const percent = valuePercent(value, {
2090
1589
  min,
1590
+ max,
2091
1591
  scale
2092
- ]);
1592
+ }, vertical !== reverse);
2093
1593
  return /* @__PURE__ */ jsxs("div", {
2094
1594
  className,
2095
1595
  style: {
2096
1596
  "--thickness": cssLength(thickness),
2097
1597
  "--length": cssLength(length),
2098
1598
  "--gap": cssLength(gap),
2099
- "--label-width": cssLength(labelWidth),
2100
1599
  position: "absolute",
2101
1600
  translate: vertical ? "var(--translate, 0 -50%)" : "var(--translate, -50% 0)",
2102
1601
  zIndex: 10,
2103
1602
  ...style,
2104
- left: !vertical ? `${calcPercent(value)}%` : void 0,
2105
- top: vertical ? `${calcPercent(value)}%` : void 0
1603
+ left: !vertical ? `${percent}%` : void 0,
1604
+ top: vertical ? `${percent}%` : void 0
2106
1605
  },
2107
1606
  "data-orientation": vertical ? "vertical" : "horizontal",
2108
1607
  ...props,
@@ -2118,23 +1617,6 @@ function MarksOption({ value, mark = true, label, thickness, length, gap, labelW
2118
1617
  });
2119
1618
  }
2120
1619
  //#endregion
2121
- //#region src/components/Slider/type.ts
2122
- function generateOptionsList(options, min, max, step) {
2123
- const { per, mark = true, label = true } = typeof options === "object" ? options : { per: options };
2124
- const optionsList = [];
2125
- const interval = per === "step" ? step : per;
2126
- const count = Math.floor(max / interval) - Math.ceil(min / interval) + 1;
2127
- for (let i = 0; i < count; i++) {
2128
- const value = toFixed(interval * (Math.ceil(min / interval) + i), decimalPart(interval)?.length);
2129
- optionsList.push({
2130
- value,
2131
- mark,
2132
- label
2133
- });
2134
- }
2135
- return optionsList;
2136
- }
2137
- //#endregion
2138
1620
  //#region src/components/Slider/Marks.tsx
2139
1621
  function Marks({ gap, options, children, className, style, ...props }) {
2140
1622
  const min = useSliderContext((s) => s.min);
@@ -2142,8 +1624,8 @@ function Marks({ gap, options, children, className, style, ...props }) {
2142
1624
  const step = useSliderContext((s) => s.step);
2143
1625
  const vertical = useSliderContext((s) => s.vertical);
2144
1626
  const reverse = useSliderContext((s) => s.reverse);
2145
- const optionsList = options ? generateOptionsList(options, min, max, step) : [];
2146
- if (xor(vertical, reverse)) optionsList.reverse();
1627
+ const optionsList = options ? sliderMarks(options, min, max, step) : [];
1628
+ if (vertical !== reverse) optionsList.reverse();
2147
1629
  return /* @__PURE__ */ jsx("div", {
2148
1630
  className,
2149
1631
  "data-orientation": vertical ? "vertical" : "horizontal",
@@ -2228,7 +1710,7 @@ const Track = /* @__PURE__ */ forwardRef(function Track({ length, thickness, act
2228
1710
  className,
2229
1711
  "data-disabled": disabled ? "" : void 0,
2230
1712
  "data-orientation": vertical ? "vertical" : "horizontal",
2231
- "data-flipped": xor(vertical, reverse) ? "" : void 0,
1713
+ "data-flipped": vertical !== reverse ? "" : void 0,
2232
1714
  style: {
2233
1715
  "--active": active,
2234
1716
  "--inactive": inactive,
@@ -2265,10 +1747,12 @@ const Slider = {
2265
1747
  ..._externalStyles
2266
1748
  };
2267
1749
  const inactive = disabled || readonly;
2268
- const p = toFixed(scale.normalize(value, min, max) * 100);
2269
- const rev = toFixed(100 - p);
2270
- const displayReversed = useMemo(() => xor(vertical, reverse), [vertical, reverse]);
2271
- const percent = displayReversed ? rev : p;
1750
+ const displayReversed = vertical !== reverse;
1751
+ const percent = valuePercent(value, {
1752
+ min,
1753
+ max,
1754
+ scale
1755
+ }, displayReversed);
2272
1756
  const axis = useMemo(() => ({
2273
1757
  min,
2274
1758
  max,
@@ -2289,18 +1773,11 @@ const Slider = {
2289
1773
  wheel
2290
1774
  });
2291
1775
  const handleKeyDown = useCallback((event) => {
2292
- const key = event.key;
2293
- if (![
2294
- "ArrowRight",
2295
- "ArrowLeft",
2296
- "ArrowUp",
2297
- "ArrowDown"
2298
- ].includes(key)) return;
1776
+ const direction = arrowKeyDirection(event.key);
1777
+ if (direction === null) return;
2299
1778
  event.preventDefault();
2300
1779
  if (!keyboard || !onChange || inactive) return;
2301
- let direction = key === "ArrowRight" || key === "ArrowUp" ? 1 : -1;
2302
- if (reverse) direction *= -1;
2303
- onChange(applyDelta(value, direction, keyboard, axis, event));
1780
+ onChange(applyDelta(value, reverse ? -direction : direction, keyboard, axis, event));
2304
1781
  }, [
2305
1782
  keyboard,
2306
1783
  onChange,
@@ -2333,15 +1810,10 @@ const Slider = {
2333
1810
  });
2334
1811
  const wheelRefCallback = useWheel((event) => {
2335
1812
  if (!wheel || !onChange || inactive) return;
1813
+ const direction = wheelDirection(event, { horizontal: !vertical });
1814
+ if (direction === null) return;
2336
1815
  event.preventDefault();
2337
- let direction;
2338
- if (!vertical && event.deltaX !== 0) direction = event.deltaX > 0 ? 1 : -1;
2339
- else {
2340
- if (event.deltaY === 0) return;
2341
- direction = event.deltaY > 0 ? -1 : 1;
2342
- }
2343
- if (reverse) direction *= -1;
2344
- onChange(applyDelta(value, direction, wheel, axis, event));
1816
+ onChange(applyDelta(value, reverse ? -direction : direction, wheel, axis, event));
2345
1817
  }, WHEEL_OPTIONS$1);
2346
1818
  const rootRefCallback = useComposedRefs(dragRefCallback, wheelRefCallback);
2347
1819
  const context = useMemo(() => ({
@@ -2426,16 +1898,13 @@ function useXYPadContext(selector) {
2426
1898
  }
2427
1899
  //#endregion
2428
1900
  //#region src/components/XYPad/Area.tsx
2429
- const Area = /* @__PURE__ */ forwardRef(function Area({ width, height, color, children, className, style, ...props }, forwardedRef) {
1901
+ const Area = /* @__PURE__ */ forwardRef(function Area({ children, className, style, ...props }, forwardedRef) {
2430
1902
  const { areaRef } = useXYPadContext();
2431
1903
  const composedRef = useComposedRefs(forwardedRef, areaRef);
2432
1904
  return /* @__PURE__ */ jsx("div", {
2433
1905
  ref: composedRef,
2434
1906
  className,
2435
1907
  style: {
2436
- "--color": color,
2437
- "--width": cssLength(width),
2438
- "--height": cssLength(height),
2439
1908
  position: "relative",
2440
1909
  ...style
2441
1910
  },
@@ -2534,10 +2003,11 @@ const XYPad = {
2534
2003
  const step = useMemo(() => toXY(_step), [_step]);
2535
2004
  const scale = useMemo(() => toXY(_scale), [_scale]);
2536
2005
  const reverse = useMemo(() => toXY(_reverse), [_reverse]);
2537
- const percent = useMemo(() => {
2538
- const normalized = [0, 1].map((i) => scale[i].normalize(value[i], min[i], max[i]));
2539
- return [0, 1].map((i) => toFixed((reverse[i] ? 1 - normalized[i] : normalized[i]) * 100));
2540
- }, [
2006
+ const percent = useMemo(() => [0, 1].map((i) => valuePercent(value[i], {
2007
+ min: min[i],
2008
+ max: max[i],
2009
+ scale: scale[i]
2010
+ }, reverse[i])), [
2541
2011
  value,
2542
2012
  min,
2543
2013
  max,
@@ -2579,20 +2049,12 @@ const XYPad = {
2579
2049
  withAxis
2580
2050
  ]);
2581
2051
  const handleKeyDown = useCallback((event) => {
2582
- const key = event.key;
2583
- if (![
2584
- "ArrowRight",
2585
- "ArrowLeft",
2586
- "ArrowUp",
2587
- "ArrowDown"
2588
- ].includes(key)) return;
2589
- const i = key === "ArrowRight" || key === "ArrowLeft" ? 0 : 1;
2052
+ const move = arrowKeyMove(event.key);
2053
+ if (!move) return;
2590
2054
  event.preventDefault();
2591
2055
  if (!onChange || inactive || !keyboard) return;
2592
- let direction = 1;
2593
- if (key === "ArrowLeft" || key === "ArrowUp") direction *= -1;
2594
- if (reverse[i]) direction *= -1;
2595
- onChange(nudge(i, direction, keyboard, event));
2056
+ const { axis: i, direction } = move;
2057
+ onChange(nudge(i, reverse[i] ? -direction : direction, keyboard, event));
2596
2058
  }, [
2597
2059
  onChange,
2598
2060
  inactive,
@@ -2623,15 +2085,11 @@ const XYPad = {
2623
2085
  });
2624
2086
  const wheelRefCallback = useWheel((event) => {
2625
2087
  if (!onChange || inactive || !wheel) return;
2626
- const horizontal = event.deltaX !== 0;
2627
- const delta = horizontal ? event.deltaX : event.deltaY;
2628
- if (delta === 0) return;
2629
- const i = horizontal || event.shiftKey ? 0 : 1;
2088
+ const move = wheelMove(event);
2089
+ if (!move) return;
2630
2090
  event.preventDefault();
2631
- let direction = 1;
2632
- if (delta < 0) direction *= -1;
2633
- if (reverse[i]) direction *= -1;
2634
- onChange(nudge(i, direction, wheel, event));
2091
+ const { axis: i, direction } = move;
2092
+ onChange(nudge(i, reverse[i] ? -direction : direction, wheel, event));
2635
2093
  }, WHEEL_OPTIONS);
2636
2094
  const rootRefCallback = useComposedRefs(rootRef, dragRefCallback, wheelRefCallback);
2637
2095
  const context = useMemo(() => ({
@@ -2716,6 +2174,82 @@ function useAnimationFrame(callback = () => {}, deps = []) {
2716
2174
  }, [runCallback, ...deps]);
2717
2175
  }
2718
2176
  //#endregion
2177
+ //#region src/hooks/useDrag.ts
2178
+ /**
2179
+ * Track a pointer drag on an element.
2180
+ *
2181
+ * @returns a ref callback to attach to the element being dragged
2182
+ */
2183
+ function useDrag({ threshold = 1, cursor, pointerLock, onDrag, onDragStart, onDragEnd }) {
2184
+ const dragHandler = useCallbackRef(onDrag);
2185
+ const dragStartHandler = useCallbackRef(onDragStart);
2186
+ const dragEndHandler = useCallbackRef(onDragEnd);
2187
+ const [node, setNode] = useState(null);
2188
+ useEffect(() => {
2189
+ if (!node) return;
2190
+ const instance = createDrag(node, {
2191
+ threshold,
2192
+ cursor,
2193
+ pointerLock,
2194
+ onDragStart: (state) => dragStartHandler(state),
2195
+ onDrag: (state) => dragHandler(state.x, state.y, state.deltaX, state.deltaY, state),
2196
+ onDragEnd: (state) => dragEndHandler(state)
2197
+ });
2198
+ return () => instance.destroy();
2199
+ }, [
2200
+ node,
2201
+ threshold,
2202
+ cursor,
2203
+ pointerLock,
2204
+ dragHandler,
2205
+ dragStartHandler,
2206
+ dragEndHandler
2207
+ ]);
2208
+ return setNode;
2209
+ }
2210
+ //#endregion
2211
+ //#region src/hooks/useEventListener.ts
2212
+ function useEventListener(target, event, handler, options) {
2213
+ const listener = useCallbackRef(handler);
2214
+ const cleanupRef = useRef(() => {});
2215
+ useEffect(() => {
2216
+ const node = typeof target === "function" ? target() : target ?? document;
2217
+ if (!node) return;
2218
+ node.addEventListener(event, listener, options);
2219
+ const cleanup = () => {
2220
+ node.removeEventListener(event, listener, options);
2221
+ };
2222
+ cleanupRef.current = cleanup;
2223
+ return () => {
2224
+ cleanup();
2225
+ if (cleanupRef.current === cleanup) cleanupRef.current = () => {};
2226
+ };
2227
+ }, [
2228
+ event,
2229
+ target,
2230
+ options,
2231
+ listener
2232
+ ]);
2233
+ return useCallback(() => cleanupRef.current(), []);
2234
+ }
2235
+ //#endregion
2236
+ //#region src/hooks/useInterval.ts
2237
+ function useInterval(callback, delay) {
2238
+ const savedCallback = useRef(callback);
2239
+ useEffect(() => {
2240
+ savedCallback.current = callback;
2241
+ }, [callback]);
2242
+ useEffect(() => {
2243
+ if (delay === null) return;
2244
+ const id = setInterval(() => {
2245
+ savedCallback.current();
2246
+ }, delay);
2247
+ return () => {
2248
+ clearInterval(id);
2249
+ };
2250
+ }, [delay]);
2251
+ }
2252
+ //#endregion
2719
2253
  //#region src/hooks/useMIDIAccess.ts
2720
2254
  const INITIAL_STATE = {
2721
2255
  midiAccess: null,
@@ -2852,6 +2386,6 @@ function useMIDIMessage(midiAccess, onMIDIMessage) {
2852
2386
  });
2853
2387
  }
2854
2388
  //#endregion
2855
- export { AnimationCanvas, DEFAULT_DRAG_SENSITIVITY, DEFAULT_KEYBOARD_OPTIONS, DEFAULT_WHEEL_OPTIONS, Knob, NOT_SUPPORTED, NumberInput, PERMISSION_DENIED, PITCH_BEND_CENTER, Piano, PointsEditor, SHORTCUTS, Slider, UNAVAILABLE, XYPad, clampPoint, useAnimationFrame, useDrag, useDragValue, useEventListener, useInterval, useKnobContext, useLongPress, useMIDIAccess, useMIDIInput, useMIDIMessage, useNumberInputContext, usePointsEditorContext, useSliderContext, useWheel, useXYPadContext };
2389
+ export { AnimationCanvas, DropZone, FileInput, Knob, NumberInput, Piano, PointsEditor, Slider, XYPad, useAnimationFrame, useDrag, useDragValue, useDropZone, useEventListener, useFileInputContext, useInterval, useKnobContext, useLongPress, useMIDIAccess, useMIDIInput, useMIDIMessage, useNumberInputContext, usePointsEditorContext, useSliderContext, useWheel, useXYPadContext };
2856
2390
 
2857
2391
  //# sourceMappingURL=index.js.map