@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.
- package/dist/index.cjs +522 -997
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +601 -329
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +601 -329
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +487 -953
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/components/DropZone/index.tsx +101 -0
- package/src/components/FileInput/Trigger.tsx +39 -0
- package/src/components/FileInput/context.tsx +24 -0
- package/src/components/FileInput/index.tsx +139 -0
- package/src/components/Knob/ActiveLine.tsx +5 -3
- package/src/components/Knob/InactiveLine.tsx +6 -4
- package/src/components/Knob/SVGRoot.tsx +3 -3
- package/src/components/Knob/Thumb.tsx +3 -2
- package/src/components/Knob/context.tsx +3 -74
- package/src/components/Knob/index.tsx +27 -21
- package/src/components/NumberInput/DecrementStepper.tsx +2 -13
- package/src/components/NumberInput/IncrementStepper.tsx +2 -14
- package/src/components/NumberInput/InputField.tsx +17 -38
- package/src/components/NumberInput/Stepper.tsx +44 -107
- package/src/components/NumberInput/StepperButton.tsx +16 -12
- package/src/components/NumberInput/context.tsx +8 -8
- package/src/components/NumberInput/index.tsx +41 -81
- package/src/components/Piano/index.tsx +39 -149
- package/src/components/PointsEditor/Background.tsx +2 -8
- package/src/components/PointsEditor/Container.tsx +17 -34
- package/src/components/PointsEditor/Point.tsx +41 -86
- package/src/components/PointsEditor/SelectionBox.tsx +2 -8
- package/src/components/PointsEditor/context.tsx +10 -61
- package/src/components/PointsEditor/index.tsx +31 -315
- package/src/components/Slider/Marks.tsx +8 -13
- package/src/components/Slider/MarksOption.tsx +11 -17
- package/src/components/Slider/Thumb.tsx +9 -15
- package/src/components/Slider/Track.tsx +7 -13
- package/src/components/Slider/index.tsx +33 -39
- package/src/components/XYPad/Area.tsx +26 -53
- package/src/components/XYPad/Thumb.tsx +8 -12
- package/src/components/XYPad/context.tsx +1 -3
- package/src/components/XYPad/index.tsx +35 -50
- package/src/components/_util/VisuallyHiddenRangeInput.tsx +3 -15
- package/src/css-variables.ts +23 -0
- package/src/hooks/_internal/useCheckSteps.ts +9 -147
- package/src/hooks/useDrag.ts +13 -4
- package/src/hooks/useDragValue.ts +17 -2
- package/src/hooks/useDropZone.ts +78 -0
- package/src/hooks/useLongPress.ts +35 -51
- package/src/hooks/useMIDIAccess.ts +0 -8
- package/src/hooks/useMIDIInput.ts +0 -3
- package/src/hooks/useWheel.ts +4 -1
- package/src/index.ts +14 -36
- package/src/components/Piano/keyboard-shortcuts.ts +0 -61
- package/src/components/Slider/type.ts +0 -44
- package/src/components/_util/css-length.ts +0 -13
- 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
|
-
|
|
19
|
-
|
|
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/
|
|
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 =
|
|
65
|
-
|
|
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 —
|
|
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 = {
|
|
311
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
}:
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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/
|
|
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 {
|
|
26
|
+
import { usePointsEditorContext } from './context'
|
|
28
27
|
|
|
29
|
-
|
|
28
|
+
import type { CSSVariables } from '../../css-variables'
|
|
30
29
|
|
|
31
|
-
export
|
|
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<
|
|
43
|
+
min?: Partial<PointPosition>
|
|
58
44
|
/** The highest position the point can take, per axis. An axis left out is 1. */
|
|
59
|
-
max?: Partial<
|
|
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:
|
|
72
|
+
onChange?: (value: PointPosition) => void
|
|
93
73
|
/** Called when a drag on this point starts, with where the point is. */
|
|
94
|
-
onDragStart?: (value:
|
|
74
|
+
onDragStart?: (value: PointPosition) => void
|
|
95
75
|
/** Called when that drag ends, with where the point is. */
|
|
96
|
-
onDragEnd?: (value:
|
|
97
|
-
}
|
|
76
|
+
onDragEnd?: (value: PointPosition) => void
|
|
98
77
|
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
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
|
-
|
|
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<
|
|
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(
|
|
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<
|
|
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:
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
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
|
-
}:
|
|
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)
|