@tremolo-ui/dom 0.4.0 → 0.5.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 +541 -55
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +366 -12
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +366 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +534 -56
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
- package/src/canvas/animation.ts +304 -0
- package/src/canvas/context.ts +81 -0
- package/src/index.ts +37 -1
- package/src/piano/input.ts +185 -0
- package/src/pointer/drag.ts +137 -57
- package/src/pointer/dragValue.ts +229 -0
- package/src/pointer/wheel.ts +34 -1
- package/src/xy.ts +38 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clamp,
|
|
3
|
+
linearScale,
|
|
4
|
+
normalizeValue,
|
|
5
|
+
stepValue,
|
|
6
|
+
type ValueRange,
|
|
7
|
+
} from '@tremolo-ui/functions'
|
|
8
|
+
|
|
9
|
+
import { toXY, type XY, type XYInput } from '../xy'
|
|
10
|
+
|
|
11
|
+
import { createDrag, type DragState } from './drag'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* How the 0-1 travel of one axis maps onto a value.
|
|
15
|
+
*
|
|
16
|
+
* Extends `ValueRange` so that a drag and an `applyDelta` nudge from a wheel
|
|
17
|
+
* or an arrow key share one description of the scaling.
|
|
18
|
+
*/
|
|
19
|
+
export interface AxisOptions extends ValueRange {
|
|
20
|
+
/**
|
|
21
|
+
* Flip the axis so that its far end is `min`.
|
|
22
|
+
*
|
|
23
|
+
* Positions follow the screen: x grows to the right, y downwards. A vertical
|
|
24
|
+
* slider whose maximum is at the top therefore reverses its y axis.
|
|
25
|
+
*
|
|
26
|
+
* @default false
|
|
27
|
+
*/
|
|
28
|
+
reverse?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Where the value of each axis currently sits, as a position (see {@link DragValueMapping}). */
|
|
32
|
+
export interface MappingContext {
|
|
33
|
+
position: () => XY<number>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Turns pointer movement into a position on each axis: 0 is the `min` end of
|
|
38
|
+
* the travel and 1 the `max` end, before `reverse` and the scaling of
|
|
39
|
+
* {@link AxisOptions} are applied.
|
|
40
|
+
*
|
|
41
|
+
* A mapping holds the state of the drag in progress, so an instance belongs to
|
|
42
|
+
* a single {@link createDragValue} instance.
|
|
43
|
+
*/
|
|
44
|
+
export interface DragValueMapping {
|
|
45
|
+
/**
|
|
46
|
+
* @returns the position, or null when it cannot be determined and the event
|
|
47
|
+
* should be ignored.
|
|
48
|
+
*/
|
|
49
|
+
start: (state: DragState, context: MappingContext) => XY<number> | null
|
|
50
|
+
move: (state: DragState, context: MappingContext) => XY<number> | null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Map the pointer onto the bounding rect of an element: the value *is* the
|
|
55
|
+
* position pointed at, so the middle of the element is 0.5.
|
|
56
|
+
*
|
|
57
|
+
* The element is read on every event, so it may be mounted after the drag is
|
|
58
|
+
* set up and may change size while a drag is in progress.
|
|
59
|
+
*/
|
|
60
|
+
export function elementMapping(
|
|
61
|
+
getElement: () => Element | null | undefined,
|
|
62
|
+
): DragValueMapping {
|
|
63
|
+
function positionIn(state: DragState): XY<number> | null {
|
|
64
|
+
const element = getElement()
|
|
65
|
+
if (!element) return null
|
|
66
|
+
const { left, top, right, bottom } = element.getBoundingClientRect()
|
|
67
|
+
// A collapsed element has no travel to normalize against, and
|
|
68
|
+
// normalizeValue rejects an empty range.
|
|
69
|
+
return [
|
|
70
|
+
right > left ? normalizeValue(state.clientX, left, right) : 0,
|
|
71
|
+
bottom > top ? normalizeValue(state.clientY, top, bottom) : 0,
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { start: positionIn, move: positionIn }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Move the value away from where it stood when the drag started, by the
|
|
80
|
+
* distance dragged. The pointer position itself carries no meaning, so the
|
|
81
|
+
* value can be adjusted from anywhere on the screen.
|
|
82
|
+
*/
|
|
83
|
+
export function relativeMapping({
|
|
84
|
+
pixelRange = 100,
|
|
85
|
+
}: {
|
|
86
|
+
/**
|
|
87
|
+
* Pixels of movement that span the whole range.
|
|
88
|
+
* @default 100
|
|
89
|
+
*/
|
|
90
|
+
pixelRange?: XYInput<number>
|
|
91
|
+
} = {}): DragValueMapping {
|
|
92
|
+
const [rangeX, rangeY] = toXY(pixelRange)
|
|
93
|
+
let origin: XY<number> = [0, 0]
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
start: (_state, context) => {
|
|
97
|
+
origin = context.position()
|
|
98
|
+
return origin
|
|
99
|
+
},
|
|
100
|
+
// `x` and `y` are measured from the start of the drag, so the position
|
|
101
|
+
// never accumulates a rounding error of its own.
|
|
102
|
+
move: (state) => [
|
|
103
|
+
origin[0] + state.x / rangeX,
|
|
104
|
+
origin[1] + state.y / rangeY,
|
|
105
|
+
],
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface DragValueOptions {
|
|
110
|
+
/** Scaling of each axis; a single value applies to both. */
|
|
111
|
+
axis: XYInput<AxisOptions>
|
|
112
|
+
|
|
113
|
+
/** How pointer movement becomes a position. */
|
|
114
|
+
mapping: DragValueMapping
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The current value of each axis. Read when a drag starts, by mappings that
|
|
118
|
+
* move the value relative to it, such as {@link relativeMapping}.
|
|
119
|
+
*/
|
|
120
|
+
getValue?: () => XY<number>
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Report the value on pointer down, before any movement.
|
|
124
|
+
*
|
|
125
|
+
* Enable it where the pointer position *is* the value, so that a plain click
|
|
126
|
+
* jumps to it. Leave it off where the element being dragged is an object in
|
|
127
|
+
* its own right, so that grabbing its edge does not shift it under the
|
|
128
|
+
* pointer.
|
|
129
|
+
*
|
|
130
|
+
* @default false
|
|
131
|
+
*/
|
|
132
|
+
updateOnPointerDown?: boolean
|
|
133
|
+
|
|
134
|
+
/** @see DragOptions.threshold */
|
|
135
|
+
threshold?: number
|
|
136
|
+
/** @see DragOptions.cursor */
|
|
137
|
+
cursor?: string
|
|
138
|
+
|
|
139
|
+
onChange?: (value: XY<number>, state: DragState) => void
|
|
140
|
+
onDragStart?: (value: XY<number>, state: DragState) => void
|
|
141
|
+
onDragEnd?: (value: XY<number>, state: DragState) => void
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface DragValueInstance {
|
|
145
|
+
/**
|
|
146
|
+
* Replace the given options. Lets a wrapper feed fresh values in without
|
|
147
|
+
* tearing down the listeners, which would abort a drag in progress.
|
|
148
|
+
*
|
|
149
|
+
* `mapping` is fixed for the lifetime of the instance and is ignored here.
|
|
150
|
+
*/
|
|
151
|
+
update: (options: Partial<DragValueOptions>) => void
|
|
152
|
+
destroy: () => void
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Drive a value with a pointer drag.
|
|
157
|
+
*
|
|
158
|
+
* Combines {@link createDrag} with the scaling of `@tremolo-ui/functions`: the
|
|
159
|
+
* mapping decides where the pointer sits on the 0-1 travel of each axis, and
|
|
160
|
+
* the axis options turn that into a value.
|
|
161
|
+
*/
|
|
162
|
+
export function createDragValue(
|
|
163
|
+
element: Element,
|
|
164
|
+
options: DragValueOptions,
|
|
165
|
+
): DragValueInstance {
|
|
166
|
+
let opts = options
|
|
167
|
+
let lastValue: XY<number> = [0, 0]
|
|
168
|
+
|
|
169
|
+
const axes = () => toXY(opts.axis)
|
|
170
|
+
|
|
171
|
+
function valueOf(position: XY<number>): XY<number> {
|
|
172
|
+
return axes().map((axis, i) => {
|
|
173
|
+
const p = axis.reverse ? 1 - position[i] : position[i]
|
|
174
|
+
// A scale clamps the position, so a mapping may report outside 0-1.
|
|
175
|
+
const scale = axis.scale ?? linearScale
|
|
176
|
+
const value = scale.denormalize(p, axis.min, axis.max)
|
|
177
|
+
const stepped = axis.step ? stepValue(value, axis.step) : value
|
|
178
|
+
// Rounding to the step can leave the range.
|
|
179
|
+
return clamp(stepped, axis.min, axis.max)
|
|
180
|
+
}) as XY<number>
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const context: MappingContext = {
|
|
184
|
+
position: () => {
|
|
185
|
+
const getValue = opts.getValue
|
|
186
|
+
if (!getValue) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
'createDragValue: getValue is required by the given mapping',
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
const value = getValue()
|
|
192
|
+
return axes().map((axis, i) => {
|
|
193
|
+
const scale = axis.scale ?? linearScale
|
|
194
|
+
const n = scale.normalize(value[i], axis.min, axis.max)
|
|
195
|
+
return axis.reverse ? 1 - n : n
|
|
196
|
+
}) as XY<number>
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const drag = createDrag(element, {
|
|
201
|
+
threshold: opts.threshold,
|
|
202
|
+
cursor: opts.cursor,
|
|
203
|
+
onDragStart: (state) => {
|
|
204
|
+
const position = opts.mapping.start(state, context)
|
|
205
|
+
if (position) {
|
|
206
|
+
lastValue = valueOf(position)
|
|
207
|
+
if (opts.updateOnPointerDown) opts.onChange?.(lastValue, state)
|
|
208
|
+
}
|
|
209
|
+
opts.onDragStart?.(lastValue, state)
|
|
210
|
+
},
|
|
211
|
+
onDrag: (state) => {
|
|
212
|
+
const position = opts.mapping.move(state, context)
|
|
213
|
+
if (!position) return
|
|
214
|
+
lastValue = valueOf(position)
|
|
215
|
+
opts.onChange?.(lastValue, state)
|
|
216
|
+
},
|
|
217
|
+
// The pointer has not moved since the last reported value, so `lastValue`
|
|
218
|
+
// is where the drag ended.
|
|
219
|
+
onDragEnd: (state) => opts.onDragEnd?.(lastValue, state),
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
update: (next) => {
|
|
224
|
+
opts = { ...opts, ...next, mapping: opts.mapping }
|
|
225
|
+
drag.update({ threshold: opts.threshold, cursor: opts.cursor })
|
|
226
|
+
},
|
|
227
|
+
destroy: () => drag.destroy(),
|
|
228
|
+
}
|
|
229
|
+
}
|
package/src/pointer/wheel.ts
CHANGED
|
@@ -1,4 +1,23 @@
|
|
|
1
|
+
export interface WheelOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Only report events while the focus is inside the element.
|
|
4
|
+
*
|
|
5
|
+
* A control that reacts to the wheel on hover alone takes the scroll away
|
|
6
|
+
* from the page, so passing over one in a long form silently changes its
|
|
7
|
+
* value. Requiring focus makes that an explicit act.
|
|
8
|
+
*
|
|
9
|
+
* The check is `contains`, not an identity test: the element that actually
|
|
10
|
+
* takes focus is usually a descendant, such as a thumb or an `<input>`, and
|
|
11
|
+
* a caller may have replaced it with markup of their own.
|
|
12
|
+
*
|
|
13
|
+
* @default false
|
|
14
|
+
*/
|
|
15
|
+
requireFocus?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
1
18
|
export interface WheelInstance {
|
|
19
|
+
/** Replace the given options, keeping the listener in place. */
|
|
20
|
+
update: (options: WheelOptions) => void
|
|
2
21
|
destroy: () => void
|
|
3
22
|
}
|
|
4
23
|
|
|
@@ -11,12 +30,26 @@ export interface WheelInstance {
|
|
|
11
30
|
export function createWheel(
|
|
12
31
|
element: Element,
|
|
13
32
|
onWheel: (event: WheelEvent) => void,
|
|
33
|
+
options: WheelOptions = {},
|
|
14
34
|
): WheelInstance {
|
|
15
|
-
|
|
35
|
+
let opts = options
|
|
36
|
+
|
|
37
|
+
function hasFocus() {
|
|
38
|
+
const active = element.ownerDocument?.activeElement
|
|
39
|
+
return !!active && element.contains(active)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const handler = (event: Event) => {
|
|
43
|
+
if (opts.requireFocus && !hasFocus()) return
|
|
44
|
+
onWheel(event as WheelEvent)
|
|
45
|
+
}
|
|
16
46
|
|
|
17
47
|
element.addEventListener('wheel', handler, { passive: false })
|
|
18
48
|
|
|
19
49
|
return {
|
|
50
|
+
update: (next) => {
|
|
51
|
+
opts = { ...opts, ...next }
|
|
52
|
+
},
|
|
20
53
|
destroy: () => {
|
|
21
54
|
// Only `capture` matters when removing, and it is false here.
|
|
22
55
|
element.removeEventListener('wheel', handler)
|
package/src/xy.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A pair of per-axis values. The tuple elements are labelled, so editors show
|
|
3
|
+
* `[x: number, y: number]` rather than a bare pair.
|
|
4
|
+
*/
|
|
5
|
+
export type XY<T> = [x: T, y: T]
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A setting that may be given once for both axes, or per axis.
|
|
9
|
+
*
|
|
10
|
+
* A single value is told from a pair with `Array.isArray`, so the single form
|
|
11
|
+
* is only offered while `T` cannot itself be an array. Where it can, the pair
|
|
12
|
+
* is the only way to write it, since a lone array would be read as a pair.
|
|
13
|
+
*/
|
|
14
|
+
export type XYInput<T> = [T] extends [readonly unknown[]]
|
|
15
|
+
? readonly [x: T, y: T]
|
|
16
|
+
: T | readonly [x: T, y: T]
|
|
17
|
+
|
|
18
|
+
// `Array.isArray` narrows a mutable tuple on its own, but not a readonly one,
|
|
19
|
+
// hence the explicit predicate.
|
|
20
|
+
function isPair<T>(
|
|
21
|
+
value: T | readonly [x: T, y: T],
|
|
22
|
+
): value is readonly [x: T, y: T] {
|
|
23
|
+
return Array.isArray(value)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Spread a setting that may have been given as a single value.
|
|
28
|
+
*
|
|
29
|
+
* The parameter is written out rather than taken as `XYInput<T>`: a
|
|
30
|
+
* conditional type cannot be narrowed, so the constraint stays where it is
|
|
31
|
+
* declared and this takes both forms.
|
|
32
|
+
*
|
|
33
|
+
* The pair is copied rather than passed along, so that the result is a tuple
|
|
34
|
+
* the caller owns even when a readonly one was given.
|
|
35
|
+
*/
|
|
36
|
+
export function toXY<T>(value: T | readonly [x: T, y: T]): XY<T> {
|
|
37
|
+
return isPair(value) ? [value[0], value[1]] : [value, value]
|
|
38
|
+
}
|