@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
|
@@ -1,88 +1,238 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
ComponentPropsWithoutRef,
|
|
3
|
+
CSSProperties,
|
|
4
|
+
forwardRef,
|
|
5
|
+
ReactNode,
|
|
6
|
+
RefObject,
|
|
7
|
+
useCallback,
|
|
8
|
+
useEffect,
|
|
9
|
+
useMemo,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
} from 'react'
|
|
3
13
|
|
|
4
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
createSelectionBox,
|
|
16
|
+
type SelectionBoxInstance,
|
|
17
|
+
type SelectionBoxRect,
|
|
18
|
+
type XY,
|
|
19
|
+
} from '@tremolo-ui/dom'
|
|
20
|
+
import {
|
|
21
|
+
applyDelta,
|
|
22
|
+
clamp,
|
|
23
|
+
type InputEventOption,
|
|
24
|
+
type ModifierState,
|
|
25
|
+
type ModifierValue,
|
|
26
|
+
toPrecision,
|
|
27
|
+
} from '@tremolo-ui/functions'
|
|
5
28
|
|
|
6
|
-
import {
|
|
29
|
+
import { DEFAULT_DRAG_SENSITIVITY } from '../../input-event'
|
|
30
|
+
import { cssLength } from '../_util/css-length'
|
|
7
31
|
|
|
8
32
|
import { Background } from './Background'
|
|
9
33
|
import { Container } from './Container'
|
|
10
|
-
import { PointsEditorProvider } from './context'
|
|
11
|
-
import { Point, PointBaseType } from './Point'
|
|
34
|
+
import { type PointRegistration, PointsEditorProvider } from './context'
|
|
35
|
+
import { AXIS, Point, type PointBaseType } from './Point'
|
|
36
|
+
import { SelectionBox } from './SelectionBox'
|
|
37
|
+
|
|
38
|
+
/** One array for every editor with selection turned off, so memos hold still. */
|
|
39
|
+
const EMPTY: readonly string[] = []
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* How far a point may move before something in the selection leaves its range.
|
|
43
|
+
*
|
|
44
|
+
* Clamping each point on its own would break the shape of the selection: the
|
|
45
|
+
* one that reached the edge would stop while the rest carried on. One amount
|
|
46
|
+
* for all of them means the whole selection stops together.
|
|
47
|
+
*/
|
|
48
|
+
function allowedDelta(
|
|
49
|
+
delta: PointBaseType,
|
|
50
|
+
entries: { start: PointBaseType; registration: PointRegistration }[],
|
|
51
|
+
): PointBaseType {
|
|
52
|
+
let loX = -Infinity
|
|
53
|
+
let hiX = Infinity
|
|
54
|
+
let loY = -Infinity
|
|
55
|
+
let hiY = Infinity
|
|
56
|
+
|
|
57
|
+
for (const { start, registration } of entries) {
|
|
58
|
+
loX = Math.max(loX, (registration.min?.x ?? 0) - start.x)
|
|
59
|
+
hiX = Math.min(hiX, (registration.max?.x ?? 1) - start.x)
|
|
60
|
+
loY = Math.max(loY, (registration.min?.y ?? 0) - start.y)
|
|
61
|
+
hiY = Math.min(hiY, (registration.max?.y ?? 1) - start.y)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A point that started outside its own range leaves nothing to move within.
|
|
65
|
+
return {
|
|
66
|
+
x: hiX < loX ? 0 : clamp(delta.x, loX, hiX),
|
|
67
|
+
y: hiY < loY ? 0 : clamp(delta.y, loY, hiY),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
12
70
|
|
|
13
71
|
/*
|
|
14
72
|
TODO:
|
|
15
73
|
|
|
16
|
-
-
|
|
17
|
-
- modifier
|
|
74
|
+
- grid
|
|
18
75
|
*/
|
|
19
76
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return (
|
|
27
|
-
<PointsEditor.Root
|
|
28
|
-
onDoubleClick={({ x, y }, _e) => {
|
|
29
|
-
const newPoints = [...points, { x, y }]
|
|
30
|
-
setPoints(newPoints)
|
|
31
|
-
}}
|
|
32
|
-
>
|
|
33
|
-
{points.map((point) => (
|
|
34
|
-
<PointsEditor.Point key={`${point.x}, ${point.y}`} value={point} />
|
|
35
|
-
))}
|
|
36
|
-
</PointsEditor.Root>
|
|
37
|
-
)
|
|
38
|
-
}
|
|
39
|
-
*/
|
|
77
|
+
/**
|
|
78
|
+
* A point moves over 0..1 in both axes, so a nudge of 0.01 crosses the editor
|
|
79
|
+
* in a hundred steps whatever its pixel size.
|
|
80
|
+
*/
|
|
81
|
+
const DEFAULT_WHEEL: ModifierValue<InputEventOption> = ['normalized', 0.01]
|
|
40
82
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Shift is the fine-adjustment key everywhere else, so it is bound here too
|
|
85
|
+
* — but only on the keyboard. On the wheel it already means the x axis, and
|
|
86
|
+
* browsers hand shift+wheel over as horizontal scrolling anyway.
|
|
87
|
+
*/
|
|
88
|
+
const DEFAULT_KEYBOARD: ModifierValue<InputEventOption> = {
|
|
89
|
+
default: ['normalized', 0.01],
|
|
90
|
+
shift: ['normalized', 0.001],
|
|
44
91
|
}
|
|
45
92
|
|
|
46
93
|
export interface PointsEditorProps {
|
|
94
|
+
/** Sets `--width`; the size the theme gives it stands when omitted. */
|
|
47
95
|
width?: number | string
|
|
96
|
+
/** Sets `--height`; the size the theme gives it stands when omitted. */
|
|
48
97
|
height?: number | string
|
|
49
98
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Make the points unchangeable and remove them from the tab order.
|
|
101
|
+
* The parts carry `data-disabled` while it is set.
|
|
102
|
+
*/
|
|
54
103
|
disabled?: boolean
|
|
104
|
+
/**
|
|
105
|
+
* Make the points unmovable.
|
|
106
|
+
* The parts carry `data-readonly` while it is set.
|
|
107
|
+
*/
|
|
55
108
|
readonly?: boolean
|
|
56
109
|
|
|
110
|
+
/**
|
|
111
|
+
* The cursor to show while dragging a point. It is set on the dragged
|
|
112
|
+
* point, so it stays while the pointer is outside it.
|
|
113
|
+
*
|
|
114
|
+
* @default { cursor: 'grabbing' }
|
|
115
|
+
*/
|
|
57
116
|
externalStyles?: {
|
|
58
|
-
|
|
59
|
-
cursor?: Cursor
|
|
117
|
+
cursor?: CSSProperties['cursor']
|
|
60
118
|
}
|
|
61
119
|
|
|
62
|
-
// TODO
|
|
63
120
|
/**
|
|
64
|
-
* wheel
|
|
65
|
-
*
|
|
121
|
+
* How much one notch of the wheel moves the focused `Point`. Scrolling
|
|
122
|
+
* sideways, or with shift held, moves x; otherwise it moves y.
|
|
123
|
+
*
|
|
124
|
+
* A position runs from 0 to 1, so `['normalized', n]` and `['raw', n]` both
|
|
125
|
+
* move it by `n` of the editor. `null` turns the wheel off, and a `Point`
|
|
126
|
+
* can override it with a `wheel` of its own.
|
|
127
|
+
*
|
|
128
|
+
* @default ['normalized', 0.01]
|
|
129
|
+
*/
|
|
130
|
+
wheel?: ModifierValue<InputEventOption> | null
|
|
131
|
+
/**
|
|
132
|
+
* How much one arrow key press moves a `Point`. Left and right move x, up
|
|
133
|
+
* and down move y.
|
|
134
|
+
*
|
|
135
|
+
* A position runs from 0 to 1, so `['normalized', n]` and `['raw', n]` both
|
|
136
|
+
* move it by `n` of the editor; give a map to set an amount per modifier
|
|
137
|
+
* key. `null` turns the arrow keys off, and a `Point` can override it with a
|
|
138
|
+
* `keyboard` of its own.
|
|
139
|
+
*
|
|
140
|
+
* @default { default: ['normalized', 0.01], shift: ['normalized', 0.001] }
|
|
141
|
+
*/
|
|
142
|
+
keyboard?: ModifierValue<InputEventOption> | null
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* How much a drag moves a `Point`, per modifier key.
|
|
146
|
+
*
|
|
147
|
+
* `1` is the pointer position itself, which is what dragging a point
|
|
148
|
+
* normally is. **Anything else turns the drag relative**: `0.1` makes the
|
|
149
|
+
* same movement cover a tenth of the editor, so the point stops following
|
|
150
|
+
* the pointer and starts moving a tenth as fast. Shift is bound to `0.1` by
|
|
151
|
+
* default, to match what it does on the arrow keys.
|
|
152
|
+
*
|
|
153
|
+
* Pressing or releasing the key mid-drag does not disturb the point: the
|
|
154
|
+
* travel so far is kept and the new sensitivity applies from there. **The
|
|
155
|
+
* pointer and the point stay apart for the rest of the drag** — snapping
|
|
156
|
+
* them back together on release would move the point nobody asked to move.
|
|
157
|
+
*
|
|
158
|
+
* @default { default: 1, shift: 0.1 }
|
|
159
|
+
*/
|
|
160
|
+
dragSensitivity?: ModifierValue<number>
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Let points be selected, and a selection be moved as one.
|
|
164
|
+
*
|
|
165
|
+
* Off by default, because it changes what a press and a drag mean: a press
|
|
166
|
+
* on empty space starts a selection box rather than doing nothing, and a drag
|
|
167
|
+
* on a point moves everything else that is selected. An editor whose points
|
|
168
|
+
* each mean something different — the four handles of an ADSR envelope, say
|
|
169
|
+
* — has nothing to gain from moving them together.
|
|
170
|
+
*
|
|
171
|
+
* **A selection calls `onChange` on several points in the same tick**, so
|
|
172
|
+
* each of them has to update from the previous state rather than from a
|
|
173
|
+
* value captured in the render:
|
|
174
|
+
*
|
|
175
|
+
* ```jsx
|
|
176
|
+
* onChange={(v) => setPoints((prev) => ({ ...prev, [id]: v }))}
|
|
177
|
+
* ```
|
|
178
|
+
*
|
|
179
|
+
* Written the other way round — `setPoints({ ...points, [id]: v })` — every
|
|
180
|
+
* call but the last is thrown away, and only one point appears to move.
|
|
181
|
+
*
|
|
182
|
+
* @default false
|
|
183
|
+
*/
|
|
184
|
+
selectable?: boolean
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Ids of the selected points, to hold the selection yourself. Leave it out
|
|
188
|
+
* and the editor keeps its own.
|
|
189
|
+
*
|
|
190
|
+
* A `Point` takes its id from its `id` prop, or generates one that lasts as
|
|
191
|
+
* long as it is mounted.
|
|
66
192
|
*/
|
|
67
|
-
|
|
193
|
+
selection?: string[]
|
|
194
|
+
/** The selection to start with, when the editor keeps its own. */
|
|
195
|
+
defaultSelection?: string[]
|
|
196
|
+
/** Called whenever the selection changes, controlled or not. */
|
|
197
|
+
onSelectionChange?: (selection: string[]) => void
|
|
198
|
+
|
|
68
199
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
200
|
+
* The editor renders exactly what you compose here; there is no default
|
|
201
|
+
* markup to fall back to.
|
|
202
|
+
*
|
|
203
|
+
* @example
|
|
204
|
+
* <PointsEditor.Root>
|
|
205
|
+
* <PointsEditor.Background>
|
|
206
|
+
* <svg viewBox="0 0 200 100">...</svg>
|
|
207
|
+
* </PointsEditor.Background>
|
|
208
|
+
* <PointsEditor.Container>
|
|
209
|
+
* {points.map((point, i) => (
|
|
210
|
+
* <PointsEditor.Point key={i} value={point} onChange={...} />
|
|
211
|
+
* ))}
|
|
212
|
+
* </PointsEditor.Container>
|
|
213
|
+
* </PointsEditor.Root>
|
|
71
214
|
*/
|
|
72
|
-
|
|
215
|
+
children: ReactNode
|
|
73
216
|
}
|
|
74
217
|
|
|
75
218
|
type Props = PointsEditorProps &
|
|
76
219
|
Omit<ComponentPropsWithoutRef<'div'>, keyof PointsEditorProps>
|
|
77
220
|
|
|
78
|
-
const Root = forwardRef<HTMLDivElement, Props>(
|
|
221
|
+
export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
|
|
79
222
|
(
|
|
80
223
|
{
|
|
81
|
-
width
|
|
82
|
-
height
|
|
224
|
+
width,
|
|
225
|
+
height,
|
|
83
226
|
disabled = false,
|
|
84
227
|
readonly = false,
|
|
85
|
-
|
|
228
|
+
wheel = DEFAULT_WHEEL,
|
|
229
|
+
keyboard = DEFAULT_KEYBOARD,
|
|
230
|
+
dragSensitivity = DEFAULT_DRAG_SENSITIVITY,
|
|
231
|
+
selectable = false,
|
|
232
|
+
selection: selectionProp,
|
|
233
|
+
defaultSelection,
|
|
234
|
+
onSelectionChange,
|
|
235
|
+
externalStyles,
|
|
86
236
|
style,
|
|
87
237
|
className,
|
|
88
238
|
children,
|
|
@@ -90,22 +240,296 @@ const Root = forwardRef<HTMLDivElement, Props>(
|
|
|
90
240
|
},
|
|
91
241
|
forwardedRef,
|
|
92
242
|
) => {
|
|
93
|
-
const
|
|
243
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
244
|
+
|
|
245
|
+
// Picked apart so that the memo below depends on values rather than on the
|
|
246
|
+
// object literal a caller writes inline, which is new on every render.
|
|
247
|
+
const { cursor = 'grabbing' } = externalStyles ?? {}
|
|
248
|
+
|
|
249
|
+
// --- selection ---
|
|
250
|
+
const controlled = selectionProp !== undefined
|
|
251
|
+
const [ownSelection, setOwnSelection] = useState<string[]>(
|
|
252
|
+
defaultSelection ?? [],
|
|
253
|
+
)
|
|
254
|
+
// Nothing is selected while selection is off, so a drag picks up only the
|
|
255
|
+
// point it started on and `data-selected` never turns on.
|
|
256
|
+
const selection = selectable ? (selectionProp ?? ownSelection) : EMPTY
|
|
257
|
+
|
|
258
|
+
// Drags read the selection from a native event handler, which runs after
|
|
259
|
+
// the commit, so a ref is current by the time it matters.
|
|
260
|
+
const selectionRef = useRef(selection)
|
|
261
|
+
const changeHandlerRef = useRef(onSelectionChange)
|
|
262
|
+
useEffect(() => {
|
|
263
|
+
selectionRef.current = selection
|
|
264
|
+
changeHandlerRef.current = onSelectionChange
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
const changeSelection = useCallback(
|
|
268
|
+
(next: string[]) => {
|
|
269
|
+
selectionRef.current = next
|
|
270
|
+
if (!controlled) setOwnSelection(next)
|
|
271
|
+
changeHandlerRef.current?.(next)
|
|
272
|
+
},
|
|
273
|
+
[controlled],
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Every mounted point, by id. A registration is a ref rather than a value:
|
|
278
|
+
* the value inside changes on every frame of a drag, and a registry keyed
|
|
279
|
+
* on it would be rebuilt just as often.
|
|
280
|
+
*/
|
|
281
|
+
const points = useRef(new Map<string, RefObject<PointRegistration>>())
|
|
282
|
+
|
|
283
|
+
const registerPoint = useCallback(
|
|
284
|
+
(id: string, entry: RefObject<PointRegistration>) => {
|
|
285
|
+
points.current.set(id, entry)
|
|
286
|
+
return () => {
|
|
287
|
+
points.current.delete(id)
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
[],
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
/** What the current drag picked up, and where those points started. */
|
|
294
|
+
const dragRef = useRef<{ id: string; start: PointBaseType }[]>([])
|
|
295
|
+
|
|
296
|
+
const snapshot = useCallback((ids: readonly string[]) => {
|
|
297
|
+
return ids.flatMap((id) => {
|
|
298
|
+
const registration = points.current.get(id)?.current
|
|
299
|
+
return registration ? [{ id, start: { ...registration.value } }] : []
|
|
300
|
+
})
|
|
301
|
+
}, [])
|
|
302
|
+
|
|
303
|
+
const applyDeltaTo = useCallback(
|
|
304
|
+
(
|
|
305
|
+
entries: { id: string; start: PointBaseType }[],
|
|
306
|
+
delta: PointBaseType,
|
|
307
|
+
) => {
|
|
308
|
+
const withRegistration = entries.flatMap((entry) => {
|
|
309
|
+
const registration = points.current.get(entry.id)?.current
|
|
310
|
+
return registration ? [{ ...entry, registration }] : []
|
|
311
|
+
})
|
|
312
|
+
const allowed = allowedDelta(delta, withRegistration)
|
|
313
|
+
for (const { start, registration } of withRegistration) {
|
|
314
|
+
if (registration.readonly) continue
|
|
315
|
+
// Rounded here as well as in the pipeline: a move is a subtraction
|
|
316
|
+
// and an addition of its own, and that is enough to put the binary
|
|
317
|
+
// artefact back (0.2 + 0.1 lands on 0.30000000000000004).
|
|
318
|
+
registration.onChange?.({
|
|
319
|
+
x: toPrecision(start.x + allowed.x),
|
|
320
|
+
y: toPrecision(start.y + allowed.y),
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
[],
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
const beginPointDrag = useCallback(
|
|
328
|
+
(id: string, modifiers: ModifierState) => {
|
|
329
|
+
if (!selectable) {
|
|
330
|
+
dragRef.current = snapshot([id])
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
const current = selectionRef.current
|
|
334
|
+
// Ctrl / meta rather than shift: shift is the fine-adjustment key on
|
|
335
|
+
// every control here, and it cannot be both.
|
|
336
|
+
const additive = modifiers.ctrlKey || modifiers.metaKey
|
|
337
|
+
let next: readonly string[]
|
|
338
|
+
if (additive) {
|
|
339
|
+
next = current.includes(id)
|
|
340
|
+
? current.filter((x) => x !== id)
|
|
341
|
+
: [...current, id]
|
|
342
|
+
} else if (current.includes(id)) {
|
|
343
|
+
// Already part of a group: keep it, so the group can be dragged.
|
|
344
|
+
next = current
|
|
345
|
+
} else {
|
|
346
|
+
next = [id]
|
|
347
|
+
}
|
|
348
|
+
changeSelection([...next])
|
|
349
|
+
// A press that took the point out of the selection was a deselect, not
|
|
350
|
+
// the start of a move, so there is nothing to drag.
|
|
351
|
+
dragRef.current = next.includes(id) ? snapshot(next) : []
|
|
352
|
+
},
|
|
353
|
+
[selectable, changeSelection, snapshot],
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
const movePointDrag = useCallback(
|
|
357
|
+
(delta: PointBaseType) => applyDeltaTo(dragRef.current, delta),
|
|
358
|
+
[applyDeltaTo],
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
const nudgeSelection = useCallback(
|
|
362
|
+
(id: string, delta: PointBaseType) => {
|
|
363
|
+
// From wherever the points are now: a key press is not a drag, so
|
|
364
|
+
// there is no earlier position to measure against.
|
|
365
|
+
const ids = selectionRef.current.includes(id)
|
|
366
|
+
? selectionRef.current
|
|
367
|
+
: [id]
|
|
368
|
+
applyDeltaTo(snapshot(ids), delta)
|
|
369
|
+
},
|
|
370
|
+
[applyDeltaTo, snapshot],
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
const isPointElement = useCallback(
|
|
374
|
+
(element: Element | null | undefined) => {
|
|
375
|
+
if (!element) return false
|
|
376
|
+
for (const [, entry] of points.current) {
|
|
377
|
+
if (entry.current.element?.contains(element)) return true
|
|
378
|
+
}
|
|
379
|
+
return false
|
|
380
|
+
},
|
|
381
|
+
[],
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
const nudgeFocusedPoint = useCallback(
|
|
385
|
+
(axis: 'x' | 'y', direction: number, modifiers: ModifierState) => {
|
|
386
|
+
const container = containerRef.current
|
|
387
|
+
const active = container?.ownerDocument.activeElement
|
|
388
|
+
if (!container || !active || !container.contains(active)) return false
|
|
389
|
+
for (const [id, entry] of points.current) {
|
|
390
|
+
const { element, wheel, readonly, onChange, value } = entry.current
|
|
391
|
+
// A point answers only for the focus inside its own inputs, so both
|
|
392
|
+
// axes stay part of the same interaction.
|
|
393
|
+
if (!element?.contains(active)) continue
|
|
394
|
+
if (!wheel || readonly || !onChange) return false
|
|
395
|
+
const next = applyDelta(
|
|
396
|
+
value[axis],
|
|
397
|
+
direction,
|
|
398
|
+
wheel,
|
|
399
|
+
AXIS,
|
|
400
|
+
modifiers,
|
|
401
|
+
)
|
|
402
|
+
// As a move, so that the rest of the selection comes along and the
|
|
403
|
+
// whole group stops together at the edge.
|
|
404
|
+
nudgeSelection(id, {
|
|
405
|
+
x: axis === 'x' ? next - value.x : 0,
|
|
406
|
+
y: axis === 'y' ? next - value.y : 0,
|
|
407
|
+
})
|
|
408
|
+
return true
|
|
409
|
+
}
|
|
410
|
+
return false
|
|
411
|
+
},
|
|
412
|
+
[nudgeSelection],
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
// --- selection box ---
|
|
416
|
+
// The box itself lives in the core: which items a rectangle covers, and
|
|
417
|
+
// what a press adds to or replaces, are not React's to decide. What is
|
|
418
|
+
// left here is the registry it reads and the state the box is drawn from.
|
|
419
|
+
const [selectionBox, setSelectionBox] = useState<SelectionBoxRect | null>(
|
|
420
|
+
null,
|
|
421
|
+
)
|
|
422
|
+
const selectionBoxRef = useRef<SelectionBoxInstance<string> | null>(null)
|
|
423
|
+
selectionBoxRef.current ??= createSelectionBox<string>({
|
|
424
|
+
*items(): Generator<readonly [string, XY<number>]> {
|
|
425
|
+
for (const [id, entry] of points.current) {
|
|
426
|
+
const { x, y } = entry.current.value
|
|
427
|
+
yield [id, [x, y]]
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
onBoxChange: setSelectionBox,
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
// The selection is reported to whoever owns it, and that handler is a new
|
|
434
|
+
// function whenever the caller's is, so it is handed over rather than
|
|
435
|
+
// closed over.
|
|
436
|
+
useEffect(() => {
|
|
437
|
+
selectionBoxRef.current?.update({ onSelectionChange: changeSelection })
|
|
438
|
+
}, [changeSelection])
|
|
439
|
+
|
|
440
|
+
useEffect(() => {
|
|
441
|
+
const box = selectionBoxRef.current
|
|
442
|
+
return () => box?.destroy()
|
|
443
|
+
}, [])
|
|
444
|
+
|
|
445
|
+
const beginSelectionBox = useCallback(
|
|
446
|
+
(at: PointBaseType, modifiers: ModifierState) => {
|
|
447
|
+
if (!selectable) return
|
|
448
|
+
selectionBoxRef.current?.begin([at.x, at.y], {
|
|
449
|
+
// Ctrl / meta rather than shift: shift is the fine-adjustment key on
|
|
450
|
+
// every control here, and it cannot be both.
|
|
451
|
+
additive: modifiers.ctrlKey || modifiers.metaKey,
|
|
452
|
+
selection: selectionRef.current,
|
|
453
|
+
})
|
|
454
|
+
},
|
|
455
|
+
[selectable],
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
const moveSelectionBox = useCallback((to: PointBaseType) => {
|
|
459
|
+
selectionBoxRef.current?.move([to.x, to.y])
|
|
460
|
+
}, [])
|
|
461
|
+
|
|
462
|
+
const endSelectionBox = useCallback(() => {
|
|
463
|
+
if (!selectionBoxRef.current?.end()) return
|
|
464
|
+
// A selection box is drawn on the container, which is not a control and
|
|
465
|
+
// cannot hold focus, so the press that started it left the focus on
|
|
466
|
+
// nothing. The arrow keys and the wheel reach a point only through the
|
|
467
|
+
// focus, so it is handed to one of the points the box selected —
|
|
468
|
+
// whichever point takes it moves the whole selection.
|
|
469
|
+
const [first] = selectionRef.current
|
|
470
|
+
if (first) points.current.get(first)?.current.element?.focus()
|
|
471
|
+
}, [])
|
|
472
|
+
|
|
473
|
+
const context = useMemo(
|
|
474
|
+
() => ({
|
|
475
|
+
disabled,
|
|
476
|
+
readonly,
|
|
477
|
+
wheel,
|
|
478
|
+
keyboard,
|
|
479
|
+
dragSensitivity,
|
|
480
|
+
externalStyles: { cursor },
|
|
481
|
+
containerRef,
|
|
482
|
+
selectable,
|
|
483
|
+
selection,
|
|
484
|
+
registerPoint,
|
|
485
|
+
isPointElement,
|
|
486
|
+
beginPointDrag,
|
|
487
|
+
movePointDrag,
|
|
488
|
+
nudgeSelection,
|
|
489
|
+
nudgeFocusedPoint,
|
|
490
|
+
selectionBox,
|
|
491
|
+
beginSelectionBox,
|
|
492
|
+
moveSelectionBox,
|
|
493
|
+
endSelectionBox,
|
|
494
|
+
}),
|
|
495
|
+
[
|
|
496
|
+
disabled,
|
|
497
|
+
readonly,
|
|
498
|
+
wheel,
|
|
499
|
+
keyboard,
|
|
500
|
+
dragSensitivity,
|
|
501
|
+
cursor,
|
|
502
|
+
selectable,
|
|
503
|
+
selection,
|
|
504
|
+
registerPoint,
|
|
505
|
+
isPointElement,
|
|
506
|
+
beginPointDrag,
|
|
507
|
+
movePointDrag,
|
|
508
|
+
nudgeSelection,
|
|
509
|
+
nudgeFocusedPoint,
|
|
510
|
+
selectionBox,
|
|
511
|
+
beginSelectionBox,
|
|
512
|
+
moveSelectionBox,
|
|
513
|
+
endSelectionBox,
|
|
514
|
+
],
|
|
515
|
+
)
|
|
94
516
|
|
|
95
517
|
return (
|
|
96
|
-
<PointsEditorProvider
|
|
97
|
-
disabled={disabled}
|
|
98
|
-
readonly={readonly}
|
|
99
|
-
externalStyles={externalStyles}
|
|
100
|
-
>
|
|
518
|
+
<PointsEditorProvider value={context}>
|
|
101
519
|
<div
|
|
102
520
|
ref={forwardedRef}
|
|
103
|
-
className={
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
521
|
+
className={className}
|
|
522
|
+
data-disabled={disabled ? '' : undefined}
|
|
523
|
+
data-readonly={readonly ? '' : undefined}
|
|
524
|
+
style={
|
|
525
|
+
{
|
|
526
|
+
'--width': cssLength(width),
|
|
527
|
+
'--height': cssLength(height),
|
|
528
|
+
// The layers inside are placed against this box.
|
|
529
|
+
position: 'relative',
|
|
530
|
+
...style,
|
|
531
|
+
} as CSSProperties
|
|
532
|
+
}
|
|
109
533
|
{...props}
|
|
110
534
|
>
|
|
111
535
|
{children}
|
|
@@ -123,7 +547,18 @@ export const PointsEditor = {
|
|
|
123
547
|
Background,
|
|
124
548
|
Container,
|
|
125
549
|
Point,
|
|
550
|
+
SelectionBox,
|
|
126
551
|
}
|
|
127
552
|
|
|
128
|
-
export {
|
|
129
|
-
|
|
553
|
+
export {
|
|
554
|
+
usePointsEditorContext,
|
|
555
|
+
type PointsEditorContextValue,
|
|
556
|
+
} from './context'
|
|
557
|
+
export { type PointsEditorBackgroundProps } from './Background'
|
|
558
|
+
export { type PointsEditorContainerProps } from './Container'
|
|
559
|
+
export { type PointsEditorSelectionBoxProps } from './SelectionBox'
|
|
560
|
+
export {
|
|
561
|
+
clampPoint,
|
|
562
|
+
type PointBaseType,
|
|
563
|
+
type PointsEditorPointProps,
|
|
564
|
+
} from './Point'
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { ComponentPropsWithoutRef, CSSProperties, ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import { xor } from '@tremolo-ui/functions'
|
|
4
|
+
|
|
5
|
+
import { cssLength } from '../_util/css-length'
|
|
6
|
+
import { Placement } from '../_util/Placement'
|
|
7
|
+
|
|
8
|
+
import { useSliderContext } from './context'
|
|
9
|
+
import { MarksOption } from './MarksOption'
|
|
10
|
+
import { generateOptionsList, MarksOptions } from './type'
|
|
11
|
+
|
|
12
|
+
export interface SliderMarksProps {
|
|
13
|
+
/**
|
|
14
|
+
* Space between the marks and the track. Sets `--gap`; the theme's own
|
|
15
|
+
* spacing stands when this is omitted.
|
|
16
|
+
*/
|
|
17
|
+
gap?: number | string
|
|
18
|
+
/**
|
|
19
|
+
* Build the marks instead of writing `Slider.MarksOption` out: a number puts
|
|
20
|
+
* one every that many, `'step'` one every `step`, and `{ per, mark, label }`
|
|
21
|
+
* also turns the mark or the label off for all of them. `children` is
|
|
22
|
+
* ignored while it is set.
|
|
23
|
+
*
|
|
24
|
+
* `'step'` makes one per step, which is a great many for a fine `step`.
|
|
25
|
+
*/
|
|
26
|
+
options?: MarksOptions
|
|
27
|
+
/**
|
|
28
|
+
* `<Slider.MarksOption />` goes here, one per mark. Ignored while `options`
|
|
29
|
+
* is set.
|
|
30
|
+
*/
|
|
31
|
+
children?: ReactNode
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function Marks({
|
|
35
|
+
gap,
|
|
36
|
+
options,
|
|
37
|
+
children,
|
|
38
|
+
className,
|
|
39
|
+
style,
|
|
40
|
+
...props
|
|
41
|
+
}: SliderMarksProps &
|
|
42
|
+
Omit<ComponentPropsWithoutRef<'div'>, keyof SliderMarksProps>) {
|
|
43
|
+
const min = useSliderContext((s) => s.min)
|
|
44
|
+
const max = useSliderContext((s) => s.max)
|
|
45
|
+
const step = useSliderContext((s) => s.step)
|
|
46
|
+
const vertical = useSliderContext((s) => s.vertical)
|
|
47
|
+
const reverse = useSliderContext((s) => s.reverse)
|
|
48
|
+
|
|
49
|
+
const optionsList = options
|
|
50
|
+
? generateOptionsList(options, min, max, step)
|
|
51
|
+
: []
|
|
52
|
+
if (xor(vertical, reverse)) optionsList.reverse()
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
className={className}
|
|
57
|
+
data-orientation={vertical ? 'vertical' : 'horizontal'}
|
|
58
|
+
style={
|
|
59
|
+
{
|
|
60
|
+
'--gap': cssLength(gap),
|
|
61
|
+
// Each option inside is placed against this box.
|
|
62
|
+
position: 'relative',
|
|
63
|
+
...style,
|
|
64
|
+
} as CSSProperties
|
|
65
|
+
}
|
|
66
|
+
{...props}
|
|
67
|
+
>
|
|
68
|
+
<Placement name="Slider.Marks">
|
|
69
|
+
{options
|
|
70
|
+
? optionsList.map(({ value, mark, label }, index) => {
|
|
71
|
+
return (
|
|
72
|
+
<MarksOption
|
|
73
|
+
key={index}
|
|
74
|
+
value={value}
|
|
75
|
+
mark={mark}
|
|
76
|
+
label={label ? undefined : null}
|
|
77
|
+
/>
|
|
78
|
+
)
|
|
79
|
+
})
|
|
80
|
+
: children}
|
|
81
|
+
</Placement>
|
|
82
|
+
</div>
|
|
83
|
+
)
|
|
84
|
+
}
|