@tremolo-ui/dom 0.7.0 → 0.9.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 +343 -595
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +230 -284
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +230 -284
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +325 -572
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs +530 -0
- package/dist/internal.cjs.map +1 -0
- package/dist/internal.d.cts +365 -0
- package/dist/internal.d.cts.map +1 -0
- package/dist/internal.d.ts +365 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +500 -0
- package/dist/internal.js.map +1 -0
- package/dist/marks-DK7xJmGM.d.cts +305 -0
- package/dist/marks-DK7xJmGM.d.cts.map +1 -0
- package/dist/marks-DK7xJmGM.d.ts +305 -0
- package/dist/marks-DK7xJmGM.d.ts.map +1 -0
- package/dist/xy-5Oc6JeJr.cjs +952 -0
- package/dist/xy-5Oc6JeJr.cjs.map +1 -0
- package/dist/xy-lNFl1CTO.js +827 -0
- package/dist/xy-lNFl1CTO.js.map +1 -0
- package/package.json +12 -2
- package/src/canvas/animation.ts +8 -0
- package/src/canvas/context.ts +0 -5
- package/src/file/accept.ts +17 -0
- package/src/file/drop-zone.ts +8 -10
- package/src/index.ts +44 -32
- package/src/input/change-gesture.ts +104 -0
- package/src/input/check-steps.ts +144 -0
- package/src/input/defaults.ts +32 -0
- package/src/input/direction.ts +107 -0
- package/src/internal.ts +51 -0
- package/src/knob/geometry.ts +100 -0
- package/src/midi/access.ts +16 -15
- package/src/midi/input.ts +2 -9
- package/src/number-input/stepper-drag.ts +147 -0
- package/src/number-input/text.ts +100 -0
- package/src/number-input/value.ts +129 -0
- package/src/options/replace.ts +22 -0
- package/src/piano/index.ts +128 -2
- package/src/piano/layout.ts +19 -0
- package/src/piano/shortcuts.ts +86 -0
- package/src/pointer/drag-value.ts +3 -1
- package/src/pointer/long-press.ts +103 -0
- package/src/pointer/wheel.ts +10 -7
- package/src/points-editor/index.ts +367 -0
- package/src/position.ts +20 -0
- package/src/slider/decimal-digits.ts +23 -0
- package/src/slider/marks.ts +56 -0
- package/src/style.ts +41 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type InputEventOption, type ModifierValue } from './modifiers'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The keyboard amount used by Knob, NumberInput, Slider, and XYPad by default:
|
|
5
|
+
* 1 per press in the units of the value, and 0.1 with shift held. That is one
|
|
6
|
+
* `step` only while `step` is 1; a coarser `step` rounds 1 straight back, which
|
|
7
|
+
* `checkSteps` warns about.
|
|
8
|
+
*
|
|
9
|
+
* A modifier entry is not snapped to `step`, which is what lets the finer
|
|
10
|
+
* amount move at all — see `applyDelta`.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_KEYBOARD_OPTIONS: ModifierValue<InputEventOption> = {
|
|
13
|
+
default: ['raw', 1],
|
|
14
|
+
shift: ['raw', 0.1],
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The wheel amount used by Knob, NumberInput, Slider, and XYPad by default.
|
|
19
|
+
*
|
|
20
|
+
* Browsers turn shift+wheel into horizontal scrolling, which empties `deltaY`
|
|
21
|
+
* and fills `deltaX`, so no modifier is bound here.
|
|
22
|
+
*/
|
|
23
|
+
export const DEFAULT_WHEEL_OPTIONS: ModifierValue<InputEventOption> = ['raw', 1]
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The drag sensitivity used by value controls by default. Shift makes the
|
|
27
|
+
* same movement cover a tenth of the range, matching the arrow keys.
|
|
28
|
+
*/
|
|
29
|
+
export const DEFAULT_DRAG_SENSITIVITY: ModifierValue<number> = {
|
|
30
|
+
default: 1,
|
|
31
|
+
shift: 0.1,
|
|
32
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which way an input moves a value, before any amount is applied.
|
|
3
|
+
*
|
|
4
|
+
* `applyDelta` takes the direction as given, since which key or which sign of
|
|
5
|
+
* `deltaY` counts as "up" differs per control. The answers are collected here
|
|
6
|
+
* so that every wrapper gives the same one: a knob that turns the other way
|
|
7
|
+
* in one framework would be a bug nobody could see from the code.
|
|
8
|
+
*
|
|
9
|
+
* Two families, by what the control looks like:
|
|
10
|
+
*
|
|
11
|
+
* - **one value** (`Knob`, `Slider`, `NumberInput`): right and up raise it
|
|
12
|
+
* - **a position on screen** (`XYPad`, `PointsEditor`): in screen coordinates,
|
|
13
|
+
* x growing rightwards and y growing downwards, plus which axis moves
|
|
14
|
+
*
|
|
15
|
+
* A control that runs the other way (`reverse`) flips the result itself.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** One of the four arrow keys, as `KeyboardEvent.key` names it. */
|
|
19
|
+
export type ArrowKey = 'ArrowRight' | 'ArrowLeft' | 'ArrowUp' | 'ArrowDown'
|
|
20
|
+
|
|
21
|
+
/** Is `key` one of the four arrow keys? */
|
|
22
|
+
export function isArrowKey(key: string): key is ArrowKey {
|
|
23
|
+
return (
|
|
24
|
+
key === 'ArrowRight' ||
|
|
25
|
+
key === 'ArrowLeft' ||
|
|
26
|
+
key === 'ArrowUp' ||
|
|
27
|
+
key === 'ArrowDown'
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A move along one of the two axes of a position on screen. */
|
|
32
|
+
export interface AxisMove {
|
|
33
|
+
/** 0 = x, 1 = y. */
|
|
34
|
+
axis: 0 | 1
|
|
35
|
+
/** `1` towards the right or the bottom, `-1` towards the left or the top. */
|
|
36
|
+
direction: 1 | -1
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The direction an arrow key moves a single value: right and up raise it.
|
|
41
|
+
* `null` for any other key.
|
|
42
|
+
*/
|
|
43
|
+
export function arrowKeyDirection(key: string): 1 | -1 | null {
|
|
44
|
+
if (!isArrowKey(key)) return null
|
|
45
|
+
return key === 'ArrowRight' || key === 'ArrowUp' ? 1 : -1
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The axis and direction an arrow key moves a position on screen. `null` for
|
|
50
|
+
* any other key.
|
|
51
|
+
*
|
|
52
|
+
* The key picks the axis, whichever element inside the control holds the
|
|
53
|
+
* focus: a two-dimensional control is one control to the person moving it.
|
|
54
|
+
*/
|
|
55
|
+
export function arrowKeyMove(key: string): AxisMove | null {
|
|
56
|
+
if (!isArrowKey(key)) return null
|
|
57
|
+
return {
|
|
58
|
+
axis: key === 'ArrowRight' || key === 'ArrowLeft' ? 0 : 1,
|
|
59
|
+
direction: key === 'ArrowLeft' || key === 'ArrowUp' ? -1 : 1,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface WheelDirectionOptions {
|
|
64
|
+
/**
|
|
65
|
+
* Read horizontal scrolling as well, for a control laid out horizontally:
|
|
66
|
+
* scrolling right raises the value. Vertical scrolling still counts when
|
|
67
|
+
* there is no horizontal movement.
|
|
68
|
+
*
|
|
69
|
+
* @default false
|
|
70
|
+
*/
|
|
71
|
+
horizontal?: boolean
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The direction one wheel event moves a single value: scrolling up raises it.
|
|
76
|
+
* `null` when the event carries no movement the control reads.
|
|
77
|
+
*/
|
|
78
|
+
export function wheelDirection(
|
|
79
|
+
event: Pick<WheelEvent, 'deltaX' | 'deltaY'>,
|
|
80
|
+
{ horizontal = false }: WheelDirectionOptions = {},
|
|
81
|
+
): 1 | -1 | null {
|
|
82
|
+
if (horizontal && event.deltaX !== 0) return event.deltaX > 0 ? 1 : -1
|
|
83
|
+
if (event.deltaY === 0) return null
|
|
84
|
+
return event.deltaY > 0 ? -1 : 1
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The axis and direction one wheel event moves a position on screen. `null`
|
|
89
|
+
* when the event carries no movement.
|
|
90
|
+
*
|
|
91
|
+
* Scrolling moves y, and shift switches to x. Browsers turn shift+wheel into
|
|
92
|
+
* horizontal scrolling: `deltaY` comes out empty and `deltaX` carries the
|
|
93
|
+
* movement. Reading whichever axis moved keeps shift working as the x-axis
|
|
94
|
+
* modifier — and picks up a trackpad's own horizontal gesture, which never
|
|
95
|
+
* had a modifier.
|
|
96
|
+
*/
|
|
97
|
+
export function wheelMove(
|
|
98
|
+
event: Pick<WheelEvent, 'deltaX' | 'deltaY' | 'shiftKey'>,
|
|
99
|
+
): AxisMove | null {
|
|
100
|
+
const horizontal = event.deltaX !== 0
|
|
101
|
+
const delta = horizontal ? event.deltaX : event.deltaY
|
|
102
|
+
if (delta === 0) return null
|
|
103
|
+
return {
|
|
104
|
+
axis: horizontal || event.shiftKey ? 0 : 1,
|
|
105
|
+
direction: delta < 0 ? -1 : 1,
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/internal.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// What the wrappers in this repository share and nothing else is meant to
|
|
2
|
+
// call. Not covered by semver: anything here can change in any release. The
|
|
3
|
+
// wrappers depend on this package at the exact version for that reason.
|
|
4
|
+
export { partitionByAccept, type AcceptCandidate } from './file/accept'
|
|
5
|
+
export { applyDelta } from './input/apply-delta'
|
|
6
|
+
export { checkSteps, type CheckStepsOptions } from './input/check-steps'
|
|
7
|
+
export {
|
|
8
|
+
arrowKeyDirection,
|
|
9
|
+
arrowKeyMove,
|
|
10
|
+
wheelDirection,
|
|
11
|
+
wheelMove,
|
|
12
|
+
type AxisMove,
|
|
13
|
+
type WheelDirectionOptions,
|
|
14
|
+
} from './input/direction'
|
|
15
|
+
export { selectModifier } from './input/modifiers'
|
|
16
|
+
export {
|
|
17
|
+
createStepperDrag,
|
|
18
|
+
type StepperDragInstance,
|
|
19
|
+
type StepperDragOptions,
|
|
20
|
+
} from './number-input/stepper-drag'
|
|
21
|
+
export {
|
|
22
|
+
commitNumberInputText,
|
|
23
|
+
numberInputBounds,
|
|
24
|
+
numberInputRanges,
|
|
25
|
+
nudgeNumberInput,
|
|
26
|
+
type NumberInputRanges,
|
|
27
|
+
type NumberInputValueOptions,
|
|
28
|
+
} from './number-input/value'
|
|
29
|
+
export {
|
|
30
|
+
caretAtDecimalOffset,
|
|
31
|
+
caretDecimalOffset,
|
|
32
|
+
numberSpan,
|
|
33
|
+
parseNumberText,
|
|
34
|
+
type NumberSpan,
|
|
35
|
+
} from './number-input/text'
|
|
36
|
+
export { replaceOptions } from './options/replace'
|
|
37
|
+
export {
|
|
38
|
+
blackKeyWidth,
|
|
39
|
+
fitWhiteKeyWidth,
|
|
40
|
+
getNoteRangeArray,
|
|
41
|
+
pianoWidth,
|
|
42
|
+
} from './piano/layout'
|
|
43
|
+
export {
|
|
44
|
+
clampPoint,
|
|
45
|
+
POINT_AXIS,
|
|
46
|
+
POINTS_EDITOR_DEFAULT_KEYBOARD,
|
|
47
|
+
POINTS_EDITOR_DEFAULT_WHEEL,
|
|
48
|
+
} from './points-editor'
|
|
49
|
+
export { sliderMarks, type SliderMark } from './slider/marks'
|
|
50
|
+
export { cssLength, visuallyHiddenStyle } from './style'
|
|
51
|
+
export { toXY } from './xy'
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { radian, type Scale } from '@tremolo-ui/functions'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Width and height of the viewBox a knob is drawn in. The arcs and the thumb
|
|
5
|
+
* are laid out in these units, and the SVG scales them to the knob's size.
|
|
6
|
+
*/
|
|
7
|
+
export const KNOB_VIEWBOX_SIZE = 100
|
|
8
|
+
|
|
9
|
+
const center = KNOB_VIEWBOX_SIZE / 2
|
|
10
|
+
|
|
11
|
+
export interface KnobAngleOptions {
|
|
12
|
+
value: number
|
|
13
|
+
min: number
|
|
14
|
+
max: number
|
|
15
|
+
scale: Scale
|
|
16
|
+
/** Where the active arc starts from, so that it can grow from the middle. */
|
|
17
|
+
startValue: number
|
|
18
|
+
/** How far the knob turns from `min` to `max`, in degrees. */
|
|
19
|
+
angleRange: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The angles a knob is drawn with, in degrees clockwise from the top.
|
|
24
|
+
*
|
|
25
|
+
* The travel is centred on the top, so `angleRange` of 270 runs from -135 to
|
|
26
|
+
* 135. Derived from the value alone, so a wrapper can call it while rendering.
|
|
27
|
+
*/
|
|
28
|
+
export interface KnobAngles {
|
|
29
|
+
/** The value, normalized to 0..1 along the scale. */
|
|
30
|
+
p: number
|
|
31
|
+
/** Where the travel starts. */
|
|
32
|
+
r1: number
|
|
33
|
+
/** Where the active arc starts: the lower of the value and `startValue`. */
|
|
34
|
+
r2: number
|
|
35
|
+
/** Where the active arc ends: the higher of the value and `startValue`. */
|
|
36
|
+
r3: number
|
|
37
|
+
/** Where the travel ends. */
|
|
38
|
+
r4: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function knobAngles({
|
|
42
|
+
value,
|
|
43
|
+
min,
|
|
44
|
+
max,
|
|
45
|
+
scale,
|
|
46
|
+
startValue,
|
|
47
|
+
angleRange,
|
|
48
|
+
}: KnobAngleOptions): KnobAngles {
|
|
49
|
+
const p = scale.normalize(value, min, max)
|
|
50
|
+
const s = scale.normalize(startValue, min, max)
|
|
51
|
+
const r1 = -angleRange / 2
|
|
52
|
+
const r2 = r1 + Math.min(p, s) * angleRange
|
|
53
|
+
const r3 = r1 + Math.max(p, s) * angleRange
|
|
54
|
+
const r4 = angleRange / 2
|
|
55
|
+
return { p, r1, r2, r3, r4 }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The point at `angle` on a circle of `radius` around the centre of the
|
|
60
|
+
* viewBox.
|
|
61
|
+
*
|
|
62
|
+
* The radius depends on the stroke of the line being drawn, which each arc
|
|
63
|
+
* has its own of, so the point is found per arc rather than once for the knob.
|
|
64
|
+
*/
|
|
65
|
+
export function knobArcPoint(angle: number, radius: number) {
|
|
66
|
+
return {
|
|
67
|
+
x: center + radius * Math.cos(radian(angle - 90)),
|
|
68
|
+
y: center + radius * Math.sin(radian(angle - 90)),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The radius that keeps a stroke of `strokeWidth` inside the viewBox: half of
|
|
74
|
+
* the stroke falls outside the path it is drawn along.
|
|
75
|
+
*/
|
|
76
|
+
export function knobArcRadius(strokeWidth: number | string | undefined) {
|
|
77
|
+
const width =
|
|
78
|
+
typeof strokeWidth === 'number'
|
|
79
|
+
? strokeWidth
|
|
80
|
+
: Number.parseFloat(String(strokeWidth))
|
|
81
|
+
return Number.isFinite(width) ? center - width / 2 : center
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Build an SVG path for an arc, splitting full turns into drawable segments. */
|
|
85
|
+
export function knobArcPath(
|
|
86
|
+
startAngle: number,
|
|
87
|
+
endAngle: number,
|
|
88
|
+
radius: number,
|
|
89
|
+
) {
|
|
90
|
+
const start = knobArcPoint(startAngle, radius)
|
|
91
|
+
const sweep = endAngle - startAngle
|
|
92
|
+
const segmentCount = Math.max(1, Math.ceil(Math.abs(sweep) / 180))
|
|
93
|
+
const segmentSweep = sweep / segmentCount
|
|
94
|
+
let path = `M ${start.x} ${start.y}`
|
|
95
|
+
for (let i = 1; i <= segmentCount; i += 1) {
|
|
96
|
+
const end = knobArcPoint(startAngle + segmentSweep * i, radius)
|
|
97
|
+
path += ` A ${radius} ${radius} 0 0 ${segmentSweep >= 0 ? 1 : 0} ${end.x} ${end.y}`
|
|
98
|
+
}
|
|
99
|
+
return path
|
|
100
|
+
}
|
package/src/midi/access.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Why MIDI access could not be had.
|
|
3
|
+
*
|
|
4
|
+
* - `'NOT_SUPPORTED'`: the browser has no Web MIDI API, and asking again will
|
|
5
|
+
* not change that
|
|
6
|
+
* - `'PERMISSION_DENIED'`: the user or the browser said no. Asking again is
|
|
7
|
+
* worthwhile
|
|
8
|
+
* - `'UNAVAILABLE'`: anything else
|
|
9
|
+
*/
|
|
9
10
|
export type MIDIAccessError =
|
|
10
|
-
|
|
|
11
|
-
|
|
|
12
|
-
|
|
|
11
|
+
| 'NOT_SUPPORTED'
|
|
12
|
+
| 'PERMISSION_DENIED'
|
|
13
|
+
| 'UNAVAILABLE'
|
|
13
14
|
|
|
14
15
|
export type MIDIAccessOptions = {
|
|
15
16
|
/**
|
|
@@ -65,13 +66,13 @@ function toError(reason: unknown): MIDIAccessError {
|
|
|
65
66
|
: ''
|
|
66
67
|
|
|
67
68
|
if (name === 'SecurityError' || name === 'NotAllowedError') {
|
|
68
|
-
return PERMISSION_DENIED
|
|
69
|
+
return 'PERMISSION_DENIED'
|
|
69
70
|
}
|
|
70
71
|
if (name === 'NotSupportedError' || name === 'TypeError') {
|
|
71
|
-
return NOT_SUPPORTED
|
|
72
|
+
return 'NOT_SUPPORTED'
|
|
72
73
|
}
|
|
73
74
|
// AbortError, InvalidStateError, and anything a browser makes up.
|
|
74
|
-
return UNAVAILABLE
|
|
75
|
+
return 'UNAVAILABLE'
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
/**
|
|
@@ -110,7 +111,7 @@ export function createMIDIAccess(): MIDIAccessInstance {
|
|
|
110
111
|
if (destroyed) return
|
|
111
112
|
const generation = ++requestGeneration
|
|
112
113
|
if (typeof navigator === 'undefined' || !navigator.requestMIDIAccess) {
|
|
113
|
-
setState({ ...state, error: NOT_SUPPORTED })
|
|
114
|
+
setState({ ...state, error: 'NOT_SUPPORTED' })
|
|
114
115
|
return
|
|
115
116
|
}
|
|
116
117
|
navigator.requestMIDIAccess({ sysex: options.sysex ?? false }).then(
|
package/src/midi/input.ts
CHANGED
|
@@ -11,14 +11,6 @@ const STATUS = {
|
|
|
11
11
|
PITCH_BEND: 0xe0,
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
* Centre of the 14-bit pitch bend range: no bend.
|
|
16
|
-
*
|
|
17
|
-
* The range is not symmetric — 0 is 8192 below centre and 16383 is 8191 above
|
|
18
|
-
* — so a wheel at rest reports exactly this rather than half of the maximum.
|
|
19
|
-
*/
|
|
20
|
-
export const PITCH_BEND_CENTER = 8192
|
|
21
|
-
|
|
22
14
|
/**
|
|
23
15
|
* Every handler is given the channel last, as 0-15. MIDI channels are written
|
|
24
16
|
* 1-16 on hardware, so add one before showing it to anyone.
|
|
@@ -27,7 +19,8 @@ export type MIDIInputHandlers = {
|
|
|
27
19
|
onNoteOnEvent?: (note: number, velocity: number, channel: number) => void
|
|
28
20
|
onNoteOffEvent?: (note: number, channel: number) => void
|
|
29
21
|
/**
|
|
30
|
-
* The 14-bit bend, 0-16383, centred at
|
|
22
|
+
* The 14-bit bend, 0-16383, centred at 8192. `normalizePitchBend` in
|
|
23
|
+
* `@tremolo-ui/functions` turns it into -1 to 1.
|
|
31
24
|
*
|
|
32
25
|
* The two data bytes are little-endian — the first carries the low 7 bits —
|
|
33
26
|
* which is the other way round from every other message.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { type ValueRange } from '@tremolo-ui/functions'
|
|
2
|
+
|
|
3
|
+
import { applyDelta } from '../input/apply-delta'
|
|
4
|
+
import { DEFAULT_DRAG_SENSITIVITY } from '../input/defaults'
|
|
5
|
+
import {
|
|
6
|
+
mapModifier,
|
|
7
|
+
selectModifier,
|
|
8
|
+
type InputEventOption,
|
|
9
|
+
type ModifierValue,
|
|
10
|
+
} from '../input/modifiers'
|
|
11
|
+
import { createDrag } from '../pointer/drag'
|
|
12
|
+
|
|
13
|
+
export interface StepperDragOptions {
|
|
14
|
+
/** The value now, read when the drag starts moving it. */
|
|
15
|
+
getValue: () => number
|
|
16
|
+
/**
|
|
17
|
+
* The range the value moves across. Its `step` is what one step of the drag
|
|
18
|
+
* is worth; see `numberInputRanges` for the one a number input uses.
|
|
19
|
+
*/
|
|
20
|
+
range: ValueRange
|
|
21
|
+
/**
|
|
22
|
+
* How many pixels of vertical movement make one step.
|
|
23
|
+
*
|
|
24
|
+
* @default 1
|
|
25
|
+
*/
|
|
26
|
+
pixels?: number
|
|
27
|
+
/**
|
|
28
|
+
* How much a step is worth, per modifier key: `0.1` makes the same movement
|
|
29
|
+
* count a tenth as much. Pressing or releasing the key mid-drag does not
|
|
30
|
+
* move the value.
|
|
31
|
+
*
|
|
32
|
+
* @default { default: 1, shift: 0.1 }
|
|
33
|
+
*/
|
|
34
|
+
sensitivity?: ModifierValue<number>
|
|
35
|
+
/** Hide the pointer and keep it from hitting the edge of the screen. */
|
|
36
|
+
pointerLock?: boolean
|
|
37
|
+
/**
|
|
38
|
+
* The cursor to show while dragging. Applied to the element itself, as
|
|
39
|
+
* `createDrag` does.
|
|
40
|
+
*
|
|
41
|
+
* @default 'ns-resize'
|
|
42
|
+
*/
|
|
43
|
+
cursor?: string
|
|
44
|
+
/** Called with the new value whenever the drag moves it. */
|
|
45
|
+
onChange: (value: number) => void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface StepperDragInstance {
|
|
49
|
+
/** Replace the given options. `pointerLock` reaches the next drag. */
|
|
50
|
+
update: (options: Partial<StepperDragOptions>) => void
|
|
51
|
+
/**
|
|
52
|
+
* Whether the drag in progress has moved the value. A stepper button's
|
|
53
|
+
* press-and-hold repeat stands down once it has, so the value is not moved
|
|
54
|
+
* twice.
|
|
55
|
+
*/
|
|
56
|
+
moved: () => boolean
|
|
57
|
+
destroy: () => void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Drag up and down on the steppers of a number input to move its value, one
|
|
62
|
+
* `step` every `pixels` — up raises it, as on a knob.
|
|
63
|
+
*
|
|
64
|
+
* Counted from where the drag started moving rather than added up per
|
|
65
|
+
* event, so rounding cannot accumulate. The start is taken on the first
|
|
66
|
+
* move, not on pointerdown: a stepper button acts on pointerdown, so by then
|
|
67
|
+
* the value may already have been nudged once, and the drag carries on from
|
|
68
|
+
* there.
|
|
69
|
+
*/
|
|
70
|
+
export function createStepperDrag(
|
|
71
|
+
element: Element,
|
|
72
|
+
options: StepperDragOptions,
|
|
73
|
+
): StepperDragInstance {
|
|
74
|
+
let opts = options
|
|
75
|
+
let origin: { y: number; value: number } | null = null
|
|
76
|
+
let moved = false
|
|
77
|
+
/**
|
|
78
|
+
* Which sensitivity the drag is counting at, and where the previous event
|
|
79
|
+
* was — a key produces no pointer event of its own, so a change is only
|
|
80
|
+
* seen on the next move and has to be dated back to the one before it.
|
|
81
|
+
*/
|
|
82
|
+
let factor = 1
|
|
83
|
+
let previousY = 0
|
|
84
|
+
|
|
85
|
+
const drag = createDrag(element, {
|
|
86
|
+
threshold: 1,
|
|
87
|
+
cursor: opts.cursor ?? 'ns-resize',
|
|
88
|
+
pointerLock: opts.pointerLock,
|
|
89
|
+
onDragStart: (state) => {
|
|
90
|
+
origin = null
|
|
91
|
+
moved = false
|
|
92
|
+
factor = selectModifier(
|
|
93
|
+
opts.sensitivity ?? DEFAULT_DRAG_SENSITIVITY,
|
|
94
|
+
state.event,
|
|
95
|
+
).value
|
|
96
|
+
},
|
|
97
|
+
onDrag: (state) => {
|
|
98
|
+
const { y } = state
|
|
99
|
+
if (!origin) {
|
|
100
|
+
origin = { y, value: opts.getValue() }
|
|
101
|
+
previousY = y
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const sensitivity = opts.sensitivity ?? DEFAULT_DRAG_SENSITIVITY
|
|
106
|
+
// Pressing or releasing the key mid-drag must not move the value, so the
|
|
107
|
+
// travel so far is folded into the origin and measuring starts again
|
|
108
|
+
// from the previous event: that event's own distance belongs to the new
|
|
109
|
+
// sensitivity.
|
|
110
|
+
const next = selectModifier(sensitivity, state.event).value
|
|
111
|
+
if (next !== factor) {
|
|
112
|
+
origin = { y: previousY, value: opts.getValue() }
|
|
113
|
+
factor = next
|
|
114
|
+
}
|
|
115
|
+
previousY = y
|
|
116
|
+
|
|
117
|
+
const steps = Math.round(-(y - origin.y) / (opts.pixels ?? 1))
|
|
118
|
+
if (steps === 0) return
|
|
119
|
+
moved = true
|
|
120
|
+
|
|
121
|
+
// The sensitivity as an amount per step, carried over as a modifier map
|
|
122
|
+
// rather than resolved here: that keeps `step` out of the pipeline for
|
|
123
|
+
// a modifier entry, since naming one is a request to move off the grid.
|
|
124
|
+
const step = opts.range.step ?? 1
|
|
125
|
+
const amounts = mapModifier(sensitivity, (f): InputEventOption => [
|
|
126
|
+
'raw',
|
|
127
|
+
step * f,
|
|
128
|
+
])
|
|
129
|
+
opts.onChange(
|
|
130
|
+
applyDelta(origin.value, steps, amounts, opts.range, state.event),
|
|
131
|
+
)
|
|
132
|
+
},
|
|
133
|
+
onDragEnd: () => {
|
|
134
|
+
moved = false
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
update: (next) => {
|
|
140
|
+
opts = { ...opts, ...next }
|
|
141
|
+
if ('pointerLock' in next) drag.update({ pointerLock: opts.pointerLock })
|
|
142
|
+
if ('cursor' in next) drag.update({ cursor: opts.cursor ?? 'ns-resize' })
|
|
143
|
+
},
|
|
144
|
+
moved: () => moved,
|
|
145
|
+
destroy: () => drag.destroy(),
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a number out of the text of a number input, and keeping the caret
|
|
3
|
+
* in place while the number under it changes.
|
|
4
|
+
*
|
|
5
|
+
* The text is whatever `format` made of the value — `"440 Hz"`, `"-6.0 dB"` —
|
|
6
|
+
* or a half-typed entry, so none of this assumes the text is a number alone.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The signs a number can carry. U+2212 is what some `Intl.NumberFormat` locales write. */
|
|
10
|
+
const SIGNS = '+-\u2212'
|
|
11
|
+
|
|
12
|
+
/** Where the number is in the text; the rest, on either side, is the unit. */
|
|
13
|
+
export interface NumberSpan {
|
|
14
|
+
start: number
|
|
15
|
+
end: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Where the number is in the text: from its first digit to its last, with the
|
|
20
|
+
* sign and the decimal point in front of it. Whatever is left on either side
|
|
21
|
+
* is taken for the unit, so it does not matter whether a space separates them,
|
|
22
|
+
* or what the number looks like — `+6.0`, `1e+21`, `1,000` and `1:30` are each
|
|
23
|
+
* one number. `null` when the text has no digit at all.
|
|
24
|
+
*/
|
|
25
|
+
export function numberSpan(text: string): NumberSpan | null {
|
|
26
|
+
const first = text.search(/\d/)
|
|
27
|
+
if (first === -1) return null
|
|
28
|
+
let start = first
|
|
29
|
+
if (text[start - 1] === '.') start -= 1
|
|
30
|
+
if (start > 0 && SIGNS.includes(text[start - 1])) start -= 1
|
|
31
|
+
return { start, end: text.search(/\d\D*$/) + 1 }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A plain number, and nothing else: no grouping, no other separators. */
|
|
35
|
+
const PLAIN_NUMBER =
|
|
36
|
+
/^[+\-\u2212]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+\-\u2212]?\d+)?$/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The number in the text, with the unit after it ignored: the default `parse`
|
|
40
|
+
* of a number input.
|
|
41
|
+
*
|
|
42
|
+
* `NaN`, which leaves the value alone, whenever the number cannot be read
|
|
43
|
+
* safely, rather than a part of it: text with no number, a number that is not
|
|
44
|
+
* a plain one (`1,000` would otherwise read as 1, and `1:30` as 1), and text
|
|
45
|
+
* with something in front of the number, which can change what it means (the
|
|
46
|
+
* `L` of a pan reading `L 30`). A `format` that writes any of those needs its
|
|
47
|
+
* own `parse`.
|
|
48
|
+
*/
|
|
49
|
+
export function parseNumberText(text: string): number {
|
|
50
|
+
const span = numberSpan(text)
|
|
51
|
+
if (!span || text.slice(0, span.start).trim() !== '') return NaN
|
|
52
|
+
const number = text.slice(span.start, span.end)
|
|
53
|
+
return PLAIN_NUMBER.test(number)
|
|
54
|
+
? Number(number.replace(/\u2212/g, '-'))
|
|
55
|
+
: NaN
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The index the caret is measured against: the decimal point, or where one
|
|
60
|
+
* would go if the number has none — in front of an exponent, if there is one.
|
|
61
|
+
*
|
|
62
|
+
* Measuring from an end instead would slide the caret across a digit whenever
|
|
63
|
+
* the number changed length — `9.9` to `10.0` gains a character in front, `10`
|
|
64
|
+
* to `9` loses one, and so does `9e+9` to `1e+10` behind — which is exactly
|
|
65
|
+
* what stepping does.
|
|
66
|
+
*/
|
|
67
|
+
function decimalAnchor(text: string, span: NumberSpan) {
|
|
68
|
+
const number = text.slice(span.start, span.end)
|
|
69
|
+
const exponent = number.search(/[eE][+\-\u2212]?\d/)
|
|
70
|
+
const mantissa = exponent === -1 ? number : number.slice(0, exponent)
|
|
71
|
+
const dot = mantissa.indexOf('.')
|
|
72
|
+
return span.start + (dot === -1 ? mantissa.length : dot)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const NO_NUMBER: NumberSpan = { start: 0, end: 0 }
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Where the caret is, relative to the decimal point, so that it can be put
|
|
79
|
+
* back at the same digit once the value has changed. See
|
|
80
|
+
* {@link caretAtDecimalOffset}.
|
|
81
|
+
*/
|
|
82
|
+
export function caretDecimalOffset(text: string, caret: number): number {
|
|
83
|
+
return caret - decimalAnchor(text, numberSpan(text) ?? NO_NUMBER)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The caret position `offset` characters from the decimal point of the new
|
|
88
|
+
* text, kept within the number.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* // The caret sits in front of the point of "9.9" when ArrowUp turns it
|
|
92
|
+
* // into "10.0"
|
|
93
|
+
* const offset = caretDecimalOffset('9.9', 1) // 0
|
|
94
|
+
* caretAtDecimalOffset('10.0', offset) // 2: still in front of the point
|
|
95
|
+
*/
|
|
96
|
+
export function caretAtDecimalOffset(text: string, offset: number): number {
|
|
97
|
+
const span = numberSpan(text) ?? NO_NUMBER
|
|
98
|
+
const place = decimalAnchor(text, span) + offset
|
|
99
|
+
return Math.max(span.start, Math.min(place, span.end))
|
|
100
|
+
}
|