@tremolo-ui/react 0.7.0 → 0.9.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.
- package/dist/index.cjs +666 -1171
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +316 -315
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +316 -315
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +643 -1123
- package/dist/index.js.map +1 -1
- package/package.json +9 -9
- package/src/components/AnimationCanvas/index.tsx +4 -18
- package/src/components/DropZone/index.tsx +2 -1
- package/src/components/FileInput/index.tsx +11 -13
- package/src/components/Knob/ActiveLine.tsx +5 -3
- package/src/components/Knob/InactiveLine.tsx +6 -4
- package/src/components/Knob/SVGRoot.tsx +3 -3
- package/src/components/Knob/Thumb.tsx +21 -20
- package/src/components/Knob/context.tsx +3 -74
- package/src/components/Knob/index.tsx +99 -52
- package/src/components/NumberInput/InputField.tsx +20 -33
- package/src/components/NumberInput/Stepper.tsx +76 -100
- package/src/components/NumberInput/StepperButton.tsx +15 -8
- package/src/components/NumberInput/context.tsx +26 -3
- package/src/components/NumberInput/index.tsx +175 -91
- package/src/components/Piano/index.tsx +54 -144
- package/src/components/PointsEditor/Container.tsx +15 -26
- package/src/components/PointsEditor/Point.tsx +94 -110
- package/src/components/PointsEditor/context.tsx +8 -60
- package/src/components/PointsEditor/index.tsx +41 -324
- package/src/components/Slider/Marks.tsx +5 -6
- package/src/components/Slider/MarksOption.tsx +9 -19
- package/src/components/Slider/Thumb.tsx +20 -11
- package/src/components/Slider/Track.tsx +16 -13
- package/src/components/Slider/context.tsx +3 -3
- package/src/components/Slider/index.tsx +110 -72
- package/src/components/XYPad/Area.tsx +27 -49
- package/src/components/XYPad/Thumb.tsx +20 -8
- package/src/components/XYPad/context.tsx +2 -4
- package/src/components/XYPad/index.tsx +102 -83
- package/src/components/_util/VisuallyHiddenRangeInput.tsx +1 -1
- package/src/hooks/_internal/useChangeGesture.ts +93 -0
- package/src/hooks/_internal/useCheckSteps.ts +9 -146
- package/src/hooks/useAnimationFrame.ts +37 -15
- package/src/hooks/useEventListener.ts +93 -22
- package/src/hooks/useLongPress.ts +35 -51
- package/src/hooks/useMIDIAccess.ts +0 -8
- package/src/hooks/useMIDIInput.ts +0 -3
- package/src/hooks/useWheel.ts +4 -4
- package/src/index.ts +11 -32
- package/src/components/Piano/keyboard-shortcuts.ts +0 -61
- package/src/components/Slider/type.ts +0 -46
- package/src/components/_util/css-length.ts +0 -13
- package/src/components/_util/decimal-digits.ts +0 -23
- package/src/components/_util/visually-hidden.ts +0 -25
- package/src/hooks/useInterval.ts +0 -27
- package/src/input-event.ts +0 -32
|
@@ -11,33 +11,37 @@ import {
|
|
|
11
11
|
} from 'react'
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
|
-
applyDelta,
|
|
15
14
|
type AxisOptions,
|
|
15
|
+
type ChangeSource,
|
|
16
|
+
DEFAULT_DRAG_SENSITIVITY,
|
|
17
|
+
DEFAULT_KEYBOARD_OPTIONS,
|
|
18
|
+
DEFAULT_WHEEL_OPTIONS,
|
|
16
19
|
InputEventOption,
|
|
17
20
|
ModifierState,
|
|
18
21
|
type ModifierValue,
|
|
19
|
-
|
|
22
|
+
valuePercent,
|
|
23
|
+
type XY,
|
|
24
|
+
type XYInput,
|
|
20
25
|
} from '@tremolo-ui/dom'
|
|
21
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
applyDelta,
|
|
28
|
+
arrowKeyMove,
|
|
29
|
+
selectModifier,
|
|
30
|
+
toXY,
|
|
31
|
+
wheelMove,
|
|
32
|
+
} from '@tremolo-ui/dom/internal'
|
|
33
|
+
import { linearScale, type Scale } from '@tremolo-ui/functions'
|
|
22
34
|
|
|
23
35
|
import { useComposedRefs } from '../../compose-refs'
|
|
36
|
+
import { useChangeGesture } from '../../hooks/_internal/useChangeGesture'
|
|
24
37
|
import { useCheckSteps } from '../../hooks/_internal/useCheckSteps'
|
|
25
38
|
import { useDragValue } from '../../hooks/useDragValue'
|
|
26
39
|
import { useWheel } from '../../hooks/useWheel'
|
|
27
|
-
import {
|
|
28
|
-
DEFAULT_DRAG_SENSITIVITY,
|
|
29
|
-
DEFAULT_KEYBOARD_OPTIONS,
|
|
30
|
-
DEFAULT_WHEEL_OPTIONS,
|
|
31
|
-
} from '../../input-event'
|
|
32
40
|
|
|
33
41
|
import { Area } from './Area'
|
|
34
|
-
import {
|
|
42
|
+
import { XYPadProvider } from './context'
|
|
35
43
|
import { Thumb, XYPadThumbMethods } from './Thumb'
|
|
36
44
|
|
|
37
|
-
const defaultExternalStyles: XYPadProps['externalStyles'] = {
|
|
38
|
-
cursor: 'pointer',
|
|
39
|
-
}
|
|
40
|
-
|
|
41
45
|
/**
|
|
42
46
|
* Two-dimensional slider component.
|
|
43
47
|
*
|
|
@@ -130,11 +134,9 @@ export interface XYPadProps {
|
|
|
130
134
|
* The cursor to show while dragging. It is set on the dragged element, so it
|
|
131
135
|
* stays while the pointer is outside the pad.
|
|
132
136
|
*
|
|
133
|
-
* @default
|
|
137
|
+
* @default 'pointer'
|
|
134
138
|
*/
|
|
135
|
-
|
|
136
|
-
cursor?: CSSProperties['cursor']
|
|
137
|
-
}
|
|
139
|
+
dragCursor?: CSSProperties['cursor']
|
|
138
140
|
|
|
139
141
|
/**
|
|
140
142
|
* Make the pad unchangeable and remove its thumb from the tab order.
|
|
@@ -145,14 +147,29 @@ export interface XYPadProps {
|
|
|
145
147
|
* Make the pad unchangeable while leaving its thumb focusable.
|
|
146
148
|
* The parts carry `data-readonly` while it is set.
|
|
147
149
|
*/
|
|
148
|
-
|
|
150
|
+
readOnly?: boolean
|
|
149
151
|
|
|
150
152
|
/** Called with the new value when a drag, the wheel or an arrow key moves it. */
|
|
151
153
|
onChange?: (value: XY<number>) => void
|
|
152
|
-
/**
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
154
|
+
/**
|
|
155
|
+
* Called when a change of the value starts — a press on the area, the first wheel notch
|
|
156
|
+
* or arrow key — with the value
|
|
157
|
+
* before it and what it is made with. A host recording automation can treat
|
|
158
|
+
* the control as touched from here until `onChangeEnd`.
|
|
159
|
+
*/
|
|
160
|
+
onChangeStart?: (value: XY<number>, source: ChangeSource) => void
|
|
161
|
+
/**
|
|
162
|
+
* Called when the change ends, with the value it ended on: on release, or
|
|
163
|
+
* `changeEndDelay` after the last wheel notch or arrow key.
|
|
164
|
+
*/
|
|
165
|
+
onChangeEnd?: (value: XY<number>, source: ChangeSource) => void
|
|
166
|
+
/**
|
|
167
|
+
* How long after the last wheel notch or arrow key the change counts as
|
|
168
|
+
* over, in milliseconds. Neither has an event that says it is done.
|
|
169
|
+
*
|
|
170
|
+
* @default 500
|
|
171
|
+
*/
|
|
172
|
+
changeEndDelay?: number
|
|
156
173
|
|
|
157
174
|
/**
|
|
158
175
|
* The pad renders exactly what you compose here; there is no default
|
|
@@ -166,12 +183,18 @@ export interface XYPadProps {
|
|
|
166
183
|
* </XYPad.Root>
|
|
167
184
|
*/
|
|
168
185
|
children: ReactNode
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Receives `focus` and `blur`, which act on the range inputs inside the thumb —
|
|
189
|
+
* the elements that take the focus — and do nothing while
|
|
190
|
+
* the pad is disabled. `ref` reaches the root element itself.
|
|
191
|
+
*/
|
|
192
|
+
actionsRef?: Ref<XYPadMethods>
|
|
169
193
|
}
|
|
170
194
|
|
|
171
195
|
export interface XYPadMethods {
|
|
172
196
|
focus: () => void
|
|
173
197
|
blur: () => void
|
|
174
|
-
original: Ref<HTMLDivElement>
|
|
175
198
|
}
|
|
176
199
|
|
|
177
200
|
type Props = XYPadProps &
|
|
@@ -183,7 +206,7 @@ type Props = XYPadProps &
|
|
|
183
206
|
*/
|
|
184
207
|
const WHEEL_OPTIONS = { requireFocus: true }
|
|
185
208
|
|
|
186
|
-
export const Root = /* @__PURE__ */ forwardRef<
|
|
209
|
+
export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
|
|
187
210
|
(
|
|
188
211
|
{
|
|
189
212
|
value,
|
|
@@ -197,17 +220,19 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
|
|
|
197
220
|
dragSensitivity = DEFAULT_DRAG_SENSITIVITY,
|
|
198
221
|
className,
|
|
199
222
|
style,
|
|
200
|
-
|
|
223
|
+
dragCursor = 'pointer',
|
|
201
224
|
disabled = false,
|
|
202
|
-
|
|
225
|
+
readOnly = false,
|
|
203
226
|
onChange,
|
|
204
|
-
|
|
205
|
-
|
|
227
|
+
onChangeStart,
|
|
228
|
+
onChangeEnd,
|
|
229
|
+
changeEndDelay,
|
|
206
230
|
onPointerDown,
|
|
207
231
|
onKeyDown,
|
|
208
232
|
onFocus,
|
|
209
233
|
onBlur,
|
|
210
234
|
children,
|
|
235
|
+
actionsRef,
|
|
211
236
|
...props
|
|
212
237
|
}: Props,
|
|
213
238
|
forwardedRef,
|
|
@@ -218,8 +243,20 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
|
|
|
218
243
|
const thumbRef = useRef<XYPadThumbMethods>(null)
|
|
219
244
|
|
|
220
245
|
// --- interpret props ---
|
|
221
|
-
const
|
|
222
|
-
|
|
246
|
+
const inactive = disabled || readOnly
|
|
247
|
+
|
|
248
|
+
const gesture = useChangeGesture(
|
|
249
|
+
value,
|
|
250
|
+
{ onChangeStart, onChangeEnd, changeEndDelay },
|
|
251
|
+
inactive,
|
|
252
|
+
)
|
|
253
|
+
const change = useCallback(
|
|
254
|
+
(next: XY<number>) => {
|
|
255
|
+
gesture.changed(next)
|
|
256
|
+
onChange?.(next)
|
|
257
|
+
},
|
|
258
|
+
[gesture, onChange],
|
|
259
|
+
)
|
|
223
260
|
|
|
224
261
|
const min = useMemo(() => toXY(_min), [_min])
|
|
225
262
|
const max = useMemo(() => toXY(_max), [_max])
|
|
@@ -227,14 +264,17 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
|
|
|
227
264
|
const scale = useMemo(() => toXY(_scale), [_scale])
|
|
228
265
|
const reverse = useMemo(() => toXY(_reverse), [_reverse])
|
|
229
266
|
|
|
230
|
-
const percent = useMemo(
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
267
|
+
const percent = useMemo(
|
|
268
|
+
(): XY<number> =>
|
|
269
|
+
[0, 1].map((i) =>
|
|
270
|
+
valuePercent(
|
|
271
|
+
value[i],
|
|
272
|
+
{ min: min[i], max: max[i], scale: scale[i] },
|
|
273
|
+
reverse[i],
|
|
274
|
+
),
|
|
275
|
+
) as XY<number>,
|
|
276
|
+
[value, min, max, scale, reverse],
|
|
277
|
+
)
|
|
238
278
|
|
|
239
279
|
// --- internal functions ---
|
|
240
280
|
// `AxisOptions` extends `ValueRange`, so the same pair also describes the
|
|
@@ -291,23 +331,18 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
|
|
|
291
331
|
|
|
292
332
|
const handleKeyDown = useCallback(
|
|
293
333
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
294
|
-
const key = event.key
|
|
295
|
-
if (!['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(key))
|
|
296
|
-
return
|
|
297
|
-
|
|
298
334
|
// The key picks the axis, whichever of the two inputs holds the
|
|
299
|
-
// focus: the
|
|
300
|
-
//
|
|
301
|
-
|
|
302
|
-
|
|
335
|
+
// focus: the focus lands on the x input, so reading the axis off the
|
|
336
|
+
// input would leave the y axis with no keys at all.
|
|
337
|
+
const move = arrowKeyMove(event.key)
|
|
338
|
+
if (!move) return
|
|
303
339
|
event.preventDefault()
|
|
304
340
|
if (!onChange || inactive || !keyboard) return
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
onChange(nudge(i, direction, keyboard, event))
|
|
341
|
+
const { axis: i, direction } = move
|
|
342
|
+
gesture.pulse('keyboard')
|
|
343
|
+
change(nudge(i, reverse[i] ? -direction : direction, keyboard, event))
|
|
309
344
|
},
|
|
310
|
-
[onChange, inactive, keyboard, reverse, nudge],
|
|
345
|
+
[onChange, inactive, keyboard, reverse, nudge, gesture, change],
|
|
311
346
|
)
|
|
312
347
|
|
|
313
348
|
// --- hooks ---
|
|
@@ -318,43 +353,34 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
|
|
|
318
353
|
sensitivity: (state) =>
|
|
319
354
|
selectModifier(dragSensitivity, state.event).value,
|
|
320
355
|
updateOnPointerDown: true,
|
|
321
|
-
cursor: inactive ? undefined :
|
|
356
|
+
cursor: inactive ? undefined : dragCursor,
|
|
322
357
|
shouldStart: () => !inactive,
|
|
323
358
|
onChange: (v) => {
|
|
324
359
|
if (inactive) return
|
|
325
|
-
|
|
360
|
+
change(v)
|
|
326
361
|
},
|
|
327
|
-
onDragStart: (
|
|
362
|
+
onDragStart: () => {
|
|
328
363
|
if (inactive) return
|
|
364
|
+
gesture.hold('pointer')
|
|
329
365
|
thumbRef.current?.focus()
|
|
330
|
-
onDragStart?.(v)
|
|
331
|
-
},
|
|
332
|
-
onDragEnd: (v) => {
|
|
333
|
-
if (inactive) return
|
|
334
|
-
onDragEnd?.(v)
|
|
335
366
|
},
|
|
367
|
+
onDragEnd: () => gesture.end(),
|
|
336
368
|
})
|
|
337
369
|
|
|
338
370
|
const wheelRefCallback = useWheel<HTMLDivElement>((event) => {
|
|
339
371
|
if (!onChange || inactive || !wheel) return
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
// moved keeps shift working as the x-axis modifier — and picks up a
|
|
343
|
-
// trackpad's own horizontal gesture, which never had a modifier.
|
|
344
|
-
const horizontal = event.deltaX !== 0
|
|
345
|
-
const delta = horizontal ? event.deltaX : event.deltaY
|
|
346
|
-
if (delta === 0) return
|
|
347
|
-
const i: 0 | 1 = horizontal || event.shiftKey ? 0 : 1
|
|
372
|
+
const move = wheelMove(event)
|
|
373
|
+
if (!move) return
|
|
348
374
|
event.preventDefault()
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
onChange(nudge(i, direction, wheel, event))
|
|
375
|
+
const { axis: i, direction } = move
|
|
376
|
+
gesture.pulse('wheel')
|
|
377
|
+
change(nudge(i, reverse[i] ? -direction : direction, wheel, event))
|
|
353
378
|
}, WHEEL_OPTIONS)
|
|
354
379
|
|
|
355
380
|
// Composed once, so React attaches the refs a single time instead of
|
|
356
381
|
// detaching and re-attaching on every render.
|
|
357
382
|
const rootRefCallback = useComposedRefs<HTMLDivElement>(
|
|
383
|
+
forwardedRef,
|
|
358
384
|
rootRef,
|
|
359
385
|
dragRefCallback,
|
|
360
386
|
wheelRefCallback,
|
|
@@ -369,7 +395,7 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
|
|
|
369
395
|
scale,
|
|
370
396
|
reverse,
|
|
371
397
|
disabled,
|
|
372
|
-
|
|
398
|
+
readOnly,
|
|
373
399
|
onChange,
|
|
374
400
|
percent,
|
|
375
401
|
areaRef,
|
|
@@ -383,13 +409,13 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
|
|
|
383
409
|
scale,
|
|
384
410
|
reverse,
|
|
385
411
|
disabled,
|
|
386
|
-
|
|
412
|
+
readOnly,
|
|
387
413
|
onChange,
|
|
388
414
|
percent,
|
|
389
415
|
],
|
|
390
416
|
)
|
|
391
417
|
|
|
392
|
-
useImperativeHandle(
|
|
418
|
+
useImperativeHandle(actionsRef, () => {
|
|
393
419
|
return {
|
|
394
420
|
focus() {
|
|
395
421
|
if (!disabled) thumbRef.current?.focus()
|
|
@@ -397,7 +423,6 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
|
|
|
397
423
|
blur() {
|
|
398
424
|
thumbRef.current?.blur()
|
|
399
425
|
},
|
|
400
|
-
original: rootRef,
|
|
401
426
|
}
|
|
402
427
|
}, [disabled])
|
|
403
428
|
|
|
@@ -410,7 +435,7 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
|
|
|
410
435
|
role="group"
|
|
411
436
|
tabIndex={-1}
|
|
412
437
|
data-disabled={disabled ? '' : undefined}
|
|
413
|
-
data-readonly={
|
|
438
|
+
data-readonly={readOnly ? '' : undefined}
|
|
414
439
|
style={style}
|
|
415
440
|
onPointerDown={onPointerDown}
|
|
416
441
|
onKeyDown={(event) => {
|
|
@@ -444,10 +469,4 @@ export const XYPad = {
|
|
|
444
469
|
}
|
|
445
470
|
|
|
446
471
|
export { type XYPadThumbProps, type XYPadThumbMethods } from './Thumb'
|
|
447
|
-
export { type
|
|
448
|
-
export {
|
|
449
|
-
useXYPadContext,
|
|
450
|
-
type XYPadContextValue,
|
|
451
|
-
type XY,
|
|
452
|
-
type XYInput,
|
|
453
|
-
} from './context'
|
|
472
|
+
export { useXYPadContext, type XYPadContextValue } from './context'
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useInsertionEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
} from 'react'
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
createChangeGesture,
|
|
12
|
+
type ChangeGestureInstance,
|
|
13
|
+
type ChangeSource,
|
|
14
|
+
} from '@tremolo-ui/dom'
|
|
15
|
+
|
|
16
|
+
export interface ChangeGestureProps<V> {
|
|
17
|
+
onChangeStart?: (value: V, source: ChangeSource) => void
|
|
18
|
+
onChangeEnd?: (value: V, source: ChangeSource) => void
|
|
19
|
+
changeEndDelay?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ChangeGesture<V> = Pick<
|
|
23
|
+
ChangeGestureInstance,
|
|
24
|
+
'hold' | 'pulse' | 'instant' | 'end' | 'active'
|
|
25
|
+
> & {
|
|
26
|
+
/**
|
|
27
|
+
* Record a value the control has just reported through `onChange`, for
|
|
28
|
+
* `onChangeEnd` to give. The prop lags behind until the parent renders, and
|
|
29
|
+
* a drag ends in the same event as its last change.
|
|
30
|
+
*/
|
|
31
|
+
changed: (value: V) => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The core's change gesture, reporting the control's value to
|
|
36
|
+
* `onChangeStart` / `onChangeEnd`. The start gets the value before the change,
|
|
37
|
+
* the end the last one reported.
|
|
38
|
+
*
|
|
39
|
+
* Internal
|
|
40
|
+
* @private
|
|
41
|
+
*/
|
|
42
|
+
export function useChangeGesture<V>(
|
|
43
|
+
value: V,
|
|
44
|
+
{ onChangeStart, onChangeEnd, changeEndDelay }: ChangeGestureProps<V>,
|
|
45
|
+
inactive: boolean,
|
|
46
|
+
): ChangeGesture<V> {
|
|
47
|
+
const [instance] = useState(() => createChangeGesture())
|
|
48
|
+
const latest = useRef({ value, onChangeStart, onChangeEnd })
|
|
49
|
+
// What `onChangeEnd` gives: the value at the start, then each one reported.
|
|
50
|
+
const lastValue = useRef(value)
|
|
51
|
+
|
|
52
|
+
useInsertionEffect(() => {
|
|
53
|
+
latest.current = { value, onChangeStart, onChangeEnd }
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// The callbacks are handed over here rather than at creation: they read the
|
|
57
|
+
// refs, which belong outside render. Nothing can start a gesture before the
|
|
58
|
+
// element is mounted, so they are in place by the first event.
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
instance.update({
|
|
61
|
+
endDelay: changeEndDelay,
|
|
62
|
+
onStart: (source) => {
|
|
63
|
+
lastValue.current = latest.current.value
|
|
64
|
+
latest.current.onChangeStart?.(latest.current.value, source)
|
|
65
|
+
},
|
|
66
|
+
onEnd: (source) =>
|
|
67
|
+
latest.current.onChangeEnd?.(lastValue.current, source),
|
|
68
|
+
})
|
|
69
|
+
}, [instance, changeEndDelay])
|
|
70
|
+
|
|
71
|
+
// A control that turns inactive mid-gesture, or goes away, should not leave
|
|
72
|
+
// a host thinking it is still being touched.
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (inactive) instance.end()
|
|
75
|
+
}, [instance, inactive])
|
|
76
|
+
useEffect(() => () => instance.end(), [instance])
|
|
77
|
+
|
|
78
|
+
const changed = useCallback((next: V) => {
|
|
79
|
+
lastValue.current = next
|
|
80
|
+
}, [])
|
|
81
|
+
|
|
82
|
+
return useMemo(
|
|
83
|
+
() => ({
|
|
84
|
+
hold: instance.hold,
|
|
85
|
+
pulse: instance.pulse,
|
|
86
|
+
instant: instance.instant,
|
|
87
|
+
end: instance.end,
|
|
88
|
+
active: instance.active,
|
|
89
|
+
changed,
|
|
90
|
+
}),
|
|
91
|
+
[instance, changed],
|
|
92
|
+
)
|
|
93
|
+
}
|
|
@@ -1,162 +1,25 @@
|
|
|
1
1
|
import { useEffect } from 'react'
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
applyDelta,
|
|
5
|
-
type InputEventOption,
|
|
6
|
-
type ModifierState,
|
|
7
|
-
type ModifierValue,
|
|
8
|
-
} from '@tremolo-ui/dom'
|
|
9
|
-
import { linearScale, type ValueRange } from '@tremolo-ui/functions'
|
|
10
|
-
|
|
11
|
-
/** Positions probed across the travel. The ends are left out so that the
|
|
12
|
-
* clamp at `min` and `max` cannot be mistaken for a press that does nothing. */
|
|
13
|
-
const PROBES = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
|
|
14
|
-
|
|
15
|
-
const NONE: ModifierState = {
|
|
16
|
-
shiftKey: false,
|
|
17
|
-
altKey: false,
|
|
18
|
-
ctrlKey: false,
|
|
19
|
-
metaKey: false,
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const MODIFIER_STATE: Record<string, ModifierState> = {
|
|
23
|
-
shift: { ...NONE, shiftKey: true },
|
|
24
|
-
alt: { ...NONE, altKey: true },
|
|
25
|
-
ctrl: { ...NONE, ctrlKey: true },
|
|
26
|
-
meta: { ...NONE, metaKey: true },
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Each entry of a modifier-aware input option, as a name and held keys. */
|
|
30
|
-
function entries(
|
|
31
|
-
options: ModifierValue<InputEventOption>,
|
|
32
|
-
): { name: string; modifiers: ModifierState }[] {
|
|
33
|
-
if (Array.isArray(options)) return [{ name: '', modifiers: NONE }]
|
|
34
|
-
return Object.keys(options).map((key) => ({
|
|
35
|
-
name: key === 'default' ? '' : key,
|
|
36
|
-
modifiers: MODIFIER_STATE[key] ?? NONE,
|
|
37
|
-
}))
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
interface Outcome {
|
|
41
|
-
/** The press changed the value at least once across the travel. */
|
|
42
|
-
moved: boolean
|
|
43
|
-
/** The change reached the displayed text at least once. */
|
|
44
|
-
visible: boolean
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Press every entry of `options` at nine points along the travel and report
|
|
49
|
-
* whether anything came of it.
|
|
50
|
-
*
|
|
51
|
-
* Run against `applyDelta` itself rather than against a reading of `step`:
|
|
52
|
-
* the whole point is that the amount, the step and the scale interact, and
|
|
53
|
-
* the pipeline is the only thing that knows how.
|
|
54
|
-
*/
|
|
55
|
-
function probe(
|
|
56
|
-
options: ModifierValue<InputEventOption>,
|
|
57
|
-
range: ValueRange,
|
|
58
|
-
format?: (value: number) => string,
|
|
59
|
-
): Outcome {
|
|
60
|
-
const { min, max, scale = linearScale } = range
|
|
61
|
-
const outcome: Outcome = { moved: false, visible: false }
|
|
62
|
-
|
|
63
|
-
for (const { modifiers } of entries(options)) {
|
|
64
|
-
for (const position of PROBES) {
|
|
65
|
-
const value = scale.denormalize(position, min, max)
|
|
66
|
-
const up = applyDelta(value, 1, options, range, modifiers)
|
|
67
|
-
const down = applyDelta(value, -1, options, range, modifiers)
|
|
68
|
-
if (up !== value || down !== value) outcome.moved = true
|
|
69
|
-
if (!format) continue
|
|
70
|
-
const shown = format(value)
|
|
71
|
-
if (format(up) !== shown || format(down) !== shown) outcome.visible = true
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return outcome
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export interface CheckStepsOptions {
|
|
79
|
-
/** The component, for the message. */
|
|
80
|
-
component: string
|
|
81
|
-
/** The axis, for a component that has more than one. */
|
|
82
|
-
axis?: string
|
|
83
|
-
/**
|
|
84
|
-
* The range to probe, or `null` to check nothing. An unbounded input has no
|
|
85
|
-
* travel to sample, so `NumberInput` passes `null` when `min` and `max` are
|
|
86
|
-
* not both there.
|
|
87
|
-
*/
|
|
88
|
-
range: ValueRange | null
|
|
89
|
-
keyboard?: ModifierValue<InputEventOption> | null
|
|
90
|
-
wheel?: ModifierValue<InputEventOption> | null
|
|
91
|
-
/**
|
|
92
|
-
* How the value is displayed, where the component shows one. Called with
|
|
93
|
-
* probe values only, and only in development.
|
|
94
|
-
*/
|
|
95
|
-
format?: (value: number) => string
|
|
96
|
-
}
|
|
3
|
+
import { checkSteps, type CheckStepsOptions } from '@tremolo-ui/dom/internal'
|
|
97
4
|
|
|
98
5
|
/**
|
|
99
6
|
* Warn, in development only, when a key press or a wheel notch cannot produce
|
|
100
|
-
* a change the user can see.
|
|
101
|
-
*
|
|
102
|
-
* Two settings that are each fine on their own can cancel out, and nothing
|
|
103
|
-
* fails when they do — the control simply sits there:
|
|
104
|
-
*
|
|
105
|
-
* - **`step` coarser than the amount.** `keyboard={['raw', 0.1]}` with
|
|
106
|
-
* `step={1}` rounds every press straight back to where it started
|
|
107
|
-
* - **the display coarser than the amount.** A `format` showing two decimals
|
|
108
|
-
* of a kHz value cannot show a press worth 1 Hz
|
|
109
|
-
*
|
|
110
|
-
* The second is only reported when the press is invisible at *every* point
|
|
111
|
-
* along the travel. A display that rounds is a deliberate choice and is
|
|
112
|
-
* normally right — it is being too coarse everywhere that makes it a mistake.
|
|
7
|
+
* a change the user can see. The probing is `checkSteps` in the core.
|
|
113
8
|
*
|
|
114
9
|
* @internal
|
|
115
10
|
*/
|
|
116
|
-
export function useCheckSteps({
|
|
117
|
-
component,
|
|
118
|
-
axis,
|
|
119
|
-
range,
|
|
120
|
-
keyboard,
|
|
121
|
-
wheel,
|
|
122
|
-
format,
|
|
123
|
-
}: CheckStepsOptions) {
|
|
11
|
+
export function useCheckSteps(options: CheckStepsOptions) {
|
|
12
|
+
const { component, axis, range, keyboard, wheel } = options
|
|
124
13
|
const { min, max, step, scale } = range ?? {}
|
|
125
|
-
const where = axis ? `${component} (${axis})` : component
|
|
126
14
|
|
|
127
15
|
useEffect(() => {
|
|
128
16
|
try {
|
|
129
17
|
// Inline and first, so a bundler folds the comparison and drops the
|
|
130
|
-
// whole block —
|
|
131
|
-
//
|
|
132
|
-
// it is wrapped.
|
|
18
|
+
// whole block — and with it the only call to `checkSteps`, which then
|
|
19
|
+
// falls out of the bundle too. See `useCheckPlacement` for why it
|
|
20
|
+
// cannot go through a helper, and why it is wrapped.
|
|
133
21
|
if (process.env.NODE_ENV === 'production') return
|
|
134
|
-
|
|
135
|
-
return
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
for (const [name, options] of [
|
|
139
|
-
['keyboard', keyboard],
|
|
140
|
-
['wheel', wheel],
|
|
141
|
-
] as const) {
|
|
142
|
-
if (!options) continue
|
|
143
|
-
const { moved, visible } = probe(options, range, format)
|
|
144
|
-
if (!moved) {
|
|
145
|
-
console.warn(
|
|
146
|
-
`[tremolo-ui] ${where}: \`${name}\` cannot move the value.` +
|
|
147
|
-
(step !== undefined
|
|
148
|
-
? ` Each press is smaller than \`step\` (${step}), so it rounds`
|
|
149
|
-
: ' Each press rounds') +
|
|
150
|
-
' straight back to where it started.',
|
|
151
|
-
)
|
|
152
|
-
} else if (format && !visible) {
|
|
153
|
-
console.warn(
|
|
154
|
-
`[tremolo-ui] ${where}: \`${name}\` moves the value, but \`format\`` +
|
|
155
|
-
' shows the same text before and after, everywhere in the range.' +
|
|
156
|
-
' The display is too coarse for it to be seen.',
|
|
157
|
-
)
|
|
158
|
-
}
|
|
159
|
-
}
|
|
22
|
+
for (const warning of checkSteps(options)) console.warn(warning)
|
|
160
23
|
} catch {
|
|
161
24
|
// No bundler substituted NODE_ENV, so a production build cannot be told
|
|
162
25
|
// from a development one. Say nothing rather than break.
|
|
@@ -165,5 +28,5 @@ export function useCheckSteps({
|
|
|
165
28
|
// on what actually decides the outcome. `format` is left out on purpose:
|
|
166
29
|
// an inline arrow function would make this run every render.
|
|
167
30
|
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
168
|
-
}, [
|
|
31
|
+
}, [component, axis, min, max, step, scale, keyboard, wheel])
|
|
169
32
|
}
|
|
@@ -1,28 +1,50 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
2
|
|
|
3
3
|
import { useCallbackRef } from './_internal/useCallbackRef'
|
|
4
4
|
|
|
5
|
+
export interface UseAnimationFrameOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Stop the loop. Turning it back on starts a new one, whose first frame has
|
|
8
|
+
* a `deltaTime` of `0`.
|
|
9
|
+
* @default false
|
|
10
|
+
*/
|
|
11
|
+
disabled?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Call `callback` on every animation frame for as long as the component is
|
|
16
|
+
* mounted.
|
|
17
|
+
*
|
|
18
|
+
* The callback is read on every frame, so it can be written inline and see
|
|
19
|
+
* the latest render without restarting the loop.
|
|
20
|
+
*
|
|
21
|
+
* @param callback receives the frame's timestamp and the milliseconds since
|
|
22
|
+
* the previous frame, which is `0` on the first one
|
|
23
|
+
*/
|
|
5
24
|
export function useAnimationFrame(
|
|
6
|
-
callback
|
|
7
|
-
|
|
25
|
+
callback: (timestamp: DOMHighResTimeStamp, deltaTime: number) => void,
|
|
26
|
+
{ disabled = false }: UseAnimationFrameOptions = {},
|
|
8
27
|
) {
|
|
9
|
-
const reqIdRef = useRef(-1)
|
|
10
28
|
// Read through a ref rather than depended on: `useAnimationFrame(() => ...)`
|
|
11
29
|
// 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.
|
|
13
|
-
// is the caller's `deps`, and nothing else.
|
|
30
|
+
// cancel and re-schedule its own loop on every frame.
|
|
14
31
|
const runCallback = useCallbackRef(callback)
|
|
15
32
|
|
|
16
33
|
useEffect(() => {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
34
|
+
if (disabled) return
|
|
35
|
+
|
|
36
|
+
let frameId = 0
|
|
37
|
+
let previous: DOMHighResTimeStamp | undefined
|
|
38
|
+
|
|
39
|
+
// Scheduled before calling back, so that a slow callback does not delay
|
|
40
|
+
// the next request.
|
|
41
|
+
const loop = (timestamp: DOMHighResTimeStamp) => {
|
|
42
|
+
frameId = requestAnimationFrame(loop)
|
|
43
|
+
runCallback(timestamp, previous === undefined ? 0 : timestamp - previous)
|
|
44
|
+
previous = timestamp
|
|
22
45
|
}
|
|
23
46
|
|
|
24
|
-
|
|
25
|
-
return () => cancelAnimationFrame(
|
|
26
|
-
|
|
27
|
-
}, [runCallback, ...deps])
|
|
47
|
+
frameId = requestAnimationFrame(loop)
|
|
48
|
+
return () => cancelAnimationFrame(frameId)
|
|
49
|
+
}, [runCallback, disabled])
|
|
28
50
|
}
|