@tremolo-ui/react 0.2.0 → 0.2.1

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 +1203 -1036
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +176 -159
  4. package/dist/index.d.cts +241 -246
  5. package/dist/index.d.cts.map +1 -1
  6. package/dist/index.d.ts +241 -246
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1187 -1021
  9. package/dist/index.js.map +1 -1
  10. package/package.json +17 -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 +0 -1
  58. package/src/hooks/useMIDIInput.ts +0 -1
  59. package/src/hooks/useMIDIMessage.ts +0 -1
  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,7 +1,6 @@
1
1
  import clsx from 'clsx'
2
2
  import { ComponentPropsWithoutRef, CSSProperties, ReactElement } from 'react'
3
3
 
4
- /** @category XYPad */
5
4
  export interface XYPadAreaProps {
6
5
  width?: number | string
7
6
  height?: number | string
@@ -14,7 +13,6 @@ export interface XYPadAreaProps {
14
13
  __thumb?: ReactElement
15
14
  }
16
15
 
17
- /** @category XYPad */
18
16
  export function Area({
19
17
  width = 120,
20
18
  height = 120,
@@ -8,7 +8,6 @@ import {
8
8
  useRef,
9
9
  } from 'react'
10
10
 
11
- /** @category XYPad */
12
11
  export interface XYPadThumbProps {
13
12
  size?: number | string
14
13
  width?: number | string
@@ -29,7 +28,6 @@ export interface XYPadThumbProps {
29
28
  __css?: CSSProperties
30
29
  }
31
30
 
32
- /** @category XYPad */
33
31
  export interface XYPadThumbMethods {
34
32
  focus: () => void
35
33
  blur: () => void
@@ -38,7 +36,6 @@ export interface XYPadThumbMethods {
38
36
  type Props = XYPadThumbProps &
39
37
  Omit<ComponentPropsWithoutRef<'div'>, keyof XYPadThumbProps>
40
38
 
41
- /** @category XYPad */
42
39
  export const Thumb = forwardRef<XYPadThumbMethods, Props>(
43
40
  (
44
41
  {
@@ -3,6 +3,7 @@ import React, {
3
3
  ComponentPropsWithoutRef,
4
4
  forwardRef,
5
5
  ReactElement,
6
+ Ref,
6
7
  useCallback,
7
8
  useImperativeHandle,
8
9
  useMemo,
@@ -21,14 +22,33 @@ import {
21
22
 
22
23
  import { useDragWithElement } from '../../hooks/useDragWithElement'
23
24
  import { useRefCallbackEvent } from '../../hooks/useRefCallbackEvent'
24
- import { addNoSelect, removeNoSelect } from '../_util'
25
+ import {
26
+ addUserSelectNone,
27
+ Cursor,
28
+ removeUserSelectNone,
29
+ resetCursorStyle,
30
+ setCursorStyle,
31
+ } from '../_util'
32
+ import { composeRefs } from '../_util/composeRefs'
25
33
 
26
34
  import { Area, XYPadAreaProps } from './Area'
27
35
  import { Thumb, XYPadThumbMethods, XYPadThumbProps } from './Thumb'
28
36
 
37
+ const defaultValueOptions = {
38
+ skew: 1,
39
+ step: 1,
40
+ reverse: false,
41
+ wheel: ['raw', 1] as InputEventOption,
42
+ keyboard: ['raw', 1] as InputEventOption,
43
+ }
44
+
45
+ const defaultExternalStyles: XYPadProps['externalStyles'] = {
46
+ userSelectNone: true,
47
+ cursor: 'pointer',
48
+ }
49
+
29
50
  /**
30
51
  * Two-dimensional slider component.
31
- * @category XYPad
32
52
  */
33
53
  export interface ValueOptions {
34
54
  // required
@@ -50,14 +70,18 @@ export interface ValueOptions {
50
70
  keyboard?: InputEventOption | null
51
71
  }
52
72
 
53
- /** @category XYPad */
54
73
  export interface XYPadProps {
55
74
  x: ValueOptions
56
75
  y: ValueOptions
57
76
 
58
- bodyNoSelect?: boolean
77
+ externalStyles?: {
78
+ userSelectNone?: boolean
79
+ cursor?: Cursor
80
+ }
81
+
59
82
  disabled?: boolean
60
83
  readonly?: boolean
84
+
61
85
  onChange?: (valueX: number, valueY: number) => void
62
86
  onDragStart?: (valueX: number, valueY: number) => void
63
87
  onDragEnd?: (valueX: number, valueY: number) => void
@@ -65,31 +89,23 @@ export interface XYPadProps {
65
89
  children?: ReactElement | ReactElement[]
66
90
  }
67
91
 
68
- /** @category XYPad */
69
92
  export interface XYPadMethods {
70
93
  focus: () => void
71
94
  blur: () => void
72
- }
73
-
74
- const defaultValueOptions = {
75
- skew: 1,
76
- step: 1,
77
- reverse: false,
78
- wheel: ['raw', 1] as InputEventOption,
79
- keyboard: ['raw', 1] as InputEventOption,
95
+ original: Ref<HTMLDivElement>
80
96
  }
81
97
 
82
98
  type Props = XYPadProps &
83
99
  Omit<ComponentPropsWithoutRef<'div'>, keyof XYPadProps>
84
100
 
85
- export const XYPadImpl = forwardRef<XYPadMethods, Props>(
101
+ export const Root = forwardRef<XYPadMethods, Props>(
86
102
  (
87
103
  {
88
104
  x: _x,
89
105
  y: _y,
90
106
  className,
91
107
  style,
92
- bodyNoSelect = true,
108
+ externalStyles: _externalStyles,
93
109
  disabled = false,
94
110
  readonly = false,
95
111
  onChange,
@@ -112,10 +128,13 @@ export const XYPadImpl = forwardRef<XYPadMethods, Props>(
112
128
  }, [_y])
113
129
 
114
130
  // -- state and ref ---
131
+ const rootRef = useRef<HTMLDivElement>(null)
115
132
  const areaElementRef = useRef<HTMLDivElement>(null)
116
133
  const thumbRef = useRef<XYPadThumbMethods>(null)
117
134
 
118
135
  // --- interpret props ---
136
+ const externalStyles = { ...defaultExternalStyles, ..._externalStyles }
137
+
119
138
  const nx = normalizeValue(x.value, x.min, x.max, x.skew)
120
139
  const ny = normalizeValue(y.value, y.min, y.max, y.skew)
121
140
  const percentX = toFixed((x.reverse ? 1 - nx : nx) * 100)
@@ -212,14 +231,18 @@ export const XYPadImpl = forwardRef<XYPadMethods, Props>(
212
231
  )
213
232
 
214
233
  // --- hooks ---
215
- const [touchMoveRefCallback, pointerDownHandler] =
234
+ const { refHandler: touchMoveRefCallback, pointerDownHandler } =
216
235
  useDragWithElement<HTMLDivElement>({
217
236
  baseElementRef: areaElementRef,
218
237
  onDrag: onDrag,
219
238
  onDragStart: (nx, ny) => {
220
239
  if (readonly) return
221
- if (bodyNoSelect) addNoSelect()
240
+
241
+ if (externalStyles.userSelectNone) addUserSelectNone()
242
+ if (externalStyles.cursor) setCursorStyle(externalStyles.cursor)
243
+
222
244
  thumbRef.current?.focus()
245
+
223
246
  const valueX = clamp(
224
247
  stepValue(rawValue(nx, x.min, x.max, x.skew), x.step),
225
248
  x.min,
@@ -230,11 +253,17 @@ export const XYPadImpl = forwardRef<XYPadMethods, Props>(
230
253
  y.min,
231
254
  y.max,
232
255
  )
256
+
257
+ // TODO: ドラッグせず、pointer downだけの場合でも、onChange を発火させるべき
258
+ // onChange()
233
259
  onDragStart?.(valueX, valueY)
234
260
  },
235
261
  onDragEnd: (nx, ny) => {
236
262
  if (readonly) return
237
- if (bodyNoSelect) removeNoSelect()
263
+
264
+ if (externalStyles.userSelectNone) removeUserSelectNone()
265
+ if (externalStyles.cursor) resetCursorStyle()
266
+
238
267
  const valueX = clamp(
239
268
  stepValue(rawValue(nx, x.min, x.max, x.skew), x.step),
240
269
  x.min,
@@ -280,6 +309,7 @@ export const XYPadImpl = forwardRef<XYPadMethods, Props>(
280
309
  blur() {
281
310
  thumbRef.current?.blur()
282
311
  },
312
+ original: rootRef,
283
313
  }
284
314
  }, [])
285
315
 
@@ -287,10 +317,7 @@ export const XYPadImpl = forwardRef<XYPadMethods, Props>(
287
317
  // eslint-disable-next-line jsx-a11y/no-static-element-interactions
288
318
  <div
289
319
  className={clsx('tremolo-xy-pad', className)}
290
- ref={(div) => {
291
- wheelRefCallback(div)
292
- touchMoveRefCallback(div)
293
- }}
320
+ ref={composeRefs(rootRef, wheelRefCallback, touchMoveRefCallback)}
294
321
  tabIndex={-1}
295
322
  aria-disabled={disabled}
296
323
  aria-readonly={readonly}
@@ -342,12 +369,12 @@ export const XYPadImpl = forwardRef<XYPadMethods, Props>(
342
369
 
343
370
  /**
344
371
  * Simple XYPad
345
- * @category XYPad
346
372
  */
347
- export const XYPad = Object.assign(XYPadImpl, {
373
+ export const XYPad = {
374
+ Root,
348
375
  Thumb,
349
376
  Area,
350
- })
377
+ }
351
378
 
352
379
  export { type XYPadThumbProps, type XYPadThumbMethods } from './Thumb'
353
380
  export { type XYPadAreaProps } from './Area'
@@ -0,0 +1,63 @@
1
+ // ref:
2
+ // https://github.com/radix-ui/primitives/blob/2bab24a811e45c7a83198659272097f6cfa5a165/packages/react/compose-refs/src/compose-refs.tsx
3
+
4
+ import { useCallback } from 'react'
5
+
6
+ type PossibleRef<T> = React.Ref<T> | undefined
7
+
8
+ /**
9
+ * Set a given ref to a given value
10
+ * This utility takes care of different types of refs: callback refs and RefObject(s)
11
+ */
12
+ function setRef<T>(ref: PossibleRef<T>, value: T) {
13
+ if (typeof ref === 'function') {
14
+ return ref(value)
15
+ } else if (ref !== null && ref !== undefined) {
16
+ ref.current = value
17
+ }
18
+ }
19
+
20
+ /**
21
+ * A utility to compose multiple refs together
22
+ * Accepts callback refs and RefObject(s)
23
+ */
24
+ function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
25
+ return (node) => {
26
+ let hasCleanup = false
27
+ const cleanups = refs.map((ref) => {
28
+ const cleanup = setRef(ref, node)
29
+ if (!hasCleanup && typeof cleanup == 'function') {
30
+ hasCleanup = true
31
+ }
32
+ return cleanup
33
+ })
34
+
35
+ // React <19 will log an error to the console if a callback ref returns a
36
+ // value. We don't use ref cleanups internally so this will only happen if a
37
+ // user's ref callback returns a value, which we only expect if they are
38
+ // using the cleanup functionality added in React 19.
39
+ if (hasCleanup) {
40
+ return () => {
41
+ for (let i = 0; i < cleanups.length; i++) {
42
+ const cleanup = cleanups[i]
43
+ if (typeof cleanup == 'function') {
44
+ cleanup()
45
+ } else {
46
+ setRef(refs[i], null)
47
+ }
48
+ }
49
+ }
50
+ }
51
+ }
52
+ }
53
+
54
+ /**
55
+ * A custom hook that composes multiple refs
56
+ * Accepts callback refs and RefObject(s)
57
+ */
58
+ function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
59
+ // eslint-disable-next-line react-hooks/exhaustive-deps
60
+ return useCallback(composeRefs(...refs), refs)
61
+ }
62
+
63
+ export { composeRefs, useComposedRefs }
@@ -1,11 +1,28 @@
1
- export function addNoSelect() {
2
- document.body.classList.add('tremolo-no-select')
1
+ export function addUserSelectNone() {
2
+ window.document.body.classList.add('tremolo-user-select-none')
3
3
  }
4
4
 
5
- export function removeNoSelect() {
6
- document.body.classList.remove('tremolo-no-select')
5
+ export function removeUserSelectNone() {
6
+ window.document.body.classList.remove('tremolo-user-select-none')
7
7
  }
8
8
 
9
- export function toggleNoSelect() {
10
- document.body.classList.toggle('tremolo-no-select')
9
+ const cursorStyles = {
10
+ grabbing: 'tremolo-cursor-grabbing',
11
+ grab: 'tremolo-cursor-grab',
12
+ pointer: 'tremolo-cursor-pointer',
13
+ move: 'tremolo-cursor-move',
14
+ none: 'tremolo-cursor-none',
15
+ }
16
+
17
+ export type Cursor = keyof typeof cursorStyles
18
+
19
+ export function setCursorStyle(type: Cursor) {
20
+ resetCursorStyle()
21
+ window.document.body.classList.add(cursorStyles[type])
22
+ }
23
+
24
+ export function resetCursorStyle() {
25
+ Object.values(cursorStyles).forEach((s) => {
26
+ window.document.body.classList.remove(s)
27
+ })
11
28
  }
@@ -0,0 +1 @@
1
+ export type Override<T, U> = T & Omit<U, keyof T>
@@ -1,8 +1,5 @@
1
1
  import { DependencyList, useCallback, useEffect, useRef } from 'react'
2
2
 
3
- /**
4
- * @category hooks
5
- */
6
3
  export function useAnimationFrame(
7
4
  callback = () => {},
8
5
  deps: DependencyList = [],
@@ -1,4 +1,4 @@
1
- import { useCallback, useInsertionEffect, useRef } from 'react'
1
+ import { DependencyList, useCallback, useInsertionEffect, useRef } from 'react'
2
2
 
3
3
  /**
4
4
  * Internal
@@ -6,7 +6,7 @@ import { useCallback, useInsertionEffect, useRef } from 'react'
6
6
  */
7
7
  export function useCallbackRef<Args extends unknown[], Return>(
8
8
  callback: ((...args: Args) => Return) | undefined,
9
- deps: React.DependencyList = [],
9
+ deps: DependencyList = [],
10
10
  ) {
11
11
  const callbackRef = useRef<typeof callback>(() => {
12
12
  throw new Error('Cannot call an event handler while rendering.')
@@ -6,17 +6,16 @@ import { useRefCallbackEvent } from './useRefCallbackEvent'
6
6
  interface UseDragProps {
7
7
  threshold?: number
8
8
 
9
- onDrag: (x: number, y: number, deltaX: number, deltaY: number) => void
9
+ onDrag?: (x: number, y: number, deltaX: number, deltaY: number) => void
10
10
  onDragStart?: () => void
11
11
  onDragEnd?: () => void
12
12
  }
13
13
 
14
14
  /**
15
- * @category hooks
16
15
  * @returns [refCallback, pointerDownHandler]
17
16
  */
18
17
  export function useDrag<T extends Element>({
19
- threshold = 1,
18
+ threshold: _threshold = 1,
20
19
  onDrag,
21
20
  onDragStart,
22
21
  onDragEnd,
@@ -29,6 +28,8 @@ export function useDrag<T extends Element>({
29
28
  const dragStartX = useRef(0)
30
29
  const dragStartY = useRef(0)
31
30
 
31
+ const threshold = Math.max(_threshold, 1)
32
+
32
33
  const handleDrag = useCallback(
33
34
  (
34
35
  event: MouseEvent | React.MouseEvent<T, MouseEvent> | TouchEvent,
@@ -53,7 +54,7 @@ export function useDrag<T extends Element>({
53
54
  dragOffsetY.current = screenY
54
55
  }
55
56
  if (Math.abs(deltaX) < threshold && Math.abs(deltaY) < threshold) return
56
- onDrag(
57
+ onDrag?.(
57
58
  screenX - dragStartX.current,
58
59
  screenY - dragStartY.current,
59
60
  deltaX,
@@ -63,13 +64,6 @@ export function useDrag<T extends Element>({
63
64
  [threshold, onDrag],
64
65
  )
65
66
 
66
- const refHandler = useRefCallbackEvent(
67
- 'touchmove',
68
- handleDrag,
69
- { passive: false },
70
- [onDrag],
71
- )
72
-
73
67
  const pointerDownHandler = useCallback(
74
68
  (event: React.PointerEvent<T>) => {
75
69
  dragOffsetX.current = event.screenX
@@ -80,6 +74,13 @@ export function useDrag<T extends Element>({
80
74
  [handleDrag, onDragStart],
81
75
  )
82
76
 
77
+ const refHandler = useRefCallbackEvent(
78
+ 'touchmove',
79
+ handleDrag,
80
+ { passive: false },
81
+ [onDrag],
82
+ )
83
+
83
84
  useEventListener(globalThis.window, 'mousemove', handleDrag)
84
85
 
85
86
  useEventListener(globalThis.window, 'pointerup', () => {
@@ -1,37 +1,30 @@
1
- import { useRef, useCallback, RefObject } from 'react'
1
+ import { useRef, useCallback, RefObject, useState } from 'react'
2
2
 
3
3
  import { normalizeValue } from '@tremolo-ui/functions'
4
4
 
5
5
  import { useEventListener } from './useEventListener'
6
6
  import { useRefCallbackEvent } from './useRefCallbackEvent'
7
7
 
8
- interface UseDragWithElementProps<T extends Element> {
8
+ interface UseDragWithElement<T extends Element> {
9
9
  baseElementRef: RefObject<T | null>
10
10
  onDrag: (normalizedX: number, normalizedY: number) => void
11
11
  onDragStart?: (normalizedX: number, normalizedY: number) => void
12
12
  onDragEnd?: (normalizedX: number, normalizedY: number) => void
13
13
  }
14
14
 
15
- /**
16
- * @category hooks
17
- * @returns [refCallback, pointerDownHandler]
18
- */
19
15
  export function useDragWithElement<T extends Element>({
20
16
  baseElementRef,
21
17
  onDrag,
22
18
  onDragStart,
23
19
  onDragEnd,
24
- }: UseDragWithElementProps<T>): [
25
- (div: EventTarget | null) => void,
26
- (event: React.PointerEvent<T>) => void,
27
- ] {
28
- const dragged = useRef(false)
20
+ }: UseDragWithElement<T>) {
21
+ const [dragging, setDragging] = useState(false)
29
22
  const normalizedX = useRef(0)
30
23
  const normalizedY = useRef(0)
31
24
 
32
25
  const handleDrag = useCallback(
33
26
  (event: MouseEvent | React.PointerEvent<T> | TouchEvent) => {
34
- if (!baseElementRef.current || !dragged.current) {
27
+ if (!baseElementRef.current || !dragging) {
35
28
  return
36
29
  }
37
30
  const isTouch = event instanceof TouchEvent
@@ -46,7 +39,7 @@ export function useDragWithElement<T extends Element>({
46
39
  normalizedY.current = ny
47
40
  onDrag(nx, ny)
48
41
  },
49
- [onDrag, baseElementRef],
42
+ [onDrag, baseElementRef, dragging],
50
43
  )
51
44
 
52
45
  const refHandler = useRefCallbackEvent(
@@ -58,7 +51,8 @@ export function useDragWithElement<T extends Element>({
58
51
 
59
52
  const pointerDownHandler = useCallback(
60
53
  (event: React.PointerEvent<T>) => {
61
- dragged.current = true
54
+ // dragging.current = true
55
+ setDragging(true)
62
56
  handleDrag(event)
63
57
  onDragStart?.(normalizedX.current, normalizedY.current)
64
58
  },
@@ -68,10 +62,11 @@ export function useDragWithElement<T extends Element>({
68
62
  useEventListener(globalThis.window, 'pointermove', handleDrag)
69
63
 
70
64
  useEventListener(globalThis.window, 'pointerup', () => {
71
- if (!dragged.current) return
72
- dragged.current = false
65
+ if (!dragging) return
66
+ // dragging.current = false
67
+ setDragging(false)
73
68
  onDragEnd?.(normalizedX.current, normalizedY.current)
74
69
  })
75
70
 
76
- return [refHandler, pointerDownHandler]
71
+ return { refHandler, pointerDownHandler, dragging }
77
72
  }
@@ -5,31 +5,28 @@ import { useCallbackRef } from './useCallbackRef'
5
5
  type Target = EventTarget | null | (() => EventTarget | null)
6
6
  type Options = boolean | AddEventListenerOptions
7
7
 
8
- /**
9
- * @category hooks
10
- */
11
8
  export function useEventListener<K extends keyof DocumentEventMap>(
12
9
  target: Target,
13
10
  event: K,
14
- handler?: (event: DocumentEventMap[K]) => void,
11
+ handler: (event: DocumentEventMap[K]) => void,
15
12
  options?: Options,
16
13
  ): VoidFunction
17
14
  export function useEventListener<K extends keyof WindowEventMap>(
18
15
  target: Target,
19
16
  event: K,
20
- handler?: (event: WindowEventMap[K]) => void,
17
+ handler: (event: WindowEventMap[K]) => void,
21
18
  options?: Options,
22
19
  ): VoidFunction
23
20
  export function useEventListener<K extends keyof GlobalEventHandlersEventMap>(
24
21
  target: Target,
25
22
  event: K,
26
- handler?: (event: GlobalEventHandlersEventMap[K]) => void,
23
+ handler: (event: GlobalEventHandlersEventMap[K]) => void,
27
24
  options?: Options,
28
25
  ): VoidFunction
29
26
  export function useEventListener(
30
27
  target: Target,
31
28
  event: string,
32
- handler: ((event: Event) => void) | undefined,
29
+ handler: (event: Event) => void,
33
30
  options?: Options,
34
31
  ) {
35
32
  const listener = useCallbackRef(handler)
@@ -37,13 +34,13 @@ export function useEventListener(
37
34
  useEffect(() => {
38
35
  const node = typeof target === 'function' ? target() : (target ?? document)
39
36
 
40
- if (!handler || !node) return
37
+ if (!node) return
41
38
 
42
39
  node.addEventListener(event, listener, options)
43
40
  return () => {
44
41
  node.removeEventListener(event, listener, options)
45
42
  }
46
- }, [event, target, options, listener, handler])
43
+ }, [event, target, options, listener])
47
44
 
48
45
  return () => {
49
46
  const node = typeof target === 'function' ? target() : (target ?? document)
@@ -1,8 +1,5 @@
1
1
  import { useEffect, useRef } from 'react'
2
2
 
3
- /**
4
- * @category hooks
5
- */
6
3
  export function useInterval(callback: () => void, delay: number | null) {
7
4
  const savedCallback = useRef(callback)
8
5
 
@@ -3,9 +3,6 @@ import { useCallback, useState } from 'react'
3
3
  import { useEventListener } from './useEventListener'
4
4
  import { useInterval } from './useInterval'
5
5
 
6
- /**
7
- * @category hooks
8
- */
9
6
  export function useLongPress(
10
7
  callback: () => void,
11
8
  initialDelay = 500,
@@ -10,7 +10,6 @@ export type MIDIAccessError = typeof PERMISSION_DENIED | typeof NOT_SUPPORTED
10
10
 
11
11
  /**
12
12
  * Hooks for requesting MIDI access in the browser. The first argument allows you to choose whether to request access on mount.
13
- * @category hooks
14
13
  */
15
14
  export function useMIDIAccess(requestOnMount = true) {
16
15
  const [midiAccess, setMidiAccess] = useState<MIDIAccess | null>(null)
@@ -16,7 +16,6 @@ const MIDI_EVENT_TO_NUMBER = {
16
16
 
17
17
  /**
18
18
  * Hooks for handling note on/off events. To be used with useMIDIAccess. Internally uses useMIDIMessage.
19
- * @category hooks
20
19
  */
21
20
  export function useMIDIInput(
22
21
  midiAccess: MIDIAccess | null,
@@ -2,7 +2,6 @@ import { useEffect } from 'react'
2
2
 
3
3
  /**
4
4
  * Hooks for when you want to process MIDI events in more detail than useMIDIInput.
5
- * @category hooks
6
5
  */
7
6
  export function useMIDIMessage(
8
7
  midiAccess: MIDIAccess | null,
@@ -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
+ }