@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.cjs CHANGED
@@ -92,114 +92,192 @@ const AnimationCanvas = /* @__PURE__ */ (0, react.forwardRef)(function Animation
92
92
  });
93
93
  });
94
94
  //#endregion
95
- //#region src/hooks/_internal/useCheckSteps.ts
96
- /** Positions probed across the travel. The ends are left out so that the
97
- * clamp at `min` and `max` cannot be mistaken for a press that does nothing. */
98
- const PROBES = [
99
- .1,
100
- .2,
101
- .3,
102
- .4,
103
- .5,
104
- .6,
105
- .7,
106
- .8,
107
- .9
108
- ];
109
- const NONE = {
110
- shiftKey: false,
111
- altKey: false,
112
- ctrlKey: false,
113
- metaKey: false
114
- };
115
- const MODIFIER_STATE = {
116
- shift: {
117
- ...NONE,
118
- shiftKey: true
119
- },
120
- alt: {
121
- ...NONE,
122
- altKey: true
123
- },
124
- ctrl: {
125
- ...NONE,
126
- ctrlKey: true
127
- },
128
- meta: {
129
- ...NONE,
130
- metaKey: true
131
- }
132
- };
133
- /** Each entry of a modifier-aware input option, as a name and held keys. */
134
- function entries(options) {
135
- if (Array.isArray(options)) return [{
136
- name: "",
137
- modifiers: NONE
138
- }];
139
- return Object.keys(options).map((key) => ({
140
- name: key === "default" ? "" : key,
141
- modifiers: MODIFIER_STATE[key] ?? NONE
142
- }));
95
+ //#region src/hooks/_internal/useCallbackRef.ts
96
+ /**
97
+ * Internal
98
+ * @private
99
+ */
100
+ function useCallbackRef(callback, deps = []) {
101
+ const callbackRef = (0, react.useRef)(() => {
102
+ throw new Error("Cannot call an event handler while rendering.");
103
+ });
104
+ (0, react.useInsertionEffect)(() => {
105
+ callbackRef.current = callback;
106
+ });
107
+ return (0, react.useCallback)((...args) => callbackRef.current?.(...args), deps);
143
108
  }
109
+ //#endregion
110
+ //#region src/hooks/useDropZone.ts
144
111
  /**
145
- * Press every entry of `options` at nine points along the travel and report
146
- * whether anything came of it.
112
+ * Take files dropped onto an element.
113
+ *
114
+ * Any element can become a drop target this way — a waveform display, a track
115
+ * lane, a canvas — without wrapping it in anything.
147
116
  *
148
- * Run against `applyDelta` itself rather than against a reading of `step`:
149
- * the whole point is that the amount, the step and the scale interact, and
150
- * the pipeline is the only thing that knows how.
117
+ * @returns a ref callback for the element, and whether a drag is over it
151
118
  */
152
- function probe(options, range, format) {
153
- const { min, max, scale = _tremolo_ui_functions.linearScale } = range;
154
- const outcome = {
155
- moved: false,
156
- visible: false
119
+ function useDropZone({ accept, multiple, disabled, onDrop, onReject } = {}) {
120
+ const dropHandler = useCallbackRef(onDrop);
121
+ const rejectHandler = useCallbackRef(onReject);
122
+ const [state, setState] = (0, react.useState)({
123
+ over: false,
124
+ invalid: false
125
+ });
126
+ const [node, setNode] = (0, react.useState)(null);
127
+ const instanceRef = (0, react.useRef)(null);
128
+ const latest = (0, react.useRef)({
129
+ accept,
130
+ multiple,
131
+ disabled
132
+ });
133
+ (0, react.useEffect)(() => {
134
+ if (!node) return;
135
+ const instance = (0, _tremolo_ui_dom.createDropZone)(node, {
136
+ ...latest.current,
137
+ onDrop: (files, event) => dropHandler(files, event),
138
+ onReject: (files, event) => rejectHandler(files, event),
139
+ onStateChange: (next) => setState(next)
140
+ });
141
+ instanceRef.current = instance;
142
+ return () => {
143
+ instanceRef.current = null;
144
+ instance.destroy();
145
+ setState({
146
+ over: false,
147
+ invalid: false
148
+ });
149
+ };
150
+ }, [
151
+ node,
152
+ dropHandler,
153
+ rejectHandler
154
+ ]);
155
+ (0, react.useEffect)(() => {
156
+ latest.current = {
157
+ accept,
158
+ multiple,
159
+ disabled
160
+ };
161
+ instanceRef.current?.update({
162
+ accept,
163
+ multiple,
164
+ disabled
165
+ });
166
+ });
167
+ return {
168
+ refCallback: setNode,
169
+ over: state.over,
170
+ invalid: state.invalid
157
171
  };
158
- for (const { modifiers } of entries(options)) for (const position of PROBES) {
159
- const value = scale.denormalize(position, min, max);
160
- const up = (0, _tremolo_ui_functions.applyDelta)(value, 1, options, range, modifiers);
161
- const down = (0, _tremolo_ui_functions.applyDelta)(value, -1, options, range, modifiers);
162
- if (up !== value || down !== value) outcome.moved = true;
163
- if (!format) continue;
164
- const shown = format(value);
165
- if (format(up) !== shown || format(down) !== shown) outcome.visible = true;
166
- }
167
- return outcome;
168
172
  }
169
173
  /**
170
- * Warn, in development only, when a key press or a wheel notch cannot produce
171
- * a change the user can see.
172
- *
173
- * Two settings that are each fine on their own can cancel out, and nothing
174
- * fails when they do — the control simply sits there:
175
- *
176
- * - **`step` coarser than the amount.** `keyboard={['raw', 0.1]}` with
177
- * `step={1}` rounds every press straight back to where it started
178
- * - **the display coarser than the amount.** A `format` showing two decimals
179
- * of a kHz value cannot show a press worth 1 Hz
174
+ * An area that takes files dropped onto it.
175
+ */
176
+ const DropZone = { Root: /* @__PURE__ */ (0, react.forwardRef)(({ accept, multiple = false, disabled = false, onDrop, onReject, children, ...props }, ref) => {
177
+ const { refCallback, over, invalid } = useDropZone({
178
+ accept,
179
+ multiple,
180
+ disabled,
181
+ onDrop,
182
+ onReject
183
+ });
184
+ const composedRef = require_compose_refs.useComposedRefs(ref, refCallback);
185
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
186
+ ref: composedRef,
187
+ "data-dragover": over ? "" : void 0,
188
+ "data-invalid": invalid ? "" : void 0,
189
+ "data-disabled": disabled ? "" : void 0,
190
+ ...props,
191
+ children
192
+ });
193
+ }) };
194
+ //#endregion
195
+ //#region src/components/FileInput/context.tsx
196
+ const FileInputContext = /* @__PURE__ */ (0, react.createContext)(null);
197
+ const FileInputProvider = FileInputContext.Provider;
198
+ function useFileInputContext(selector) {
199
+ const context = (0, react.useContext)(FileInputContext);
200
+ if (!context) throw new Error("Missing FileInputContext.Provider in the tree");
201
+ return selector ? selector(context) : context;
202
+ }
203
+ //#endregion
204
+ //#region src/components/FileInput/Trigger.tsx
205
+ /**
206
+ * Opens the file picker, and names the input for a screen reader.
180
207
  *
181
- * The second is only reported when the press is invisible at *every* point
182
- * along the travel. A display that rounds is a deliberate choice and is
183
- * normally right — it is being too coarse everywhere that makes it a mistake.
208
+ * A `<label>` rather than a `<button>`: the browser forwards the click to the
209
+ * input on its own, the input stays the one thing in the tab order, and its
210
+ * accessible name comes from this text without an `aria-label` to keep in
211
+ * step. A disabled input ignores the click, so nothing has to be intercepted.
212
+ */
213
+ const Trigger = /* @__PURE__ */ (0, react.forwardRef)(({ children, ...props }, ref) => {
214
+ const { inputId, disabled } = useFileInputContext();
215
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
216
+ ref,
217
+ htmlFor: inputId,
218
+ "data-disabled": disabled ? "" : void 0,
219
+ ...props,
220
+ children
221
+ });
222
+ });
223
+ /**
224
+ * Picks files, and hands them over as `File`s.
225
+ */
226
+ const FileInput = {
227
+ Root: /* @__PURE__ */ (0, react.forwardRef)(({ accept, multiple = false, disabled = false, onChange, onReject, children, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, "aria-describedby": ariaDescribedby, ...props }, ref) => {
228
+ const inputId = (0, react.useId)();
229
+ const context = (0, react.useMemo)(() => ({
230
+ inputId,
231
+ disabled
232
+ }), [inputId, disabled]);
233
+ function handleChange(event) {
234
+ const { accepted, rejected } = (0, _tremolo_ui_dom.partitionByAccept)(Array.from(event.target.files ?? []), accept);
235
+ event.target.value = "";
236
+ if (accepted.length > 0) onChange?.(accepted);
237
+ if (rejected.length > 0) onReject?.(rejected);
238
+ }
239
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FileInputProvider, {
240
+ value: context,
241
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
242
+ ref,
243
+ "data-disabled": disabled ? "" : void 0,
244
+ ...props,
245
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
246
+ id: inputId,
247
+ type: "file",
248
+ accept,
249
+ multiple,
250
+ disabled,
251
+ style: _tremolo_ui_dom.visuallyHiddenStyle,
252
+ "aria-label": ariaLabel,
253
+ "aria-labelledby": ariaLabelledby,
254
+ "aria-describedby": ariaDescribedby,
255
+ onChange: handleChange
256
+ }), children]
257
+ })
258
+ });
259
+ }),
260
+ Trigger
261
+ };
262
+ //#endregion
263
+ //#region src/hooks/_internal/useCheckSteps.ts
264
+ /**
265
+ * Warn, in development only, when a key press or a wheel notch cannot produce
266
+ * a change the user can see. The probing is `checkSteps` in the core.
184
267
  *
185
268
  * @internal
186
269
  */
187
- function useCheckSteps({ component, axis, range, keyboard, wheel, format }) {
270
+ function useCheckSteps(options) {
271
+ const { component, axis, range, keyboard, wheel } = options;
188
272
  const { min, max, step, scale } = range ?? {};
189
- const where = axis ? `${component} (${axis})` : component;
190
273
  (0, react.useEffect)(() => {
191
274
  try {
192
275
  if (process.env.NODE_ENV === "production") return;
193
- if (!range || min === void 0 || max === void 0 || !(min < max)) return;
194
- for (const [name, options] of [["keyboard", keyboard], ["wheel", wheel]]) {
195
- if (!options) continue;
196
- const { moved, visible } = probe(options, range, format);
197
- 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.");
198
- 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.`);
199
- }
276
+ for (const warning of (0, _tremolo_ui_dom.checkSteps)(options)) console.warn(warning);
200
277
  } catch {}
201
278
  }, [
202
- where,
279
+ component,
280
+ axis,
203
281
  min,
204
282
  max,
205
283
  step,
@@ -209,21 +287,6 @@ function useCheckSteps({ component, axis, range, keyboard, wheel, format }) {
209
287
  ]);
210
288
  }
211
289
  //#endregion
212
- //#region src/hooks/_internal/useCallbackRef.ts
213
- /**
214
- * Internal
215
- * @private
216
- */
217
- function useCallbackRef(callback, deps = []) {
218
- const callbackRef = (0, react.useRef)(() => {
219
- throw new Error("Cannot call an event handler while rendering.");
220
- });
221
- (0, react.useInsertionEffect)(() => {
222
- callbackRef.current = callback;
223
- });
224
- return (0, react.useCallback)((...args) => callbackRef.current?.(...args), deps);
225
- }
226
- //#endregion
227
290
  //#region src/hooks/useDragValue.ts
228
291
  /**
229
292
  * Drive a value with a pointer drag.
@@ -322,49 +385,6 @@ function useWheel(onWheel, { requireFocus = false, target } = {}) {
322
385
  return setNode;
323
386
  }
324
387
  //#endregion
325
- //#region src/input-event.ts
326
- /**
327
- * The keyboard amount used by Knob, NumberInput, Slider, and XYPad by default:
328
- * 1 per press in the units of the value, and 0.1 with shift held. That is one
329
- * `step` only while `step` is 1; a coarser `step` rounds 1 straight back, which
330
- * `useCheckSteps` warns about.
331
- *
332
- * A modifier entry is not snapped to `step`, which is what lets the finer
333
- * amount move at all — see `applyDelta` in `@tremolo-ui/functions`.
334
- */
335
- const DEFAULT_KEYBOARD_OPTIONS = {
336
- default: ["raw", 1],
337
- shift: ["raw", .1]
338
- };
339
- /**
340
- * The wheel amount used by Knob, NumberInput, Slider, and XYPad by default.
341
- *
342
- * Browsers turn shift+wheel into horizontal scrolling, which empties `deltaY`
343
- * and fills `deltaX`, so no modifier is bound here.
344
- */
345
- const DEFAULT_WHEEL_OPTIONS = ["raw", 1];
346
- /**
347
- * The drag sensitivity used by value controls by default. Shift makes the
348
- * same movement cover a tenth of the range, matching the arrow keys.
349
- */
350
- const DEFAULT_DRAG_SENSITIVITY = {
351
- default: 1,
352
- shift: .1
353
- };
354
- //#endregion
355
- //#region src/components/_util/css-length.ts
356
- /**
357
- * A number as a CSS length, for a custom property.
358
- *
359
- * React appends `px` to a bare number for the properties it knows take a
360
- * length, and a custom property is never one of them: `--size: 50` would come
361
- * out as the invalid `50`. Anything already a string is passed through, so
362
- * `'3rem'` and `'100%'` still work.
363
- */
364
- function cssLength(value) {
365
- return typeof value === "number" ? `${value}px` : value;
366
- }
367
- //#endregion
368
388
  //#region src/components/_util/Placement.tsx
369
389
  /**
370
390
  * The subcomponent that established the current placement, if any.
@@ -406,49 +426,8 @@ function useCheckPlacement(child, parent) {
406
426
  parent
407
427
  ]);
408
428
  }
409
- /**
410
- * 角度から円弧上の点を求める。
411
- *
412
- * 半径は線の太さの分だけ内側に取る必要があるが、太さは ActiveLine / InactiveLine が
413
- * それぞれ持つため、座標はストアで先に計算せず各コンポーネント側で求める。
414
- */
415
- function pointOnArc(angle, radius) {
416
- return {
417
- x: 50 + radius * Math.cos((0, _tremolo_ui_functions.radian)(angle - 90)),
418
- y: 50 + radius * Math.sin((0, _tremolo_ui_functions.radian)(angle - 90))
419
- };
420
- }
421
- /** 線の太さが viewBox からはみ出さないようにした半径 */
422
- function arcRadius(strokeWidth) {
423
- const width = typeof strokeWidth === "number" ? strokeWidth : Number.parseFloat(String(strokeWidth));
424
- return Number.isFinite(width) ? 50 - width / 2 : 50;
425
- }
426
- /** Build an SVG path for an arc, splitting full turns into drawable segments. */
427
- function arcPath(startAngle, endAngle, radius) {
428
- const start = pointOnArc(startAngle, radius);
429
- const sweep = endAngle - startAngle;
430
- const segmentCount = Math.max(1, Math.ceil(Math.abs(sweep) / 180));
431
- const segmentSweep = sweep / segmentCount;
432
- let path = `M ${start.x} ${start.y}`;
433
- for (let i = 1; i <= segmentCount; i += 1) {
434
- const end = pointOnArc(startAngle + segmentSweep * i, radius);
435
- path += ` A ${radius} ${radius} 0 0 ${segmentSweep >= 0 ? 1 : 0} ${end.x} ${end.y}`;
436
- }
437
- return path;
438
- }
439
- /** 設定から描画に必要な角度を導出する。レンダー中に呼ぶ。 */
440
- function calcAngles({ value, min, max, scale, startValue, angleRange }) {
441
- const p = scale.normalize(value, min, max);
442
- const s = scale.normalize(startValue, min, max);
443
- const r1 = -angleRange / 2;
444
- return {
445
- p,
446
- r1,
447
- r2: r1 + Math.min(p, s) * angleRange,
448
- r3: r1 + Math.max(p, s) * angleRange,
449
- r4: angleRange / 2
450
- };
451
- }
429
+ //#endregion
430
+ //#region src/components/Knob/context.tsx
452
431
  const KnobContext = /* @__PURE__ */ (0, react.createContext)(null);
453
432
  const KnobProvider = KnobContext.Provider;
454
433
  function useKnobContext(selector) {
@@ -462,10 +441,10 @@ function ActiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...pr
462
441
  useCheckPlacement("Knob.ActiveLine", "Knob.SVGRoot");
463
442
  const r2 = useKnobContext((s) => s.r2);
464
443
  const r3 = useKnobContext((s) => s.r3);
465
- const radius = arcRadius(strokeWidth);
444
+ const radius = (0, _tremolo_ui_dom.knobArcRadius)(strokeWidth);
466
445
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
467
446
  className,
468
- d: arcPath(r2, r3, radius),
447
+ d: (0, _tremolo_ui_dom.knobArcPath)(r2, r3, radius),
469
448
  fill: "none",
470
449
  stroke,
471
450
  strokeWidth,
@@ -483,17 +462,17 @@ function InactiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...
483
462
  const r2 = useKnobContext((s) => s.r2);
484
463
  const r3 = useKnobContext((s) => s.r3);
485
464
  const r4 = useKnobContext((s) => s.r4);
486
- const radius = arcRadius(strokeWidth);
465
+ const radius = (0, _tremolo_ui_dom.knobArcRadius)(strokeWidth);
487
466
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [startValue > min && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
488
467
  className,
489
- d: arcPath(r1, r2, radius),
468
+ d: (0, _tremolo_ui_dom.knobArcPath)(r1, r2, radius),
490
469
  fill: "none",
491
470
  stroke,
492
471
  strokeWidth,
493
472
  ...props
494
473
  }), startValue < max && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
495
474
  className,
496
- d: arcPath(r3, r4, radius),
475
+ d: (0, _tremolo_ui_dom.knobArcPath)(r3, r4, radius),
497
476
  fill: "none",
498
477
  stroke,
499
478
  strokeWidth,
@@ -504,7 +483,7 @@ function InactiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...
504
483
  //#region src/components/Knob/SVGRoot.tsx
505
484
  function SVGRoot({ children, style, ...props }) {
506
485
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
507
- viewBox: `0 0 100 100`,
486
+ viewBox: `0 0 ${_tremolo_ui_dom.KNOB_VIEWBOX_SIZE} ${_tremolo_ui_dom.KNOB_VIEWBOX_SIZE}`,
508
487
  style: {
509
488
  display: "block",
510
489
  ...style
@@ -534,7 +513,7 @@ function Thumb$2({ className, thumb = "currentColor", thumbLine = "currentColor"
534
513
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("line", {
535
514
  className: classes?.thumbLine,
536
515
  x1: "50%",
537
- y1: `${(100 - (0, _tremolo_ui_functions.clamp)(thumbSize, 0, 100)) / 2}%`,
516
+ y1: `${(_tremolo_ui_dom.KNOB_VIEWBOX_SIZE - (0, _tremolo_ui_functions.clamp)(thumbSize, 0, 100)) / 2}%`,
538
517
  x2: "50%",
539
518
  y2: `${thumbLineLength}%`,
540
519
  stroke: thumbLine,
@@ -558,7 +537,7 @@ const WHEEL_OPTIONS$2 = { requireFocus: true };
558
537
  * Interactive rotary knob component implemented in SVG.
559
538
  */
560
539
  const Knob = {
561
- Root: /* @__PURE__ */ (0, react.forwardRef)(({ value, min, max, step = 1, scale = _tremolo_ui_functions.linearScale, defaultValue = min, startValue = min, size, externalStyles: _externalStyles, wheel = DEFAULT_WHEEL_OPTIONS, keyboard = DEFAULT_KEYBOARD_OPTIONS, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, pointerLock = false, enableDoubleClickDefault = true, disabled = false, readonly = false, angleRange = 270, onChange, onKeyDown, onPointerDown, onDoubleClick, className, style, children, ...props }, forwardedRef) => {
540
+ Root: /* @__PURE__ */ (0, react.forwardRef)(({ value, min, max, step = 1, scale = _tremolo_ui_functions.linearScale, defaultValue = min, startValue = min, size, externalStyles: _externalStyles, wheel = _tremolo_ui_dom.DEFAULT_WHEEL_OPTIONS, keyboard = _tremolo_ui_dom.DEFAULT_KEYBOARD_OPTIONS, dragSensitivity = _tremolo_ui_dom.DEFAULT_DRAG_SENSITIVITY, pointerLock = false, enableDoubleClickDefault = true, disabled = false, readonly = false, angleRange = 270, onChange, onKeyDown, onPointerDown, onDoubleClick, className, style, children, ...props }, forwardedRef) => {
562
541
  const elmRef = (0, react.useRef)(null);
563
542
  const externalStyles = {
564
543
  ...defaultExternalStyles$2,
@@ -584,16 +563,10 @@ const Knob = {
584
563
  });
585
564
  const handleKeyDown = (0, react.useCallback)((event) => {
586
565
  if (!keyboard || !onChange || inactive) return;
587
- const key = event.key;
588
- if ([
589
- "ArrowRight",
590
- "ArrowLeft",
591
- "ArrowUp",
592
- "ArrowDown"
593
- ].includes(key)) {
594
- event.preventDefault();
595
- onChange((0, _tremolo_ui_functions.applyDelta)(value, key === "ArrowRight" || key === "ArrowUp" ? 1 : -1, keyboard, range, event));
596
- }
566
+ const direction = (0, _tremolo_ui_dom.arrowKeyDirection)(event.key);
567
+ if (direction === null) return;
568
+ event.preventDefault();
569
+ onChange((0, _tremolo_ui_dom.applyDelta)(value, direction, keyboard, range, event));
597
570
  }, [
598
571
  keyboard,
599
572
  onChange,
@@ -607,7 +580,7 @@ const Knob = {
607
580
  reverse: true
608
581
  }], [range]),
609
582
  getValue: () => [value, value],
610
- sensitivity: (state) => (0, _tremolo_ui_functions.selectModifier)(dragSensitivity, state.event).value,
583
+ sensitivity: (state) => (0, _tremolo_ui_dom.selectModifier)(dragSensitivity, state.event).value,
611
584
  threshold: 1,
612
585
  cursor: inactive ? void 0 : externalStyles.cursor,
613
586
  pointerLock: inactive ? false : pointerLock,
@@ -619,9 +592,11 @@ const Knob = {
619
592
  });
620
593
  const wheelRefCallback = useWheel((event) => {
621
594
  if (!wheel || inactive) return;
595
+ const direction = (0, _tremolo_ui_dom.wheelDirection)(event);
596
+ if (direction === null) return;
622
597
  event.preventDefault();
623
- if (!onChange || event.deltaY === 0) return;
624
- onChange((0, _tremolo_ui_functions.applyDelta)(value, -Math.sign(event.deltaY), wheel, range, event));
598
+ if (!onChange) return;
599
+ onChange((0, _tremolo_ui_dom.applyDelta)(value, direction, wheel, range, event));
625
600
  }, WHEEL_OPTIONS$2);
626
601
  const rootRefCallback = require_compose_refs.useComposedRefs(elmRef, dragRefCallback, wheelRefCallback);
627
602
  const context = (0, react.useMemo)(() => {
@@ -636,7 +611,7 @@ const Knob = {
636
611
  };
637
612
  return {
638
613
  ...config,
639
- ...calcAngles(config)
614
+ ...(0, _tremolo_ui_dom.knobAngles)(config)
640
615
  };
641
616
  }, [
642
617
  value,
@@ -673,7 +648,7 @@ const Knob = {
673
648
  "data-readonly": readonly ? "" : void 0,
674
649
  "data-dragging": dragging ? "" : void 0,
675
650
  style: {
676
- "--knob-size": cssLength(size),
651
+ "--knob-size": (0, _tremolo_ui_dom.cssLength)(size),
677
652
  ...style
678
653
  },
679
654
  onPointerDown,
@@ -710,82 +685,36 @@ function useStepperContext() {
710
685
  return (0, react.useContext)(StepperContext);
711
686
  }
712
687
  //#endregion
713
- //#region src/hooks/useEventListener.ts
714
- function useEventListener(target, event, handler, options) {
715
- const listener = useCallbackRef(handler);
716
- const cleanupRef = (0, react.useRef)(() => {});
717
- (0, react.useEffect)(() => {
718
- const node = typeof target === "function" ? target() : target ?? document;
719
- if (!node) return;
720
- node.addEventListener(event, listener, options);
721
- const cleanup = () => {
722
- node.removeEventListener(event, listener, options);
723
- };
724
- cleanupRef.current = cleanup;
725
- return () => {
726
- cleanup();
727
- if (cleanupRef.current === cleanup) cleanupRef.current = () => {};
728
- };
729
- }, [
730
- event,
731
- target,
732
- options,
733
- listener
734
- ]);
735
- return (0, react.useCallback)(() => cleanupRef.current(), []);
736
- }
737
- //#endregion
738
- //#region src/hooks/useInterval.ts
739
- function useInterval(callback, delay) {
740
- const savedCallback = (0, react.useRef)(callback);
688
+ //#region src/hooks/useLongPress.ts
689
+ /**
690
+ * Repeat `callback` while a pointer is held down: once on the press, then
691
+ * every `interval` after `initialDelay`. The repeat itself is
692
+ * `createLongPress` in `@tremolo-ui/dom`.
693
+ *
694
+ * Returns the function that starts a press, for an `onPointerDown`.
695
+ */
696
+ function useLongPress(callback, initialDelay = 500, interval = 40) {
697
+ const onPress = useCallbackRef(callback);
698
+ const instanceRef = (0, react.useRef)(null);
741
699
  (0, react.useEffect)(() => {
742
- savedCallback.current = callback;
743
- }, [callback]);
700
+ instanceRef.current?.update({
701
+ delay: initialDelay,
702
+ interval
703
+ });
704
+ });
744
705
  (0, react.useEffect)(() => {
745
- if (delay === null) return;
746
- const id = setInterval(() => {
747
- savedCallback.current();
748
- }, delay);
706
+ const instance = (0, _tremolo_ui_dom.createLongPress)({
707
+ onPress: () => onPress(),
708
+ delay: initialDelay,
709
+ interval
710
+ });
711
+ instanceRef.current = instance;
749
712
  return () => {
750
- clearInterval(id);
713
+ instanceRef.current = null;
714
+ instance.destroy();
751
715
  };
752
- }, [delay]);
753
- }
754
- //#endregion
755
- //#region src/hooks/useLongPress.ts
756
- function useLongPress(callback, initialDelay = 500, interval = 40) {
757
- const [pressed, setPressed] = (0, react.useState)(false);
758
- const [delay, setDelay] = (0, react.useState)(initialDelay);
759
- const activePointerId = (0, react.useRef)(null);
760
- const isPressed = (0, react.useRef)(false);
761
- useInterval(() => {
762
- callback();
763
- setDelay(interval);
764
- }, pressed ? delay : null);
765
- const stop = (0, react.useCallback)((pointerId) => {
766
- if (!isPressed.current) return;
767
- if (pointerId !== void 0 && activePointerId.current !== null && activePointerId.current !== pointerId) return;
768
- isPressed.current = false;
769
- activePointerId.current = null;
770
- setPressed(false);
771
- setDelay(initialDelay);
772
- }, [initialDelay]);
773
- useEventListener(globalThis.window, "pointerup", (event) => {
774
- stop(event.pointerId);
775
- });
776
- useEventListener(globalThis.window, "pointercancel", (event) => {
777
- stop(event.pointerId);
778
- });
779
- useEventListener(globalThis.window, "blur", () => {
780
- stop();
781
- });
782
- return (0, react.useCallback)((event) => {
783
- if (isPressed.current || event && event.button !== 0) return;
784
- isPressed.current = true;
785
- activePointerId.current = event?.pointerId ?? null;
786
- callback();
787
- setPressed(true);
788
- }, [callback]);
716
+ }, []);
717
+ return (0, react.useCallback)((event) => instanceRef.current?.start(event), []);
789
718
  }
790
719
  //#endregion
791
720
  //#region src/components/NumberInput/StepperButton.tsx
@@ -797,7 +726,7 @@ function StepperButton({ direction, variant, icon, className, style, children, o
797
726
  const { step, disabled, readonly, atMin, atMax, nudge } = useNumberInputContext();
798
727
  const stepper = useStepperContext();
799
728
  const press = useLongPress(() => {
800
- if (stepper?.draggingRef.current) return;
729
+ if (stepper?.moved()) return;
801
730
  nudge(direction, ["raw", step]);
802
731
  });
803
732
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -856,21 +785,6 @@ function IncrementStepper(props) {
856
785
  }
857
786
  //#endregion
858
787
  //#region src/components/NumberInput/InputField.tsx
859
- /** The leading number of the displayed text, whatever the format put around it. */
860
- const NUMBER_PREFIX = /^\s*-?[\d.,]*/;
861
- const numberEnd = (text) => text.match(NUMBER_PREFIX)?.[0].length ?? text.length;
862
- /**
863
- * The index the caret is measured against: the decimal point, or where one
864
- * would go if the number has none.
865
- *
866
- * Measuring from an end instead would slide the caret across a digit whenever
867
- * the number changed length — `9.9` to `10.0` gains a character in front, `10`
868
- * to `9` loses one — which is exactly what stepping does.
869
- */
870
- const decimalAnchor = (text) => {
871
- const dot = text.indexOf(".");
872
- return dot === -1 ? numberEnd(text) : dot;
873
- };
874
788
  /**
875
789
  * The text field of a `NumberInput`, and the only place the value can be typed.
876
790
  *
@@ -880,7 +794,7 @@ const decimalAnchor = (text) => {
880
794
  * How it behaves on focus, Enter and the arrow keys is set on `Root`
881
795
  * (`selectOnFocus`, `unformatOnFocus`, `keepCaretOnStep`, `blurOnEnter`).
882
796
  */
883
- const InputField = /* @__PURE__ */ (0, react.forwardRef)(function InputField({ className, style, onFocus, onBlur, onKeyDown, ...props }, forwardedRef) {
797
+ const InputField = /* @__PURE__ */ (0, react.forwardRef)(function InputField({ className, style, onChange, onFocus, onBlur, onKeyDown, ...props }, forwardedRef) {
884
798
  const { value, min, max, step, disabled, readonly, keyboard, selectOnFocus, unformatOnFocus, keepCaretOnStep, blurOnEnter, text, editing, outOfRange, setDraft, commitDraft, nudge, inputRef } = useNumberInputContext();
885
799
  const [focused, setFocused] = (0, react.useState)(false);
886
800
  const shown = unformatOnFocus && focused && !editing ? String(value) : text;
@@ -889,7 +803,10 @@ const InputField = /* @__PURE__ */ (0, react.forwardRef)(function InputField({ c
889
803
  const input = inputRef.current;
890
804
  if (!input) return;
891
805
  if (selectOnFocus === "all") input.setSelectionRange(0, input.value.length);
892
- else input.setSelectionRange(0, input.value.match(NUMBER_PREFIX)?.[0].length ?? 0);
806
+ else {
807
+ const span = (0, _tremolo_ui_dom.numberSpan)(input.value);
808
+ if (span) input.setSelectionRange(span.start, span.end);
809
+ }
893
810
  }, [
894
811
  focused,
895
812
  selectOnFocus,
@@ -906,8 +823,7 @@ const InputField = /* @__PURE__ */ (0, react.forwardRef)(function InputField({ c
906
823
  caretAfterStep.current = null;
907
824
  const input = inputRef.current;
908
825
  if (!input || input.value === pending.from) return;
909
- const place = decimalAnchor(input.value) + pending.offset;
910
- const caret = Math.max(0, Math.min(place, numberEnd(input.value)));
826
+ const caret = (0, _tremolo_ui_dom.caretAtDecimalOffset)(input.value, pending.offset);
911
827
  input.setSelectionRange(caret, caret);
912
828
  });
913
829
  const composedRef = require_compose_refs.useComposedRefs(forwardedRef, inputRef);
@@ -934,6 +850,7 @@ const InputField = /* @__PURE__ */ (0, react.forwardRef)(function InputField({ c
934
850
  onChange: (event) => {
935
851
  caretAfterStep.current = null;
936
852
  setDraft(event.currentTarget.value);
853
+ onChange?.(event);
937
854
  },
938
855
  onFocus: (event) => {
939
856
  setFocused(true);
@@ -953,7 +870,7 @@ const InputField = /* @__PURE__ */ (0, react.forwardRef)(function InputField({ c
953
870
  event.preventDefault();
954
871
  const input = event.currentTarget;
955
872
  if (keepCaretOnStep && input.selectionStart !== null) caretAfterStep.current = {
956
- offset: input.selectionStart - decimalAnchor(input.value),
873
+ offset: (0, _tremolo_ui_dom.caretDecimalOffset)(input.value, input.selectionStart),
957
874
  from: input.value
958
875
  };
959
876
  nudge(key === "ArrowUp" ? 1 : -1, keyboard, event);
@@ -964,117 +881,54 @@ const InputField = /* @__PURE__ */ (0, react.forwardRef)(function InputField({ c
964
881
  });
965
882
  });
966
883
  //#endregion
967
- //#region src/hooks/useDrag.ts
968
- /**
969
- * Track a pointer drag on an element.
970
- *
971
- * @returns a ref callback to attach to the element being dragged
972
- */
973
- function useDrag({ threshold = 1, cursor, pointerLock, onDrag, onDragStart, onDragEnd }) {
974
- const dragHandler = useCallbackRef(onDrag);
975
- const dragStartHandler = useCallbackRef(onDragStart);
976
- const dragEndHandler = useCallbackRef(onDragEnd);
977
- const [node, setNode] = (0, react.useState)(null);
978
- (0, react.useEffect)(() => {
979
- if (!node) return;
980
- const instance = (0, _tremolo_ui_dom.createDrag)(node, {
981
- threshold,
982
- cursor,
983
- pointerLock,
984
- onDragStart: (state) => dragStartHandler(state),
985
- onDrag: (state) => dragHandler(state.x, state.y, state.deltaX, state.deltaY, state),
986
- onDragEnd: (state) => dragEndHandler(state)
987
- });
988
- return () => instance.destroy();
989
- }, [
990
- node,
991
- threshold,
992
- cursor,
993
- pointerLock,
994
- dragHandler,
995
- dragStartHandler,
996
- dragEndHandler
997
- ]);
998
- return setNode;
999
- }
1000
- //#endregion
1001
884
  //#region src/components/NumberInput/Stepper.tsx
1002
885
  /**
1003
886
  * The area the steppers sit in, and a drag handle in its own right: dragging it
1004
- * up and down moves the value one `step` every `drag` pixels.
887
+ * up and down moves the value one `step` every `drag` pixels. The counting is
888
+ * `createStepperDrag` in the core.
1005
889
  *
1006
890
  * The drag lives here rather than on `InputField` because `createDrag` turns
1007
891
  * off text selection on whatever element it is attached to.
1008
892
  */
1009
893
  const Stepper = /* @__PURE__ */ (0, react.forwardRef)(function Stepper({ className, style, children, ...props }, forwardedRef) {
1010
- const { value, step, disabled, readonly, drag, dragSensitivity, pointerLock, rawRange, changeValue } = useNumberInputContext();
1011
- const inactive = disabled || readonly;
1012
- /**
1013
- * The sensitivity as an amount per `drag` pixels, so that the drag goes
1014
- * through the same `applyDelta` the wheel and the arrow keys do. Carrying
1015
- * the modifier map over rather than resolving it here is what keeps `step`
1016
- * out of the pipeline for a modifier entry — naming one is a request to move
1017
- * off the grid.
1018
- */
1019
- const dragOptions = (0, react.useMemo)(() => (0, _tremolo_ui_functions.mapModifier)(dragSensitivity, (factor) => ["raw", step * factor]), [dragSensitivity, step]);
1020
- /**
1021
- * Set once the drag has actually moved the value, so that the press-and-hold
1022
- * repeat of a stepper stands down and leaves the value to the drag. A ref,
1023
- * because it is read from inside the repeat rather than rendered.
1024
- */
1025
- const draggingRef = (0, react.useRef)(false);
1026
- /**
1027
- * Where the drag started, taken on the first move rather than on pointer
1028
- * down: the steppers act on pointer down, so by then the value may already
1029
- * have been nudged once, and the drag should carry on from there.
1030
- */
1031
- const originRef = (0, react.useRef)(null);
1032
- /**
1033
- * Which modifier the drag is currently counting at, and where the previous
1034
- * event was — a key produces no pointer event of its own, so a change is
1035
- * only seen on the next move and has to be dated back to the one before it.
1036
- */
1037
- const factorRef = (0, react.useRef)(1);
1038
- const previousYRef = (0, react.useRef)(0);
1039
- const dragRefCallback = useDrag({
1040
- threshold: 1,
1041
- cursor: inactive ? void 0 : "ns-resize",
1042
- pointerLock: inactive ? false : pointerLock,
1043
- onDragStart: (state) => {
1044
- originRef.current = null;
1045
- draggingRef.current = false;
1046
- factorRef.current = (0, _tremolo_ui_functions.selectModifier)(dragSensitivity, state.event).value;
1047
- },
1048
- onDrag: (_x, y, _dx, _dy, state) => {
1049
- if (inactive || drag === null) return;
1050
- if (!originRef.current) {
1051
- originRef.current = {
1052
- y,
1053
- value
1054
- };
1055
- previousYRef.current = y;
1056
- return;
1057
- }
1058
- const factor = (0, _tremolo_ui_functions.selectModifier)(dragSensitivity, state.event).value;
1059
- if (factor !== factorRef.current) {
1060
- originRef.current = {
1061
- y: previousYRef.current,
1062
- value
1063
- };
1064
- factorRef.current = factor;
1065
- }
1066
- previousYRef.current = y;
1067
- const steps = Math.round(-(y - originRef.current.y) / drag);
1068
- if (steps === 0) return;
1069
- draggingRef.current = true;
1070
- changeValue((0, _tremolo_ui_functions.applyDelta)(originRef.current.value, steps, dragOptions, rawRange, state.event));
1071
- },
1072
- onDragEnd: () => {
1073
- draggingRef.current = false;
1074
- }
894
+ const { value, disabled, readonly, drag, dragSensitivity, pointerLock, rawRange, changeValue } = useNumberInputContext();
895
+ const enabled = drag !== null && !disabled && !readonly;
896
+ const [node, setNode] = (0, react.useState)(null);
897
+ const instanceRef = (0, react.useRef)(null);
898
+ const latest = (0, react.useRef)({
899
+ value,
900
+ changeValue
1075
901
  });
1076
- const composedRef = require_compose_refs.useComposedRefs(forwardedRef, drag !== null && !inactive ? dragRefCallback : void 0);
1077
- const context = (0, react.useMemo)(() => ({ draggingRef }), []);
902
+ const options = {
903
+ range: rawRange,
904
+ pixels: drag ?? 1,
905
+ sensitivity: dragSensitivity,
906
+ pointerLock
907
+ };
908
+ const optionsRef = (0, react.useRef)(options);
909
+ (0, react.useEffect)(() => {
910
+ latest.current = {
911
+ value,
912
+ changeValue
913
+ };
914
+ optionsRef.current = options;
915
+ instanceRef.current?.update(options);
916
+ });
917
+ (0, react.useEffect)(() => {
918
+ if (!node || !enabled) return;
919
+ const instance = (0, _tremolo_ui_dom.createStepperDrag)(node, {
920
+ ...optionsRef.current,
921
+ getValue: () => latest.current.value,
922
+ onChange: (next) => latest.current.changeValue(next)
923
+ });
924
+ instanceRef.current = instance;
925
+ return () => {
926
+ instanceRef.current = null;
927
+ instance.destroy();
928
+ };
929
+ }, [node, enabled]);
930
+ const composedRef = require_compose_refs.useComposedRefs(forwardedRef, setNode);
931
+ const context = (0, react.useMemo)(() => ({ moved: () => instanceRef.current?.moved() ?? false }), []);
1078
932
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StepperProvider, {
1079
933
  value: context,
1080
934
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -1088,22 +942,12 @@ const Stepper = /* @__PURE__ */ (0, react.forwardRef)(function Stepper({ classNa
1088
942
  });
1089
943
  //#endregion
1090
944
  //#region src/components/NumberInput/index.tsx
1091
- /**
1092
- * A number at the start of the text, and nothing read after it. A half-typed
1093
- * entry still yields the number in front of it, but text with no number is
1094
- * `NaN` rather than 0, so that "unreadable" and "the user typed 0" stay apart.
1095
- */
1096
- const LEADING_NUMBER = /^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)/;
1097
945
  const defaultFormat = (value) => String(value);
1098
- const defaultParse = (text) => {
1099
- const match = text.match(LEADING_NUMBER);
1100
- return match ? Number(match[1]) : NaN;
1101
- };
1102
946
  /**
1103
947
  * Input with some useful functions for entering numerical values.
1104
948
  */
1105
949
  const NumberInput = {
1106
- Root: /* @__PURE__ */ (0, react.forwardRef)(({ value, min, max, step = 1, scale = _tremolo_ui_functions.linearScale, format: formatProp, parse: parseProp, clampValue = true, wheel = DEFAULT_WHEEL_OPTIONS, keyboard = DEFAULT_KEYBOARD_OPTIONS, drag = 1, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, pointerLock = false, selectOnFocus = "none", unformatOnFocus = false, keepCaretOnStep = false, blurOnEnter = true, disabled = false, readonly = false, className, style, onChange, children, ...props }, forwardedRef) => {
950
+ Root: /* @__PURE__ */ (0, react.forwardRef)(({ value, min, max, step = 1, scale = _tremolo_ui_functions.linearScale, format: formatProp, parse: parseProp, clampValue = true, wheel = _tremolo_ui_dom.DEFAULT_WHEEL_OPTIONS, keyboard = _tremolo_ui_dom.DEFAULT_KEYBOARD_OPTIONS, drag = 1, dragSensitivity = _tremolo_ui_dom.DEFAULT_DRAG_SENSITIVITY, pointerLock = false, selectOnFocus = "none", unformatOnFocus = false, keepCaretOnStep = false, blurOnEnter = true, disabled = false, readonly = false, className, style, onChange, children, ...props }, forwardedRef) => {
1107
951
  const inputRef = (0, react.useRef)(null);
1108
952
  /**
1109
953
  * The text being typed. The only state here: it is not a copy of `value`,
@@ -1112,24 +956,13 @@ const NumberInput = {
1112
956
  const [draft, setDraft] = (0, react.useState)(null);
1113
957
  const inactive = disabled || readonly;
1114
958
  const format = formatProp ?? defaultFormat;
1115
- const parse = parseProp ?? defaultParse;
1116
- const range = (0, react.useMemo)(() => ({
1117
- min: (clampValue ? min : void 0) ?? Number.MIN_SAFE_INTEGER,
1118
- max: (clampValue ? max : void 0) ?? Number.MAX_SAFE_INTEGER,
1119
- step,
1120
- scale
1121
- }), [
1122
- clampValue,
959
+ const parse = parseProp ?? _tremolo_ui_dom.parseNumberText;
960
+ const { normalized: range, raw: rawRange } = (0, react.useMemo)(() => (0, _tremolo_ui_dom.numberInputRanges)({
1123
961
  min,
1124
962
  max,
1125
963
  step,
1126
- scale
1127
- ]);
1128
- const rawRange = (0, react.useMemo)(() => ({
1129
- min: (clampValue ? min : void 0) ?? -Number.MAX_VALUE,
1130
- max: (clampValue ? max : void 0) ?? Number.MAX_VALUE,
1131
- step,
1132
- scale
964
+ scale,
965
+ clampValue
1133
966
  }), [
1134
967
  clampValue,
1135
968
  min,
@@ -1137,6 +970,10 @@ const NumberInput = {
1137
970
  step,
1138
971
  scale
1139
972
  ]);
973
+ const ranges = (0, react.useMemo)(() => ({
974
+ normalized: range,
975
+ raw: rawRange
976
+ }), [range, rawRange]);
1140
977
  useCheckSteps({
1141
978
  component: "NumberInput",
1142
979
  range: (0, react.useMemo)(() => min !== void 0 && max !== void 0 && min < max ? {
@@ -1156,7 +993,13 @@ const NumberInput = {
1156
993
  });
1157
994
  const text = draft ?? format(value);
1158
995
  const editing = draft !== null;
1159
- const outOfRange = !editing && (min !== void 0 && value < min || max !== void 0 && value > max);
996
+ const bounds = (0, _tremolo_ui_dom.numberInputBounds)(value, {
997
+ min,
998
+ max,
999
+ clampValue
1000
+ });
1001
+ const { atMin, atMax } = bounds;
1002
+ const outOfRange = !editing && bounds.outOfRange;
1160
1003
  const handleDraft = (0, react.useCallback)((next) => {
1161
1004
  if (inactive) return;
1162
1005
  setDraft(next);
@@ -1178,15 +1021,13 @@ const NumberInput = {
1178
1021
  ]);
1179
1022
  const commitDraft = (0, react.useCallback)(() => {
1180
1023
  if (draft === null || inactive) return;
1181
- const parsed = parse(draft);
1182
- if (!Number.isFinite(parsed)) {
1183
- setDraft(null);
1184
- return;
1185
- }
1186
- let committed = parsed;
1187
- if (clampValue && min !== void 0) committed = Math.max(committed, min);
1188
- if (clampValue && max !== void 0) committed = Math.min(committed, max);
1189
- changeValue(committed);
1024
+ const committed = (0, _tremolo_ui_dom.commitNumberInputText)(draft, parse, {
1025
+ min,
1026
+ max,
1027
+ clampValue
1028
+ });
1029
+ if (committed === null) setDraft(null);
1030
+ else changeValue(committed);
1190
1031
  }, [
1191
1032
  draft,
1192
1033
  inactive,
@@ -1197,18 +1038,17 @@ const NumberInput = {
1197
1038
  changeValue
1198
1039
  ]);
1199
1040
  const nudge = (0, react.useCallback)((direction, option, modifiers) => {
1200
- const [mode] = (0, _tremolo_ui_functions.selectInputEvent)(option, modifiers).option;
1201
- changeValue((0, _tremolo_ui_functions.applyDelta)(value, direction, option, mode === "raw" ? rawRange : range, modifiers));
1041
+ changeValue((0, _tremolo_ui_dom.nudgeNumberInput)(value, direction, option, ranges, modifiers));
1202
1042
  }, [
1203
1043
  changeValue,
1204
1044
  value,
1205
- rawRange,
1206
- range
1045
+ ranges
1207
1046
  ]);
1208
1047
  const wheelRefCallback = useWheel((event) => {
1209
- if (!wheel || inactive || event.deltaY === 0) return;
1048
+ const direction = (0, _tremolo_ui_dom.wheelDirection)(event);
1049
+ if (!wheel || inactive || direction === null) return;
1210
1050
  event.preventDefault();
1211
- nudge(-Math.sign(event.deltaY), wheel, event);
1051
+ nudge(direction, wheel, event);
1212
1052
  }, { requireFocus: true });
1213
1053
  const context = (0, react.useMemo)(() => ({
1214
1054
  value,
@@ -1231,8 +1071,8 @@ const NumberInput = {
1231
1071
  unformatOnFocus,
1232
1072
  keepCaretOnStep,
1233
1073
  blurOnEnter,
1234
- atMin: clampValue && min !== void 0 && value <= min,
1235
- atMax: clampValue && max !== void 0 && value >= max,
1074
+ atMin,
1075
+ atMax,
1236
1076
  drag,
1237
1077
  format,
1238
1078
  parse,
@@ -1256,6 +1096,8 @@ const NumberInput = {
1256
1096
  text,
1257
1097
  editing,
1258
1098
  outOfRange,
1099
+ atMin,
1100
+ atMax,
1259
1101
  drag,
1260
1102
  dragSensitivity,
1261
1103
  pointerLock,
@@ -1298,78 +1140,15 @@ const NumberInput = {
1298
1140
  IncrementStepper,
1299
1141
  DecrementStepper
1300
1142
  };
1301
- //#endregion
1302
- //#region src/components/Piano/keyboard-shortcuts.ts
1303
- /**
1304
- * Ready-made keyboard layouts. Both assume `noteRange.first` is a C.
1305
- */
1306
- const SHORTCUTS = {
1307
- /** Every semitone from C, over the two rows of a QWERTY keyboard. */
1308
- HOME_ROW: { keys: [
1309
- "a",
1310
- "w",
1311
- "s",
1312
- "e",
1313
- "d",
1314
- "f",
1315
- "t",
1316
- "g",
1317
- "y",
1318
- "h",
1319
- "u",
1320
- "j",
1321
- "k",
1322
- "o",
1323
- "l",
1324
- "p",
1325
- ";"
1326
- ] },
1327
- /** The white keys only, on the home row. Black keys have no shortcut. */
1328
- HOME_ROW_NATURAL: { keys: [
1329
- "a",
1330
- "",
1331
- "s",
1332
- "",
1333
- "d",
1334
- "f",
1335
- "",
1336
- "g",
1337
- "",
1338
- "h",
1339
- "",
1340
- "j",
1341
- "k",
1342
- "",
1343
- "l",
1344
- "",
1345
- ";"
1346
- ] }
1347
- };
1348
- //#endregion
1349
- //#region src/components/Piano/index.tsx
1350
- function isEditableTarget(target) {
1351
- if (!(target instanceof HTMLElement)) return false;
1352
- if (target.matches("input, textarea, select")) return true;
1353
- for (let element = target; element;) {
1354
- const contentEditable = element.getAttribute("contenteditable");
1355
- if (contentEditable !== null) return contentEditable.toLowerCase() !== "false";
1356
- element = element.parentElement;
1357
- }
1358
- return false;
1359
- }
1360
- function shortcutKey(event) {
1361
- return event.code || event.key;
1362
- }
1363
1143
  /**
1364
1144
  * Customizable piano component.
1365
1145
  */
1366
- const Piano = { Root: /* @__PURE__ */ (0, react.forwardRef)(function Root({ noteRange, glissando = true, midiMax = 127, keyboardShortcuts, keyboardShortcutsScope = "root", resizable = false, whiteKeyWidth = 40, keyGap = 1, blackKeyWidthRatio = .65, blackKeyHeightRatio = .6, height, style, className, label, keyProps, classes, onPlayNote, onStopNote, ...props }, forwardedRef) {
1146
+ const Piano = { Root: /* @__PURE__ */ (0, react.forwardRef)(function Root({ noteRange, glissando = true, midiMax = 127, keyboardShortcuts, keyboardShortcutsScope = "root", resizable = false, whiteKeyWidth = 40, keyGap = 1, blackKeyWidthRatio = .65, blackKeyHeightRatio = .6, style, className, label, keyProps, classes, onPlayNote, onStopNote, ...props }, forwardedRef) {
1367
1147
  const [node, setNode] = (0, react.useState)(null);
1368
1148
  const [activeNotes, setActiveNotes] = (0, react.useState)([]);
1369
1149
  /** Set while `resizable` is on, measured from the parent. */
1370
1150
  const [resizedKeyWidth, setResizedKeyWidth] = (0, react.useState)(whiteKeyWidth);
1371
- const notes = (0, react.useMemo)(() => (0, _tremolo_ui_functions.getNoteRangeArray)(noteRange), [noteRange]);
1372
- const whiteKeyCount = (0, react.useMemo)(() => notes.filter(_tremolo_ui_functions.isWhiteKey).length, [notes]);
1151
+ const notes = (0, react.useMemo)(() => (0, _tremolo_ui_dom.getNoteRangeArray)(noteRange), [noteRange]);
1373
1152
  const layout = (0, react.useMemo)(() => ({
1374
1153
  noteRange,
1375
1154
  whiteKeyWidth: resizable ? resizedKeyWidth : whiteKeyWidth,
@@ -1389,21 +1168,20 @@ const Piano = { Root: /* @__PURE__ */ (0, react.forwardRef)(function Root({ note
1389
1168
  layout,
1390
1169
  glissando,
1391
1170
  midiMax,
1171
+ keyboardShortcuts,
1172
+ keyboardShortcutsScope,
1392
1173
  onPlayNote,
1393
1174
  onStopNote
1394
1175
  });
1395
1176
  const instanceRef = (0, react.useRef)(null);
1396
- const shortcutNotes = (0, react.useRef)(/* @__PURE__ */ new Map());
1397
- const releaseShortcutNotes = (0, react.useCallback)(() => {
1398
- for (const { note, source } of shortcutNotes.current.values()) instanceRef.current?.noteOff(note, { source });
1399
- shortcutNotes.current.clear();
1400
- }, []);
1401
1177
  (0, react.useEffect)(() => {
1402
1178
  if (!node) return;
1403
1179
  const instance = (0, _tremolo_ui_dom.createPianoInput)(node, {
1404
1180
  layout: latest.current.layout,
1405
1181
  glissando: latest.current.glissando,
1406
1182
  midiMax: latest.current.midiMax,
1183
+ keyboardShortcuts: latest.current.keyboardShortcuts,
1184
+ keyboardShortcutsScope: latest.current.keyboardShortcutsScope,
1407
1185
  onPlayNote: (note, velocity) => latest.current.onPlayNote?.(note, velocity),
1408
1186
  onStopNote: (note) => latest.current.onStopNote?.(note),
1409
1187
  onActiveNotesChange: setActiveNotes
@@ -1419,86 +1197,44 @@ const Piano = { Root: /* @__PURE__ */ (0, react.forwardRef)(function Root({ note
1419
1197
  layout,
1420
1198
  glissando,
1421
1199
  midiMax,
1200
+ keyboardShortcuts,
1201
+ keyboardShortcutsScope,
1422
1202
  onPlayNote,
1423
1203
  onStopNote
1424
1204
  };
1425
1205
  instanceRef.current?.update({
1426
1206
  layout,
1427
1207
  glissando,
1428
- midiMax
1208
+ midiMax,
1209
+ keyboardShortcuts,
1210
+ keyboardShortcutsScope
1429
1211
  });
1430
1212
  });
1213
+ const { first, last } = noteRange;
1431
1214
  (0, react.useEffect)(() => {
1432
1215
  if (!resizable || !node) return;
1433
1216
  const parent = node.parentElement;
1434
1217
  if (!parent) throw new Error("doesn't have a parent element.");
1435
1218
  const resizeObserver = new ResizeObserver(() => {
1436
- setResizedKeyWidth(node.clientWidth / Math.max(whiteKeyCount, 1) - keyGap);
1219
+ setResizedKeyWidth((0, _tremolo_ui_dom.fitWhiteKeyWidth)(node.clientWidth, {
1220
+ noteRange: {
1221
+ first,
1222
+ last
1223
+ },
1224
+ keyGap,
1225
+ blackKeyWidthRatio
1226
+ }));
1437
1227
  });
1438
1228
  resizeObserver.observe(parent);
1439
1229
  return () => resizeObserver.disconnect();
1440
1230
  }, [
1441
1231
  resizable,
1442
1232
  node,
1443
- whiteKeyCount,
1444
- keyGap
1445
- ]);
1446
- const shortcutKeys = keyboardShortcuts?.keys;
1447
- const hasShortcuts = shortcutKeys !== void 0;
1448
- const shortcutMapping = shortcutKeys?.join("\0");
1449
- (0, react.useEffect)(() => releaseShortcutNotes, [
1450
- releaseShortcutNotes,
1451
- shortcutMapping,
1452
- keyboardShortcutsScope,
1453
- noteRange.first,
1454
- noteRange.last
1455
- ]);
1456
- /** The note a shortcut key plays, or null when it has none. */
1457
- function shortcutNote(key) {
1458
- if (!shortcutKeys || key === "") return null;
1459
- const index = shortcutKeys.indexOf(key);
1460
- const note = noteRange.first + index;
1461
- return index === -1 || note > noteRange.last ? null : note;
1462
- }
1463
- const shortcutTarget = (0, react.useMemo)(() => () => {
1464
- if (!hasShortcuts) return null;
1465
- return keyboardShortcutsScope === "window" ? globalThis.window : node;
1466
- }, [
1467
- hasShortcuts,
1468
- keyboardShortcutsScope,
1469
- node
1470
- ]);
1471
- const focusOutTarget = (0, react.useMemo)(() => () => hasShortcuts && keyboardShortcutsScope === "root" ? node : null, [
1472
- hasShortcuts,
1473
- keyboardShortcutsScope,
1474
- node
1233
+ first,
1234
+ last,
1235
+ keyGap,
1236
+ blackKeyWidthRatio
1475
1237
  ]);
1476
- const windowBlurTarget = (0, react.useMemo)(() => () => hasShortcuts ? globalThis.window : null, [hasShortcuts]);
1477
- useEventListener(shortcutTarget, "keydown", (e) => {
1478
- const key = shortcutKey(e);
1479
- if (e.repeat || shortcutNotes.current.has(key)) return;
1480
- if (isEditableTarget(e.target)) return;
1481
- const note = shortcutNote(e.key);
1482
- if (note === null) return;
1483
- const source = `keyboard:${key}`;
1484
- shortcutNotes.current.set(key, {
1485
- note,
1486
- source
1487
- });
1488
- instanceRef.current?.noteOn(note, { source });
1489
- });
1490
- useEventListener(shortcutTarget, "keyup", (e) => {
1491
- const key = shortcutKey(e);
1492
- const shortcut = shortcutNotes.current.get(key);
1493
- if (!shortcut) return;
1494
- shortcutNotes.current.delete(key);
1495
- instanceRef.current?.noteOff(shortcut.note, { source: shortcut.source });
1496
- });
1497
- useEventListener(focusOutTarget, "focusout", (e) => {
1498
- if (e.relatedTarget instanceof Node && node?.contains(e.relatedTarget)) return;
1499
- releaseShortcutNotes();
1500
- });
1501
- useEventListener(windowBlurTarget, "blur", releaseShortcutNotes);
1502
1238
  (0, react.useImperativeHandle)(forwardedRef, () => ({
1503
1239
  playNote: (note, velocity) => instanceRef.current?.noteOn(note, {
1504
1240
  source: "api",
@@ -1513,8 +1249,7 @@ const Piano = { Root: /* @__PURE__ */ (0, react.forwardRef)(function Root({ note
1513
1249
  role: "group",
1514
1250
  tabIndex: 0,
1515
1251
  style: {
1516
- width: resizable ? "100%" : (0, _tremolo_ui_functions.pianoWidth)(layout),
1517
- "--height": cssLength(height),
1252
+ width: resizable ? "100%" : (0, _tremolo_ui_dom.pianoWidth)(layout),
1518
1253
  position: "relative",
1519
1254
  ...style
1520
1255
  },
@@ -1540,8 +1275,8 @@ const Piano = { Root: /* @__PURE__ */ (0, react.forwardRef)(function Root({ note
1540
1275
  position: "absolute",
1541
1276
  zIndex: keyType === "white" ? 1 : 2,
1542
1277
  ...keyStyle,
1543
- left: (0, _tremolo_ui_functions.notePosition)(note, layout),
1544
- width: keyType === "white" ? layout.whiteKeyWidth : (0, _tremolo_ui_functions.blackKeyWidth)(layout),
1278
+ left: (0, _tremolo_ui_dom.notePosition)(note, layout),
1279
+ width: keyType === "white" ? layout.whiteKeyWidth : (0, _tremolo_ui_dom.blackKeyWidth)(layout),
1545
1280
  height: keyType === "white" ? "100%" : `${blackKeyHeightRatio * 100}%`
1546
1281
  },
1547
1282
  children: content !== "" && content !== null && content !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -1580,43 +1315,58 @@ function usePointsEditorContext(selector) {
1580
1315
  return selector ? selector(context) : context;
1581
1316
  }
1582
1317
  //#endregion
1318
+ //#region src/components/PointsEditor/Container.tsx
1319
+ const Container = /* @__PURE__ */ (0, react.forwardRef)(function Container({ children, className, style, ...props }, forwardedRef) {
1320
+ const { containerRef, disabled, selectable, editor } = usePointsEditorContext();
1321
+ const { refCallback: dragRefCallback } = useDragValue({
1322
+ axis: _tremolo_ui_dom.POINT_AXIS,
1323
+ baseElementRef: containerRef,
1324
+ shouldStart: (event) => !disabled && !editor.isPointElement(event.target),
1325
+ onDragStart: ([x, y], state) => {
1326
+ editor.beginSelectionBox({
1327
+ x,
1328
+ y
1329
+ }, state.event);
1330
+ },
1331
+ onChange: ([x, y]) => editor.moveSelectionBox({
1332
+ x,
1333
+ y
1334
+ }),
1335
+ onDragEnd: () => editor.endSelectionBox()
1336
+ });
1337
+ useWheel((event) => {
1338
+ const move = (0, _tremolo_ui_dom.wheelMove)(event);
1339
+ if (!move) return;
1340
+ const axis = move.axis === 0 ? "x" : "y";
1341
+ if (editor.nudgeFocusedPoint(axis, move.direction, event)) event.preventDefault();
1342
+ }, { target: containerRef });
1343
+ const composedRef = require_compose_refs.useComposedRefs(forwardedRef, containerRef, selectable && !disabled ? dragRefCallback : void 0);
1344
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1345
+ ref: composedRef,
1346
+ className,
1347
+ style: {
1348
+ position: "absolute",
1349
+ inset: 0,
1350
+ zIndex: 10,
1351
+ ...style
1352
+ },
1353
+ ...props,
1354
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Placement, {
1355
+ name: "PointsEditor.Container",
1356
+ children
1357
+ })
1358
+ });
1359
+ });
1360
+ //#endregion
1583
1361
  //#region src/components/_util/VisuallyHiddenRangeInput.tsx
1584
- const hiddenStyle = {
1585
- position: "absolute",
1586
- width: 1,
1587
- height: 1,
1588
- padding: 0,
1589
- margin: -1,
1590
- overflow: "hidden",
1591
- clip: "rect(0, 0, 0, 0)",
1592
- clipPath: "inset(50%)",
1593
- whiteSpace: "nowrap",
1594
- border: 0,
1595
- pointerEvents: "none"
1596
- };
1597
1362
  const VisuallyHiddenRangeInput = /* @__PURE__ */ (0, react.forwardRef)((props, ref) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1598
1363
  ...props,
1599
1364
  ref,
1600
1365
  type: "range",
1601
- style: hiddenStyle
1366
+ style: _tremolo_ui_dom.visuallyHiddenStyle
1602
1367
  }));
1603
1368
  //#endregion
1604
1369
  //#region src/components/PointsEditor/Point.tsx
1605
- function clampPoint(point, min, max) {
1606
- const { x, y } = point;
1607
- return {
1608
- x: (0, _tremolo_ui_functions.clamp)(x, min?.x ?? 0, max?.x ?? 1),
1609
- y: (0, _tremolo_ui_functions.clamp)(y, min?.y ?? 0, max?.y ?? 1)
1610
- };
1611
- }
1612
- /**
1613
- * A point is placed by its position within the container, so its value is a
1614
- * position: 0..1 on each axis, with y growing downwards.
1615
- */
1616
- const AXIS = {
1617
- min: 0,
1618
- max: 1
1619
- };
1620
1370
  /**
1621
1371
  * One setting for both axes, or one per axis. The point holds a range input
1622
1372
  * for each, so anything that names or describes it comes in a pair.
@@ -1624,8 +1374,8 @@ const AXIS = {
1624
1374
  function perAxis(value, axis) {
1625
1375
  return typeof value === "string" ? value : value?.[axis];
1626
1376
  }
1627
- 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 }) {
1628
- const { containerRef, externalStyles, disabled: rootDisabled, readonly: rootReadonly, wheel: rootWheel, keyboard: rootKeyboard, dragSensitivity, selection, registerPoint, beginPointDrag, movePointDrag, nudgeSelection } = usePointsEditorContext();
1377
+ function Point({ value, children, id: idProp, min, max, color, disabled: _disabled, readonly: _readonly, wheel: _wheel, keyboard: _keyboard, "aria-label": ariaLabel, "aria-valuetext": ariaValuetext, onChange, onDragStart, onDragEnd, className, style, onPointerDown, onKeyDown, onFocus, ...props }) {
1378
+ const { containerRef, externalStyles, disabled: rootDisabled, readonly: rootReadonly, wheel: rootWheel, keyboard: rootKeyboard, dragSensitivity, selection, editor } = usePointsEditorContext();
1629
1379
  const generatedId = (0, react.useId)();
1630
1380
  const id = idProp ?? generatedId;
1631
1381
  const selected = selection.includes(id);
@@ -1658,65 +1408,48 @@ function Point({ value, children, id: idProp, min, max, size, width, height, col
1658
1408
  wheel
1659
1409
  };
1660
1410
  });
1661
- (0, react.useEffect)(() => registerPoint(id, registration), [id, registerPoint]);
1411
+ (0, react.useEffect)(() => editor.registerPoint(id, () => registration.current), [id, editor]);
1662
1412
  /** Where the pointer was when the drag started, to measure the move from. */
1663
1413
  const pointerOrigin = (0, react.useRef)(null);
1664
1414
  const { refCallback: dragRefCallback, dragging } = useDragValue({
1665
- axis: AXIS,
1415
+ axis: _tremolo_ui_dom.POINT_AXIS,
1666
1416
  baseElementRef: containerRef,
1667
- sensitivity: (state) => (0, _tremolo_ui_functions.selectModifier)(dragSensitivity, state.event).value,
1417
+ sensitivity: (state) => (0, _tremolo_ui_dom.selectModifier)(dragSensitivity, state.event).value,
1668
1418
  cursor: inactive ? void 0 : externalStyles.cursor,
1669
1419
  shouldStart: () => !disabled,
1670
1420
  onChange: ([x, y]) => {
1671
1421
  const origin = pointerOrigin.current;
1672
1422
  if (!origin) return;
1673
- movePointDrag({
1423
+ editor.movePointDrag({
1674
1424
  x: x - origin.x,
1675
1425
  y: y - origin.y
1676
1426
  });
1677
1427
  },
1678
1428
  onDragStart: ([x, y], state) => {
1679
- beginPointDrag(id, state.event);
1429
+ editor.beginPointDrag(id, state.event);
1680
1430
  pointerOrigin.current = {
1681
1431
  x,
1682
1432
  y
1683
1433
  };
1684
1434
  xInputRef.current?.focus();
1685
1435
  if (inactive) return;
1686
- onDragStart?.(clampPoint(value, min, max));
1436
+ onDragStart?.((0, _tremolo_ui_dom.clampPoint)(value, min, max));
1687
1437
  },
1688
1438
  onDragEnd: () => {
1689
1439
  pointerOrigin.current = null;
1690
1440
  if (inactive) return;
1691
- onDragEnd?.(clampPoint(value, min, max));
1441
+ onDragEnd?.((0, _tremolo_ui_dom.clampPoint)(value, min, max));
1692
1442
  }
1693
1443
  });
1694
- const nudge = (0, react.useCallback)((axis, direction, option, modifiers) => {
1695
- const next = (0, _tremolo_ui_functions.applyDelta)(value[axis], direction, option, AXIS, modifiers);
1696
- nudgeSelection(id, {
1697
- x: axis === "x" ? next - value.x : 0,
1698
- y: axis === "y" ? next - value.y : 0
1699
- });
1700
- }, [
1701
- value,
1702
- id,
1703
- nudgeSelection
1704
- ]);
1705
1444
  const refCallback = require_compose_refs.useComposedRefs(dragRefCallback, setElement);
1706
1445
  const handleKeyDown = (event) => {
1707
- const key = event.key;
1708
- if (![
1709
- "ArrowRight",
1710
- "ArrowLeft",
1711
- "ArrowUp",
1712
- "ArrowDown"
1713
- ].includes(key)) return;
1714
- const axis = key === "ArrowRight" || key === "ArrowLeft" ? "x" : "y";
1446
+ const move = (0, _tremolo_ui_dom.arrowKeyMove)(event.key);
1447
+ if (!move) return;
1715
1448
  event.preventDefault();
1716
1449
  if (!onChange || inactive || !keyboard) return;
1717
- nudge(axis, key === "ArrowLeft" || key === "ArrowUp" ? -1 : 1, keyboard, event);
1450
+ editor.nudgePoint(id, move.axis === 0 ? "x" : "y", move.direction, keyboard, event);
1718
1451
  };
1719
- const current = clampPoint(value, min, max);
1452
+ const current = (0, _tremolo_ui_dom.clampPoint)(value, min, max);
1720
1453
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1721
1454
  ref: refCallback,
1722
1455
  className,
@@ -1727,8 +1460,6 @@ function Point({ value, children, id: idProp, min, max, size, width, height, col
1727
1460
  "data-selected": selected ? "" : void 0,
1728
1461
  style: {
1729
1462
  "--color": color,
1730
- "--width": cssLength(size ?? width),
1731
- "--height": cssLength(size ?? height),
1732
1463
  position: "absolute",
1733
1464
  translate: "var(--translate, -50% -50%)",
1734
1465
  ...style,
@@ -1763,7 +1494,7 @@ function Point({ value, children, id: idProp, min, max, size, width, height, col
1763
1494
  return;
1764
1495
  }
1765
1496
  const delta = event.currentTarget.valueAsNumber - value[axis];
1766
- nudgeSelection(id, {
1497
+ editor.nudgeSelection(id, {
1767
1498
  x: axis === "x" ? delta : 0,
1768
1499
  y: axis === "y" ? delta : 0
1769
1500
  });
@@ -1772,50 +1503,6 @@ function Point({ value, children, id: idProp, min, max, size, width, height, col
1772
1503
  });
1773
1504
  }
1774
1505
  //#endregion
1775
- //#region src/components/PointsEditor/Container.tsx
1776
- const Container = /* @__PURE__ */ (0, react.forwardRef)(function Container({ children, className, style, ...props }, forwardedRef) {
1777
- const { containerRef, disabled, selectable, isPointElement, nudgeFocusedPoint, beginSelectionBox, moveSelectionBox, endSelectionBox } = usePointsEditorContext();
1778
- const { refCallback: dragRefCallback } = useDragValue({
1779
- axis: AXIS,
1780
- baseElementRef: containerRef,
1781
- shouldStart: (event) => !disabled && !isPointElement(event.target),
1782
- onDragStart: ([x, y], state) => {
1783
- beginSelectionBox({
1784
- x,
1785
- y
1786
- }, state.event);
1787
- },
1788
- onChange: ([x, y]) => moveSelectionBox({
1789
- x,
1790
- y
1791
- }),
1792
- onDragEnd: endSelectionBox
1793
- });
1794
- useWheel((event) => {
1795
- const horizontal = event.deltaX !== 0;
1796
- const delta = horizontal ? event.deltaX : event.deltaY;
1797
- if (delta === 0) return;
1798
- const axis = horizontal || event.shiftKey ? "x" : "y";
1799
- if (nudgeFocusedPoint(axis, delta < 0 ? -1 : 1, event)) event.preventDefault();
1800
- }, { target: containerRef });
1801
- const composedRef = require_compose_refs.useComposedRefs(forwardedRef, containerRef, selectable && !disabled ? dragRefCallback : void 0);
1802
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1803
- ref: composedRef,
1804
- className,
1805
- style: {
1806
- position: "absolute",
1807
- inset: 0,
1808
- zIndex: 10,
1809
- ...style
1810
- },
1811
- ...props,
1812
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Placement, {
1813
- name: "PointsEditor.Container",
1814
- children
1815
- })
1816
- });
1817
- });
1818
- //#endregion
1819
1506
  //#region src/components/PointsEditor/SelectionBox.tsx
1820
1507
  /**
1821
1508
  * The box a drag on empty space draws, and what it covers is selected.
@@ -1848,181 +1535,28 @@ function SelectionBox({ children, className, style, ...props }) {
1848
1535
  /** One array for every editor with selection turned off, so memos hold still. */
1849
1536
  const EMPTY = [];
1850
1537
  /**
1851
- * How far a point may move before something in the selection leaves its range.
1852
- *
1853
- * Clamping each point on its own would break the shape of the selection: the
1854
- * one that reached the edge would stop while the rest carried on. One amount
1855
- * for all of them means the whole selection stops together.
1856
- */
1857
- function allowedDelta(delta, entries) {
1858
- let loX = -Infinity;
1859
- let hiX = Infinity;
1860
- let loY = -Infinity;
1861
- let hiY = Infinity;
1862
- for (const { start, registration } of entries) {
1863
- loX = Math.max(loX, (registration.min?.x ?? 0) - start.x);
1864
- hiX = Math.min(hiX, (registration.max?.x ?? 1) - start.x);
1865
- loY = Math.max(loY, (registration.min?.y ?? 0) - start.y);
1866
- hiY = Math.min(hiY, (registration.max?.y ?? 1) - start.y);
1867
- }
1868
- return {
1869
- x: hiX < loX ? 0 : (0, _tremolo_ui_functions.clamp)(delta.x, loX, hiX),
1870
- y: hiY < loY ? 0 : (0, _tremolo_ui_functions.clamp)(delta.y, loY, hiY)
1871
- };
1872
- }
1873
- /**
1874
- * A point moves over 0..1 in both axes, so a nudge of 0.01 crosses the editor
1875
- * in a hundred steps whatever its pixel size.
1876
- */
1877
- const DEFAULT_WHEEL = ["normalized", .01];
1878
- /**
1879
- * Shift is the fine-adjustment key everywhere else, so it is bound here too
1880
- * — but only on the keyboard. On the wheel it already means the x axis, and
1881
- * browsers hand shift+wheel over as horizontal scrolling anyway.
1882
- */
1883
- const DEFAULT_KEYBOARD = {
1884
- default: ["normalized", .01],
1885
- shift: ["normalized", .001]
1886
- };
1887
- /**
1888
1538
  * Multiple Point Controller
1889
1539
  */
1890
1540
  const PointsEditor = {
1891
- Root: /* @__PURE__ */ (0, react.forwardRef)(({ width, height, disabled = false, readonly = false, wheel = DEFAULT_WHEEL, keyboard = DEFAULT_KEYBOARD, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, selectable = false, selection: selectionProp, defaultSelection, onSelectionChange, externalStyles, style, className, children, ...props }, forwardedRef) => {
1541
+ Root: /* @__PURE__ */ (0, react.forwardRef)(({ disabled = false, readonly = false, wheel = _tremolo_ui_dom.POINTS_EDITOR_DEFAULT_WHEEL, keyboard = _tremolo_ui_dom.POINTS_EDITOR_DEFAULT_KEYBOARD, dragSensitivity = _tremolo_ui_dom.DEFAULT_DRAG_SENSITIVITY, selectable = false, selection: selectionProp, defaultSelection, onSelectionChange, externalStyles, style, className, children, ...props }, forwardedRef) => {
1892
1542
  const containerRef = (0, react.useRef)(null);
1893
1543
  const { cursor = "grabbing" } = externalStyles ?? {};
1894
1544
  const controlled = selectionProp !== void 0;
1895
1545
  const [ownSelection, setOwnSelection] = (0, react.useState)(defaultSelection ?? []);
1896
1546
  const selection = selectable ? selectionProp ?? ownSelection : EMPTY;
1897
- const selectionRef = (0, react.useRef)(selection);
1898
- const changeHandlerRef = (0, react.useRef)(onSelectionChange);
1899
- (0, react.useEffect)(() => {
1900
- selectionRef.current = selection;
1901
- changeHandlerRef.current = onSelectionChange;
1902
- });
1903
- const changeSelection = (0, react.useCallback)((next) => {
1904
- selectionRef.current = next;
1905
- if (!controlled) setOwnSelection(next);
1906
- changeHandlerRef.current?.(next);
1907
- }, [controlled]);
1908
- /**
1909
- * Every mounted point, by id. A registration is a ref rather than a value:
1910
- * the value inside changes on every frame of a drag, and a registry keyed
1911
- * on it would be rebuilt just as often.
1912
- */
1913
- const points = (0, react.useRef)(/* @__PURE__ */ new Map());
1914
- const registerPoint = (0, react.useCallback)((id, entry) => {
1915
- points.current.set(id, entry);
1916
- return () => {
1917
- points.current.delete(id);
1918
- };
1919
- }, []);
1920
- /** What the current drag picked up, and where those points started. */
1921
- const dragRef = (0, react.useRef)([]);
1922
- const snapshot = (0, react.useCallback)((ids) => {
1923
- return ids.flatMap((id) => {
1924
- const registration = points.current.get(id)?.current;
1925
- return registration ? [{
1926
- id,
1927
- start: { ...registration.value }
1928
- }] : [];
1929
- });
1930
- }, []);
1931
- const applyDeltaTo = (0, react.useCallback)((entries, delta) => {
1932
- const withRegistration = entries.flatMap((entry) => {
1933
- const registration = points.current.get(entry.id)?.current;
1934
- return registration ? [{
1935
- ...entry,
1936
- registration
1937
- }] : [];
1938
- });
1939
- const allowed = allowedDelta(delta, withRegistration);
1940
- for (const { start, registration } of withRegistration) {
1941
- if (registration.readonly) continue;
1942
- registration.onChange?.({
1943
- x: (0, _tremolo_ui_functions.toPrecision)(start.x + allowed.x),
1944
- y: (0, _tremolo_ui_functions.toPrecision)(start.y + allowed.y)
1945
- });
1946
- }
1947
- }, []);
1948
- const beginPointDrag = (0, react.useCallback)((id, modifiers) => {
1949
- if (!selectable) {
1950
- dragRef.current = snapshot([id]);
1951
- return;
1952
- }
1953
- const current = selectionRef.current;
1954
- const additive = modifiers.ctrlKey || modifiers.metaKey;
1955
- let next;
1956
- if (additive) next = current.includes(id) ? current.filter((x) => x !== id) : [...current, id];
1957
- else if (current.includes(id)) next = current;
1958
- else next = [id];
1959
- changeSelection([...next]);
1960
- dragRef.current = next.includes(id) ? snapshot(next) : [];
1961
- }, [
1962
- selectable,
1963
- changeSelection,
1964
- snapshot
1965
- ]);
1966
- const movePointDrag = (0, react.useCallback)((delta) => applyDeltaTo(dragRef.current, delta), [applyDeltaTo]);
1967
- const nudgeSelection = (0, react.useCallback)((id, delta) => {
1968
- const ids = selectionRef.current.includes(id) ? selectionRef.current : [id];
1969
- applyDeltaTo(snapshot(ids), delta);
1970
- }, [applyDeltaTo, snapshot]);
1971
- const isPointElement = (0, react.useCallback)((element) => {
1972
- if (!element) return false;
1973
- for (const [, entry] of points.current) if (entry.current.element?.contains(element)) return true;
1974
- return false;
1975
- }, []);
1976
- const nudgeFocusedPoint = (0, react.useCallback)((axis, direction, modifiers) => {
1977
- const container = containerRef.current;
1978
- const active = container?.ownerDocument.activeElement;
1979
- if (!container || !active || !container.contains(active)) return false;
1980
- for (const [id, entry] of points.current) {
1981
- const { element, wheel, readonly, onChange, value } = entry.current;
1982
- if (!element?.contains(active)) continue;
1983
- if (!wheel || readonly || !onChange) return false;
1984
- const next = (0, _tremolo_ui_functions.applyDelta)(value[axis], direction, wheel, AXIS, modifiers);
1985
- nudgeSelection(id, {
1986
- x: axis === "x" ? next - value.x : 0,
1987
- y: axis === "y" ? next - value.y : 0
1988
- });
1989
- return true;
1990
- }
1991
- return false;
1992
- }, [nudgeSelection]);
1993
1547
  const [selectionBox, setSelectionBox] = (0, react.useState)(null);
1994
- const selectionBoxRef = (0, react.useRef)(null);
1995
- selectionBoxRef.current ??= (0, _tremolo_ui_dom.createSelectionBox)({
1996
- *items() {
1997
- for (const [id, entry] of points.current) {
1998
- const { x, y } = entry.current.value;
1999
- yield [id, [x, y]];
2000
- }
2001
- },
2002
- onBoxChange: setSelectionBox
2003
- });
1548
+ const [editor] = (0, react.useState)(() => (0, _tremolo_ui_dom.createPointsEditor)({ onSelectionBoxChange: setSelectionBox }));
2004
1549
  (0, react.useEffect)(() => {
2005
- selectionBoxRef.current?.update({ onSelectionChange: changeSelection });
2006
- }, [changeSelection]);
2007
- (0, react.useEffect)(() => {
2008
- const box = selectionBoxRef.current;
2009
- return () => box?.destroy();
2010
- }, []);
2011
- const beginSelectionBox = (0, react.useCallback)((at, modifiers) => {
2012
- if (!selectable) return;
2013
- selectionBoxRef.current?.begin([at.x, at.y], {
2014
- additive: modifiers.ctrlKey || modifiers.metaKey,
2015
- selection: selectionRef.current
1550
+ editor.update({
1551
+ selectable,
1552
+ selection,
1553
+ onSelectionChange: (next) => {
1554
+ if (!controlled) setOwnSelection(next);
1555
+ onSelectionChange?.(next);
1556
+ }
2016
1557
  });
2017
- }, [selectable]);
2018
- const moveSelectionBox = (0, react.useCallback)((to) => {
2019
- selectionBoxRef.current?.move([to.x, to.y]);
2020
- }, []);
2021
- const endSelectionBox = (0, react.useCallback)(() => {
2022
- if (!selectionBoxRef.current?.end()) return;
2023
- const [first] = selectionRef.current;
2024
- if (first) points.current.get(first)?.current.element?.focus();
2025
- }, []);
1558
+ });
1559
+ (0, react.useEffect)(() => () => editor.destroy(), [editor]);
2026
1560
  const context = (0, react.useMemo)(() => ({
2027
1561
  disabled,
2028
1562
  readonly,
@@ -2033,16 +1567,8 @@ const PointsEditor = {
2033
1567
  containerRef,
2034
1568
  selectable,
2035
1569
  selection,
2036
- registerPoint,
2037
- isPointElement,
2038
- beginPointDrag,
2039
- movePointDrag,
2040
- nudgeSelection,
2041
- nudgeFocusedPoint,
2042
- selectionBox,
2043
- beginSelectionBox,
2044
- moveSelectionBox,
2045
- endSelectionBox
1570
+ editor,
1571
+ selectionBox
2046
1572
  }), [
2047
1573
  disabled,
2048
1574
  readonly,
@@ -2052,16 +1578,8 @@ const PointsEditor = {
2052
1578
  cursor,
2053
1579
  selectable,
2054
1580
  selection,
2055
- registerPoint,
2056
- isPointElement,
2057
- beginPointDrag,
2058
- movePointDrag,
2059
- nudgeSelection,
2060
- nudgeFocusedPoint,
2061
- selectionBox,
2062
- beginSelectionBox,
2063
- moveSelectionBox,
2064
- endSelectionBox
1581
+ editor,
1582
+ selectionBox
2065
1583
  ]);
2066
1584
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PointsEditorProvider, {
2067
1585
  value: context,
@@ -2071,8 +1589,6 @@ const PointsEditor = {
2071
1589
  "data-disabled": disabled ? "" : void 0,
2072
1590
  "data-readonly": readonly ? "" : void 0,
2073
1591
  style: {
2074
- "--width": cssLength(width),
2075
- "--height": cssLength(height),
2076
1592
  position: "relative",
2077
1593
  ...style
2078
1594
  },
@@ -2097,36 +1613,30 @@ function useSliderContext(selector) {
2097
1613
  }
2098
1614
  //#endregion
2099
1615
  //#region src/components/Slider/MarksOption.tsx
2100
- function MarksOption({ value, mark = true, label, thickness, length, gap, labelWidth, classes, styles, className, style, ...props }) {
1616
+ function MarksOption({ value, mark = true, label, thickness, length, gap, classes, styles, className, style, ...props }) {
2101
1617
  useCheckPlacement("Slider.MarksOption", "Slider.Marks");
2102
1618
  const min = useSliderContext((s) => s.min);
2103
1619
  const max = useSliderContext((s) => s.max);
2104
1620
  const scale = useSliderContext((s) => s.scale);
2105
1621
  const vertical = useSliderContext((s) => s.vertical);
2106
1622
  const reverse = useSliderContext((s) => s.reverse);
2107
- const calcPercent = (0, react.useCallback)((value) => {
2108
- const percent = scale.normalize(value, min, max) * 100;
2109
- return (0, _tremolo_ui_functions.toFixed)((0, _tremolo_ui_functions.xor)(vertical, reverse) ? 100 - percent : percent);
2110
- }, [
2111
- vertical,
2112
- reverse,
2113
- max,
1623
+ const percent = (0, _tremolo_ui_dom.valuePercent)(value, {
2114
1624
  min,
1625
+ max,
2115
1626
  scale
2116
- ]);
1627
+ }, vertical !== reverse);
2117
1628
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2118
1629
  className,
2119
1630
  style: {
2120
- "--thickness": cssLength(thickness),
2121
- "--length": cssLength(length),
2122
- "--gap": cssLength(gap),
2123
- "--label-width": cssLength(labelWidth),
1631
+ "--thickness": (0, _tremolo_ui_dom.cssLength)(thickness),
1632
+ "--length": (0, _tremolo_ui_dom.cssLength)(length),
1633
+ "--gap": (0, _tremolo_ui_dom.cssLength)(gap),
2124
1634
  position: "absolute",
2125
1635
  translate: vertical ? "var(--translate, 0 -50%)" : "var(--translate, -50% 0)",
2126
1636
  zIndex: 10,
2127
1637
  ...style,
2128
- left: !vertical ? `${calcPercent(value)}%` : void 0,
2129
- top: vertical ? `${calcPercent(value)}%` : void 0
1638
+ left: !vertical ? `${percent}%` : void 0,
1639
+ top: vertical ? `${percent}%` : void 0
2130
1640
  },
2131
1641
  "data-orientation": vertical ? "vertical" : "horizontal",
2132
1642
  ...props,
@@ -2142,23 +1652,6 @@ function MarksOption({ value, mark = true, label, thickness, length, gap, labelW
2142
1652
  });
2143
1653
  }
2144
1654
  //#endregion
2145
- //#region src/components/Slider/type.ts
2146
- function generateOptionsList(options, min, max, step) {
2147
- const { per, mark = true, label = true } = typeof options === "object" ? options : { per: options };
2148
- const optionsList = [];
2149
- const interval = per === "step" ? step : per;
2150
- const count = Math.floor(max / interval) - Math.ceil(min / interval) + 1;
2151
- for (let i = 0; i < count; i++) {
2152
- const value = (0, _tremolo_ui_functions.toFixed)(interval * (Math.ceil(min / interval) + i), (0, _tremolo_ui_functions.decimalPart)(interval)?.length);
2153
- optionsList.push({
2154
- value,
2155
- mark,
2156
- label
2157
- });
2158
- }
2159
- return optionsList;
2160
- }
2161
- //#endregion
2162
1655
  //#region src/components/Slider/Marks.tsx
2163
1656
  function Marks({ gap, options, children, className, style, ...props }) {
2164
1657
  const min = useSliderContext((s) => s.min);
@@ -2166,13 +1659,13 @@ function Marks({ gap, options, children, className, style, ...props }) {
2166
1659
  const step = useSliderContext((s) => s.step);
2167
1660
  const vertical = useSliderContext((s) => s.vertical);
2168
1661
  const reverse = useSliderContext((s) => s.reverse);
2169
- const optionsList = options ? generateOptionsList(options, min, max, step) : [];
2170
- if ((0, _tremolo_ui_functions.xor)(vertical, reverse)) optionsList.reverse();
1662
+ const optionsList = options ? (0, _tremolo_ui_dom.sliderMarks)(options, min, max, step) : [];
1663
+ if (vertical !== reverse) optionsList.reverse();
2171
1664
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2172
1665
  className,
2173
1666
  "data-orientation": vertical ? "vertical" : "horizontal",
2174
1667
  style: {
2175
- "--gap": cssLength(gap),
1668
+ "--gap": (0, _tremolo_ui_dom.cssLength)(gap),
2176
1669
  position: "relative",
2177
1670
  ...style
2178
1671
  },
@@ -2252,12 +1745,12 @@ const Track = /* @__PURE__ */ (0, react.forwardRef)(function Track({ length, thi
2252
1745
  className,
2253
1746
  "data-disabled": disabled ? "" : void 0,
2254
1747
  "data-orientation": vertical ? "vertical" : "horizontal",
2255
- "data-flipped": (0, _tremolo_ui_functions.xor)(vertical, reverse) ? "" : void 0,
1748
+ "data-flipped": vertical !== reverse ? "" : void 0,
2256
1749
  style: {
2257
1750
  "--active": active,
2258
1751
  "--inactive": inactive,
2259
- "--length": cssLength(length),
2260
- "--thickness": cssLength(thickness),
1752
+ "--length": (0, _tremolo_ui_dom.cssLength)(length),
1753
+ "--thickness": (0, _tremolo_ui_dom.cssLength)(thickness),
2261
1754
  "--percent": `${percent}%`,
2262
1755
  position: "relative",
2263
1756
  ...style
@@ -2281,7 +1774,7 @@ const WHEEL_OPTIONS$1 = { requireFocus: true };
2281
1774
  * Customizable slider
2282
1775
  */
2283
1776
  const Slider = {
2284
- Root: /* @__PURE__ */ (0, react.forwardRef)(({ value, min, max, step = 1, scale = _tremolo_ui_functions.linearScale, vertical = false, reverse = false, externalStyles: _externalStyles, wheel = DEFAULT_WHEEL_OPTIONS, keyboard = DEFAULT_KEYBOARD_OPTIONS, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, disabled = false, readonly = false, onChange, onDragStart, onDragEnd, className, style, children, onFocus, onBlur, onPointerDown, onKeyDown, ...props }, forwardedRef) => {
1777
+ Root: /* @__PURE__ */ (0, react.forwardRef)(({ value, min, max, step = 1, scale = _tremolo_ui_functions.linearScale, vertical = false, reverse = false, externalStyles: _externalStyles, wheel = _tremolo_ui_dom.DEFAULT_WHEEL_OPTIONS, keyboard = _tremolo_ui_dom.DEFAULT_KEYBOARD_OPTIONS, dragSensitivity = _tremolo_ui_dom.DEFAULT_DRAG_SENSITIVITY, disabled = false, readonly = false, onChange, onDragStart, onDragEnd, className, style, children, onFocus, onBlur, onPointerDown, onKeyDown, ...props }, forwardedRef) => {
2285
1778
  const trackRef = (0, react.useRef)(null);
2286
1779
  const thumbRef = (0, react.useRef)(null);
2287
1780
  const externalStyles = {
@@ -2289,10 +1782,12 @@ const Slider = {
2289
1782
  ..._externalStyles
2290
1783
  };
2291
1784
  const inactive = disabled || readonly;
2292
- const p = (0, _tremolo_ui_functions.toFixed)(scale.normalize(value, min, max) * 100);
2293
- const rev = (0, _tremolo_ui_functions.toFixed)(100 - p);
2294
- const displayReversed = (0, react.useMemo)(() => (0, _tremolo_ui_functions.xor)(vertical, reverse), [vertical, reverse]);
2295
- const percent = displayReversed ? rev : p;
1785
+ const displayReversed = vertical !== reverse;
1786
+ const percent = (0, _tremolo_ui_dom.valuePercent)(value, {
1787
+ min,
1788
+ max,
1789
+ scale
1790
+ }, displayReversed);
2296
1791
  const axis = (0, react.useMemo)(() => ({
2297
1792
  min,
2298
1793
  max,
@@ -2313,18 +1808,11 @@ const Slider = {
2313
1808
  wheel
2314
1809
  });
2315
1810
  const handleKeyDown = (0, react.useCallback)((event) => {
2316
- const key = event.key;
2317
- if (![
2318
- "ArrowRight",
2319
- "ArrowLeft",
2320
- "ArrowUp",
2321
- "ArrowDown"
2322
- ].includes(key)) return;
1811
+ const direction = (0, _tremolo_ui_dom.arrowKeyDirection)(event.key);
1812
+ if (direction === null) return;
2323
1813
  event.preventDefault();
2324
1814
  if (!keyboard || !onChange || inactive) return;
2325
- let direction = key === "ArrowRight" || key === "ArrowUp" ? 1 : -1;
2326
- if (reverse) direction *= -1;
2327
- onChange((0, _tremolo_ui_functions.applyDelta)(value, direction, keyboard, axis, event));
1815
+ onChange((0, _tremolo_ui_dom.applyDelta)(value, reverse ? -direction : direction, keyboard, axis, event));
2328
1816
  }, [
2329
1817
  keyboard,
2330
1818
  onChange,
@@ -2339,7 +1827,7 @@ const Slider = {
2339
1827
  baseElementRef: trackRef,
2340
1828
  cursor: inactive ? void 0 : externalStyles.cursor,
2341
1829
  shouldStart: () => !inactive,
2342
- sensitivity: (state) => (0, _tremolo_ui_functions.selectModifier)(dragSensitivity, state.event).value,
1830
+ sensitivity: (state) => (0, _tremolo_ui_dom.selectModifier)(dragSensitivity, state.event).value,
2343
1831
  updateOnPointerDown: true,
2344
1832
  onChange: (v) => {
2345
1833
  if (inactive) return;
@@ -2357,15 +1845,10 @@ const Slider = {
2357
1845
  });
2358
1846
  const wheelRefCallback = useWheel((event) => {
2359
1847
  if (!wheel || !onChange || inactive) return;
1848
+ const direction = (0, _tremolo_ui_dom.wheelDirection)(event, { horizontal: !vertical });
1849
+ if (direction === null) return;
2360
1850
  event.preventDefault();
2361
- let direction;
2362
- if (!vertical && event.deltaX !== 0) direction = event.deltaX > 0 ? 1 : -1;
2363
- else {
2364
- if (event.deltaY === 0) return;
2365
- direction = event.deltaY > 0 ? -1 : 1;
2366
- }
2367
- if (reverse) direction *= -1;
2368
- onChange((0, _tremolo_ui_functions.applyDelta)(value, direction, wheel, axis, event));
1851
+ onChange((0, _tremolo_ui_dom.applyDelta)(value, reverse ? -direction : direction, wheel, axis, event));
2369
1852
  }, WHEEL_OPTIONS$1);
2370
1853
  const rootRefCallback = require_compose_refs.useComposedRefs(dragRefCallback, wheelRefCallback);
2371
1854
  const context = (0, react.useMemo)(() => ({
@@ -2450,16 +1933,13 @@ function useXYPadContext(selector) {
2450
1933
  }
2451
1934
  //#endregion
2452
1935
  //#region src/components/XYPad/Area.tsx
2453
- const Area = /* @__PURE__ */ (0, react.forwardRef)(function Area({ width, height, color, children, className, style, ...props }, forwardedRef) {
1936
+ const Area = /* @__PURE__ */ (0, react.forwardRef)(function Area({ children, className, style, ...props }, forwardedRef) {
2454
1937
  const { areaRef } = useXYPadContext();
2455
1938
  const composedRef = require_compose_refs.useComposedRefs(forwardedRef, areaRef);
2456
1939
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2457
1940
  ref: composedRef,
2458
1941
  className,
2459
1942
  style: {
2460
- "--color": color,
2461
- "--width": cssLength(width),
2462
- "--height": cssLength(height),
2463
1943
  position: "relative",
2464
1944
  ...style
2465
1945
  },
@@ -2544,7 +2024,7 @@ const WHEEL_OPTIONS = { requireFocus: true };
2544
2024
  * Simple XYPad
2545
2025
  */
2546
2026
  const XYPad = {
2547
- Root: /* @__PURE__ */ (0, react.forwardRef)(({ value, min: _min, max: _max, step: _step = 1, scale: _scale = _tremolo_ui_functions.linearScale, reverse: _reverse = false, wheel = DEFAULT_WHEEL_OPTIONS, keyboard = DEFAULT_KEYBOARD_OPTIONS, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, className, style, externalStyles: _externalStyles, disabled = false, readonly = false, onChange, onDragStart, onDragEnd, onPointerDown, onKeyDown, onFocus, onBlur, children, ...props }, forwardedRef) => {
2027
+ Root: /* @__PURE__ */ (0, react.forwardRef)(({ value, min: _min, max: _max, step: _step = 1, scale: _scale = _tremolo_ui_functions.linearScale, reverse: _reverse = false, wheel = _tremolo_ui_dom.DEFAULT_WHEEL_OPTIONS, keyboard = _tremolo_ui_dom.DEFAULT_KEYBOARD_OPTIONS, dragSensitivity = _tremolo_ui_dom.DEFAULT_DRAG_SENSITIVITY, className, style, externalStyles: _externalStyles, disabled = false, readonly = false, onChange, onDragStart, onDragEnd, onPointerDown, onKeyDown, onFocus, onBlur, children, ...props }, forwardedRef) => {
2548
2028
  const rootRef = (0, react.useRef)(null);
2549
2029
  const areaRef = (0, react.useRef)(null);
2550
2030
  const thumbRef = (0, react.useRef)(null);
@@ -2558,10 +2038,11 @@ const XYPad = {
2558
2038
  const step = (0, react.useMemo)(() => (0, _tremolo_ui_dom.toXY)(_step), [_step]);
2559
2039
  const scale = (0, react.useMemo)(() => (0, _tremolo_ui_dom.toXY)(_scale), [_scale]);
2560
2040
  const reverse = (0, react.useMemo)(() => (0, _tremolo_ui_dom.toXY)(_reverse), [_reverse]);
2561
- const percent = (0, react.useMemo)(() => {
2562
- const normalized = [0, 1].map((i) => scale[i].normalize(value[i], min[i], max[i]));
2563
- return [0, 1].map((i) => (0, _tremolo_ui_functions.toFixed)((reverse[i] ? 1 - normalized[i] : normalized[i]) * 100));
2564
- }, [
2041
+ const percent = (0, react.useMemo)(() => [0, 1].map((i) => (0, _tremolo_ui_dom.valuePercent)(value[i], {
2042
+ min: min[i],
2043
+ max: max[i],
2044
+ scale: scale[i]
2045
+ }, reverse[i])), [
2565
2046
  value,
2566
2047
  min,
2567
2048
  max,
@@ -2597,26 +2078,18 @@ const XYPad = {
2597
2078
  });
2598
2079
  const withAxis = (0, react.useCallback)((axis, next) => axis === 0 ? [next, value[1]] : [value[0], next], [value]);
2599
2080
  /** @param i 0 = x, 1 = y */
2600
- const nudge = (0, react.useCallback)((i, direction, option, modifiers) => withAxis(i, (0, _tremolo_ui_functions.applyDelta)(value[i], direction, option, axis[i], modifiers)), [
2081
+ const nudge = (0, react.useCallback)((i, direction, option, modifiers) => withAxis(i, (0, _tremolo_ui_dom.applyDelta)(value[i], direction, option, axis[i], modifiers)), [
2601
2082
  value,
2602
2083
  axis,
2603
2084
  withAxis
2604
2085
  ]);
2605
2086
  const handleKeyDown = (0, react.useCallback)((event) => {
2606
- const key = event.key;
2607
- if (![
2608
- "ArrowRight",
2609
- "ArrowLeft",
2610
- "ArrowUp",
2611
- "ArrowDown"
2612
- ].includes(key)) return;
2613
- const i = key === "ArrowRight" || key === "ArrowLeft" ? 0 : 1;
2087
+ const move = (0, _tremolo_ui_dom.arrowKeyMove)(event.key);
2088
+ if (!move) return;
2614
2089
  event.preventDefault();
2615
2090
  if (!onChange || inactive || !keyboard) return;
2616
- let direction = 1;
2617
- if (key === "ArrowLeft" || key === "ArrowUp") direction *= -1;
2618
- if (reverse[i]) direction *= -1;
2619
- onChange(nudge(i, direction, keyboard, event));
2091
+ const { axis: i, direction } = move;
2092
+ onChange(nudge(i, reverse[i] ? -direction : direction, keyboard, event));
2620
2093
  }, [
2621
2094
  onChange,
2622
2095
  inactive,
@@ -2627,7 +2100,7 @@ const XYPad = {
2627
2100
  const { refCallback: dragRefCallback } = useDragValue({
2628
2101
  axis,
2629
2102
  baseElementRef: areaRef,
2630
- sensitivity: (state) => (0, _tremolo_ui_functions.selectModifier)(dragSensitivity, state.event).value,
2103
+ sensitivity: (state) => (0, _tremolo_ui_dom.selectModifier)(dragSensitivity, state.event).value,
2631
2104
  updateOnPointerDown: true,
2632
2105
  cursor: inactive ? void 0 : externalStyles.cursor,
2633
2106
  shouldStart: () => !inactive,
@@ -2647,15 +2120,11 @@ const XYPad = {
2647
2120
  });
2648
2121
  const wheelRefCallback = useWheel((event) => {
2649
2122
  if (!onChange || inactive || !wheel) return;
2650
- const horizontal = event.deltaX !== 0;
2651
- const delta = horizontal ? event.deltaX : event.deltaY;
2652
- if (delta === 0) return;
2653
- const i = horizontal || event.shiftKey ? 0 : 1;
2123
+ const move = (0, _tremolo_ui_dom.wheelMove)(event);
2124
+ if (!move) return;
2654
2125
  event.preventDefault();
2655
- let direction = 1;
2656
- if (delta < 0) direction *= -1;
2657
- if (reverse[i]) direction *= -1;
2658
- onChange(nudge(i, direction, wheel, event));
2126
+ const { axis: i, direction } = move;
2127
+ onChange(nudge(i, reverse[i] ? -direction : direction, wheel, event));
2659
2128
  }, WHEEL_OPTIONS);
2660
2129
  const rootRefCallback = require_compose_refs.useComposedRefs(rootRef, dragRefCallback, wheelRefCallback);
2661
2130
  const context = (0, react.useMemo)(() => ({
@@ -2740,6 +2209,82 @@ function useAnimationFrame(callback = () => {}, deps = []) {
2740
2209
  }, [runCallback, ...deps]);
2741
2210
  }
2742
2211
  //#endregion
2212
+ //#region src/hooks/useDrag.ts
2213
+ /**
2214
+ * Track a pointer drag on an element.
2215
+ *
2216
+ * @returns a ref callback to attach to the element being dragged
2217
+ */
2218
+ function useDrag({ threshold = 1, cursor, pointerLock, onDrag, onDragStart, onDragEnd }) {
2219
+ const dragHandler = useCallbackRef(onDrag);
2220
+ const dragStartHandler = useCallbackRef(onDragStart);
2221
+ const dragEndHandler = useCallbackRef(onDragEnd);
2222
+ const [node, setNode] = (0, react.useState)(null);
2223
+ (0, react.useEffect)(() => {
2224
+ if (!node) return;
2225
+ const instance = (0, _tremolo_ui_dom.createDrag)(node, {
2226
+ threshold,
2227
+ cursor,
2228
+ pointerLock,
2229
+ onDragStart: (state) => dragStartHandler(state),
2230
+ onDrag: (state) => dragHandler(state.x, state.y, state.deltaX, state.deltaY, state),
2231
+ onDragEnd: (state) => dragEndHandler(state)
2232
+ });
2233
+ return () => instance.destroy();
2234
+ }, [
2235
+ node,
2236
+ threshold,
2237
+ cursor,
2238
+ pointerLock,
2239
+ dragHandler,
2240
+ dragStartHandler,
2241
+ dragEndHandler
2242
+ ]);
2243
+ return setNode;
2244
+ }
2245
+ //#endregion
2246
+ //#region src/hooks/useEventListener.ts
2247
+ function useEventListener(target, event, handler, options) {
2248
+ const listener = useCallbackRef(handler);
2249
+ const cleanupRef = (0, react.useRef)(() => {});
2250
+ (0, react.useEffect)(() => {
2251
+ const node = typeof target === "function" ? target() : target ?? document;
2252
+ if (!node) return;
2253
+ node.addEventListener(event, listener, options);
2254
+ const cleanup = () => {
2255
+ node.removeEventListener(event, listener, options);
2256
+ };
2257
+ cleanupRef.current = cleanup;
2258
+ return () => {
2259
+ cleanup();
2260
+ if (cleanupRef.current === cleanup) cleanupRef.current = () => {};
2261
+ };
2262
+ }, [
2263
+ event,
2264
+ target,
2265
+ options,
2266
+ listener
2267
+ ]);
2268
+ return (0, react.useCallback)(() => cleanupRef.current(), []);
2269
+ }
2270
+ //#endregion
2271
+ //#region src/hooks/useInterval.ts
2272
+ function useInterval(callback, delay) {
2273
+ const savedCallback = (0, react.useRef)(callback);
2274
+ (0, react.useEffect)(() => {
2275
+ savedCallback.current = callback;
2276
+ }, [callback]);
2277
+ (0, react.useEffect)(() => {
2278
+ if (delay === null) return;
2279
+ const id = setInterval(() => {
2280
+ savedCallback.current();
2281
+ }, delay);
2282
+ return () => {
2283
+ clearInterval(id);
2284
+ };
2285
+ }, [delay]);
2286
+ }
2287
+ //#endregion
2743
2288
  //#region src/hooks/useMIDIAccess.ts
2744
2289
  const INITIAL_STATE = {
2745
2290
  midiAccess: null,
@@ -2877,40 +2422,20 @@ function useMIDIMessage(midiAccess, onMIDIMessage) {
2877
2422
  }
2878
2423
  //#endregion
2879
2424
  exports.AnimationCanvas = AnimationCanvas;
2880
- exports.DEFAULT_DRAG_SENSITIVITY = DEFAULT_DRAG_SENSITIVITY;
2881
- exports.DEFAULT_KEYBOARD_OPTIONS = DEFAULT_KEYBOARD_OPTIONS;
2882
- exports.DEFAULT_WHEEL_OPTIONS = DEFAULT_WHEEL_OPTIONS;
2425
+ exports.DropZone = DropZone;
2426
+ exports.FileInput = FileInput;
2883
2427
  exports.Knob = Knob;
2884
- Object.defineProperty(exports, "NOT_SUPPORTED", {
2885
- enumerable: true,
2886
- get: function() {
2887
- return _tremolo_ui_dom.NOT_SUPPORTED;
2888
- }
2889
- });
2890
2428
  exports.NumberInput = NumberInput;
2891
- Object.defineProperty(exports, "PERMISSION_DENIED", {
2892
- enumerable: true,
2893
- get: function() {
2894
- return _tremolo_ui_dom.PERMISSION_DENIED;
2895
- }
2896
- });
2897
- exports.PITCH_BEND_CENTER = _tremolo_ui_dom.PITCH_BEND_CENTER;
2898
2429
  exports.Piano = Piano;
2899
2430
  exports.PointsEditor = PointsEditor;
2900
- exports.SHORTCUTS = SHORTCUTS;
2901
2431
  exports.Slider = Slider;
2902
- Object.defineProperty(exports, "UNAVAILABLE", {
2903
- enumerable: true,
2904
- get: function() {
2905
- return _tremolo_ui_dom.UNAVAILABLE;
2906
- }
2907
- });
2908
2432
  exports.XYPad = XYPad;
2909
- exports.clampPoint = clampPoint;
2910
2433
  exports.useAnimationFrame = useAnimationFrame;
2911
2434
  exports.useDrag = useDrag;
2912
2435
  exports.useDragValue = useDragValue;
2436
+ exports.useDropZone = useDropZone;
2913
2437
  exports.useEventListener = useEventListener;
2438
+ exports.useFileInputContext = useFileInputContext;
2914
2439
  exports.useInterval = useInterval;
2915
2440
  exports.useKnobContext = useKnobContext;
2916
2441
  exports.useLongPress = useLongPress;