@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.
- package/dist/compose-refs.cjs +72 -0
- package/dist/compose-refs.cjs.map +1 -0
- package/dist/compose-refs.d.cts +43 -0
- package/dist/compose-refs.d.cts.map +1 -0
- package/dist/compose-refs.d.ts +43 -0
- package/dist/compose-refs.d.ts.map +1 -0
- package/dist/compose-refs.js +70 -0
- package/dist/compose-refs.js.map +1 -0
- package/dist/index.cjs +2272 -1650
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1461 -475
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +1461 -475
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2258 -1648
- package/dist/index.js.map +1 -1
- package/package.json +32 -31
- package/src/components/AnimationCanvas/index.tsx +162 -194
- package/src/components/Knob/ActiveLine.tsx +9 -8
- package/src/components/Knob/InactiveLine.tsx +11 -14
- package/src/components/Knob/SVGRoot.tsx +10 -17
- package/src/components/Knob/Thumb.tsx +33 -11
- package/src/components/Knob/context.tsx +73 -89
- package/src/components/Knob/index.tsx +227 -105
- package/src/components/NumberInput/DecrementStepper.tsx +21 -50
- package/src/components/NumberInput/IncrementStepper.tsx +22 -50
- package/src/components/NumberInput/InputField.tsx +206 -0
- package/src/components/NumberInput/Stepper.tsx +150 -21
- package/src/components/NumberInput/StepperButton.tsx +95 -0
- package/src/components/NumberInput/context.tsx +104 -149
- package/src/components/NumberInput/index.tsx +461 -110
- package/src/components/Piano/index.tsx +413 -263
- package/src/components/Piano/keyboard-shortcuts.ts +61 -0
- package/src/components/PointsEditor/Background.tsx +19 -4
- package/src/components/PointsEditor/Container.tsx +92 -22
- package/src/components/PointsEditor/Point.tsx +293 -53
- package/src/components/PointsEditor/SelectionBox.tsx +54 -0
- package/src/components/PointsEditor/context.tsx +97 -75
- package/src/components/PointsEditor/index.tsx +497 -62
- package/src/components/Slider/Marks.tsx +84 -0
- package/src/components/Slider/MarksOption.tsx +115 -0
- package/src/components/Slider/Thumb.tsx +99 -54
- package/src/components/Slider/Track.tsx +73 -62
- package/src/components/Slider/context.tsx +40 -53
- package/src/components/Slider/index.tsx +237 -190
- package/src/components/Slider/type.ts +27 -9
- package/src/components/XYPad/Area.tsx +52 -33
- package/src/components/XYPad/Thumb.tsx +121 -60
- package/src/components/XYPad/context.tsx +49 -0
- package/src/components/XYPad/index.tsx +338 -246
- package/src/components/_util/Placement.tsx +68 -0
- package/src/components/_util/VisuallyHiddenRangeInput.tsx +24 -0
- package/src/components/_util/css-length.ts +13 -0
- package/src/{components/_util/composeRefs.tsx → compose-refs/index.tsx} +33 -5
- package/src/hooks/{useCallbackRef.ts → _internal/useCallbackRef.ts} +3 -1
- package/src/hooks/_internal/useCheckSteps.ts +170 -0
- package/src/hooks/useAnimationFrame.ts +17 -7
- package/src/hooks/useDrag.ts +39 -9
- package/src/hooks/useDragValue.ts +194 -0
- package/src/hooks/useEventListener.ts +11 -7
- package/src/hooks/useLongPress.ts +42 -8
- package/src/hooks/useMIDIAccess.ts +82 -11
- package/src/hooks/useMIDIInput.ts +45 -13
- package/src/hooks/useMIDIMessage.ts +23 -5
- package/src/hooks/useWheel.ts +29 -9
- package/src/index.ts +50 -27
- package/src/input-event.ts +32 -0
- package/dist/index.css +0 -536
- package/src/Globals.d.ts +0 -3
- package/src/components/AnimationCanvas/canvas.ts +0 -46
- package/src/components/Knob/index.css +0 -77
- package/src/components/NumberInput/InternalInput.tsx +0 -216
- package/src/components/NumberInput/index.css +0 -185
- package/src/components/NumberInput/type.ts +0 -58
- package/src/components/Piano/KeyLabel.tsx +0 -51
- package/src/components/Piano/context.tsx +0 -73
- package/src/components/Piano/index.css +0 -90
- package/src/components/Piano/key.tsx +0 -205
- package/src/components/Piano/keyboardShortcuts.ts +0 -31
- package/src/components/PointsEditor/index.css +0 -30
- package/src/components/Slider/Scale.tsx +0 -52
- package/src/components/Slider/ScaleOption.tsx +0 -103
- package/src/components/Slider/index.css +0 -112
- package/src/components/XYPad/index.css +0 -48
- package/src/components/_util/index.ts +0 -28
- package/src/components/_util/type.ts +0 -1
- package/src/hooks/useDragWithElement.ts +0 -100
- package/src/hooks/usePianoDrag.ts +0 -75
- package/src/hooks/useRefCallbackEvent.ts +0 -34
- package/src/styles/global.css +0 -19
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type KeyboardShortcuts = {
|
|
2
|
+
/**
|
|
3
|
+
* Keys laid out from `noteRange.first`, one entry per semitone.
|
|
4
|
+
*
|
|
5
|
+
* An empty string leaves that note without a shortcut: `KeyboardEvent.key` is
|
|
6
|
+
* never empty, so the entry can never match. Use it to skip the black keys
|
|
7
|
+
* (see {@link SHORTCUTS.HOME_ROW_NATURAL}) and keep the remaining entries
|
|
8
|
+
* lined up with the notes.
|
|
9
|
+
*/
|
|
10
|
+
keys: string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Ready-made keyboard layouts. Both assume `noteRange.first` is a C.
|
|
15
|
+
*/
|
|
16
|
+
export const SHORTCUTS = {
|
|
17
|
+
/** Every semitone from C, over the two rows of a QWERTY keyboard. */
|
|
18
|
+
HOME_ROW: {
|
|
19
|
+
keys: [
|
|
20
|
+
'a',
|
|
21
|
+
'w',
|
|
22
|
+
's',
|
|
23
|
+
'e',
|
|
24
|
+
'd',
|
|
25
|
+
'f',
|
|
26
|
+
't',
|
|
27
|
+
'g',
|
|
28
|
+
'y',
|
|
29
|
+
'h',
|
|
30
|
+
'u',
|
|
31
|
+
'j',
|
|
32
|
+
'k',
|
|
33
|
+
'o',
|
|
34
|
+
'l',
|
|
35
|
+
'p',
|
|
36
|
+
';',
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
/** The white keys only, on the home row. Black keys have no shortcut. */
|
|
40
|
+
HOME_ROW_NATURAL: {
|
|
41
|
+
keys: [
|
|
42
|
+
'a',
|
|
43
|
+
'',
|
|
44
|
+
's',
|
|
45
|
+
'',
|
|
46
|
+
'd',
|
|
47
|
+
'f',
|
|
48
|
+
'',
|
|
49
|
+
'g',
|
|
50
|
+
'',
|
|
51
|
+
'h',
|
|
52
|
+
'',
|
|
53
|
+
'j',
|
|
54
|
+
'k',
|
|
55
|
+
'',
|
|
56
|
+
'l',
|
|
57
|
+
'',
|
|
58
|
+
';',
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
}
|
|
@@ -1,14 +1,29 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import { ComponentPropsWithoutRef, ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface PointsEditorBackgroundProps {
|
|
4
|
+
/** Whatever the points are placed over: a graph, a canvas, an image. */
|
|
5
|
+
children?: ReactNode
|
|
6
|
+
}
|
|
3
7
|
|
|
4
8
|
export function Background({
|
|
5
9
|
className,
|
|
6
10
|
children,
|
|
11
|
+
style,
|
|
7
12
|
...props
|
|
8
|
-
}:
|
|
13
|
+
}: PointsEditorBackgroundProps &
|
|
14
|
+
Omit<ComponentPropsWithoutRef<'div'>, keyof PointsEditorBackgroundProps>) {
|
|
9
15
|
return (
|
|
10
16
|
<div
|
|
11
|
-
className={
|
|
17
|
+
className={className}
|
|
18
|
+
style={{
|
|
19
|
+
// Behind the points, filling the editor: what it is for, rather than
|
|
20
|
+
// a look. The order of the layers holds whichever way they are
|
|
21
|
+
// written, so the z-index is part of the same mechanics.
|
|
22
|
+
position: 'absolute',
|
|
23
|
+
inset: 0,
|
|
24
|
+
zIndex: 0,
|
|
25
|
+
...style,
|
|
26
|
+
}}
|
|
12
27
|
{...props}
|
|
13
28
|
>
|
|
14
29
|
{children}
|
|
@@ -1,26 +1,96 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import { ComponentPropsWithoutRef, forwardRef, ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import { useComposedRefs } from '../../compose-refs'
|
|
4
|
+
import { useDragValue } from '../../hooks/useDragValue'
|
|
5
|
+
import { useWheel } from '../../hooks/useWheel'
|
|
6
|
+
import { Placement } from '../_util/Placement'
|
|
3
7
|
|
|
4
8
|
import { usePointsEditorContext } from './context'
|
|
9
|
+
import { AXIS } from './Point'
|
|
5
10
|
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
children
|
|
9
|
-
...props
|
|
10
|
-
}: ComponentPropsWithoutRef<'div'>) {
|
|
11
|
-
const containerElementRef = useRef<HTMLDivElement>(null)
|
|
12
|
-
const setContainerElementRef = usePointsEditorContext(
|
|
13
|
-
(s) => s.setContainerElementRef,
|
|
14
|
-
)
|
|
15
|
-
setContainerElementRef(containerElementRef)
|
|
16
|
-
|
|
17
|
-
return (
|
|
18
|
-
<div
|
|
19
|
-
ref={containerElementRef}
|
|
20
|
-
className={clsx('tremolo-points-editor-container', className)}
|
|
21
|
-
{...props}
|
|
22
|
-
>
|
|
23
|
-
{children}
|
|
24
|
-
</div>
|
|
25
|
-
)
|
|
11
|
+
export interface PointsEditorContainerProps {
|
|
12
|
+
/** `<PointsEditor.Point />` goes here. */
|
|
13
|
+
children?: ReactNode
|
|
26
14
|
}
|
|
15
|
+
|
|
16
|
+
type Props = PointsEditorContainerProps &
|
|
17
|
+
Omit<ComponentPropsWithoutRef<'div'>, keyof PointsEditorContainerProps>
|
|
18
|
+
|
|
19
|
+
export const Container = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
|
|
20
|
+
function Container({ children, className, style, ...props }, forwardedRef) {
|
|
21
|
+
const {
|
|
22
|
+
containerRef,
|
|
23
|
+
disabled,
|
|
24
|
+
selectable,
|
|
25
|
+
isPointElement,
|
|
26
|
+
nudgeFocusedPoint,
|
|
27
|
+
beginSelectionBox,
|
|
28
|
+
moveSelectionBox,
|
|
29
|
+
endSelectionBox,
|
|
30
|
+
} = usePointsEditorContext()
|
|
31
|
+
const { refCallback: dragRefCallback } = useDragValue<HTMLDivElement>({
|
|
32
|
+
axis: AXIS,
|
|
33
|
+
baseElementRef: containerRef,
|
|
34
|
+
// A press that landed on a point belongs to that point. Declining here
|
|
35
|
+
// rather than in onDragStart matters: by then the container would already
|
|
36
|
+
// have taken the pointer capture away from the point.
|
|
37
|
+
shouldStart: (event) =>
|
|
38
|
+
!disabled && !isPointElement(event.target as Element | null),
|
|
39
|
+
onDragStart: ([x, y], state) => {
|
|
40
|
+
beginSelectionBox({ x, y }, state.event)
|
|
41
|
+
},
|
|
42
|
+
onChange: ([x, y]) => moveSelectionBox({ x, y }),
|
|
43
|
+
onDragEnd: endSelectionBox,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// One listener for the whole editor rather than one per point: a wheel
|
|
47
|
+
// event only reaches what the cursor is over, and a point is a 16px
|
|
48
|
+
// target, so the focused point takes it from anywhere over the editor —
|
|
49
|
+
// the way it does for Slider and XYPad.
|
|
50
|
+
useWheel(
|
|
51
|
+
(event) => {
|
|
52
|
+
// Scrolling up moves the point towards y = 0; shift switches to x.
|
|
53
|
+
// Browsers turn shift+wheel into horizontal scrolling: `deltaY` comes
|
|
54
|
+
// out empty and `deltaX` carries the movement. Reading whichever axis
|
|
55
|
+
// moved keeps shift working as the x-axis modifier — and picks up a
|
|
56
|
+
// trackpad's own horizontal gesture, which never had a modifier.
|
|
57
|
+
const horizontal = event.deltaX !== 0
|
|
58
|
+
const delta = horizontal ? event.deltaX : event.deltaY
|
|
59
|
+
if (delta === 0) return
|
|
60
|
+
const axis = horizontal || event.shiftKey ? 'x' : 'y'
|
|
61
|
+
const direction = delta < 0 ? -1 : 1
|
|
62
|
+
if (nudgeFocusedPoint(axis, direction, event)) event.preventDefault()
|
|
63
|
+
},
|
|
64
|
+
{ target: containerRef },
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
// The container is what the pointer position is normalized against, so the
|
|
68
|
+
// context ref is composed with any ref the caller passed.
|
|
69
|
+
// Only attached when there is a selection to draw: `createDrag` puts
|
|
70
|
+
// `touch-action: none` on whatever it holds, and an editor that cannot
|
|
71
|
+
// select has no reason to stop the page scrolling under a finger.
|
|
72
|
+
const composedRef = useComposedRefs<HTMLDivElement>(
|
|
73
|
+
forwardedRef,
|
|
74
|
+
containerRef,
|
|
75
|
+
selectable && !disabled ? dragRefCallback : undefined,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div
|
|
80
|
+
ref={composedRef}
|
|
81
|
+
className={className}
|
|
82
|
+
style={{
|
|
83
|
+
// The points inside are placed against this box, over the
|
|
84
|
+
// background and under the selection box.
|
|
85
|
+
position: 'absolute',
|
|
86
|
+
inset: 0,
|
|
87
|
+
zIndex: 10,
|
|
88
|
+
...style,
|
|
89
|
+
}}
|
|
90
|
+
{...props}
|
|
91
|
+
>
|
|
92
|
+
<Placement name="PointsEditor.Container">{children}</Placement>
|
|
93
|
+
</div>
|
|
94
|
+
)
|
|
95
|
+
},
|
|
96
|
+
)
|
|
@@ -1,12 +1,30 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
ComponentPropsWithoutRef,
|
|
3
|
+
CSSProperties,
|
|
4
|
+
ReactNode,
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useId,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from 'react'
|
|
3
11
|
|
|
4
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
applyDelta,
|
|
14
|
+
clamp,
|
|
15
|
+
type InputEventOption,
|
|
16
|
+
type ModifierState,
|
|
17
|
+
type ModifierValue,
|
|
18
|
+
selectModifier,
|
|
19
|
+
} from '@tremolo-ui/functions'
|
|
5
20
|
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
21
|
+
import { useComposedRefs } from '../../compose-refs'
|
|
22
|
+
import { useDragValue } from '../../hooks/useDragValue'
|
|
23
|
+
import { cssLength } from '../_util/css-length'
|
|
24
|
+
import { useCheckPlacement } from '../_util/Placement'
|
|
25
|
+
import { VisuallyHiddenRangeInput } from '../_util/VisuallyHiddenRangeInput'
|
|
8
26
|
|
|
9
|
-
import { usePointsEditorContext } from './context'
|
|
27
|
+
import { type PointRegistration, usePointsEditorContext } from './context'
|
|
10
28
|
|
|
11
29
|
export type PointBaseType = { x: number; y: number }
|
|
12
30
|
|
|
@@ -21,35 +39,97 @@ export function clampPoint(
|
|
|
21
39
|
return { x: newX, y: newY }
|
|
22
40
|
}
|
|
23
41
|
|
|
24
|
-
export interface
|
|
42
|
+
export interface PointsEditorPointProps<T extends PointBaseType> {
|
|
43
|
+
/**
|
|
44
|
+
* Where the point is, as `{ x, y }` from 0 to 1 on each axis, with `y`
|
|
45
|
+
* growing downwards.
|
|
46
|
+
*/
|
|
25
47
|
value: T
|
|
48
|
+
/** Drawn inside the point: the theme's own dot stands when it is left out. */
|
|
49
|
+
children?: ReactNode
|
|
50
|
+
/**
|
|
51
|
+
* How the selection refers to this point. One is generated when it is left
|
|
52
|
+
* out, which lasts as long as the point is mounted — give your own if the
|
|
53
|
+
* selection has to survive a remount, or be recognised in your own state.
|
|
54
|
+
*/
|
|
55
|
+
id?: string
|
|
56
|
+
/** The lowest position the point can take, per axis. An axis left out is 0. */
|
|
26
57
|
min?: Partial<PointBaseType>
|
|
58
|
+
/** The highest position the point can take, per axis. An axis left out is 1. */
|
|
27
59
|
max?: Partial<PointBaseType>
|
|
28
60
|
|
|
61
|
+
/** Width and height at once. Sets both `--width` and `--height`. */
|
|
29
62
|
size?: number | string
|
|
63
|
+
/** Sets `--width`; the size the theme gives it stands when omitted. */
|
|
30
64
|
width?: number | string
|
|
65
|
+
/** Sets `--height`; the size the theme gives it stands when omitted. */
|
|
31
66
|
height?: number | string
|
|
67
|
+
/** Sets `--color`, for the theme to colour the point with. */
|
|
32
68
|
color?: string
|
|
33
69
|
|
|
70
|
+
/** Overrides the `disabled` of `PointsEditor.Root`. */
|
|
34
71
|
disabled?: boolean
|
|
72
|
+
/** Overrides the `readonly` of `PointsEditor.Root`. */
|
|
35
73
|
readonly?: boolean
|
|
36
74
|
|
|
75
|
+
/** Overrides the `wheel` of `PointsEditor.Root`. */
|
|
76
|
+
wheel?: ModifierValue<InputEventOption> | null
|
|
77
|
+
/** Overrides the `keyboard` of `PointsEditor.Root`. */
|
|
78
|
+
keyboard?: ModifierValue<InputEventOption> | null
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The accessible name of each axis. There are two range inputs inside the
|
|
82
|
+
* point, so this takes one name per axis; a single string names them both.
|
|
83
|
+
*/
|
|
84
|
+
'aria-label'?: string | Partial<Record<'x' | 'y', string>>
|
|
85
|
+
/** What the value of each axis means, when the number does not say it. */
|
|
86
|
+
'aria-valuetext'?: string | Partial<Record<'x' | 'y', string>>
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Called with the new position when the point is dragged or moved by the
|
|
90
|
+
* arrow keys or the wheel, including when it moves along with a selection.
|
|
91
|
+
*/
|
|
37
92
|
onChange?: (value: PointBaseType) => void
|
|
93
|
+
/** Called when a drag on this point starts, with where the point is. */
|
|
38
94
|
onDragStart?: (value: PointBaseType) => void
|
|
95
|
+
/** Called when that drag ends, with where the point is. */
|
|
39
96
|
onDragEnd?: (value: PointBaseType) => void
|
|
40
97
|
}
|
|
41
98
|
|
|
99
|
+
/**
|
|
100
|
+
* A point is placed by its position within the container, so its value is a
|
|
101
|
+
* position: 0..1 on each axis, with y growing downwards.
|
|
102
|
+
*/
|
|
103
|
+
export const AXIS = { min: 0, max: 1 }
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* One setting for both axes, or one per axis. The point holds a range input
|
|
107
|
+
* for each, so anything that names or describes it comes in a pair.
|
|
108
|
+
*/
|
|
109
|
+
function perAxis(
|
|
110
|
+
value: string | Partial<Record<'x' | 'y', string>> | undefined,
|
|
111
|
+
axis: 'x' | 'y',
|
|
112
|
+
) {
|
|
113
|
+
return typeof value === 'string' ? value : value?.[axis]
|
|
114
|
+
}
|
|
115
|
+
|
|
42
116
|
export function Point<T extends PointBaseType>({
|
|
43
117
|
value,
|
|
118
|
+
children,
|
|
119
|
+
id: idProp,
|
|
44
120
|
min,
|
|
45
121
|
max,
|
|
46
122
|
size,
|
|
47
|
-
width
|
|
48
|
-
height
|
|
123
|
+
width,
|
|
124
|
+
height,
|
|
49
125
|
color,
|
|
50
126
|
|
|
51
|
-
disabled,
|
|
52
|
-
readonly,
|
|
127
|
+
disabled: _disabled,
|
|
128
|
+
readonly: _readonly,
|
|
129
|
+
wheel: _wheel,
|
|
130
|
+
keyboard: _keyboard,
|
|
131
|
+
'aria-label': ariaLabel,
|
|
132
|
+
'aria-valuetext': ariaValuetext,
|
|
53
133
|
|
|
54
134
|
onChange,
|
|
55
135
|
onDragStart,
|
|
@@ -58,65 +138,225 @@ export function Point<T extends PointBaseType>({
|
|
|
58
138
|
className,
|
|
59
139
|
style,
|
|
60
140
|
onPointerDown,
|
|
141
|
+
onKeyDown,
|
|
142
|
+
onFocus,
|
|
61
143
|
...props
|
|
62
|
-
}:
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
144
|
+
}: PointsEditorPointProps<T> &
|
|
145
|
+
Omit<ComponentPropsWithoutRef<'div'>, keyof PointsEditorPointProps<T>>) {
|
|
146
|
+
const {
|
|
147
|
+
containerRef,
|
|
148
|
+
externalStyles,
|
|
149
|
+
disabled: rootDisabled,
|
|
150
|
+
readonly: rootReadonly,
|
|
151
|
+
wheel: rootWheel,
|
|
152
|
+
keyboard: rootKeyboard,
|
|
153
|
+
dragSensitivity,
|
|
154
|
+
selection,
|
|
155
|
+
registerPoint,
|
|
156
|
+
beginPointDrag,
|
|
157
|
+
movePointDrag,
|
|
158
|
+
nudgeSelection,
|
|
159
|
+
} = usePointsEditorContext()
|
|
69
160
|
|
|
70
|
-
|
|
161
|
+
const generatedId = useId()
|
|
162
|
+
const id = idProp ?? generatedId
|
|
163
|
+
const selected = selection.includes(id)
|
|
71
164
|
|
|
72
|
-
|
|
165
|
+
const disabled = _disabled ?? rootDisabled
|
|
166
|
+
const readonly = _readonly ?? rootReadonly
|
|
167
|
+
const inactive = disabled || readonly
|
|
168
|
+
// `null` means "no event" and has to survive the fallback, so `??` is not
|
|
169
|
+
// enough: only an omitted prop inherits from the root.
|
|
170
|
+
const wheel = _wheel === undefined ? rootWheel : _wheel
|
|
171
|
+
const keyboard = _keyboard === undefined ? rootKeyboard : _keyboard
|
|
73
172
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
173
|
+
useCheckPlacement('PointsEditor.Point', 'PointsEditor.Container')
|
|
174
|
+
|
|
175
|
+
// Compared against the focus below, so the point needs its own element.
|
|
176
|
+
const [element, setElement] = useState<HTMLDivElement | null>(null)
|
|
177
|
+
const xInputRef = useRef<HTMLInputElement>(null)
|
|
178
|
+
const yInputRef = useRef<HTMLInputElement>(null)
|
|
179
|
+
|
|
180
|
+
// What the editor needs to move this point along with the rest of a
|
|
181
|
+
// selection. Rewritten after every render rather than kept in the registry
|
|
182
|
+
// itself: the value changes on every frame of a drag.
|
|
183
|
+
const registration = useRef<PointRegistration>({
|
|
184
|
+
value,
|
|
185
|
+
min,
|
|
186
|
+
max,
|
|
187
|
+
readonly: inactive,
|
|
188
|
+
onChange,
|
|
189
|
+
element,
|
|
190
|
+
wheel,
|
|
191
|
+
})
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
registration.current = {
|
|
194
|
+
value,
|
|
195
|
+
min,
|
|
196
|
+
max,
|
|
197
|
+
readonly: inactive,
|
|
198
|
+
onChange,
|
|
199
|
+
element,
|
|
200
|
+
wheel,
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
useEffect(() => registerPoint(id, registration), [id, registerPoint])
|
|
80
205
|
|
|
81
|
-
|
|
206
|
+
/** Where the pointer was when the drag started, to measure the move from. */
|
|
207
|
+
const pointerOrigin = useRef<PointBaseType | null>(null)
|
|
208
|
+
|
|
209
|
+
// The value is the position itself: no scaling, and no rounding to a step.
|
|
210
|
+
const { refCallback: dragRefCallback, dragging } =
|
|
211
|
+
useDragValue<HTMLDivElement>({
|
|
212
|
+
axis: AXIS,
|
|
213
|
+
baseElementRef: containerRef,
|
|
214
|
+
sensitivity: (state) =>
|
|
215
|
+
selectModifier(dragSensitivity, state.event).value,
|
|
216
|
+
cursor: inactive ? undefined : externalStyles.cursor,
|
|
217
|
+
shouldStart: () => !disabled,
|
|
218
|
+
// The value is a move rather than a position: the point keeps the offset
|
|
219
|
+
// it was grabbed at, and everything else selected moves with it by the
|
|
220
|
+
// same amount.
|
|
221
|
+
onChange: ([x, y]) => {
|
|
222
|
+
const origin = pointerOrigin.current
|
|
223
|
+
if (!origin) return
|
|
224
|
+
movePointDrag({ x: x - origin.x, y: y - origin.y })
|
|
82
225
|
},
|
|
83
|
-
onDragStart: (x, y) => {
|
|
84
|
-
|
|
85
|
-
|
|
226
|
+
onDragStart: ([x, y], state) => {
|
|
227
|
+
// The press decides the selection before the snapshot is taken, so it
|
|
228
|
+
// happens here rather than in an onPointerDown: a native listener on
|
|
229
|
+
// the element runs before React's, and the two would disagree.
|
|
230
|
+
beginPointDrag(id, state.event)
|
|
231
|
+
pointerOrigin.current = { x, y }
|
|
232
|
+
xInputRef.current?.focus()
|
|
86
233
|
|
|
87
|
-
|
|
234
|
+
if (inactive) return
|
|
235
|
+
onDragStart?.(clampPoint(value, min, max))
|
|
88
236
|
},
|
|
89
|
-
onDragEnd: (
|
|
90
|
-
|
|
91
|
-
if (externalStyles.userSelectNone) removeUserSelectNone()
|
|
237
|
+
onDragEnd: () => {
|
|
238
|
+
pointerOrigin.current = null
|
|
92
239
|
|
|
93
|
-
|
|
240
|
+
if (inactive) return
|
|
241
|
+
onDragEnd?.(clampPoint(value, min, max))
|
|
94
242
|
},
|
|
95
243
|
})
|
|
96
244
|
|
|
97
|
-
const
|
|
98
|
-
|
|
245
|
+
const nudge = useCallback(
|
|
246
|
+
(
|
|
247
|
+
axis: 'x' | 'y',
|
|
248
|
+
direction: number,
|
|
249
|
+
option: ModifierValue<InputEventOption>,
|
|
250
|
+
modifiers: ModifierState,
|
|
251
|
+
) => {
|
|
252
|
+
const next = applyDelta(value[axis], direction, option, AXIS, modifiers)
|
|
253
|
+
// As a move, so that the rest of the selection comes along and the whole
|
|
254
|
+
// group stops together at the edge.
|
|
255
|
+
nudgeSelection(id, {
|
|
256
|
+
x: axis === 'x' ? next - value.x : 0,
|
|
257
|
+
y: axis === 'y' ? next - value.y : 0,
|
|
258
|
+
})
|
|
259
|
+
},
|
|
260
|
+
[value, id, nudgeSelection],
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
const refCallback = useComposedRefs<HTMLDivElement>(
|
|
264
|
+
dragRefCallback,
|
|
265
|
+
setElement,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
269
|
+
const key = event.key
|
|
270
|
+
if (!['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(key))
|
|
271
|
+
return
|
|
272
|
+
// The key picks the axis, whichever of the two inputs holds the focus: a
|
|
273
|
+
// point is one control to the person moving it, and the focus lands on the
|
|
274
|
+
// x input, so reading the axis off the input would leave the y axis with
|
|
275
|
+
// no keys at all.
|
|
276
|
+
const axis = key === 'ArrowRight' || key === 'ArrowLeft' ? 'x' : 'y'
|
|
277
|
+
event.preventDefault()
|
|
278
|
+
if (!onChange || inactive || !keyboard) return
|
|
279
|
+
// y grows downwards, so ArrowUp moves the point towards 0.
|
|
280
|
+
const direction = key === 'ArrowLeft' || key === 'ArrowUp' ? -1 : 1
|
|
281
|
+
nudge(axis, direction, keyboard, event)
|
|
99
282
|
}
|
|
100
283
|
|
|
284
|
+
const current = clampPoint(value, min, max)
|
|
285
|
+
|
|
101
286
|
return (
|
|
287
|
+
// The visual point has two values, so its semantics live on the two range
|
|
288
|
+
// inputs nested inside it rather than on this drag handle.
|
|
289
|
+
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions
|
|
102
290
|
<div
|
|
103
|
-
ref={
|
|
104
|
-
className={
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
291
|
+
ref={refCallback}
|
|
292
|
+
className={className}
|
|
293
|
+
data-disabled={disabled ? '' : undefined}
|
|
294
|
+
data-readonly={readonly ? '' : undefined}
|
|
295
|
+
// A press lands on the point, which cannot hold focus, and the browser
|
|
296
|
+
// answers that by clearing the focus to the body — undoing the focus the
|
|
297
|
+
// drag just gave the input. Taking the focus here keeps it inside, and
|
|
298
|
+
// it is passed on to the input below.
|
|
299
|
+
tabIndex={-1}
|
|
300
|
+
data-dragging={dragging ? '' : undefined}
|
|
301
|
+
data-selected={selected ? '' : undefined}
|
|
302
|
+
style={
|
|
303
|
+
{
|
|
304
|
+
'--color': color,
|
|
305
|
+
'--width': cssLength(size ?? width),
|
|
306
|
+
'--height': cssLength(size ?? height),
|
|
307
|
+
// The mechanics of the position below: a point is placed by its
|
|
308
|
+
// position in the container, measured from its own centre.
|
|
309
|
+
position: 'absolute',
|
|
310
|
+
translate: 'var(--translate, -50% -50%)',
|
|
311
|
+
...style,
|
|
312
|
+
// Where the point is: the value, not a style.
|
|
313
|
+
left: `${value.x * 100}%`,
|
|
314
|
+
top: `${value.y * 100}%`,
|
|
315
|
+
} as CSSProperties
|
|
316
|
+
}
|
|
118
317
|
onPointerDown={onPointerDown}
|
|
318
|
+
onFocus={(event) => {
|
|
319
|
+
// The point itself is not the control: its semantics live on the
|
|
320
|
+
// inputs, so whatever reaches it is handed to the first of them.
|
|
321
|
+
if (!disabled && event.target === event.currentTarget) {
|
|
322
|
+
xInputRef.current?.focus()
|
|
323
|
+
}
|
|
324
|
+
onFocus?.(event)
|
|
325
|
+
}}
|
|
326
|
+
onKeyDown={(event) => {
|
|
327
|
+
handleKeyDown(event)
|
|
328
|
+
onKeyDown?.(event)
|
|
329
|
+
}}
|
|
119
330
|
{...props}
|
|
120
|
-
|
|
331
|
+
>
|
|
332
|
+
{(['x', 'y'] as const).map((axis) => (
|
|
333
|
+
<VisuallyHiddenRangeInput
|
|
334
|
+
key={axis}
|
|
335
|
+
ref={axis === 'x' ? xInputRef : yInputRef}
|
|
336
|
+
data-axis={axis}
|
|
337
|
+
value={current[axis]}
|
|
338
|
+
min={min?.[axis] ?? 0}
|
|
339
|
+
max={max?.[axis] ?? 1}
|
|
340
|
+
step="any"
|
|
341
|
+
disabled={disabled}
|
|
342
|
+
aria-readonly={readonly}
|
|
343
|
+
aria-orientation={axis === 'x' ? 'horizontal' : 'vertical'}
|
|
344
|
+
aria-label={perAxis(ariaLabel, axis) ?? axis}
|
|
345
|
+
aria-valuetext={perAxis(ariaValuetext, axis)}
|
|
346
|
+
onChange={(event) => {
|
|
347
|
+
if (readonly) {
|
|
348
|
+
event.currentTarget.value = String(current[axis])
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
const delta = event.currentTarget.valueAsNumber - value[axis]
|
|
352
|
+
nudgeSelection(id, {
|
|
353
|
+
x: axis === 'x' ? delta : 0,
|
|
354
|
+
y: axis === 'y' ? delta : 0,
|
|
355
|
+
})
|
|
356
|
+
}}
|
|
357
|
+
/>
|
|
358
|
+
))}
|
|
359
|
+
{children}
|
|
360
|
+
</div>
|
|
121
361
|
)
|
|
122
362
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { ComponentPropsWithoutRef, CSSProperties, ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import { useCheckPlacement } from '../_util/Placement'
|
|
4
|
+
|
|
5
|
+
import { usePointsEditorContext } from './context'
|
|
6
|
+
|
|
7
|
+
export interface PointsEditorSelectionBoxProps {
|
|
8
|
+
/** Drawn inside the box, which is only there while a drag is running. */
|
|
9
|
+
children?: ReactNode
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The box a drag on empty space draws, and what it covers is selected.
|
|
14
|
+
*
|
|
15
|
+
* Nothing is rendered while no drag is running, and leaving it out leaves the
|
|
16
|
+
* editor without one — the selection still works, it just cannot be seen.
|
|
17
|
+
*/
|
|
18
|
+
export function SelectionBox({
|
|
19
|
+
children,
|
|
20
|
+
className,
|
|
21
|
+
style,
|
|
22
|
+
...props
|
|
23
|
+
}: PointsEditorSelectionBoxProps &
|
|
24
|
+
Omit<ComponentPropsWithoutRef<'div'>, keyof PointsEditorSelectionBoxProps>) {
|
|
25
|
+
useCheckPlacement('PointsEditor.SelectionBox', 'PointsEditor.Container')
|
|
26
|
+
|
|
27
|
+
const selectionBox = usePointsEditorContext((s) => s.selectionBox)
|
|
28
|
+
if (!selectionBox) return null
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
className={className}
|
|
33
|
+
{...props}
|
|
34
|
+
style={
|
|
35
|
+
{
|
|
36
|
+
// Drawn over the points while a drag runs, and taking none of the
|
|
37
|
+
// pointer: the drag underneath is what it is reporting on.
|
|
38
|
+
position: 'absolute',
|
|
39
|
+
zIndex: 20,
|
|
40
|
+
pointerEvents: 'none',
|
|
41
|
+
...style,
|
|
42
|
+
// Where the box is and how big it is: the drag, not a style, so it
|
|
43
|
+
// is written after whatever the caller passed.
|
|
44
|
+
left: `${selectionBox.x * 100}%`,
|
|
45
|
+
top: `${selectionBox.y * 100}%`,
|
|
46
|
+
width: `${selectionBox.width * 100}%`,
|
|
47
|
+
height: `${selectionBox.height * 100}%`,
|
|
48
|
+
} as CSSProperties
|
|
49
|
+
}
|
|
50
|
+
>
|
|
51
|
+
{children}
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|