@tremolo-ui/dom 0.7.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 +1298 -235
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +767 -84
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +767 -84
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1263 -237
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/file/accept.ts +17 -0
- package/src/file/drop-zone.ts +8 -10
- package/src/index.ts +76 -1
- package/src/input/check-steps.ts +144 -0
- package/src/input/defaults.ts +32 -0
- package/src/input/direction.ts +107 -0
- package/src/knob/geometry.ts +100 -0
- package/src/number-input/stepper-drag.ts +139 -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/long-press.ts +103 -0
- package/src/points-editor/index.ts +361 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tremolo-ui/dom",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "framework-agnostic DOM layer used in @tremolo-ui/*",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"format": "prettier --write ."
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@tremolo-ui/functions": "^0.
|
|
22
|
+
"@tremolo-ui/functions": "^0.8.0"
|
|
23
23
|
},
|
|
24
24
|
"files": [
|
|
25
25
|
"dist",
|
package/src/file/accept.ts
CHANGED
|
@@ -56,3 +56,20 @@ export function matchesAccept(
|
|
|
56
56
|
|
|
57
57
|
return undecided
|
|
58
58
|
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Split files into those that satisfy `accept` and those that do not, keeping
|
|
62
|
+
* their order. See {@link matchesAccept}.
|
|
63
|
+
*/
|
|
64
|
+
export function partitionByAccept(
|
|
65
|
+
files: Iterable<File>,
|
|
66
|
+
accept?: string,
|
|
67
|
+
): { accepted: File[]; rejected: File[] } {
|
|
68
|
+
const accepted: File[] = []
|
|
69
|
+
const rejected: File[] = []
|
|
70
|
+
for (const file of files) {
|
|
71
|
+
if (matchesAccept(file, accept)) accepted.push(file)
|
|
72
|
+
else rejected.push(file)
|
|
73
|
+
}
|
|
74
|
+
return { accepted, rejected }
|
|
75
|
+
}
|
package/src/file/drop-zone.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { matchesAccept } from './accept'
|
|
1
|
+
import { matchesAccept, partitionByAccept } from './accept'
|
|
2
2
|
|
|
3
3
|
/** What is in the air over the element. */
|
|
4
4
|
export interface DropZoneState {
|
|
@@ -41,7 +41,8 @@ export interface DropZoneOptions {
|
|
|
41
41
|
onDrop?: (files: File[], event: DragEvent) => void
|
|
42
42
|
/**
|
|
43
43
|
* Called with the dropped files that do not match `accept`, so that the
|
|
44
|
-
* reason can be shown.
|
|
44
|
+
* reason can be shown. It comes after `onDrop` for the same drop, so a list
|
|
45
|
+
* of rejected files can be cleared in `onDrop` and filled here.
|
|
45
46
|
*/
|
|
46
47
|
onReject?: (files: File[], event: DragEvent) => void
|
|
47
48
|
/** Called whenever {@link DropZoneInstance.state} would change. */
|
|
@@ -146,17 +147,14 @@ export function createDropZone(
|
|
|
146
147
|
setState({ over: false, invalid: false })
|
|
147
148
|
if (opts.disabled) return
|
|
148
149
|
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (matchesAccept(file, opts.accept)) accepted.push(file)
|
|
154
|
-
else rejected.push(file)
|
|
155
|
-
}
|
|
150
|
+
const { accepted, rejected } = partitionByAccept(
|
|
151
|
+
event.dataTransfer?.files ?? [],
|
|
152
|
+
opts.accept,
|
|
153
|
+
)
|
|
156
154
|
|
|
157
|
-
if (rejected.length > 0) opts.onReject?.(rejected, event)
|
|
158
155
|
const taken = opts.multiple ? accepted : accepted.slice(0, 1)
|
|
159
156
|
if (taken.length > 0) opts.onDrop?.(taken, event)
|
|
157
|
+
if (rejected.length > 0) opts.onReject?.(rejected, event)
|
|
160
158
|
}
|
|
161
159
|
|
|
162
160
|
/**
|
package/src/index.ts
CHANGED
|
@@ -13,7 +13,11 @@ export {
|
|
|
13
13
|
type DrawingState,
|
|
14
14
|
type DrawingStateValue,
|
|
15
15
|
} from './canvas/context'
|
|
16
|
-
export {
|
|
16
|
+
export {
|
|
17
|
+
matchesAccept,
|
|
18
|
+
partitionByAccept,
|
|
19
|
+
type AcceptCandidate,
|
|
20
|
+
} from './file/accept'
|
|
17
21
|
export {
|
|
18
22
|
createDropZone,
|
|
19
23
|
type DropZoneInstance,
|
|
@@ -21,6 +25,22 @@ export {
|
|
|
21
25
|
type DropZoneState,
|
|
22
26
|
} from './file/drop-zone'
|
|
23
27
|
export { applyDelta } from './input/apply-delta'
|
|
28
|
+
export { checkSteps, type CheckStepsOptions } from './input/check-steps'
|
|
29
|
+
export {
|
|
30
|
+
DEFAULT_DRAG_SENSITIVITY,
|
|
31
|
+
DEFAULT_KEYBOARD_OPTIONS,
|
|
32
|
+
DEFAULT_WHEEL_OPTIONS,
|
|
33
|
+
} from './input/defaults'
|
|
34
|
+
export {
|
|
35
|
+
arrowKeyDirection,
|
|
36
|
+
arrowKeyMove,
|
|
37
|
+
isArrowKey,
|
|
38
|
+
wheelDirection,
|
|
39
|
+
wheelMove,
|
|
40
|
+
type ArrowKey,
|
|
41
|
+
type AxisMove,
|
|
42
|
+
type WheelDirectionOptions,
|
|
43
|
+
} from './input/direction'
|
|
24
44
|
export {
|
|
25
45
|
mapModifier,
|
|
26
46
|
selectModifier,
|
|
@@ -30,6 +50,15 @@ export {
|
|
|
30
50
|
type ModifierState,
|
|
31
51
|
type ModifierValue,
|
|
32
52
|
} from './input/modifiers'
|
|
53
|
+
export {
|
|
54
|
+
KNOB_VIEWBOX_SIZE,
|
|
55
|
+
knobAngles,
|
|
56
|
+
knobArcPath,
|
|
57
|
+
knobArcPoint,
|
|
58
|
+
knobArcRadius,
|
|
59
|
+
type KnobAngleOptions,
|
|
60
|
+
type KnobAngles,
|
|
61
|
+
} from './knob/geometry'
|
|
33
62
|
export {
|
|
34
63
|
createMIDIAccess,
|
|
35
64
|
NOT_SUPPORTED,
|
|
@@ -47,8 +76,30 @@ export {
|
|
|
47
76
|
type MIDIInputInstance,
|
|
48
77
|
} from './midi/input'
|
|
49
78
|
export { createMIDIMessage, type MIDIMessageInstance } from './midi/message'
|
|
79
|
+
export {
|
|
80
|
+
createStepperDrag,
|
|
81
|
+
type StepperDragInstance,
|
|
82
|
+
type StepperDragOptions,
|
|
83
|
+
} from './number-input/stepper-drag'
|
|
84
|
+
export {
|
|
85
|
+
commitNumberInputText,
|
|
86
|
+
numberInputBounds,
|
|
87
|
+
numberInputRanges,
|
|
88
|
+
nudgeNumberInput,
|
|
89
|
+
type NumberInputRanges,
|
|
90
|
+
type NumberInputValueOptions,
|
|
91
|
+
} from './number-input/value'
|
|
92
|
+
export {
|
|
93
|
+
caretAtDecimalOffset,
|
|
94
|
+
caretDecimalOffset,
|
|
95
|
+
numberSpan,
|
|
96
|
+
parseNumberText,
|
|
97
|
+
type NumberSpan,
|
|
98
|
+
} from './number-input/text'
|
|
99
|
+
export { replaceOptions } from './options/replace'
|
|
50
100
|
export {
|
|
51
101
|
blackKeyWidth,
|
|
102
|
+
fitWhiteKeyWidth,
|
|
52
103
|
getNoteRangeArray,
|
|
53
104
|
noteAt,
|
|
54
105
|
notePosition,
|
|
@@ -56,12 +107,28 @@ export {
|
|
|
56
107
|
type NoteRange,
|
|
57
108
|
type PianoLayout,
|
|
58
109
|
} from './piano/layout'
|
|
110
|
+
export {
|
|
111
|
+
SHORTCUTS,
|
|
112
|
+
type KeyboardShortcuts,
|
|
113
|
+
type KeyboardShortcutsScope,
|
|
114
|
+
} from './piano/shortcuts'
|
|
59
115
|
export {
|
|
60
116
|
createPianoInput,
|
|
61
117
|
type NoteSource,
|
|
62
118
|
type PianoInputInstance,
|
|
63
119
|
type PianoInputOptions,
|
|
64
120
|
} from './piano'
|
|
121
|
+
export {
|
|
122
|
+
clampPoint,
|
|
123
|
+
createPointsEditor,
|
|
124
|
+
POINT_AXIS,
|
|
125
|
+
POINTS_EDITOR_DEFAULT_KEYBOARD,
|
|
126
|
+
POINTS_EDITOR_DEFAULT_WHEEL,
|
|
127
|
+
type PointPosition,
|
|
128
|
+
type PointsEditorInstance,
|
|
129
|
+
type PointsEditorOptions,
|
|
130
|
+
type PointsEditorPoint,
|
|
131
|
+
} from './points-editor'
|
|
65
132
|
export {
|
|
66
133
|
createDrag,
|
|
67
134
|
type DragInstance,
|
|
@@ -78,11 +145,17 @@ export {
|
|
|
78
145
|
type DragValueOptions,
|
|
79
146
|
type MappingContext,
|
|
80
147
|
} from './pointer/drag-value'
|
|
148
|
+
export {
|
|
149
|
+
createLongPress,
|
|
150
|
+
type LongPressInstance,
|
|
151
|
+
type LongPressOptions,
|
|
152
|
+
} from './pointer/long-press'
|
|
81
153
|
export {
|
|
82
154
|
createWheel,
|
|
83
155
|
type WheelInstance,
|
|
84
156
|
type WheelOptions,
|
|
85
157
|
} from './pointer/wheel'
|
|
158
|
+
export { valuePercent } from './position'
|
|
86
159
|
export {
|
|
87
160
|
createSelectionBox,
|
|
88
161
|
selectionBoxCovers,
|
|
@@ -91,4 +164,6 @@ export {
|
|
|
91
164
|
type SelectionBoxOptions,
|
|
92
165
|
type SelectionBoxRect,
|
|
93
166
|
} from './selection/box'
|
|
167
|
+
export { sliderMarks, type MarksOptions, type SliderMark } from './slider/marks'
|
|
168
|
+
export { cssLength, visuallyHiddenStyle } from './style'
|
|
94
169
|
export { toXY, type XY, type XYInput } from './xy'
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { linearScale, type ValueRange } from '@tremolo-ui/functions'
|
|
2
|
+
|
|
3
|
+
import { applyDelta } from './apply-delta'
|
|
4
|
+
import {
|
|
5
|
+
type InputEventOption,
|
|
6
|
+
type ModifierState,
|
|
7
|
+
type ModifierValue,
|
|
8
|
+
} from './modifiers'
|
|
9
|
+
|
|
10
|
+
/** Positions probed across the travel. The ends are left out so that the
|
|
11
|
+
* clamp at `min` and `max` cannot be mistaken for a press that does nothing. */
|
|
12
|
+
const PROBES = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
|
|
13
|
+
|
|
14
|
+
const NONE: ModifierState = {
|
|
15
|
+
shiftKey: false,
|
|
16
|
+
altKey: false,
|
|
17
|
+
ctrlKey: false,
|
|
18
|
+
metaKey: false,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MODIFIER_STATE: Record<string, ModifierState> = {
|
|
22
|
+
shift: { ...NONE, shiftKey: true },
|
|
23
|
+
alt: { ...NONE, altKey: true },
|
|
24
|
+
ctrl: { ...NONE, ctrlKey: true },
|
|
25
|
+
meta: { ...NONE, metaKey: true },
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The held keys that select each entry of a modifier-aware input option. */
|
|
29
|
+
function entries(options: ModifierValue<InputEventOption>): ModifierState[] {
|
|
30
|
+
if (Array.isArray(options)) return [NONE]
|
|
31
|
+
return Object.keys(options).map((key) => MODIFIER_STATE[key] ?? NONE)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Outcome {
|
|
35
|
+
/** The press changed the value at least once across the travel. */
|
|
36
|
+
moved: boolean
|
|
37
|
+
/** The change reached the displayed text at least once. */
|
|
38
|
+
visible: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Press every entry of `options` at nine points along the travel and report
|
|
43
|
+
* whether anything came of it.
|
|
44
|
+
*
|
|
45
|
+
* Run against `applyDelta` itself rather than against a reading of `step`:
|
|
46
|
+
* the whole point is that the amount, the step and the scale interact, and
|
|
47
|
+
* the pipeline is the only thing that knows how.
|
|
48
|
+
*/
|
|
49
|
+
function probe(
|
|
50
|
+
options: ModifierValue<InputEventOption>,
|
|
51
|
+
range: ValueRange,
|
|
52
|
+
format?: (value: number) => string,
|
|
53
|
+
): Outcome {
|
|
54
|
+
const { min, max, scale = linearScale } = range
|
|
55
|
+
const outcome: Outcome = { moved: false, visible: false }
|
|
56
|
+
for (const modifiers of entries(options)) {
|
|
57
|
+
for (const position of PROBES) {
|
|
58
|
+
const value = scale.denormalize(position, min, max)
|
|
59
|
+
const up = applyDelta(value, 1, options, range, modifiers)
|
|
60
|
+
const down = applyDelta(value, -1, options, range, modifiers)
|
|
61
|
+
if (up !== value || down !== value) outcome.moved = true
|
|
62
|
+
if (!format) continue
|
|
63
|
+
const shown = format(value)
|
|
64
|
+
if (format(up) !== shown || format(down) !== shown) outcome.visible = true
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return outcome
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface CheckStepsOptions {
|
|
71
|
+
/** The component, for the message. */
|
|
72
|
+
component: string
|
|
73
|
+
/** The axis, for a component that has more than one. */
|
|
74
|
+
axis?: string
|
|
75
|
+
/**
|
|
76
|
+
* The range to probe, or `null` to check nothing. An unbounded input has no
|
|
77
|
+
* travel to sample, so `NumberInput` passes `null` when `min` and `max` are
|
|
78
|
+
* not both there.
|
|
79
|
+
*/
|
|
80
|
+
range: ValueRange | null
|
|
81
|
+
keyboard?: ModifierValue<InputEventOption> | null
|
|
82
|
+
wheel?: ModifierValue<InputEventOption> | null
|
|
83
|
+
/**
|
|
84
|
+
* How the value is displayed, where the component shows one. Called with
|
|
85
|
+
* probe values only.
|
|
86
|
+
*/
|
|
87
|
+
format?: (value: number) => string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The warnings to show when a key press or a wheel notch cannot produce a
|
|
92
|
+
* change the user can see. Empty when there is nothing to say.
|
|
93
|
+
*
|
|
94
|
+
* Two settings that are each fine on their own can cancel out, and nothing
|
|
95
|
+
* fails when they do — the control simply sits there:
|
|
96
|
+
*
|
|
97
|
+
* - **`step` coarser than the amount.** `keyboard={['raw', 0.1]}` with
|
|
98
|
+
* `step={1}` rounds every press straight back to where it started
|
|
99
|
+
* - **the display coarser than the amount.** A `format` showing two decimals
|
|
100
|
+
* of a kHz value cannot show a press worth 1 Hz
|
|
101
|
+
*
|
|
102
|
+
* The second is only reported when the press is invisible at *every* point
|
|
103
|
+
* along the travel. A display that rounds is a deliberate choice and is
|
|
104
|
+
* normally right — it is being too coarse everywhere that makes it a mistake.
|
|
105
|
+
*
|
|
106
|
+
* **Meant for development builds only.** It probes the whole travel, so call
|
|
107
|
+
* it behind an inline `process.env.NODE_ENV` check: the bundler then drops
|
|
108
|
+
* the call, and this function and its messages with it.
|
|
109
|
+
*/
|
|
110
|
+
export function checkSteps({
|
|
111
|
+
component,
|
|
112
|
+
axis,
|
|
113
|
+
range,
|
|
114
|
+
keyboard,
|
|
115
|
+
wheel,
|
|
116
|
+
format,
|
|
117
|
+
}: CheckStepsOptions): string[] {
|
|
118
|
+
if (!range || !(range.min < range.max)) return []
|
|
119
|
+
const where = axis ? `${component} (${axis})` : component
|
|
120
|
+
const warnings: string[] = []
|
|
121
|
+
for (const [name, options] of [
|
|
122
|
+
['keyboard', keyboard],
|
|
123
|
+
['wheel', wheel],
|
|
124
|
+
] as const) {
|
|
125
|
+
if (!options) continue
|
|
126
|
+
const { moved, visible } = probe(options, range, format)
|
|
127
|
+
if (!moved) {
|
|
128
|
+
warnings.push(
|
|
129
|
+
`[tremolo-ui] ${where}: \`${name}\` cannot move the value.` +
|
|
130
|
+
(range.step !== undefined
|
|
131
|
+
? ` Each press is smaller than \`step\` (${range.step}), so it rounds`
|
|
132
|
+
: ' Each press rounds') +
|
|
133
|
+
' straight back to where it started.',
|
|
134
|
+
)
|
|
135
|
+
} else if (format && !visible) {
|
|
136
|
+
warnings.push(
|
|
137
|
+
`[tremolo-ui] ${where}: \`${name}\` moves the value, but \`format\`` +
|
|
138
|
+
' shows the same text before and after, everywhere in the range.' +
|
|
139
|
+
' The display is too coarse for it to be seen.',
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return warnings
|
|
144
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|