@tremolo-ui/react 0.2.0 → 0.3.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 +1245 -1083
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +176 -159
  4. package/dist/index.d.cts +244 -253
  5. package/dist/index.d.cts.map +1 -1
  6. package/dist/index.d.ts +244 -253
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1218 -1067
  9. package/dist/index.js.map +1 -1
  10. package/package.json +18 -19
  11. package/src/components/AnimationCanvas/index.tsx +0 -7
  12. package/src/components/DragObserver/index.tsx +9 -7
  13. package/src/components/Knob/ActiveLine.tsx +29 -0
  14. package/src/components/Knob/InactiveLine.tsx +53 -0
  15. package/src/components/Knob/SVGRoot.tsx +39 -0
  16. package/src/components/Knob/Thumb.tsx +64 -0
  17. package/src/components/Knob/context.tsx +120 -0
  18. package/src/components/Knob/index.css +5 -1
  19. package/src/components/Knob/index.tsx +110 -164
  20. package/src/components/NumberInput/DecrementStepper.tsx +0 -2
  21. package/src/components/NumberInput/IncrementStepper.tsx +0 -2
  22. package/src/components/NumberInput/InternalInput.tsx +3 -1
  23. package/src/components/NumberInput/Stepper.tsx +0 -2
  24. package/src/components/NumberInput/context.tsx +0 -1
  25. package/src/components/NumberInput/index.tsx +6 -8
  26. package/src/components/Piano/KeyLabel.tsx +0 -2
  27. package/src/components/Piano/context.tsx +0 -2
  28. package/src/components/Piano/index.tsx +7 -11
  29. package/src/components/Piano/key.tsx +0 -10
  30. package/src/components/Piano/keyboardShortcuts.ts +0 -2
  31. package/src/components/PointsEditor/Background.tsx +17 -0
  32. package/src/components/PointsEditor/Container.tsx +26 -0
  33. package/src/components/PointsEditor/Point.tsx +137 -0
  34. package/src/components/PointsEditor/context.tsx +91 -0
  35. package/src/components/PointsEditor/index.css +30 -0
  36. package/src/components/PointsEditor/index.tsx +129 -0
  37. package/src/components/Slider/Scale.tsx +0 -2
  38. package/src/components/Slider/ScaleOption.tsx +0 -2
  39. package/src/components/Slider/Thumb.tsx +0 -4
  40. package/src/components/Slider/Track.tsx +0 -2
  41. package/src/components/Slider/context.tsx +0 -1
  42. package/src/components/Slider/index.tsx +42 -14
  43. package/src/components/WheelObserver/index.tsx +17 -19
  44. package/src/components/XYPad/Area.tsx +0 -2
  45. package/src/components/XYPad/Thumb.tsx +0 -3
  46. package/src/components/XYPad/index.tsx +52 -25
  47. package/src/components/_util/composeRefs.tsx +63 -0
  48. package/src/components/_util/index.ts +23 -6
  49. package/src/components/_util/type.ts +1 -0
  50. package/src/hooks/useAnimationFrame.ts +0 -3
  51. package/src/hooks/useCallbackRef.ts +2 -2
  52. package/src/hooks/useDrag.ts +12 -11
  53. package/src/hooks/useDragWithElement.ts +12 -17
  54. package/src/hooks/useEventListener.ts +6 -9
  55. package/src/hooks/useInterval.ts +0 -3
  56. package/src/hooks/useLongPress.ts +0 -3
  57. package/src/hooks/useMIDIAccess.ts +20 -28
  58. package/src/hooks/useMIDIInput.ts +10 -38
  59. package/src/hooks/useMIDIMessage.ts +4 -12
  60. package/src/hooks/usePianoDrag.ts +75 -0
  61. package/src/index.ts +8 -5
  62. package/src/styles/global.css +17 -1
  63. package/dist/index.css.map +0 -1
  64. package/src/components/AnimationKnob/index.tsx +0 -314
@@ -1,22 +1,9 @@
1
- import { useCallback } from 'react'
1
+ import { useEffect } from 'react'
2
2
 
3
- import { useMIDIMessage } from './useMIDIMessage'
4
-
5
- const MIDI_EVENT_TO_NUMBER = {
6
- NOTE_ON: 0x90,
7
- NOTE_OFF: 0x80,
8
- PITCH_BEND: 0xe0,
9
- }
10
-
11
- // const NUMBER_TO_MIDI_EVENT: Record<number, string> = {
12
- // 0x90: 'NOTE_ON',
13
- // 0x80: 'NOTE_OFF',
14
- // 0xe0: 'PITCH_BEND',
15
- // }
3
+ import { createMIDIInput } from '@tremolo-ui/dom'
16
4
 
17
5
  /**
18
6
  * Hooks for handling note on/off events. To be used with useMIDIAccess. Internally uses useMIDIMessage.
19
- * @category hooks
20
7
  */
21
8
  export function useMIDIInput(
22
9
  midiAccess: MIDIAccess | null,
@@ -24,27 +11,12 @@ export function useMIDIInput(
24
11
  onNoteOffEvent?: (note: number) => void,
25
12
  onPitchBendEvent?: (msb: number, lsb: number) => void,
26
13
  ) {
27
- const handleMIDIMessage = useCallback(
28
- (event: MIDIMessageEvent) => {
29
- if (!event.data) return
30
- // event.data[0] ... command
31
- // event.data[1] ... note, MSB (Most Significant Byte)
32
- // event.data[2] ... velocity, LSB (Least Significant Byte)
33
- const kind = event.data[0] & 0xf0
34
-
35
- if (
36
- kind == MIDI_EVENT_TO_NUMBER.NOTE_OFF ||
37
- (kind == MIDI_EVENT_TO_NUMBER.NOTE_ON && event.data[2] == 0)
38
- ) {
39
- onNoteOffEvent?.(event.data[1])
40
- } else if (kind == MIDI_EVENT_TO_NUMBER.NOTE_ON) {
41
- onNoteOnEvent?.(event.data[1], event.data[2])
42
- } else if (kind == MIDI_EVENT_TO_NUMBER.PITCH_BEND) {
43
- onPitchBendEvent?.(event.data[1], event.data[2])
44
- }
45
- },
46
- [onNoteOffEvent, onNoteOnEvent, onPitchBendEvent],
47
- )
48
-
49
- useMIDIMessage(midiAccess, handleMIDIMessage)
14
+ useEffect(() => {
15
+ const instance = createMIDIInput(midiAccess, {
16
+ onNoteOnEvent,
17
+ onNoteOffEvent,
18
+ onPitchBendEvent,
19
+ })
20
+ return () => instance.destroy()
21
+ }, [midiAccess, onNoteOnEvent, onNoteOffEvent, onPitchBendEvent])
50
22
  }
@@ -1,24 +1,16 @@
1
1
  import { useEffect } from 'react'
2
2
 
3
+ import { createMIDIMessage } from '@tremolo-ui/dom'
4
+
3
5
  /**
4
6
  * Hooks for when you want to process MIDI events in more detail than useMIDIInput.
5
- * @category hooks
6
7
  */
7
8
  export function useMIDIMessage(
8
9
  midiAccess: MIDIAccess | null,
9
10
  onMIDIMessage: (event: MIDIMessageEvent) => void,
10
11
  ) {
11
12
  useEffect(() => {
12
- if (!midiAccess) return
13
-
14
- for (const input of midiAccess.inputs.values()) {
15
- input.addEventListener('midimessage', onMIDIMessage)
16
- }
17
-
18
- return () => {
19
- for (const input of midiAccess.inputs.values()) {
20
- input.removeEventListener('midimessage', onMIDIMessage)
21
- }
22
- }
13
+ const instance = createMIDIMessage(midiAccess, onMIDIMessage)
14
+ return () => instance.destroy()
23
15
  }, [midiAccess, onMIDIMessage])
24
16
  }
@@ -0,0 +1,75 @@
1
+ import { useRef, useCallback, RefObject } from 'react'
2
+
3
+ import { normalizeValue } from '@tremolo-ui/functions'
4
+
5
+ import { useEventListener } from './useEventListener'
6
+ import { useRefCallbackEvent } from './useRefCallbackEvent'
7
+
8
+ interface UsePianoDrag<T extends Element> {
9
+ baseElementRef: RefObject<T | null>
10
+ onDrag: (normalizedX: number, normalizedY: number) => void
11
+ onDragStart?: (normalizedX: number, normalizedY: number) => void
12
+ onDragEnd?: (normalizedX: number, normalizedY: number) => void
13
+ }
14
+
15
+ /**
16
+ * Internal
17
+ * @returns [refCallback, pointerDownHandler]
18
+ * @private
19
+ */
20
+ export function usePianoDrag<T extends Element>({
21
+ baseElementRef,
22
+ onDrag,
23
+ onDragStart,
24
+ onDragEnd,
25
+ }: UsePianoDrag<T>) {
26
+ const dragged = useRef(false)
27
+ const normalizedX = useRef(0)
28
+ const normalizedY = useRef(0)
29
+
30
+ const handleDrag = useCallback(
31
+ (event: MouseEvent | React.PointerEvent<T> | TouchEvent) => {
32
+ if (!baseElementRef.current || !dragged.current) {
33
+ return
34
+ }
35
+ const isTouch = event instanceof TouchEvent
36
+ if (isTouch && event.cancelable) event.preventDefault()
37
+ const { left, top, right, bottom } =
38
+ baseElementRef.current.getBoundingClientRect()
39
+ const mouseX = isTouch ? event.touches[0].clientX : event.clientX
40
+ const mouseY = isTouch ? event.touches[0].clientY : event.clientY
41
+ const nx = normalizeValue(mouseX, left, right)
42
+ const ny = normalizeValue(mouseY, top, bottom)
43
+ normalizedX.current = nx
44
+ normalizedY.current = ny
45
+ onDrag(nx, ny)
46
+ },
47
+ [onDrag, baseElementRef],
48
+ )
49
+
50
+ const refHandler = useRefCallbackEvent(
51
+ 'touchmove',
52
+ handleDrag,
53
+ { passive: false },
54
+ [onDrag],
55
+ )
56
+
57
+ const pointerDownHandler = useCallback(
58
+ (event: React.PointerEvent<T>) => {
59
+ dragged.current = true
60
+ handleDrag(event)
61
+ onDragStart?.(normalizedX.current, normalizedY.current)
62
+ },
63
+ [handleDrag, onDragStart],
64
+ )
65
+
66
+ useEventListener(globalThis.window, 'pointermove', handleDrag)
67
+
68
+ useEventListener(globalThis.window, 'pointerup', () => {
69
+ if (!dragged.current) return
70
+ dragged.current = false
71
+ onDragEnd?.(normalizedX.current, normalizedY.current)
72
+ })
73
+
74
+ return { refHandler, pointerDownHandler }
75
+ }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import './styles/global.css'
3
3
  import './components/Knob/index.css'
4
4
  import './components/NumberInput/index.css'
5
5
  import './components/Piano/index.css'
6
+ import './components/PointsEditor/index.css'
6
7
  import './components/Slider/index.css'
7
8
  import './components/XYPad/index.css'
8
9
 
@@ -15,11 +16,6 @@ export {
15
16
  type InitFunction,
16
17
  type RelativeSizingProps,
17
18
  } from './components/AnimationCanvas'
18
- export {
19
- AnimationKnob,
20
- type AnimationKnobProps,
21
- type AnimationKnobMethods,
22
- } from './components/AnimationKnob'
23
19
  export { DragObserver, type DragObserverProps } from './components/DragObserver'
24
20
  export { Knob, type KnobProps, type KnobMethods } from './components/Knob'
25
21
  export {
@@ -41,6 +37,13 @@ export {
41
37
  type KeyLabelProps,
42
38
  type KeyboardShortcuts,
43
39
  } from './components/Piano'
40
+ export {
41
+ PointsEditor,
42
+ clampPoint,
43
+ type PointsEditorProps,
44
+ type PointProps,
45
+ type PointBaseType,
46
+ } from './components/PointsEditor'
44
47
  export {
45
48
  Slider,
46
49
  useSliderContext,
@@ -1,3 +1,19 @@
1
- .tremolo-no-select {
1
+ .tremolo-user-select-none {
2
2
  user-select: none;
3
3
  }
4
+
5
+ .tremolo-cursor-pointer {
6
+ cursor: pointer;
7
+ }
8
+
9
+ .tremolo-cursor-move {
10
+ cursor: move;
11
+ }
12
+
13
+ .tremolo-cursor-grabbing {
14
+ cursor: grabbing;
15
+ }
16
+
17
+ .tremolo-cursor-none {
18
+ cursor: none;
19
+ }
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.css","names":[],"sources":["../src/styles/global.css","../src/components/Knob/index.css","../src/components/NumberInput/index.css","../src/components/Piano/index.css","../src/components/Slider/index.css","../src/components/XYPad/index.css"],"sourcesContent":[".tremolo-no-select {\n user-select: none;\n}\n",".tremolo-knob {\n display: inline-block;\n cursor: pointer;\n overflow: visible;\n outline: 0;\n\n &[aria-readonly='true'] {\n cursor: not-allowed;\n }\n}\n\n.tremolo-knob-active-line {\n overflow: visible;\n color: oklch(69.2% 0.17548 266.373);\n\n :where(.tremolo-knob:focus, .tremolo-knob:hover) & {\n color: oklch(59.5% 0.17548 266.373);\n }\n\n :where(.tremolo-knob[aria-disabled='true']) & {\n color: oklch(50.5% 0.03307 270.021);\n }\n}\n\n.tremolo-knob-inactive-line {\n color: oklch(95% 0 0);\n\n :where(.tremolo-knob:focus, .tremolo-knob:hover) & {\n color: oklch(90% 0 0);\n }\n}\n\n.tremolo-knob-thumb {\n color: oklch(85.7% 0 0);\n\n :where(.tremolo-knob:focus, .tremolo-knob:hover) & {\n color: oklch(84.5% 0 0);\n }\n}\n\n.tremolo-knob-thumb-line {\n color: oklch(94.2% 0 0);\n}\n\n:where(.dark, [data-theme='dark']) {\n .tremolo-knob-active-line {\n color: oklch(59.5% 0.17548 266.373);\n\n :where(.tremolo-knob:focus, .tremolo-knob:hover) & {\n color: oklch(65.2% 0.17548 266.373);\n }\n }\n\n .tremolo-knob-inactive-line {\n color: oklch(41.7% 0 0);\n\n :where(.tremolo-knob:focus, .tremolo-knob:hover) & {\n color: oklch(44.59% 0 0);\n }\n }\n\n .tremolo-knob-thumb {\n color: oklch(48.6% 0 0);\n\n :where(.tremolo-knob:focus, .tremolo-knob:hover) & {\n color: oklch(51.7% 0 0);\n }\n }\n\n .tremolo-knob-thumb-line {\n color: oklch(84.2% 0 0);\n }\n}\n",".tremolo-number-input-wrapper {\n --border-radius: 4px;\n --stepper-width: 20px;\n --active-color: oklch(59.5% 0.17548 266.373);\n --border-color: oklch(81% 0 0);\n\n display: inline-flex;\n flex-direction: row;\n position: relative;\n width: 140px;\n height: min-content;\n border-style: solid;\n border-color: var(--border-color);\n border-width: 0px;\n outline: none;\n transition: all 0.1s;\n\n &:not(:focus-within):has(> .tremolo-number-input[data-error='true']) {\n border-color: oklch(64.245% 0.21266 25.499);\n }\n\n &[data-variant='outline'] {\n border-width: 1px;\n border-radius: var(--border-radius);\n\n &:hover {\n --border-color: oklch(65.335% 0 0);\n }\n\n &:focus-within {\n --border-color: var(--active-color);\n box-shadow: 0px 0px 0px 2px\n color-mix(in srgb, var(--active-color) 10%, transparent);\n }\n }\n\n &[data-variant='filled'] {\n --bg: oklch(95.88% 0 0);\n border-width: 1px;\n border-radius: var(--border-radius);\n border-color: transparent;\n background-color: var(--bg);\n\n &:hover {\n --bg: oklch(93.166% 0 0);\n }\n\n &:focus-within {\n background-color: inherit;\n --border-color: var(--active-color);\n box-shadow: 0px 0px 0px 2px\n color-mix(in srgb, var(--active-color) 10%, transparent);\n }\n }\n\n &[data-variant='flushed'] {\n border-bottom-width: 1px;\n\n &:hover {\n --border-color: oklch(65.335% 0 0);\n }\n\n &:focus-within {\n --border-color: var(--active-color);\n box-shadow: 0px 2px 0px 0px\n color-mix(in srgb, var(--active-color) 10%, transparent);\n }\n }\n\n &[data-stepper='false'] {\n --stepper-width: 0px;\n }\n}\n\n.tremolo-number-input {\n display: block;\n color: inherit;\n width: calc(100% - 20px - var(--stepper-width));\n background-color: inherit;\n font: inherit;\n padding: 6px 10px;\n\n margin: 0;\n border: none;\n outline: none;\n appearance: none;\n\n &[aria-readonly='true'] {\n cursor: not-allowed;\n }\n}\n\n.tremolo-number-input-stepper {\n display: flex;\n flex-direction: column;\n height: 100%;\n position: absolute;\n top: 0;\n right: 0;\n width: var(--stepper-width);\n transition: all 0.1s linear;\n\n &[data-dynamic='true'] {\n opacity: 0;\n transform: translateX(40%);\n }\n}\n\n:is(\n .tremolo-number-input-wrapper:hover,\n .tremolo-number-input-wrapper:focus-within\n )\n .tremolo-number-input-stepper {\n opacity: 1;\n transform: translateX(0);\n}\n\n.tremolo-number-input-increment-stepper {\n border-inline-start-width: 1px;\n border-inline-start-style: solid;\n border-inline-start-color: oklch(84.522% 0 0);\n font-size: calc(0.75rem);\n display: flex;\n justify-content: center;\n align-items: center;\n flex: 1 1 0%;\n max-height: 50%;\n user-select: none;\n cursor: pointer;\n border-top-right-radius: var(--border-radius);\n\n &[aria-disabled='false']:active {\n background-color: oklch(37.798% 0.03812 254.612 / 0.055);\n }\n\n &[aria-disabled='true'] {\n cursor: not-allowed;\n }\n\n &[aria-readonly='true'] {\n cursor: not-allowed;\n }\n}\n\n.tremolo-number-input-decrement-stepper {\n border-inline-start-width: 1px;\n border-inline-start-style: solid;\n border-inline-start-color: oklch(84.522% 0 0);\n border-top: 1px solid oklch(84.522% 0 0);\n font-size: calc(0.75rem);\n display: flex;\n justify-content: center;\n align-items: center;\n flex: 1 1 0%;\n max-height: 50%;\n user-select: none;\n cursor: pointer;\n border-bottom-right-radius: var(--border-radius);\n\n &[aria-disabled='false']:active {\n background-color: oklch(37.798% 0.03812 254.612 / 0.055);\n }\n\n &[aria-disabled='true'] {\n cursor: not-allowed;\n }\n\n &[aria-readonly='true'] {\n cursor: not-allowed;\n }\n}\n\n:where(.dark, [data-theme='dark']) {\n .tremolo-number-input-wrapper {\n --border-color: oklch(52% 0 0);\n\n &[data-variant='filled'] {\n --bg: oklch(43.5% 0 0);\n\n &:hover {\n --bg: oklch(46.6% 0 0);\n }\n }\n }\n}\n",".tremolo-piano {\n display: inline-block;\n box-sizing: border-box;\n user-select: none;\n touch-action: none;\n position: relative;\n}\n\n.tremolo-piano-white-key {\n --bg: oklch(100% 0 0);\n --color: oklch(0% 0 0);\n --active-bg: oklch(92% 0 0);\n --active-color: oklch(0% 0 0);\n\n box-sizing: border-box;\n position: absolute;\n background-color: var(--bg);\n -webkit-tap-highlight-color: transparent;\n color: var(--color);\n border: 1px solid #555;\n border-radius: 0px 0px 8px 8px;\n cursor: pointer;\n z-index: 1;\n\n &[data-active='true'] {\n background-color: var(--active-bg);\n color: var(--active-color);\n }\n\n &[aria-disabled='true'] {\n cursor: not-allowed;\n }\n}\n\n.tremolo-piano-black-key {\n --color: oklch(100% 0 0);\n --bg: oklch(32% 0 0);\n --active-color: oklch(100% 0 0);\n --active-bg: oklch(44% 0 0);\n\n box-sizing: border-box;\n position: absolute;\n background-color: var(--bg);\n -webkit-tap-highlight-color: transparent;\n color: var(--color);\n border: 1px solid #555;\n border-radius: 0px 0px 8px 8px;\n cursor: pointer;\n z-index: 2;\n\n &[data-active='true'] {\n background-color: var(--active-bg);\n color: var(--active-color);\n }\n\n &[aria-disabled='true'] {\n cursor: not-allowed;\n }\n}\n\n.tremolo-piano-key-label-wrapper {\n display: flex;\n justify-content: center;\n align-items: end;\n height: 100%;\n}\n\n.tremolo-piano-key-label {\n box-sizing: content-box;\n display: flex;\n justify-content: center;\n align-items: center;\n font-size: 0.6rem;\n height: 10px;\n margin-bottom: 10px;\n padding: 4px;\n border-radius: 4px;\n border: 1px solid #888;\n aspect-ratio: 1;\n max-width: calc(100% - 16px);\n}\n\n:where(.dark, [data-theme='dark']) {\n .tremolo-piano-white-key {\n --bg: oklch(80% 0 0);\n --color: oklch(0% 0 0);\n --active-bg: oklch(72% 0 0);\n --active-color: oklch(0% 0 0);\n }\n}\n",".tremolo-slider {\n display: inline-block;\n cursor: pointer;\n outline: 0;\n -webkit-tap-highlight-color: transparent;\n\n &[aria-readonly='true'] {\n cursor: not-allowed;\n }\n}\n\n.tremolo-slider-thumb-wrapper {\n position: absolute;\n translate: -50% -50%;\n z-index: 100;\n}\n\n.tremolo-slider-thumb {\n --color: oklch(59.5% 0.17548 266.373);\n\n background: var(--color);\n border-radius: 50%;\n outline: none;\n\n &[aria-disabled='true'] {\n background: oklch(50.462% 0.03307 270.021);\n }\n\n &[aria-readonly='false']:focus {\n box-shadow: 0px 0px 0px 3px\n color-mix(in srgb, var(--color) 20%, transparent);\n }\n}\n\n.tremolo-slider-track {\n --active: oklch(69.212% 0.12903 267.633);\n --inactive: oklch(95% 0 0);\n\n position: relative;\n\n &[data-vertical='false'] {\n min-width: 50px;\n }\n\n &[data-vertical='true'] {\n min-height: 50px;\n }\n\n &:hover {\n --active: oklch(64.276% 0.15244 266.932);\n --inactive: #e0e0e0;\n }\n\n &[aria-disabled='true'] {\n --active: oklch(75.327% 0.01237 264.547);\n --inactive: oklch(89.755% 0.0001 271.152);\n }\n}\n\n.tremolo-slider-scale {\n display: block;\n position: relative;\n}\n\n.tremolo-slider-scale-option {\n display: flex;\n color: oklch(25.197% 0.00003 271.152);\n flex-direction: column;\n justify-content: center;\n align-items: center;\n position: absolute;\n translate: -50% 0;\n z-index: 10;\n\n &[data-vertical='true'] {\n flex-direction: row;\n translate: 0 -50%;\n }\n}\n\n.tremolo-slider-scale-option-mark {\n background-color: currentColor;\n}\n\n.tremolo-slider-scale-option-label {\n text-align: right;\n}\n\n:where(.dark, [data-theme='dark']) {\n .tremolo-slider-thumb {\n --color: oklch(64.5% 0.17548 266.373);\n\n &:hover {\n --color: oklch(67.5% 0.17548 266.373);\n }\n }\n\n .tremolo-slider-track {\n --active: oklch(59.5% 0.17548 266.373);\n --inactive: oklch(41.7% 0 0);\n\n &:hover {\n --active: oklch(62.5% 0.17548 266.373);\n --inactive: oklch(44.7% 0 0);\n }\n\n &[aria-disabled='true'] {\n --active: oklch(75.327% 0.01237 264.547);\n --inactive: oklch(89.755% 0.0001 271.152);\n }\n }\n}\n",".tremolo-xy-pad {\n display: inline-block;\n cursor: pointer;\n}\n\n.tremolo-xy-pad-area {\n --color: oklch(95% 0 0);\n\n position: relative;\n background-color: var(--color);\n}\n\n.tremolo-xy-pad-thumb-wrapper {\n position: absolute;\n translate: -50% -50%;\n z-index: 100;\n}\n\n.tremolo-xy-pad-thumb {\n --color: oklch(59.5% 0.17548 266.373);\n\n border-radius: 50%;\n outline: none;\n background-color: var(--color);\n\n &[aria-disabled='true'] {\n background-color: oklch(50.462% 0.03307 270.021);\n }\n\n &[aria-readonly='false']:focus {\n box-shadow: 0px 0px 0px 3px\n color-mix(in srgb, var(--color) 20%, transparent);\n }\n}\n\n:where(.dark, [data-theme='dark']) {\n .tremolo-xy-pad-area {\n --color: oklch(41.7% 0 0);\n }\n\n .tremolo-xy-pad-thumb {\n --color: oklch(64.5% 0.17548 266.373);\n\n &:hover {\n --color: oklch(67.5% 0.17548 266.373);\n }\n }\n}\n"],"mappings":"AAAA;AACA;AACA;;ACFA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;ACxEA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;ACxLA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;ACzFA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;;AAEA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AC/GA;AACA;AACA;AACA;;AAEA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;;AAEA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;;AAEA;AACA;AACA;AACA;AACA"}
@@ -1,314 +0,0 @@
1
- import clsx from 'clsx'
2
- import React, {
3
- ComponentPropsWithoutRef,
4
- CSSProperties,
5
- forwardRef,
6
- useCallback,
7
- useImperativeHandle,
8
- useRef,
9
- } from 'react'
10
-
11
- import {
12
- clamp,
13
- normalizeValue,
14
- radian,
15
- rawValue,
16
- stepValue,
17
- InputEventOption,
18
- } from '@tremolo-ui/functions'
19
-
20
- import { useDrag } from '../../hooks/useDrag'
21
- import { useRefCallbackEvent } from '../../hooks/useRefCallbackEvent'
22
- import { addNoSelect, removeNoSelect } from '../_util'
23
- import { AnimationCanvas, DrawFunction } from '../AnimationCanvas'
24
-
25
- /** @category AnimationKnob */
26
- export type AnimationKnobProps = {
27
- // required
28
- value: number
29
- min: number
30
- max: number
31
-
32
- // optional
33
- step?: number
34
- skew?: number // | SkewFunction
35
- /**
36
- * value set when double-clicking
37
- * @see enableDoubleClickDefault
38
- */
39
- defaultValue?: number
40
-
41
- /**
42
- * Value to be used as the starting point of the line when drawing.
43
- * @default min
44
- */
45
- startValue?: number
46
-
47
- /** Priority over width or height */
48
- size?: number | `${number}%`
49
-
50
- width?: number | `${number}%`
51
- height?: number | `${number}%`
52
-
53
- /** active line color */
54
- activeColor?: string
55
- /** inactive line color */
56
- inactiveColor?: string
57
- /** background color */
58
- bg?: string
59
- /** thumb color */
60
- thumb?: string
61
- lineWeight?: number
62
- thumbWeight?: number
63
-
64
- draw?: DrawFunction
65
-
66
- /** Whether to apply `{use-select: none}` when dragging */
67
- bodyNoSelect?: boolean
68
- /**
69
- * wheel control option
70
- */
71
- wheel?: InputEventOption | null
72
- /**
73
- * keyboard control option
74
- */
75
- keyboard?: InputEventOption | null
76
- enableDoubleClickDefault?: boolean
77
- style?: CSSProperties
78
- onChange?: (value: number) => void
79
- }
80
-
81
- /**
82
- * @category AnimationKnob
83
- */
84
- export interface AnimationKnobMethods {
85
- focus: () => void
86
- blur: () => void
87
- }
88
-
89
- type Props = AnimationKnobProps &
90
- Omit<ComponentPropsWithoutRef<'div'>, keyof AnimationKnobProps>
91
-
92
- /**
93
- * Animatable rotary knob.
94
- * @category AnimationKnob
95
- */
96
- export const AnimationKnob = forwardRef<AnimationKnobMethods, Props>(
97
- (
98
- {
99
- value,
100
- min,
101
- max,
102
- defaultValue = min,
103
- startValue = min,
104
- step = 1,
105
- skew = 1,
106
- size,
107
- width = 50,
108
- height = 50,
109
- activeColor = '#7998ec',
110
- inactiveColor = '#eee',
111
- bg = '#ccc',
112
- thumb = '#4e76e6',
113
- lineWeight = 6,
114
- thumbWeight = 4,
115
- draw,
116
- bodyNoSelect = true,
117
- wheel = ['raw', 1],
118
- keyboard = ['raw', 1],
119
- enableDoubleClickDefault = true,
120
- style,
121
- className,
122
- onChange,
123
- ...props
124
- }: Props,
125
- ref,
126
- ) => {
127
- // -- state and ref ---
128
- const valueRef = useRef(0)
129
- const elmRef = useRef<HTMLDivElement>(null)
130
-
131
- // --- interpret props ---
132
- const isRelativeSize =
133
- typeof (size ?? width) == 'string' || typeof height == 'string'
134
-
135
- const onDrag = useCallback(
136
- (_x: number, y: number) => {
137
- if (!onChange) return
138
- const v = rawValue(valueRef.current - y / 100, min, max, skew)
139
- const v2 = clamp(stepValue(v, step), min, max)
140
- onChange(v2)
141
- },
142
- [max, min, onChange, skew, step],
143
- )
144
-
145
- const handleKeyDown = useCallback(
146
- (event: React.KeyboardEvent<HTMLDivElement>) => {
147
- if (!keyboard || !onChange) return
148
- const key = event.key
149
- if (['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(key)) {
150
- event.preventDefault()
151
- const x =
152
- key == 'ArrowRight' || key == 'ArrowUp' ? keyboard[1] : -keyboard[1]
153
- let v
154
- if (keyboard[0] == 'normalized') {
155
- const n = normalizeValue(value, min, max, skew)
156
- v = rawValue(n + x, min, max, skew)
157
- } else {
158
- v = value + x
159
- }
160
- onChange(clamp(stepValue(v, step), min, max))
161
- }
162
- },
163
- [value, min, max, step, skew, keyboard, onChange],
164
- )
165
-
166
- // --- hooks ---
167
- const [touchMoveRefCallback, pointerDownHandler] = useDrag<HTMLDivElement>({
168
- onDrag: onDrag,
169
- onDragStart: () => {
170
- valueRef.current = normalizeValue(value, min, max, skew)
171
- if (bodyNoSelect) addNoSelect()
172
- },
173
- onDragEnd: () => {
174
- if (bodyNoSelect) removeNoSelect()
175
- },
176
- })
177
-
178
- const wheelRefCallback = useRefCallbackEvent(
179
- 'wheel',
180
- (event) => {
181
- if (!wheel) return
182
- event.preventDefault()
183
- const x = event.deltaY > 0 ? -wheel[1] : wheel[1]
184
- let v
185
- if (wheel[0] == 'normalized') {
186
- const n = normalizeValue(value, min, max, skew)
187
- v = rawValue(n + x, min, max, skew)
188
- } else {
189
- v = value + x
190
- }
191
- if (onChange) onChange(clamp(stepValue(v, step), min, max))
192
- },
193
- { passive: false },
194
- [wheel, value, min, max, skew, step, onChange],
195
- )
196
-
197
- useImperativeHandle(ref, () => {
198
- return {
199
- focus() {
200
- elmRef.current?.focus()
201
- },
202
- blur() {
203
- elmRef.current?.blur()
204
- },
205
- }
206
- }, [])
207
-
208
- return (
209
- <div
210
- className={clsx('tremolo-animation-knob', className)}
211
- ref={(div) => {
212
- elmRef.current = div
213
- wheelRefCallback(div)
214
- touchMoveRefCallback(div)
215
- }}
216
- role="slider"
217
- tabIndex={0}
218
- aria-valuenow={value}
219
- aria-valuemax={max}
220
- aria-valuemin={min}
221
- style={{
222
- display: 'inline-block',
223
- width: size ?? width,
224
- height: size ?? height,
225
- cursor: 'pointer',
226
- WebkitTapHighlightColor: 'transparent',
227
- ...style,
228
- }}
229
- onPointerDown={pointerDownHandler}
230
- onDoubleClick={() => {
231
- if (enableDoubleClickDefault && onChange) {
232
- onChange(defaultValue)
233
- }
234
- }}
235
- onKeyDown={handleKeyDown}
236
- {...props}
237
- >
238
- <AnimationCanvas
239
- {...(isRelativeSize
240
- ? { relativeSize: true }
241
- : {
242
- width: size ?? width,
243
- height: size ?? height,
244
- })}
245
- draw={
246
- draw
247
- ? draw
248
- : (ctx, { width, height }) => {
249
- const p = normalizeValue(value, min, max, skew)
250
- const startP = normalizeValue(startValue, min, max, skew)
251
- const cx = width / 2
252
- const cy = height / 2
253
- const r = Math.min(cx, cy)
254
- if (r <= 0) return
255
-
256
- // reset
257
- ctx.clearRect(0, 0, width, height)
258
- ctx.lineCap = 'butt'
259
-
260
- // bg
261
- ctx.beginPath()
262
- ctx.fillStyle = bg
263
- ctx.arc(cx, cy, r - 0.5, 0, 2 * Math.PI)
264
- ctx.fill()
265
-
266
- ctx.lineWidth = lineWeight
267
-
268
- // inactive rotary
269
- ctx.beginPath()
270
- ctx.strokeStyle = inactiveColor
271
- ctx.arc(
272
- cx,
273
- cy,
274
- r - 0.5 - lineWeight / 2,
275
- radian(135),
276
- radian(45),
277
- )
278
- ctx.stroke()
279
-
280
- // active rotary
281
- ctx.beginPath()
282
- ctx.strokeStyle = activeColor
283
- ctx.arc(
284
- cx,
285
- cy,
286
- r - 0.5 - lineWeight / 2,
287
- radian(135 + 270 * startP),
288
- radian(135 + 270 * p),
289
- p < startP,
290
- )
291
- ctx.stroke()
292
-
293
- // thumb
294
- ctx.lineCap = 'round'
295
- ctx.beginPath()
296
- ctx.moveTo(cx, cy)
297
- ctx.lineTo(
298
- cx +
299
- Math.cos(radian(135 + 270 * p)) *
300
- (r - 0.5 - thumbWeight / 2),
301
- cy +
302
- Math.sin(radian(135 + 270 * p)) *
303
- (r - 0.5 - thumbWeight / 2),
304
- )
305
- ctx.strokeStyle = thumb
306
- ctx.lineWidth = thumbWeight
307
- ctx.stroke()
308
- }
309
- }
310
- />
311
- </div>
312
- )
313
- },
314
- )