@tremolo-ui/dom 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/index.cjs +343 -595
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +230 -284
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.ts +230 -284
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +325 -572
  8. package/dist/index.js.map +1 -1
  9. package/dist/internal.cjs +530 -0
  10. package/dist/internal.cjs.map +1 -0
  11. package/dist/internal.d.cts +365 -0
  12. package/dist/internal.d.cts.map +1 -0
  13. package/dist/internal.d.ts +365 -0
  14. package/dist/internal.d.ts.map +1 -0
  15. package/dist/internal.js +500 -0
  16. package/dist/internal.js.map +1 -0
  17. package/dist/marks-DK7xJmGM.d.cts +305 -0
  18. package/dist/marks-DK7xJmGM.d.cts.map +1 -0
  19. package/dist/marks-DK7xJmGM.d.ts +305 -0
  20. package/dist/marks-DK7xJmGM.d.ts.map +1 -0
  21. package/dist/xy-5Oc6JeJr.cjs +952 -0
  22. package/dist/xy-5Oc6JeJr.cjs.map +1 -0
  23. package/dist/xy-lNFl1CTO.js +827 -0
  24. package/dist/xy-lNFl1CTO.js.map +1 -0
  25. package/package.json +12 -2
  26. package/src/canvas/animation.ts +8 -0
  27. package/src/canvas/context.ts +0 -5
  28. package/src/file/accept.ts +17 -0
  29. package/src/file/drop-zone.ts +8 -10
  30. package/src/index.ts +44 -32
  31. package/src/input/change-gesture.ts +104 -0
  32. package/src/input/check-steps.ts +144 -0
  33. package/src/input/defaults.ts +32 -0
  34. package/src/input/direction.ts +107 -0
  35. package/src/internal.ts +51 -0
  36. package/src/knob/geometry.ts +100 -0
  37. package/src/midi/access.ts +16 -15
  38. package/src/midi/input.ts +2 -9
  39. package/src/number-input/stepper-drag.ts +147 -0
  40. package/src/number-input/text.ts +100 -0
  41. package/src/number-input/value.ts +129 -0
  42. package/src/options/replace.ts +22 -0
  43. package/src/piano/index.ts +128 -2
  44. package/src/piano/layout.ts +19 -0
  45. package/src/piano/shortcuts.ts +86 -0
  46. package/src/pointer/drag-value.ts +3 -1
  47. package/src/pointer/long-press.ts +103 -0
  48. package/src/pointer/wheel.ts +10 -7
  49. package/src/points-editor/index.ts +367 -0
  50. package/src/position.ts +20 -0
  51. package/src/slider/decimal-digits.ts +23 -0
  52. package/src/slider/marks.ts +56 -0
  53. package/src/style.ts +41 -0
@@ -0,0 +1,367 @@
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
+ * Called just before the wheel moves this point as the focused one, so that
73
+ * it can report its change as started before the value arrives.
74
+ */
75
+ beforeWheel?: () => void
76
+ }
77
+
78
+ export interface PointsEditorOptions {
79
+ /**
80
+ * Let points be selected, and a selection be moved as one. While it is off
81
+ * nothing is selected, and a drag moves only the point it started on.
82
+ *
83
+ * @default false
84
+ */
85
+ selectable?: boolean
86
+ /**
87
+ * The ids of the selected points. Whoever holds the selection pushes it
88
+ * back with `update()` after {@link PointsEditorOptions.onSelectionChange}.
89
+ */
90
+ selection?: readonly string[]
91
+ /** Called whenever a press or a selection box changes the selection. */
92
+ onSelectionChange?: (selection: string[]) => void
93
+ /** Called whenever the selection box changes, with `null` once it is gone. */
94
+ onSelectionBoxChange?: (rect: SelectionBoxRect | null) => void
95
+ }
96
+
97
+ export interface PointsEditorInstance {
98
+ /** Replace the given options. */
99
+ update: (options: Partial<PointsEditorOptions>) => void
100
+ /**
101
+ * Register a point under `id`. `read` is called whenever the editor needs
102
+ * the point, so it can return what the point is now rather than what it
103
+ * was when it registered. Returns the function that unregisters it.
104
+ */
105
+ registerPoint: (id: string, read: () => PointsEditorPoint) => () => void
106
+ /** Whether the element is a point, or inside one. */
107
+ isPointElement: (element: Element | null | undefined) => boolean
108
+ /**
109
+ * A pointer went down on a point: work out the new selection and remember
110
+ * where everything the drag picked up started.
111
+ *
112
+ * Ctrl / meta add the point to the selection or take it out: shift is the
113
+ * fine-adjustment key on every control here, and it cannot be both. A
114
+ * press on a point already selected keeps the selection, so the group can
115
+ * be dragged.
116
+ */
117
+ beginPointDrag: (id: string, modifiers: ModifierState) => void
118
+ /**
119
+ * Move everything the drag picked up by `delta` from where it started,
120
+ * stopping the whole group together at the edge.
121
+ */
122
+ movePointDrag: (delta: PointPosition) => void
123
+ /**
124
+ * Move `id` — and the selection, when it is part of one — by `delta` from
125
+ * where the points are now: an input that is not a drag has no earlier
126
+ * position to measure against.
127
+ */
128
+ nudgeSelection: (id: string, delta: PointPosition) => void
129
+ /**
130
+ * Move `id` by one press of `option` along one axis, taking the selection
131
+ * along as {@link PointsEditorInstance.nudgeSelection} does.
132
+ */
133
+ nudgePoint: (
134
+ id: string,
135
+ axis: 'x' | 'y',
136
+ direction: number,
137
+ option: ModifierValue<InputEventOption>,
138
+ modifiers?: ModifierState,
139
+ ) => void
140
+ /**
141
+ * Move the point holding the focus by one wheel notch, with its own wheel
142
+ * option. The wheel is listened to once for the whole editor, since a wheel
143
+ * event only reaches what the cursor is over. Returns whether a point took
144
+ * it, so the caller knows whether to consume the event.
145
+ */
146
+ nudgeFocusedPoint: (
147
+ axis: 'x' | 'y',
148
+ direction: number,
149
+ modifiers: ModifierState,
150
+ ) => boolean
151
+ /** A drag on empty space started at `at`: start a selection box there. */
152
+ beginSelectionBox: (at: PointPosition, modifiers: ModifierState) => void
153
+ moveSelectionBox: (to: PointPosition) => void
154
+ /**
155
+ * End the selection box. The press that started it left the focus on
156
+ * nothing, and the arrow keys and the wheel reach a point only through the
157
+ * focus, so it is handed to one of the points the box selected.
158
+ */
159
+ endSelectionBox: () => void
160
+ destroy: () => void
161
+ }
162
+
163
+ const EMPTY: readonly string[] = []
164
+
165
+ /**
166
+ * How far a group may move before something in it leaves its range.
167
+ *
168
+ * Clamping each point on its own would break the shape of the selection: the
169
+ * one that reached the edge would stop while the rest carried on. One amount
170
+ * for all of them means the whole selection stops together.
171
+ */
172
+ function allowedDelta(
173
+ delta: PointPosition,
174
+ entries: { start: PointPosition; point: PointsEditorPoint }[],
175
+ ): PointPosition {
176
+ let loX = -Infinity
177
+ let hiX = Infinity
178
+ let loY = -Infinity
179
+ let hiY = Infinity
180
+ for (const { start, point } of entries) {
181
+ // A point that cannot move stays where it is, so its range says nothing
182
+ // about how far the rest may go.
183
+ if (point.readonly) continue
184
+ loX = Math.max(loX, (point.min?.x ?? 0) - start.x)
185
+ hiX = Math.min(hiX, (point.max?.x ?? 1) - start.x)
186
+ loY = Math.max(loY, (point.min?.y ?? 0) - start.y)
187
+ hiY = Math.min(hiY, (point.max?.y ?? 1) - start.y)
188
+ }
189
+ // A point that started outside its own range leaves nothing to move within.
190
+ return {
191
+ x: hiX < loX ? 0 : clamp(delta.x, loX, hiX),
192
+ y: hiY < loY ? 0 : clamp(delta.y, loY, hiY),
193
+ }
194
+ }
195
+
196
+ /**
197
+ * The selection and the moves of a points editor: which points a press or a
198
+ * box selects, and how a selection moves as one.
199
+ *
200
+ * The points stay with the wrapper, which registers each one; the editor only
201
+ * reads them when it needs to, so a point's value can change on every frame
202
+ * of a drag without anything being re-registered.
203
+ */
204
+ export function createPointsEditor(
205
+ options: PointsEditorOptions = {},
206
+ ): PointsEditorInstance {
207
+ let opts = options
208
+ const points = new Map<string, () => PointsEditorPoint>()
209
+ /** What the current drag picked up, and where those points started. */
210
+ let dragged: { id: string; start: PointPosition }[] = []
211
+
212
+ const selection = () => (opts.selectable ? (opts.selection ?? EMPTY) : EMPTY)
213
+
214
+ function changeSelection(next: string[]) {
215
+ // Read back at once by the drag that follows, before the owner has had a
216
+ // chance to push it with update().
217
+ opts = { ...opts, selection: next }
218
+ opts.onSelectionChange?.(next)
219
+ }
220
+
221
+ function snapshot(ids: readonly string[]) {
222
+ return ids.flatMap((id) => {
223
+ const point = points.get(id)?.()
224
+ return point ? [{ id, start: { ...point.value } }] : []
225
+ })
226
+ }
227
+
228
+ function moveFrom(
229
+ entries: { id: string; start: PointPosition }[],
230
+ delta: PointPosition,
231
+ ) {
232
+ const withPoint = entries.flatMap((entry) => {
233
+ const point = points.get(entry.id)?.()
234
+ return point ? [{ ...entry, point }] : []
235
+ })
236
+ const allowed = allowedDelta(delta, withPoint)
237
+ for (const { start, point } of withPoint) {
238
+ if (point.readonly) continue
239
+ // Rounded here as well as in the pipeline: a move is a subtraction and
240
+ // an addition of its own, and that is enough to put the binary artefact
241
+ // back (0.2 + 0.1 lands on 0.30000000000000004).
242
+ point.onChange?.({
243
+ x: toPrecision(start.x + allowed.x),
244
+ y: toPrecision(start.y + allowed.y),
245
+ })
246
+ }
247
+ }
248
+
249
+ function nudgeSelection(id: string, delta: PointPosition) {
250
+ const current = selection()
251
+ moveFrom(snapshot(current.includes(id) ? current : [id]), delta)
252
+ }
253
+
254
+ function nudgePoint(
255
+ id: string,
256
+ axis: 'x' | 'y',
257
+ direction: number,
258
+ option: ModifierValue<InputEventOption>,
259
+ modifiers?: ModifierState,
260
+ ) {
261
+ const point = points.get(id)?.()
262
+ if (!point) return
263
+ const { value } = point
264
+ const next = applyDelta(
265
+ value[axis],
266
+ direction,
267
+ option,
268
+ POINT_AXIS,
269
+ modifiers,
270
+ )
271
+ // As a move, so that the rest of the selection comes along and the whole
272
+ // group stops together at the edge.
273
+ nudgeSelection(id, {
274
+ x: axis === 'x' ? next - value.x : 0,
275
+ y: axis === 'y' ? next - value.y : 0,
276
+ })
277
+ }
278
+
279
+ const box = createSelectionBox<string>({
280
+ *items(): Generator<readonly [string, XY<number>]> {
281
+ for (const [id, read] of points) {
282
+ const { x, y } = read().value
283
+ yield [id, [x, y]]
284
+ }
285
+ },
286
+ onBoxChange: (rect) => opts.onSelectionBoxChange?.(rect),
287
+ onSelectionChange: changeSelection,
288
+ })
289
+
290
+ return {
291
+ update: (next) => {
292
+ opts = { ...opts, ...next }
293
+ },
294
+ registerPoint: (id, read) => {
295
+ points.set(id, read)
296
+ return () => {
297
+ if (points.get(id) === read) points.delete(id)
298
+ }
299
+ },
300
+ isPointElement: (element) => {
301
+ if (!element) return false
302
+ for (const read of points.values()) {
303
+ if (read().element?.contains(element)) return true
304
+ }
305
+ return false
306
+ },
307
+ beginPointDrag: (id, modifiers) => {
308
+ if (!opts.selectable) {
309
+ dragged = snapshot([id])
310
+ return
311
+ }
312
+ const current = selection()
313
+ const additive = modifiers.ctrlKey || modifiers.metaKey
314
+ let next: readonly string[]
315
+ if (additive) {
316
+ next = current.includes(id)
317
+ ? current.filter((x) => x !== id)
318
+ : [...current, id]
319
+ } else if (current.includes(id)) {
320
+ next = current
321
+ } else {
322
+ next = [id]
323
+ }
324
+ changeSelection([...next])
325
+ // A press that took the point out of the selection was a deselect, not
326
+ // the start of a move, so there is nothing to drag.
327
+ dragged = next.includes(id) ? snapshot(next) : []
328
+ },
329
+ movePointDrag: (delta) => moveFrom(dragged, delta),
330
+ nudgeSelection,
331
+ nudgePoint,
332
+ nudgeFocusedPoint: (axis, direction, modifiers) => {
333
+ for (const [id, read] of points) {
334
+ const { element, wheel, readonly, onChange, beforeWheel } = read()
335
+ // The document the point is in, which need not be the one this script
336
+ // runs in — an editor rendered into an iframe has its own focus.
337
+ const active = element?.ownerDocument.activeElement
338
+ // A point answers only for the focus inside its own inputs, so both
339
+ // axes stay part of the same interaction.
340
+ if (!active || !element?.contains(active)) continue
341
+ if (!wheel || readonly || !onChange) return false
342
+ beforeWheel?.()
343
+ nudgePoint(id, axis, direction, wheel, modifiers)
344
+ return true
345
+ }
346
+ return false
347
+ },
348
+ beginSelectionBox: (at, modifiers) => {
349
+ if (!opts.selectable) return
350
+ box.begin([at.x, at.y], {
351
+ additive: modifiers.ctrlKey || modifiers.metaKey,
352
+ selection: selection(),
353
+ })
354
+ },
355
+ moveSelectionBox: (to) => box.move([to.x, to.y]),
356
+ endSelectionBox: () => {
357
+ if (!box.end()) return
358
+ const [first] = selection()
359
+ const element = first ? points.get(first)?.().element : null
360
+ if (element && 'focus' in element) (element as HTMLElement).focus()
361
+ },
362
+ destroy: () => {
363
+ box.destroy()
364
+ dragged = []
365
+ },
366
+ }
367
+ }
@@ -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