@tremolo-ui/react 0.5.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 +1468 -534
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1103 -435
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +1103 -435
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1457 -534
- package/dist/index.js.map +1 -1
- package/package.json +28 -26
- package/src/components/AnimationCanvas/index.tsx +115 -76
- package/src/components/Knob/ActiveLine.tsx +7 -6
- package/src/components/Knob/InactiveLine.tsx +9 -10
- package/src/components/Knob/SVGRoot.tsx +5 -3
- package/src/components/Knob/Thumb.tsx +33 -11
- package/src/components/Knob/context.tsx +17 -1
- package/src/components/Knob/index.tsx +148 -55
- package/src/components/NumberInput/DecrementStepper.tsx +8 -4
- package/src/components/NumberInput/IncrementStepper.tsx +9 -4
- package/src/components/NumberInput/InputField.tsx +188 -105
- package/src/components/NumberInput/Stepper.tsx +123 -72
- package/src/components/NumberInput/{stepperButton.tsx → StepperButton.tsx} +27 -22
- package/src/components/NumberInput/context.tsx +35 -8
- package/src/components/NumberInput/index.tsx +272 -75
- package/src/components/Piano/index.tsx +354 -188
- package/src/components/PointsEditor/Background.tsx +11 -2
- package/src/components/PointsEditor/Container.tsx +87 -27
- package/src/components/PointsEditor/Point.tsx +239 -79
- package/src/components/PointsEditor/SelectionBox.tsx +54 -0
- package/src/components/PointsEditor/context.tsx +78 -10
- package/src/components/PointsEditor/index.tsx +440 -39
- package/src/components/Slider/Marks.tsx +48 -16
- package/src/components/Slider/MarksOption.tsx +57 -43
- package/src/components/Slider/Thumb.tsx +108 -50
- package/src/components/Slider/Track.tsx +69 -58
- package/src/components/Slider/context.tsx +4 -2
- package/src/components/Slider/index.tsx +131 -65
- package/src/components/Slider/type.ts +26 -8
- package/src/components/XYPad/Area.tsx +45 -35
- package/src/components/XYPad/Thumb.tsx +135 -62
- package/src/components/XYPad/context.tsx +4 -1
- package/src/components/XYPad/index.tsx +180 -48
- 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/_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 +38 -8
- package/src/hooks/useDragValue.ts +54 -9
- package/src/hooks/useEventListener.ts +10 -6
- 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/index.ts +38 -21
- package/src/input-event.ts +32 -0
- package/dist/index.css +0 -501
- package/src/Globals.d.ts +0 -3
- package/src/components/Knob/index.css +0 -81
- package/src/components/NumberInput/index.css +0 -121
- package/src/components/Piano/index.css +0 -90
- package/src/components/PointsEditor/index.css +0 -35
- package/src/components/Slider/index.css +0 -125
- package/src/components/XYPad/index.css +0 -60
- package/src/components/_util/index.ts +0 -28
- package/src/styles/global.css +0 -19
- /package/src/components/Piano/{keyboardShortcuts.ts → keyboard-shortcuts.ts} +0 -0
|
@@ -1,36 +1,96 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { ComponentPropsWithoutRef, ReactNode, Ref } from 'react'
|
|
1
|
+
import { ComponentPropsWithoutRef, forwardRef, ReactNode } from 'react'
|
|
3
2
|
|
|
4
|
-
import { useComposedRefs } from '
|
|
3
|
+
import { useComposedRefs } from '../../compose-refs'
|
|
4
|
+
import { useDragValue } from '../../hooks/useDragValue'
|
|
5
|
+
import { useWheel } from '../../hooks/useWheel'
|
|
6
|
+
import { Placement } from '../_util/Placement'
|
|
5
7
|
|
|
6
8
|
import { usePointsEditorContext } from './context'
|
|
9
|
+
import { AXIS } from './Point'
|
|
7
10
|
|
|
8
11
|
export interface PointsEditorContainerProps {
|
|
9
12
|
/** `<PointsEditor.Point />` goes here. */
|
|
10
13
|
children?: ReactNode
|
|
11
|
-
ref?: Ref<HTMLDivElement>
|
|
12
14
|
}
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
...props
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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,14 +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
|
|
|
21
|
+
import { useComposedRefs } from '../../compose-refs'
|
|
6
22
|
import { useDragValue } from '../../hooks/useDragValue'
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
23
|
+
import { cssLength } from '../_util/css-length'
|
|
24
|
+
import { useCheckPlacement } from '../_util/Placement'
|
|
25
|
+
import { VisuallyHiddenRangeInput } from '../_util/VisuallyHiddenRangeInput'
|
|
10
26
|
|
|
11
|
-
import { usePointsEditorContext } from './context'
|
|
27
|
+
import { type PointRegistration, usePointsEditorContext } from './context'
|
|
12
28
|
|
|
13
29
|
export type PointBaseType = { x: number; y: number }
|
|
14
30
|
|
|
@@ -23,14 +39,32 @@ export function clampPoint(
|
|
|
23
39
|
return { x: newX, y: newY }
|
|
24
40
|
}
|
|
25
41
|
|
|
26
|
-
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
|
+
*/
|
|
27
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. */
|
|
28
57
|
min?: Partial<PointBaseType>
|
|
58
|
+
/** The highest position the point can take, per axis. An axis left out is 1. */
|
|
29
59
|
max?: Partial<PointBaseType>
|
|
30
60
|
|
|
61
|
+
/** Width and height at once. Sets both `--width` and `--height`. */
|
|
31
62
|
size?: number | string
|
|
63
|
+
/** Sets `--width`; the size the theme gives it stands when omitted. */
|
|
32
64
|
width?: number | string
|
|
65
|
+
/** Sets `--height`; the size the theme gives it stands when omitted. */
|
|
33
66
|
height?: number | string
|
|
67
|
+
/** Sets `--color`, for the theme to colour the point with. */
|
|
34
68
|
color?: string
|
|
35
69
|
|
|
36
70
|
/** Overrides the `disabled` of `PointsEditor.Root`. */
|
|
@@ -39,12 +73,26 @@ export interface PointProps<T extends PointBaseType> {
|
|
|
39
73
|
readonly?: boolean
|
|
40
74
|
|
|
41
75
|
/** Overrides the `wheel` of `PointsEditor.Root`. */
|
|
42
|
-
wheel?: InputEventOption | null
|
|
76
|
+
wheel?: ModifierValue<InputEventOption> | null
|
|
43
77
|
/** Overrides the `keyboard` of `PointsEditor.Root`. */
|
|
44
|
-
keyboard?: InputEventOption | null
|
|
78
|
+
keyboard?: ModifierValue<InputEventOption> | null
|
|
45
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
|
+
*/
|
|
46
92
|
onChange?: (value: PointBaseType) => void
|
|
93
|
+
/** Called when a drag on this point starts, with where the point is. */
|
|
47
94
|
onDragStart?: (value: PointBaseType) => void
|
|
95
|
+
/** Called when that drag ends, with where the point is. */
|
|
48
96
|
onDragEnd?: (value: PointBaseType) => void
|
|
49
97
|
}
|
|
50
98
|
|
|
@@ -52,21 +100,36 @@ export interface PointProps<T extends PointBaseType> {
|
|
|
52
100
|
* A point is placed by its position within the container, so its value is a
|
|
53
101
|
* position: 0..1 on each axis, with y growing downwards.
|
|
54
102
|
*/
|
|
55
|
-
const AXIS = { min: 0, max: 1 }
|
|
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
|
+
}
|
|
56
115
|
|
|
57
116
|
export function Point<T extends PointBaseType>({
|
|
58
117
|
value,
|
|
118
|
+
children,
|
|
119
|
+
id: idProp,
|
|
59
120
|
min,
|
|
60
121
|
max,
|
|
61
122
|
size,
|
|
62
|
-
width
|
|
63
|
-
height
|
|
123
|
+
width,
|
|
124
|
+
height,
|
|
64
125
|
color,
|
|
65
126
|
|
|
66
127
|
disabled: _disabled,
|
|
67
128
|
readonly: _readonly,
|
|
68
129
|
wheel: _wheel,
|
|
69
130
|
keyboard: _keyboard,
|
|
131
|
+
'aria-label': ariaLabel,
|
|
132
|
+
'aria-valuetext': ariaValuetext,
|
|
70
133
|
|
|
71
134
|
onChange,
|
|
72
135
|
onDragStart,
|
|
@@ -76,8 +139,10 @@ export function Point<T extends PointBaseType>({
|
|
|
76
139
|
style,
|
|
77
140
|
onPointerDown,
|
|
78
141
|
onKeyDown,
|
|
142
|
+
onFocus,
|
|
79
143
|
...props
|
|
80
|
-
}:
|
|
144
|
+
}: PointsEditorPointProps<T> &
|
|
145
|
+
Omit<ComponentPropsWithoutRef<'div'>, keyof PointsEditorPointProps<T>>) {
|
|
81
146
|
const {
|
|
82
147
|
containerRef,
|
|
83
148
|
externalStyles,
|
|
@@ -85,69 +150,114 @@ export function Point<T extends PointBaseType>({
|
|
|
85
150
|
readonly: rootReadonly,
|
|
86
151
|
wheel: rootWheel,
|
|
87
152
|
keyboard: rootKeyboard,
|
|
153
|
+
dragSensitivity,
|
|
154
|
+
selection,
|
|
155
|
+
registerPoint,
|
|
156
|
+
beginPointDrag,
|
|
157
|
+
movePointDrag,
|
|
158
|
+
nudgeSelection,
|
|
88
159
|
} = usePointsEditorContext()
|
|
89
160
|
|
|
161
|
+
const generatedId = useId()
|
|
162
|
+
const id = idProp ?? generatedId
|
|
163
|
+
const selected = selection.includes(id)
|
|
164
|
+
|
|
90
165
|
const disabled = _disabled ?? rootDisabled
|
|
91
166
|
const readonly = _readonly ?? rootReadonly
|
|
167
|
+
const inactive = disabled || readonly
|
|
92
168
|
// `null` means "no event" and has to survive the fallback, so `??` is not
|
|
93
169
|
// enough: only an omitted prop inherits from the root.
|
|
94
170
|
const wheel = _wheel === undefined ? rootWheel : _wheel
|
|
95
171
|
const keyboard = _keyboard === undefined ? rootKeyboard : _keyboard
|
|
96
172
|
|
|
173
|
+
useCheckPlacement('PointsEditor.Point', 'PointsEditor.Container')
|
|
174
|
+
|
|
97
175
|
// Compared against the focus below, so the point needs its own element.
|
|
98
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])
|
|
205
|
+
|
|
206
|
+
/** Where the pointer was when the drag started, to measure the move from. */
|
|
207
|
+
const pointerOrigin = useRef<PointBaseType | null>(null)
|
|
99
208
|
|
|
100
209
|
// The value is the position itself: no scaling, and no rounding to a step.
|
|
101
210
|
const { refCallback: dragRefCallback, dragging } =
|
|
102
211
|
useDragValue<HTMLDivElement>({
|
|
103
212
|
axis: AXIS,
|
|
104
213
|
baseElementRef: containerRef,
|
|
105
|
-
|
|
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.
|
|
106
221
|
onChange: ([x, y]) => {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
222
|
+
const origin = pointerOrigin.current
|
|
223
|
+
if (!origin) return
|
|
224
|
+
movePointDrag({ x: x - origin.x, y: y - origin.y })
|
|
110
225
|
},
|
|
111
|
-
onDragStart: ([x, y]) => {
|
|
112
|
-
|
|
113
|
-
|
|
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()
|
|
114
233
|
|
|
115
|
-
|
|
234
|
+
if (inactive) return
|
|
235
|
+
onDragStart?.(clampPoint(value, min, max))
|
|
116
236
|
},
|
|
117
|
-
onDragEnd: (
|
|
118
|
-
|
|
119
|
-
if (externalStyles.userSelectNone) removeUserSelectNone()
|
|
237
|
+
onDragEnd: () => {
|
|
238
|
+
pointerOrigin.current = null
|
|
120
239
|
|
|
121
|
-
|
|
240
|
+
if (inactive) return
|
|
241
|
+
onDragEnd?.(clampPoint(value, min, max))
|
|
122
242
|
},
|
|
123
243
|
})
|
|
124
244
|
|
|
125
245
|
const nudge = useCallback(
|
|
126
|
-
(
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
// point would match the container's focus and they would all move at once.
|
|
140
|
-
useWheel(
|
|
141
|
-
(event) => {
|
|
142
|
-
if (!onChange || readonly || !wheel) return
|
|
143
|
-
if (!element || element.ownerDocument.activeElement !== element) return
|
|
144
|
-
event.preventDefault()
|
|
145
|
-
// Scrolling up moves the point towards y = 0; shift switches to x.
|
|
146
|
-
const axis = event.shiftKey ? 'x' : 'y'
|
|
147
|
-
const direction = event.deltaY < 0 ? -1 : 1
|
|
148
|
-
nudge(axis, direction, wheel)
|
|
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
|
+
})
|
|
149
259
|
},
|
|
150
|
-
|
|
260
|
+
[value, id, nudgeSelection],
|
|
151
261
|
)
|
|
152
262
|
|
|
153
263
|
const refCallback = useComposedRefs<HTMLDivElement>(
|
|
@@ -156,47 +266,97 @@ export function Point<T extends PointBaseType>({
|
|
|
156
266
|
)
|
|
157
267
|
|
|
158
268
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
159
|
-
if (!onChange || readonly || !keyboard) return
|
|
160
269
|
const key = event.key
|
|
161
|
-
if (['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(key))
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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)
|
|
168
282
|
}
|
|
169
283
|
|
|
170
|
-
const
|
|
171
|
-
'--color': color,
|
|
172
|
-
}
|
|
284
|
+
const current = clampPoint(value, min, max)
|
|
173
285
|
|
|
174
286
|
return (
|
|
175
|
-
// The point
|
|
176
|
-
//
|
|
177
|
-
//
|
|
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
|
|
178
290
|
<div
|
|
179
291
|
ref={refCallback}
|
|
180
|
-
className={
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
+
}
|
|
194
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
|
+
}}
|
|
195
326
|
onKeyDown={(event) => {
|
|
196
327
|
handleKeyDown(event)
|
|
197
328
|
onKeyDown?.(event)
|
|
198
329
|
}}
|
|
199
330
|
{...props}
|
|
200
|
-
|
|
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>
|
|
201
361
|
)
|
|
202
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
|
+
}
|