@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.
- package/dist/compose-refs.cjs +72 -0
- package/dist/compose-refs.cjs.map +1 -0
- package/dist/compose-refs.d.cts +43 -0
- package/dist/compose-refs.d.cts.map +1 -0
- package/dist/compose-refs.d.ts +43 -0
- package/dist/compose-refs.d.ts.map +1 -0
- package/dist/compose-refs.js +70 -0
- package/dist/compose-refs.js.map +1 -0
- package/dist/index.cjs +1669 -541
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1571 -486
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +1571 -486
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1640 -538
- package/dist/index.js.map +1 -1
- package/package.json +29 -27
- package/src/components/AnimationCanvas/index.tsx +115 -76
- package/src/components/DropZone/index.tsx +100 -0
- package/src/components/FileInput/Trigger.tsx +39 -0
- package/src/components/FileInput/context.tsx +24 -0
- package/src/components/FileInput/index.tsx +144 -0
- package/src/components/Knob/ActiveLine.tsx +7 -6
- package/src/components/Knob/InactiveLine.tsx +9 -10
- package/src/components/Knob/SVGRoot.tsx +5 -3
- package/src/components/Knob/Thumb.tsx +33 -11
- package/src/components/Knob/context.tsx +17 -1
- package/src/components/Knob/index.tsx +156 -60
- package/src/components/NumberInput/DecrementStepper.tsx +3 -10
- package/src/components/NumberInput/IncrementStepper.tsx +3 -10
- package/src/components/NumberInput/InputField.tsx +185 -108
- package/src/components/NumberInput/Stepper.tsx +120 -84
- package/src/components/NumberInput/StepperButton.tsx +100 -0
- package/src/components/NumberInput/context.tsx +34 -8
- package/src/components/NumberInput/index.tsx +270 -82
- package/src/components/Piano/index.tsx +363 -200
- package/src/components/PointsEditor/Background.tsx +13 -10
- package/src/components/PointsEditor/Container.tsx +83 -29
- package/src/components/PointsEditor/Point.tsx +241 -79
- package/src/components/PointsEditor/SelectionBox.tsx +48 -0
- package/src/components/PointsEditor/context.tsx +78 -10
- package/src/components/PointsEditor/index.tsx +440 -41
- package/src/components/Slider/Marks.tsx +48 -19
- package/src/components/Slider/MarksOption.tsx +64 -45
- package/src/components/Slider/Thumb.tsx +107 -55
- package/src/components/Slider/Track.tsx +66 -62
- package/src/components/Slider/context.tsx +4 -2
- package/src/components/Slider/index.tsx +136 -77
- package/src/components/Slider/type.ts +29 -9
- package/src/components/XYPad/Area.tsx +46 -41
- package/src/components/XYPad/Thumb.tsx +131 -64
- package/src/components/XYPad/context.tsx +4 -1
- package/src/components/XYPad/index.tsx +183 -53
- package/src/components/_util/Placement.tsx +68 -0
- package/src/components/_util/VisuallyHiddenRangeInput.tsx +12 -0
- package/src/components/_util/css-length.ts +13 -0
- package/src/components/_util/decimal-digits.ts +23 -0
- package/src/components/_util/visually-hidden.ts +25 -0
- package/src/{components/_util/composeRefs.tsx → compose-refs/index.tsx} +33 -5
- package/src/css-variables.ts +23 -0
- package/src/hooks/_internal/useCallbackRef.ts +3 -1
- package/src/hooks/_internal/useCheckSteps.ts +169 -0
- package/src/hooks/useAnimationFrame.ts +17 -7
- package/src/hooks/useDrag.ts +51 -12
- package/src/hooks/useDragValue.ts +71 -11
- package/src/hooks/useDropZone.ts +78 -0
- package/src/hooks/useEventListener.ts +10 -6
- package/src/hooks/useLongPress.ts +42 -8
- package/src/hooks/useMIDIAccess.ts +82 -11
- package/src/hooks/useMIDIInput.ts +45 -13
- package/src/hooks/useMIDIMessage.ts +23 -5
- package/src/hooks/useWheel.ts +4 -1
- package/src/index.ts +45 -25
- package/src/input-event.ts +32 -0
- package/dist/index.css +0 -501
- package/src/Globals.d.ts +0 -3
- package/src/components/Knob/index.css +0 -81
- package/src/components/NumberInput/index.css +0 -121
- package/src/components/NumberInput/stepperButton.tsx +0 -90
- package/src/components/Piano/index.css +0 -90
- package/src/components/PointsEditor/index.css +0 -35
- package/src/components/Slider/index.css +0 -125
- package/src/components/XYPad/index.css +0 -60
- package/src/components/_util/index.ts +0 -28
- package/src/styles/global.css +0 -19
- /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
|
|
3
|
+
import {
|
|
4
|
+
type InputEventOption,
|
|
5
|
+
type ModifierState,
|
|
6
|
+
type ModifierValue,
|
|
7
|
+
type SelectionBoxRect,
|
|
8
|
+
} from '@tremolo-ui/dom'
|
|
4
9
|
|
|
5
|
-
import {
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
32
|
-
*
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
-
*
|
|
39
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
51
|
-
cursor?: Cursor
|
|
116
|
+
cursor?: CSSProperties['cursor']
|
|
52
117
|
}
|
|
53
118
|
|
|
54
119
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
-
*
|
|
181
|
+
* @default false
|
|
59
182
|
*/
|
|
60
|
-
|
|
183
|
+
selectable?: boolean
|
|
184
|
+
|
|
61
185
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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`
|
|
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
|
-
|
|
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
|
|
95
|
-
height
|
|
225
|
+
width,
|
|
226
|
+
height,
|
|
96
227
|
disabled = false,
|
|
97
228
|
readonly = false,
|
|
98
|
-
wheel =
|
|
99
|
-
keyboard =
|
|
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 {
|
|
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
|
-
|
|
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
|
-
[
|
|
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={
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
style={
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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 {
|
|
162
|
-
|
|
163
|
-
|
|
558
|
+
export {
|
|
559
|
+
clampPoint,
|
|
560
|
+
type PointBaseType,
|
|
561
|
+
type PointsEditorPointProps,
|
|
562
|
+
} from './Point'
|