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