@tremolo-ui/dom 0.4.0 → 0.6.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 +934 -89
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +580 -31
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +580 -31
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +923 -90
- package/dist/index.js.map +1 -1
- package/package.json +6 -2
- package/src/canvas/animation.ts +322 -0
- package/src/canvas/context.ts +108 -0
- package/src/index.ts +48 -1
- package/src/midi/access.ts +83 -13
- package/src/midi/input.ts +97 -26
- package/src/midi/message.ts +37 -8
- package/src/piano/index.ts +205 -0
- package/src/pointer/drag-value.ts +365 -0
- package/src/pointer/drag.ts +340 -65
- package/src/pointer/wheel.ts +36 -1
- package/src/selection/box.ts +136 -0
- package/src/xy.ts +38 -0
package/dist/index.js
CHANGED
|
@@ -1,13 +1,265 @@
|
|
|
1
|
+
import { clamp, linearScale, normalizeValue, noteAt, stepValue, toPrecision } from "@tremolo-ui/functions";
|
|
2
|
+
//#region src/canvas/context.ts
|
|
3
|
+
/**
|
|
4
|
+
* The assignable parts of a 2D context's drawing state. They have to be
|
|
5
|
+
* carried across a resize by hand because setting `canvas.width` resets the
|
|
6
|
+
* context to its defaults. The transform and line dash are handled separately
|
|
7
|
+
* by {@link DrawingContext} because they are exposed through methods.
|
|
8
|
+
*
|
|
9
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/save
|
|
10
|
+
*/
|
|
11
|
+
const drawingState = [
|
|
12
|
+
"strokeStyle",
|
|
13
|
+
"fillStyle",
|
|
14
|
+
"globalAlpha",
|
|
15
|
+
"lineWidth",
|
|
16
|
+
"lineCap",
|
|
17
|
+
"lineJoin",
|
|
18
|
+
"miterLimit",
|
|
19
|
+
"lineDashOffset",
|
|
20
|
+
"shadowOffsetX",
|
|
21
|
+
"shadowOffsetY",
|
|
22
|
+
"shadowBlur",
|
|
23
|
+
"shadowColor",
|
|
24
|
+
"globalCompositeOperation",
|
|
25
|
+
"filter",
|
|
26
|
+
"font",
|
|
27
|
+
"fontKerning",
|
|
28
|
+
"fontStretch",
|
|
29
|
+
"fontVariantCaps",
|
|
30
|
+
"textAlign",
|
|
31
|
+
"textBaseline",
|
|
32
|
+
"direction",
|
|
33
|
+
"letterSpacing",
|
|
34
|
+
"textRendering",
|
|
35
|
+
"wordSpacing",
|
|
36
|
+
"imageSmoothingEnabled",
|
|
37
|
+
"imageSmoothingQuality"
|
|
38
|
+
];
|
|
39
|
+
function isDrawingState(value) {
|
|
40
|
+
return typeof value === "string" && drawingState.includes(value);
|
|
41
|
+
}
|
|
42
|
+
/** Copy the drawing state off a context, to put back after a resize. */
|
|
43
|
+
function readDrawingState(context) {
|
|
44
|
+
const state = {};
|
|
45
|
+
for (const property of drawingState) state[property] = context[property];
|
|
46
|
+
state.lineDash = context.getLineDash();
|
|
47
|
+
state.transform = context.getTransform();
|
|
48
|
+
return state;
|
|
49
|
+
}
|
|
50
|
+
/** Put a state read by {@link readDrawingState} back onto a context. */
|
|
51
|
+
function writeDrawingState(context, state, transformScale = 1) {
|
|
52
|
+
for (const property of drawingState) context[property] = state[property];
|
|
53
|
+
context.setLineDash(state.lineDash);
|
|
54
|
+
const { a, b, c, d, e, f } = state.transform;
|
|
55
|
+
context.setTransform(a * transformScale, b * transformScale, c * transformScale, d * transformScale, e * transformScale, f * transformScale);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Give the canvas a backing store of `width * dpr` by `height * dpr` device
|
|
59
|
+
* pixels while keeping it `width` by `height` in CSS pixels, and scale the
|
|
60
|
+
* context so that drawing code can work in CSS pixels throughout.
|
|
61
|
+
*
|
|
62
|
+
* Assigning to `canvas.width` clears the canvas and resets the context, so
|
|
63
|
+
* this both resizes and re-establishes the transform.
|
|
64
|
+
*/
|
|
65
|
+
function applyDevicePixelRatio(canvas, context, width, height, dpr) {
|
|
66
|
+
canvas.width = width * dpr;
|
|
67
|
+
canvas.height = height * dpr;
|
|
68
|
+
context.setTransform(1, 0, 0, 1, 0, 0);
|
|
69
|
+
context.scale(dpr, dpr);
|
|
70
|
+
canvas.style.width = `${width}px`;
|
|
71
|
+
canvas.style.height = `${height}px`;
|
|
72
|
+
}
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region src/canvas/animation.ts
|
|
75
|
+
/**
|
|
76
|
+
* Drive a canvas from `requestAnimationFrame`, keeping its backing store in
|
|
77
|
+
* step with the device pixel ratio and, optionally, with the size of its
|
|
78
|
+
* parent.
|
|
79
|
+
*
|
|
80
|
+
* Drawing code works in CSS pixels: the context is scaled by the device pixel
|
|
81
|
+
* ratio, and `width` / `height` of each {@link AnimationFrame} are CSS pixels.
|
|
82
|
+
*/
|
|
83
|
+
function createAnimationCanvas(canvas, options) {
|
|
84
|
+
let opts = options;
|
|
85
|
+
const context2d = canvas.getContext("2d", opts.contextAttributes);
|
|
86
|
+
if (!context2d) throw new Error("createAnimationCanvas: cannot get a 2d context");
|
|
87
|
+
const context = context2d;
|
|
88
|
+
const resizable = opts.resizable ?? false;
|
|
89
|
+
/** Off-document canvas holding the drawing while the real one is resized. */
|
|
90
|
+
let memo = null;
|
|
91
|
+
let memoContext = null;
|
|
92
|
+
let width = 0;
|
|
93
|
+
let height = 0;
|
|
94
|
+
let appliedDpr = 0;
|
|
95
|
+
/** Whether a size has been applied, so a frame can be drawn. */
|
|
96
|
+
let sized = false;
|
|
97
|
+
let initialized = false;
|
|
98
|
+
let frameId = null;
|
|
99
|
+
let count = -1;
|
|
100
|
+
const startTime = performance.now();
|
|
101
|
+
let previousTime = startTime;
|
|
102
|
+
function devicePixelRatio() {
|
|
103
|
+
return globalThis.devicePixelRatio || 1;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Copy the canvas onto the memo canvas at its full device resolution, 1:1.
|
|
107
|
+
*
|
|
108
|
+
* Assigning to `memo.width` resets the memo's transform to the identity, so
|
|
109
|
+
* the copy neither scales nor resamples.
|
|
110
|
+
*/
|
|
111
|
+
function takeSnapshot() {
|
|
112
|
+
if (!(opts.reduceFlickering ?? true)) return false;
|
|
113
|
+
if (!memo) {
|
|
114
|
+
memo = globalThis.document?.createElement("canvas") ?? null;
|
|
115
|
+
memoContext = memo?.getContext("2d", opts.contextAttributes) ?? null;
|
|
116
|
+
}
|
|
117
|
+
if (!memo || !memoContext) return false;
|
|
118
|
+
memo.width = canvas.width;
|
|
119
|
+
memo.height = canvas.height;
|
|
120
|
+
if (canvas.width > 0 && canvas.height > 0) memoContext.drawImage(canvas, 0, 0);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Put the snapshot back, given the size in CSS pixels it was taken at.
|
|
125
|
+
*
|
|
126
|
+
* The context is scaled to CSS pixels, so drawing the snapshot at its old
|
|
127
|
+
* CSS size leaves it exactly where it was. While the device pixel ratio
|
|
128
|
+
* stays put that is a 1:1 copy of device pixels, and when it changes the
|
|
129
|
+
* snapshot is rescaled once, from its full resolution.
|
|
130
|
+
*/
|
|
131
|
+
function restoreSnapshot(hasSnapshot, previousWidth, previousHeight) {
|
|
132
|
+
if (!hasSnapshot || !memo) return;
|
|
133
|
+
if (memo.width <= 0 || memo.height <= 0) return;
|
|
134
|
+
context.drawImage(memo, 0, 0, previousWidth, previousHeight);
|
|
135
|
+
}
|
|
136
|
+
function applySize(w, h) {
|
|
137
|
+
const dpr = devicePixelRatio();
|
|
138
|
+
const previousWidth = width;
|
|
139
|
+
const previousHeight = height;
|
|
140
|
+
const previousDpr = appliedDpr;
|
|
141
|
+
const state = sized ? readDrawingState(context) : null;
|
|
142
|
+
const hasSnapshot = sized && takeSnapshot();
|
|
143
|
+
applyDevicePixelRatio(canvas, context, w, h, dpr);
|
|
144
|
+
width = w;
|
|
145
|
+
height = h;
|
|
146
|
+
appliedDpr = dpr;
|
|
147
|
+
sized = true;
|
|
148
|
+
restoreSnapshot(hasSnapshot, previousWidth, previousHeight);
|
|
149
|
+
if (state) writeDrawingState(context, state, dpr / previousDpr);
|
|
150
|
+
}
|
|
151
|
+
function applyDevicePixelRatioIfNeeded() {
|
|
152
|
+
if (sized && devicePixelRatio() !== appliedDpr) applySize(width, height);
|
|
153
|
+
}
|
|
154
|
+
function drawFrame(timestamp = performance.now()) {
|
|
155
|
+
if (!sized) return;
|
|
156
|
+
applyDevicePixelRatioIfNeeded();
|
|
157
|
+
if (!initialized) {
|
|
158
|
+
initialized = true;
|
|
159
|
+
opts.init?.(context, {
|
|
160
|
+
width,
|
|
161
|
+
height
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
const deltaTime = timestamp - previousTime;
|
|
165
|
+
previousTime = timestamp;
|
|
166
|
+
count += 1;
|
|
167
|
+
opts.draw(context, {
|
|
168
|
+
width,
|
|
169
|
+
height,
|
|
170
|
+
count,
|
|
171
|
+
deltaTime,
|
|
172
|
+
elapsedTime: timestamp - startTime,
|
|
173
|
+
fps: deltaTime > 0 ? 1e3 / deltaTime : 0
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
function tick(timestamp) {
|
|
177
|
+
frameId = requestAnimationFrame(tick);
|
|
178
|
+
drawFrame(timestamp);
|
|
179
|
+
}
|
|
180
|
+
function startLoop() {
|
|
181
|
+
if (frameId !== null) return;
|
|
182
|
+
previousTime = performance.now();
|
|
183
|
+
frameId = requestAnimationFrame(tick);
|
|
184
|
+
}
|
|
185
|
+
function stopLoop() {
|
|
186
|
+
if (frameId === null) return;
|
|
187
|
+
cancelAnimationFrame(frameId);
|
|
188
|
+
frameId = null;
|
|
189
|
+
}
|
|
190
|
+
let observer = null;
|
|
191
|
+
if (resizable) {
|
|
192
|
+
const parent = canvas.parentElement;
|
|
193
|
+
if (!parent) throw new Error("createAnimationCanvas: resizable needs the canvas to have a parent element");
|
|
194
|
+
observer = new ResizeObserver((entries) => {
|
|
195
|
+
for (const entry of entries) {
|
|
196
|
+
const { width: w, height: h } = entry.contentRect;
|
|
197
|
+
if (w !== width || h !== height || devicePixelRatio() !== appliedDpr) applySize(w, h);
|
|
198
|
+
if (!(opts.animate ?? true)) drawFrame();
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
observer.observe(parent);
|
|
202
|
+
} else {
|
|
203
|
+
const { width: w = 100, height: h = 100 } = opts.size ?? {};
|
|
204
|
+
applySize(w, h);
|
|
205
|
+
}
|
|
206
|
+
if (opts.animate ?? true) startLoop();
|
|
207
|
+
else if (sized) drawFrame();
|
|
208
|
+
return {
|
|
209
|
+
update: (next) => {
|
|
210
|
+
const wasAnimating = opts.animate ?? true;
|
|
211
|
+
opts = {
|
|
212
|
+
...opts,
|
|
213
|
+
...next,
|
|
214
|
+
resizable,
|
|
215
|
+
contextAttributes: opts.contextAttributes
|
|
216
|
+
};
|
|
217
|
+
if (!resizable) {
|
|
218
|
+
const { width: w = 100, height: h = 100 } = opts.size ?? {};
|
|
219
|
+
if (w !== width || h !== height) applySize(w, h);
|
|
220
|
+
}
|
|
221
|
+
applyDevicePixelRatioIfNeeded();
|
|
222
|
+
const animating = opts.animate ?? true;
|
|
223
|
+
if (animating && !wasAnimating) startLoop();
|
|
224
|
+
if (!animating && wasAnimating) stopLoop();
|
|
225
|
+
if (!animating) drawFrame();
|
|
226
|
+
},
|
|
227
|
+
redraw: drawFrame,
|
|
228
|
+
destroy: () => {
|
|
229
|
+
stopLoop();
|
|
230
|
+
observer?.disconnect();
|
|
231
|
+
observer = null;
|
|
232
|
+
memo = null;
|
|
233
|
+
memoContext = null;
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
//#endregion
|
|
1
238
|
//#region src/midi/access.ts
|
|
2
239
|
/** @private */
|
|
3
240
|
const PERMISSION_DENIED = "PERMISSION_DENIED";
|
|
4
241
|
/** @private */
|
|
5
242
|
const NOT_SUPPORTED = "NOT_SUPPORTED";
|
|
243
|
+
/** @private */
|
|
244
|
+
const UNAVAILABLE = "UNAVAILABLE";
|
|
6
245
|
const INITIAL_STATE = {
|
|
7
246
|
midiAccess: null,
|
|
8
|
-
error: null
|
|
247
|
+
error: null,
|
|
248
|
+
inputs: []
|
|
9
249
|
};
|
|
10
250
|
/**
|
|
251
|
+
* Which of our errors a rejected `requestMIDIAccess()` amounts to.
|
|
252
|
+
*
|
|
253
|
+
* The spec names the reasons, and they need telling apart: a user who said no
|
|
254
|
+
* can say yes on a second ask, while a browser without the API never will.
|
|
255
|
+
*/
|
|
256
|
+
function toError(reason) {
|
|
257
|
+
const name = typeof reason === "object" && reason !== null && "name" in reason ? String(reason.name) : "";
|
|
258
|
+
if (name === "SecurityError" || name === "NotAllowedError") return PERMISSION_DENIED;
|
|
259
|
+
if (name === "NotSupportedError" || name === "TypeError") return NOT_SUPPORTED;
|
|
260
|
+
return UNAVAILABLE;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
11
263
|
* Request MIDI access in the browser.
|
|
12
264
|
*
|
|
13
265
|
* The returned instance holds the state and notifies subscribers when it changes,
|
|
@@ -16,13 +268,31 @@ const INITIAL_STATE = {
|
|
|
16
268
|
function createMIDIAccess() {
|
|
17
269
|
let state = INITIAL_STATE;
|
|
18
270
|
let destroyed = false;
|
|
271
|
+
let access = null;
|
|
272
|
+
let requestGeneration = 0;
|
|
19
273
|
const listeners = /* @__PURE__ */ new Set();
|
|
20
274
|
function setState(next) {
|
|
21
275
|
state = next;
|
|
22
276
|
for (const listener of listeners) listener();
|
|
23
277
|
}
|
|
24
|
-
|
|
278
|
+
/**
|
|
279
|
+
* A new array every time, so that `useSyncExternalStore` and its equivalents
|
|
280
|
+
* see the change. The identity of the state object is what they compare.
|
|
281
|
+
*/
|
|
282
|
+
function readInputs() {
|
|
283
|
+
return access ? [...access.inputs.values()] : [];
|
|
284
|
+
}
|
|
285
|
+
function handleStateChange(event) {
|
|
286
|
+
if (destroyed || !access) return;
|
|
287
|
+
if (event && event.port?.type === "output") return;
|
|
288
|
+
setState({
|
|
289
|
+
...state,
|
|
290
|
+
inputs: readInputs()
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
function request(options = {}) {
|
|
25
294
|
if (destroyed) return;
|
|
295
|
+
const generation = ++requestGeneration;
|
|
26
296
|
if (typeof navigator === "undefined" || !navigator.requestMIDIAccess) {
|
|
27
297
|
setState({
|
|
28
298
|
...state,
|
|
@@ -30,17 +300,21 @@ function createMIDIAccess() {
|
|
|
30
300
|
});
|
|
31
301
|
return;
|
|
32
302
|
}
|
|
33
|
-
navigator.requestMIDIAccess().then((
|
|
34
|
-
if (destroyed) return;
|
|
303
|
+
navigator.requestMIDIAccess({ sysex: options.sysex ?? false }).then((granted) => {
|
|
304
|
+
if (destroyed || generation !== requestGeneration) return;
|
|
305
|
+
access?.removeEventListener("statechange", handleStateChange);
|
|
306
|
+
access = granted;
|
|
307
|
+
granted.addEventListener("statechange", handleStateChange);
|
|
35
308
|
setState({
|
|
36
|
-
midiAccess:
|
|
37
|
-
error: null
|
|
309
|
+
midiAccess: granted,
|
|
310
|
+
error: null,
|
|
311
|
+
inputs: readInputs()
|
|
38
312
|
});
|
|
39
|
-
}
|
|
40
|
-
if (destroyed) return;
|
|
313
|
+
}, (reason) => {
|
|
314
|
+
if (destroyed || generation !== requestGeneration) return;
|
|
41
315
|
setState({
|
|
42
316
|
...state,
|
|
43
|
-
error:
|
|
317
|
+
error: toError(reason)
|
|
44
318
|
});
|
|
45
319
|
});
|
|
46
320
|
}
|
|
@@ -56,6 +330,9 @@ function createMIDIAccess() {
|
|
|
56
330
|
},
|
|
57
331
|
destroy: () => {
|
|
58
332
|
destroyed = true;
|
|
333
|
+
requestGeneration += 1;
|
|
334
|
+
access?.removeEventListener("statechange", handleStateChange);
|
|
335
|
+
access = null;
|
|
59
336
|
listeners.clear();
|
|
60
337
|
}
|
|
61
338
|
};
|
|
@@ -65,35 +342,110 @@ function createMIDIAccess() {
|
|
|
65
342
|
/**
|
|
66
343
|
* Listen to raw `midimessage` events on every input of a MIDIAccess.
|
|
67
344
|
*
|
|
345
|
+
* The set of inputs is followed rather than sampled: MIDIAccess fires
|
|
346
|
+
* `statechange` when a device is plugged in or unplugged, and the listeners
|
|
347
|
+
* move with it. A keyboard connected after access was granted works without
|
|
348
|
+
* the caller having to rebuild anything.
|
|
349
|
+
*
|
|
68
350
|
* Use this when you need more detail than {@link createMIDIInput} provides.
|
|
69
351
|
*/
|
|
70
352
|
function createMIDIMessage(midiAccess, onMIDIMessage) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
353
|
+
let handler = onMIDIMessage;
|
|
354
|
+
/** Inputs this instance currently listens on. */
|
|
355
|
+
const attached = /* @__PURE__ */ new Set();
|
|
356
|
+
const listener = (event) => handler(event);
|
|
357
|
+
function sync() {
|
|
358
|
+
if (!midiAccess) return;
|
|
359
|
+
const connected = new Set(midiAccess.inputs.values());
|
|
360
|
+
for (const input of connected) {
|
|
361
|
+
if (attached.has(input)) continue;
|
|
362
|
+
input.addEventListener("midimessage", listener);
|
|
363
|
+
attached.add(input);
|
|
364
|
+
}
|
|
365
|
+
for (const input of [...attached]) {
|
|
366
|
+
if (connected.has(input)) continue;
|
|
367
|
+
input.removeEventListener("midimessage", listener);
|
|
368
|
+
attached.delete(input);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
sync();
|
|
372
|
+
midiAccess?.addEventListener("statechange", sync);
|
|
373
|
+
return {
|
|
374
|
+
update: (next) => {
|
|
375
|
+
handler = next;
|
|
376
|
+
},
|
|
377
|
+
destroy: () => {
|
|
378
|
+
midiAccess?.removeEventListener("statechange", sync);
|
|
379
|
+
for (const input of attached) input.removeEventListener("midimessage", listener);
|
|
380
|
+
attached.clear();
|
|
381
|
+
}
|
|
382
|
+
};
|
|
77
383
|
}
|
|
78
384
|
//#endregion
|
|
79
385
|
//#region src/midi/input.ts
|
|
80
|
-
|
|
81
|
-
|
|
386
|
+
/** Status byte of each channel message, with the channel nibble cleared. */
|
|
387
|
+
const STATUS = {
|
|
82
388
|
NOTE_OFF: 128,
|
|
389
|
+
NOTE_ON: 144,
|
|
390
|
+
AFTERTOUCH: 160,
|
|
391
|
+
CONTROL_CHANGE: 176,
|
|
392
|
+
PROGRAM_CHANGE: 192,
|
|
393
|
+
CHANNEL_PRESSURE: 208,
|
|
83
394
|
PITCH_BEND: 224
|
|
84
395
|
};
|
|
85
396
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
397
|
+
* Centre of the 14-bit pitch bend range: no bend.
|
|
398
|
+
*
|
|
399
|
+
* The range is not symmetric — 0 is 8192 below centre and 16383 is 8191 above
|
|
400
|
+
* — so a wheel at rest reports exactly this rather than half of the maximum.
|
|
88
401
|
*/
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
402
|
+
const PITCH_BEND_CENTER = 8192;
|
|
403
|
+
/**
|
|
404
|
+
* Handle the channel voice messages of every connected input. To be used with
|
|
405
|
+
* {@link createMIDIAccess}. Internally uses {@link createMIDIMessage}, so
|
|
406
|
+
* devices plugged in later are picked up.
|
|
407
|
+
*
|
|
408
|
+
* System messages (clock, sysex, and the rest of `0xf0`-`0xff`) are not
|
|
409
|
+
* decoded here; reach for {@link createMIDIMessage} for those.
|
|
410
|
+
*/
|
|
411
|
+
function createMIDIInput(midiAccess, handlers) {
|
|
412
|
+
let current = handlers;
|
|
413
|
+
return {
|
|
414
|
+
update: (next) => {
|
|
415
|
+
current = next;
|
|
416
|
+
},
|
|
417
|
+
destroy: createMIDIMessage(midiAccess, (event) => {
|
|
418
|
+
if (!event.data || event.data.length === 0) return;
|
|
419
|
+
const status = event.data[0];
|
|
420
|
+
if (status >= 240) return;
|
|
421
|
+
const kind = status & 240;
|
|
422
|
+
const channel = status & 15;
|
|
423
|
+
const first = event.data[1] ?? 0;
|
|
424
|
+
const second = event.data[2] ?? 0;
|
|
425
|
+
switch (kind) {
|
|
426
|
+
case STATUS.NOTE_OFF:
|
|
427
|
+
current.onNoteOffEvent?.(first, channel);
|
|
428
|
+
break;
|
|
429
|
+
case STATUS.NOTE_ON:
|
|
430
|
+
if (second === 0) current.onNoteOffEvent?.(first, channel);
|
|
431
|
+
else current.onNoteOnEvent?.(first, second, channel);
|
|
432
|
+
break;
|
|
433
|
+
case STATUS.AFTERTOUCH:
|
|
434
|
+
current.onAftertouchEvent?.(first, second, channel);
|
|
435
|
+
break;
|
|
436
|
+
case STATUS.CONTROL_CHANGE:
|
|
437
|
+
current.onControlChangeEvent?.(first, second, channel);
|
|
438
|
+
break;
|
|
439
|
+
case STATUS.PROGRAM_CHANGE:
|
|
440
|
+
current.onProgramChangeEvent?.(first, channel);
|
|
441
|
+
break;
|
|
442
|
+
case STATUS.CHANNEL_PRESSURE:
|
|
443
|
+
current.onChannelPressureEvent?.(first, channel);
|
|
444
|
+
break;
|
|
445
|
+
case STATUS.PITCH_BEND: current.onPitchBendEvent?.(second << 7 | first, channel);
|
|
446
|
+
}
|
|
447
|
+
}).destroy
|
|
448
|
+
};
|
|
97
449
|
}
|
|
98
450
|
//#endregion
|
|
99
451
|
//#region src/pointer/drag.ts
|
|
@@ -110,6 +462,7 @@ const MANAGED_STYLES = [
|
|
|
110
462
|
["-webkit-user-select", "none"],
|
|
111
463
|
["-webkit-touch-callout", "none"]
|
|
112
464
|
];
|
|
465
|
+
const managedStyleStates = /* @__PURE__ */ new WeakMap();
|
|
113
466
|
/**
|
|
114
467
|
* Track a pointer drag on an element.
|
|
115
468
|
*
|
|
@@ -118,32 +471,54 @@ const MANAGED_STYLES = [
|
|
|
118
471
|
* element gets `touch-action: none` so that touch dragging does not scroll the
|
|
119
472
|
* page, plus `user-select: none` so that a long press does not start a text
|
|
120
473
|
* selection instead.
|
|
474
|
+
*
|
|
475
|
+
* One pointer at a time by default; see {@link DragOptions.multiPointer}.
|
|
121
476
|
*/
|
|
122
|
-
function createDrag(element,
|
|
123
|
-
|
|
477
|
+
function createDrag(element, options = {}) {
|
|
478
|
+
let opts = options;
|
|
479
|
+
const multiPointer = options.multiPointer ?? false;
|
|
124
480
|
const capture = element;
|
|
125
481
|
const style = element.style;
|
|
126
|
-
|
|
127
|
-
if (style)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
482
|
+
let managedStyles = managedStyleStates.get(element);
|
|
483
|
+
if (style) {
|
|
484
|
+
if (managedStyles) managedStyles.count += 1;
|
|
485
|
+
else {
|
|
486
|
+
const previous = /* @__PURE__ */ new Map();
|
|
487
|
+
for (const [property, value] of MANAGED_STYLES) {
|
|
488
|
+
previous.set(property, {
|
|
489
|
+
value: style.getPropertyValue(property),
|
|
490
|
+
priority: style.getPropertyPriority(property)
|
|
491
|
+
});
|
|
492
|
+
style.setProperty(property, value);
|
|
493
|
+
}
|
|
494
|
+
managedStyles = {
|
|
495
|
+
count: 1,
|
|
496
|
+
previous
|
|
497
|
+
};
|
|
498
|
+
managedStyleStates.set(element, managedStyles);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
const pointers = /* @__PURE__ */ new Map();
|
|
502
|
+
/**
|
|
503
|
+
* How many pointers each target carries. Adding the same listener twice is a
|
|
504
|
+
* no-op and removing it once removes it for good, so a target is subscribed
|
|
505
|
+
* to while at least one pointer is on it and no longer.
|
|
506
|
+
*/
|
|
507
|
+
const targets = /* @__PURE__ */ new Map();
|
|
138
508
|
let previousCursor;
|
|
139
|
-
|
|
509
|
+
/** The pointer that asked for the lock, while it is still down. */
|
|
510
|
+
let lockedPointerId = null;
|
|
511
|
+
let lockRequest = 0;
|
|
512
|
+
let destroyed = false;
|
|
513
|
+
function state(event, pointer, deltaX, deltaY) {
|
|
140
514
|
return {
|
|
141
|
-
x: event.screenX - startX,
|
|
142
|
-
y: event.screenY - startY,
|
|
515
|
+
x: pointer.lockBaseX !== void 0 ? pointer.lockBaseX + pointer.lockMoveX : event.screenX - pointer.startX,
|
|
516
|
+
y: pointer.lockBaseY !== void 0 ? pointer.lockBaseY + pointer.lockMoveY : event.screenY - pointer.startY,
|
|
143
517
|
deltaX,
|
|
144
518
|
deltaY,
|
|
145
519
|
clientX: event.clientX,
|
|
146
520
|
clientY: event.clientY,
|
|
521
|
+
pointerId: event.pointerId,
|
|
147
522
|
event
|
|
148
523
|
};
|
|
149
524
|
}
|
|
@@ -155,65 +530,437 @@ function createDrag(element, { threshold: _threshold = 1, cursor, onDragStart, o
|
|
|
155
530
|
function preventSelectStart(event) {
|
|
156
531
|
event.preventDefault();
|
|
157
532
|
}
|
|
533
|
+
function retainTarget(target) {
|
|
534
|
+
const count = targets.get(target) ?? 0;
|
|
535
|
+
if (count === 0) {
|
|
536
|
+
target.addEventListener("pointermove", handlePointerMove);
|
|
537
|
+
target.addEventListener("pointerup", handlePointerUp);
|
|
538
|
+
target.addEventListener("pointercancel", handlePointerUp);
|
|
539
|
+
if (target === element) target.addEventListener("lostpointercapture", handleLostPointerCapture);
|
|
540
|
+
}
|
|
541
|
+
targets.set(target, count + 1);
|
|
542
|
+
}
|
|
543
|
+
function releaseTarget(target) {
|
|
544
|
+
const count = targets.get(target) ?? 0;
|
|
545
|
+
if (count > 1) {
|
|
546
|
+
targets.set(target, count - 1);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
target.removeEventListener("pointermove", handlePointerMove);
|
|
550
|
+
target.removeEventListener("pointerup", handlePointerUp);
|
|
551
|
+
target.removeEventListener("pointercancel", handlePointerUp);
|
|
552
|
+
if (target === element) target.removeEventListener("lostpointercapture", handleLostPointerCapture);
|
|
553
|
+
targets.delete(target);
|
|
554
|
+
}
|
|
158
555
|
function handlePointerDown(event) {
|
|
159
556
|
const pointerEvent = event;
|
|
160
|
-
if (
|
|
161
|
-
pointerId = pointerEvent.pointerId;
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (
|
|
557
|
+
if (pointerEvent.button !== 0) return;
|
|
558
|
+
const pointerId = pointerEvent.pointerId;
|
|
559
|
+
if (pointers.has(pointerId)) return;
|
|
560
|
+
if (!multiPointer && pointers.size > 0) return;
|
|
561
|
+
if (opts.shouldStart && !opts.shouldStart(pointerEvent)) return;
|
|
562
|
+
const isFirst = pointers.size === 0;
|
|
563
|
+
if (isFirst && opts.cursor && style) {
|
|
165
564
|
previousCursor = style.cursor;
|
|
166
|
-
style.cursor = cursor;
|
|
565
|
+
style.cursor = opts.cursor;
|
|
167
566
|
}
|
|
168
567
|
capture.setPointerCapture?.(pointerId);
|
|
169
|
-
moveTarget = capture.hasPointerCapture?.(pointerId) === true ? element : globalThis.window ?? element;
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
568
|
+
const moveTarget = capture.hasPointerCapture?.(pointerId) === true ? element : globalThis.window ?? element;
|
|
569
|
+
const pointer = {
|
|
570
|
+
moveTarget,
|
|
571
|
+
startX: pointerEvent.screenX,
|
|
572
|
+
startY: pointerEvent.screenY,
|
|
573
|
+
lastX: pointerEvent.screenX,
|
|
574
|
+
lastY: pointerEvent.screenY,
|
|
575
|
+
lockMoveX: 0,
|
|
576
|
+
lockMoveY: 0,
|
|
577
|
+
lastEvent: pointerEvent
|
|
578
|
+
};
|
|
579
|
+
pointers.set(pointerId, pointer);
|
|
580
|
+
retainTarget(moveTarget);
|
|
581
|
+
if (isFirst) globalThis.document?.addEventListener("selectstart", preventSelectStart);
|
|
582
|
+
if (isFirst && opts.pointerLock) {
|
|
583
|
+
const requestId = ++lockRequest;
|
|
584
|
+
lockedPointerId = pointerId;
|
|
585
|
+
globalThis.document?.addEventListener("pointerlockchange", handleLockChange);
|
|
586
|
+
try {
|
|
587
|
+
(capture.requestPointerLock?.())?.then?.(() => {
|
|
588
|
+
if (requestId === lockRequest && (!pointers.has(pointerId) || destroyed) && globalThis.document?.pointerLockElement === element) globalThis.document.exitPointerLock?.();
|
|
589
|
+
}, () => {});
|
|
590
|
+
} catch {}
|
|
591
|
+
}
|
|
592
|
+
opts.onDragStart?.(state(pointerEvent, pointer, 0, 0));
|
|
175
593
|
}
|
|
176
594
|
function handlePointerMove(event) {
|
|
177
595
|
const pointerEvent = event;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
596
|
+
const pointer = pointers.get(pointerEvent.pointerId);
|
|
597
|
+
if (!pointer) return;
|
|
598
|
+
const locked = pointer.lockBaseX !== void 0;
|
|
599
|
+
const deltaX = locked ? pointerEvent.movementX ?? 0 : pointerEvent.screenX - pointer.lastX;
|
|
600
|
+
const deltaY = locked ? pointerEvent.movementY ?? 0 : pointerEvent.screenY - pointer.lastY;
|
|
601
|
+
const threshold = opts.threshold ?? 0;
|
|
183
602
|
if (Math.abs(deltaX) < threshold && Math.abs(deltaY) < threshold) return;
|
|
184
|
-
|
|
603
|
+
pointer.lastX = pointerEvent.screenX;
|
|
604
|
+
pointer.lastY = pointerEvent.screenY;
|
|
605
|
+
pointer.lastEvent = pointerEvent;
|
|
606
|
+
if (locked) {
|
|
607
|
+
pointer.lockMoveX += deltaX;
|
|
608
|
+
pointer.lockMoveY += deltaY;
|
|
609
|
+
}
|
|
610
|
+
opts.onDrag?.(state(pointerEvent, pointer, deltaX, deltaY));
|
|
611
|
+
}
|
|
612
|
+
function handleLockChange() {
|
|
613
|
+
if (lockedPointerId === null) return;
|
|
614
|
+
const pointer = pointers.get(lockedPointerId);
|
|
615
|
+
if (!pointer) return;
|
|
616
|
+
if (globalThis.document?.pointerLockElement === element) {
|
|
617
|
+
pointer.lockBaseX = pointer.lastX - pointer.startX;
|
|
618
|
+
pointer.lockBaseY = pointer.lastY - pointer.startY;
|
|
619
|
+
pointer.lockMoveX = 0;
|
|
620
|
+
pointer.lockMoveY = 0;
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (pointer.lockBaseX === void 0) return;
|
|
624
|
+
finishDrag(lockedPointerId);
|
|
185
625
|
}
|
|
186
626
|
function handlePointerUp(event) {
|
|
187
627
|
const pointerEvent = event;
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
function
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
628
|
+
finishDrag(pointerEvent.pointerId, pointerEvent);
|
|
629
|
+
}
|
|
630
|
+
function handleLostPointerCapture(event) {
|
|
631
|
+
finishDrag(event.pointerId);
|
|
632
|
+
}
|
|
633
|
+
function finishDrag(pointerId, event) {
|
|
634
|
+
const pointer = pointers.get(pointerId);
|
|
635
|
+
if (!pointer) return;
|
|
636
|
+
const finalState = event ? state(event, pointer, event.screenX - pointer.lastX, event.screenY - pointer.lastY) : state(pointer.lastEvent, pointer, 0, 0);
|
|
637
|
+
stopTracking(pointerId);
|
|
638
|
+
opts.onDragEnd?.(finalState);
|
|
639
|
+
}
|
|
640
|
+
function stopTracking(pointerId) {
|
|
641
|
+
const pointer = pointers.get(pointerId);
|
|
642
|
+
if (!pointer) return;
|
|
643
|
+
pointers.delete(pointerId);
|
|
644
|
+
releaseTarget(pointer.moveTarget);
|
|
645
|
+
try {
|
|
646
|
+
if (capture.hasPointerCapture?.(pointerId) === true) capture.releasePointerCapture?.(pointerId);
|
|
647
|
+
} catch {}
|
|
648
|
+
if (lockedPointerId === pointerId) {
|
|
649
|
+
lockedPointerId = null;
|
|
650
|
+
const document = globalThis.document;
|
|
651
|
+
document?.removeEventListener("pointerlockchange", handleLockChange);
|
|
652
|
+
if (document?.pointerLockElement === element) document.exitPointerLock?.();
|
|
653
|
+
}
|
|
654
|
+
if (pointers.size > 0) return;
|
|
199
655
|
globalThis.document?.removeEventListener("selectstart", preventSelectStart);
|
|
200
|
-
if (
|
|
201
|
-
style.cursor = previousCursor
|
|
656
|
+
if (previousCursor !== void 0 && style) {
|
|
657
|
+
style.cursor = previousCursor;
|
|
202
658
|
previousCursor = void 0;
|
|
203
659
|
}
|
|
204
|
-
moveTarget = null;
|
|
205
|
-
pointerId = null;
|
|
206
660
|
}
|
|
207
661
|
element.addEventListener("pointerdown", handlePointerDown);
|
|
208
|
-
return {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
662
|
+
return {
|
|
663
|
+
update: (next) => {
|
|
664
|
+
opts = {
|
|
665
|
+
...opts,
|
|
666
|
+
...next,
|
|
667
|
+
multiPointer
|
|
668
|
+
};
|
|
669
|
+
},
|
|
670
|
+
destroy: () => {
|
|
671
|
+
if (destroyed) return;
|
|
672
|
+
destroyed = true;
|
|
673
|
+
element.removeEventListener("pointerdown", handlePointerDown);
|
|
674
|
+
for (const pointerId of [...pointers.keys()]) finishDrag(pointerId);
|
|
675
|
+
if (style && managedStyles && --managedStyles.count === 0) {
|
|
676
|
+
for (const [property] of MANAGED_STYLES) {
|
|
677
|
+
const previous = managedStyles.previous.get(property);
|
|
678
|
+
if (previous?.value) style.setProperty(property, previous.value, previous.priority);
|
|
679
|
+
else style.removeProperty(property);
|
|
680
|
+
}
|
|
681
|
+
managedStyleStates.delete(element);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
//#endregion
|
|
687
|
+
//#region src/piano/index.ts
|
|
688
|
+
const DEFAULT_SOURCE = "api";
|
|
689
|
+
/**
|
|
690
|
+
* Drive a piano keyboard with pointers, and own what is sounding.
|
|
691
|
+
*
|
|
692
|
+
* Every way of playing a note goes through the one instance — pointers here,
|
|
693
|
+
* keyboard shortcuts and MIDI through {@link PianoInputInstance.noteOn} — so
|
|
694
|
+
* there is a single answer to what is currently held, and drawing the keys is
|
|
695
|
+
* left entirely to the wrapper.
|
|
696
|
+
*
|
|
697
|
+
* Tracks every pointer at once, so a chord can be played with several fingers.
|
|
698
|
+
*/
|
|
699
|
+
function createPianoInput(element, options) {
|
|
700
|
+
let opts = options;
|
|
701
|
+
/** Which sources are holding each sounding note. */
|
|
702
|
+
const held = /* @__PURE__ */ new Map();
|
|
703
|
+
/** The note each pointer is currently on. */
|
|
704
|
+
const pointerNotes = /* @__PURE__ */ new Map();
|
|
705
|
+
function activeNotes() {
|
|
706
|
+
return [...held.keys()].sort((a, b) => a - b);
|
|
707
|
+
}
|
|
708
|
+
function noteOn(note, { source = DEFAULT_SOURCE, velocity } = {}) {
|
|
709
|
+
if (!Number.isInteger(note) || note < 0 || note > 127) throw new RangeError("note: requirements: an integer from 0 to 127");
|
|
710
|
+
if (note > (opts.midiMax ?? 127)) return;
|
|
711
|
+
const sources = held.get(note);
|
|
712
|
+
if (sources) {
|
|
713
|
+
sources.add(source);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
held.set(note, /* @__PURE__ */ new Set([source]));
|
|
717
|
+
opts.onPlayNote?.(note, velocity);
|
|
718
|
+
opts.onActiveNotesChange?.(activeNotes());
|
|
719
|
+
}
|
|
720
|
+
function noteOff(note, { source = DEFAULT_SOURCE } = {}) {
|
|
721
|
+
if (!Number.isInteger(note) || note < 0 || note > 127) throw new RangeError("note: requirements: an integer from 0 to 127");
|
|
722
|
+
const sources = held.get(note);
|
|
723
|
+
if (!sources) return;
|
|
724
|
+
sources.delete(source);
|
|
725
|
+
if (sources.size > 0) return;
|
|
726
|
+
held.delete(note);
|
|
727
|
+
opts.onStopNote?.(note);
|
|
728
|
+
opts.onActiveNotesChange?.(activeNotes());
|
|
729
|
+
}
|
|
730
|
+
/** The note under a pointer, or null where the pointer is off the keys. */
|
|
731
|
+
function noteUnder(clientX, clientY) {
|
|
732
|
+
const { left, top, height } = element.getBoundingClientRect();
|
|
733
|
+
return noteAt(clientX - left, clientY - top, height, opts.layout);
|
|
734
|
+
}
|
|
735
|
+
/** Move a pointer onto a note, releasing whatever it held before. */
|
|
736
|
+
function movePointer(pointerId, note) {
|
|
737
|
+
const source = `pointer:${pointerId}`;
|
|
738
|
+
const previous = pointerNotes.get(pointerId);
|
|
739
|
+
if (previous === note) return;
|
|
740
|
+
if (previous !== void 0) noteOff(previous, { source });
|
|
741
|
+
if (note === null || note > (opts.midiMax ?? 127)) pointerNotes.delete(pointerId);
|
|
742
|
+
else {
|
|
743
|
+
pointerNotes.set(pointerId, note);
|
|
744
|
+
noteOn(note, { source });
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
const drag = createDrag(element, {
|
|
748
|
+
multiPointer: true,
|
|
749
|
+
onDragStart: (state) => movePointer(state.pointerId, noteUnder(state.clientX, state.clientY)),
|
|
750
|
+
onDrag: (state) => {
|
|
751
|
+
if (opts.glissando === false) return;
|
|
752
|
+
movePointer(state.pointerId, noteUnder(state.clientX, state.clientY));
|
|
753
|
+
},
|
|
754
|
+
onDragEnd: (state) => movePointer(state.pointerId, null)
|
|
755
|
+
});
|
|
756
|
+
return {
|
|
757
|
+
update: (next) => {
|
|
758
|
+
opts = {
|
|
759
|
+
...opts,
|
|
760
|
+
...next
|
|
761
|
+
};
|
|
762
|
+
const midiMax = opts.midiMax ?? 127;
|
|
763
|
+
const stoppedNotes = activeNotes().filter((note) => note > midiMax);
|
|
764
|
+
if (stoppedNotes.length === 0) return;
|
|
765
|
+
for (const note of stoppedNotes) {
|
|
766
|
+
held.delete(note);
|
|
767
|
+
opts.onStopNote?.(note);
|
|
768
|
+
}
|
|
769
|
+
for (const [pointerId, note] of pointerNotes) if (note > midiMax) pointerNotes.delete(pointerId);
|
|
770
|
+
opts.onActiveNotesChange?.(activeNotes());
|
|
771
|
+
},
|
|
772
|
+
noteOn,
|
|
773
|
+
noteOff,
|
|
774
|
+
activeNotes,
|
|
775
|
+
destroy: () => {
|
|
776
|
+
drag.destroy();
|
|
777
|
+
const notes = activeNotes();
|
|
778
|
+
for (const note of notes) {
|
|
779
|
+
held.delete(note);
|
|
780
|
+
opts.onStopNote?.(note);
|
|
781
|
+
}
|
|
782
|
+
pointerNotes.clear();
|
|
783
|
+
if (notes.length > 0) opts.onActiveNotesChange?.([]);
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
//#endregion
|
|
788
|
+
//#region src/xy.ts
|
|
789
|
+
function isPair(value) {
|
|
790
|
+
return Array.isArray(value);
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Spread a setting that may have been given as a single value.
|
|
794
|
+
*
|
|
795
|
+
* The parameter is written out rather than taken as `XYInput<T>`: a
|
|
796
|
+
* conditional type cannot be narrowed, so the constraint stays where it is
|
|
797
|
+
* declared and this takes both forms.
|
|
798
|
+
*
|
|
799
|
+
* The pair is copied rather than passed along, so that the result is a tuple
|
|
800
|
+
* the caller owns even when a readonly one was given.
|
|
801
|
+
*/
|
|
802
|
+
function toXY(value) {
|
|
803
|
+
return isPair(value) ? [value[0], value[1]] : [value, value];
|
|
804
|
+
}
|
|
805
|
+
//#endregion
|
|
806
|
+
//#region src/pointer/drag-value.ts
|
|
807
|
+
/**
|
|
808
|
+
* Map the pointer onto the bounding rect of an element: the value *is* the
|
|
809
|
+
* position pointed at, so the middle of the element is 0.5.
|
|
810
|
+
*
|
|
811
|
+
* The element is read on every event, so it may be mounted after the drag is
|
|
812
|
+
* set up and may change size while a drag is in progress.
|
|
813
|
+
*/
|
|
814
|
+
function elementMapping(getElement, { sensitivity } = {}) {
|
|
815
|
+
/** The reported position when the sensitivity last changed. */
|
|
816
|
+
let origin = [0, 0];
|
|
817
|
+
/** The raw position at that same moment. */
|
|
818
|
+
let anchor = [0, 0];
|
|
819
|
+
/** The previous event, so a sensitivity change can be dated back to it. */
|
|
820
|
+
let previous = [0, 0];
|
|
821
|
+
let factor = 1;
|
|
822
|
+
function positionIn(state) {
|
|
823
|
+
const element = getElement();
|
|
824
|
+
if (!element) return null;
|
|
825
|
+
const { left, top, right, bottom } = element.getBoundingClientRect();
|
|
826
|
+
return [right > left ? normalizeValue(state.clientX, left, right) : 0, bottom > top ? normalizeValue(state.clientY, top, bottom) : 0];
|
|
827
|
+
}
|
|
828
|
+
const reported = (raw, at) => [origin[0] + (raw[0] - anchor[0]) * at, origin[1] + (raw[1] - anchor[1]) * at];
|
|
829
|
+
return {
|
|
830
|
+
start: (state) => {
|
|
831
|
+
const raw = positionIn(state);
|
|
832
|
+
if (!raw) return null;
|
|
833
|
+
origin = raw;
|
|
834
|
+
anchor = raw;
|
|
835
|
+
previous = raw;
|
|
836
|
+
factor = sensitivity ? sensitivity(state) : 1;
|
|
837
|
+
return raw;
|
|
838
|
+
},
|
|
839
|
+
move: (state) => {
|
|
840
|
+
const raw = positionIn(state);
|
|
841
|
+
if (!raw) return null;
|
|
842
|
+
const next = sensitivity ? sensitivity(state) : 1;
|
|
843
|
+
if (next !== factor) {
|
|
844
|
+
origin = reported(previous, factor);
|
|
845
|
+
anchor = previous;
|
|
846
|
+
factor = next;
|
|
847
|
+
}
|
|
848
|
+
previous = raw;
|
|
849
|
+
return reported(raw, factor);
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Move the value away from where it stood when the drag started, by the
|
|
855
|
+
* distance dragged. The pointer position itself carries no meaning, so the
|
|
856
|
+
* value can be adjusted from anywhere on the screen.
|
|
857
|
+
*/
|
|
858
|
+
function relativeMapping({ pixelRange = 100, sensitivity } = {}) {
|
|
859
|
+
const [baseX, baseY] = toXY(pixelRange);
|
|
860
|
+
let origin = [0, 0];
|
|
861
|
+
/** Where the current sensitivity took over, in drag coordinates. */
|
|
862
|
+
let anchor = [0, 0];
|
|
863
|
+
/** The previous event, so a sensitivity change can be dated back to it. */
|
|
864
|
+
let previous = [0, 0];
|
|
865
|
+
let factor = 1;
|
|
866
|
+
const travelled = (to, at) => [baseX === 0 ? origin[0] : origin[0] + (to[0] - anchor[0]) * at / baseX, baseY === 0 ? origin[1] : origin[1] + (to[1] - anchor[1]) * at / baseY];
|
|
867
|
+
return {
|
|
868
|
+
start: (state, context) => {
|
|
869
|
+
origin = context.position();
|
|
870
|
+
anchor = [0, 0];
|
|
871
|
+
previous = [0, 0];
|
|
872
|
+
factor = sensitivity ? sensitivity(state) : 1;
|
|
873
|
+
return origin;
|
|
874
|
+
},
|
|
875
|
+
move: (state) => {
|
|
876
|
+
const next = sensitivity ? sensitivity(state) : 1;
|
|
877
|
+
if (next !== factor) {
|
|
878
|
+
origin = travelled(previous, factor);
|
|
879
|
+
anchor = previous;
|
|
880
|
+
factor = next;
|
|
881
|
+
}
|
|
882
|
+
previous = [state.x, state.y];
|
|
883
|
+
return travelled([state.x, state.y], factor);
|
|
215
884
|
}
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Drive a value with a pointer drag.
|
|
889
|
+
*
|
|
890
|
+
* Combines {@link createDrag} with the scaling of `@tremolo-ui/functions`: the
|
|
891
|
+
* mapping decides where the pointer sits on the 0-1 travel of each axis, and
|
|
892
|
+
* the axis options turn that into a value.
|
|
893
|
+
*/
|
|
894
|
+
function createDragValue(element, options) {
|
|
895
|
+
let opts = options;
|
|
896
|
+
let lastValue = [0, 0];
|
|
897
|
+
let active = false;
|
|
898
|
+
const axes = () => toXY(opts.axis);
|
|
899
|
+
function valueOf(position) {
|
|
900
|
+
return axes().map((axis, i) => {
|
|
901
|
+
const p = axis.reverse ? 1 - position[i] : position[i];
|
|
902
|
+
const value = (axis.scale ?? linearScale).denormalize(p, axis.min, axis.max);
|
|
903
|
+
const stepped = axis.step ? stepValue(value, axis.step) : value;
|
|
904
|
+
return clamp(toPrecision(stepped), axis.min, axis.max);
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
const context = { position: () => {
|
|
908
|
+
const getValue = opts.getValue;
|
|
909
|
+
if (!getValue) throw new Error("createDragValue: getValue is required by the given mapping");
|
|
910
|
+
const value = getValue();
|
|
911
|
+
return axes().map((axis, i) => {
|
|
912
|
+
const n = (axis.scale ?? linearScale).normalize(value[i], axis.min, axis.max);
|
|
913
|
+
return axis.reverse ? 1 - n : n;
|
|
914
|
+
});
|
|
216
915
|
} };
|
|
916
|
+
const drag = createDrag(element, {
|
|
917
|
+
threshold: opts.threshold,
|
|
918
|
+
cursor: opts.cursor,
|
|
919
|
+
pointerLock: opts.pointerLock,
|
|
920
|
+
shouldStart: (event) => opts.shouldStart?.(event) ?? true,
|
|
921
|
+
onDragStart: (state) => {
|
|
922
|
+
const position = opts.mapping.start(state, context);
|
|
923
|
+
if (!position) return;
|
|
924
|
+
active = true;
|
|
925
|
+
lastValue = valueOf(position);
|
|
926
|
+
if (opts.updateOnPointerDown) opts.onChange?.(lastValue, state);
|
|
927
|
+
opts.onDragStart?.(lastValue, state);
|
|
928
|
+
},
|
|
929
|
+
onDrag: (state) => {
|
|
930
|
+
if (!active) return;
|
|
931
|
+
const position = opts.mapping.move(state, context);
|
|
932
|
+
if (!position) return;
|
|
933
|
+
lastValue = valueOf(position);
|
|
934
|
+
opts.onChange?.(lastValue, state);
|
|
935
|
+
},
|
|
936
|
+
onDragEnd: (state) => {
|
|
937
|
+
if (!active) return;
|
|
938
|
+
if (state.event.type === "pointerup" && (state.deltaX !== 0 || state.deltaY !== 0)) {
|
|
939
|
+
const position = opts.mapping.move(state, context);
|
|
940
|
+
if (position) {
|
|
941
|
+
lastValue = valueOf(position);
|
|
942
|
+
opts.onChange?.(lastValue, state);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
active = false;
|
|
946
|
+
opts.onDragEnd?.(lastValue, state);
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
return {
|
|
950
|
+
update: (next) => {
|
|
951
|
+
opts = {
|
|
952
|
+
...opts,
|
|
953
|
+
...next,
|
|
954
|
+
mapping: opts.mapping
|
|
955
|
+
};
|
|
956
|
+
drag.update({
|
|
957
|
+
threshold: opts.threshold,
|
|
958
|
+
cursor: opts.cursor,
|
|
959
|
+
pointerLock: opts.pointerLock
|
|
960
|
+
});
|
|
961
|
+
},
|
|
962
|
+
destroy: () => drag.destroy()
|
|
963
|
+
};
|
|
217
964
|
}
|
|
218
965
|
//#endregion
|
|
219
966
|
//#region src/pointer/wheel.ts
|
|
@@ -223,14 +970,100 @@ function createDrag(element, { threshold: _threshold = 1, cursor, onDragStart, o
|
|
|
223
970
|
* The listener is registered with `passive: false` so that the handler can call
|
|
224
971
|
* `preventDefault()` to stop the page from scrolling.
|
|
225
972
|
*/
|
|
226
|
-
function createWheel(element, onWheel) {
|
|
227
|
-
|
|
973
|
+
function createWheel(element, onWheel, options = {}) {
|
|
974
|
+
let opts = {
|
|
975
|
+
...options,
|
|
976
|
+
onWheel
|
|
977
|
+
};
|
|
978
|
+
function hasFocus() {
|
|
979
|
+
const active = element.ownerDocument?.activeElement;
|
|
980
|
+
return !!active && element.contains(active);
|
|
981
|
+
}
|
|
982
|
+
const handler = (event) => {
|
|
983
|
+
if (opts.requireFocus && !hasFocus()) return;
|
|
984
|
+
opts.onWheel?.(event);
|
|
985
|
+
};
|
|
228
986
|
element.addEventListener("wheel", handler, { passive: false });
|
|
229
|
-
return {
|
|
230
|
-
|
|
231
|
-
|
|
987
|
+
return {
|
|
988
|
+
update: (next) => {
|
|
989
|
+
opts = {
|
|
990
|
+
...opts,
|
|
991
|
+
...next
|
|
992
|
+
};
|
|
993
|
+
},
|
|
994
|
+
destroy: () => {
|
|
995
|
+
element.removeEventListener("wheel", handler);
|
|
996
|
+
}
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
//#endregion
|
|
1000
|
+
//#region src/selection/box.ts
|
|
1001
|
+
function rectOf(from, to) {
|
|
1002
|
+
return {
|
|
1003
|
+
x: Math.min(from[0], to[0]),
|
|
1004
|
+
y: Math.min(from[1], to[1]),
|
|
1005
|
+
width: Math.abs(to[0] - from[0]),
|
|
1006
|
+
height: Math.abs(to[1] - from[1])
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
/** Whether the box covers the point, edges included. */
|
|
1010
|
+
function selectionBoxCovers(box, [x, y]) {
|
|
1011
|
+
return x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height;
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Selecting by dragging a box over a set of items.
|
|
1015
|
+
*
|
|
1016
|
+
* The drag itself is not here: the caller owns the pointer, and reports where
|
|
1017
|
+
* it went in the same 0..1 space the items are given in.
|
|
1018
|
+
*/
|
|
1019
|
+
function createSelectionBox(options) {
|
|
1020
|
+
let opts = options;
|
|
1021
|
+
let drag = null;
|
|
1022
|
+
let box = null;
|
|
1023
|
+
function setBox(next) {
|
|
1024
|
+
box = next;
|
|
1025
|
+
opts.onBoxChange?.(next);
|
|
1026
|
+
}
|
|
1027
|
+
function apply(rect, base) {
|
|
1028
|
+
const inside = [];
|
|
1029
|
+
for (const [id, at] of opts.items()) if (selectionBoxCovers(rect, at) && !base.includes(id)) inside.push(id);
|
|
1030
|
+
opts.onSelectionChange?.([...base, ...inside]);
|
|
1031
|
+
}
|
|
1032
|
+
return {
|
|
1033
|
+
update: (next) => {
|
|
1034
|
+
opts = {
|
|
1035
|
+
...opts,
|
|
1036
|
+
...next
|
|
1037
|
+
};
|
|
1038
|
+
},
|
|
1039
|
+
begin: (at, { additive = false, selection = [] } = {}) => {
|
|
1040
|
+
drag = {
|
|
1041
|
+
from: at,
|
|
1042
|
+
base: additive ? [...selection] : []
|
|
1043
|
+
};
|
|
1044
|
+
setBox(rectOf(at, at));
|
|
1045
|
+
if (!additive) opts.onSelectionChange?.([]);
|
|
1046
|
+
},
|
|
1047
|
+
move: (to) => {
|
|
1048
|
+
if (!drag) return;
|
|
1049
|
+
const rect = rectOf(drag.from, to);
|
|
1050
|
+
setBox(rect);
|
|
1051
|
+
apply(rect, drag.base);
|
|
1052
|
+
},
|
|
1053
|
+
end: () => {
|
|
1054
|
+
const running = drag !== null;
|
|
1055
|
+
drag = null;
|
|
1056
|
+
if (box !== null) setBox(null);
|
|
1057
|
+
return running;
|
|
1058
|
+
},
|
|
1059
|
+
box: () => box,
|
|
1060
|
+
destroy: () => {
|
|
1061
|
+
drag = null;
|
|
1062
|
+
box = null;
|
|
1063
|
+
}
|
|
1064
|
+
};
|
|
232
1065
|
}
|
|
233
1066
|
//#endregion
|
|
234
|
-
export { NOT_SUPPORTED, PERMISSION_DENIED, createDrag, createMIDIAccess, createMIDIInput, createMIDIMessage, createWheel };
|
|
1067
|
+
export { NOT_SUPPORTED, PERMISSION_DENIED, PITCH_BEND_CENTER, UNAVAILABLE, createAnimationCanvas, createDrag, createDragValue, createMIDIAccess, createMIDIInput, createMIDIMessage, createPianoInput, createSelectionBox, createWheel, drawingState, elementMapping, isDrawingState, relativeMapping, selectionBoxCovers, toXY };
|
|
235
1068
|
|
|
236
1069
|
//# sourceMappingURL=index.js.map
|