@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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export interface LongPressOptions {
|
|
2
|
+
/** Called once on the press, then again every `interval` after `delay`. */
|
|
3
|
+
onPress: () => void
|
|
4
|
+
/**
|
|
5
|
+
* How long the press has to be held before it starts repeating, in
|
|
6
|
+
* milliseconds.
|
|
7
|
+
*
|
|
8
|
+
* @default 500
|
|
9
|
+
*/
|
|
10
|
+
delay?: number
|
|
11
|
+
/**
|
|
12
|
+
* How often it repeats once it has started, in milliseconds.
|
|
13
|
+
*
|
|
14
|
+
* @default 40
|
|
15
|
+
*/
|
|
16
|
+
interval?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LongPressInstance {
|
|
20
|
+
/**
|
|
21
|
+
* Start a press, from a `pointerdown` or with no event at all. Only the
|
|
22
|
+
* primary button starts one, and a press already in progress is left alone.
|
|
23
|
+
*/
|
|
24
|
+
start: (event?: Pick<PointerEvent, 'button' | 'pointerId'>) => void
|
|
25
|
+
/** End the press, as releasing the pointer would. */
|
|
26
|
+
stop: () => void
|
|
27
|
+
/** Replace the given options. The press in progress picks them up. */
|
|
28
|
+
update: (options: Partial<LongPressOptions>) => void
|
|
29
|
+
/** Whether a press is in progress. */
|
|
30
|
+
pressed: () => boolean
|
|
31
|
+
destroy: () => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Repeat an action while a pointer is held down: once on the press, then
|
|
36
|
+
* again every `interval` after `delay` — the way a stepper button or a
|
|
37
|
+
* key held on a keyboard behaves.
|
|
38
|
+
*
|
|
39
|
+
* The release is listened for on the window, not on the element: the
|
|
40
|
+
* pointer may be let go anywhere. Only the pointer that started the press
|
|
41
|
+
* ends it, so a second finger lifting elsewhere does not. The window losing
|
|
42
|
+
* focus ends it too, since the release would never arrive.
|
|
43
|
+
*/
|
|
44
|
+
export function createLongPress(options: LongPressOptions): LongPressInstance {
|
|
45
|
+
let opts = options
|
|
46
|
+
let pointerId: number | null = null
|
|
47
|
+
let active = false
|
|
48
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
49
|
+
|
|
50
|
+
function clearTimer() {
|
|
51
|
+
if (timer !== null) clearTimeout(timer)
|
|
52
|
+
timer = null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Wait `wait`, fire, and keep going at `interval` from then on. */
|
|
56
|
+
function schedule(wait: number) {
|
|
57
|
+
timer = setTimeout(() => {
|
|
58
|
+
opts.onPress()
|
|
59
|
+
if (active) schedule(opts.interval ?? 40)
|
|
60
|
+
}, wait)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function onPointerEnd(event: PointerEvent) {
|
|
64
|
+
// Pointer events carry an id; a press started without one ends on any.
|
|
65
|
+
if (pointerId !== null && event.pointerId !== pointerId) return
|
|
66
|
+
stop()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function listen(add: boolean) {
|
|
70
|
+
const target = globalThis.window
|
|
71
|
+
if (!target) return
|
|
72
|
+
const method = add ? 'addEventListener' : 'removeEventListener'
|
|
73
|
+
target[method]('pointerup', onPointerEnd as EventListener)
|
|
74
|
+
target[method]('pointercancel', onPointerEnd as EventListener)
|
|
75
|
+
target[method]('blur', stop)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function stop() {
|
|
79
|
+
if (!active) return
|
|
80
|
+
active = false
|
|
81
|
+
pointerId = null
|
|
82
|
+
clearTimer()
|
|
83
|
+
listen(false)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
start: (event) => {
|
|
88
|
+
if (active || (event && event.button !== 0)) return
|
|
89
|
+
active = true
|
|
90
|
+
pointerId = event?.pointerId ?? null
|
|
91
|
+
listen(true)
|
|
92
|
+
opts.onPress()
|
|
93
|
+
// The action may have stopped the press itself.
|
|
94
|
+
if (active) schedule(opts.delay ?? 500)
|
|
95
|
+
},
|
|
96
|
+
stop,
|
|
97
|
+
update: (next) => {
|
|
98
|
+
opts = { ...opts, ...next }
|
|
99
|
+
},
|
|
100
|
+
pressed: () => active,
|
|
101
|
+
destroy: stop,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { clamp, toPrecision } from '@tremolo-ui/functions'
|
|
2
|
+
|
|
3
|
+
import { applyDelta } from '../input/apply-delta'
|
|
4
|
+
import {
|
|
5
|
+
type InputEventOption,
|
|
6
|
+
type ModifierState,
|
|
7
|
+
type ModifierValue,
|
|
8
|
+
} from '../input/modifiers'
|
|
9
|
+
import { createSelectionBox, type SelectionBoxRect } from '../selection/box'
|
|
10
|
+
import { type XY } from '../xy'
|
|
11
|
+
|
|
12
|
+
/** Where a point is: 0..1 on each axis, with y growing downwards. */
|
|
13
|
+
export interface PointPosition {
|
|
14
|
+
x: number
|
|
15
|
+
y: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A point's value is its position in the editor, so its range is the editor:
|
|
20
|
+
* no scaling, and no rounding to a step.
|
|
21
|
+
*/
|
|
22
|
+
export const POINT_AXIS = { min: 0, max: 1 }
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How much one wheel notch moves a point by default: a hundredth of the
|
|
26
|
+
* editor, whatever its pixel size.
|
|
27
|
+
*/
|
|
28
|
+
export const POINTS_EDITOR_DEFAULT_WHEEL: ModifierValue<InputEventOption> = [
|
|
29
|
+
'normalized',
|
|
30
|
+
0.01,
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How much one arrow key press moves a point by default. Shift is the
|
|
35
|
+
* fine-adjustment key everywhere else, so it is bound here too — but only on
|
|
36
|
+
* the keyboard. On the wheel it already means the x axis, and browsers hand
|
|
37
|
+
* shift+wheel over as horizontal scrolling anyway.
|
|
38
|
+
*/
|
|
39
|
+
export const POINTS_EDITOR_DEFAULT_KEYBOARD: ModifierValue<InputEventOption> = {
|
|
40
|
+
default: ['normalized', 0.01],
|
|
41
|
+
shift: ['normalized', 0.001],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Keep a point within its range. An axis left out runs from 0 to 1. */
|
|
45
|
+
export function clampPoint(
|
|
46
|
+
point: PointPosition,
|
|
47
|
+
min?: Partial<PointPosition>,
|
|
48
|
+
max?: Partial<PointPosition>,
|
|
49
|
+
): PointPosition {
|
|
50
|
+
return {
|
|
51
|
+
x: clamp(point.x, min?.x ?? 0, max?.x ?? 1),
|
|
52
|
+
y: clamp(point.y, min?.y ?? 0, max?.y ?? 1),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What a point tells the editor about itself, so that a selection can be
|
|
58
|
+
* moved without the editor knowing how the points are stored.
|
|
59
|
+
*/
|
|
60
|
+
export interface PointsEditorPoint {
|
|
61
|
+
value: PointPosition
|
|
62
|
+
min?: Partial<PointPosition>
|
|
63
|
+
max?: Partial<PointPosition>
|
|
64
|
+
/** A point that cannot move stays put while the rest of a selection moves. */
|
|
65
|
+
readonly?: boolean
|
|
66
|
+
onChange?: (value: PointPosition) => void
|
|
67
|
+
/** The point's own element, to match the focus and a press against. */
|
|
68
|
+
element?: Element | null
|
|
69
|
+
/** The wheel option the point resolved, `null` for no wheel. */
|
|
70
|
+
wheel?: ModifierValue<InputEventOption> | null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface PointsEditorOptions {
|
|
74
|
+
/**
|
|
75
|
+
* Let points be selected, and a selection be moved as one. While it is off
|
|
76
|
+
* nothing is selected, and a drag moves only the point it started on.
|
|
77
|
+
*
|
|
78
|
+
* @default false
|
|
79
|
+
*/
|
|
80
|
+
selectable?: boolean
|
|
81
|
+
/**
|
|
82
|
+
* The ids of the selected points. Whoever holds the selection pushes it
|
|
83
|
+
* back with `update()` after {@link PointsEditorOptions.onSelectionChange}.
|
|
84
|
+
*/
|
|
85
|
+
selection?: readonly string[]
|
|
86
|
+
/** Called whenever a press or a selection box changes the selection. */
|
|
87
|
+
onSelectionChange?: (selection: string[]) => void
|
|
88
|
+
/** Called whenever the selection box changes, with `null` once it is gone. */
|
|
89
|
+
onSelectionBoxChange?: (rect: SelectionBoxRect | null) => void
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface PointsEditorInstance {
|
|
93
|
+
/** Replace the given options. */
|
|
94
|
+
update: (options: Partial<PointsEditorOptions>) => void
|
|
95
|
+
/**
|
|
96
|
+
* Register a point under `id`. `read` is called whenever the editor needs
|
|
97
|
+
* the point, so it can return what the point is now rather than what it
|
|
98
|
+
* was when it registered. Returns the function that unregisters it.
|
|
99
|
+
*/
|
|
100
|
+
registerPoint: (id: string, read: () => PointsEditorPoint) => () => void
|
|
101
|
+
/** Whether the element is a point, or inside one. */
|
|
102
|
+
isPointElement: (element: Element | null | undefined) => boolean
|
|
103
|
+
/**
|
|
104
|
+
* A pointer went down on a point: work out the new selection and remember
|
|
105
|
+
* where everything the drag picked up started.
|
|
106
|
+
*
|
|
107
|
+
* Ctrl / meta add the point to the selection or take it out: shift is the
|
|
108
|
+
* fine-adjustment key on every control here, and it cannot be both. A
|
|
109
|
+
* press on a point already selected keeps the selection, so the group can
|
|
110
|
+
* be dragged.
|
|
111
|
+
*/
|
|
112
|
+
beginPointDrag: (id: string, modifiers: ModifierState) => void
|
|
113
|
+
/**
|
|
114
|
+
* Move everything the drag picked up by `delta` from where it started,
|
|
115
|
+
* stopping the whole group together at the edge.
|
|
116
|
+
*/
|
|
117
|
+
movePointDrag: (delta: PointPosition) => void
|
|
118
|
+
/**
|
|
119
|
+
* Move `id` — and the selection, when it is part of one — by `delta` from
|
|
120
|
+
* where the points are now: an input that is not a drag has no earlier
|
|
121
|
+
* position to measure against.
|
|
122
|
+
*/
|
|
123
|
+
nudgeSelection: (id: string, delta: PointPosition) => void
|
|
124
|
+
/**
|
|
125
|
+
* Move `id` by one press of `option` along one axis, taking the selection
|
|
126
|
+
* along as {@link PointsEditorInstance.nudgeSelection} does.
|
|
127
|
+
*/
|
|
128
|
+
nudgePoint: (
|
|
129
|
+
id: string,
|
|
130
|
+
axis: 'x' | 'y',
|
|
131
|
+
direction: number,
|
|
132
|
+
option: ModifierValue<InputEventOption>,
|
|
133
|
+
modifiers?: ModifierState,
|
|
134
|
+
) => void
|
|
135
|
+
/**
|
|
136
|
+
* Move the point holding the focus by one wheel notch, with its own wheel
|
|
137
|
+
* option. The wheel is listened to once for the whole editor, since a wheel
|
|
138
|
+
* event only reaches what the cursor is over. Returns whether a point took
|
|
139
|
+
* it, so the caller knows whether to consume the event.
|
|
140
|
+
*/
|
|
141
|
+
nudgeFocusedPoint: (
|
|
142
|
+
axis: 'x' | 'y',
|
|
143
|
+
direction: number,
|
|
144
|
+
modifiers: ModifierState,
|
|
145
|
+
) => boolean
|
|
146
|
+
/** A drag on empty space started at `at`: start a selection box there. */
|
|
147
|
+
beginSelectionBox: (at: PointPosition, modifiers: ModifierState) => void
|
|
148
|
+
moveSelectionBox: (to: PointPosition) => void
|
|
149
|
+
/**
|
|
150
|
+
* End the selection box. The press that started it left the focus on
|
|
151
|
+
* nothing, and the arrow keys and the wheel reach a point only through the
|
|
152
|
+
* focus, so it is handed to one of the points the box selected.
|
|
153
|
+
*/
|
|
154
|
+
endSelectionBox: () => void
|
|
155
|
+
destroy: () => void
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const EMPTY: readonly string[] = []
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* How far a group may move before something in it leaves its range.
|
|
162
|
+
*
|
|
163
|
+
* Clamping each point on its own would break the shape of the selection: the
|
|
164
|
+
* one that reached the edge would stop while the rest carried on. One amount
|
|
165
|
+
* for all of them means the whole selection stops together.
|
|
166
|
+
*/
|
|
167
|
+
function allowedDelta(
|
|
168
|
+
delta: PointPosition,
|
|
169
|
+
entries: { start: PointPosition; point: PointsEditorPoint }[],
|
|
170
|
+
): PointPosition {
|
|
171
|
+
let loX = -Infinity
|
|
172
|
+
let hiX = Infinity
|
|
173
|
+
let loY = -Infinity
|
|
174
|
+
let hiY = Infinity
|
|
175
|
+
for (const { start, point } of entries) {
|
|
176
|
+
// A point that cannot move stays where it is, so its range says nothing
|
|
177
|
+
// about how far the rest may go.
|
|
178
|
+
if (point.readonly) continue
|
|
179
|
+
loX = Math.max(loX, (point.min?.x ?? 0) - start.x)
|
|
180
|
+
hiX = Math.min(hiX, (point.max?.x ?? 1) - start.x)
|
|
181
|
+
loY = Math.max(loY, (point.min?.y ?? 0) - start.y)
|
|
182
|
+
hiY = Math.min(hiY, (point.max?.y ?? 1) - start.y)
|
|
183
|
+
}
|
|
184
|
+
// A point that started outside its own range leaves nothing to move within.
|
|
185
|
+
return {
|
|
186
|
+
x: hiX < loX ? 0 : clamp(delta.x, loX, hiX),
|
|
187
|
+
y: hiY < loY ? 0 : clamp(delta.y, loY, hiY),
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The selection and the moves of a points editor: which points a press or a
|
|
193
|
+
* box selects, and how a selection moves as one.
|
|
194
|
+
*
|
|
195
|
+
* The points stay with the wrapper, which registers each one; the editor only
|
|
196
|
+
* reads them when it needs to, so a point's value can change on every frame
|
|
197
|
+
* of a drag without anything being re-registered.
|
|
198
|
+
*/
|
|
199
|
+
export function createPointsEditor(
|
|
200
|
+
options: PointsEditorOptions = {},
|
|
201
|
+
): PointsEditorInstance {
|
|
202
|
+
let opts = options
|
|
203
|
+
const points = new Map<string, () => PointsEditorPoint>()
|
|
204
|
+
/** What the current drag picked up, and where those points started. */
|
|
205
|
+
let dragged: { id: string; start: PointPosition }[] = []
|
|
206
|
+
|
|
207
|
+
const selection = () => (opts.selectable ? (opts.selection ?? EMPTY) : EMPTY)
|
|
208
|
+
|
|
209
|
+
function changeSelection(next: string[]) {
|
|
210
|
+
// Read back at once by the drag that follows, before the owner has had a
|
|
211
|
+
// chance to push it with update().
|
|
212
|
+
opts = { ...opts, selection: next }
|
|
213
|
+
opts.onSelectionChange?.(next)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function snapshot(ids: readonly string[]) {
|
|
217
|
+
return ids.flatMap((id) => {
|
|
218
|
+
const point = points.get(id)?.()
|
|
219
|
+
return point ? [{ id, start: { ...point.value } }] : []
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function moveFrom(
|
|
224
|
+
entries: { id: string; start: PointPosition }[],
|
|
225
|
+
delta: PointPosition,
|
|
226
|
+
) {
|
|
227
|
+
const withPoint = entries.flatMap((entry) => {
|
|
228
|
+
const point = points.get(entry.id)?.()
|
|
229
|
+
return point ? [{ ...entry, point }] : []
|
|
230
|
+
})
|
|
231
|
+
const allowed = allowedDelta(delta, withPoint)
|
|
232
|
+
for (const { start, point } of withPoint) {
|
|
233
|
+
if (point.readonly) continue
|
|
234
|
+
// Rounded here as well as in the pipeline: a move is a subtraction and
|
|
235
|
+
// an addition of its own, and that is enough to put the binary artefact
|
|
236
|
+
// back (0.2 + 0.1 lands on 0.30000000000000004).
|
|
237
|
+
point.onChange?.({
|
|
238
|
+
x: toPrecision(start.x + allowed.x),
|
|
239
|
+
y: toPrecision(start.y + allowed.y),
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function nudgeSelection(id: string, delta: PointPosition) {
|
|
245
|
+
const current = selection()
|
|
246
|
+
moveFrom(snapshot(current.includes(id) ? current : [id]), delta)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function nudgePoint(
|
|
250
|
+
id: string,
|
|
251
|
+
axis: 'x' | 'y',
|
|
252
|
+
direction: number,
|
|
253
|
+
option: ModifierValue<InputEventOption>,
|
|
254
|
+
modifiers?: ModifierState,
|
|
255
|
+
) {
|
|
256
|
+
const point = points.get(id)?.()
|
|
257
|
+
if (!point) return
|
|
258
|
+
const { value } = point
|
|
259
|
+
const next = applyDelta(
|
|
260
|
+
value[axis],
|
|
261
|
+
direction,
|
|
262
|
+
option,
|
|
263
|
+
POINT_AXIS,
|
|
264
|
+
modifiers,
|
|
265
|
+
)
|
|
266
|
+
// As a move, so that the rest of the selection comes along and the whole
|
|
267
|
+
// group stops together at the edge.
|
|
268
|
+
nudgeSelection(id, {
|
|
269
|
+
x: axis === 'x' ? next - value.x : 0,
|
|
270
|
+
y: axis === 'y' ? next - value.y : 0,
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const box = createSelectionBox<string>({
|
|
275
|
+
*items(): Generator<readonly [string, XY<number>]> {
|
|
276
|
+
for (const [id, read] of points) {
|
|
277
|
+
const { x, y } = read().value
|
|
278
|
+
yield [id, [x, y]]
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
onBoxChange: (rect) => opts.onSelectionBoxChange?.(rect),
|
|
282
|
+
onSelectionChange: changeSelection,
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
update: (next) => {
|
|
287
|
+
opts = { ...opts, ...next }
|
|
288
|
+
},
|
|
289
|
+
registerPoint: (id, read) => {
|
|
290
|
+
points.set(id, read)
|
|
291
|
+
return () => {
|
|
292
|
+
if (points.get(id) === read) points.delete(id)
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
isPointElement: (element) => {
|
|
296
|
+
if (!element) return false
|
|
297
|
+
for (const read of points.values()) {
|
|
298
|
+
if (read().element?.contains(element)) return true
|
|
299
|
+
}
|
|
300
|
+
return false
|
|
301
|
+
},
|
|
302
|
+
beginPointDrag: (id, modifiers) => {
|
|
303
|
+
if (!opts.selectable) {
|
|
304
|
+
dragged = snapshot([id])
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
const current = selection()
|
|
308
|
+
const additive = modifiers.ctrlKey || modifiers.metaKey
|
|
309
|
+
let next: readonly string[]
|
|
310
|
+
if (additive) {
|
|
311
|
+
next = current.includes(id)
|
|
312
|
+
? current.filter((x) => x !== id)
|
|
313
|
+
: [...current, id]
|
|
314
|
+
} else if (current.includes(id)) {
|
|
315
|
+
next = current
|
|
316
|
+
} else {
|
|
317
|
+
next = [id]
|
|
318
|
+
}
|
|
319
|
+
changeSelection([...next])
|
|
320
|
+
// A press that took the point out of the selection was a deselect, not
|
|
321
|
+
// the start of a move, so there is nothing to drag.
|
|
322
|
+
dragged = next.includes(id) ? snapshot(next) : []
|
|
323
|
+
},
|
|
324
|
+
movePointDrag: (delta) => moveFrom(dragged, delta),
|
|
325
|
+
nudgeSelection,
|
|
326
|
+
nudgePoint,
|
|
327
|
+
nudgeFocusedPoint: (axis, direction, modifiers) => {
|
|
328
|
+
for (const [id, read] of points) {
|
|
329
|
+
const { element, wheel, readonly, onChange } = read()
|
|
330
|
+
// The document the point is in, which need not be the one this script
|
|
331
|
+
// runs in — an editor rendered into an iframe has its own focus.
|
|
332
|
+
const active = element?.ownerDocument.activeElement
|
|
333
|
+
// A point answers only for the focus inside its own inputs, so both
|
|
334
|
+
// axes stay part of the same interaction.
|
|
335
|
+
if (!active || !element?.contains(active)) continue
|
|
336
|
+
if (!wheel || readonly || !onChange) return false
|
|
337
|
+
nudgePoint(id, axis, direction, wheel, modifiers)
|
|
338
|
+
return true
|
|
339
|
+
}
|
|
340
|
+
return false
|
|
341
|
+
},
|
|
342
|
+
beginSelectionBox: (at, modifiers) => {
|
|
343
|
+
if (!opts.selectable) return
|
|
344
|
+
box.begin([at.x, at.y], {
|
|
345
|
+
additive: modifiers.ctrlKey || modifiers.metaKey,
|
|
346
|
+
selection: selection(),
|
|
347
|
+
})
|
|
348
|
+
},
|
|
349
|
+
moveSelectionBox: (to) => box.move([to.x, to.y]),
|
|
350
|
+
endSelectionBox: () => {
|
|
351
|
+
if (!box.end()) return
|
|
352
|
+
const [first] = selection()
|
|
353
|
+
const element = first ? points.get(first)?.().element : null
|
|
354
|
+
if (element && 'focus' in element) (element as HTMLElement).focus()
|
|
355
|
+
},
|
|
356
|
+
destroy: () => {
|
|
357
|
+
box.destroy()
|
|
358
|
+
dragged = []
|
|
359
|
+
},
|
|
360
|
+
}
|
|
361
|
+
}
|
package/src/position.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { linearScale, toFixed, type ValueRange } from '@tremolo-ui/functions'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where a value sits along a track, as a whole percentage from its start.
|
|
5
|
+
*
|
|
6
|
+
* Normalized along the scale, so a thumb, the fill behind it and the marks
|
|
7
|
+
* all sit on the curve the drag follows. `reversed` measures from the other
|
|
8
|
+
* end — for a track that grows upwards or leftwards on screen, since CSS
|
|
9
|
+
* places things from the top and the left.
|
|
10
|
+
*/
|
|
11
|
+
export function valuePercent(
|
|
12
|
+
value: number,
|
|
13
|
+
{ min, max, scale = linearScale }: Pick<ValueRange, 'min' | 'max' | 'scale'>,
|
|
14
|
+
reversed = false,
|
|
15
|
+
): number {
|
|
16
|
+
// Reversed before rounding, so that a value halfway between two whole
|
|
17
|
+
// percentages lands on the same one whichever end it is measured from.
|
|
18
|
+
const normalized = scale.normalize(value, min, max)
|
|
19
|
+
return toFixed((reversed ? 1 - normalized : normalized) * 100)
|
|
20
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many digits a number carries after the decimal point.
|
|
3
|
+
*
|
|
4
|
+
* `Slider.Marks` builds its labels by multiplying an interval, which leaves
|
|
5
|
+
* binary debris in the last digits (`0.1 * 3` is `0.30000000000000004`). The
|
|
6
|
+
* count is what tells `toFixed` how far to round that back.
|
|
7
|
+
*
|
|
8
|
+
* The exponent form has to be handled separately: `String(1e-7)` is `'1e-7'`,
|
|
9
|
+
* which has no decimal point at all, so reading the text after the point
|
|
10
|
+
* reports no digits and the interval rounds away to whole numbers.
|
|
11
|
+
*/
|
|
12
|
+
export function decimalDigits(x: number): number {
|
|
13
|
+
if (!Number.isFinite(x)) return 0
|
|
14
|
+
|
|
15
|
+
const text = String(x)
|
|
16
|
+
const e = text.indexOf('e')
|
|
17
|
+
if (e === -1) return text.split('.')[1]?.length ?? 0
|
|
18
|
+
|
|
19
|
+
const fraction = text.slice(0, e).split('.')[1]?.length ?? 0
|
|
20
|
+
// A negative exponent pushes the point further right, a positive one pulls
|
|
21
|
+
// it back past the digits that are there.
|
|
22
|
+
return Math.max(0, fraction - Number(text.slice(e + 1)))
|
|
23
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { toFixed } from '@tremolo-ui/functions'
|
|
2
|
+
|
|
3
|
+
import { decimalDigits } from './decimal-digits'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How `Slider.Marks` fills itself in when it is given no children: one option
|
|
7
|
+
* every `per`, or every `step` of the slider. The object form turns off the
|
|
8
|
+
* mark or the label for the whole set; a single option is customized by
|
|
9
|
+
* writing `Slider.MarksOption` out instead.
|
|
10
|
+
*/
|
|
11
|
+
export type MarksOptions =
|
|
12
|
+
| 'step'
|
|
13
|
+
| number
|
|
14
|
+
| {
|
|
15
|
+
per: 'step' | number
|
|
16
|
+
mark?: boolean
|
|
17
|
+
label?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One mark along a slider, as {@link sliderMarks} lays them out. */
|
|
21
|
+
export interface SliderMark {
|
|
22
|
+
value: number
|
|
23
|
+
mark: boolean
|
|
24
|
+
label: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The marks `options` asks for between `min` and `max`, in ascending order.
|
|
29
|
+
*
|
|
30
|
+
* Each value is a whole multiple of the interval, rounded to the digits the
|
|
31
|
+
* interval has: stepping by 0.1 would otherwise put binary debris in the
|
|
32
|
+
* labels (`0.1 * 3` is `0.30000000000000004`).
|
|
33
|
+
*/
|
|
34
|
+
export function sliderMarks(
|
|
35
|
+
options: MarksOptions,
|
|
36
|
+
min: number,
|
|
37
|
+
max: number,
|
|
38
|
+
step: number,
|
|
39
|
+
): SliderMark[] {
|
|
40
|
+
const {
|
|
41
|
+
per,
|
|
42
|
+
mark = true,
|
|
43
|
+
label = true,
|
|
44
|
+
} = typeof options === 'object' ? options : { per: options }
|
|
45
|
+
const optionsList: SliderMark[] = []
|
|
46
|
+
const interval = per === 'step' ? step : per
|
|
47
|
+
const count = Math.floor(max / interval) - Math.ceil(min / interval) + 1
|
|
48
|
+
for (let i = 0; i < count; i++) {
|
|
49
|
+
const value = toFixed(
|
|
50
|
+
interval * (Math.ceil(min / interval) + i),
|
|
51
|
+
decimalDigits(interval),
|
|
52
|
+
)
|
|
53
|
+
optionsList.push({ value: value, mark: mark, label: label })
|
|
54
|
+
}
|
|
55
|
+
return optionsList
|
|
56
|
+
}
|
package/src/style.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A number as a CSS length, for a custom property.
|
|
3
|
+
*
|
|
4
|
+
* A custom property takes whatever text it is given, so `--size: 50` comes out
|
|
5
|
+
* as the invalid `50` rather than `50px`. React appends `px` to a bare number
|
|
6
|
+
* only for the properties it knows take a length, and a custom property is
|
|
7
|
+
* never one of them; Vue and Svelte append nothing at all. Anything already a
|
|
8
|
+
* string is passed through, so `'3rem'` and `'100%'` still work.
|
|
9
|
+
*/
|
|
10
|
+
export function cssLength(
|
|
11
|
+
value: number | string | undefined,
|
|
12
|
+
): string | undefined {
|
|
13
|
+
return typeof value === 'number' ? `${value}px` : value
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Take an element out of sight while leaving it in the accessibility tree and
|
|
18
|
+
* in the tab order.
|
|
19
|
+
*
|
|
20
|
+
* `display: none` and `visibility: hidden` would remove it from both, and the
|
|
21
|
+
* native control underneath a headless component is what carries the ARIA and
|
|
22
|
+
* the keyboard behaviour. `pointer-events: none` is safe because nothing is
|
|
23
|
+
* ever clicked here directly: a `<label>` forwards its click, and a drag is
|
|
24
|
+
* handled by the part that is visible.
|
|
25
|
+
*
|
|
26
|
+
* Every value is a string with its unit, so the object can be handed to any
|
|
27
|
+
* framework's `style` binding as it is.
|
|
28
|
+
*/
|
|
29
|
+
export const visuallyHiddenStyle = {
|
|
30
|
+
position: 'absolute',
|
|
31
|
+
width: '1px',
|
|
32
|
+
height: '1px',
|
|
33
|
+
padding: '0',
|
|
34
|
+
margin: '-1px',
|
|
35
|
+
overflow: 'hidden',
|
|
36
|
+
clip: 'rect(0, 0, 0, 0)',
|
|
37
|
+
clipPath: 'inset(50%)',
|
|
38
|
+
whiteSpace: 'nowrap',
|
|
39
|
+
border: '0',
|
|
40
|
+
pointerEvents: 'none',
|
|
41
|
+
} as const
|