@tremolo-ui/dom 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 +1590 -145
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1030 -99
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +1030 -99
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1544 -146
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/file/accept.ts +75 -0
- package/src/file/drop-zone.ts +200 -0
- package/src/index.ts +101 -0
- package/src/input/apply-delta.ts +73 -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/input/modifiers.ts +121 -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 +130 -4
- package/src/piano/layout.ts +202 -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",
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an `accept` rule is matched against.
|
|
3
|
+
*
|
|
4
|
+
* `File` satisfies it, and so does `DataTransferItem` — which matters while a
|
|
5
|
+
* drag is still in the air, since the browser reports the type of what is
|
|
6
|
+
* being dragged but withholds the name.
|
|
7
|
+
*/
|
|
8
|
+
export interface AcceptCandidate {
|
|
9
|
+
/** The file name, when it is known. */
|
|
10
|
+
name?: string
|
|
11
|
+
/** The MIME type, or `''` when the browser has no type for it. */
|
|
12
|
+
type: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Does a file satisfy an `accept` attribute?
|
|
17
|
+
*
|
|
18
|
+
* `accept` is written the way the HTML attribute is: a comma separated list of
|
|
19
|
+
* extensions (`.wav`), MIME types (`audio/wav`) and type groups (`audio/*`).
|
|
20
|
+
* Anything that matches one entry is accepted, and an empty or missing
|
|
21
|
+
* `accept` takes everything.
|
|
22
|
+
*
|
|
23
|
+
* **The browser's own `accept` is only a hint to the file picker.** A person
|
|
24
|
+
* can switch it to "All Files", drag a file in, or pick one the picker was
|
|
25
|
+
* never asked about, so what arrives still has to be checked.
|
|
26
|
+
*
|
|
27
|
+
* A rule that cannot be decided is not treated as a rejection: with no `name`,
|
|
28
|
+
* an extension rule says nothing either way, and `accept=".wav"` reports a
|
|
29
|
+
* match rather than refusing a file it has not seen the name of. The name is
|
|
30
|
+
* there by the time the file is dropped, which is when the answer counts.
|
|
31
|
+
*/
|
|
32
|
+
export function matchesAccept(
|
|
33
|
+
candidate: AcceptCandidate,
|
|
34
|
+
accept?: string,
|
|
35
|
+
): boolean {
|
|
36
|
+
const rules = (accept ?? '')
|
|
37
|
+
.split(',')
|
|
38
|
+
.map((rule) => rule.trim().toLowerCase())
|
|
39
|
+
.filter(Boolean)
|
|
40
|
+
if (rules.length === 0) return true
|
|
41
|
+
|
|
42
|
+
const name = candidate.name?.toLowerCase()
|
|
43
|
+
const type = candidate.type.toLowerCase()
|
|
44
|
+
let undecided = false
|
|
45
|
+
|
|
46
|
+
for (const rule of rules) {
|
|
47
|
+
if (rule.startsWith('.')) {
|
|
48
|
+
if (name === undefined) undecided = true
|
|
49
|
+
else if (name.endsWith(rule)) return true
|
|
50
|
+
} else if (rule.endsWith('/*')) {
|
|
51
|
+
if (type.startsWith(rule.slice(0, -1))) return true
|
|
52
|
+
} else if (type === rule) {
|
|
53
|
+
return true
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return undecided
|
|
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
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { matchesAccept, partitionByAccept } from './accept'
|
|
2
|
+
|
|
3
|
+
/** What is in the air over the element. */
|
|
4
|
+
export interface DropZoneState {
|
|
5
|
+
/** Files are being dragged over the element. */
|
|
6
|
+
over: boolean
|
|
7
|
+
/**
|
|
8
|
+
* None of what is being dragged matches `accept`, as far as can be told
|
|
9
|
+
* before the drop.
|
|
10
|
+
*
|
|
11
|
+
* The browser reports the type of what is being dragged but withholds the
|
|
12
|
+
* name, so a rule written as an extension cannot be decided yet and is not
|
|
13
|
+
* counted against the drag. It is decided on the drop, where the name is.
|
|
14
|
+
*/
|
|
15
|
+
invalid: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DropZoneOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Which files to take, written the way the `accept` attribute of a file
|
|
21
|
+
* input is: a comma separated list of extensions (`.wav`), MIME types
|
|
22
|
+
* (`audio/wav`) and type groups (`audio/*`).
|
|
23
|
+
*/
|
|
24
|
+
accept?: string
|
|
25
|
+
/**
|
|
26
|
+
* Take more than one file from a single drop. With it off, only the first
|
|
27
|
+
* accepted file is reported, as a file input without `multiple` does.
|
|
28
|
+
*
|
|
29
|
+
* @default false
|
|
30
|
+
*/
|
|
31
|
+
multiple?: boolean
|
|
32
|
+
/**
|
|
33
|
+
* Refuse the drop. The drag is still swallowed rather than let through: an
|
|
34
|
+
* unhandled drop makes the browser leave the page and open the file.
|
|
35
|
+
*
|
|
36
|
+
* @default false
|
|
37
|
+
*/
|
|
38
|
+
disabled?: boolean
|
|
39
|
+
|
|
40
|
+
/** Called with the dropped files that match `accept`. */
|
|
41
|
+
onDrop?: (files: File[], event: DragEvent) => void
|
|
42
|
+
/**
|
|
43
|
+
* Called with the dropped files that do not match `accept`, so that the
|
|
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.
|
|
46
|
+
*/
|
|
47
|
+
onReject?: (files: File[], event: DragEvent) => void
|
|
48
|
+
/** Called whenever {@link DropZoneInstance.state} would change. */
|
|
49
|
+
onStateChange?: (state: DropZoneState) => void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface DropZoneInstance {
|
|
53
|
+
/** What is in the air over the element, right now. */
|
|
54
|
+
readonly state: DropZoneState
|
|
55
|
+
/** Replace the given options, keeping the listeners in place. */
|
|
56
|
+
update: (options: DropZoneOptions) => void
|
|
57
|
+
destroy: () => void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Take files dropped onto an element.
|
|
62
|
+
*
|
|
63
|
+
* ```ts
|
|
64
|
+
* const zone = createDropZone(element, {
|
|
65
|
+
* accept: 'audio/*',
|
|
66
|
+
* onDrop: (files) => load(files[0]),
|
|
67
|
+
* })
|
|
68
|
+
* ```
|
|
69
|
+
*
|
|
70
|
+
* The element needs no attribute of its own: a drop target is made by
|
|
71
|
+
* cancelling `dragover`, which this does.
|
|
72
|
+
*/
|
|
73
|
+
export function createDropZone(
|
|
74
|
+
element: Element,
|
|
75
|
+
options: DropZoneOptions = {},
|
|
76
|
+
): DropZoneInstance {
|
|
77
|
+
let opts = options
|
|
78
|
+
let state: DropZoneState = { over: false, invalid: false }
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `dragenter` and `dragleave` fire for descendants too, so moving between
|
|
82
|
+
* two children of the zone leaves before it enters. Counting the pairs is
|
|
83
|
+
* what keeps the state from flickering off in the middle of the element.
|
|
84
|
+
*/
|
|
85
|
+
let depth = 0
|
|
86
|
+
|
|
87
|
+
function setState(next: DropZoneState) {
|
|
88
|
+
if (next.over === state.over && next.invalid === state.invalid) return
|
|
89
|
+
state = next
|
|
90
|
+
opts.onStateChange?.(state)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function carriesFiles(transfer: DataTransfer | null) {
|
|
94
|
+
// `types` is the only thing that can be trusted during a drag; `files` is
|
|
95
|
+
// empty until the drop.
|
|
96
|
+
return !!transfer?.types.includes('Files')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Whether anything being dragged could still be accepted. */
|
|
100
|
+
function anyAcceptable(transfer: DataTransfer | null) {
|
|
101
|
+
const items = Array.from(transfer?.items ?? []).filter(
|
|
102
|
+
(item) => item.kind === 'file',
|
|
103
|
+
)
|
|
104
|
+
// Some browsers hand over no items at all, only the `Files` type. Nothing
|
|
105
|
+
// is known, so nothing is refused.
|
|
106
|
+
if (items.length === 0) return true
|
|
107
|
+
return items.some((item) => matchesAccept({ type: item.type }, opts.accept))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function onDragEnter(event: DragEvent) {
|
|
111
|
+
if (!carriesFiles(event.dataTransfer)) return
|
|
112
|
+
// Cancelled as well as `dragover`: a target that only cancels one of the
|
|
113
|
+
// two is not a drop target in every browser.
|
|
114
|
+
event.preventDefault()
|
|
115
|
+
depth += 1
|
|
116
|
+
setState({ over: true, invalid: !anyAcceptable(event.dataTransfer) })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function onDragOver(event: DragEvent) {
|
|
120
|
+
if (!carriesFiles(event.dataTransfer)) return
|
|
121
|
+
event.preventDefault()
|
|
122
|
+
if (event.dataTransfer) {
|
|
123
|
+
// Decides the cursor the pointer shows, and whether a drop is offered.
|
|
124
|
+
event.dataTransfer.dropEffect =
|
|
125
|
+
opts.disabled || state.invalid ? 'none' : 'copy'
|
|
126
|
+
}
|
|
127
|
+
// A drag that began outside the document can arrive without a `dragenter`
|
|
128
|
+
// the listener saw, so the state is settled here too.
|
|
129
|
+
if (!state.over) {
|
|
130
|
+
depth = Math.max(depth, 1)
|
|
131
|
+
setState({ over: true, invalid: !anyAcceptable(event.dataTransfer) })
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function onDragLeave(event: DragEvent) {
|
|
136
|
+
if (!carriesFiles(event.dataTransfer)) return
|
|
137
|
+
depth = Math.max(0, depth - 1)
|
|
138
|
+
if (depth === 0) setState({ over: false, invalid: false })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function onDrop(event: DragEvent) {
|
|
142
|
+
if (!carriesFiles(event.dataTransfer)) return
|
|
143
|
+
// Always cancelled, even while disabled: an unhandled drop makes the
|
|
144
|
+
// browser leave the page and open the file.
|
|
145
|
+
event.preventDefault()
|
|
146
|
+
depth = 0
|
|
147
|
+
setState({ over: false, invalid: false })
|
|
148
|
+
if (opts.disabled) return
|
|
149
|
+
|
|
150
|
+
const { accepted, rejected } = partitionByAccept(
|
|
151
|
+
event.dataTransfer?.files ?? [],
|
|
152
|
+
opts.accept,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
const taken = opts.multiple ? accepted : accepted.slice(0, 1)
|
|
156
|
+
if (taken.length > 0) opts.onDrop?.(taken, event)
|
|
157
|
+
if (rejected.length > 0) opts.onReject?.(rejected, event)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A drag that ends anywhere else — dropped on another element, cancelled
|
|
162
|
+
* with Esc, taken out of the window — sends no `dragleave` here, and the
|
|
163
|
+
* element would stay marked as a drop target for good.
|
|
164
|
+
*/
|
|
165
|
+
function onDragEndAnywhere() {
|
|
166
|
+
depth = 0
|
|
167
|
+
setState({ over: false, invalid: false })
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const handlers = {
|
|
171
|
+
dragenter: onDragEnter,
|
|
172
|
+
dragover: onDragOver,
|
|
173
|
+
dragleave: onDragLeave,
|
|
174
|
+
drop: onDrop,
|
|
175
|
+
} as const
|
|
176
|
+
|
|
177
|
+
for (const [type, handler] of Object.entries(handlers)) {
|
|
178
|
+
element.addEventListener(type, handler as EventListener)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const doc = element.ownerDocument
|
|
182
|
+
doc?.addEventListener('dragend', onDragEndAnywhere)
|
|
183
|
+
doc?.addEventListener('drop', onDragEndAnywhere)
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
get state() {
|
|
187
|
+
return state
|
|
188
|
+
},
|
|
189
|
+
update: (next) => {
|
|
190
|
+
opts = { ...opts, ...next }
|
|
191
|
+
},
|
|
192
|
+
destroy: () => {
|
|
193
|
+
for (const [type, handler] of Object.entries(handlers)) {
|
|
194
|
+
element.removeEventListener(type, handler as EventListener)
|
|
195
|
+
}
|
|
196
|
+
doc?.removeEventListener('dragend', onDragEndAnywhere)
|
|
197
|
+
doc?.removeEventListener('drop', onDragEndAnywhere)
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,52 @@ export {
|
|
|
13
13
|
type DrawingState,
|
|
14
14
|
type DrawingStateValue,
|
|
15
15
|
} from './canvas/context'
|
|
16
|
+
export {
|
|
17
|
+
matchesAccept,
|
|
18
|
+
partitionByAccept,
|
|
19
|
+
type AcceptCandidate,
|
|
20
|
+
} from './file/accept'
|
|
21
|
+
export {
|
|
22
|
+
createDropZone,
|
|
23
|
+
type DropZoneInstance,
|
|
24
|
+
type DropZoneOptions,
|
|
25
|
+
type DropZoneState,
|
|
26
|
+
} from './file/drop-zone'
|
|
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'
|
|
44
|
+
export {
|
|
45
|
+
mapModifier,
|
|
46
|
+
selectModifier,
|
|
47
|
+
type InputEventOption,
|
|
48
|
+
type Modifier,
|
|
49
|
+
type ModifierMap,
|
|
50
|
+
type ModifierState,
|
|
51
|
+
type ModifierValue,
|
|
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'
|
|
16
62
|
export {
|
|
17
63
|
createMIDIAccess,
|
|
18
64
|
NOT_SUPPORTED,
|
|
@@ -30,12 +76,59 @@ export {
|
|
|
30
76
|
type MIDIInputInstance,
|
|
31
77
|
} from './midi/input'
|
|
32
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'
|
|
100
|
+
export {
|
|
101
|
+
blackKeyWidth,
|
|
102
|
+
fitWhiteKeyWidth,
|
|
103
|
+
getNoteRangeArray,
|
|
104
|
+
noteAt,
|
|
105
|
+
notePosition,
|
|
106
|
+
pianoWidth,
|
|
107
|
+
type NoteRange,
|
|
108
|
+
type PianoLayout,
|
|
109
|
+
} from './piano/layout'
|
|
110
|
+
export {
|
|
111
|
+
SHORTCUTS,
|
|
112
|
+
type KeyboardShortcuts,
|
|
113
|
+
type KeyboardShortcutsScope,
|
|
114
|
+
} from './piano/shortcuts'
|
|
33
115
|
export {
|
|
34
116
|
createPianoInput,
|
|
35
117
|
type NoteSource,
|
|
36
118
|
type PianoInputInstance,
|
|
37
119
|
type PianoInputOptions,
|
|
38
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'
|
|
39
132
|
export {
|
|
40
133
|
createDrag,
|
|
41
134
|
type DragInstance,
|
|
@@ -52,11 +145,17 @@ export {
|
|
|
52
145
|
type DragValueOptions,
|
|
53
146
|
type MappingContext,
|
|
54
147
|
} from './pointer/drag-value'
|
|
148
|
+
export {
|
|
149
|
+
createLongPress,
|
|
150
|
+
type LongPressInstance,
|
|
151
|
+
type LongPressOptions,
|
|
152
|
+
} from './pointer/long-press'
|
|
55
153
|
export {
|
|
56
154
|
createWheel,
|
|
57
155
|
type WheelInstance,
|
|
58
156
|
type WheelOptions,
|
|
59
157
|
} from './pointer/wheel'
|
|
158
|
+
export { valuePercent } from './position'
|
|
60
159
|
export {
|
|
61
160
|
createSelectionBox,
|
|
62
161
|
selectionBoxCovers,
|
|
@@ -65,4 +164,6 @@ export {
|
|
|
65
164
|
type SelectionBoxOptions,
|
|
66
165
|
type SelectionBoxRect,
|
|
67
166
|
} from './selection/box'
|
|
167
|
+
export { sliderMarks, type MarksOptions, type SliderMark } from './slider/marks'
|
|
168
|
+
export { cssLength, visuallyHiddenStyle } from './style'
|
|
68
169
|
export { toXY, type XY, type XYInput } from './xy'
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clamp,
|
|
3
|
+
linearScale,
|
|
4
|
+
stepValue,
|
|
5
|
+
toPrecision,
|
|
6
|
+
type ValueRange,
|
|
7
|
+
} from '@tremolo-ui/functions'
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
selectModifier,
|
|
11
|
+
type InputEventOption,
|
|
12
|
+
type ModifierState,
|
|
13
|
+
type ModifierValue,
|
|
14
|
+
} from './modifiers'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Move a value by an amount of input, as reported by a wheel or an arrow key.
|
|
18
|
+
*
|
|
19
|
+
* The pipeline matches {@link createDragValue}: scale, then step, then clamp.
|
|
20
|
+
* Which key or which sign of `deltaY` counts as which direction is left to the
|
|
21
|
+
* caller, since it differs per component.
|
|
22
|
+
*
|
|
23
|
+
* @param direction which way, and how many times, to apply the option. The
|
|
24
|
+
* size of one step is `option[1]`, so this is normally `1` or `-1`.
|
|
25
|
+
*
|
|
26
|
+
* @param modifiers the event, for `options` that name a modifier key. See
|
|
27
|
+
* {@link selectModifier}.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* // ArrowDown on a slider whose keyboard option is ['raw', 1]
|
|
31
|
+
* applyDelta(value, -1, keyboard, { min, max, step, scale })
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* // Shift+ArrowDown, where `keyboard` is { default: …, shift: ['raw', 0.1] }
|
|
35
|
+
* applyDelta(value, -1, keyboard, range, event)
|
|
36
|
+
*/
|
|
37
|
+
export function applyDelta(
|
|
38
|
+
value: number,
|
|
39
|
+
direction: number,
|
|
40
|
+
options: ModifierValue<InputEventOption>,
|
|
41
|
+
{ min, max, step, scale = linearScale }: ValueRange,
|
|
42
|
+
modifiers?: ModifierState,
|
|
43
|
+
): number {
|
|
44
|
+
if (min >= max) throw new RangeError('requirements: min < max')
|
|
45
|
+
if (step !== undefined && (!Number.isFinite(step) || step <= 0)) {
|
|
46
|
+
throw new RangeError(
|
|
47
|
+
'applyDelta step: requirements: finite and greater than 0',
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const {
|
|
52
|
+
value: [mode, amount],
|
|
53
|
+
modifier,
|
|
54
|
+
} = selectModifier(options, modifiers)
|
|
55
|
+
|
|
56
|
+
const x = direction * amount
|
|
57
|
+
const next =
|
|
58
|
+
mode === 'normalized'
|
|
59
|
+
? scale.denormalize(scale.normalize(value, min, max) + x, min, max)
|
|
60
|
+
: value + x
|
|
61
|
+
|
|
62
|
+
// Naming a modifier is a deliberate request to move off the grid, so `step`
|
|
63
|
+
// does not apply to it. Without this a finer amount would round straight
|
|
64
|
+
// back to where it started: `stepValue(3 + 0.1, 1)` is 3.
|
|
65
|
+
const quantum = modifier === null ? step : undefined
|
|
66
|
+
const stepped = quantum !== undefined ? stepValue(next, quantum) : next
|
|
67
|
+
|
|
68
|
+
// Rounded before the clamp, so that `min` and `max` still have the last
|
|
69
|
+
// word and the value can land on them exactly. Without this the artefact
|
|
70
|
+
// accumulates: with no `step` to round it back, twelve presses of a 0.1
|
|
71
|
+
// modifier amount reach 5.699999999999998 rather than 5.7.
|
|
72
|
+
return clamp(toPrecision(stepped), min, max)
|
|
73
|
+
}
|
|
@@ -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
|
+
}
|