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