@tremolo-ui/react 0.6.0 → 0.8.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 (57) hide show
  1. package/dist/index.cjs +522 -997
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +601 -329
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.ts +601 -329
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +487 -953
  8. package/dist/index.js.map +1 -1
  9. package/package.json +4 -4
  10. package/src/components/DropZone/index.tsx +101 -0
  11. package/src/components/FileInput/Trigger.tsx +39 -0
  12. package/src/components/FileInput/context.tsx +24 -0
  13. package/src/components/FileInput/index.tsx +139 -0
  14. package/src/components/Knob/ActiveLine.tsx +5 -3
  15. package/src/components/Knob/InactiveLine.tsx +6 -4
  16. package/src/components/Knob/SVGRoot.tsx +3 -3
  17. package/src/components/Knob/Thumb.tsx +3 -2
  18. package/src/components/Knob/context.tsx +3 -74
  19. package/src/components/Knob/index.tsx +27 -21
  20. package/src/components/NumberInput/DecrementStepper.tsx +2 -13
  21. package/src/components/NumberInput/IncrementStepper.tsx +2 -14
  22. package/src/components/NumberInput/InputField.tsx +17 -38
  23. package/src/components/NumberInput/Stepper.tsx +44 -107
  24. package/src/components/NumberInput/StepperButton.tsx +16 -12
  25. package/src/components/NumberInput/context.tsx +8 -8
  26. package/src/components/NumberInput/index.tsx +41 -81
  27. package/src/components/Piano/index.tsx +39 -149
  28. package/src/components/PointsEditor/Background.tsx +2 -8
  29. package/src/components/PointsEditor/Container.tsx +17 -34
  30. package/src/components/PointsEditor/Point.tsx +41 -86
  31. package/src/components/PointsEditor/SelectionBox.tsx +2 -8
  32. package/src/components/PointsEditor/context.tsx +10 -61
  33. package/src/components/PointsEditor/index.tsx +31 -315
  34. package/src/components/Slider/Marks.tsx +8 -13
  35. package/src/components/Slider/MarksOption.tsx +11 -17
  36. package/src/components/Slider/Thumb.tsx +9 -15
  37. package/src/components/Slider/Track.tsx +7 -13
  38. package/src/components/Slider/index.tsx +33 -39
  39. package/src/components/XYPad/Area.tsx +26 -53
  40. package/src/components/XYPad/Thumb.tsx +8 -12
  41. package/src/components/XYPad/context.tsx +1 -3
  42. package/src/components/XYPad/index.tsx +35 -50
  43. package/src/components/_util/VisuallyHiddenRangeInput.tsx +3 -15
  44. package/src/css-variables.ts +23 -0
  45. package/src/hooks/_internal/useCheckSteps.ts +9 -147
  46. package/src/hooks/useDrag.ts +13 -4
  47. package/src/hooks/useDragValue.ts +17 -2
  48. package/src/hooks/useDropZone.ts +78 -0
  49. package/src/hooks/useLongPress.ts +35 -51
  50. package/src/hooks/useMIDIAccess.ts +0 -8
  51. package/src/hooks/useMIDIInput.ts +0 -3
  52. package/src/hooks/useWheel.ts +4 -1
  53. package/src/index.ts +14 -36
  54. package/src/components/Piano/keyboard-shortcuts.ts +0 -61
  55. package/src/components/Slider/type.ts +0 -44
  56. package/src/components/_util/css-length.ts +0 -13
  57. package/src/input-event.ts +0 -32
@@ -3,7 +3,6 @@ import {
3
3
  CSSProperties,
4
4
  forwardRef,
5
5
  ReactNode,
6
- useCallback,
7
6
  useEffect,
8
7
  useImperativeHandle,
9
8
  useMemo,
@@ -11,48 +10,20 @@ import {
11
10
  useState,
12
11
  } from 'react'
13
12
 
14
- import { createPianoInput, type PianoInputInstance } from '@tremolo-ui/dom'
15
13
  import {
16
14
  blackKeyWidth,
15
+ createPianoInput,
16
+ fitWhiteKeyWidth,
17
17
  getNoteRangeArray,
18
- isWhiteKey,
19
- noteKey,
18
+ type KeyboardShortcuts,
19
+ type KeyboardShortcutsScope,
20
20
  notePosition,
21
21
  pianoWidth,
22
22
  type NoteRange,
23
+ type PianoInputInstance,
23
24
  type PianoLayout,
24
- } from '@tremolo-ui/functions'
25
-
26
- import { useEventListener } from '../../hooks/useEventListener'
27
- import { cssLength } from '../_util/css-length'
28
-
29
- import { KeyboardShortcuts } from './keyboard-shortcuts'
30
-
31
- type KeyboardShortcutsScope = 'root' | 'window'
32
-
33
- function isEditableTarget(target: EventTarget | null) {
34
- if (!(target instanceof HTMLElement)) return false
35
- if (target.matches('input, textarea, select')) return true
36
-
37
- for (let element: HTMLElement | null = target; element;) {
38
- const contentEditable = element.getAttribute('contenteditable')
39
- if (contentEditable !== null)
40
- return contentEditable.toLowerCase() !== 'false'
41
- element = element.parentElement
42
- }
43
-
44
- return false
45
- }
46
-
47
- function shortcutKey(event: KeyboardEvent) {
48
- return event.code || event.key
49
- }
50
-
51
- /**
52
- * `style` that also takes CSS custom properties, which is how a key's colours
53
- * are set: see `index.css` for the ones each key type reads.
54
- */
55
- export type CSSVariables = Record<`--${string}`, string | number | undefined>
25
+ } from '@tremolo-ui/dom'
26
+ import { isWhiteKey, noteKey } from '@tremolo-ui/functions'
56
27
 
57
28
  /**
58
29
  * What {@link PianoProps.keyProps} may return for one key.
@@ -61,9 +32,8 @@ export type CSSVariables = Record<`--${string}`, string | number | undefined>
61
32
  * JSX syntax, not on an object type, and selecting on one is the usual way to
62
33
  * mark a key out.
63
34
  */
64
- export type KeyAttributes = Omit<ComponentPropsWithoutRef<'div'>, 'style'> & {
65
- style?: CSSProperties & CSSVariables
66
- } & Record<`data-${string}`, string | number | boolean | undefined>
35
+ export type KeyAttributes = ComponentPropsWithoutRef<'div'> &
36
+ Record<`data-${string}`, string | number | boolean | undefined>
67
37
 
68
38
  /** What a key is, when {@link PianoProps.label} or `keyProps` is asked about it. */
69
39
  export interface KeyState {
@@ -154,18 +124,9 @@ export interface PianoProps {
154
124
  */
155
125
  blackKeyHeightRatio?: number
156
126
 
157
- /**
158
- * Height of the keyboard. Sets `--height`; when it is omitted, the height
159
- * the theme gives stands, and the theme can tell `resizable` apart through
160
- * `data-resizable`.
161
- */
162
- height?: number | string
163
-
164
- style?: CSSProperties & CSSVariables
165
-
166
127
  /**
167
128
  * What to draw inside a key. `''`, `null` and `undefined` leave it bare, so
168
- * a layout with gaps — {@link SHORTCUTS.HOME_ROW_NATURAL}, say — needs no
129
+ * a layout with gaps — `SHORTCUTS.HOME_ROW_NATURAL`, say — needs no
169
130
  * special casing.
170
131
  */
171
132
  label?: (note: number, state: KeyState) => ReactNode
@@ -217,7 +178,6 @@ export const Root = /* @__PURE__ */ forwardRef<PianoMethods, Props>(
217
178
  keyGap = 1,
218
179
  blackKeyWidthRatio = 0.65,
219
180
  blackKeyHeightRatio = 0.6,
220
- height,
221
181
  style,
222
182
  className,
223
183
  label,
@@ -237,10 +197,6 @@ export const Root = /* @__PURE__ */ forwardRef<PianoMethods, Props>(
237
197
  const [resizedKeyWidth, setResizedKeyWidth] = useState(whiteKeyWidth)
238
198
 
239
199
  const notes = useMemo(() => getNoteRangeArray(noteRange), [noteRange])
240
- const whiteKeyCount = useMemo(
241
- () => notes.filter(isWhiteKey).length,
242
- [notes],
243
- )
244
200
 
245
201
  const layout: PianoLayout = useMemo(
246
202
  () => ({
@@ -267,21 +223,12 @@ export const Root = /* @__PURE__ */ forwardRef<PianoMethods, Props>(
267
223
  layout,
268
224
  glissando,
269
225
  midiMax,
226
+ keyboardShortcuts,
227
+ keyboardShortcutsScope,
270
228
  onPlayNote,
271
229
  onStopNote,
272
230
  })
273
231
  const instanceRef = useRef<PianoInputInstance | null>(null)
274
- const shortcutNotes = useRef(
275
- new Map<string, { note: number; source: string }>(),
276
- )
277
-
278
- const releaseShortcutNotes = useCallback(() => {
279
- for (const { note, source } of shortcutNotes.current.values()) {
280
- instanceRef.current?.noteOff(note, { source })
281
- }
282
- shortcutNotes.current.clear()
283
- }, [])
284
-
285
232
  useEffect(() => {
286
233
  if (!node) return
287
234
 
@@ -289,6 +236,8 @@ export const Root = /* @__PURE__ */ forwardRef<PianoMethods, Props>(
289
236
  layout: latest.current.layout,
290
237
  glissando: latest.current.glissando,
291
238
  midiMax: latest.current.midiMax,
239
+ keyboardShortcuts: latest.current.keyboardShortcuts,
240
+ keyboardShortcutsScope: latest.current.keyboardShortcutsScope,
292
241
  onPlayNote: (note, velocity) =>
293
242
  latest.current.onPlayNote?.(note, velocity),
294
243
  onStopNote: (note) => latest.current.onStopNote?.(note),
@@ -307,10 +256,27 @@ export const Root = /* @__PURE__ */ forwardRef<PianoMethods, Props>(
307
256
 
308
257
  // Runs after every render.
309
258
  useEffect(() => {
310
- latest.current = { layout, glissando, midiMax, onPlayNote, onStopNote }
311
- instanceRef.current?.update({ layout, glissando, midiMax })
259
+ latest.current = {
260
+ layout,
261
+ glissando,
262
+ midiMax,
263
+ keyboardShortcuts,
264
+ keyboardShortcutsScope,
265
+ onPlayNote,
266
+ onStopNote,
267
+ }
268
+ instanceRef.current?.update({
269
+ layout,
270
+ glissando,
271
+ midiMax,
272
+ keyboardShortcuts,
273
+ keyboardShortcutsScope,
274
+ })
312
275
  })
313
276
 
277
+ // By value, so that a range written inline does not reconnect the
278
+ // observer on every render.
279
+ const { first, last } = noteRange
314
280
  useEffect(() => {
315
281
  if (!resizable || !node) return
316
282
  const parent = node.parentElement
@@ -318,89 +284,16 @@ export const Root = /* @__PURE__ */ forwardRef<PianoMethods, Props>(
318
284
 
319
285
  const resizeObserver = new ResizeObserver(() => {
320
286
  setResizedKeyWidth(
321
- node.clientWidth / Math.max(whiteKeyCount, 1) - keyGap,
287
+ fitWhiteKeyWidth(node.clientWidth, {
288
+ noteRange: { first, last },
289
+ keyGap,
290
+ blackKeyWidthRatio,
291
+ }),
322
292
  )
323
293
  })
324
294
  resizeObserver.observe(parent)
325
295
  return () => resizeObserver.disconnect()
326
- }, [resizable, node, whiteKeyCount, keyGap])
327
-
328
- const shortcutKeys = keyboardShortcuts?.keys
329
- const hasShortcuts = shortcutKeys !== undefined
330
- // What the held keys were started against, as a value rather than the
331
- // identity of the array: a caller who writes the keys inline hands over a
332
- // new array on every render, and releasing on that would stop a note as
333
- // soon as anything else re-renders — including the state change that
334
- // playing the note caused.
335
- const shortcutMapping = shortcutKeys?.join('\u0000')
336
-
337
- useEffect(
338
- () => releaseShortcutNotes,
339
- [
340
- releaseShortcutNotes,
341
- shortcutMapping,
342
- keyboardShortcutsScope,
343
- noteRange.first,
344
- noteRange.last,
345
- ],
346
- )
347
-
348
- /** The note a shortcut key plays, or null when it has none. */
349
- function shortcutNote(key: string) {
350
- if (!shortcutKeys || key === '') return null
351
- const index = shortcutKeys.indexOf(key)
352
- const note = noteRange.first + index
353
- return index === -1 || note > noteRange.last ? null : note
354
- }
355
-
356
- const shortcutTarget = useMemo(
357
- () => () => {
358
- if (!hasShortcuts) return null
359
- return keyboardShortcutsScope === 'window' ? globalThis.window : node
360
- },
361
- [hasShortcuts, keyboardShortcutsScope, node],
362
- )
363
-
364
- const focusOutTarget = useMemo(
365
- () => () =>
366
- hasShortcuts && keyboardShortcutsScope === 'root' ? node : null,
367
- [hasShortcuts, keyboardShortcutsScope, node],
368
- )
369
-
370
- const windowBlurTarget = useMemo(
371
- () => () => (hasShortcuts ? globalThis.window : null),
372
- [hasShortcuts],
373
- )
374
-
375
- useEventListener(shortcutTarget, 'keydown', (e) => {
376
- const key = shortcutKey(e)
377
- if (e.repeat || shortcutNotes.current.has(key)) return
378
- if (isEditableTarget(e.target)) return
379
-
380
- const note = shortcutNote(e.key)
381
- if (note === null) return
382
-
383
- const source = `keyboard:${key}`
384
- shortcutNotes.current.set(key, { note, source })
385
- instanceRef.current?.noteOn(note, { source })
386
- })
387
-
388
- useEventListener(shortcutTarget, 'keyup', (e) => {
389
- const key = shortcutKey(e)
390
- const shortcut = shortcutNotes.current.get(key)
391
- if (!shortcut) return
392
-
393
- shortcutNotes.current.delete(key)
394
- instanceRef.current?.noteOff(shortcut.note, { source: shortcut.source })
395
- })
396
-
397
- useEventListener(focusOutTarget, 'focusout', (e) => {
398
- if (e.relatedTarget instanceof Node && node?.contains(e.relatedTarget))
399
- return
400
- releaseShortcutNotes()
401
- })
402
-
403
- useEventListener(windowBlurTarget, 'blur', releaseShortcutNotes)
296
+ }, [resizable, node, first, last, keyGap, blackKeyWidthRatio])
404
297
 
405
298
  useImperativeHandle(
406
299
  forwardedRef,
@@ -426,7 +319,6 @@ export const Root = /* @__PURE__ */ forwardRef<PianoMethods, Props>(
426
319
  {
427
320
  // Computed from the layout rather than chosen, so it stays inline.
428
321
  width: resizable ? '100%' : pianoWidth(layout),
429
- '--height': cssLength(height),
430
322
  // The keys inside are placed against this box.
431
323
  position: 'relative',
432
324
  ...style,
@@ -495,5 +387,3 @@ export const Root = /* @__PURE__ */ forwardRef<PianoMethods, Props>(
495
387
  * Customizable piano component.
496
388
  */
497
389
  export const Piano = { Root }
498
-
499
- export { type KeyboardShortcuts, SHORTCUTS } from './keyboard-shortcuts'
@@ -1,17 +1,11 @@
1
- import { ComponentPropsWithoutRef, ReactNode } from 'react'
2
-
3
- export interface PointsEditorBackgroundProps {
4
- /** Whatever the points are placed over: a graph, a canvas, an image. */
5
- children?: ReactNode
6
- }
1
+ import { ComponentPropsWithoutRef } from 'react'
7
2
 
8
3
  export function Background({
9
4
  className,
10
5
  children,
11
6
  style,
12
7
  ...props
13
- }: PointsEditorBackgroundProps &
14
- Omit<ComponentPropsWithoutRef<'div'>, keyof PointsEditorBackgroundProps>) {
8
+ }: ComponentPropsWithoutRef<'div'>) {
15
9
  return (
16
10
  <div
17
11
  className={className}
@@ -1,4 +1,6 @@
1
- import { ComponentPropsWithoutRef, forwardRef, ReactNode } from 'react'
1
+ import { ComponentPropsWithoutRef, forwardRef } from 'react'
2
+
3
+ import { POINT_AXIS, wheelMove } from '@tremolo-ui/dom'
2
4
 
3
5
  import { useComposedRefs } from '../../compose-refs'
4
6
  import { useDragValue } from '../../hooks/useDragValue'
@@ -6,41 +8,26 @@ import { useWheel } from '../../hooks/useWheel'
6
8
  import { Placement } from '../_util/Placement'
7
9
 
8
10
  import { usePointsEditorContext } from './context'
9
- import { AXIS } from './Point'
10
-
11
- export interface PointsEditorContainerProps {
12
- /** `<PointsEditor.Point />` goes here. */
13
- children?: ReactNode
14
- }
15
11
 
16
- type Props = PointsEditorContainerProps &
17
- Omit<ComponentPropsWithoutRef<'div'>, keyof PointsEditorContainerProps>
12
+ type Props = ComponentPropsWithoutRef<'div'>
18
13
 
19
14
  export const Container = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
20
15
  function Container({ children, className, style, ...props }, forwardedRef) {
21
- const {
22
- containerRef,
23
- disabled,
24
- selectable,
25
- isPointElement,
26
- nudgeFocusedPoint,
27
- beginSelectionBox,
28
- moveSelectionBox,
29
- endSelectionBox,
30
- } = usePointsEditorContext()
16
+ const { containerRef, disabled, selectable, editor } =
17
+ usePointsEditorContext()
31
18
  const { refCallback: dragRefCallback } = useDragValue<HTMLDivElement>({
32
- axis: AXIS,
19
+ axis: POINT_AXIS,
33
20
  baseElementRef: containerRef,
34
21
  // A press that landed on a point belongs to that point. Declining here
35
22
  // rather than in onDragStart matters: by then the container would already
36
23
  // have taken the pointer capture away from the point.
37
24
  shouldStart: (event) =>
38
- !disabled && !isPointElement(event.target as Element | null),
25
+ !disabled && !editor.isPointElement(event.target as Element | null),
39
26
  onDragStart: ([x, y], state) => {
40
- beginSelectionBox({ x, y }, state.event)
27
+ editor.beginSelectionBox({ x, y }, state.event)
41
28
  },
42
- onChange: ([x, y]) => moveSelectionBox({ x, y }),
43
- onDragEnd: endSelectionBox,
29
+ onChange: ([x, y]) => editor.moveSelectionBox({ x, y }),
30
+ onDragEnd: () => editor.endSelectionBox(),
44
31
  })
45
32
 
46
33
  // One listener for the whole editor rather than one per point: a wheel
@@ -50,16 +37,12 @@ export const Container = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
50
37
  useWheel(
51
38
  (event) => {
52
39
  // Scrolling up moves the point towards y = 0; shift switches to x.
53
- // Browsers turn shift+wheel into horizontal scrolling: `deltaY` comes
54
- // out empty and `deltaX` carries the movement. Reading whichever axis
55
- // moved keeps shift working as the x-axis modifier — and picks up a
56
- // trackpad's own horizontal gesture, which never had a modifier.
57
- const horizontal = event.deltaX !== 0
58
- const delta = horizontal ? event.deltaX : event.deltaY
59
- if (delta === 0) return
60
- const axis = horizontal || event.shiftKey ? 'x' : 'y'
61
- const direction = delta < 0 ? -1 : 1
62
- if (nudgeFocusedPoint(axis, direction, event)) event.preventDefault()
40
+ const move = wheelMove(event)
41
+ if (!move) return
42
+ const axis = move.axis === 0 ? 'x' : 'y'
43
+ if (editor.nudgeFocusedPoint(axis, move.direction, event)) {
44
+ event.preventDefault()
45
+ }
63
46
  },
64
47
  { target: containerRef },
65
48
  )
@@ -1,8 +1,6 @@
1
1
  import {
2
2
  ComponentPropsWithoutRef,
3
3
  CSSProperties,
4
- ReactNode,
5
- useCallback,
6
4
  useEffect,
7
5
  useId,
8
6
  useRef,
@@ -10,43 +8,31 @@ import {
10
8
  } from 'react'
11
9
 
12
10
  import {
13
- applyDelta,
14
- clamp,
11
+ arrowKeyMove,
12
+ clampPoint,
15
13
  type InputEventOption,
16
- type ModifierState,
17
14
  type ModifierValue,
15
+ POINT_AXIS,
16
+ type PointPosition,
17
+ type PointsEditorPoint,
18
18
  selectModifier,
19
- } from '@tremolo-ui/functions'
19
+ } from '@tremolo-ui/dom'
20
20
 
21
21
  import { useComposedRefs } from '../../compose-refs'
22
22
  import { useDragValue } from '../../hooks/useDragValue'
23
- import { cssLength } from '../_util/css-length'
24
23
  import { useCheckPlacement } from '../_util/Placement'
25
24
  import { VisuallyHiddenRangeInput } from '../_util/VisuallyHiddenRangeInput'
26
25
 
27
- import { type PointRegistration, usePointsEditorContext } from './context'
26
+ import { usePointsEditorContext } from './context'
28
27
 
29
- export type PointBaseType = { x: number; y: number }
28
+ import type { CSSVariables } from '../../css-variables'
30
29
 
31
- export function clampPoint(
32
- point: PointBaseType,
33
- min?: Partial<PointBaseType>,
34
- max?: Partial<PointBaseType>,
35
- ) {
36
- const { x, y } = point
37
- const newX = clamp(x, min?.x ?? 0, max?.x ?? 1)
38
- const newY = clamp(y, min?.y ?? 0, max?.y ?? 1)
39
- return { x: newX, y: newY }
40
- }
41
-
42
- export interface PointsEditorPointProps<T extends PointBaseType> {
30
+ export interface PointsEditorPointProps<T extends PointPosition> {
43
31
  /**
44
32
  * Where the point is, as `{ x, y }` from 0 to 1 on each axis, with `y`
45
33
  * growing downwards.
46
34
  */
47
35
  value: T
48
- /** Drawn inside the point: the theme's own dot stands when it is left out. */
49
- children?: ReactNode
50
36
  /**
51
37
  * How the selection refers to this point. One is generated when it is left
52
38
  * out, which lasts as long as the point is mounted — give your own if the
@@ -54,16 +40,10 @@ export interface PointsEditorPointProps<T extends PointBaseType> {
54
40
  */
55
41
  id?: string
56
42
  /** The lowest position the point can take, per axis. An axis left out is 0. */
57
- min?: Partial<PointBaseType>
43
+ min?: Partial<PointPosition>
58
44
  /** The highest position the point can take, per axis. An axis left out is 1. */
59
- max?: Partial<PointBaseType>
45
+ max?: Partial<PointPosition>
60
46
 
61
- /** Width and height at once. Sets both `--width` and `--height`. */
62
- size?: number | string
63
- /** Sets `--width`; the size the theme gives it stands when omitted. */
64
- width?: number | string
65
- /** Sets `--height`; the size the theme gives it stands when omitted. */
66
- height?: number | string
67
47
  /** Sets `--color`, for the theme to colour the point with. */
68
48
  color?: string
69
49
 
@@ -89,18 +69,14 @@ export interface PointsEditorPointProps<T extends PointBaseType> {
89
69
  * Called with the new position when the point is dragged or moved by the
90
70
  * arrow keys or the wheel, including when it moves along with a selection.
91
71
  */
92
- onChange?: (value: PointBaseType) => void
72
+ onChange?: (value: PointPosition) => void
93
73
  /** Called when a drag on this point starts, with where the point is. */
94
- onDragStart?: (value: PointBaseType) => void
74
+ onDragStart?: (value: PointPosition) => void
95
75
  /** Called when that drag ends, with where the point is. */
96
- onDragEnd?: (value: PointBaseType) => void
97
- }
76
+ onDragEnd?: (value: PointPosition) => void
98
77
 
99
- /**
100
- * A point is placed by its position within the container, so its value is a
101
- * position: 0..1 on each axis, with y growing downwards.
102
- */
103
- export const AXIS = { min: 0, max: 1 }
78
+ style?: CSSProperties & CSSVariables<'color' | 'translate'>
79
+ }
104
80
 
105
81
  /**
106
82
  * One setting for both axes, or one per axis. The point holds a range input
@@ -113,15 +89,12 @@ function perAxis(
113
89
  return typeof value === 'string' ? value : value?.[axis]
114
90
  }
115
91
 
116
- export function Point<T extends PointBaseType>({
92
+ export function Point<T extends PointPosition>({
117
93
  value,
118
94
  children,
119
95
  id: idProp,
120
96
  min,
121
97
  max,
122
- size,
123
- width,
124
- height,
125
98
  color,
126
99
 
127
100
  disabled: _disabled,
@@ -152,10 +125,7 @@ export function Point<T extends PointBaseType>({
152
125
  keyboard: rootKeyboard,
153
126
  dragSensitivity,
154
127
  selection,
155
- registerPoint,
156
- beginPointDrag,
157
- movePointDrag,
158
- nudgeSelection,
128
+ editor,
159
129
  } = usePointsEditorContext()
160
130
 
161
131
  const generatedId = useId()
@@ -180,7 +150,7 @@ export function Point<T extends PointBaseType>({
180
150
  // What the editor needs to move this point along with the rest of a
181
151
  // selection. Rewritten after every render rather than kept in the registry
182
152
  // itself: the value changes on every frame of a drag.
183
- const registration = useRef<PointRegistration>({
153
+ const registration = useRef<PointsEditorPoint>({
184
154
  value,
185
155
  min,
186
156
  max,
@@ -201,15 +171,18 @@ export function Point<T extends PointBaseType>({
201
171
  }
202
172
  })
203
173
 
204
- useEffect(() => registerPoint(id, registration), [id, registerPoint])
174
+ useEffect(
175
+ () => editor.registerPoint(id, () => registration.current),
176
+ [id, editor],
177
+ )
205
178
 
206
179
  /** Where the pointer was when the drag started, to measure the move from. */
207
- const pointerOrigin = useRef<PointBaseType | null>(null)
180
+ const pointerOrigin = useRef<PointPosition | null>(null)
208
181
 
209
182
  // The value is the position itself: no scaling, and no rounding to a step.
210
183
  const { refCallback: dragRefCallback, dragging } =
211
184
  useDragValue<HTMLDivElement>({
212
- axis: AXIS,
185
+ axis: POINT_AXIS,
213
186
  baseElementRef: containerRef,
214
187
  sensitivity: (state) =>
215
188
  selectModifier(dragSensitivity, state.event).value,
@@ -221,13 +194,13 @@ export function Point<T extends PointBaseType>({
221
194
  onChange: ([x, y]) => {
222
195
  const origin = pointerOrigin.current
223
196
  if (!origin) return
224
- movePointDrag({ x: x - origin.x, y: y - origin.y })
197
+ editor.movePointDrag({ x: x - origin.x, y: y - origin.y })
225
198
  },
226
199
  onDragStart: ([x, y], state) => {
227
200
  // The press decides the selection before the snapshot is taken, so it
228
201
  // happens here rather than in an onPointerDown: a native listener on
229
202
  // the element runs before React's, and the two would disagree.
230
- beginPointDrag(id, state.event)
203
+ editor.beginPointDrag(id, state.event)
231
204
  pointerOrigin.current = { x, y }
232
205
  xInputRef.current?.focus()
233
206
 
@@ -242,43 +215,27 @@ export function Point<T extends PointBaseType>({
242
215
  },
243
216
  })
244
217
 
245
- const nudge = useCallback(
246
- (
247
- axis: 'x' | 'y',
248
- direction: number,
249
- option: ModifierValue<InputEventOption>,
250
- modifiers: ModifierState,
251
- ) => {
252
- const next = applyDelta(value[axis], direction, option, AXIS, modifiers)
253
- // As a move, so that the rest of the selection comes along and the whole
254
- // group stops together at the edge.
255
- nudgeSelection(id, {
256
- x: axis === 'x' ? next - value.x : 0,
257
- y: axis === 'y' ? next - value.y : 0,
258
- })
259
- },
260
- [value, id, nudgeSelection],
261
- )
262
-
263
218
  const refCallback = useComposedRefs<HTMLDivElement>(
264
219
  dragRefCallback,
265
220
  setElement,
266
221
  )
267
222
 
268
223
  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
269
- const key = event.key
270
- if (!['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(key))
271
- return
272
- // The key picks the axis, whichever of the two inputs holds the focus: a
273
- // point is one control to the person moving it, and the focus lands on the
274
- // x input, so reading the axis off the input would leave the y axis with
275
- // no keys at all.
276
- const axis = key === 'ArrowRight' || key === 'ArrowLeft' ? 'x' : 'y'
224
+ // The key picks the axis, whichever of the two inputs holds the focus:
225
+ // the focus lands on the x input, so reading the axis off the input would
226
+ // leave the y axis with no keys at all. y grows downwards, so ArrowUp
227
+ // moves the point towards 0.
228
+ const move = arrowKeyMove(event.key)
229
+ if (!move) return
277
230
  event.preventDefault()
278
231
  if (!onChange || inactive || !keyboard) return
279
- // y grows downwards, so ArrowUp moves the point towards 0.
280
- const direction = key === 'ArrowLeft' || key === 'ArrowUp' ? -1 : 1
281
- nudge(axis, direction, keyboard, event)
232
+ editor.nudgePoint(
233
+ id,
234
+ move.axis === 0 ? 'x' : 'y',
235
+ move.direction,
236
+ keyboard,
237
+ event,
238
+ )
282
239
  }
283
240
 
284
241
  const current = clampPoint(value, min, max)
@@ -302,8 +259,6 @@ export function Point<T extends PointBaseType>({
302
259
  style={
303
260
  {
304
261
  '--color': color,
305
- '--width': cssLength(size ?? width),
306
- '--height': cssLength(size ?? height),
307
262
  // The mechanics of the position below: a point is placed by its
308
263
  // position in the container, measured from its own centre.
309
264
  position: 'absolute',
@@ -349,7 +304,7 @@ export function Point<T extends PointBaseType>({
349
304
  return
350
305
  }
351
306
  const delta = event.currentTarget.valueAsNumber - value[axis]
352
- nudgeSelection(id, {
307
+ editor.nudgeSelection(id, {
353
308
  x: axis === 'x' ? delta : 0,
354
309
  y: axis === 'y' ? delta : 0,
355
310
  })
@@ -1,14 +1,9 @@
1
- import { ComponentPropsWithoutRef, CSSProperties, ReactNode } from 'react'
1
+ import { ComponentPropsWithoutRef, CSSProperties } from 'react'
2
2
 
3
3
  import { useCheckPlacement } from '../_util/Placement'
4
4
 
5
5
  import { usePointsEditorContext } from './context'
6
6
 
7
- export interface PointsEditorSelectionBoxProps {
8
- /** Drawn inside the box, which is only there while a drag is running. */
9
- children?: ReactNode
10
- }
11
-
12
7
  /**
13
8
  * The box a drag on empty space draws, and what it covers is selected.
14
9
  *
@@ -20,8 +15,7 @@ export function SelectionBox({
20
15
  className,
21
16
  style,
22
17
  ...props
23
- }: PointsEditorSelectionBoxProps &
24
- Omit<ComponentPropsWithoutRef<'div'>, keyof PointsEditorSelectionBoxProps>) {
18
+ }: ComponentPropsWithoutRef<'div'>) {
25
19
  useCheckPlacement('PointsEditor.SelectionBox', 'PointsEditor.Container')
26
20
 
27
21
  const selectionBox = usePointsEditorContext((s) => s.selectionBox)