@tremolo-ui/dom 0.5.0 → 0.7.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 +842 -101
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +490 -47
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +490 -47
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +829 -102
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/src/canvas/animation.ts +49 -31
- package/src/canvas/context.ts +32 -5
- package/src/file/accept.ts +58 -0
- package/src/file/drop-zone.ts +202 -0
- package/src/index.ts +39 -2
- package/src/input/apply-delta.ts +73 -0
- package/src/input/modifiers.ts +121 -0
- package/src/midi/access.ts +83 -13
- package/src/midi/input.ts +97 -26
- package/src/midi/message.ts +37 -8
- package/src/piano/{input.ts → index.ts} +25 -5
- package/src/piano/layout.ts +183 -0
- package/src/pointer/{dragValue.ts → drag-value.ts} +155 -19
- package/src/pointer/drag.ts +215 -20
- package/src/pointer/wheel.ts +4 -2
- package/src/selection/box.ts +136 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isBlackKey,
|
|
3
|
+
isWhiteKey,
|
|
4
|
+
noteKey,
|
|
5
|
+
noteKeys,
|
|
6
|
+
type NoteKey,
|
|
7
|
+
} from '@tremolo-ui/functions'
|
|
8
|
+
|
|
9
|
+
export type NoteRange = {
|
|
10
|
+
first: number
|
|
11
|
+
last: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `[noteRange.first, noteRange.first + 1, ..., noteRange.last]`
|
|
16
|
+
*/
|
|
17
|
+
export function getNoteRangeArray(noteRange: NoteRange): number[] {
|
|
18
|
+
return Array.from(
|
|
19
|
+
{ length: noteRange.last - noteRange.first + 1 },
|
|
20
|
+
(_, i) => i + noteRange.first,
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The geometry of a drawn keyboard.
|
|
26
|
+
*
|
|
27
|
+
* One description is shared by the drawing and the hit testing, so a key cannot
|
|
28
|
+
* be drawn somewhere other than where it responds.
|
|
29
|
+
*/
|
|
30
|
+
export interface PianoLayout {
|
|
31
|
+
noteRange: NoteRange
|
|
32
|
+
|
|
33
|
+
/** Width of a white key, excluding {@link PianoLayout.keyGap}. */
|
|
34
|
+
whiteKeyWidth: number
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Space between two white keys. Part of the slot a white key occupies, so it
|
|
38
|
+
* still belongs to one of the keys for the purpose of hit testing.
|
|
39
|
+
*
|
|
40
|
+
* @default 1
|
|
41
|
+
*/
|
|
42
|
+
keyGap?: number
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Width of a black key, as a fraction of {@link PianoLayout.whiteKeyWidth}.
|
|
46
|
+
*
|
|
47
|
+
* @default 0.65
|
|
48
|
+
*/
|
|
49
|
+
blackKeyWidthRatio?: number
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Height of a black key, as a fraction of the height of the keyboard.
|
|
53
|
+
*
|
|
54
|
+
* @default 0.6
|
|
55
|
+
*/
|
|
56
|
+
blackKeyHeightRatio?: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const DEFAULT_KEY_GAP = 1
|
|
60
|
+
const DEFAULT_BLACK_KEY_WIDTH_RATIO = 0.65
|
|
61
|
+
const DEFAULT_BLACK_KEY_HEIGHT_RATIO = 0.6
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* How many white keys sit at or before each pitch class, counting from C.
|
|
65
|
+
*
|
|
66
|
+
* A black key shares the number of the white key to its left plus one, which
|
|
67
|
+
* puts it on the boundary between the two; {@link notePosition} then shifts it
|
|
68
|
+
* back by half its width to centre it there.
|
|
69
|
+
*/
|
|
70
|
+
const whiteKeysBefore: Record<NoteKey, number> = {
|
|
71
|
+
C: 0,
|
|
72
|
+
'C#': 1,
|
|
73
|
+
D: 1,
|
|
74
|
+
'D#': 2,
|
|
75
|
+
E: 2,
|
|
76
|
+
F: 3,
|
|
77
|
+
'F#': 4,
|
|
78
|
+
G: 4,
|
|
79
|
+
'G#': 5,
|
|
80
|
+
A: 5,
|
|
81
|
+
'A#': 6,
|
|
82
|
+
B: 6,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Width of a black key in pixels. */
|
|
86
|
+
export function blackKeyWidth(layout: PianoLayout): number {
|
|
87
|
+
return (
|
|
88
|
+
layout.whiteKeyWidth *
|
|
89
|
+
(layout.blackKeyWidthRatio ?? DEFAULT_BLACK_KEY_WIDTH_RATIO)
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function rawNotePosition(note: number, layout: PianoLayout): number {
|
|
94
|
+
const slot = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP)
|
|
95
|
+
const target = noteKey(note)
|
|
96
|
+
const first = noteKey(layout.noteRange.first)
|
|
97
|
+
|
|
98
|
+
const octave = Math.floor((note - layout.noteRange.first) / 12)
|
|
99
|
+
const octaveOffset =
|
|
100
|
+
noteKeys.indexOf(first) > noteKeys.indexOf(target) ? 1 : 0
|
|
101
|
+
const whiteKeysIn =
|
|
102
|
+
whiteKeysBefore[target] -
|
|
103
|
+
whiteKeysBefore[first] +
|
|
104
|
+
(octave + octaveOffset) * 7
|
|
105
|
+
|
|
106
|
+
return isBlackKey(note)
|
|
107
|
+
? whiteKeysIn * slot - blackKeyWidth(layout) / 2
|
|
108
|
+
: whiteKeysIn * slot
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function pianoBounds(layout: PianoLayout) {
|
|
112
|
+
const notes = getNoteRangeArray(layout.noteRange)
|
|
113
|
+
if (notes.length === 0) return { left: 0, right: 0 }
|
|
114
|
+
|
|
115
|
+
let left = Infinity
|
|
116
|
+
let right = -Infinity
|
|
117
|
+
const whiteWidth = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP)
|
|
118
|
+
for (const note of notes) {
|
|
119
|
+
const noteLeft = rawNotePosition(note, layout)
|
|
120
|
+
const width = isBlackKey(note) ? blackKeyWidth(layout) : whiteWidth
|
|
121
|
+
left = Math.min(left, noteLeft)
|
|
122
|
+
right = Math.max(right, noteLeft + width)
|
|
123
|
+
}
|
|
124
|
+
return { left, right }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Width of the whole keyboard in pixels. */
|
|
128
|
+
export function pianoWidth(layout: PianoLayout): number {
|
|
129
|
+
const { left, right } = pianoBounds(layout)
|
|
130
|
+
return right - left
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Offset of the left edge of a key from the left edge of the keyboard, in
|
|
135
|
+
* pixels.
|
|
136
|
+
*
|
|
137
|
+
* Notes outside `noteRange` are placed too, so the value is negative below
|
|
138
|
+
* `noteRange.first`.
|
|
139
|
+
*/
|
|
140
|
+
export function notePosition(note: number, layout: PianoLayout): number {
|
|
141
|
+
return rawNotePosition(note, layout) - pianoBounds(layout).left
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The note drawn at a point, or null where there is none.
|
|
146
|
+
*
|
|
147
|
+
* Black keys are tested first, so they win where they overlap a white one. A
|
|
148
|
+
* white key covers its gap as well as its width, so the whole width of the
|
|
149
|
+
* keyboard belongs to some key and a click cannot fall between two.
|
|
150
|
+
*
|
|
151
|
+
* @param x offset from the left edge of the keyboard, in pixels
|
|
152
|
+
* @param y offset from its top edge, in pixels
|
|
153
|
+
* @param height height of the keyboard, in pixels
|
|
154
|
+
*/
|
|
155
|
+
export function noteAt(
|
|
156
|
+
x: number,
|
|
157
|
+
y: number,
|
|
158
|
+
height: number,
|
|
159
|
+
layout: PianoLayout,
|
|
160
|
+
): number | null {
|
|
161
|
+
if (x < 0 || x >= pianoWidth(layout) || y < 0 || y >= height) return null
|
|
162
|
+
|
|
163
|
+
const notes = getNoteRangeArray(layout.noteRange)
|
|
164
|
+
const blackHeight =
|
|
165
|
+
height * (layout.blackKeyHeightRatio ?? DEFAULT_BLACK_KEY_HEIGHT_RATIO)
|
|
166
|
+
|
|
167
|
+
if (y < blackHeight) {
|
|
168
|
+
for (const note of notes) {
|
|
169
|
+
if (isWhiteKey(note)) continue
|
|
170
|
+
const left = notePosition(note, layout)
|
|
171
|
+
if (left <= x && x < left + blackKeyWidth(layout)) return note
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const slot = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP)
|
|
176
|
+
for (const note of notes) {
|
|
177
|
+
if (isBlackKey(note)) continue
|
|
178
|
+
const left = notePosition(note, layout)
|
|
179
|
+
if (left <= x && x < left + slot) return note
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
linearScale,
|
|
4
4
|
normalizeValue,
|
|
5
5
|
stepValue,
|
|
6
|
+
toPrecision,
|
|
6
7
|
type ValueRange,
|
|
7
8
|
} from '@tremolo-ui/functions'
|
|
8
9
|
|
|
@@ -59,7 +60,32 @@ export interface DragValueMapping {
|
|
|
59
60
|
*/
|
|
60
61
|
export function elementMapping(
|
|
61
62
|
getElement: () => Element | null | undefined,
|
|
63
|
+
{
|
|
64
|
+
sensitivity,
|
|
65
|
+
}: {
|
|
66
|
+
/**
|
|
67
|
+
* How much the movement counts, read on every move. `1` is the pointer
|
|
68
|
+
* position itself; `0.1` makes the same movement cover a tenth of the
|
|
69
|
+
* travel, which is what a fine-adjustment modifier wants.
|
|
70
|
+
*
|
|
71
|
+
* Anything but `1` turns the mapping relative: the value stops being the
|
|
72
|
+
* position pointed at and starts being where it stood when the sensitivity
|
|
73
|
+
* changed, plus the movement since. **The pointer and the value stay apart
|
|
74
|
+
* for the rest of the drag** rather than snapping back together when the
|
|
75
|
+
* key is released, since snapping would move the value nobody asked to
|
|
76
|
+
* move.
|
|
77
|
+
*/
|
|
78
|
+
sensitivity?: (state: DragState) => number
|
|
79
|
+
} = {},
|
|
62
80
|
): DragValueMapping {
|
|
81
|
+
/** The reported position when the sensitivity last changed. */
|
|
82
|
+
let origin: XY<number> = [0, 0]
|
|
83
|
+
/** The raw position at that same moment. */
|
|
84
|
+
let anchor: XY<number> = [0, 0]
|
|
85
|
+
/** The previous event, so a sensitivity change can be dated back to it. */
|
|
86
|
+
let previous: XY<number> = [0, 0]
|
|
87
|
+
let factor = 1
|
|
88
|
+
|
|
63
89
|
function positionIn(state: DragState): XY<number> | null {
|
|
64
90
|
const element = getElement()
|
|
65
91
|
if (!element) return null
|
|
@@ -72,7 +98,48 @@ export function elementMapping(
|
|
|
72
98
|
]
|
|
73
99
|
}
|
|
74
100
|
|
|
75
|
-
|
|
101
|
+
const reported = (raw: XY<number>, at: number): XY<number> => [
|
|
102
|
+
origin[0] + (raw[0] - anchor[0]) * at,
|
|
103
|
+
origin[1] + (raw[1] - anchor[1]) * at,
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
start: (state) => {
|
|
108
|
+
const raw = positionIn(state)
|
|
109
|
+
if (!raw) return null
|
|
110
|
+
// origin and anchor together, so the value is the position pointed at:
|
|
111
|
+
// a plain click still lands where it was aimed.
|
|
112
|
+
origin = raw
|
|
113
|
+
anchor = raw
|
|
114
|
+
previous = raw
|
|
115
|
+
// Read here too, so a modifier already held when the pointer went down
|
|
116
|
+
// applies from the first pixel rather than from the first change.
|
|
117
|
+
factor = sensitivity ? sensitivity(state) : 1
|
|
118
|
+
return raw
|
|
119
|
+
},
|
|
120
|
+
move: (state) => {
|
|
121
|
+
const raw = positionIn(state)
|
|
122
|
+
if (!raw) return null
|
|
123
|
+
|
|
124
|
+
const next = sensitivity ? sensitivity(state) : 1
|
|
125
|
+
if (next !== factor) {
|
|
126
|
+
// Fold the travel so far into the origin, or the new sensitivity would
|
|
127
|
+
// apply to the whole drag and the value would jump.
|
|
128
|
+
//
|
|
129
|
+
// Dated to the previous event rather than this one: a key produces no
|
|
130
|
+
// pointer event of its own, so the change is only seen on the next
|
|
131
|
+
// move, and that move's own distance belongs to the new sensitivity.
|
|
132
|
+
origin = reported(previous, factor)
|
|
133
|
+
anchor = previous
|
|
134
|
+
factor = next
|
|
135
|
+
}
|
|
136
|
+
previous = raw
|
|
137
|
+
|
|
138
|
+
// With no sensitivity given, origin and anchor never move apart and this
|
|
139
|
+
// is the raw position, exactly as before.
|
|
140
|
+
return reported(raw, factor)
|
|
141
|
+
},
|
|
142
|
+
}
|
|
76
143
|
}
|
|
77
144
|
|
|
78
145
|
/**
|
|
@@ -82,27 +149,65 @@ export function elementMapping(
|
|
|
82
149
|
*/
|
|
83
150
|
export function relativeMapping({
|
|
84
151
|
pixelRange = 100,
|
|
152
|
+
sensitivity,
|
|
85
153
|
}: {
|
|
86
154
|
/**
|
|
87
155
|
* Pixels of movement that span the whole range.
|
|
88
156
|
* @default 100
|
|
89
157
|
*/
|
|
90
158
|
pixelRange?: XYInput<number>
|
|
159
|
+
/**
|
|
160
|
+
* How much the movement counts, read on every move. `1` is `pixelRange` as
|
|
161
|
+
* given; `0.1` makes the same movement cover a tenth of the range, which is
|
|
162
|
+
* what a fine-adjustment modifier wants.
|
|
163
|
+
*
|
|
164
|
+
* Changing it mid-drag does not disturb the value: the travel so far is
|
|
165
|
+
* folded into the origin and measuring starts again from there.
|
|
166
|
+
*/
|
|
167
|
+
sensitivity?: (state: DragState) => number
|
|
91
168
|
} = {}): DragValueMapping {
|
|
92
|
-
const [
|
|
169
|
+
const [baseX, baseY] = toXY(pixelRange)
|
|
93
170
|
let origin: XY<number> = [0, 0]
|
|
171
|
+
/** Where the current sensitivity took over, in drag coordinates. */
|
|
172
|
+
let anchor: XY<number> = [0, 0]
|
|
173
|
+
/** The previous event, so a sensitivity change can be dated back to it. */
|
|
174
|
+
let previous: XY<number> = [0, 0]
|
|
175
|
+
let factor = 1
|
|
176
|
+
|
|
177
|
+
const travelled = (to: XY<number>, at: number): XY<number> => [
|
|
178
|
+
baseX === 0 ? origin[0] : origin[0] + ((to[0] - anchor[0]) * at) / baseX,
|
|
179
|
+
baseY === 0 ? origin[1] : origin[1] + ((to[1] - anchor[1]) * at) / baseY,
|
|
180
|
+
]
|
|
94
181
|
|
|
95
182
|
return {
|
|
96
|
-
start: (
|
|
183
|
+
start: (state, context) => {
|
|
97
184
|
origin = context.position()
|
|
185
|
+
anchor = [0, 0]
|
|
186
|
+
previous = [0, 0]
|
|
187
|
+
// Read here too, so a modifier already held when the pointer went down
|
|
188
|
+
// applies from the first pixel rather than from the first change.
|
|
189
|
+
factor = sensitivity ? sensitivity(state) : 1
|
|
98
190
|
return origin
|
|
99
191
|
},
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
192
|
+
// Measured from the anchor rather than accumulated per event, so the
|
|
193
|
+
// position picks up no rounding error of its own. The anchor only moves
|
|
194
|
+
// when the sensitivity does, which is a handful of times per drag at most.
|
|
195
|
+
move: (state) => {
|
|
196
|
+
const next = sensitivity ? sensitivity(state) : 1
|
|
197
|
+
if (next !== factor) {
|
|
198
|
+
// Fold the travel so far into the origin, or the new sensitivity would
|
|
199
|
+
// apply to the whole drag and the value would jump.
|
|
200
|
+
//
|
|
201
|
+
// Dated to the previous event rather than this one: a key produces no
|
|
202
|
+
// pointer event of its own, so the change is only seen on the next
|
|
203
|
+
// move, and that move's own distance belongs to the new sensitivity.
|
|
204
|
+
origin = travelled(previous, factor)
|
|
205
|
+
anchor = previous
|
|
206
|
+
factor = next
|
|
207
|
+
}
|
|
208
|
+
previous = [state.x, state.y]
|
|
209
|
+
return travelled([state.x, state.y], factor)
|
|
210
|
+
},
|
|
106
211
|
}
|
|
107
212
|
}
|
|
108
213
|
|
|
@@ -135,6 +240,16 @@ export interface DragValueOptions {
|
|
|
135
240
|
threshold?: number
|
|
136
241
|
/** @see DragOptions.cursor */
|
|
137
242
|
cursor?: string
|
|
243
|
+
/**
|
|
244
|
+
* @see DragOptions.pointerLock
|
|
245
|
+
*
|
|
246
|
+
* Only for a mapping that moves the value relative to where it stood, such
|
|
247
|
+
* as {@link relativeMapping}. {@link elementMapping} reads the pointer
|
|
248
|
+
* position, and there is none while it is locked.
|
|
249
|
+
*/
|
|
250
|
+
pointerLock?: boolean
|
|
251
|
+
/** @see DragOptions.shouldStart */
|
|
252
|
+
shouldStart?: (event: PointerEvent) => boolean
|
|
138
253
|
|
|
139
254
|
onChange?: (value: XY<number>, state: DragState) => void
|
|
140
255
|
onDragStart?: (value: XY<number>, state: DragState) => void
|
|
@@ -165,6 +280,7 @@ export function createDragValue(
|
|
|
165
280
|
): DragValueInstance {
|
|
166
281
|
let opts = options
|
|
167
282
|
let lastValue: XY<number> = [0, 0]
|
|
283
|
+
let active = false
|
|
168
284
|
|
|
169
285
|
const axes = () => toXY(opts.axis)
|
|
170
286
|
|
|
@@ -175,8 +291,9 @@ export function createDragValue(
|
|
|
175
291
|
const scale = axis.scale ?? linearScale
|
|
176
292
|
const value = scale.denormalize(p, axis.min, axis.max)
|
|
177
293
|
const stepped = axis.step ? stepValue(value, axis.step) : value
|
|
178
|
-
// Rounding to the step can leave the range
|
|
179
|
-
|
|
294
|
+
// Rounding to the step can leave the range, and so can dropping the
|
|
295
|
+
// binary artefact, so the clamp comes last.
|
|
296
|
+
return clamp(toPrecision(stepped), axis.min, axis.max)
|
|
180
297
|
}) as XY<number>
|
|
181
298
|
}
|
|
182
299
|
|
|
@@ -200,29 +317,48 @@ export function createDragValue(
|
|
|
200
317
|
const drag = createDrag(element, {
|
|
201
318
|
threshold: opts.threshold,
|
|
202
319
|
cursor: opts.cursor,
|
|
320
|
+
pointerLock: opts.pointerLock,
|
|
321
|
+
shouldStart: (event) => opts.shouldStart?.(event) ?? true,
|
|
203
322
|
onDragStart: (state) => {
|
|
204
323
|
const position = opts.mapping.start(state, context)
|
|
205
|
-
if (position)
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
324
|
+
if (!position) return
|
|
325
|
+
active = true
|
|
326
|
+
lastValue = valueOf(position)
|
|
327
|
+
if (opts.updateOnPointerDown) opts.onChange?.(lastValue, state)
|
|
209
328
|
opts.onDragStart?.(lastValue, state)
|
|
210
329
|
},
|
|
211
330
|
onDrag: (state) => {
|
|
331
|
+
if (!active) return
|
|
212
332
|
const position = opts.mapping.move(state, context)
|
|
213
333
|
if (!position) return
|
|
214
334
|
lastValue = valueOf(position)
|
|
215
335
|
opts.onChange?.(lastValue, state)
|
|
216
336
|
},
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
337
|
+
onDragEnd: (state) => {
|
|
338
|
+
if (!active) return
|
|
339
|
+
if (
|
|
340
|
+
state.event.type === 'pointerup' &&
|
|
341
|
+
(state.deltaX !== 0 || state.deltaY !== 0)
|
|
342
|
+
) {
|
|
343
|
+
const position = opts.mapping.move(state, context)
|
|
344
|
+
if (position) {
|
|
345
|
+
lastValue = valueOf(position)
|
|
346
|
+
opts.onChange?.(lastValue, state)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
active = false
|
|
350
|
+
opts.onDragEnd?.(lastValue, state)
|
|
351
|
+
},
|
|
220
352
|
})
|
|
221
353
|
|
|
222
354
|
return {
|
|
223
355
|
update: (next) => {
|
|
224
356
|
opts = { ...opts, ...next, mapping: opts.mapping }
|
|
225
|
-
drag.update({
|
|
357
|
+
drag.update({
|
|
358
|
+
threshold: opts.threshold,
|
|
359
|
+
cursor: opts.cursor,
|
|
360
|
+
pointerLock: opts.pointerLock,
|
|
361
|
+
})
|
|
226
362
|
},
|
|
227
363
|
destroy: () => drag.destroy(),
|
|
228
364
|
}
|