@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.
Files changed (76) hide show
  1. package/dist/compose-refs.cjs +72 -0
  2. package/dist/compose-refs.cjs.map +1 -0
  3. package/dist/compose-refs.d.cts +43 -0
  4. package/dist/compose-refs.d.cts.map +1 -0
  5. package/dist/compose-refs.d.ts +43 -0
  6. package/dist/compose-refs.d.ts.map +1 -0
  7. package/dist/compose-refs.js +70 -0
  8. package/dist/compose-refs.js.map +1 -0
  9. package/dist/index.cjs +1468 -534
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +1103 -435
  12. package/dist/index.d.cts.map +1 -1
  13. package/dist/index.d.ts +1103 -435
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1457 -534
  16. package/dist/index.js.map +1 -1
  17. package/package.json +28 -26
  18. package/src/components/AnimationCanvas/index.tsx +115 -76
  19. package/src/components/Knob/ActiveLine.tsx +7 -6
  20. package/src/components/Knob/InactiveLine.tsx +9 -10
  21. package/src/components/Knob/SVGRoot.tsx +5 -3
  22. package/src/components/Knob/Thumb.tsx +33 -11
  23. package/src/components/Knob/context.tsx +17 -1
  24. package/src/components/Knob/index.tsx +148 -55
  25. package/src/components/NumberInput/DecrementStepper.tsx +8 -4
  26. package/src/components/NumberInput/IncrementStepper.tsx +9 -4
  27. package/src/components/NumberInput/InputField.tsx +188 -105
  28. package/src/components/NumberInput/Stepper.tsx +123 -72
  29. package/src/components/NumberInput/{stepperButton.tsx → StepperButton.tsx} +27 -22
  30. package/src/components/NumberInput/context.tsx +35 -8
  31. package/src/components/NumberInput/index.tsx +272 -75
  32. package/src/components/Piano/index.tsx +354 -188
  33. package/src/components/PointsEditor/Background.tsx +11 -2
  34. package/src/components/PointsEditor/Container.tsx +87 -27
  35. package/src/components/PointsEditor/Point.tsx +239 -79
  36. package/src/components/PointsEditor/SelectionBox.tsx +54 -0
  37. package/src/components/PointsEditor/context.tsx +78 -10
  38. package/src/components/PointsEditor/index.tsx +440 -39
  39. package/src/components/Slider/Marks.tsx +48 -16
  40. package/src/components/Slider/MarksOption.tsx +57 -43
  41. package/src/components/Slider/Thumb.tsx +108 -50
  42. package/src/components/Slider/Track.tsx +69 -58
  43. package/src/components/Slider/context.tsx +4 -2
  44. package/src/components/Slider/index.tsx +131 -65
  45. package/src/components/Slider/type.ts +26 -8
  46. package/src/components/XYPad/Area.tsx +45 -35
  47. package/src/components/XYPad/Thumb.tsx +135 -62
  48. package/src/components/XYPad/context.tsx +4 -1
  49. package/src/components/XYPad/index.tsx +180 -48
  50. package/src/components/_util/Placement.tsx +68 -0
  51. package/src/components/_util/VisuallyHiddenRangeInput.tsx +24 -0
  52. package/src/components/_util/css-length.ts +13 -0
  53. package/src/{components/_util/composeRefs.tsx → compose-refs/index.tsx} +33 -5
  54. package/src/hooks/_internal/useCallbackRef.ts +3 -1
  55. package/src/hooks/_internal/useCheckSteps.ts +170 -0
  56. package/src/hooks/useAnimationFrame.ts +17 -7
  57. package/src/hooks/useDrag.ts +38 -8
  58. package/src/hooks/useDragValue.ts +54 -9
  59. package/src/hooks/useEventListener.ts +10 -6
  60. package/src/hooks/useLongPress.ts +42 -8
  61. package/src/hooks/useMIDIAccess.ts +82 -11
  62. package/src/hooks/useMIDIInput.ts +45 -13
  63. package/src/hooks/useMIDIMessage.ts +23 -5
  64. package/src/index.ts +38 -21
  65. package/src/input-event.ts +32 -0
  66. package/dist/index.css +0 -501
  67. package/src/Globals.d.ts +0 -3
  68. package/src/components/Knob/index.css +0 -81
  69. package/src/components/NumberInput/index.css +0 -121
  70. package/src/components/Piano/index.css +0 -90
  71. package/src/components/PointsEditor/index.css +0 -35
  72. package/src/components/Slider/index.css +0 -125
  73. package/src/components/XYPad/index.css +0 -60
  74. package/src/components/_util/index.ts +0 -28
  75. package/src/styles/global.css +0 -19
  76. /package/src/components/Piano/{keyboardShortcuts.ts → keyboard-shortcuts.ts} +0 -0
@@ -1,19 +1,44 @@
1
- import { createContext, RefObject, useContext } from 'react'
1
+ import { createContext, CSSProperties, RefObject, useContext } from 'react'
2
2
 
3
- import type { InputEventOption } from '@tremolo-ui/functions'
3
+ import type { SelectionBoxRect } from '@tremolo-ui/dom'
4
+ import type {
5
+ InputEventOption,
6
+ ModifierState,
7
+ ModifierValue,
8
+ } from '@tremolo-ui/functions'
4
9
 
5
- import { Cursor } from '../_util'
10
+ import type { PointBaseType } from './Point'
11
+
12
+ /**
13
+ * What a `Point` tells the editor about itself, so that a selection can be
14
+ * moved without the editor knowing how the points are stored.
15
+ *
16
+ * Held behind a ref and rewritten on every render: the value changes on every
17
+ * frame of a drag, and a registry keyed on it would be rebuilt just as often.
18
+ */
19
+ export interface PointRegistration {
20
+ value: PointBaseType
21
+ min?: Partial<PointBaseType>
22
+ max?: Partial<PointBaseType>
23
+ readonly: boolean
24
+ onChange?: (value: PointBaseType) => void
25
+ /** The point's own element, to match the focus against. */
26
+ element: HTMLElement | null
27
+ /** The wheel option this point resolved, root inheritance applied. */
28
+ wheel: ModifierValue<InputEventOption> | null
29
+ }
6
30
 
7
31
  export type PointsEditorContextValue = {
8
32
  disabled: boolean
9
33
  readonly: boolean
10
34
  /** Inherited by every `Point`; `null` turns the wheel off. */
11
- wheel: InputEventOption | null
35
+ wheel: ModifierValue<InputEventOption> | null
12
36
  /** Inherited by every `Point`; `null` turns the keyboard off. */
13
- keyboard: InputEventOption | null
37
+ keyboard: ModifierValue<InputEventOption> | null
38
+ /** Inherited by every `Point`. See `PointsEditorProps.dragSensitivity`. */
39
+ dragSensitivity: ModifierValue<number>
14
40
  externalStyles: {
15
- userSelectNone?: boolean
16
- cursor?: Cursor
41
+ cursor?: CSSProperties['cursor']
17
42
  }
18
43
 
19
44
  /**
@@ -21,15 +46,58 @@ export type PointsEditorContextValue = {
21
46
  * against it, so a point is placed by its position within the container.
22
47
  */
23
48
  containerRef: RefObject<HTMLDivElement | null>
49
+
50
+ /** Whether points can be selected at all. See `PointsEditorProps.selectable`. */
51
+ selectable: boolean
52
+ /** Ids of the points currently selected. Always empty while `selectable` is off. */
53
+ selection: readonly string[]
54
+ /** Register a point so that a selection can move it with the rest. */
55
+ registerPoint: (id: string, entry: RefObject<PointRegistration>) => () => void
56
+ /**
57
+ * Whether the element is a point, or inside one. The registry knows which
58
+ * elements the points are, so nothing has to be recognised by a class name.
59
+ */
60
+ isPointElement: (element: Element | null | undefined) => boolean
61
+ /**
62
+ * A pointer went down on a point: works out the new selection and takes the
63
+ * snapshot the move will be measured against.
64
+ */
65
+ beginPointDrag: (id: string, modifiers: ModifierState) => void
66
+ /** Move everything the drag picked up, by one amount, clamped as one. */
67
+ movePointDrag: (delta: PointBaseType) => void
68
+ /**
69
+ * Move the selection by an amount that did not come from a drag — an arrow
70
+ * key or a wheel notch. The current values are the starting point.
71
+ */
72
+ nudgeSelection: (id: string, delta: PointBaseType) => void
73
+ /**
74
+ * Move the point that currently holds focus by one notch. The wheel listener
75
+ * belongs to `Container` rather than to every `Point`, so the lookup of who
76
+ * has focus happens here, where the registry is. Returns whether a point
77
+ * took it, so the caller knows whether to consume the event.
78
+ */
79
+ nudgeFocusedPoint: (
80
+ axis: 'x' | 'y',
81
+ direction: number,
82
+ modifiers: ModifierState,
83
+ ) => boolean
84
+
85
+ /** The selection box, while one is being dragged. */
86
+ selectionBox: SelectionBoxRect | null
87
+ beginSelectionBox: (at: PointBaseType, modifiers: ModifierState) => void
88
+ moveSelectionBox: (to: PointBaseType) => void
89
+ endSelectionBox: () => void
24
90
  }
25
91
 
26
- const PointsEditorContext = createContext<PointsEditorContextValue | null>(null)
92
+ const PointsEditorContext =
93
+ /* @__PURE__ */ createContext<PointsEditorContextValue | null>(null)
27
94
 
28
95
  export const PointsEditorProvider = PointsEditorContext.Provider
29
96
 
30
97
  /**
31
- * The settings `Root` was given, for the subcomponents to read. There is no
32
- * state to keep in sync: a point's value belongs to the `Point` that draws it.
98
+ * The settings `Root` was given, for the subcomponents to read. A point's
99
+ * value still belongs to the `Point` that draws it; what the root keeps is
100
+ * which points are selected, and a registry of who they are.
33
101
  */
34
102
  export function usePointsEditorContext(): PointsEditorContextValue
35
103
  export function usePointsEditorContext<T>(
@@ -1,26 +1,76 @@
1
- import clsx from 'clsx'
2
1
  import {
3
2
  ComponentPropsWithoutRef,
3
+ CSSProperties,
4
4
  forwardRef,
5
5
  ReactNode,
6
+ RefObject,
7
+ useCallback,
8
+ useEffect,
6
9
  useMemo,
7
10
  useRef,
11
+ useState,
8
12
  } from 'react'
9
13
 
10
- import { InputEventOption } from '@tremolo-ui/functions'
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'
11
28
 
12
- import { Cursor } from '../_util'
29
+ import { DEFAULT_DRAG_SENSITIVITY } from '../../input-event'
30
+ import { cssLength } from '../_util/css-length'
13
31
 
14
32
  import { Background } from './Background'
15
33
  import { Container } from './Container'
16
- import { PointsEditorProvider } from './context'
17
- import { Point } 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
+ }
18
70
 
19
71
  /*
20
72
  TODO:
21
73
 
22
- - 複数選択
23
- - modifier
24
74
  - grid
25
75
  */
26
76
 
@@ -28,43 +78,123 @@ TODO:
28
78
  * A point moves over 0..1 in both axes, so a nudge of 0.01 crosses the editor
29
79
  * in a hundred steps whatever its pixel size.
30
80
  */
31
- const DEFAULT_INPUT_EVENT_OPTION: InputEventOption = ['normalized', 0.01]
81
+ const DEFAULT_WHEEL: ModifierValue<InputEventOption> = ['normalized', 0.01]
82
+
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],
91
+ }
32
92
 
33
93
  export interface PointsEditorProps {
94
+ /** Sets `--width`; the size the theme gives it stands when omitted. */
34
95
  width?: number | string
96
+ /** Sets `--height`; the size the theme gives it stands when omitted. */
35
97
  height?: number | string
36
98
 
37
99
  /**
38
- * Only the appearance will change.
39
- * Please consider using with readonly.
40
- * aria-disabled property is also applied.
100
+ * Make the points unchangeable and remove them from the tab order.
101
+ * The parts carry `data-disabled` while it is set.
41
102
  */
42
103
  disabled?: boolean
43
104
  /**
44
105
  * Make the points unmovable.
45
- * aria-readonly property is also applied.
106
+ * The parts carry `data-readonly` while it is set.
46
107
  */
47
108
  readonly?: boolean
48
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
+ */
49
116
  externalStyles?: {
50
- userSelectNone?: boolean
51
- cursor?: Cursor
117
+ cursor?: CSSProperties['cursor']
52
118
  }
53
119
 
54
120
  /**
55
- * wheel control option for every `Point`. Shift selects the x axis.
56
- * If null, no event will be triggered
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.
57
127
  *
58
- * A `Point` can override it with a `wheel` of its own.
128
+ * @default ['normalized', 0.01]
59
129
  */
60
- wheel?: InputEventOption | null
130
+ wheel?: ModifierValue<InputEventOption> | null
61
131
  /**
62
- * keyboard control option for every `Point`, on the arrow keys.
63
- * If null, no event will be triggered
132
+ * How much one arrow key press moves a `Point`. Left and right move x, up
133
+ * and down move y.
64
134
  *
65
- * A `Point` can override it with a `keyboard` of its own.
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
66
183
  */
67
- keyboard?: InputEventOption | null
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.
192
+ */
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
68
198
 
69
199
  /**
70
200
  * The editor renders exactly what you compose here; there is no default
@@ -88,15 +218,20 @@ export interface PointsEditorProps {
88
218
  type Props = PointsEditorProps &
89
219
  Omit<ComponentPropsWithoutRef<'div'>, keyof PointsEditorProps>
90
220
 
91
- export const Root = forwardRef<HTMLDivElement, Props>(
221
+ export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
92
222
  (
93
223
  {
94
- width = 200,
95
- height = 100,
224
+ width,
225
+ height,
96
226
  disabled = false,
97
227
  readonly = false,
98
- wheel = DEFAULT_INPUT_EVENT_OPTION,
99
- keyboard = DEFAULT_INPUT_EVENT_OPTION,
228
+ wheel = DEFAULT_WHEEL,
229
+ keyboard = DEFAULT_KEYBOARD,
230
+ dragSensitivity = DEFAULT_DRAG_SENSITIVITY,
231
+ selectable = false,
232
+ selection: selectionProp,
233
+ defaultSelection,
234
+ onSelectionChange,
100
235
  externalStyles,
101
236
  style,
102
237
  className,
@@ -109,7 +244,231 @@ export const Root = forwardRef<HTMLDivElement, Props>(
109
244
 
110
245
  // Picked apart so that the memo below depends on values rather than on the
111
246
  // object literal a caller writes inline, which is new on every render.
112
- const { userSelectNone = true, cursor = 'grabbing' } = externalStyles ?? {}
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
+ }, [])
113
472
 
114
473
  const context = useMemo(
115
474
  () => ({
@@ -117,24 +476,60 @@ export const Root = forwardRef<HTMLDivElement, Props>(
117
476
  readonly,
118
477
  wheel,
119
478
  keyboard,
120
- externalStyles: { userSelectNone, cursor },
479
+ dragSensitivity,
480
+ externalStyles: { cursor },
121
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,
122
494
  }),
123
- [disabled, readonly, wheel, keyboard, userSelectNone, cursor],
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
+ ],
124
515
  )
125
516
 
126
517
  return (
127
518
  <PointsEditorProvider value={context}>
128
519
  <div
129
520
  ref={forwardedRef}
130
- className={clsx('tremolo-points-editor', className)}
131
- aria-disabled={disabled}
132
- aria-readonly={readonly}
133
- style={{
134
- width,
135
- height,
136
- ...style,
137
- }}
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
+ }
138
533
  {...props}
139
534
  >
140
535
  {children}
@@ -152,6 +547,7 @@ export const PointsEditor = {
152
547
  Background,
153
548
  Container,
154
549
  Point,
550
+ SelectionBox,
155
551
  }
156
552
 
157
553
  export {
@@ -160,4 +556,9 @@ export {
160
556
  } from './context'
161
557
  export { type PointsEditorBackgroundProps } from './Background'
162
558
  export { type PointsEditorContainerProps } from './Container'
163
- export { clampPoint, type PointBaseType, type PointProps } from './Point'
559
+ export { type PointsEditorSelectionBoxProps } from './SelectionBox'
560
+ export {
561
+ clampPoint,
562
+ type PointBaseType,
563
+ type PointsEditorPointProps,
564
+ } from './Point'