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