@tremolo-ui/react 0.4.0 → 0.6.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 (90) hide show
  1. package/dist/compose-refs.cjs +72 -0
  2. package/dist/compose-refs.cjs.map +1 -0
  3. package/dist/compose-refs.d.cts +43 -0
  4. package/dist/compose-refs.d.cts.map +1 -0
  5. package/dist/compose-refs.d.ts +43 -0
  6. package/dist/compose-refs.d.ts.map +1 -0
  7. package/dist/compose-refs.js +70 -0
  8. package/dist/compose-refs.js.map +1 -0
  9. package/dist/index.cjs +2272 -1650
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +1461 -475
  12. package/dist/index.d.cts.map +1 -1
  13. package/dist/index.d.ts +1461 -475
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2258 -1648
  16. package/dist/index.js.map +1 -1
  17. package/package.json +32 -31
  18. package/src/components/AnimationCanvas/index.tsx +162 -194
  19. package/src/components/Knob/ActiveLine.tsx +9 -8
  20. package/src/components/Knob/InactiveLine.tsx +11 -14
  21. package/src/components/Knob/SVGRoot.tsx +10 -17
  22. package/src/components/Knob/Thumb.tsx +33 -11
  23. package/src/components/Knob/context.tsx +73 -89
  24. package/src/components/Knob/index.tsx +227 -105
  25. package/src/components/NumberInput/DecrementStepper.tsx +21 -50
  26. package/src/components/NumberInput/IncrementStepper.tsx +22 -50
  27. package/src/components/NumberInput/InputField.tsx +206 -0
  28. package/src/components/NumberInput/Stepper.tsx +150 -21
  29. package/src/components/NumberInput/StepperButton.tsx +95 -0
  30. package/src/components/NumberInput/context.tsx +104 -149
  31. package/src/components/NumberInput/index.tsx +461 -110
  32. package/src/components/Piano/index.tsx +413 -263
  33. package/src/components/Piano/keyboard-shortcuts.ts +61 -0
  34. package/src/components/PointsEditor/Background.tsx +19 -4
  35. package/src/components/PointsEditor/Container.tsx +92 -22
  36. package/src/components/PointsEditor/Point.tsx +293 -53
  37. package/src/components/PointsEditor/SelectionBox.tsx +54 -0
  38. package/src/components/PointsEditor/context.tsx +97 -75
  39. package/src/components/PointsEditor/index.tsx +497 -62
  40. package/src/components/Slider/Marks.tsx +84 -0
  41. package/src/components/Slider/MarksOption.tsx +115 -0
  42. package/src/components/Slider/Thumb.tsx +99 -54
  43. package/src/components/Slider/Track.tsx +73 -62
  44. package/src/components/Slider/context.tsx +40 -53
  45. package/src/components/Slider/index.tsx +237 -190
  46. package/src/components/Slider/type.ts +27 -9
  47. package/src/components/XYPad/Area.tsx +52 -33
  48. package/src/components/XYPad/Thumb.tsx +121 -60
  49. package/src/components/XYPad/context.tsx +49 -0
  50. package/src/components/XYPad/index.tsx +338 -246
  51. package/src/components/_util/Placement.tsx +68 -0
  52. package/src/components/_util/VisuallyHiddenRangeInput.tsx +24 -0
  53. package/src/components/_util/css-length.ts +13 -0
  54. package/src/{components/_util/composeRefs.tsx → compose-refs/index.tsx} +33 -5
  55. package/src/hooks/{useCallbackRef.ts → _internal/useCallbackRef.ts} +3 -1
  56. package/src/hooks/_internal/useCheckSteps.ts +170 -0
  57. package/src/hooks/useAnimationFrame.ts +17 -7
  58. package/src/hooks/useDrag.ts +39 -9
  59. package/src/hooks/useDragValue.ts +194 -0
  60. package/src/hooks/useEventListener.ts +11 -7
  61. package/src/hooks/useLongPress.ts +42 -8
  62. package/src/hooks/useMIDIAccess.ts +82 -11
  63. package/src/hooks/useMIDIInput.ts +45 -13
  64. package/src/hooks/useMIDIMessage.ts +23 -5
  65. package/src/hooks/useWheel.ts +29 -9
  66. package/src/index.ts +50 -27
  67. package/src/input-event.ts +32 -0
  68. package/dist/index.css +0 -536
  69. package/src/Globals.d.ts +0 -3
  70. package/src/components/AnimationCanvas/canvas.ts +0 -46
  71. package/src/components/Knob/index.css +0 -77
  72. package/src/components/NumberInput/InternalInput.tsx +0 -216
  73. package/src/components/NumberInput/index.css +0 -185
  74. package/src/components/NumberInput/type.ts +0 -58
  75. package/src/components/Piano/KeyLabel.tsx +0 -51
  76. package/src/components/Piano/context.tsx +0 -73
  77. package/src/components/Piano/index.css +0 -90
  78. package/src/components/Piano/key.tsx +0 -205
  79. package/src/components/Piano/keyboardShortcuts.ts +0 -31
  80. package/src/components/PointsEditor/index.css +0 -30
  81. package/src/components/Slider/Scale.tsx +0 -52
  82. package/src/components/Slider/ScaleOption.tsx +0 -103
  83. package/src/components/Slider/index.css +0 -112
  84. package/src/components/XYPad/index.css +0 -48
  85. package/src/components/_util/index.ts +0 -28
  86. package/src/components/_util/type.ts +0 -1
  87. package/src/hooks/useDragWithElement.ts +0 -100
  88. package/src/hooks/usePianoDrag.ts +0 -75
  89. package/src/hooks/useRefCallbackEvent.ts +0 -34
  90. package/src/styles/global.css +0 -19
@@ -0,0 +1,68 @@
1
+ import { createContext, ReactNode, useContext, useEffect } from 'react'
2
+
3
+ /**
4
+ * The subcomponent that established the current placement, if any.
5
+ *
6
+ * @internal
7
+ */
8
+ const PlacementContext = /* @__PURE__ */ createContext<string | null>(null)
9
+
10
+ /**
11
+ * Marks its children as being inside `name`, for {@link useCheckPlacement}.
12
+ *
13
+ * @internal
14
+ */
15
+ export function Placement({
16
+ name,
17
+ children,
18
+ }: {
19
+ name: string
20
+ children: ReactNode
21
+ }) {
22
+ return (
23
+ <PlacementContext.Provider value={name}>
24
+ {children}
25
+ </PlacementContext.Provider>
26
+ )
27
+ }
28
+
29
+ /**
30
+ * Warn, in development only, when `child` is not rendered inside `parent`.
31
+ *
32
+ * A compound component renders its children exactly as they are composed, so a
33
+ * subcomponent put at the wrong level still renders — it only loses whatever
34
+ * its layout depended on, and does so silently. `Slider.Thumb` outside
35
+ * `Slider.Track` keeps its `position: absolute` but resolves it against the
36
+ * page rather than the track, and neither the build nor the tests notice.
37
+ *
38
+ * @internal
39
+ */
40
+ export function useCheckPlacement(child: string, parent: string) {
41
+ const found = useContext(PlacementContext)
42
+
43
+ useEffect(() => {
44
+ try {
45
+ // `process.env.NODE_ENV` is written out here, inline and first, so that a
46
+ // bundler substituting it folds the comparison and drops this whole
47
+ // block — the message strings with it. Reading the flag through a helper
48
+ // or a module constant leaves the branch in the bundle instead: neither
49
+ // folds, which was measured with esbuild rather than assumed.
50
+ //
51
+ // The read is wrapped because `process` may not exist at all. The
52
+ // package is built with `platform: 'neutral'`, so a page loading the ESM
53
+ // straight from a CDN has no bundler to substitute it, and a bare
54
+ // reference would throw a `ReferenceError` mid-effect.
55
+ if (process.env.NODE_ENV !== 'production' && found !== parent) {
56
+ console.warn(
57
+ `[tremolo-ui] ${child} has to be rendered inside ${parent}` +
58
+ (found === null ? '.' : `, but it is inside ${found}.`) +
59
+ ' It renders either way, so nothing fails — its position just' +
60
+ ' comes out wrong.',
61
+ )
62
+ }
63
+ } catch {
64
+ // No bundler substituted NODE_ENV, so there is no way to tell a
65
+ // production build from a development one. Say nothing rather than break.
66
+ }
67
+ }, [found, child, parent])
68
+ }
@@ -0,0 +1,24 @@
1
+ import { ComponentPropsWithoutRef, CSSProperties, forwardRef } from 'react'
2
+
3
+ const hiddenStyle: CSSProperties = {
4
+ position: 'absolute',
5
+ width: 1,
6
+ height: 1,
7
+ padding: 0,
8
+ margin: -1,
9
+ overflow: 'hidden',
10
+ clip: 'rect(0, 0, 0, 0)',
11
+ clipPath: 'inset(50%)',
12
+ whiteSpace: 'nowrap',
13
+ border: 0,
14
+ pointerEvents: 'none',
15
+ }
16
+
17
+ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type' | 'style'>
18
+
19
+ export const VisuallyHiddenRangeInput = /* @__PURE__ */ forwardRef<
20
+ HTMLInputElement,
21
+ Props
22
+ >((props, ref) => (
23
+ <input {...props} ref={ref} type="range" style={hiddenStyle} />
24
+ ))
@@ -0,0 +1,13 @@
1
+ /**
2
+ * A number as a CSS length, for a custom property.
3
+ *
4
+ * React appends `px` to a bare number for the properties it knows take a
5
+ * length, and a custom property is never one of them: `--size: 50` would come
6
+ * out as the invalid `50`. Anything already a string is passed through, so
7
+ * `'3rem'` and `'100%'` still work.
8
+ */
9
+ export function cssLength(
10
+ value: number | string | undefined,
11
+ ): string | undefined {
12
+ return typeof value === 'number' ? `${value}px` : value
13
+ }
@@ -1,5 +1,31 @@
1
- // ref:
2
- // https://github.com/radix-ui/primitives/blob/2bab24a811e45c7a83198659272097f6cfa5a165/packages/react/compose-refs/src/compose-refs.tsx
1
+ /**
2
+ * MIT License
3
+ *
4
+ * Copyright (c) 2022 WorkOS
5
+ *
6
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ * of this software and associated documentation files (the "Software"), to deal
8
+ * in the Software without restriction, including without limitation the rights
9
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ * copies of the Software, and to permit persons to whom the Software is
11
+ * furnished to do so, subject to the following conditions:
12
+ *
13
+ * The above copyright notice and this permission notice shall be included in all
14
+ * copies or substantial portions of the Software.
15
+ *
16
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ * SOFTWARE.
23
+ *
24
+ *
25
+ * Copied from Radix UI Primitives.
26
+ * The source is here.
27
+ * https://github.com/radix-ui/primitives/blob/2bab24a811e45c7a83198659272097f6cfa5a165/packages/react/compose-refs/src/compose-refs.tsx
28
+ */
3
29
 
4
30
  import { useCallback } from 'react'
5
31
 
@@ -26,7 +52,7 @@ function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
26
52
  let hasCleanup = false
27
53
  const cleanups = refs.map((ref) => {
28
54
  const cleanup = setRef(ref, node)
29
- if (!hasCleanup && typeof cleanup == 'function') {
55
+ if (!hasCleanup && typeof cleanup === 'function') {
30
56
  hasCleanup = true
31
57
  }
32
58
  return cleanup
@@ -40,7 +66,7 @@ function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
40
66
  return () => {
41
67
  for (let i = 0; i < cleanups.length; i++) {
42
68
  const cleanup = cleanups[i]
43
- if (typeof cleanup == 'function') {
69
+ if (typeof cleanup === 'function') {
44
70
  cleanup()
45
71
  } else {
46
72
  setRef(refs[i], null)
@@ -56,7 +82,9 @@ function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
56
82
  * Accepts callback refs and RefObject(s)
57
83
  */
58
84
  function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
59
- // eslint-disable-next-line react-hooks/exhaustive-deps
85
+ // The ref list is variadic, so neither the callback nor its dependencies can
86
+ // be written as the literals the compiler rules expect.
87
+ // oxlint-disable-next-line react-hooks/exhaustive-deps, react/use-memo
60
88
  return useCallback(composeRefs(...refs), refs)
61
89
  }
62
90
 
@@ -16,6 +16,8 @@ export function useCallbackRef<Args extends unknown[], Return>(
16
16
  callbackRef.current = callback
17
17
  })
18
18
 
19
- // eslint-disable-next-line react-hooks/exhaustive-deps
19
+ // `deps` comes from the caller, so it cannot be the array literal the
20
+ // compiler rules expect.
21
+ // oxlint-disable-next-line react-hooks/exhaustive-deps, react/use-memo
20
22
  return useCallback((...args: Args) => callbackRef.current?.(...args), deps)
21
23
  }
@@ -0,0 +1,170 @@
1
+ import { useEffect } from 'react'
2
+
3
+ import {
4
+ applyDelta,
5
+ linearScale,
6
+ type InputEventOption,
7
+ type ModifierState,
8
+ type ModifierValue,
9
+ type ValueRange,
10
+ } from '@tremolo-ui/functions'
11
+
12
+ /** Positions probed across the travel. The ends are left out so that the
13
+ * clamp at `min` and `max` cannot be mistaken for a press that does nothing. */
14
+ const PROBES = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
15
+
16
+ const NONE: ModifierState = {
17
+ shiftKey: false,
18
+ altKey: false,
19
+ ctrlKey: false,
20
+ metaKey: false,
21
+ }
22
+
23
+ const MODIFIER_STATE: Record<string, ModifierState> = {
24
+ shift: { ...NONE, shiftKey: true },
25
+ alt: { ...NONE, altKey: true },
26
+ ctrl: { ...NONE, ctrlKey: true },
27
+ meta: { ...NONE, metaKey: true },
28
+ }
29
+
30
+ /** Each entry of a modifier-aware input option, as a name and held keys. */
31
+ function entries(
32
+ options: ModifierValue<InputEventOption>,
33
+ ): { name: string; modifiers: ModifierState }[] {
34
+ if (Array.isArray(options)) return [{ name: '', modifiers: NONE }]
35
+ return Object.keys(options).map((key) => ({
36
+ name: key === 'default' ? '' : key,
37
+ modifiers: MODIFIER_STATE[key] ?? NONE,
38
+ }))
39
+ }
40
+
41
+ interface Outcome {
42
+ /** The press changed the value at least once across the travel. */
43
+ moved: boolean
44
+ /** The change reached the displayed text at least once. */
45
+ visible: boolean
46
+ }
47
+
48
+ /**
49
+ * Press every entry of `options` at nine points along the travel and report
50
+ * whether anything came of it.
51
+ *
52
+ * Run against `applyDelta` itself rather than against a reading of `step`:
53
+ * the whole point is that the amount, the step and the scale interact, and
54
+ * the pipeline is the only thing that knows how.
55
+ */
56
+ function probe(
57
+ options: ModifierValue<InputEventOption>,
58
+ range: ValueRange,
59
+ format?: (value: number) => string,
60
+ ): Outcome {
61
+ const { min, max, scale = linearScale } = range
62
+ const outcome: Outcome = { moved: false, visible: false }
63
+
64
+ for (const { modifiers } of entries(options)) {
65
+ for (const position of PROBES) {
66
+ const value = scale.denormalize(position, min, max)
67
+ const up = applyDelta(value, 1, options, range, modifiers)
68
+ const down = applyDelta(value, -1, options, range, modifiers)
69
+ if (up !== value || down !== value) outcome.moved = true
70
+ if (!format) continue
71
+ const shown = format(value)
72
+ if (format(up) !== shown || format(down) !== shown) outcome.visible = true
73
+ }
74
+ }
75
+
76
+ return outcome
77
+ }
78
+
79
+ export interface CheckStepsOptions {
80
+ /** The component, for the message. */
81
+ component: string
82
+ /** The axis, for a component that has more than one. */
83
+ axis?: string
84
+ /**
85
+ * The range to probe, or `null` to check nothing. An unbounded input has no
86
+ * travel to sample, so `NumberInput` passes `null` when `min` and `max` are
87
+ * not both there.
88
+ */
89
+ range: ValueRange | null
90
+ keyboard?: ModifierValue<InputEventOption> | null
91
+ wheel?: ModifierValue<InputEventOption> | null
92
+ /**
93
+ * How the value is displayed, where the component shows one. Called with
94
+ * probe values only, and only in development.
95
+ */
96
+ format?: (value: number) => string
97
+ }
98
+
99
+ /**
100
+ * Warn, in development only, when a key press or a wheel notch cannot produce
101
+ * a change the user can see.
102
+ *
103
+ * Two settings that are each fine on their own can cancel out, and nothing
104
+ * fails when they do — the control simply sits there:
105
+ *
106
+ * - **`step` coarser than the amount.** `keyboard={['raw', 0.1]}` with
107
+ * `step={1}` rounds every press straight back to where it started
108
+ * - **the display coarser than the amount.** A `format` showing two decimals
109
+ * of a kHz value cannot show a press worth 1 Hz
110
+ *
111
+ * The second is only reported when the press is invisible at *every* point
112
+ * along the travel. A display that rounds is a deliberate choice and is
113
+ * normally right — it is being too coarse everywhere that makes it a mistake.
114
+ *
115
+ * @internal
116
+ */
117
+ export function useCheckSteps({
118
+ component,
119
+ axis,
120
+ range,
121
+ keyboard,
122
+ wheel,
123
+ format,
124
+ }: CheckStepsOptions) {
125
+ const { min, max, step, scale } = range ?? {}
126
+ const where = axis ? `${component} (${axis})` : component
127
+
128
+ useEffect(() => {
129
+ try {
130
+ // Inline and first, so a bundler folds the comparison and drops the
131
+ // whole block — the probing and the message strings with it. See
132
+ // `useCheckPlacement` for why it cannot go through a helper, and why
133
+ // it is wrapped.
134
+ if (process.env.NODE_ENV === 'production') return
135
+ if (!range || min === undefined || max === undefined || !(min < max)) {
136
+ return
137
+ }
138
+
139
+ for (const [name, options] of [
140
+ ['keyboard', keyboard],
141
+ ['wheel', wheel],
142
+ ] as const) {
143
+ if (!options) continue
144
+ const { moved, visible } = probe(options, range, format)
145
+ if (!moved) {
146
+ console.warn(
147
+ `[tremolo-ui] ${where}: \`${name}\` cannot move the value.` +
148
+ (step !== undefined
149
+ ? ` Each press is smaller than \`step\` (${step}), so it rounds`
150
+ : ' Each press rounds') +
151
+ ' straight back to where it started.',
152
+ )
153
+ } else if (format && !visible) {
154
+ console.warn(
155
+ `[tremolo-ui] ${where}: \`${name}\` moves the value, but \`format\`` +
156
+ ' shows the same text before and after, everywhere in the range.' +
157
+ ' The display is too coarse for it to be seen.',
158
+ )
159
+ }
160
+ }
161
+ } catch {
162
+ // No bundler substituted NODE_ENV, so a production build cannot be told
163
+ // from a development one. Say nothing rather than break.
164
+ }
165
+ // `range` and `format` are rebuilt on most renders, so the effect is keyed
166
+ // on what actually decides the outcome. `format` is left out on purpose:
167
+ // an inline arrow function would make this run every render.
168
+ // oxlint-disable-next-line react-hooks/exhaustive-deps
169
+ }, [where, min, max, step, scale, keyboard, wheel])
170
+ }
@@ -1,18 +1,28 @@
1
- import { DependencyList, useCallback, useEffect, useRef } from 'react'
1
+ import { DependencyList, useEffect, useRef } from 'react'
2
+
3
+ import { useCallbackRef } from './_internal/useCallbackRef'
2
4
 
3
5
  export function useAnimationFrame(
4
6
  callback = () => {},
5
7
  deps: DependencyList = [],
6
8
  ) {
7
9
  const reqIdRef = useRef(-1)
8
- const loop = useCallback(() => {
9
- reqIdRef.current = requestAnimationFrame(loop)
10
- callback()
11
- // eslint-disable-next-line react-hooks/exhaustive-deps
12
- }, [callback, ...deps])
10
+ // Read through a ref rather than depended on: `useAnimationFrame(() => ...)`
11
+ // is a new function on every render, and a callback that renders would then
12
+ // cancel and re-schedule its own loop on every frame. What restarts the loop
13
+ // is the caller's `deps`, and nothing else.
14
+ const runCallback = useCallbackRef(callback)
13
15
 
14
16
  useEffect(() => {
17
+ // Kept inside the effect: as a `useCallback` the loop would have to
18
+ // reference itself before it is declared, which the compiler rules reject.
19
+ const loop = () => {
20
+ reqIdRef.current = requestAnimationFrame(loop)
21
+ runCallback()
22
+ }
23
+
15
24
  reqIdRef.current = requestAnimationFrame(loop)
16
25
  return () => cancelAnimationFrame(reqIdRef.current)
17
- }, [loop])
26
+ // oxlint-disable-next-line react-hooks/exhaustive-deps
27
+ }, [runCallback, ...deps])
18
28
  }
@@ -1,8 +1,8 @@
1
1
  import { useEffect, useState } from 'react'
2
2
 
3
- import { createDrag } from '@tremolo-ui/dom'
3
+ import { createDrag, type DragState } from '@tremolo-ui/dom'
4
4
 
5
- import { useCallbackRef } from './useCallbackRef'
5
+ import { useCallbackRef } from './_internal/useCallbackRef'
6
6
 
7
7
  interface UseDragProps {
8
8
  /**
@@ -14,9 +14,28 @@ interface UseDragProps {
14
14
  /** CSS cursor to show while dragging. Applied to the element itself. */
15
15
  cursor?: string
16
16
 
17
- onDrag?: (x: number, y: number, deltaX: number, deltaY: number) => void
18
- onDragStart?: () => void
19
- onDragEnd?: () => void
17
+ /**
18
+ * Hide the pointer and read its movement directly, instead of following it
19
+ * around the screen.
20
+ *
21
+ * @see DragOptions.pointerLock
22
+ * @default false
23
+ */
24
+ pointerLock?: boolean
25
+
26
+ /**
27
+ * @param state the whole drag, for anything the four numbers leave out —
28
+ * the pointer event and its modifier keys, most of all.
29
+ */
30
+ onDrag?: (
31
+ x: number,
32
+ y: number,
33
+ deltaX: number,
34
+ deltaY: number,
35
+ state: DragState,
36
+ ) => void
37
+ onDragStart?: (state: DragState) => void
38
+ onDragEnd?: (state: DragState) => void
20
39
  }
21
40
 
22
41
  /**
@@ -27,6 +46,7 @@ interface UseDragProps {
27
46
  export function useDrag<T extends Element>({
28
47
  threshold = 1,
29
48
  cursor,
49
+ pointerLock,
30
50
  onDrag,
31
51
  onDragStart,
32
52
  onDragEnd,
@@ -46,13 +66,23 @@ export function useDrag<T extends Element>({
46
66
  const instance = createDrag(node, {
47
67
  threshold,
48
68
  cursor,
49
- onDragStart: () => dragStartHandler(),
50
- onDrag: ({ x, y, deltaX, deltaY }) => dragHandler(x, y, deltaX, deltaY),
51
- onDragEnd: () => dragEndHandler(),
69
+ pointerLock,
70
+ onDragStart: (state) => dragStartHandler(state),
71
+ onDrag: (state) =>
72
+ dragHandler(state.x, state.y, state.deltaX, state.deltaY, state),
73
+ onDragEnd: (state) => dragEndHandler(state),
52
74
  })
53
75
 
54
76
  return () => instance.destroy()
55
- }, [node, threshold, cursor, dragHandler, dragStartHandler, dragEndHandler])
77
+ }, [
78
+ node,
79
+ threshold,
80
+ cursor,
81
+ pointerLock,
82
+ dragHandler,
83
+ dragStartHandler,
84
+ dragEndHandler,
85
+ ])
56
86
 
57
87
  return setNode
58
88
  }
@@ -0,0 +1,194 @@
1
+ import { RefObject, useEffect, useRef, useState } from 'react'
2
+
3
+ import {
4
+ createDragValue,
5
+ elementMapping,
6
+ relativeMapping,
7
+ type AxisOptions,
8
+ type DragState,
9
+ type DragValueInstance,
10
+ type XY,
11
+ type XYInput,
12
+ } from '@tremolo-ui/dom'
13
+
14
+ import { useCallbackRef } from './_internal/useCallbackRef'
15
+
16
+ export interface UseDragValueOptions {
17
+ /** Scaling of each axis; a single value applies to both. */
18
+ axis: XYInput<AxisOptions>
19
+
20
+ /**
21
+ * Normalize the pointer against the bounding rect of this element, so that
22
+ * the value *is* the position pointed at.
23
+ *
24
+ * Give either this or `getValue`.
25
+ */
26
+ baseElementRef?: RefObject<Element | null>
27
+
28
+ /**
29
+ * Move the value away from where it stood when the drag started, by the
30
+ * distance dragged. The pointer position itself carries no meaning.
31
+ *
32
+ * Give either this or `baseElementRef`.
33
+ */
34
+ getValue?: () => XY<number>
35
+ /**
36
+ * Pixels of movement that span the whole range, with `getValue`.
37
+ * @default 100
38
+ */
39
+ pixelRange?: XYInput<number>
40
+ /**
41
+ * How much the movement counts, read on every move. `0.1` makes the same
42
+ * movement cover a tenth of the range, which is what a fine-adjustment
43
+ * modifier wants.
44
+ *
45
+ * With `baseElementRef` the value is normally the position pointed at, so
46
+ * anything but `1` turns the mapping relative and leaves the pointer and the
47
+ * value apart for the rest of the drag.
48
+ *
49
+ * @see relativeMapping
50
+ * @see elementMapping
51
+ */
52
+ sensitivity?: (state: DragState) => number
53
+
54
+ /** @see DragValueOptions.updateOnPointerDown */
55
+ updateOnPointerDown?: boolean
56
+ /** @default 0 */
57
+ threshold?: number
58
+ /** CSS cursor to show while dragging. Applied to the element itself. */
59
+ cursor?: string
60
+ /**
61
+ * Hide the pointer and read its movement directly, instead of following it
62
+ * around the screen. Only with `getValue`: there is no pointer position to
63
+ * normalize against a `baseElementRef` while the pointer is locked.
64
+ *
65
+ * @see DragOptions.pointerLock
66
+ * @default false
67
+ */
68
+ pointerLock?: boolean
69
+ /**
70
+ * Decide whether a pointerdown starts a drag at all, before the pointer is
71
+ * captured.
72
+ *
73
+ * @see DragOptions.shouldStart
74
+ */
75
+ shouldStart?: (event: PointerEvent) => boolean
76
+
77
+ /**
78
+ * @param state the whole drag, for anything the value leaves out — the
79
+ * pointer event and its modifier keys, most of all.
80
+ */
81
+ onChange?: (value: XY<number>, state: DragState) => void
82
+ onDragStart?: (value: XY<number>, state: DragState) => void
83
+ onDragEnd?: (value: XY<number>, state: DragState) => void
84
+ }
85
+
86
+ /**
87
+ * Drive a value with a pointer drag.
88
+ *
89
+ * @returns a ref callback for the element that starts the drag, and whether a drag is in progress
90
+ */
91
+ export function useDragValue<T extends Element>(
92
+ options: UseDragValueOptions,
93
+ ): { refCallback: (node: T | null) => void; dragging: boolean } {
94
+ const {
95
+ axis,
96
+ baseElementRef,
97
+ pixelRange,
98
+ updateOnPointerDown,
99
+ threshold,
100
+ cursor,
101
+ pointerLock,
102
+ } = options
103
+
104
+ if (!baseElementRef && !options.getValue) {
105
+ throw new Error(
106
+ 'useDragValue: give either baseElementRef or getValue, so that the drag has something to move',
107
+ )
108
+ }
109
+
110
+ const [dragging, setDragging] = useState(false)
111
+
112
+ const valueGetter = useCallbackRef(options.getValue)
113
+ const changeHandler = useCallbackRef(options.onChange)
114
+ const dragStartHandler = useCallbackRef(options.onDragStart)
115
+ const dragEndHandler = useCallbackRef(options.onDragEnd)
116
+
117
+ // Read when the instance is created. The effect below keeps it current, and
118
+ // runs right after, so a stale setting is replaced within the same commit.
119
+ const latest = useRef(options)
120
+
121
+ const instanceRef = useRef<DragValueInstance | null>(null)
122
+
123
+ // See useDrag for why the node is held in state.
124
+ const [node, setNode] = useState<T | null>(null)
125
+
126
+ const [pixelRangeX, pixelRangeY] = Array.isArray(pixelRange)
127
+ ? pixelRange
128
+ : [pixelRange, pixelRange]
129
+
130
+ useEffect(() => {
131
+ if (!node) return
132
+
133
+ const instance = createDragValue(node, {
134
+ axis: latest.current.axis,
135
+ mapping: baseElementRef
136
+ ? elementMapping(() => baseElementRef.current, {
137
+ // Read through the ref so that a changed setting reaches a drag
138
+ // already in progress.
139
+ sensitivity: (state) => latest.current.sensitivity?.(state) ?? 1,
140
+ })
141
+ : relativeMapping({
142
+ pixelRange: [pixelRangeX ?? 100, pixelRangeY ?? 100],
143
+ // Read through the ref so that a changed setting reaches a drag
144
+ // already in progress.
145
+ sensitivity: (state) => latest.current.sensitivity?.(state) ?? 1,
146
+ }),
147
+ getValue: () => valueGetter() ?? [0, 0],
148
+ updateOnPointerDown: latest.current.updateOnPointerDown,
149
+ threshold: latest.current.threshold,
150
+ cursor: latest.current.cursor,
151
+ pointerLock: latest.current.pointerLock,
152
+ shouldStart: (event) => latest.current.shouldStart?.(event) ?? true,
153
+ onChange: (value, state) => changeHandler(value, state),
154
+ onDragStart: (value, state) => {
155
+ setDragging(true)
156
+ dragStartHandler(value, state)
157
+ },
158
+ onDragEnd: (value, state) => {
159
+ setDragging(false)
160
+ dragEndHandler(value, state)
161
+ },
162
+ })
163
+ instanceRef.current = instance
164
+
165
+ return () => {
166
+ instanceRef.current = null
167
+ instance.destroy()
168
+ }
169
+ }, [
170
+ node,
171
+ baseElementRef,
172
+ pixelRangeX,
173
+ pixelRangeY,
174
+ valueGetter,
175
+ changeHandler,
176
+ dragStartHandler,
177
+ dragEndHandler,
178
+ ])
179
+
180
+ // Runs after every render: the settings come from props and are cheap to
181
+ // push, and updating in place leaves a drag in progress untouched.
182
+ useEffect(() => {
183
+ latest.current = options
184
+ instanceRef.current?.update({
185
+ axis,
186
+ updateOnPointerDown,
187
+ threshold,
188
+ cursor,
189
+ pointerLock,
190
+ })
191
+ })
192
+
193
+ return { refCallback: setNode, dragging }
194
+ }