@tremolo-ui/react 0.4.0 → 0.5.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 (62) hide show
  1. package/dist/index.cjs +1125 -1437
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +58 -93
  4. package/dist/index.d.cts +561 -243
  5. package/dist/index.d.cts.map +1 -1
  6. package/dist/index.d.ts +561 -243
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1125 -1438
  9. package/dist/index.js.map +1 -1
  10. package/package.json +9 -10
  11. package/src/components/AnimationCanvas/index.tsx +84 -155
  12. package/src/components/Knob/ActiveLine.tsx +6 -6
  13. package/src/components/Knob/InactiveLine.tsx +9 -11
  14. package/src/components/Knob/SVGRoot.tsx +9 -18
  15. package/src/components/Knob/context.tsx +57 -89
  16. package/src/components/Knob/index.css +6 -2
  17. package/src/components/Knob/index.tsx +90 -61
  18. package/src/components/NumberInput/DecrementStepper.tsx +17 -50
  19. package/src/components/NumberInput/IncrementStepper.tsx +17 -50
  20. package/src/components/NumberInput/InputField.tsx +123 -0
  21. package/src/components/NumberInput/Stepper.tsx +90 -12
  22. package/src/components/NumberInput/context.tsx +74 -146
  23. package/src/components/NumberInput/index.css +38 -102
  24. package/src/components/NumberInput/index.tsx +252 -98
  25. package/src/components/NumberInput/stepperButton.tsx +90 -0
  26. package/src/components/Piano/index.tsx +272 -288
  27. package/src/components/Piano/keyboardShortcuts.ts +34 -4
  28. package/src/components/PointsEditor/Background.tsx +8 -2
  29. package/src/components/PointsEditor/Container.tsx +19 -9
  30. package/src/components/PointsEditor/Point.tsx +104 -24
  31. package/src/components/PointsEditor/context.tsx +28 -74
  32. package/src/components/PointsEditor/index.css +5 -0
  33. package/src/components/PointsEditor/index.tsx +77 -43
  34. package/src/components/Slider/{Scale.tsx → Marks.tsx} +8 -8
  35. package/src/components/Slider/{ScaleOption.tsx → MarksOption.tsx} +16 -18
  36. package/src/components/Slider/Thumb.tsx +55 -68
  37. package/src/components/Slider/Track.tsx +15 -15
  38. package/src/components/Slider/context.tsx +36 -51
  39. package/src/components/Slider/index.css +18 -5
  40. package/src/components/Slider/index.tsx +127 -146
  41. package/src/components/Slider/type.ts +4 -4
  42. package/src/components/XYPad/Area.tsx +16 -7
  43. package/src/components/XYPad/Thumb.tsx +62 -74
  44. package/src/components/XYPad/context.tsx +46 -0
  45. package/src/components/XYPad/index.css +12 -0
  46. package/src/components/XYPad/index.tsx +190 -230
  47. package/src/hooks/useDrag.ts +1 -1
  48. package/src/hooks/useDragValue.ts +149 -0
  49. package/src/hooks/useEventListener.ts +1 -1
  50. package/src/hooks/useWheel.ts +29 -9
  51. package/src/index.ts +15 -9
  52. package/src/components/AnimationCanvas/canvas.ts +0 -46
  53. package/src/components/NumberInput/InternalInput.tsx +0 -216
  54. package/src/components/NumberInput/type.ts +0 -58
  55. package/src/components/Piano/KeyLabel.tsx +0 -51
  56. package/src/components/Piano/context.tsx +0 -73
  57. package/src/components/Piano/key.tsx +0 -205
  58. package/src/components/_util/type.ts +0 -1
  59. package/src/hooks/useDragWithElement.ts +0 -100
  60. package/src/hooks/usePianoDrag.ts +0 -75
  61. package/src/hooks/useRefCallbackEvent.ts +0 -34
  62. /package/src/hooks/{useCallbackRef.ts → _internal/useCallbackRef.ts} +0 -0
package/dist/index.js CHANGED
@@ -1,146 +1,77 @@
1
1
  import clsx from "clsx";
2
- import React, { createContext, createRef, forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useInsertionEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
+ import { createContext, forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useInsertionEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
3
+ import { NOT_SUPPORTED, PERMISSION_DENIED, createAnimationCanvas, createDrag, createDragValue, createMIDIAccess, createMIDIInput, createMIDIMessage, createPianoInput, createWheel, elementMapping, relativeMapping, toXY } from "@tremolo-ui/dom";
3
4
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
- import { clamp, decimalPart, isBlackKey, isWhiteKey, normalizeValue, noteKey, noteKeys, radian, rawValue, stepValue, styleHelper, toFixed, xor } from "@tremolo-ui/functions";
5
- import { NOT_SUPPORTED, PERMISSION_DENIED, createDrag, createMIDIAccess, createMIDIInput, createMIDIMessage, createWheel } from "@tremolo-ui/dom";
6
- import { createStore, useStore } from "zustand";
7
- //#region src/components/AnimationCanvas/canvas.ts
8
- const drawingState = [
9
- "strokeStyle",
10
- "fillStyle",
11
- "globalAlpha",
12
- "lineWidth",
13
- "lineCap",
14
- "lineJoin",
15
- "miterLimit",
16
- "lineDashOffset",
17
- "shadowOffsetX",
18
- "shadowOffsetY",
19
- "shadowBlur",
20
- "shadowColor",
21
- "globalCompositeOperation",
22
- "font",
23
- "textAlign",
24
- "textBaseline",
25
- "direction",
26
- "imageSmoothingEnabled"
27
- ];
28
- function setDprConfig(canvas, context, width, height, dpr) {
29
- canvas.width = width * dpr;
30
- canvas.height = height * dpr;
31
- context.setTransform(1, 0, 0, 1, 0, 0);
32
- context.scale(dpr, dpr);
33
- canvas.style.width = `${width}px`;
34
- canvas.style.height = `${height}px`;
35
- }
36
- //#endregion
5
+ import { applyDelta, blackKeyWidth, clamp, decimalPart, formatValue, getNoteRangeArray, isWhiteKey, linearScale, noteKey, notePosition, parseValue, pianoWidth, radian, styleHelper, toFixed, xor } from "@tremolo-ui/functions";
37
6
  //#region src/components/AnimationCanvas/index.tsx
38
- function AnimationCanvas({ draw, init, animate = true, options, width: _width = 100, height: _height = 100, relativeSize, reduceFlickering = true, className, onContextMenu = (event) => event.preventDefault(), ...props }) {
39
- const canvasRef = useRef(null);
40
- const memoCanvasRef = useRef(null);
41
- const reqIdRef = useRef(-1);
42
- const widthRef = useRef(0);
43
- const heightRef = useRef(0);
44
- const deltaMemoRef = useRef(-1);
45
- const startTimeRef = useRef(-1);
46
- const loop = useCallback((context, width, height, count) => {
47
- const now = performance.now();
48
- const deltaTime = now - deltaMemoRef.current;
49
- const elapsedTime = now - startTimeRef.current;
50
- deltaMemoRef.current = now;
51
- if (animate) reqIdRef.current = requestAnimationFrame(() => loop(context, width, height, count + 1));
52
- draw(context, {
53
- width: width.current,
54
- height: height.current,
55
- count: count + 1,
56
- deltaTime,
57
- elapsedTime,
58
- fps: 1e3 / deltaTime
7
+ function AnimationCanvas({ draw, init, animate = true, options, width = 100, height = 100, relativeSize = false, reduceFlickering = true, className, onContextMenu = (event) => event.preventDefault(), ...props }) {
8
+ const [node, setNode] = useState(null);
9
+ const latest = useRef({
10
+ draw,
11
+ init,
12
+ animate,
13
+ width,
14
+ height,
15
+ reduceFlickering,
16
+ options
17
+ });
18
+ const instanceRef = useRef(null);
19
+ /** Set while the instance below is newer than the effect that pushes into it. */
20
+ const justCreated = useRef(false);
21
+ useEffect(() => {
22
+ if (!node) return;
23
+ const current = latest.current;
24
+ const instance = createAnimationCanvas(node, {
25
+ draw: (context, frame) => latest.current.draw(context, frame),
26
+ init: (context, size) => latest.current.init?.(context, size),
27
+ animate: current.animate,
28
+ size: {
29
+ width: current.width,
30
+ height: current.height
31
+ },
32
+ reduceFlickering: current.reduceFlickering,
33
+ relativeSize,
34
+ contextAttributes: current.options
59
35
  });
60
- }, [draw, animate]);
36
+ instanceRef.current = instance;
37
+ justCreated.current = true;
38
+ return () => {
39
+ instanceRef.current = null;
40
+ instance.destroy();
41
+ };
42
+ }, [node, relativeSize]);
61
43
  useEffect(() => {
62
- if (!canvasRef.current) return;
63
- const canvas = canvasRef.current;
64
- const context = canvas.getContext("2d", options);
65
- if (!context) throw new Error("Cannot get canvas context.");
66
- const dpr = globalThis.devicePixelRatio;
67
- const firstRendering = (width, height) => {
68
- setDprConfig(canvas, context, width, height, dpr);
69
- widthRef.current = width;
70
- heightRef.current = height;
71
- if (init) init(context, {
72
- width,
73
- height
74
- });
75
- const now = performance.now();
76
- deltaMemoRef.current = now;
77
- startTimeRef.current = now;
78
- loop(context, widthRef, heightRef, -1);
44
+ latest.current = {
45
+ draw,
46
+ init,
47
+ animate,
48
+ width,
49
+ height,
50
+ reduceFlickering,
51
+ options
79
52
  };
80
- if (relativeSize) {
81
- const parent = canvas.parentElement;
82
- if (!parent) throw new Error("Canvas doesn't have a parent element.");
83
- const memoCanvas = memoCanvasRef.current;
84
- const memoContext = memoCanvas?.getContext("2d", options);
85
- const ro = new ResizeObserver((entries) => {
86
- for (const entry of entries) {
87
- const w = entry.contentRect.width;
88
- const h = entry.contentRect.height;
89
- const contextMemo = {};
90
- if (reduceFlickering && memoCanvas && memoContext) {
91
- for (const prop of drawingState) contextMemo[prop] = context[prop];
92
- memoCanvas.width = w * dpr;
93
- memoCanvas.height = h * dpr;
94
- memoContext.scale(1 / dpr, 1 / dpr);
95
- if (canvas.width > 0 && canvas.height > 0) memoContext.drawImage(canvas, 0, 0);
96
- }
97
- setDprConfig(canvas, context, w, h, dpr);
98
- widthRef.current = w;
99
- heightRef.current = h;
100
- if (reduceFlickering && memoContext && memoCanvas && memoCanvas.width > 0 && memoCanvas.height > 0) {
101
- for (const prop of drawingState) context[prop] = contextMemo[prop];
102
- context.drawImage(memoContext.canvas, 0, 0);
103
- }
104
- if (!animate) loop(context, widthRef, heightRef, -1);
105
- }
106
- });
107
- ro.observe(parent);
108
- const width = parent.clientWidth;
109
- const height = parent.clientHeight;
110
- firstRendering(width, height);
111
- return () => {
112
- if (reqIdRef.current) cancelAnimationFrame(reqIdRef.current);
113
- ro.disconnect();
114
- };
115
- } else {
116
- const { width, height } = canvas.getBoundingClientRect();
117
- firstRendering(width, height);
118
- return () => {
119
- if (reqIdRef.current) cancelAnimationFrame(reqIdRef.current);
120
- };
53
+ if (justCreated.current) {
54
+ justCreated.current = false;
55
+ return;
121
56
  }
122
- }, [
123
- loop,
124
- init,
125
- options,
126
- reduceFlickering,
127
- relativeSize,
128
- animate
129
- ]);
130
- return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("canvas", {
57
+ instanceRef.current?.update({
58
+ animate,
59
+ size: {
60
+ width,
61
+ height
62
+ },
63
+ reduceFlickering
64
+ });
65
+ });
66
+ return /* @__PURE__ */ jsx("canvas", {
131
67
  className: clsx("tremolo-animation-canvas", className),
132
- width: relativeSize ? 0 : _width,
133
- height: relativeSize ? 0 : _height,
134
- ref: canvasRef,
68
+ ref: setNode,
135
69
  onContextMenu,
136
70
  ...props
137
- }), relativeSize && reduceFlickering && /* @__PURE__ */ jsx("canvas", {
138
- style: { display: "none" },
139
- ref: memoCanvasRef
140
- })] });
71
+ });
141
72
  }
142
73
  //#endregion
143
- //#region src/hooks/useCallbackRef.ts
74
+ //#region src/hooks/_internal/useCallbackRef.ts
144
75
  /**
145
76
  * Internal
146
77
  * @private
@@ -155,36 +86,71 @@ function useCallbackRef(callback, deps = []) {
155
86
  return useCallback((...args) => callbackRef.current?.(...args), deps);
156
87
  }
157
88
  //#endregion
158
- //#region src/hooks/useDrag.ts
89
+ //#region src/hooks/useDragValue.ts
159
90
  /**
160
- * Track a pointer drag on an element.
91
+ * Drive a value with a pointer drag.
161
92
  *
162
- * @returns a ref callback to attach to the element being dragged
93
+ * @returns a ref callback for the element that starts the drag, and whether a drag is in progress
163
94
  */
164
- function useDrag({ threshold = 1, cursor, onDrag, onDragStart, onDragEnd }) {
165
- const dragHandler = useCallbackRef(onDrag);
166
- const dragStartHandler = useCallbackRef(onDragStart);
167
- const dragEndHandler = useCallbackRef(onDragEnd);
95
+ function useDragValue(options) {
96
+ const { axis, baseElementRef, pixelRange, updateOnPointerDown, threshold, cursor } = options;
97
+ if (!baseElementRef && !options.getValue) throw new Error("useDragValue: give either baseElementRef or getValue, so that the drag has something to move");
98
+ const [dragging, setDragging] = useState(false);
99
+ const valueGetter = useCallbackRef(options.getValue);
100
+ const changeHandler = useCallbackRef(options.onChange);
101
+ const dragStartHandler = useCallbackRef(options.onDragStart);
102
+ const dragEndHandler = useCallbackRef(options.onDragEnd);
103
+ const latest = useRef(options);
104
+ const instanceRef = useRef(null);
168
105
  const [node, setNode] = useState(null);
106
+ const [pixelRangeX, pixelRangeY] = Array.isArray(pixelRange) ? pixelRange : [pixelRange, pixelRange];
169
107
  useEffect(() => {
170
108
  if (!node) return;
171
- const instance = createDrag(node, {
172
- threshold,
173
- cursor,
174
- onDragStart: () => dragStartHandler(),
175
- onDrag: ({ x, y, deltaX, deltaY }) => dragHandler(x, y, deltaX, deltaY),
176
- onDragEnd: () => dragEndHandler()
109
+ const instance = createDragValue(node, {
110
+ axis: latest.current.axis,
111
+ mapping: baseElementRef ? elementMapping(() => baseElementRef.current) : relativeMapping({ pixelRange: [pixelRangeX ?? 100, pixelRangeY ?? 100] }),
112
+ getValue: () => valueGetter() ?? [0, 0],
113
+ updateOnPointerDown: latest.current.updateOnPointerDown,
114
+ threshold: latest.current.threshold,
115
+ cursor: latest.current.cursor,
116
+ onChange: (value) => changeHandler(value),
117
+ onDragStart: (value) => {
118
+ setDragging(true);
119
+ dragStartHandler(value);
120
+ },
121
+ onDragEnd: (value) => {
122
+ setDragging(false);
123
+ dragEndHandler(value);
124
+ }
177
125
  });
178
- return () => instance.destroy();
126
+ instanceRef.current = instance;
127
+ return () => {
128
+ instanceRef.current = null;
129
+ instance.destroy();
130
+ };
179
131
  }, [
180
132
  node,
181
- threshold,
182
- cursor,
183
- dragHandler,
133
+ baseElementRef,
134
+ pixelRangeX,
135
+ pixelRangeY,
136
+ valueGetter,
137
+ changeHandler,
184
138
  dragStartHandler,
185
139
  dragEndHandler
186
140
  ]);
187
- return setNode;
141
+ useEffect(() => {
142
+ latest.current = options;
143
+ instanceRef.current?.update({
144
+ axis,
145
+ updateOnPointerDown,
146
+ threshold,
147
+ cursor
148
+ });
149
+ });
150
+ return {
151
+ refCallback: setNode,
152
+ dragging
153
+ };
188
154
  }
189
155
  //#endregion
190
156
  //#region src/hooks/useWheel.ts
@@ -193,16 +159,22 @@ function useDrag({ threshold = 1, cursor, onDrag, onDragStart, onDragEnd }) {
193
159
  *
194
160
  * The listener is not passive, so the handler may call `preventDefault()`.
195
161
  *
196
- * @returns a ref callback to attach to the element
162
+ * @returns a ref callback to attach to the element, unused when `target` is given
197
163
  */
198
- function useWheel(onWheel) {
164
+ function useWheel(onWheel, { requireFocus = false, target } = {}) {
199
165
  const wheelHandler = useCallbackRef(onWheel);
200
166
  const [node, setNode] = useState(null);
201
167
  useEffect(() => {
202
- if (!node) return;
203
- const instance = createWheel(node, (event) => wheelHandler(event));
168
+ const element = target ? target.current : node;
169
+ if (!element) return;
170
+ const instance = createWheel(element, (event) => wheelHandler(event), { requireFocus });
204
171
  return () => instance.destroy();
205
- }, [node, wheelHandler]);
172
+ }, [
173
+ node,
174
+ target,
175
+ wheelHandler,
176
+ requireFocus
177
+ ]);
206
178
  return setNode;
207
179
  }
208
180
  //#endregion
@@ -213,68 +185,92 @@ function addUserSelectNone() {
213
185
  function removeUserSelectNone() {
214
186
  window.document.body.classList.remove("tremolo-user-select-none");
215
187
  }
216
- function calcDrawingState({ value, min, max, skew, startValue, angleRange }) {
217
- const p = normalizeValue(value, min, max, skew);
218
- const s = normalizeValue(startValue, min, max, skew);
188
+ //#endregion
189
+ //#region src/components/_util/composeRefs.tsx
190
+ /**
191
+ * Set a given ref to a given value
192
+ * This utility takes care of different types of refs: callback refs and RefObject(s)
193
+ */
194
+ function setRef(ref, value) {
195
+ if (typeof ref === "function") return ref(value);
196
+ else if (ref !== null && ref !== void 0) ref.current = value;
197
+ }
198
+ /**
199
+ * A utility to compose multiple refs together
200
+ * Accepts callback refs and RefObject(s)
201
+ */
202
+ function composeRefs(...refs) {
203
+ return (node) => {
204
+ let hasCleanup = false;
205
+ const cleanups = refs.map((ref) => {
206
+ const cleanup = setRef(ref, node);
207
+ if (!hasCleanup && typeof cleanup == "function") hasCleanup = true;
208
+ return cleanup;
209
+ });
210
+ if (hasCleanup) return () => {
211
+ for (let i = 0; i < cleanups.length; i++) {
212
+ const cleanup = cleanups[i];
213
+ if (typeof cleanup == "function") cleanup();
214
+ else setRef(refs[i], null);
215
+ }
216
+ };
217
+ };
218
+ }
219
+ /**
220
+ * A custom hook that composes multiple refs
221
+ * Accepts callback refs and RefObject(s)
222
+ */
223
+ function useComposedRefs(...refs) {
224
+ return useCallback(composeRefs(...refs), refs);
225
+ }
226
+ /**
227
+ * 角度から円弧上の点を求める。
228
+ *
229
+ * 半径は線の太さの分だけ内側に取る必要があるが、太さは ActiveLine / InactiveLine が
230
+ * それぞれ持つため、座標はストアで先に計算せず各コンポーネント側で求める。
231
+ */
232
+ function pointOnArc(angle, radius) {
233
+ return {
234
+ x: 50 + radius * Math.cos(radian(angle - 90)),
235
+ y: 50 + radius * Math.sin(radian(angle - 90))
236
+ };
237
+ }
238
+ /** 線の太さが viewBox からはみ出さないようにした半径 */
239
+ function arcRadius(strokeWidth) {
240
+ const width = typeof strokeWidth === "number" ? strokeWidth : Number.parseFloat(String(strokeWidth));
241
+ return Number.isFinite(width) ? 50 - width / 2 : 50;
242
+ }
243
+ /** 設定から描画に必要な角度を導出する。レンダー中に呼ぶ。 */
244
+ function calcAngles({ value, min, max, scale, startValue, angleRange }) {
245
+ const p = scale.normalize(value, min, max);
246
+ const s = scale.normalize(startValue, min, max);
219
247
  const r1 = -angleRange / 2;
220
- const r2 = r1 + Math.min(p, s) * angleRange;
221
- const r3 = r1 + Math.max(p, s) * angleRange;
222
- const r4 = angleRange / 2;
223
248
  return {
224
249
  p,
225
250
  r1,
226
- r2,
227
- r3,
228
- r4,
229
- x1: 50 + 50 * Math.cos(radian(r1 - 90)),
230
- y1: 50 + 50 * Math.sin(radian(r1 - 90)),
231
- x2: 50 + 50 * Math.cos(radian(r2 - 90)),
232
- y2: 50 + 50 * Math.sin(radian(r2 - 90)),
233
- x3: 50 + 50 * Math.cos(radian(r3 - 90)),
234
- y3: 50 + 50 * Math.sin(radian(r3 - 90)),
235
- x4: 50 + 50 * Math.cos(radian(r4 - 90)),
236
- y4: 50 + 50 * Math.sin(radian(r4 - 90))
251
+ r2: r1 + Math.min(p, s) * angleRange,
252
+ r3: r1 + Math.max(p, s) * angleRange,
253
+ r4: angleRange / 2
237
254
  };
238
255
  }
239
- const createKnobStore = (initProps) => {
240
- return createStore()(() => ({
241
- ...initProps,
242
- ...calcDrawingState(initProps)
243
- }));
244
- };
245
256
  const KnobContext = createContext(null);
246
- function KnobProvider({ children, ...props }) {
247
- const storeRef = useRef(null);
248
- if (!storeRef.current) storeRef.current = createKnobStore(props);
249
- useEffect(() => {
250
- if (storeRef.current) storeRef.current.setState({
251
- ...props,
252
- ...calcDrawingState(props)
253
- });
254
- else storeRef.current = createKnobStore(props);
255
- }, [props]);
256
- return /* @__PURE__ */ jsx(KnobContext.Provider, {
257
- value: storeRef.current,
258
- children
259
- });
260
- }
257
+ const KnobProvider = KnobContext.Provider;
261
258
  function useKnobContext(selector) {
262
- const store = useContext(KnobContext);
263
- if (!store) throw new Error("Missing KnobContext.Provider in the tree");
264
- return useStore(store, selector);
259
+ const context = useContext(KnobContext);
260
+ if (!context) throw new Error("Missing KnobContext.Provider in the tree");
261
+ return selector ? selector(context) : context;
265
262
  }
266
263
  //#endregion
267
264
  //#region src/components/Knob/ActiveLine.tsx
268
265
  function ActiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...props }) {
269
266
  const r2 = useKnobContext((s) => s.r2);
270
267
  const r3 = useKnobContext((s) => s.r3);
271
- const x2 = useKnobContext((s) => s.x2);
272
- const y2 = useKnobContext((s) => s.y2);
273
- const x3 = useKnobContext((s) => s.x3);
274
- const y3 = useKnobContext((s) => s.y3);
268
+ const radius = arcRadius(strokeWidth);
269
+ const start = pointOnArc(r2, radius);
270
+ const end = pointOnArc(r3, radius);
275
271
  return /* @__PURE__ */ jsx("path", {
276
272
  className: clsx("tremolo-knob-active-line", className),
277
- d: `M ${x2} ${y2} A 50 50 -135 ${r3 - r2 > 180 ? 1 : 0} 1 ${x3} ${y3}`,
273
+ d: `M ${start.x} ${start.y} A ${radius} ${radius} -135 ${r3 - r2 > 180 ? 1 : 0} 1 ${end.x} ${end.y}`,
278
274
  fill: "none",
279
275
  stroke,
280
276
  strokeWidth,
@@ -291,24 +287,21 @@ function InactiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...
291
287
  const r2 = useKnobContext((s) => s.r2);
292
288
  const r3 = useKnobContext((s) => s.r3);
293
289
  const r4 = useKnobContext((s) => s.r4);
294
- const x1 = useKnobContext((s) => s.x1);
295
- const y1 = useKnobContext((s) => s.y1);
296
- const x2 = useKnobContext((s) => s.x2);
297
- const y2 = useKnobContext((s) => s.y2);
298
- const x3 = useKnobContext((s) => s.x3);
299
- const y3 = useKnobContext((s) => s.y3);
300
- const x4 = useKnobContext((s) => s.x4);
301
- const y4 = useKnobContext((s) => s.y4);
290
+ const radius = arcRadius(strokeWidth);
291
+ const p1 = pointOnArc(r1, radius);
292
+ const p2 = pointOnArc(r2, radius);
293
+ const p3 = pointOnArc(r3, radius);
294
+ const p4 = pointOnArc(r4, radius);
302
295
  return /* @__PURE__ */ jsxs(Fragment, { children: [startValue > min && /* @__PURE__ */ jsx("path", {
303
296
  className: clsx("tremolo-knob-inactive-line", className),
304
- d: `M ${x1} ${y1} A 50 50 -135 ${r2 - r1 > 180 ? 1 : 0} 1 ${x2} ${y2}`,
297
+ d: `M ${p1.x} ${p1.y} A ${radius} ${radius} -135 ${r2 - r1 > 180 ? 1 : 0} 1 ${p2.x} ${p2.y}`,
305
298
  fill: "none",
306
299
  stroke,
307
300
  strokeWidth,
308
301
  ...props
309
302
  }), startValue < max && /* @__PURE__ */ jsx("path", {
310
303
  className: clsx("tremolo-knob-inactive-line", className),
311
- d: `M ${x3} ${y3} A 50 50 -135 ${r4 - r3 > 180 ? 1 : 0} 1 ${x4} ${y4}`,
304
+ d: `M ${p3.x} ${p3.y} A ${radius} ${radius} -135 ${r4 - r3 > 180 ? 1 : 0} 1 ${p4.x} ${p4.y}`,
312
305
  fill: "none",
313
306
  stroke,
314
307
  strokeWidth,
@@ -316,6 +309,19 @@ function InactiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...
316
309
  })] });
317
310
  }
318
311
  //#endregion
312
+ //#region src/components/Knob/SVGRoot.tsx
313
+ function SVGRoot({ children, style, ...props }) {
314
+ return /* @__PURE__ */ jsx("svg", {
315
+ viewBox: `0 0 100 100`,
316
+ style: {
317
+ display: "block",
318
+ ...style
319
+ },
320
+ ...props,
321
+ children
322
+ });
323
+ }
324
+ //#endregion
319
325
  //#region src/components/Knob/Thumb.tsx
320
326
  function Thumb$2({ className, thumb = "currentColor", thumbLine = "currentColor", thumbSize = 84, thumbLineWeight = 6, thumbLineLength = 35, classes, ...props }) {
321
327
  const angleRange = useKnobContext((s) => s.angleRange);
@@ -345,63 +351,36 @@ function Thumb$2({ className, thumb = "currentColor", thumbLine = "currentColor"
345
351
  });
346
352
  }
347
353
  //#endregion
348
- //#region src/components/Knob/SVGRoot.tsx
349
- function SVGRoot({ children, style, ...props }) {
350
- return /* @__PURE__ */ jsx("svg", {
351
- viewBox: `0 0 100 100`,
352
- style: {
353
- display: "block",
354
- overflow: "visible",
355
- ...style
356
- },
357
- ...props,
358
- children: children || /* @__PURE__ */ jsxs(Fragment, { children: [
359
- /* @__PURE__ */ jsx(InactiveLine, {}),
360
- /* @__PURE__ */ jsx(ActiveLine, {}),
361
- /* @__PURE__ */ jsx(Thumb$2, {})
362
- ] })
363
- });
364
- }
365
- //#endregion
366
354
  //#region src/components/Knob/index.tsx
367
- const defaultExternalStyles$3 = {
355
+ const defaultExternalStyles$2 = {
368
356
  userSelectNone: true,
369
357
  cursor: "grabbing"
370
358
  };
371
359
  /**
360
+ * The wheel only acts while the focus is inside, so that scrolling a page past
361
+ * the control does not change its value.
362
+ */
363
+ const WHEEL_OPTIONS$2 = { requireFocus: true };
364
+ /**
372
365
  * Interactive rotary knob component implemented in SVG.
373
366
  */
374
367
  const Knob = {
375
- Root: forwardRef(({ value, min, max, step = 1, skew = 1, defaultValue = min, startValue = min, size, externalStyles: _externalStyles, wheel = ["raw", 1], keyboard = ["raw", 1], enableDoubleClickDefault = true, disabled = false, readonly = false, angleRange = 270, onChange, onKeyDown, onPointerDown, onDoubleClick, className, style, children, ...props }, forwardedRef) => {
376
- const [dragging, setDragging] = useState(false);
377
- const valueRef = useRef(0);
368
+ Root: forwardRef(({ value, min, max, step = 1, scale = linearScale, defaultValue = min, startValue = min, size, externalStyles: _externalStyles, wheel = ["raw", 1], keyboard = ["raw", 1], enableDoubleClickDefault = true, disabled = false, readonly = false, angleRange = 270, onChange, onKeyDown, onPointerDown, onDoubleClick, className, style, children, ...props }, forwardedRef) => {
378
369
  const elmRef = useRef(null);
379
370
  const externalStyles = {
380
- ...defaultExternalStyles$3,
371
+ ...defaultExternalStyles$2,
381
372
  ..._externalStyles
382
373
  };
383
- const onDrag = useCallback((_x, y) => {
384
- if (!onChange || readonly) return;
385
- onChange(clamp(stepValue(rawValue(valueRef.current - y / 100, min, max, skew), step), min, max));
386
- }, [
387
- max,
374
+ const range = useMemo(() => ({
388
375
  min,
389
- onChange,
390
- readonly,
391
- skew,
392
- step
393
- ]);
394
- const updateValueByEvent = useCallback((eventType, x) => {
395
- let newValue;
396
- if (eventType == "normalized") newValue = rawValue(normalizeValue(value, min, max, skew) + x, min, max, skew);
397
- else newValue = value + x;
398
- return clamp(stepValue(newValue, step), min, max);
399
- }, [
400
376
  max,
377
+ step,
378
+ scale
379
+ }), [
401
380
  min,
402
- skew,
381
+ max,
403
382
  step,
404
- value
383
+ scale
405
384
  ]);
406
385
  const handleKeyDown = useCallback((event) => {
407
386
  if (!keyboard || !onChange || readonly) return;
@@ -413,35 +392,63 @@ const Knob = {
413
392
  "ArrowDown"
414
393
  ].includes(key)) {
415
394
  event.preventDefault();
416
- const x = key == "ArrowRight" || key == "ArrowUp" ? keyboard[1] : -keyboard[1];
417
- onChange(updateValueByEvent(keyboard[0], x));
395
+ onChange(applyDelta(value, key == "ArrowRight" || key == "ArrowUp" ? 1 : -1, keyboard, range));
418
396
  }
419
397
  }, [
420
398
  keyboard,
421
399
  onChange,
422
400
  readonly,
423
- updateValueByEvent
401
+ value,
402
+ range
424
403
  ]);
425
- const dragRefCallback = useDrag({
404
+ const { refCallback: dragRefCallback, dragging } = useDragValue({
405
+ axis: useMemo(() => [range, {
406
+ ...range,
407
+ reverse: true
408
+ }], [range]),
409
+ getValue: () => [value, value],
410
+ threshold: 1,
426
411
  cursor: readonly ? void 0 : externalStyles.cursor,
427
- onDrag,
412
+ onChange: (v) => {
413
+ if (readonly) return;
414
+ onChange?.(v[1]);
415
+ },
428
416
  onDragStart: () => {
429
- setDragging(true);
430
- valueRef.current = normalizeValue(value, min, max, skew);
431
417
  if (externalStyles.userSelectNone) addUserSelectNone();
432
418
  },
433
419
  onDragEnd: () => {
434
- setDragging(false);
435
420
  if (externalStyles.userSelectNone) removeUserSelectNone();
436
421
  }
437
422
  });
438
- const wheelRefCallback = useWheel((event) => {
423
+ const rootRefCallback = useComposedRefs(elmRef, dragRefCallback, useWheel((event) => {
439
424
  if (!wheel || readonly) return;
440
425
  event.preventDefault();
441
426
  if (!onChange || event.deltaY == 0) return;
442
- const x = Math.sign(event.deltaY) * -wheel[1];
443
- onChange(updateValueByEvent(wheel[0], x));
444
- });
427
+ onChange(applyDelta(value, -Math.sign(event.deltaY), wheel, range));
428
+ }, WHEEL_OPTIONS$2));
429
+ const context = useMemo(() => {
430
+ const config = {
431
+ value,
432
+ min,
433
+ max,
434
+ step,
435
+ scale,
436
+ startValue,
437
+ angleRange
438
+ };
439
+ return {
440
+ ...config,
441
+ ...calcAngles(config)
442
+ };
443
+ }, [
444
+ value,
445
+ min,
446
+ max,
447
+ step,
448
+ scale,
449
+ startValue,
450
+ angleRange
451
+ ]);
445
452
  useImperativeHandle(forwardedRef, () => {
446
453
  return {
447
454
  focus() {
@@ -453,19 +460,9 @@ const Knob = {
453
460
  };
454
461
  }, []);
455
462
  return /* @__PURE__ */ jsx(KnobProvider, {
456
- value,
457
- min,
458
- max,
459
- step,
460
- skew,
461
- startValue,
462
- angleRange,
463
+ value: context,
463
464
  children: /* @__PURE__ */ jsx("div", {
464
- ref: (elm) => {
465
- elmRef.current = elm;
466
- wheelRefCallback(elm);
467
- dragRefCallback(elm);
468
- },
465
+ ref: rootRefCallback,
469
466
  className: clsx("tremolo-knob", className),
470
467
  tabIndex: 0,
471
468
  role: "slider",
@@ -490,7 +487,7 @@ const Knob = {
490
487
  onKeyDown?.(event);
491
488
  },
492
489
  ...props,
493
- children: children || /* @__PURE__ */ jsx(SVGRoot, {})
490
+ children
494
491
  })
495
492
  });
496
493
  }),
@@ -500,121 +497,18 @@ const Knob = {
500
497
  Thumb: Thumb$2
501
498
  };
502
499
  //#endregion
503
- //#region src/components/NumberInput/type.ts
504
- function selectUnit(units, value) {
505
- let i = 0;
506
- for (; i < units.length; i++) if (Math.abs(units[i][1]) > Math.abs(value)) break;
507
- return units[Math.max(0, i - 1)];
508
- }
509
- function parseValue(inputString, units, digit) {
510
- const str = inputString.trim();
511
- if (!units || typeof units == "string") {
512
- const m = str.match(/-?\d+(\.\d+)?/);
513
- let v = Number(m?.[0] ?? "0");
514
- v = isNaN(v) ? 0 : v;
515
- return {
516
- rawValue: v,
517
- formatValue: (digit != void 0 ? v.toFixed(digit) : v) + (units ?? ""),
518
- unit: units ?? ""
519
- };
520
- }
521
- const unitList = units.map(([u, _s]) => u);
522
- const scaleList = units.map(([_u, s]) => s);
523
- let rawValue = 0;
524
- let unit = unitList[0];
525
- let formatValue = (digit != void 0 ? rawValue.toFixed(digit) : rawValue) + unit;
526
- const m = str.match(/^(-?\d+(\.\d+)?)\s*(\w*)$/);
527
- if (m) {
528
- rawValue = Number(m[1]) || 0;
529
- const uIndex = unitList.indexOf(m[3]);
530
- rawValue *= uIndex != -1 ? scaleList[uIndex] : 1;
531
- const [u, scale] = selectUnit(units, rawValue);
532
- const v = rawValue / scale;
533
- formatValue = (digit != void 0 ? v.toFixed(digit) : v) + u;
534
- unit = u;
535
- }
536
- return {
537
- rawValue,
538
- formatValue,
539
- unit
540
- };
541
- }
542
- //#endregion
543
500
  //#region src/components/NumberInput/context.tsx
544
- function safeClamp(value, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER) {
545
- return clamp(value, min, max);
546
- }
547
- const createNumberInputStore = (initProps) => {
548
- const DEFAULT_PROPS = {
549
- value: "",
550
- valueAsNumber: 0,
551
- min: 0,
552
- max: 0,
553
- step: 1,
554
- readonly: false,
555
- keepWithinRange: true,
556
- onChange: (_v, _t) => {}
557
- };
558
- return createStore()((set) => ({
559
- ...DEFAULT_PROPS,
560
- ...initProps,
561
- value: parseValue(String(initProps?.value || ""), initProps?.units, initProps?.digit).formatValue,
562
- valueAsNumber: parseValue(String(initProps?.value || ""), initProps?.units, initProps?.digit).rawValue,
563
- increment: () => set((state) => {
564
- if (state.readonly) return {};
565
- let next = parseValue(state.value, state.units, state.digit).rawValue + (state.step ?? 1);
566
- if (state.keepWithinRange) next = safeClamp(next, state.min, state.max);
567
- const v = parseValue(String(next), state.units, state.digit).formatValue;
568
- state?.onChange?.(next, v);
569
- return {
570
- value: v,
571
- valueAsNumber: next
572
- };
573
- }),
574
- decrement: () => set((state) => {
575
- if (state.readonly) return {};
576
- let next = parseValue(state.value, state.units, state.digit).rawValue - (state.step ?? 1);
577
- if (state.keepWithinRange) next = safeClamp(next, state.min, state.max);
578
- const v = parseValue(String(next), state.units, state.digit).formatValue;
579
- state?.onChange?.(next, v);
580
- return {
581
- value: v,
582
- valueAsNumber: next
583
- };
584
- }),
585
- change: (value) => set((state) => {
586
- if (state.readonly) return {};
587
- const v = value ?? "";
588
- return {
589
- value: v,
590
- valueAsNumber: parseValue(v, state.units, state.digit).rawValue
591
- };
592
- })
593
- }));
594
- };
595
501
  const NumberInputContext = createContext(null);
596
- function NumberInputProvider({ children, ...props }) {
597
- const storeRef = useRef(null);
598
- if (!storeRef.current) storeRef.current = createNumberInputStore(props);
599
- useEffect(() => {
600
- if (storeRef.current) {
601
- const parsed = parseValue(String(props?.value || ""), props?.units, props?.digit);
602
- storeRef.current.setState({
603
- ...props,
604
- value: parsed.formatValue,
605
- valueAsNumber: parsed.rawValue
606
- });
607
- } else storeRef.current = createNumberInputStore(props);
608
- }, [props]);
609
- return /* @__PURE__ */ jsx(NumberInputContext.Provider, {
610
- value: storeRef.current,
611
- children
612
- });
613
- }
502
+ const NumberInputProvider = NumberInputContext.Provider;
614
503
  function useNumberInputContext(selector) {
615
- const store = useContext(NumberInputContext);
616
- if (!store) throw new Error("Missing NumberInputContext.Provider in the tree");
617
- return useStore(store, selector);
504
+ const context = useContext(NumberInputContext);
505
+ if (!context) throw new Error("Missing NumberInputContext.Provider in the tree");
506
+ return selector ? selector(context) : context;
507
+ }
508
+ const StepperContext = createContext(null);
509
+ const StepperProvider = StepperContext.Provider;
510
+ function useStepperContext() {
511
+ return useContext(StepperContext);
618
512
  }
619
513
  //#endregion
620
514
  //#region src/hooks/useEventListener.ts
@@ -673,653 +567,554 @@ function useLongPress(callback, initialDelay = 500, interval = 40) {
673
567
  }, [callback]);
674
568
  }
675
569
  //#endregion
676
- //#region src/components/NumberInput/DecrementStepper.tsx
677
- function DecrementStepper({ size = 12, children, className, ...props }) {
678
- const min = useNumberInputContext((s) => s.min);
679
- const valueAsNumber = useNumberInputContext((s) => s.valueAsNumber);
680
- const readonly = useNumberInputContext((s) => s.readonly);
681
- const keepWithinRange = useNumberInputContext((s) => s.keepWithinRange);
682
- const press = useLongPress(useNumberInputContext((s) => s.decrement));
570
+ //#region src/components/NumberInput/stepperButton.tsx
571
+ /**
572
+ * Shared body of `IncrementStepper` and `DecrementStepper`: the two differ only
573
+ * in which way they move the value and which arrow they draw.
574
+ */
575
+ function StepperButton({ direction, variant, icon, className, style, children, onPointerDown, ...props }) {
576
+ const { step, readonly, atMin, atMax, nudge } = useNumberInputContext();
577
+ const stepper = useStepperContext();
578
+ const press = useLongPress(useCallback(() => {
579
+ if (stepper?.draggingRef.current) return;
580
+ nudge(direction, ["raw", step]);
581
+ }, [
582
+ stepper,
583
+ nudge,
584
+ direction,
585
+ step
586
+ ]));
683
587
  return /* @__PURE__ */ jsx("div", {
684
- className: clsx("tremolo-number-input-decrement-stepper", className),
588
+ className: clsx(`tremolo-number-input-${variant}-stepper`, className),
685
589
  role: "button",
686
590
  tabIndex: -1,
687
- "aria-disabled": keepWithinRange && valueAsNumber <= (min ?? Number.MIN_SAFE_INTEGER),
591
+ "aria-label": direction > 0 ? "Increment" : "Decrement",
592
+ "aria-disabled": direction > 0 ? atMax : atMin,
688
593
  "aria-readonly": readonly,
689
- onPointerDown: press,
594
+ style,
595
+ onPointerDown: (event) => {
596
+ if (!readonly) press();
597
+ onPointerDown?.(event);
598
+ },
690
599
  ...props,
691
- children: children || /* @__PURE__ */ jsx("svg", {
692
- width: size,
693
- height: size,
694
- viewBox: "0 0 24 24",
695
- fill: "none",
696
- stroke: "currentColor",
697
- strokeWidth: "2",
698
- strokeLinecap: "round",
699
- strokeLinejoin: "round",
700
- children: /* @__PURE__ */ jsx("polyline", { points: "6 9 12 15 18 9" })
701
- })
600
+ children: children ?? icon
601
+ });
602
+ }
603
+ function StepperArrow({ up }) {
604
+ return /* @__PURE__ */ jsx("svg", {
605
+ className: "tremolo-number-input-stepper-icon",
606
+ viewBox: "0 0 24 24",
607
+ fill: "none",
608
+ stroke: "currentColor",
609
+ strokeWidth: "2",
610
+ strokeLinecap: "round",
611
+ strokeLinejoin: "round",
612
+ children: /* @__PURE__ */ jsx("polyline", { points: up ? "18 15 12 9 6 15" : "6 9 12 15 18 9" })
702
613
  });
703
614
  }
704
615
  //#endregion
705
- //#region src/components/NumberInput/IncrementStepper.tsx
706
- function IncrementStepper({ size = 12, children, className, ...props }) {
707
- const max = useNumberInputContext((s) => s.max);
708
- const valueAsNumber = useNumberInputContext((s) => s.valueAsNumber);
709
- const readonly = useNumberInputContext((s) => s.readonly);
710
- const keepWithinRange = useNumberInputContext((s) => s.keepWithinRange);
711
- const press = useLongPress(useNumberInputContext((s) => s.increment));
712
- return /* @__PURE__ */ jsx("div", {
713
- className: clsx("tremolo-number-input-increment-stepper", className),
714
- role: "button",
715
- tabIndex: -1,
716
- "aria-disabled": keepWithinRange && valueAsNumber >= (max ?? Number.MAX_SAFE_INTEGER),
717
- "aria-readonly": readonly,
718
- onPointerDown: press,
719
- ...props,
720
- children: children || /* @__PURE__ */ jsx("svg", {
721
- width: size,
722
- height: size,
723
- viewBox: "0 0 24 24",
724
- fill: "none",
725
- stroke: "currentColor",
726
- strokeWidth: "2",
727
- strokeLinecap: "round",
728
- strokeLinejoin: "round",
729
- children: /* @__PURE__ */ jsx("polyline", { points: "18 15 12 9 6 15" })
730
- })
616
+ //#region src/components/NumberInput/DecrementStepper.tsx
617
+ /** Lowers the value by one `step`, repeating while held. */
618
+ function DecrementStepper(props) {
619
+ return /* @__PURE__ */ jsx(StepperButton, {
620
+ direction: -1,
621
+ variant: "decrement",
622
+ icon: /* @__PURE__ */ jsx(StepperArrow, { up: false }),
623
+ ...props
731
624
  });
732
625
  }
733
626
  //#endregion
734
- //#region src/components/NumberInput/InternalInput.tsx
735
- const InternalInput = forwardRef(({ disabled, readonly, selectWithFocus, blurOnEnter, clampValueOnBlur, wheel, keyboard, className, onChange, onFocus, onBlur, onKeyDown, children: _children, ...props }, ref) => {
736
- const elmRef = useRef(null);
737
- const [editing, setEditing] = useState(false);
738
- const value = useNumberInputContext((s) => s.value);
739
- const valueAsNumber = useNumberInputContext((s) => s.valueAsNumber);
740
- const min = useNumberInputContext((s) => s.min);
741
- const max = useNumberInputContext((s) => s.max);
742
- const step = useNumberInputContext((s) => s.step);
743
- const units = useNumberInputContext((s) => s.units);
744
- const digit = useNumberInputContext((s) => s.digit);
745
- const change = useNumberInputContext((s) => s.change);
746
- const error = useMemo(() => {
747
- if (editing) return false;
748
- const v = valueAsNumber;
749
- return (min ?? -Infinity) > v || v > (max ?? Infinity);
750
- }, [
751
- editing,
752
- max,
753
- min,
754
- valueAsNumber
755
- ]);
756
- const updateValueByEvent = useCallback((eventType, x) => {
757
- let newValue;
758
- if (eventType == "normalized") {
759
- if (!min || !max) throw new Error("[NumberInput] \"min\" and \"max\" are required when InputEventOption[0] is set to \"normalized\".");
760
- newValue = rawValue(normalizeValue(valueAsNumber, min, max) + x, min, max);
761
- return clamp(stepValue(newValue, step), min, max);
762
- } else {
763
- newValue = valueAsNumber + x;
764
- return stepValue(newValue, step);
765
- }
766
- }, [
767
- max,
768
- min,
769
- step,
770
- valueAsNumber
771
- ]);
772
- const handleKeyDown = useCallback((event) => {
773
- if (!keyboard || !onChange || readonly) return;
774
- const key = event.key;
775
- if (!["ArrowUp", "ArrowDown"].includes(key)) return;
776
- event.preventDefault();
777
- const x = key == "ArrowUp" ? keyboard[1] : -keyboard[1];
778
- const parsed = parseValue(String(updateValueByEvent(keyboard[0], x)));
779
- onChange?.(parsed.rawValue, parsed.formatValue);
780
- }, [
781
- keyboard,
782
- readonly,
783
- onChange,
784
- updateValueByEvent
785
- ]);
786
- const wheelRefCallback = useWheel((event) => {
787
- if (!wheel || readonly) return;
788
- event.preventDefault();
789
- if (!onChange || event.deltaY == 0) return;
790
- const x = Math.sign(event.deltaY) * -wheel[1];
791
- const parsed = parseValue(String(updateValueByEvent(wheel[0], x)));
792
- onChange?.(parsed.rawValue, parsed.formatValue);
627
+ //#region src/components/NumberInput/IncrementStepper.tsx
628
+ /** Raises the value by one `step`, repeating while held. */
629
+ function IncrementStepper(props) {
630
+ return /* @__PURE__ */ jsx(StepperButton, {
631
+ direction: 1,
632
+ variant: "increment",
633
+ icon: /* @__PURE__ */ jsx(StepperArrow, { up: true }),
634
+ ...props
793
635
  });
794
- useImperativeHandle(ref, () => {
795
- return {
796
- focus() {
797
- elmRef.current?.focus();
798
- },
799
- blur() {
800
- elmRef.current?.blur();
801
- }
802
- };
803
- }, []);
636
+ }
637
+ //#endregion
638
+ //#region src/components/NumberInput/InputField.tsx
639
+ /** The leading number of the displayed text, whatever the format put around it. */
640
+ const NUMBER_PREFIX = /^\s*-?[\d.,]*/;
641
+ /**
642
+ * The text field of a `NumberInput`, and the only place the value can be typed.
643
+ *
644
+ * While the user types, their own text stands rather than `format(value)`, so
645
+ * that a half-finished entry is not rewritten under the caret.
646
+ */
647
+ function InputField({ selectOnFocus = "none", blurOnEnter = true, className, style, ref, onFocus, onBlur, onKeyDown, ...props }) {
648
+ const { value, min, max, step, disabled, readonly, keyboard, text, outOfRange, setDraft, commitDraft, nudge, inputRef } = useNumberInputContext();
804
649
  return /* @__PURE__ */ jsx("input", {
805
- ref: (input) => {
806
- elmRef.current = input;
807
- wheelRefCallback(input);
808
- },
809
- className: clsx("tremolo-number-input", className),
810
- value,
811
- min,
812
- max,
813
- step,
650
+ ref: useComposedRefs(ref, inputRef),
651
+ className: clsx("tremolo-number-input-field", className),
814
652
  type: "text",
653
+ inputMode: "decimal",
654
+ role: "spinbutton",
655
+ value: text,
656
+ readOnly: readonly,
815
657
  "aria-disabled": disabled,
816
658
  "aria-readonly": readonly,
817
- tabIndex: -1,
818
- "data-error": error,
819
- onChange: (event) => {
820
- if (readonly) return;
821
- const v = event.currentTarget.value;
822
- const cursorPos = event.target.selectionStart;
823
- change(v);
824
- const valueAsNumber = parseValue(v, units, digit).rawValue;
825
- onChange?.(valueAsNumber, v);
826
- if (cursorPos) {
827
- event.target.selectionStart = cursorPos;
828
- event.target.selectionEnd = cursorPos;
829
- }
830
- },
659
+ "aria-valuenow": value,
660
+ "aria-valuemin": min,
661
+ "aria-valuemax": max,
662
+ "aria-valuetext": text,
663
+ step,
664
+ "data-out-of-range": outOfRange,
665
+ style,
666
+ onChange: (event) => setDraft(event.currentTarget.value),
831
667
  onFocus: (event) => {
832
- setEditing(true);
833
- const v = event.currentTarget.value;
834
- const parsed = parseValue(v, units, digit);
835
- if (selectWithFocus == "all") event.currentTarget.setSelectionRange(0, v.length);
836
- else if (selectWithFocus == "number") event.currentTarget.setSelectionRange(0, parsed.formatValue.length - parsed.unit.length);
837
- onFocus?.(parsed.rawValue, parsed.formatValue, event);
668
+ const input = event.currentTarget;
669
+ if (selectOnFocus == "all") input.setSelectionRange(0, input.value.length);
670
+ else if (selectOnFocus == "number") input.setSelectionRange(0, input.value.match(NUMBER_PREFIX)?.[0].length ?? 0);
671
+ onFocus?.(event);
838
672
  },
839
673
  onBlur: (event) => {
840
- setEditing(false);
841
- let v = parseValue(event.currentTarget.value, units, digit).rawValue;
842
- if (clampValueOnBlur) v = safeClamp(v, min, max);
843
- const parsed = parseValue(String(v), units, digit);
844
- change(parsed.formatValue);
845
- if (clampValueOnBlur) onChange?.(parsed.rawValue, parsed.formatValue);
846
- onBlur?.(parsed.rawValue, parsed.formatValue, event);
674
+ commitDraft();
675
+ onBlur?.(event);
847
676
  },
848
677
  onKeyDown: (event) => {
849
- if (blurOnEnter && event.key == "Enter") event.currentTarget.blur();
850
- handleKeyDown(event);
678
+ const key = event.key;
679
+ if (key == "Enter") {
680
+ commitDraft();
681
+ if (blurOnEnter) event.currentTarget.blur();
682
+ } else if (keyboard && !readonly && (key == "ArrowUp" || key == "ArrowDown")) {
683
+ event.preventDefault();
684
+ nudge(key == "ArrowUp" ? 1 : -1, keyboard);
685
+ }
851
686
  onKeyDown?.(event);
852
687
  },
853
688
  ...props
854
689
  });
855
- });
690
+ }
691
+ //#endregion
692
+ //#region src/hooks/useDrag.ts
693
+ /**
694
+ * Track a pointer drag on an element.
695
+ *
696
+ * @returns a ref callback to attach to the element being dragged
697
+ */
698
+ function useDrag({ threshold = 1, cursor, onDrag, onDragStart, onDragEnd }) {
699
+ const dragHandler = useCallbackRef(onDrag);
700
+ const dragStartHandler = useCallbackRef(onDragStart);
701
+ const dragEndHandler = useCallbackRef(onDragEnd);
702
+ const [node, setNode] = useState(null);
703
+ useEffect(() => {
704
+ if (!node) return;
705
+ const instance = createDrag(node, {
706
+ threshold,
707
+ cursor,
708
+ onDragStart: () => dragStartHandler(),
709
+ onDrag: ({ x, y, deltaX, deltaY }) => dragHandler(x, y, deltaX, deltaY),
710
+ onDragEnd: () => dragEndHandler()
711
+ });
712
+ return () => instance.destroy();
713
+ }, [
714
+ node,
715
+ threshold,
716
+ cursor,
717
+ dragHandler,
718
+ dragStartHandler,
719
+ dragEndHandler
720
+ ]);
721
+ return setNode;
722
+ }
856
723
  //#endregion
857
724
  //#region src/components/NumberInput/Stepper.tsx
858
- function Stepper({ dynamic = true, children, className, ...props }) {
859
- return /* @__PURE__ */ jsx("div", {
860
- className: clsx("tremolo-number-input-stepper", className),
861
- "data-dynamic": dynamic,
862
- ...props,
863
- children
725
+ /**
726
+ * The area the steppers sit in, and a drag handle in its own right: dragging it
727
+ * up and down moves the value one `step` every `drag` pixels.
728
+ *
729
+ * The drag lives here rather than on `InputField` because `createDrag` turns
730
+ * off text selection on whatever element it is attached to.
731
+ */
732
+ function Stepper({ className, style, children, ref, ...props }) {
733
+ const { value, step, readonly, drag, range, changeValue } = useNumberInputContext();
734
+ /**
735
+ * Set once the drag has actually moved the value, so that the press-and-hold
736
+ * repeat of a stepper stands down and leaves the value to the drag. A ref,
737
+ * because it is read from inside the repeat rather than rendered.
738
+ */
739
+ const draggingRef = useRef(false);
740
+ /**
741
+ * Where the drag started, taken on the first move rather than on pointer
742
+ * down: the steppers act on pointer down, so by then the value may already
743
+ * have been nudged once, and the drag should carry on from there.
744
+ */
745
+ const originRef = useRef(null);
746
+ const dragRefCallback = useDrag({
747
+ threshold: 1,
748
+ cursor: readonly ? void 0 : "ns-resize",
749
+ onDragStart: () => {
750
+ originRef.current = null;
751
+ draggingRef.current = false;
752
+ },
753
+ onDrag: (_x, y) => {
754
+ if (readonly || drag == null) return;
755
+ if (!originRef.current) {
756
+ originRef.current = {
757
+ y,
758
+ value
759
+ };
760
+ return;
761
+ }
762
+ const steps = Math.round(-(y - originRef.current.y) / drag);
763
+ if (steps == 0) return;
764
+ draggingRef.current = true;
765
+ changeValue(applyDelta(originRef.current.value, steps, ["raw", step], range));
766
+ },
767
+ onDragEnd: () => {
768
+ draggingRef.current = false;
769
+ }
770
+ });
771
+ const composedRef = useComposedRefs(ref, drag != null && !readonly ? dragRefCallback : void 0);
772
+ return /* @__PURE__ */ jsx(StepperProvider, {
773
+ value: useMemo(() => ({ draggingRef }), []),
774
+ children: /* @__PURE__ */ jsx("div", {
775
+ ref: composedRef,
776
+ className: clsx("tremolo-number-input-stepper", className),
777
+ style,
778
+ ...props,
779
+ children
780
+ })
864
781
  });
865
782
  }
866
783
  /**
867
784
  * Input with some useful functions for entering numerical values.
868
785
  */
869
786
  const NumberInput = {
870
- Root: forwardRef(({ value, min, max, step = 1, units, readonly = false, disabled = false, digit, variant = "outline", selectWithFocus = "none", blurOnEnter = true, keepWithinRange = true, clampValueOnBlur = true, wheel = ["raw", 1], keyboard = ["raw", 1], activeColor, wrapperClassName, className, onChange, onFocus, onBlur, children, ...props }, forwardedRef) => {
871
- const colors = { "--active-color": activeColor };
872
- return /* @__PURE__ */ jsx(NumberInputProvider, {
873
- value: String(value),
787
+ Root: forwardRef(({ value, min, max, step = 1, scale = linearScale, units, digit, format: formatProp, parse: parseProp, clampValue = true, wheel = ["raw", 1], keyboard = ["raw", 1], drag = 1, disabled = false, readonly = false, className, style, onChange, children, ...props }, forwardedRef) => {
788
+ const inputRef = useRef(null);
789
+ /**
790
+ * The text being typed. The only state here: it is not a copy of `value`,
791
+ * but the half-finished entry that has no value to be derived from yet.
792
+ */
793
+ const [draft, setDraft] = useState(null);
794
+ const format = useCallback((v) => formatProp ? formatProp(v) : formatValue(v, units, digit), [
795
+ formatProp,
796
+ units,
797
+ digit
798
+ ]);
799
+ const parse = useCallback((t) => parseProp ? parseProp(t) : parseValue(t, units), [parseProp, units]);
800
+ const range = useMemo(() => ({
801
+ min: (clampValue ? min : void 0) ?? Number.MIN_SAFE_INTEGER,
802
+ max: (clampValue ? max : void 0) ?? Number.MAX_SAFE_INTEGER,
803
+ step,
804
+ scale
805
+ }), [
806
+ clampValue,
874
807
  min,
875
808
  max,
876
809
  step,
877
- units,
878
- digit,
810
+ scale
811
+ ]);
812
+ const text = draft ?? format(value);
813
+ const editing = draft != null;
814
+ const outOfRange = !editing && (min != void 0 && value < min || max != void 0 && value > max);
815
+ const handleDraft = useCallback((next) => {
816
+ if (readonly) return;
817
+ setDraft(next);
818
+ const parsed = parse(next);
819
+ if (Number.isFinite(parsed)) onChange?.(parsed);
820
+ }, [
879
821
  readonly,
880
- keepWithinRange,
881
- onChange,
882
- children: /* @__PURE__ */ jsxs("div", {
883
- className: clsx("tremolo-number-input-wrapper", wrapperClassName),
884
- tabIndex: 0,
885
- style: { ...colors },
886
- "data-stepper": !!children,
887
- "data-variant": variant,
888
- children: [/* @__PURE__ */ jsx(InternalInput, {
889
- ref: forwardedRef,
890
- readonly,
891
- disabled,
892
- selectWithFocus,
893
- blurOnEnter,
894
- clampValueOnBlur,
895
- wheel,
896
- keyboard,
897
- className,
898
- onChange,
899
- onFocus,
900
- onBlur,
901
- ...props
902
- }), children]
822
+ parse,
823
+ onChange
824
+ ]);
825
+ const changeValue = useCallback((next) => {
826
+ if (readonly) return;
827
+ setDraft(null);
828
+ if (next != value) onChange?.(next);
829
+ }, [
830
+ readonly,
831
+ value,
832
+ onChange
833
+ ]);
834
+ const commitDraft = useCallback(() => {
835
+ if (draft == null || readonly) return;
836
+ changeValue(clamp(parse(draft), range.min, range.max));
837
+ }, [
838
+ draft,
839
+ readonly,
840
+ parse,
841
+ range,
842
+ changeValue
843
+ ]);
844
+ const nudge = useCallback((direction, option) => {
845
+ changeValue(applyDelta(value, direction, option, range));
846
+ }, [
847
+ changeValue,
848
+ value,
849
+ range
850
+ ]);
851
+ const wheelRefCallback = useWheel((event) => {
852
+ if (!wheel || readonly || event.deltaY == 0) return;
853
+ event.preventDefault();
854
+ nudge(-Math.sign(event.deltaY), wheel);
855
+ }, { requireFocus: true });
856
+ const context = useMemo(() => ({
857
+ value,
858
+ min,
859
+ max,
860
+ step,
861
+ scale,
862
+ disabled,
863
+ readonly,
864
+ clampValue,
865
+ range,
866
+ keyboard,
867
+ text,
868
+ editing,
869
+ outOfRange,
870
+ atMin: clampValue && min != void 0 && value <= min,
871
+ atMax: clampValue && max != void 0 && value >= max,
872
+ drag,
873
+ format,
874
+ parse,
875
+ setDraft: handleDraft,
876
+ commitDraft,
877
+ changeValue,
878
+ nudge,
879
+ inputRef
880
+ }), [
881
+ value,
882
+ min,
883
+ max,
884
+ step,
885
+ scale,
886
+ disabled,
887
+ readonly,
888
+ clampValue,
889
+ range,
890
+ keyboard,
891
+ text,
892
+ editing,
893
+ outOfRange,
894
+ drag,
895
+ format,
896
+ parse,
897
+ handleDraft,
898
+ commitDraft,
899
+ changeValue,
900
+ nudge
901
+ ]);
902
+ useImperativeHandle(forwardedRef, () => {
903
+ return {
904
+ focus() {
905
+ inputRef.current?.focus();
906
+ },
907
+ blur() {
908
+ inputRef.current?.blur();
909
+ }
910
+ };
911
+ }, []);
912
+ return /* @__PURE__ */ jsx(NumberInputProvider, {
913
+ value: context,
914
+ children: /* @__PURE__ */ jsx("div", {
915
+ ref: wheelRefCallback,
916
+ className: clsx("tremolo-number-input", className),
917
+ "aria-disabled": disabled,
918
+ "aria-readonly": readonly,
919
+ style,
920
+ ...props,
921
+ children
903
922
  })
904
923
  });
905
924
  }),
925
+ InputField,
906
926
  Stepper,
907
927
  IncrementStepper,
908
928
  DecrementStepper
909
929
  };
910
930
  //#endregion
911
- //#region src/hooks/useRefCallbackEvent.ts
912
- function useRefCallbackEvent(event, handler, options, deps = []) {
913
- return useMemo(() => {
914
- let cleanup;
915
- return (node) => {
916
- if (!node) {
917
- cleanup?.();
918
- return;
919
- }
920
- node.addEventListener(event, handler, options);
921
- cleanup = () => {
922
- node.removeEventListener(event, handler, options);
923
- };
924
- };
925
- }, deps);
926
- }
927
- //#endregion
928
- //#region src/hooks/usePianoDrag.ts
931
+ //#region src/components/Piano/keyboardShortcuts.ts
929
932
  /**
930
- * Internal
931
- * @returns [refCallback, pointerDownHandler]
932
- * @private
933
+ * Ready-made keyboard layouts. Both assume `noteRange.first` is a C.
933
934
  */
934
- function usePianoDrag({ baseElementRef, onDrag, onDragStart, onDragEnd }) {
935
- const dragged = useRef(false);
936
- const normalizedX = useRef(0);
937
- const normalizedY = useRef(0);
938
- const handleDrag = useCallback((event) => {
939
- if (!baseElementRef.current || !dragged.current) return;
940
- const isTouch = event instanceof TouchEvent;
941
- if (isTouch && event.cancelable) event.preventDefault();
942
- const { left, top, right, bottom } = baseElementRef.current.getBoundingClientRect();
943
- const mouseX = isTouch ? event.touches[0].clientX : event.clientX;
944
- const mouseY = isTouch ? event.touches[0].clientY : event.clientY;
945
- const nx = normalizeValue(mouseX, left, right);
946
- const ny = normalizeValue(mouseY, top, bottom);
947
- normalizedX.current = nx;
948
- normalizedY.current = ny;
949
- onDrag(nx, ny);
950
- }, [onDrag, baseElementRef]);
951
- const refHandler = useRefCallbackEvent("touchmove", handleDrag, { passive: false }, [onDrag]);
952
- const pointerDownHandler = useCallback((event) => {
953
- dragged.current = true;
954
- handleDrag(event);
955
- onDragStart?.(normalizedX.current, normalizedY.current);
956
- }, [handleDrag, onDragStart]);
957
- useEventListener(globalThis.window, "pointermove", handleDrag);
958
- useEventListener(globalThis.window, "pointerup", () => {
959
- if (!dragged.current) return;
960
- dragged.current = false;
961
- onDragEnd?.(normalizedX.current, normalizedY.current);
962
- });
963
- return {
964
- refHandler,
965
- pointerDownHandler
966
- };
967
- }
968
- //#endregion
969
- //#region src/components/Piano/context.tsx
970
- const createPianoStore = (initProps) => {
971
- const DEFAULT_PROPS = {
972
- noteRange: {
973
- first: 0,
974
- last: 127
975
- },
976
- glissando: true,
977
- midiMax: 127,
978
- fill: false,
979
- label: () => void 0,
980
- onPlayNote: () => {},
981
- onStopNote: () => {}
982
- };
983
- return createStore()(() => ({
984
- ...DEFAULT_PROPS,
985
- ...initProps,
986
- notePosition: initProps.notePosition
987
- }));
935
+ const SHORTCUTS = {
936
+ HOME_ROW: { keys: [
937
+ "a",
938
+ "w",
939
+ "s",
940
+ "e",
941
+ "d",
942
+ "f",
943
+ "t",
944
+ "g",
945
+ "y",
946
+ "h",
947
+ "u",
948
+ "j",
949
+ "k",
950
+ "o",
951
+ "l",
952
+ "p",
953
+ ";"
954
+ ] },
955
+ HOME_ROW_NATURAL: { keys: [
956
+ "a",
957
+ "",
958
+ "s",
959
+ "",
960
+ "d",
961
+ "f",
962
+ "",
963
+ "g",
964
+ "",
965
+ "h",
966
+ "",
967
+ "j",
968
+ "k",
969
+ "",
970
+ "l",
971
+ "",
972
+ ";"
973
+ ] }
988
974
  };
989
- const PianoContext = createContext(null);
990
- function PianoProvider({ children, ...props }) {
991
- const storeRef = useRef(null);
992
- if (!storeRef.current) storeRef.current = createPianoStore(props);
993
- useEffect(() => {
994
- if (storeRef.current) storeRef.current.setState(props);
995
- else storeRef.current = createPianoStore(props);
996
- }, [props]);
997
- return /* @__PURE__ */ jsx(PianoContext.Provider, {
998
- value: storeRef.current,
999
- children
1000
- });
1001
- }
1002
- function usePianoContext(selector) {
1003
- const store = useContext(PianoContext);
1004
- if (!store) throw new Error("Missing PianoContext.Provider in the tree");
1005
- return useStore(store, selector);
1006
- }
1007
- //#endregion
1008
- //#region src/components/Piano/KeyLabel.tsx
1009
- function KeyLabel({ label, className, wrapperClassName, wrapperStyle, __note, __label, ...props }) {
1010
- const index = getNoteRangeArray(usePianoContext((s) => s.noteRange)).indexOf(__note);
1011
- const content = label ? label(__note, index) : __label && __label(__note, index);
1012
- return (content != void 0 || content != null) && /* @__PURE__ */ jsx("div", {
1013
- className: clsx("tremolo-piano-key-label-wrapper", wrapperClassName),
1014
- style: wrapperStyle,
1015
- children: /* @__PURE__ */ jsx("div", {
1016
- className: clsx("tremolo-piano-key-label", className),
1017
- ...props,
1018
- children: content
1019
- })
975
+ /**
976
+ * Customizable piano component.
977
+ */
978
+ const Piano = { Root: forwardRef(function Root({ noteRange, glissando = true, midiMax = 127, keyboardShortcuts, fill = false, whiteKeyWidth = 40, keyGap = 1, blackKeyWidthRatio = .65, blackKeyHeightRatio = .6, height = fill ? "100%" : 160, style, className, label, keyProps, onPlayNote, onStopNote, ...props }, forwardedRef) {
979
+ const [node, setNode] = useState(null);
980
+ const [activeNotes, setActiveNotes] = useState([]);
981
+ /** Set while `fill` is on, measured from the parent. */
982
+ const [filledKeyWidth, setFilledKeyWidth] = useState(whiteKeyWidth);
983
+ const notes = useMemo(() => getNoteRangeArray(noteRange), [noteRange]);
984
+ const whiteKeyCount = useMemo(() => notes.filter(isWhiteKey).length, [notes]);
985
+ const layout = useMemo(() => ({
986
+ noteRange,
987
+ whiteKeyWidth: fill ? filledKeyWidth : whiteKeyWidth,
988
+ keyGap,
989
+ blackKeyWidthRatio,
990
+ blackKeyHeightRatio
991
+ }), [
992
+ noteRange,
993
+ fill,
994
+ filledKeyWidth,
995
+ whiteKeyWidth,
996
+ keyGap,
997
+ blackKeyWidthRatio,
998
+ blackKeyHeightRatio
999
+ ]);
1000
+ const latest = useRef({
1001
+ layout,
1002
+ glissando,
1003
+ midiMax,
1004
+ onPlayNote,
1005
+ onStopNote
1020
1006
  });
1021
- }
1022
- const KeyImpl = forwardRef(({ keyType, noteNumber, width, height, bg, color, activeBg, activeColor, className, style, children, __width, ...props }, ref) => {
1023
- const [played, setPlayed] = useState(false);
1024
- const midiMax = usePianoContext((s) => s.midiMax);
1025
- const fill = usePianoContext((s) => s.fill);
1026
- const onPlayNote = usePianoContext((s) => s.onPlayNote);
1027
- const onStopNote = usePianoContext((s) => s.onStopNote);
1028
- const label = usePianoContext((s) => s.label);
1029
- const position = usePianoContext((s) => s.notePosition)(noteNumber);
1030
- const disabled = noteNumber > midiMax;
1031
- const colors = {
1032
- "--bg": bg,
1033
- "--color": color,
1034
- "--active-bg": activeBg,
1035
- "--active-color": activeColor
1036
- };
1037
- useImperativeHandle(ref, () => {
1038
- return {
1039
- play(velocity) {
1040
- if (disabled) return;
1041
- setPlayed(true);
1042
- if (onPlayNote) onPlayNote(noteNumber, velocity);
1043
- },
1044
- stop() {
1045
- setPlayed(false);
1046
- if (onStopNote) onStopNote(noteNumber);
1047
- },
1048
- played() {
1049
- return played;
1050
- }
1007
+ const instanceRef = useRef(null);
1008
+ useEffect(() => {
1009
+ if (!node) return;
1010
+ const instance = createPianoInput(node, {
1011
+ layout: latest.current.layout,
1012
+ glissando: latest.current.glissando,
1013
+ midiMax: latest.current.midiMax,
1014
+ onPlayNote: (note, velocity) => latest.current.onPlayNote?.(note, velocity),
1015
+ onStopNote: (note) => latest.current.onStopNote?.(note),
1016
+ onActiveNotesChange: setActiveNotes
1017
+ });
1018
+ instanceRef.current = instance;
1019
+ return () => {
1020
+ instanceRef.current = null;
1021
+ instance.destroy();
1051
1022
  };
1023
+ }, [node]);
1024
+ useEffect(() => {
1025
+ latest.current = {
1026
+ layout,
1027
+ glissando,
1028
+ midiMax,
1029
+ onPlayNote,
1030
+ onStopNote
1031
+ };
1032
+ instanceRef.current?.update({
1033
+ layout,
1034
+ glissando,
1035
+ midiMax
1036
+ });
1037
+ });
1038
+ useEffect(() => {
1039
+ if (!fill || !node) return;
1040
+ const parent = node.parentElement;
1041
+ if (!parent) throw new Error("doesn't have a parent element.");
1042
+ const resizeObserver = new ResizeObserver(() => {
1043
+ setFilledKeyWidth(node.clientWidth / whiteKeyCount - keyGap);
1044
+ });
1045
+ resizeObserver.observe(parent);
1046
+ return () => resizeObserver.disconnect();
1052
1047
  }, [
1053
- disabled,
1054
- noteNumber,
1055
- onPlayNote,
1056
- onStopNote,
1057
- played
1048
+ fill,
1049
+ node,
1050
+ whiteKeyCount,
1051
+ keyGap
1058
1052
  ]);
1059
- let keyLabelProps = {};
1060
- if (children != void 0) React.Children.forEach(children, (child) => {
1061
- if (React.isValidElement(child)) if (child.type == KeyLabel) keyLabelProps = child.props;
1062
- else throw new Error("only <KeyLabel>");
1063
- else throw new Error("children is an invalid element.");
1053
+ /** The note a shortcut key plays, or null when it has none. */
1054
+ function shortcutNote(key) {
1055
+ if (!keyboardShortcuts || key === "") return null;
1056
+ const index = keyboardShortcuts.keys.indexOf(key);
1057
+ return index === -1 ? null : noteRange.first + index;
1058
+ }
1059
+ useEventListener(globalThis.window, "keydown", (e) => {
1060
+ if (e.repeat) return;
1061
+ const note = shortcutNote(e.key);
1062
+ if (note !== null) instanceRef.current?.noteOn(note, { source: "keyboard" });
1063
+ });
1064
+ useEventListener(globalThis.window, "keyup", (e) => {
1065
+ const note = shortcutNote(e.key);
1066
+ if (note !== null) instanceRef.current?.noteOff(note, { source: "keyboard" });
1064
1067
  });
1068
+ useImperativeHandle(forwardedRef, () => ({
1069
+ playNote: (note, velocity) => instanceRef.current?.noteOn(note, {
1070
+ source: "api",
1071
+ velocity
1072
+ }),
1073
+ stopNote: (note) => instanceRef.current?.noteOff(note, { source: "api" })
1074
+ }), []);
1065
1075
  return /* @__PURE__ */ jsx("div", {
1066
- className: clsx(`tremolo-piano-${keyType}-key`, className),
1067
- "aria-disabled": disabled,
1068
- "data-active": played,
1076
+ ref: setNode,
1077
+ className: clsx("tremolo-piano", className),
1069
1078
  style: {
1070
- ...colors,
1071
- left: position,
1072
- width: fill ? __width : width,
1079
+ width: fill ? "100%" : pianoWidth(layout),
1073
1080
  height,
1074
1081
  ...style
1075
1082
  },
1076
1083
  ...props,
1077
- children: /* @__PURE__ */ jsx(KeyLabel, {
1078
- label,
1079
- __note: noteNumber,
1080
- ...keyLabelProps
1081
- })
1082
- });
1083
- });
1084
- const WhiteKey = forwardRef(({ width = 40, height = "100%", onContextMenu = (e) => e.preventDefault(), ...props }, ref) => {
1085
- return /* @__PURE__ */ jsx(KeyImpl, {
1086
- ref,
1087
- keyType: "white",
1088
- width,
1089
- height,
1090
- onContextMenu,
1091
- ...props
1092
- });
1093
- });
1094
- const BlackKey = forwardRef(({ width = 26, height = "60%", onContextMenu = (e) => e.preventDefault(), ...props }, ref) => {
1095
- return /* @__PURE__ */ jsx(KeyImpl, {
1096
- ref,
1097
- keyType: "black",
1098
- width,
1099
- height,
1100
- onContextMenu,
1101
- ...props
1102
- });
1103
- });
1104
- //#endregion
1105
- //#region src/components/Piano/keyboardShortcuts.ts
1106
- const SHORTCUTS = { HOME_ROW: { keys: [
1107
- "a",
1108
- "w",
1109
- "s",
1110
- "e",
1111
- "d",
1112
- "f",
1113
- "t",
1114
- "g",
1115
- "y",
1116
- "h",
1117
- "u",
1118
- "j",
1119
- "k",
1120
- "o",
1121
- "l",
1122
- "p",
1123
- ";"
1124
- ] } };
1125
- //#endregion
1126
- //#region src/components/Piano/index.tsx
1127
- /**
1128
- * [noteRange.first, noteRange.first + 1 ..., noteRange.last]
1129
- */
1130
- function getNoteRangeArray(noteRange) {
1131
- return Array.from({ length: noteRange.last - noteRange.first + 1 }, (_, i) => i + noteRange.first);
1132
- }
1133
- const blackPerWhiteWidth = 26 / 40;
1134
- /**
1135
- * Customizable piano component.
1136
- */
1137
- const Piano = {
1138
- Root: forwardRef(({ noteRange, glissando = true, midiMax = 127, keyboardShortcuts, fill = false, height = fill ? "100%" : 160, whiteNoteWidth: _whiteNoteWidth = 40, style, className, onPlayNote, onStopNote, label, children, onPointerDown, ...props }, forwardedRef) => {
1139
- const [whiteNoteWidth, setWhiteNoteWidth] = useState(_whiteNoteWidth);
1140
- const keyRefs = useRef([]);
1141
- for (let i = 0; i < noteRange.last - noteRange.first + 1; i++) keyRefs.current[i] = createRef();
1142
- const pianoRef = useRef(null);
1143
- const hitKeyIndex = useRef(-1);
1144
- const noteRangeArray = getNoteRangeArray(noteRange);
1145
- const whiteNotes = noteRangeArray.filter((v) => isWhiteKey(v));
1146
- const blackNotes = noteRangeArray.filter((v) => isBlackKey(v));
1147
- const whiteNoteCount = whiteNotes.length;
1148
- const padding = 1;
1149
- const staticWidth = (whiteNoteWidth + padding) * whiteNoteCount;
1150
- const childrenWithProps = useMemo(() => {
1151
- return children && React.Children.map(children, (child, index) => {
1152
- if (React.isValidElement(child)) {
1153
- const __width = child.type == WhiteKey ? whiteNoteWidth : whiteNoteWidth * .65;
1154
- const props = {
1155
- ref: keyRefs.current[index],
1156
- __width
1157
- };
1158
- return React.cloneElement(child, props);
1159
- }
1160
- return child;
1161
- });
1162
- }, [children, whiteNoteWidth]);
1163
- const notePosition = useCallback((note) => {
1164
- const pitchPositions = {
1165
- C: 0,
1166
- "C#": 1,
1167
- D: 1,
1168
- "D#": 2,
1169
- E: 2,
1170
- F: 3,
1171
- "F#": 4,
1172
- G: 4,
1173
- "G#": 5,
1174
- A: 5,
1175
- "A#": 6,
1176
- B: 6
1177
- };
1178
- const blackNoteWidth = whiteNoteWidth * blackPerWhiteWidth;
1179
- const padding = 1;
1180
- const targetNoteKey = noteKey(note);
1181
- const firstNoteKey = noteKey(noteRange.first);
1182
- const octave = Math.floor((note - noteRange.first) / 12);
1183
- const octaveOffset = noteKeys.indexOf(firstNoteKey) > noteKeys.indexOf(targetNoteKey) ? 1 : 0;
1184
- const w = whiteNoteWidth + padding;
1185
- const pos = pitchPositions[targetNoteKey] - pitchPositions[firstNoteKey];
1186
- const blackKeyOffset = isBlackKey(note) ? blackNoteWidth / 2 : 0;
1187
- return pos * w + (octave + octaveOffset) * 7 * w - blackKeyOffset;
1188
- }, [noteRange.first, whiteNoteWidth]);
1189
- const getHitKeyIndex = useCallback((x, y) => {
1190
- if (!pianoRef.current) return -1;
1191
- const containerHeight = pianoRef.current.clientHeight;
1192
- const notes = [...blackNotes, ...whiteNotes];
1193
- for (let i = 0; i < notes.length; i++) {
1194
- const note = notes[i];
1195
- const pos = notePosition(note);
1196
- const w = isWhiteKey(note) ? whiteNoteWidth : whiteNoteWidth * blackPerWhiteWidth;
1197
- const h = isWhiteKey(note) ? containerHeight : containerHeight * .6;
1198
- if (pos <= x && x < pos + w && 0 <= y && y < h) return note;
1199
- }
1200
- return -1;
1201
- }, [
1202
- blackNotes,
1203
- notePosition,
1204
- whiteNoteWidth,
1205
- whiteNotes
1206
- ]);
1207
- const onDrag = useCallback((perX, perY) => {
1208
- if (!pianoRef.current) return;
1209
- const note = getHitKeyIndex(perX * staticWidth, perY * pianoRef.current.clientHeight);
1210
- const index = noteRangeArray.indexOf(note);
1211
- if (index == -1) return;
1212
- if (hitKeyIndex.current != index) {
1213
- keyRefs.current[hitKeyIndex.current]?.current?.stop();
1214
- keyRefs.current[index]?.current?.play();
1215
- hitKeyIndex.current = index;
1216
- }
1217
- }, [
1218
- getHitKeyIndex,
1219
- noteRangeArray,
1220
- staticWidth
1221
- ]);
1222
- const onDragEnd = useCallback(() => {
1223
- if (keyRefs.current[hitKeyIndex.current]?.current?.played()) keyRefs.current[hitKeyIndex.current]?.current?.stop();
1224
- hitKeyIndex.current = -1;
1225
- }, [keyRefs]);
1226
- useEffect(() => {
1227
- if (fill && pianoRef.current) {
1228
- const parent = pianoRef.current.parentElement;
1229
- if (!parent) throw new Error("doesn't have a parent element.");
1230
- const resizeObserver = new ResizeObserver(() => {
1231
- const w = pianoRef.current.clientWidth;
1232
- setWhiteNoteWidth(w / whiteNoteCount - padding);
1233
- });
1234
- resizeObserver.observe(parent);
1235
- return () => {
1236
- resizeObserver.unobserve(parent);
1237
- };
1238
- } else setWhiteNoteWidth(_whiteNoteWidth);
1239
- }, [
1240
- fill,
1241
- _whiteNoteWidth,
1242
- whiteNoteCount
1243
- ]);
1244
- const { refHandler: touchMoveRefCallback, pointerDownHandler } = usePianoDrag({
1245
- baseElementRef: pianoRef,
1246
- onDrag,
1247
- onDragEnd
1248
- });
1249
- useEventListener(globalThis.window, "keydown", (e) => {
1250
- if (e.repeat) return;
1251
- if (!keyboardShortcuts) return;
1252
- const index = keyboardShortcuts.keys.indexOf(e.key);
1253
- if (index != -1) {
1254
- if (!keyRefs.current[index]?.current?.played()) keyRefs.current[index]?.current?.play();
1255
- }
1256
- });
1257
- useEventListener(globalThis.window, "keyup", (e) => {
1258
- if (e.repeat) return;
1259
- if (!keyboardShortcuts) return;
1260
- const index = keyboardShortcuts.keys.indexOf(e.key);
1261
- if (index != -1) keyRefs.current[index]?.current?.stop();
1262
- });
1263
- useImperativeHandle(forwardedRef, () => {
1264
- return {
1265
- playNote(note, velocity) {
1266
- const index = noteRangeArray.indexOf(note);
1267
- if (index != -1) {
1268
- if (!keyRefs.current[index]?.current?.played()) keyRefs.current[index]?.current?.play(velocity);
1269
- } else onPlayNote?.(note, velocity);
1270
- },
1271
- stopNote(note) {
1272
- const index = noteRangeArray.indexOf(note);
1273
- if (index != -1) keyRefs.current[index]?.current?.stop();
1274
- else onStopNote?.(note);
1275
- }
1084
+ children: notes.map((note, index) => {
1085
+ const keyType = isWhiteKey(note) ? "white" : "black";
1086
+ const state = {
1087
+ index,
1088
+ keyType,
1089
+ active: activeNotes.includes(note),
1090
+ disabled: note > midiMax
1276
1091
  };
1277
- }, [
1278
- noteRangeArray,
1279
- onPlayNote,
1280
- onStopNote
1281
- ]);
1282
- return /* @__PURE__ */ jsx(PianoProvider, {
1283
- notePosition,
1284
- noteRange,
1285
- glissando,
1286
- midiMax,
1287
- fill,
1288
- onPlayNote,
1289
- onStopNote,
1290
- label,
1291
- children: /* @__PURE__ */ jsx("div", {
1292
- ref: (div) => {
1293
- pianoRef.current = div;
1294
- touchMoveRefCallback(div);
1295
- },
1296
- className: clsx("tremolo-piano", className),
1092
+ const { className: keyClassName, style: keyStyle, ...rest } = keyProps?.(note, state) ?? {};
1093
+ const content = label?.(note, state);
1094
+ return /* @__PURE__ */ jsx("div", {
1095
+ className: clsx(`tremolo-piano-${keyType}-key`, keyClassName),
1096
+ "data-note": note,
1097
+ "data-note-key": noteKey(note),
1098
+ "data-active": state.active,
1099
+ "aria-disabled": state.disabled,
1100
+ ...rest,
1297
1101
  style: {
1298
- width: fill ? "100%" : staticWidth,
1299
- height,
1300
- ...style
1301
- },
1302
- onPointerDown: (event) => {
1303
- pointerDownHandler(event);
1304
- onPointerDown?.(event);
1102
+ ...keyStyle,
1103
+ left: notePosition(note, layout),
1104
+ width: keyType === "white" ? layout.whiteKeyWidth : blackKeyWidth(layout),
1105
+ height: keyType === "white" ? "100%" : `${blackKeyHeightRatio * 100}%`
1305
1106
  },
1306
- ...props,
1307
- children: childrenWithProps || noteRangeArray.map((note, index) => isWhiteKey(note) ? /* @__PURE__ */ jsx(WhiteKey, {
1308
- ref: keyRefs.current[index],
1309
- noteNumber: note,
1310
- __width: whiteNoteWidth
1311
- }, note) : /* @__PURE__ */ jsx(BlackKey, {
1312
- ref: keyRefs.current[index],
1313
- noteNumber: note,
1314
- __width: whiteNoteWidth * blackPerWhiteWidth
1315
- }, note))
1316
- })
1317
- });
1318
- }),
1319
- WhiteKey,
1320
- BlackKey,
1321
- KeyLabel
1322
- };
1107
+ children: content !== "" && content !== null && content !== void 0 && /* @__PURE__ */ jsx("div", {
1108
+ className: "tremolo-piano-key-label-wrapper",
1109
+ children: /* @__PURE__ */ jsx("div", {
1110
+ className: "tremolo-piano-key-label",
1111
+ children: content
1112
+ })
1113
+ })
1114
+ }, note);
1115
+ })
1116
+ });
1117
+ }) };
1323
1118
  //#endregion
1324
1119
  //#region src/components/PointsEditor/Background.tsx
1325
1120
  function Background({ className, children, ...props }) {
@@ -1331,111 +1126,24 @@ function Background({ className, children, ...props }) {
1331
1126
  }
1332
1127
  //#endregion
1333
1128
  //#region src/components/PointsEditor/context.tsx
1334
- const createPointsEditorStore = (initProps) => {
1335
- const DEFAULT_PROPS = {
1336
- containerElementRef: createRef(),
1337
- disabled: false,
1338
- readonly: false,
1339
- externalStyles: {
1340
- userSelectNone: true,
1341
- cursor: "grabbing"
1342
- }
1343
- };
1344
- return createStore()((set) => ({
1345
- ...DEFAULT_PROPS,
1346
- ...initProps,
1347
- setContainerElementRef: (containerElementRef) => {
1348
- set(() => ({ containerElementRef }));
1349
- }
1350
- }));
1351
- };
1352
1129
  const PointsEditorContext = createContext(null);
1353
- function PointsEditorProvider({ children, ...props }) {
1354
- const storeRef = useRef(null);
1355
- if (!storeRef.current) storeRef.current = createPointsEditorStore(props);
1356
- useEffect(() => {
1357
- if (storeRef.current) storeRef.current.setState(props);
1358
- else storeRef.current = createPointsEditorStore(props);
1359
- }, [props]);
1360
- return /* @__PURE__ */ jsx(PointsEditorContext.Provider, {
1361
- value: storeRef.current,
1362
- children
1363
- });
1364
- }
1130
+ const PointsEditorProvider = PointsEditorContext.Provider;
1365
1131
  function usePointsEditorContext(selector) {
1366
- const store = useContext(PointsEditorContext);
1367
- if (!store) throw new Error("Missing PointsEditorContext.Provider in the tree");
1368
- return useStore(store, selector);
1132
+ const context = useContext(PointsEditorContext);
1133
+ if (!context) throw new Error("Missing PointsEditorContext.Provider in the tree");
1134
+ return selector ? selector(context) : context;
1369
1135
  }
1370
1136
  //#endregion
1371
1137
  //#region src/components/PointsEditor/Container.tsx
1372
- function Container({ className, children, ...props }) {
1373
- const containerElementRef = useRef(null);
1374
- usePointsEditorContext((s) => s.setContainerElementRef)(containerElementRef);
1138
+ function Container({ children, className, ref, ...props }) {
1375
1139
  return /* @__PURE__ */ jsx("div", {
1376
- ref: containerElementRef,
1140
+ ref: useComposedRefs(ref, usePointsEditorContext((s) => s.containerRef)),
1377
1141
  className: clsx("tremolo-points-editor-container", className),
1378
1142
  ...props,
1379
1143
  children
1380
1144
  });
1381
1145
  }
1382
1146
  //#endregion
1383
- //#region src/hooks/useDragWithElement.ts
1384
- /**
1385
- * Track a pointer drag, reporting the position normalized against
1386
- * the bounding rect of `baseElementRef`.
1387
- *
1388
- * @returns a ref callback for the element that starts the drag, and whether a drag is in progress
1389
- */
1390
- function useDragWithElement({ baseElementRef, updateOnPointerDown = false, cursor, onDrag, onDragStart, onDragEnd }) {
1391
- const [dragging, setDragging] = useState(false);
1392
- const normalizedX = useRef(0);
1393
- const normalizedY = useRef(0);
1394
- const dragHandler = useCallbackRef(onDrag);
1395
- const dragStartHandler = useCallbackRef(onDragStart);
1396
- const dragEndHandler = useCallbackRef(onDragEnd);
1397
- const update = useCallback((clientX, clientY) => {
1398
- const base = baseElementRef.current;
1399
- if (!base) return false;
1400
- const { left, top, right, bottom } = base.getBoundingClientRect();
1401
- normalizedX.current = normalizeValue(clientX, left, right);
1402
- normalizedY.current = normalizeValue(clientY, top, bottom);
1403
- return true;
1404
- }, [baseElementRef]);
1405
- const [node, setNode] = useState(null);
1406
- useEffect(() => {
1407
- if (!node) return;
1408
- const instance = createDrag(node, {
1409
- cursor,
1410
- onDragStart: ({ clientX, clientY }) => {
1411
- setDragging(true);
1412
- if (update(clientX, clientY) && updateOnPointerDown) dragHandler(normalizedX.current, normalizedY.current);
1413
- dragStartHandler(normalizedX.current, normalizedY.current);
1414
- },
1415
- onDrag: ({ clientX, clientY }) => {
1416
- if (update(clientX, clientY)) dragHandler(normalizedX.current, normalizedY.current);
1417
- },
1418
- onDragEnd: () => {
1419
- setDragging(false);
1420
- dragEndHandler(normalizedX.current, normalizedY.current);
1421
- }
1422
- });
1423
- return () => instance.destroy();
1424
- }, [
1425
- node,
1426
- update,
1427
- updateOnPointerDown,
1428
- cursor,
1429
- dragHandler,
1430
- dragStartHandler,
1431
- dragEndHandler
1432
- ]);
1433
- return {
1434
- refCallback: setNode,
1435
- dragging
1436
- };
1437
- }
1438
- //#endregion
1439
1147
  //#region src/components/PointsEditor/Point.tsx
1440
1148
  function clampPoint(point, min, max) {
1441
1149
  const { x, y } = point;
@@ -1444,22 +1152,33 @@ function clampPoint(point, min, max) {
1444
1152
  y: clamp(y, min?.y ?? 0, max?.y ?? 1)
1445
1153
  };
1446
1154
  }
1447
- function Point({ value, min, max, size, width = 16, height = 16, color, disabled, readonly, onChange, onDragStart, onDragEnd, className, style, onPointerDown, ...props }) {
1448
- const containerElementRef = usePointsEditorContext((s) => s.containerElementRef);
1449
- const externalStyles = usePointsEditorContext((s) => s.externalStyles);
1450
- const __disabled = usePointsEditorContext((s) => s.disabled);
1451
- const __readonly = usePointsEditorContext((s) => s.readonly);
1452
- const { refCallback: dragRefCallback, dragging } = useDragWithElement({
1453
- baseElementRef: containerElementRef,
1155
+ /**
1156
+ * A point is placed by its position within the container, so its value is a
1157
+ * position: 0..1 on each axis, with y growing downwards.
1158
+ */
1159
+ const AXIS = {
1160
+ min: 0,
1161
+ max: 1
1162
+ };
1163
+ function Point({ value, min, max, size, width = 16, height = 16, color, disabled: _disabled, readonly: _readonly, wheel: _wheel, keyboard: _keyboard, onChange, onDragStart, onDragEnd, className, style, onPointerDown, onKeyDown, ...props }) {
1164
+ const { containerRef, externalStyles, disabled: rootDisabled, readonly: rootReadonly, wheel: rootWheel, keyboard: rootKeyboard } = usePointsEditorContext();
1165
+ const disabled = _disabled ?? rootDisabled;
1166
+ const readonly = _readonly ?? rootReadonly;
1167
+ const wheel = _wheel === void 0 ? rootWheel : _wheel;
1168
+ const keyboard = _keyboard === void 0 ? rootKeyboard : _keyboard;
1169
+ const [element, setElement] = useState(null);
1170
+ const { refCallback: dragRefCallback, dragging } = useDragValue({
1171
+ axis: AXIS,
1172
+ baseElementRef: containerRef,
1454
1173
  cursor: readonly ? void 0 : externalStyles.cursor,
1455
- onDrag: (x, y) => {
1174
+ onChange: ([x, y]) => {
1456
1175
  if (readonly) return;
1457
1176
  onChange?.(clampPoint({
1458
1177
  x,
1459
1178
  y
1460
1179
  }, min, max));
1461
1180
  },
1462
- onDragStart: (x, y) => {
1181
+ onDragStart: ([x, y]) => {
1463
1182
  if (readonly) return;
1464
1183
  if (externalStyles.userSelectNone) addUserSelectNone();
1465
1184
  onDragStart?.(clampPoint({
@@ -1467,7 +1186,7 @@ function Point({ value, min, max, size, width = 16, height = 16, color, disabled
1467
1186
  y
1468
1187
  }, min, max));
1469
1188
  },
1470
- onDragEnd: (x, y) => {
1189
+ onDragEnd: ([x, y]) => {
1471
1190
  if (readonly) return;
1472
1191
  if (externalStyles.userSelectNone) removeUserSelectNone();
1473
1192
  onDragEnd?.(clampPoint({
@@ -1476,13 +1195,45 @@ function Point({ value, min, max, size, width = 16, height = 16, color, disabled
1476
1195
  }, min, max));
1477
1196
  }
1478
1197
  });
1198
+ const nudge = useCallback((axis, direction, option) => {
1199
+ const next = applyDelta(value[axis], direction, option, AXIS);
1200
+ onChange?.(clampPoint({
1201
+ ...value,
1202
+ [axis]: next
1203
+ }, min, max));
1204
+ }, [
1205
+ value,
1206
+ min,
1207
+ max,
1208
+ onChange
1209
+ ]);
1210
+ useWheel((event) => {
1211
+ if (!onChange || readonly || !wheel) return;
1212
+ if (!element || element.ownerDocument.activeElement !== element) return;
1213
+ event.preventDefault();
1214
+ nudge(event.shiftKey ? "x" : "y", event.deltaY < 0 ? -1 : 1, wheel);
1215
+ }, { target: containerRef });
1216
+ const refCallback = useComposedRefs(dragRefCallback, setElement);
1217
+ const handleKeyDown = (event) => {
1218
+ if (!onChange || readonly || !keyboard) return;
1219
+ const key = event.key;
1220
+ if ([
1221
+ "ArrowRight",
1222
+ "ArrowLeft",
1223
+ "ArrowUp",
1224
+ "ArrowDown"
1225
+ ].includes(key)) {
1226
+ event.preventDefault();
1227
+ nudge(key === "ArrowRight" || key === "ArrowLeft" ? "x" : "y", key === "ArrowLeft" || key === "ArrowUp" ? -1 : 1, keyboard);
1228
+ }
1229
+ };
1479
1230
  const colors = { "--color": color };
1480
1231
  return /* @__PURE__ */ jsx("div", {
1481
- ref: dragRefCallback,
1232
+ ref: refCallback,
1482
1233
  className: clsx("tremolo-points-editor-point", className),
1483
1234
  tabIndex: 0,
1484
- "aria-disabled": disabled != void 0 ? disabled : __disabled,
1485
- "aria-readonly": readonly != void 0 ? readonly : __readonly,
1235
+ "aria-disabled": disabled,
1236
+ "aria-readonly": readonly,
1486
1237
  "data-dragging": dragging,
1487
1238
  style: {
1488
1239
  ...colors,
@@ -1493,30 +1244,51 @@ function Point({ value, min, max, size, width = 16, height = 16, color, disabled
1493
1244
  ...style
1494
1245
  },
1495
1246
  onPointerDown,
1247
+ onKeyDown: (event) => {
1248
+ handleKeyDown(event);
1249
+ onKeyDown?.(event);
1250
+ },
1496
1251
  ...props
1497
1252
  });
1498
1253
  }
1499
1254
  //#endregion
1500
1255
  //#region src/components/PointsEditor/index.tsx
1501
- const defaultExternalStyles$2 = {
1502
- userSelectNone: true,
1503
- cursor: "grabbing"
1504
- };
1256
+ /**
1257
+ * A point moves over 0..1 in both axes, so a nudge of 0.01 crosses the editor
1258
+ * in a hundred steps whatever its pixel size.
1259
+ */
1260
+ const DEFAULT_INPUT_EVENT_OPTION = ["normalized", .01];
1505
1261
  /**
1506
1262
  * Multiple Point Controller
1507
1263
  */
1508
1264
  const PointsEditor = {
1509
- Root: forwardRef(({ width = 200, height = 100, disabled = false, readonly = false, externalStyles: _externalStyles, style, className, children, ...props }, forwardedRef) => {
1265
+ Root: forwardRef(({ width = 200, height = 100, disabled = false, readonly = false, wheel = DEFAULT_INPUT_EVENT_OPTION, keyboard = DEFAULT_INPUT_EVENT_OPTION, externalStyles, style, className, children, ...props }, forwardedRef) => {
1266
+ const containerRef = useRef(null);
1267
+ const { userSelectNone = true, cursor = "grabbing" } = externalStyles ?? {};
1510
1268
  return /* @__PURE__ */ jsx(PointsEditorProvider, {
1511
- disabled,
1512
- readonly,
1513
- externalStyles: {
1514
- ...defaultExternalStyles$2,
1515
- ..._externalStyles
1516
- },
1269
+ value: useMemo(() => ({
1270
+ disabled,
1271
+ readonly,
1272
+ wheel,
1273
+ keyboard,
1274
+ externalStyles: {
1275
+ userSelectNone,
1276
+ cursor
1277
+ },
1278
+ containerRef
1279
+ }), [
1280
+ disabled,
1281
+ readonly,
1282
+ wheel,
1283
+ keyboard,
1284
+ userSelectNone,
1285
+ cursor
1286
+ ]),
1517
1287
  children: /* @__PURE__ */ jsx("div", {
1518
1288
  ref: forwardedRef,
1519
1289
  className: clsx("tremolo-points-editor", className),
1290
+ "aria-disabled": disabled,
1291
+ "aria-readonly": readonly,
1520
1292
  style: {
1521
1293
  width,
1522
1294
  height,
@@ -1533,59 +1305,33 @@ const PointsEditor = {
1533
1305
  };
1534
1306
  //#endregion
1535
1307
  //#region src/components/Slider/context.tsx
1536
- const createSliderStore = (initProps) => {
1537
- const DEFAULT_PROPS = {
1538
- min: 0,
1539
- max: 0,
1540
- step: 0,
1541
- skew: 0,
1542
- vertical: false,
1543
- reverse: false,
1544
- disabled: false,
1545
- readonly: false
1546
- };
1547
- return createStore()(() => ({
1548
- ...DEFAULT_PROPS,
1549
- ...initProps
1550
- }));
1551
- };
1552
1308
  const SliderContext = createContext(null);
1553
- function SliderProvider({ children, ...props }) {
1554
- const storeRef = useRef(null);
1555
- if (!storeRef.current) storeRef.current = createSliderStore(props);
1556
- useEffect(() => {
1557
- if (storeRef.current) storeRef.current.setState(props);
1558
- else storeRef.current = createSliderStore(props);
1559
- }, [props]);
1560
- return /* @__PURE__ */ jsx(SliderContext.Provider, {
1561
- value: storeRef.current,
1562
- children
1563
- });
1564
- }
1309
+ const SliderProvider = SliderContext.Provider;
1565
1310
  function useSliderContext(selector) {
1566
- const store = useContext(SliderContext);
1567
- if (!store) throw new Error("Missing SliderContext.Provider in the tree");
1568
- return useStore(store, selector);
1311
+ const context = useContext(SliderContext);
1312
+ if (!context) throw new Error("Missing SliderContext.Provider in the tree");
1313
+ return selector ? selector(context) : context;
1569
1314
  }
1570
1315
  //#endregion
1571
- //#region src/components/Slider/ScaleOption.tsx
1572
- function ScaleOption({ value, type = "mark-number", label, thickness = 1, length = "0.5rem", gap = 2, labelWidth, classes, styles, className, style, ...props }) {
1316
+ //#region src/components/Slider/MarksOption.tsx
1317
+ function MarksOption({ value, type = "mark-number", label, thickness = 1, length = "0.5rem", gap = 2, labelWidth, classes, styles, className, style, ...props }) {
1573
1318
  const min = useSliderContext((s) => s.min);
1574
1319
  const max = useSliderContext((s) => s.max);
1575
- const skew = useSliderContext((s) => s.skew);
1320
+ const scale = useSliderContext((s) => s.scale);
1576
1321
  const vertical = useSliderContext((s) => s.vertical);
1577
1322
  const reverse = useSliderContext((s) => s.reverse);
1578
- const calcPercent = useCallback((rawValue) => {
1579
- return toFixed(xor(vertical, reverse) ? 100 - normalizeValue(rawValue, min, max, skew) * 100 : normalizeValue(rawValue, min, max, skew) * 100);
1323
+ const calcPercent = useCallback((value) => {
1324
+ const percent = scale.normalize(value, min, max) * 100;
1325
+ return toFixed(xor(vertical, reverse) ? 100 - percent : percent);
1580
1326
  }, [
1581
1327
  vertical,
1582
1328
  reverse,
1583
1329
  max,
1584
1330
  min,
1585
- skew
1331
+ scale
1586
1332
  ]);
1587
1333
  return /* @__PURE__ */ jsxs("div", {
1588
- className: clsx("tremolo-slider-scale-option", className),
1334
+ className: clsx("tremolo-slider-marks-option", className),
1589
1335
  style: {
1590
1336
  left: !vertical ? `${calcPercent(value)}%` : void 0,
1591
1337
  top: vertical ? `${calcPercent(value)}%` : void 0,
@@ -1594,7 +1340,7 @@ function ScaleOption({ value, type = "mark-number", label, thickness = 1, length
1594
1340
  "data-vertical": vertical,
1595
1341
  ...props,
1596
1342
  children: [type != "number" && /* @__PURE__ */ jsx("div", {
1597
- className: clsx("tremolo-slider-scale-option-mark", classes?.mark),
1343
+ className: clsx("tremolo-slider-marks-option-mark", classes?.mark),
1598
1344
  style: {
1599
1345
  width: !vertical ? thickness : length,
1600
1346
  height: vertical ? thickness : length,
@@ -1604,7 +1350,7 @@ function ScaleOption({ value, type = "mark-number", label, thickness = 1, length
1604
1350
  },
1605
1351
  "data-vertical": vertical
1606
1352
  }), type != "mark" && /* @__PURE__ */ jsx("div", {
1607
- className: clsx("tremolo-slider-scale-option-label", classes?.label),
1353
+ className: clsx("tremolo-slider-marks-option-label", classes?.label),
1608
1354
  style: {
1609
1355
  width: labelWidth,
1610
1356
  ...styles?.label
@@ -1629,8 +1375,8 @@ function generateOptionsList(options, min, max, step) {
1629
1375
  return optionsList;
1630
1376
  }
1631
1377
  //#endregion
1632
- //#region src/components/Slider/Scale.tsx
1633
- function Scale({ gap = 6, options, children, className, style, ...props }) {
1378
+ //#region src/components/Slider/Marks.tsx
1379
+ function Marks({ gap = 6, options, children, className, style, ...props }) {
1634
1380
  const min = useSliderContext((s) => s.min);
1635
1381
  const max = useSliderContext((s) => s.max);
1636
1382
  const step = useSliderContext((s) => s.step);
@@ -1639,7 +1385,7 @@ function Scale({ gap = 6, options, children, className, style, ...props }) {
1639
1385
  const optionsList = options ? generateOptionsList(options, min, max, step) : [];
1640
1386
  if (xor(vertical, reverse)) optionsList.reverse();
1641
1387
  return /* @__PURE__ */ jsx("div", {
1642
- className: clsx("tremolo-slider-scale", className),
1388
+ className: clsx("tremolo-slider-marks", className),
1643
1389
  style: {
1644
1390
  marginLeft: vertical ? gap : void 0,
1645
1391
  marginTop: !vertical ? gap : void 0,
@@ -1647,72 +1393,70 @@ function Scale({ gap = 6, options, children, className, style, ...props }) {
1647
1393
  },
1648
1394
  ...props,
1649
1395
  children: options ? optionsList.map(({ value, type }, index) => {
1650
- return /* @__PURE__ */ jsx(ScaleOption, {
1396
+ return /* @__PURE__ */ jsx(MarksOption, {
1651
1397
  value,
1652
1398
  type
1653
1399
  }, index);
1654
1400
  }) : children
1655
1401
  });
1656
1402
  }
1657
- const Thumb$1 = forwardRef(({ size, width = 22, height = 22, color, children, className, style, __percent }, ref) => {
1658
- const wrapperRef = useRef(null);
1659
- const vertical = useSliderContext((s) => s.vertical);
1660
- const disabled = useSliderContext((s) => s.disabled);
1661
- const readonly = useSliderContext((s) => s.readonly);
1662
- useImperativeHandle(ref, () => {
1663
- return {
1664
- focus() {
1665
- wrapperRef.current?.focus();
1666
- },
1667
- blur() {
1668
- wrapperRef.current?.blur();
1669
- }
1670
- };
1671
- }, []);
1403
+ //#endregion
1404
+ //#region src/components/Slider/Thumb.tsx
1405
+ function Thumb$1({ color, children, className, style, ref }) {
1406
+ const elementRef = useRef(null);
1407
+ const { vertical, disabled, readonly, percent, thumbRef } = useSliderContext();
1408
+ const methods = () => ({
1409
+ focus() {
1410
+ elementRef.current?.focus();
1411
+ },
1412
+ blur() {
1413
+ elementRef.current?.blur();
1414
+ }
1415
+ });
1416
+ useImperativeHandle(ref, methods, []);
1417
+ useImperativeHandle(thumbRef, methods, []);
1672
1418
  return /* @__PURE__ */ jsx("div", {
1673
1419
  className: "tremolo-slider-thumb-wrapper",
1674
1420
  style: {
1675
- top: vertical ? `${__percent}%` : "50%",
1676
- left: !vertical ? `${__percent}%` : "50%"
1421
+ top: vertical ? `${percent}%` : "50%",
1422
+ left: !vertical ? `${percent}%` : "50%"
1677
1423
  },
1678
1424
  children: children ? children : /* @__PURE__ */ jsx("div", {
1679
- ref: wrapperRef,
1425
+ ref: elementRef,
1680
1426
  className: clsx("tremolo-slider-thumb", className),
1681
1427
  tabIndex: 0,
1682
1428
  "aria-disabled": disabled,
1683
1429
  "aria-readonly": readonly,
1684
1430
  style: {
1685
1431
  "--color": color,
1686
- width: size ?? width,
1687
- height: size ?? height,
1688
1432
  ...style
1689
1433
  }
1690
1434
  })
1691
1435
  });
1692
- });
1693
- function Track({ length = 140, thickness = 10, active, inactive, children, className, style, defaultStyle = true, __thumb, __percent = 0, ...props }) {
1694
- const vertical = useSliderContext((s) => s.vertical);
1695
- const reverse = useSliderContext((s) => s.reverse);
1696
- const disabled = useSliderContext((s) => s.disabled);
1436
+ }
1437
+ function Track({ length = 140, thickness = 10, active, inactive, children, className, style, defaultStyle = true, ref, ...props }) {
1438
+ const { vertical, reverse, disabled, percent, trackRef } = useSliderContext();
1439
+ const composedRef = useComposedRefs(ref, trackRef);
1697
1440
  const direction = vertical ? "bottom" : "right";
1698
1441
  const colors = {
1699
1442
  "--active": active,
1700
1443
  "--inactive": inactive
1701
1444
  };
1702
- return /* @__PURE__ */ jsxs("div", {
1445
+ return /* @__PURE__ */ jsx("div", {
1446
+ ref: composedRef,
1703
1447
  className: clsx("tremolo-slider-track", className),
1704
1448
  "aria-disabled": disabled,
1705
1449
  "data-vertical": vertical,
1706
1450
  style: !defaultStyle ? style : {
1707
1451
  ...colors,
1708
- background: xor(vertical, reverse) ? `linear-gradient(to ${direction}, var(--inactive) ${__percent}%, var(--active) ${__percent}%)` : `linear-gradient(to ${direction}, var(--active) ${__percent}%, var(--inactive) ${__percent}%)`,
1452
+ background: xor(vertical, reverse) ? `linear-gradient(to ${direction}, var(--inactive) ${percent}%, var(--active) ${percent}%)` : `linear-gradient(to ${direction}, var(--active) ${percent}%, var(--inactive) ${percent}%)`,
1709
1453
  borderRadius: styleHelper(thickness, "/", 2),
1710
1454
  width: !vertical ? length : thickness,
1711
1455
  height: vertical ? length : thickness,
1712
1456
  ...style
1713
1457
  },
1714
1458
  ...props,
1715
- children: [children, __thumb]
1459
+ children
1716
1460
  });
1717
1461
  }
1718
1462
  //#endregion
@@ -1722,55 +1466,37 @@ const defaultExternalStyles$1 = {
1722
1466
  cursor: "pointer"
1723
1467
  };
1724
1468
  /**
1469
+ * The wheel only acts while the focus is inside, so that scrolling a page past
1470
+ * the control does not change its value.
1471
+ */
1472
+ const WHEEL_OPTIONS$1 = { requireFocus: true };
1473
+ /**
1725
1474
  * Customizable slider
1726
1475
  */
1727
1476
  const Slider = {
1728
- Root: forwardRef(({ value, min, max, step = 1, skew = 1, vertical = false, reverse = false, externalStyles: _externalStyles, wheel = ["raw", 1], keyboard = ["raw", 1], disabled = false, readonly = false, onChange, onDragStart, onDragEnd, className, style, children, onFocus, onBlur, onPointerDown, onKeyDown, ...props }, forwardedRef) => {
1729
- const trackElementRef = useRef(null);
1477
+ Root: forwardRef(({ value, min, max, step = 1, scale = linearScale, vertical = false, reverse = false, externalStyles: _externalStyles, wheel = ["raw", 1], keyboard = ["raw", 1], disabled = false, readonly = false, onChange, onDragStart, onDragEnd, className, style, children, onFocus, onBlur, onPointerDown, onKeyDown, ...props }, forwardedRef) => {
1478
+ const trackRef = useRef(null);
1730
1479
  const thumbRef = useRef(null);
1731
1480
  const externalStyles = {
1732
1481
  ...defaultExternalStyles$1,
1733
1482
  ..._externalStyles
1734
1483
  };
1735
- const p = toFixed(normalizeValue(value, min, max, skew) * 100);
1484
+ const p = toFixed(scale.normalize(value, min, max) * 100);
1736
1485
  const rev = toFixed(100 - p);
1737
1486
  const displayReversed = useMemo(() => xor(vertical, reverse), [vertical, reverse]);
1738
1487
  const percent = displayReversed ? rev : p;
1739
- let trackProps = {};
1740
- let thumbProps = {};
1741
- let scaleComponent = void 0;
1742
- if (children != void 0) React.Children.forEach(children, (child) => {
1743
- if (React.isValidElement(child)) if (child.type == Thumb$1) thumbProps = child.props;
1744
- else if (child.type == Track) trackProps = child.props;
1745
- else if (child.type == Scale) scaleComponent = child;
1746
- else throw new Error("only <SliderThumb> or <SliderTrack>");
1747
- else throw new Error("children is an invalid element.");
1748
- });
1749
- const onDrag = useCallback((nx, ny) => {
1750
- if (!onChange || readonly) return;
1751
- const n = vertical ? ny : nx;
1752
- onChange(clamp(stepValue(rawValue(displayReversed ? 1 - n : n, min, max, skew), step), min, max));
1753
- }, [
1754
- displayReversed,
1755
- max,
1488
+ const axis = useMemo(() => ({
1756
1489
  min,
1757
- onChange,
1758
- readonly,
1759
- skew,
1760
- step,
1761
- vertical
1762
- ]);
1763
- const updateValueByEvent = useCallback((eventType, x) => {
1764
- let newValue;
1765
- if (eventType == "normalized") newValue = rawValue(normalizeValue(value, min, max, skew) + x, min, max, skew);
1766
- else newValue = value + x;
1767
- return clamp(stepValue(newValue, step), min, max);
1768
- }, [
1769
1490
  max,
1491
+ step,
1492
+ scale,
1493
+ reverse: displayReversed
1494
+ }), [
1770
1495
  min,
1771
- skew,
1496
+ max,
1772
1497
  step,
1773
- value
1498
+ scale,
1499
+ displayReversed
1774
1500
  ]);
1775
1501
  const handleKeyDown = useCallback((event) => {
1776
1502
  if (!keyboard || !onChange || readonly) return;
@@ -1782,46 +1508,77 @@ const Slider = {
1782
1508
  "ArrowDown"
1783
1509
  ].includes(key)) {
1784
1510
  event.preventDefault();
1785
- let x = key == "ArrowRight" || key == "ArrowUp" ? keyboard[1] : -keyboard[1];
1786
- if (reverse) x *= -1;
1787
- onChange(updateValueByEvent(keyboard[0], x));
1511
+ let direction = key == "ArrowRight" || key == "ArrowUp" ? 1 : -1;
1512
+ if (reverse) direction *= -1;
1513
+ onChange(applyDelta(value, direction, keyboard, axis));
1788
1514
  }
1789
1515
  }, [
1790
1516
  keyboard,
1791
1517
  onChange,
1792
1518
  readonly,
1793
1519
  reverse,
1794
- updateValueByEvent
1520
+ value,
1521
+ axis
1795
1522
  ]);
1796
- const { refCallback: dragRefCallback } = useDragWithElement({
1797
- baseElementRef: trackElementRef,
1523
+ const valueOf = (v) => v[vertical ? 1 : 0];
1524
+ const { refCallback: dragRefCallback } = useDragValue({
1525
+ axis,
1526
+ baseElementRef: trackRef,
1798
1527
  cursor: readonly ? void 0 : externalStyles.cursor,
1799
1528
  updateOnPointerDown: true,
1800
- onDrag,
1801
- onDragStart: (nx, ny) => {
1529
+ onChange: (v) => {
1530
+ if (readonly) return;
1531
+ onChange?.(valueOf(v));
1532
+ },
1533
+ onDragStart: (v) => {
1802
1534
  if (readonly) return;
1803
1535
  if (externalStyles.userSelectNone) addUserSelectNone();
1804
1536
  thumbRef.current?.focus();
1805
- onDragStart?.(clamp(stepValue(rawValue(vertical ? ny : nx, min, max, skew), step), min, max));
1537
+ onDragStart?.(valueOf(v));
1806
1538
  },
1807
- onDragEnd: (nx, ny) => {
1539
+ onDragEnd: (v) => {
1808
1540
  if (readonly) return;
1809
1541
  if (externalStyles.userSelectNone) removeUserSelectNone();
1810
- onDragEnd?.(clamp(stepValue(rawValue(vertical ? ny : nx, min, max, skew), step), min, max));
1542
+ onDragEnd?.(valueOf(v));
1811
1543
  }
1812
1544
  });
1813
- const wheelRefCallback = useWheel((event) => {
1545
+ const rootRefCallback = useComposedRefs(dragRefCallback, useWheel((event) => {
1814
1546
  if (!wheel || !onChange || readonly) return;
1815
1547
  event.preventDefault();
1816
- let x;
1817
- if (!vertical && event.deltaX != 0) x = event.deltaX > 0 ? wheel[1] : -wheel[1];
1548
+ let direction;
1549
+ if (!vertical && event.deltaX != 0) direction = event.deltaX > 0 ? 1 : -1;
1818
1550
  else {
1819
1551
  if (event.deltaY == 0) return;
1820
- x = event.deltaY > 0 ? -wheel[1] : wheel[1];
1552
+ direction = event.deltaY > 0 ? -1 : 1;
1821
1553
  }
1822
- if (vertical && reverse) x *= -1;
1823
- onChange(updateValueByEvent(wheel[0], x));
1824
- });
1554
+ if (vertical && reverse) direction *= -1;
1555
+ onChange(applyDelta(value, direction, wheel, axis));
1556
+ }, WHEEL_OPTIONS$1));
1557
+ const context = useMemo(() => ({
1558
+ value,
1559
+ min,
1560
+ max,
1561
+ step,
1562
+ scale,
1563
+ vertical,
1564
+ reverse,
1565
+ disabled,
1566
+ readonly,
1567
+ percent,
1568
+ trackRef,
1569
+ thumbRef
1570
+ }), [
1571
+ value,
1572
+ min,
1573
+ max,
1574
+ step,
1575
+ scale,
1576
+ vertical,
1577
+ reverse,
1578
+ disabled,
1579
+ readonly,
1580
+ percent
1581
+ ]);
1825
1582
  useImperativeHandle(forwardedRef, () => {
1826
1583
  return {
1827
1584
  focus() {
@@ -1833,20 +1590,10 @@ const Slider = {
1833
1590
  };
1834
1591
  }, []);
1835
1592
  return /* @__PURE__ */ jsx(SliderProvider, {
1836
- min,
1837
- max,
1838
- step,
1839
- skew,
1840
- vertical,
1841
- reverse,
1842
- disabled,
1843
- readonly,
1593
+ value: context,
1844
1594
  children: /* @__PURE__ */ jsx("div", {
1845
1595
  className: clsx("tremolo-slider", className),
1846
- ref: (div) => {
1847
- wheelRefCallback(div);
1848
- dragRefCallback(div);
1849
- },
1596
+ ref: rootRefCallback,
1850
1597
  tabIndex: -1,
1851
1598
  role: "slider",
1852
1599
  "aria-valuenow": value,
@@ -1855,10 +1602,8 @@ const Slider = {
1855
1602
  "aria-orientation": vertical ? "vertical" : "horizontal",
1856
1603
  "aria-disabled": disabled,
1857
1604
  "aria-readonly": readonly,
1858
- style: {
1859
- margin: `calc(${styleHelper(thumbProps?.size ?? 22)} / 2)`,
1860
- ...style
1861
- },
1605
+ "data-vertical": vertical,
1606
+ style,
1862
1607
  onPointerDown,
1863
1608
  onKeyDown: (event) => {
1864
1609
  handleKeyDown(event);
@@ -1873,70 +1618,30 @@ const Slider = {
1873
1618
  onBlur?.(event);
1874
1619
  },
1875
1620
  ...props,
1876
- children: /* @__PURE__ */ jsxs("div", {
1877
- style: {
1878
- display: "flex",
1879
- flexDirection: vertical ? "row" : "column",
1880
- width: !vertical ? "100%" : void 0,
1881
- height: vertical ? "100%" : void 0
1882
- },
1883
- children: [/* @__PURE__ */ jsx("div", {
1884
- className: "tremolo-slider-track-wrapper",
1885
- ref: trackElementRef,
1886
- children: /* @__PURE__ */ jsx(Track, {
1887
- __percent: percent,
1888
- __thumb: /* @__PURE__ */ jsx(Thumb$1, {
1889
- ref: thumbRef,
1890
- __percent: percent,
1891
- ...thumbProps
1892
- }),
1893
- ...trackProps
1894
- })
1895
- }), scaleComponent]
1896
- })
1621
+ children
1897
1622
  })
1898
1623
  });
1899
1624
  }),
1900
1625
  Thumb: Thumb$1,
1901
1626
  Track,
1902
- Scale,
1903
- ScaleOption
1627
+ Marks,
1628
+ MarksOption
1904
1629
  };
1905
1630
  //#endregion
1906
- //#region src/components/_util/composeRefs.tsx
1907
- /**
1908
- * Set a given ref to a given value
1909
- * This utility takes care of different types of refs: callback refs and RefObject(s)
1910
- */
1911
- function setRef(ref, value) {
1912
- if (typeof ref === "function") return ref(value);
1913
- else if (ref !== null && ref !== void 0) ref.current = value;
1914
- }
1915
- /**
1916
- * A utility to compose multiple refs together
1917
- * Accepts callback refs and RefObject(s)
1918
- */
1919
- function composeRefs(...refs) {
1920
- return (node) => {
1921
- let hasCleanup = false;
1922
- const cleanups = refs.map((ref) => {
1923
- const cleanup = setRef(ref, node);
1924
- if (!hasCleanup && typeof cleanup == "function") hasCleanup = true;
1925
- return cleanup;
1926
- });
1927
- if (hasCleanup) return () => {
1928
- for (let i = 0; i < cleanups.length; i++) {
1929
- const cleanup = cleanups[i];
1930
- if (typeof cleanup == "function") cleanup();
1931
- else setRef(refs[i], null);
1932
- }
1933
- };
1934
- };
1631
+ //#region src/components/XYPad/context.tsx
1632
+ const XYPadContext = createContext(null);
1633
+ const XYPadProvider = XYPadContext.Provider;
1634
+ function useXYPadContext(selector) {
1635
+ const context = useContext(XYPadContext);
1636
+ if (!context) throw new Error("Missing XYPadContext.Provider in the tree");
1637
+ return selector ? selector(context) : context;
1935
1638
  }
1936
1639
  //#endregion
1937
1640
  //#region src/components/XYPad/Area.tsx
1938
- function Area({ width = 120, height = 120, color, children, className, style, __thumb, ...props }) {
1939
- return /* @__PURE__ */ jsxs("div", {
1641
+ function Area({ width = 120, height = 120, color, children, className, style, ref, ...props }) {
1642
+ const { areaRef } = useXYPadContext();
1643
+ return /* @__PURE__ */ jsx("div", {
1644
+ ref: useComposedRefs(ref, areaRef),
1940
1645
  className: clsx("tremolo-xy-pad-area", className),
1941
1646
  style: {
1942
1647
  "--color": color,
@@ -1945,121 +1650,105 @@ function Area({ width = 120, height = 120, color, children, className, style, __
1945
1650
  ...style
1946
1651
  },
1947
1652
  ...props,
1948
- children: [children, __thumb]
1653
+ children
1949
1654
  });
1950
1655
  }
1951
1656
  //#endregion
1952
1657
  //#region src/components/XYPad/Thumb.tsx
1953
- const Thumb = forwardRef(({ size, width = 22, height = 22, color, children, wrapperClassName, wrapperStyle, className, style, __disabled, __readonly, __css, ...props }, ref) => {
1954
- const wrapperRef = useRef(null);
1955
- useImperativeHandle(ref, () => {
1956
- return {
1957
- focus() {
1958
- wrapperRef.current?.focus();
1959
- },
1960
- blur() {
1961
- wrapperRef.current?.blur();
1962
- }
1963
- };
1964
- }, []);
1658
+ function Thumb({ color, children, wrapperClassName, wrapperStyle, className, style, ref, ...props }) {
1659
+ const elementRef = useRef(null);
1660
+ const { disabled, readonly, percent, thumbRef } = useXYPadContext();
1661
+ const methods = () => ({
1662
+ focus() {
1663
+ elementRef.current?.focus();
1664
+ },
1665
+ blur() {
1666
+ elementRef.current?.blur();
1667
+ }
1668
+ });
1669
+ useImperativeHandle(ref, methods, []);
1670
+ useImperativeHandle(thumbRef, methods, []);
1965
1671
  return /* @__PURE__ */ jsx("div", {
1966
1672
  className: clsx("tremolo-xy-pad-thumb-wrapper", wrapperClassName),
1967
1673
  style: {
1968
- ...__css,
1674
+ left: `${percent[0]}%`,
1675
+ top: `${percent[1]}%`,
1969
1676
  ...wrapperStyle
1970
1677
  },
1971
1678
  ...props,
1972
1679
  children: children ? children : /* @__PURE__ */ jsx("div", {
1973
- ref: wrapperRef,
1680
+ ref: elementRef,
1974
1681
  className: clsx("tremolo-xy-pad-thumb", className),
1975
1682
  tabIndex: 0,
1976
- "aria-disabled": __disabled,
1977
- "aria-readonly": __readonly,
1683
+ "aria-disabled": disabled,
1684
+ "aria-readonly": readonly,
1978
1685
  style: {
1979
1686
  "--color": color,
1980
- width: size ?? width,
1981
- height: size ?? height,
1982
1687
  ...style
1983
1688
  }
1984
1689
  })
1985
1690
  });
1986
- });
1691
+ }
1987
1692
  //#endregion
1988
1693
  //#region src/components/XYPad/index.tsx
1989
- const defaultValueOptions = {
1990
- skew: 1,
1991
- step: 1,
1992
- reverse: false,
1993
- wheel: ["raw", 1],
1994
- keyboard: ["raw", 1]
1995
- };
1996
1694
  const defaultExternalStyles = {
1997
1695
  userSelectNone: true,
1998
1696
  cursor: "pointer"
1999
1697
  };
2000
1698
  /**
1699
+ * The wheel only acts while the focus is inside, so that scrolling a page past
1700
+ * the control does not change its value.
1701
+ */
1702
+ const WHEEL_OPTIONS = { requireFocus: true };
1703
+ /**
2001
1704
  * Simple XYPad
2002
1705
  */
2003
1706
  const XYPad = {
2004
- Root: forwardRef(({ x: _x, y: _y, className, style, externalStyles: _externalStyles, disabled = false, readonly = false, onChange, onDragStart, onDragEnd, onPointerDown, onKeyDown, onFocus, onBlur, children, ...props }, forwardedRef) => {
2005
- const x = useMemo(() => {
2006
- return {
2007
- ...defaultValueOptions,
2008
- ..._x
2009
- };
2010
- }, [_x]);
2011
- const y = useMemo(() => {
2012
- return {
2013
- ...defaultValueOptions,
2014
- ..._y
2015
- };
2016
- }, [_y]);
1707
+ Root: forwardRef(({ value, min: _min, max: _max, step: _step = 1, scale: _scale = linearScale, reverse: _reverse = false, wheel = ["raw", 1], keyboard = ["raw", 1], className, style, externalStyles: _externalStyles, disabled = false, readonly = false, onChange, onDragStart, onDragEnd, onPointerDown, onKeyDown, onFocus, onBlur, children, ...props }, forwardedRef) => {
2017
1708
  const rootRef = useRef(null);
2018
- const areaElementRef = useRef(null);
1709
+ const areaRef = useRef(null);
2019
1710
  const thumbRef = useRef(null);
2020
1711
  const externalStyles = {
2021
1712
  ...defaultExternalStyles,
2022
1713
  ..._externalStyles
2023
1714
  };
2024
- const nx = normalizeValue(x.value, x.min, x.max, x.skew);
2025
- const ny = normalizeValue(y.value, y.min, y.max, y.skew);
2026
- const percentX = toFixed((x.reverse ? 1 - nx : nx) * 100);
2027
- const percentY = toFixed((y.reverse ? 1 - ny : ny) * 100);
2028
- let areaProps = {};
2029
- let thumbProps = {};
2030
- if (children != void 0) React.Children.forEach(children, (child) => {
2031
- if (React.isValidElement(child)) if (child.type == Thumb) thumbProps = child.props;
2032
- else if (child.type == Area) areaProps = child.props;
2033
- else throw new Error("only <XYPadThumb> or <XYPadArea>");
2034
- else throw new Error("children is an invalid element.");
2035
- });
2036
- const onDrag = useCallback((nx, ny) => {
2037
- if (!onChange || readonly) return;
2038
- const vx = rawValue(x.reverse ? 1 - nx : nx, x.min, x.max, x.skew);
2039
- const vy = rawValue(y.reverse ? 1 - ny : ny, y.min, y.max, y.skew);
2040
- onChange(clamp(stepValue(vx, x.step), x.min, x.max), clamp(stepValue(vy, y.step), y.min, y.max));
1715
+ const min = useMemo(() => toXY(_min), [_min]);
1716
+ const max = useMemo(() => toXY(_max), [_max]);
1717
+ const step = useMemo(() => toXY(_step), [_step]);
1718
+ const scale = useMemo(() => toXY(_scale), [_scale]);
1719
+ const reverse = useMemo(() => toXY(_reverse), [_reverse]);
1720
+ const percent = useMemo(() => {
1721
+ const normalized = [0, 1].map((i) => scale[i].normalize(value[i], min[i], max[i]));
1722
+ return [0, 1].map((i) => toFixed((reverse[i] ? 1 - normalized[i] : normalized[i]) * 100));
2041
1723
  }, [
2042
- onChange,
2043
- readonly,
2044
- x.max,
2045
- x.min,
2046
- x.reverse,
2047
- x.skew,
2048
- x.step,
2049
- y.max,
2050
- y.min,
2051
- y.reverse,
2052
- y.skew,
2053
- y.step
1724
+ value,
1725
+ min,
1726
+ max,
1727
+ scale,
1728
+ reverse
1729
+ ]);
1730
+ const axis = useMemo(() => [0, 1].map((i) => ({
1731
+ min: min[i],
1732
+ max: max[i],
1733
+ step: step[i],
1734
+ scale: scale[i],
1735
+ reverse: reverse[i]
1736
+ })), [
1737
+ min,
1738
+ max,
1739
+ step,
1740
+ scale,
1741
+ reverse
1742
+ ]);
1743
+ const withAxis = useCallback((axis, next) => axis == 0 ? [next, value[1]] : [value[0], next], [value]);
1744
+ /** @param i 0 = x, 1 = y */
1745
+ const nudge = useCallback((i, direction, option) => withAxis(i, applyDelta(value[i], direction, option, axis[i])), [
1746
+ value,
1747
+ axis,
1748
+ withAxis
2054
1749
  ]);
2055
- const updateValueByEvent = useCallback((eventType, target, x) => {
2056
- let v;
2057
- if (eventType == "normalized") v = rawValue(normalizeValue(target.value, target.min, target.max, target.skew) + x, target.min, target.max, target.skew);
2058
- else v = target.value + x;
2059
- return clamp(stepValue(v, target.step), target.min, target.max);
2060
- }, []);
2061
1750
  const handleKeyDown = useCallback((event) => {
2062
- if (!onChange || readonly) return;
1751
+ if (!onChange || readonly || !keyboard) return;
2063
1752
  const key = event.key;
2064
1753
  if ([
2065
1754
  "ArrowRight",
@@ -2067,57 +1756,73 @@ const XYPad = {
2067
1756
  "ArrowUp",
2068
1757
  "ArrowDown"
2069
1758
  ].includes(key)) {
2070
- const isHorizontal = key == "ArrowRight" || key == "ArrowLeft";
2071
- const target = isHorizontal ? x : y;
2072
- if (!target.keyboard) return;
1759
+ const i = key == "ArrowRight" || key == "ArrowLeft" ? 0 : 1;
2073
1760
  event.preventDefault();
2074
- let delta = target.keyboard[1];
2075
- if (key == "ArrowLeft" || key == "ArrowUp") delta *= -1;
2076
- if (target.reverse) delta *= -1;
2077
- const newValue = updateValueByEvent(target.keyboard[0], target, delta);
2078
- if (isHorizontal) onChange(newValue, y.value);
2079
- else onChange(x.value, newValue);
1761
+ let direction = 1;
1762
+ if (key == "ArrowLeft" || key == "ArrowUp") direction *= -1;
1763
+ if (reverse[i]) direction *= -1;
1764
+ onChange(nudge(i, direction, keyboard));
2080
1765
  }
2081
1766
  }, [
2082
1767
  onChange,
2083
1768
  readonly,
2084
- x,
2085
- y,
2086
- updateValueByEvent
1769
+ keyboard,
1770
+ reverse,
1771
+ nudge
2087
1772
  ]);
2088
- const { refCallback: dragRefCallback } = useDragWithElement({
2089
- baseElementRef: areaElementRef,
2090
- cursor: readonly ? void 0 : externalStyles.cursor,
1773
+ const { refCallback: dragRefCallback } = useDragValue({
1774
+ axis,
1775
+ baseElementRef: areaRef,
2091
1776
  updateOnPointerDown: true,
2092
- onDrag,
2093
- onDragStart: (nx, ny) => {
1777
+ cursor: readonly ? void 0 : externalStyles.cursor,
1778
+ onChange: (v) => {
1779
+ if (readonly) return;
1780
+ onChange?.(v);
1781
+ },
1782
+ onDragStart: (v) => {
2094
1783
  if (readonly) return;
2095
1784
  if (externalStyles.userSelectNone) addUserSelectNone();
2096
1785
  thumbRef.current?.focus();
2097
- const valueX = clamp(stepValue(rawValue(nx, x.min, x.max, x.skew), x.step), x.min, x.max);
2098
- const valueY = clamp(stepValue(rawValue(ny, y.min, y.max, y.skew), y.step), y.min, y.max);
2099
- onDragStart?.(valueX, valueY);
1786
+ onDragStart?.(v);
2100
1787
  },
2101
- onDragEnd: (nx, ny) => {
1788
+ onDragEnd: (v) => {
2102
1789
  if (readonly) return;
2103
1790
  if (externalStyles.userSelectNone) removeUserSelectNone();
2104
- const valueX = clamp(stepValue(rawValue(nx, x.min, x.max, x.skew), x.step), x.min, x.max);
2105
- const valueY = clamp(stepValue(rawValue(ny, y.min, y.max, y.skew), y.step), y.min, y.max);
2106
- onDragEnd?.(valueX, valueY);
1791
+ onDragEnd?.(v);
2107
1792
  }
2108
1793
  });
2109
- const wheelRefCallback = useWheel((event) => {
2110
- if (!onChange || readonly) return;
2111
- const target = event.shiftKey ? x : y;
2112
- if (!target.wheel) return;
1794
+ const rootRefCallback = useComposedRefs(rootRef, dragRefCallback, useWheel((event) => {
1795
+ if (!onChange || readonly || !wheel) return;
1796
+ const i = event.shiftKey ? 0 : 1;
2113
1797
  event.preventDefault();
2114
- let delta = target.wheel[1];
2115
- if (event.deltaY < 0) delta *= -1;
2116
- if (target.reverse) delta *= -1;
2117
- const newValue = updateValueByEvent(target.wheel[0], target, delta);
2118
- if (event.shiftKey) onChange(newValue, y.value);
2119
- else onChange(x.value, newValue);
2120
- });
1798
+ let direction = 1;
1799
+ if (event.deltaY < 0) direction *= -1;
1800
+ if (reverse[i]) direction *= -1;
1801
+ onChange(nudge(i, direction, wheel));
1802
+ }, WHEEL_OPTIONS));
1803
+ const context = useMemo(() => ({
1804
+ value,
1805
+ min,
1806
+ max,
1807
+ step,
1808
+ scale,
1809
+ reverse,
1810
+ disabled,
1811
+ readonly,
1812
+ percent,
1813
+ areaRef,
1814
+ thumbRef
1815
+ }), [
1816
+ value,
1817
+ min,
1818
+ max,
1819
+ step,
1820
+ scale,
1821
+ reverse,
1822
+ disabled,
1823
+ readonly,
1824
+ percent
1825
+ ]);
2121
1826
  useImperativeHandle(forwardedRef, () => {
2122
1827
  return {
2123
1828
  focus() {
@@ -2129,48 +1834,30 @@ const XYPad = {
2129
1834
  original: rootRef
2130
1835
  };
2131
1836
  }, []);
2132
- return /* @__PURE__ */ jsx("div", {
2133
- className: clsx("tremolo-xy-pad", className),
2134
- ref: composeRefs(rootRef, wheelRefCallback, dragRefCallback),
2135
- tabIndex: -1,
2136
- "aria-disabled": disabled,
2137
- "aria-readonly": readonly,
2138
- style: {
2139
- margin: `calc(${styleHelper(thumbProps?.size ?? 22)} / 2)`,
2140
- cursor: readonly ? "not-allowed" : "pointer",
2141
- WebkitTapHighlightColor: "transparent",
2142
- ...style
2143
- },
2144
- onPointerDown,
2145
- onKeyDown: (event) => {
2146
- handleKeyDown(event);
2147
- onKeyDown?.(event);
2148
- },
2149
- onFocus: (event) => {
2150
- thumbRef.current?.focus();
2151
- onFocus?.(event);
2152
- },
2153
- onBlur: (event) => {
2154
- thumbRef.current?.blur();
2155
- onBlur?.(event);
2156
- },
2157
- ...props,
1837
+ return /* @__PURE__ */ jsx(XYPadProvider, {
1838
+ value: context,
2158
1839
  children: /* @__PURE__ */ jsx("div", {
2159
- className: "tremolo-xy-pad-area-wrapper",
2160
- ref: areaElementRef,
2161
- children: /* @__PURE__ */ jsx(Area, {
2162
- __thumb: /* @__PURE__ */ jsx(Thumb, {
2163
- ref: thumbRef,
2164
- __disabled: disabled,
2165
- __readonly: readonly,
2166
- __css: {
2167
- top: `${percentY}%`,
2168
- left: `${percentX}%`
2169
- },
2170
- ...thumbProps
2171
- }),
2172
- ...areaProps
2173
- })
1840
+ className: clsx("tremolo-xy-pad", className),
1841
+ ref: rootRefCallback,
1842
+ tabIndex: -1,
1843
+ "aria-disabled": disabled,
1844
+ "aria-readonly": readonly,
1845
+ style,
1846
+ onPointerDown,
1847
+ onKeyDown: (event) => {
1848
+ handleKeyDown(event);
1849
+ onKeyDown?.(event);
1850
+ },
1851
+ onFocus: (event) => {
1852
+ thumbRef.current?.focus();
1853
+ onFocus?.(event);
1854
+ },
1855
+ onBlur: (event) => {
1856
+ thumbRef.current?.blur();
1857
+ onBlur?.(event);
1858
+ },
1859
+ ...props,
1860
+ children
2174
1861
  })
2175
1862
  });
2176
1863
  }),
@@ -2242,6 +1929,6 @@ function useMIDIMessage(midiAccess, onMIDIMessage) {
2242
1929
  }, [midiAccess, onMIDIMessage]);
2243
1930
  }
2244
1931
  //#endregion
2245
- export { AnimationCanvas, Knob, NOT_SUPPORTED, NumberInput, PERMISSION_DENIED, Piano, PointsEditor, SHORTCUTS, Slider, XYPad, clampPoint, getNoteRangeArray, useAnimationFrame, useDrag, useDragWithElement, useEventListener, useInterval, useLongPress, useMIDIAccess, useMIDIInput, useMIDIMessage, useSliderContext, useWheel };
1932
+ export { AnimationCanvas, Knob, NOT_SUPPORTED, NumberInput, PERMISSION_DENIED, Piano, PointsEditor, SHORTCUTS, Slider, XYPad, clampPoint, useAnimationFrame, useDrag, useDragValue, useEventListener, useInterval, useLongPress, useMIDIAccess, useMIDIInput, useMIDIMessage, useNumberInputContext, usePointsEditorContext, useSliderContext, useWheel };
2246
1933
 
2247
1934
  //# sourceMappingURL=index.js.map