@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.
Files changed (90) 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 +2272 -1650
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +1461 -475
  12. package/dist/index.d.cts.map +1 -1
  13. package/dist/index.d.ts +1461 -475
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2258 -1648
  16. package/dist/index.js.map +1 -1
  17. package/package.json +32 -31
  18. package/src/components/AnimationCanvas/index.tsx +162 -194
  19. package/src/components/Knob/ActiveLine.tsx +9 -8
  20. package/src/components/Knob/InactiveLine.tsx +11 -14
  21. package/src/components/Knob/SVGRoot.tsx +10 -17
  22. package/src/components/Knob/Thumb.tsx +33 -11
  23. package/src/components/Knob/context.tsx +73 -89
  24. package/src/components/Knob/index.tsx +227 -105
  25. package/src/components/NumberInput/DecrementStepper.tsx +21 -50
  26. package/src/components/NumberInput/IncrementStepper.tsx +22 -50
  27. package/src/components/NumberInput/InputField.tsx +206 -0
  28. package/src/components/NumberInput/Stepper.tsx +150 -21
  29. package/src/components/NumberInput/StepperButton.tsx +95 -0
  30. package/src/components/NumberInput/context.tsx +104 -149
  31. package/src/components/NumberInput/index.tsx +461 -110
  32. package/src/components/Piano/index.tsx +413 -263
  33. package/src/components/Piano/keyboard-shortcuts.ts +61 -0
  34. package/src/components/PointsEditor/Background.tsx +19 -4
  35. package/src/components/PointsEditor/Container.tsx +92 -22
  36. package/src/components/PointsEditor/Point.tsx +293 -53
  37. package/src/components/PointsEditor/SelectionBox.tsx +54 -0
  38. package/src/components/PointsEditor/context.tsx +97 -75
  39. package/src/components/PointsEditor/index.tsx +497 -62
  40. package/src/components/Slider/Marks.tsx +84 -0
  41. package/src/components/Slider/MarksOption.tsx +115 -0
  42. package/src/components/Slider/Thumb.tsx +99 -54
  43. package/src/components/Slider/Track.tsx +73 -62
  44. package/src/components/Slider/context.tsx +40 -53
  45. package/src/components/Slider/index.tsx +237 -190
  46. package/src/components/Slider/type.ts +27 -9
  47. package/src/components/XYPad/Area.tsx +52 -33
  48. package/src/components/XYPad/Thumb.tsx +121 -60
  49. package/src/components/XYPad/context.tsx +49 -0
  50. package/src/components/XYPad/index.tsx +338 -246
  51. package/src/components/_util/Placement.tsx +68 -0
  52. package/src/components/_util/VisuallyHiddenRangeInput.tsx +24 -0
  53. package/src/components/_util/css-length.ts +13 -0
  54. package/src/{components/_util/composeRefs.tsx → compose-refs/index.tsx} +33 -5
  55. package/src/hooks/{useCallbackRef.ts → _internal/useCallbackRef.ts} +3 -1
  56. package/src/hooks/_internal/useCheckSteps.ts +170 -0
  57. package/src/hooks/useAnimationFrame.ts +17 -7
  58. package/src/hooks/useDrag.ts +39 -9
  59. package/src/hooks/useDragValue.ts +194 -0
  60. package/src/hooks/useEventListener.ts +11 -7
  61. package/src/hooks/useLongPress.ts +42 -8
  62. package/src/hooks/useMIDIAccess.ts +82 -11
  63. package/src/hooks/useMIDIInput.ts +45 -13
  64. package/src/hooks/useMIDIMessage.ts +23 -5
  65. package/src/hooks/useWheel.ts +29 -9
  66. package/src/index.ts +50 -27
  67. package/src/input-event.ts +32 -0
  68. package/dist/index.css +0 -536
  69. package/src/Globals.d.ts +0 -3
  70. package/src/components/AnimationCanvas/canvas.ts +0 -46
  71. package/src/components/Knob/index.css +0 -77
  72. package/src/components/NumberInput/InternalInput.tsx +0 -216
  73. package/src/components/NumberInput/index.css +0 -185
  74. package/src/components/NumberInput/type.ts +0 -58
  75. package/src/components/Piano/KeyLabel.tsx +0 -51
  76. package/src/components/Piano/context.tsx +0 -73
  77. package/src/components/Piano/index.css +0 -90
  78. package/src/components/Piano/key.tsx +0 -205
  79. package/src/components/Piano/keyboardShortcuts.ts +0 -31
  80. package/src/components/PointsEditor/index.css +0 -30
  81. package/src/components/Slider/Scale.tsx +0 -52
  82. package/src/components/Slider/ScaleOption.tsx +0 -103
  83. package/src/components/Slider/index.css +0 -112
  84. package/src/components/XYPad/index.css +0 -48
  85. package/src/components/_util/index.ts +0 -28
  86. package/src/components/_util/type.ts +0 -1
  87. package/src/hooks/useDragWithElement.ts +0 -100
  88. package/src/hooks/usePianoDrag.ts +0 -75
  89. package/src/hooks/useRefCallbackEvent.ts +0 -34
  90. package/src/styles/global.css +0 -19
@@ -1,88 +1,238 @@
1
- import clsx from 'clsx'
2
- import { ComponentPropsWithoutRef, forwardRef } from 'react'
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 { 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'
5
28
 
6
- import { Cursor } from '../_util'
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
- import { useState } from 'react'
22
-
23
- export default function App() {
24
- const [points, setPoints] = useState(initialPoints)
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
- const defaultExternalStyles: PointsEditorProps['externalStyles'] = {
42
- userSelectNone: true,
43
- cursor: 'grabbing',
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
- // TODO
51
- grid?: number | PointBaseType
52
- // modifier?:
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
- userSelectNone?: boolean
59
- cursor?: Cursor
117
+ cursor?: CSSProperties['cursor']
60
118
  }
61
119
 
62
- // TODO
63
120
  /**
64
- * wheel control option
65
- * 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.
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
- wheel?: InputEventOption | null
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
- * keyboard control option
70
- * If null, no event will be triggered
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
- keyboard?: InputEventOption | null
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 = 200,
82
- height = 100,
224
+ width,
225
+ height,
83
226
  disabled = false,
84
227
  readonly = false,
85
- externalStyles: _externalStyles,
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 externalStyles = { ...defaultExternalStyles, ..._externalStyles }
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={clsx('tremolo-points-editor', className)}
104
- style={{
105
- width,
106
- height,
107
- ...style,
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 { usePointsEditorContext } from './context'
129
- export { clampPoint, type PointBaseType, type PointProps } from './Point'
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
+ }