@tremolo-ui/dom 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +842 -101
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +490 -47
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +490 -47
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +829 -102
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/src/canvas/animation.ts +49 -31
- package/src/canvas/context.ts +32 -5
- package/src/file/accept.ts +58 -0
- package/src/file/drop-zone.ts +202 -0
- package/src/index.ts +39 -2
- package/src/input/apply-delta.ts +73 -0
- package/src/input/modifiers.ts +121 -0
- package/src/midi/access.ts +83 -13
- package/src/midi/input.ts +97 -26
- package/src/midi/message.ts +37 -8
- package/src/piano/{input.ts → index.ts} +25 -5
- package/src/piano/layout.ts +183 -0
- package/src/pointer/{dragValue.ts → drag-value.ts} +155 -19
- package/src/pointer/drag.ts +215 -20
- package/src/pointer/wheel.ts +4 -2
- package/src/selection/box.ts +136 -0
package/dist/index.cjs
CHANGED
|
@@ -2,9 +2,10 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
|
2
2
|
let _tremolo_ui_functions = require("@tremolo-ui/functions");
|
|
3
3
|
//#region src/canvas/context.ts
|
|
4
4
|
/**
|
|
5
|
-
* The
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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.
|
|
8
9
|
*
|
|
9
10
|
* @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/save
|
|
10
11
|
*/
|
|
@@ -22,24 +23,37 @@ const drawingState = [
|
|
|
22
23
|
"shadowBlur",
|
|
23
24
|
"shadowColor",
|
|
24
25
|
"globalCompositeOperation",
|
|
26
|
+
"filter",
|
|
25
27
|
"font",
|
|
28
|
+
"fontKerning",
|
|
29
|
+
"fontStretch",
|
|
30
|
+
"fontVariantCaps",
|
|
26
31
|
"textAlign",
|
|
27
32
|
"textBaseline",
|
|
28
33
|
"direction",
|
|
29
|
-
"
|
|
34
|
+
"letterSpacing",
|
|
35
|
+
"textRendering",
|
|
36
|
+
"wordSpacing",
|
|
37
|
+
"imageSmoothingEnabled",
|
|
38
|
+
"imageSmoothingQuality"
|
|
30
39
|
];
|
|
31
40
|
function isDrawingState(value) {
|
|
32
|
-
return typeof value
|
|
41
|
+
return typeof value === "string" && drawingState.includes(value);
|
|
33
42
|
}
|
|
34
43
|
/** Copy the drawing state off a context, to put back after a resize. */
|
|
35
44
|
function readDrawingState(context) {
|
|
36
45
|
const state = {};
|
|
37
46
|
for (const property of drawingState) state[property] = context[property];
|
|
47
|
+
state.lineDash = context.getLineDash();
|
|
48
|
+
state.transform = context.getTransform();
|
|
38
49
|
return state;
|
|
39
50
|
}
|
|
40
51
|
/** Put a state read by {@link readDrawingState} back onto a context. */
|
|
41
|
-
function writeDrawingState(context, state) {
|
|
52
|
+
function writeDrawingState(context, state, transformScale = 1) {
|
|
42
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);
|
|
43
57
|
}
|
|
44
58
|
/**
|
|
45
59
|
* Give the canvas a backing store of `width * dpr` by `height * dpr` device
|
|
@@ -72,12 +86,13 @@ function createAnimationCanvas(canvas, options) {
|
|
|
72
86
|
const context2d = canvas.getContext("2d", opts.contextAttributes);
|
|
73
87
|
if (!context2d) throw new Error("createAnimationCanvas: cannot get a 2d context");
|
|
74
88
|
const context = context2d;
|
|
75
|
-
const
|
|
89
|
+
const resizable = opts.resizable ?? false;
|
|
76
90
|
/** Off-document canvas holding the drawing while the real one is resized. */
|
|
77
91
|
let memo = null;
|
|
78
92
|
let memoContext = null;
|
|
79
93
|
let width = 0;
|
|
80
94
|
let height = 0;
|
|
95
|
+
let appliedDpr = 0;
|
|
81
96
|
/** Whether a size has been applied, so a frame can be drawn. */
|
|
82
97
|
let sized = false;
|
|
83
98
|
let initialized = false;
|
|
@@ -95,17 +110,16 @@ function createAnimationCanvas(canvas, options) {
|
|
|
95
110
|
* the copy neither scales nor resamples.
|
|
96
111
|
*/
|
|
97
112
|
function takeSnapshot() {
|
|
98
|
-
if (!(opts.reduceFlickering ?? true)) return
|
|
113
|
+
if (!(opts.reduceFlickering ?? true)) return false;
|
|
99
114
|
if (!memo) {
|
|
100
115
|
memo = globalThis.document?.createElement("canvas") ?? null;
|
|
101
116
|
memoContext = memo?.getContext("2d", opts.contextAttributes) ?? null;
|
|
102
117
|
}
|
|
103
|
-
if (!memo || !memoContext) return
|
|
104
|
-
const state = readDrawingState(context);
|
|
118
|
+
if (!memo || !memoContext) return false;
|
|
105
119
|
memo.width = canvas.width;
|
|
106
120
|
memo.height = canvas.height;
|
|
107
121
|
if (canvas.width > 0 && canvas.height > 0) memoContext.drawImage(canvas, 0, 0);
|
|
108
|
-
return
|
|
122
|
+
return true;
|
|
109
123
|
}
|
|
110
124
|
/**
|
|
111
125
|
* Put the snapshot back, given the size in CSS pixels it was taken at.
|
|
@@ -115,25 +129,32 @@ function createAnimationCanvas(canvas, options) {
|
|
|
115
129
|
* stays put that is a 1:1 copy of device pixels, and when it changes the
|
|
116
130
|
* snapshot is rescaled once, from its full resolution.
|
|
117
131
|
*/
|
|
118
|
-
function restoreSnapshot(
|
|
119
|
-
if (!
|
|
132
|
+
function restoreSnapshot(hasSnapshot, previousWidth, previousHeight) {
|
|
133
|
+
if (!hasSnapshot || !memo) return;
|
|
120
134
|
if (memo.width <= 0 || memo.height <= 0) return;
|
|
121
|
-
writeDrawingState(context, state);
|
|
122
135
|
context.drawImage(memo, 0, 0, previousWidth, previousHeight);
|
|
123
136
|
}
|
|
124
137
|
function applySize(w, h) {
|
|
125
138
|
const dpr = devicePixelRatio();
|
|
126
139
|
const previousWidth = width;
|
|
127
140
|
const previousHeight = height;
|
|
128
|
-
const
|
|
141
|
+
const previousDpr = appliedDpr;
|
|
142
|
+
const state = sized ? readDrawingState(context) : null;
|
|
143
|
+
const hasSnapshot = sized && takeSnapshot();
|
|
129
144
|
applyDevicePixelRatio(canvas, context, w, h, dpr);
|
|
130
145
|
width = w;
|
|
131
146
|
height = h;
|
|
147
|
+
appliedDpr = dpr;
|
|
132
148
|
sized = true;
|
|
133
|
-
restoreSnapshot(
|
|
149
|
+
restoreSnapshot(hasSnapshot, previousWidth, previousHeight);
|
|
150
|
+
if (state) writeDrawingState(context, state, dpr / previousDpr);
|
|
134
151
|
}
|
|
135
|
-
function
|
|
152
|
+
function applyDevicePixelRatioIfNeeded() {
|
|
153
|
+
if (sized && devicePixelRatio() !== appliedDpr) applySize(width, height);
|
|
154
|
+
}
|
|
155
|
+
function drawFrame(timestamp = performance.now()) {
|
|
136
156
|
if (!sized) return;
|
|
157
|
+
applyDevicePixelRatioIfNeeded();
|
|
137
158
|
if (!initialized) {
|
|
138
159
|
initialized = true;
|
|
139
160
|
opts.init?.(context, {
|
|
@@ -141,41 +162,40 @@ function createAnimationCanvas(canvas, options) {
|
|
|
141
162
|
height
|
|
142
163
|
});
|
|
143
164
|
}
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
previousTime = now;
|
|
165
|
+
const deltaTime = timestamp - previousTime;
|
|
166
|
+
previousTime = timestamp;
|
|
147
167
|
count += 1;
|
|
148
168
|
opts.draw(context, {
|
|
149
169
|
width,
|
|
150
170
|
height,
|
|
151
171
|
count,
|
|
152
172
|
deltaTime,
|
|
153
|
-
elapsedTime:
|
|
154
|
-
fps: 1e3 / deltaTime
|
|
173
|
+
elapsedTime: timestamp - startTime,
|
|
174
|
+
fps: deltaTime > 0 ? 1e3 / deltaTime : 0
|
|
155
175
|
});
|
|
156
176
|
}
|
|
157
|
-
function tick() {
|
|
177
|
+
function tick(timestamp) {
|
|
158
178
|
frameId = requestAnimationFrame(tick);
|
|
159
|
-
drawFrame();
|
|
179
|
+
drawFrame(timestamp);
|
|
160
180
|
}
|
|
161
181
|
function startLoop() {
|
|
162
|
-
if (frameId
|
|
182
|
+
if (frameId !== null) return;
|
|
163
183
|
previousTime = performance.now();
|
|
164
184
|
frameId = requestAnimationFrame(tick);
|
|
165
185
|
}
|
|
166
186
|
function stopLoop() {
|
|
167
|
-
if (frameId
|
|
187
|
+
if (frameId === null) return;
|
|
168
188
|
cancelAnimationFrame(frameId);
|
|
169
189
|
frameId = null;
|
|
170
190
|
}
|
|
171
191
|
let observer = null;
|
|
172
|
-
if (
|
|
192
|
+
if (resizable) {
|
|
173
193
|
const parent = canvas.parentElement;
|
|
174
|
-
if (!parent) throw new Error("createAnimationCanvas:
|
|
194
|
+
if (!parent) throw new Error("createAnimationCanvas: resizable needs the canvas to have a parent element");
|
|
175
195
|
observer = new ResizeObserver((entries) => {
|
|
176
196
|
for (const entry of entries) {
|
|
177
197
|
const { width: w, height: h } = entry.contentRect;
|
|
178
|
-
applySize(w, h);
|
|
198
|
+
if (w !== width || h !== height || devicePixelRatio() !== appliedDpr) applySize(w, h);
|
|
179
199
|
if (!(opts.animate ?? true)) drawFrame();
|
|
180
200
|
}
|
|
181
201
|
});
|
|
@@ -192,13 +212,14 @@ function createAnimationCanvas(canvas, options) {
|
|
|
192
212
|
opts = {
|
|
193
213
|
...opts,
|
|
194
214
|
...next,
|
|
195
|
-
|
|
215
|
+
resizable,
|
|
196
216
|
contextAttributes: opts.contextAttributes
|
|
197
217
|
};
|
|
198
|
-
if (!
|
|
218
|
+
if (!resizable) {
|
|
199
219
|
const { width: w = 100, height: h = 100 } = opts.size ?? {};
|
|
200
|
-
if (w
|
|
220
|
+
if (w !== width || h !== height) applySize(w, h);
|
|
201
221
|
}
|
|
222
|
+
applyDevicePixelRatioIfNeeded();
|
|
202
223
|
const animating = opts.animate ?? true;
|
|
203
224
|
if (animating && !wasAnimating) startLoop();
|
|
204
225
|
if (!animating && wasAnimating) stopLoop();
|
|
@@ -215,16 +236,296 @@ function createAnimationCanvas(canvas, options) {
|
|
|
215
236
|
};
|
|
216
237
|
}
|
|
217
238
|
//#endregion
|
|
239
|
+
//#region src/file/accept.ts
|
|
240
|
+
/**
|
|
241
|
+
* Does a file satisfy an `accept` attribute?
|
|
242
|
+
*
|
|
243
|
+
* `accept` is written the way the HTML attribute is: a comma separated list of
|
|
244
|
+
* extensions (`.wav`), MIME types (`audio/wav`) and type groups (`audio/*`).
|
|
245
|
+
* Anything that matches one entry is accepted, and an empty or missing
|
|
246
|
+
* `accept` takes everything.
|
|
247
|
+
*
|
|
248
|
+
* **The browser's own `accept` is only a hint to the file picker.** A person
|
|
249
|
+
* can switch it to "All Files", drag a file in, or pick one the picker was
|
|
250
|
+
* never asked about, so what arrives still has to be checked.
|
|
251
|
+
*
|
|
252
|
+
* A rule that cannot be decided is not treated as a rejection: with no `name`,
|
|
253
|
+
* an extension rule says nothing either way, and `accept=".wav"` reports a
|
|
254
|
+
* match rather than refusing a file it has not seen the name of. The name is
|
|
255
|
+
* there by the time the file is dropped, which is when the answer counts.
|
|
256
|
+
*/
|
|
257
|
+
function matchesAccept(candidate, accept) {
|
|
258
|
+
const rules = (accept ?? "").split(",").map((rule) => rule.trim().toLowerCase()).filter(Boolean);
|
|
259
|
+
if (rules.length === 0) return true;
|
|
260
|
+
const name = candidate.name?.toLowerCase();
|
|
261
|
+
const type = candidate.type.toLowerCase();
|
|
262
|
+
let undecided = false;
|
|
263
|
+
for (const rule of rules) if (rule.startsWith(".")) {
|
|
264
|
+
if (name === void 0) undecided = true;
|
|
265
|
+
else if (name.endsWith(rule)) return true;
|
|
266
|
+
} else if (rule.endsWith("/*")) {
|
|
267
|
+
if (type.startsWith(rule.slice(0, -1))) return true;
|
|
268
|
+
} else if (type === rule) return true;
|
|
269
|
+
return undecided;
|
|
270
|
+
}
|
|
271
|
+
//#endregion
|
|
272
|
+
//#region src/file/drop-zone.ts
|
|
273
|
+
/**
|
|
274
|
+
* Take files dropped onto an element.
|
|
275
|
+
*
|
|
276
|
+
* ```ts
|
|
277
|
+
* const zone = createDropZone(element, {
|
|
278
|
+
* accept: 'audio/*',
|
|
279
|
+
* onDrop: (files) => load(files[0]),
|
|
280
|
+
* })
|
|
281
|
+
* ```
|
|
282
|
+
*
|
|
283
|
+
* The element needs no attribute of its own: a drop target is made by
|
|
284
|
+
* cancelling `dragover`, which this does.
|
|
285
|
+
*/
|
|
286
|
+
function createDropZone(element, options = {}) {
|
|
287
|
+
let opts = options;
|
|
288
|
+
let state = {
|
|
289
|
+
over: false,
|
|
290
|
+
invalid: false
|
|
291
|
+
};
|
|
292
|
+
/**
|
|
293
|
+
* `dragenter` and `dragleave` fire for descendants too, so moving between
|
|
294
|
+
* two children of the zone leaves before it enters. Counting the pairs is
|
|
295
|
+
* what keeps the state from flickering off in the middle of the element.
|
|
296
|
+
*/
|
|
297
|
+
let depth = 0;
|
|
298
|
+
function setState(next) {
|
|
299
|
+
if (next.over === state.over && next.invalid === state.invalid) return;
|
|
300
|
+
state = next;
|
|
301
|
+
opts.onStateChange?.(state);
|
|
302
|
+
}
|
|
303
|
+
function carriesFiles(transfer) {
|
|
304
|
+
return !!transfer?.types.includes("Files");
|
|
305
|
+
}
|
|
306
|
+
/** Whether anything being dragged could still be accepted. */
|
|
307
|
+
function anyAcceptable(transfer) {
|
|
308
|
+
const items = Array.from(transfer?.items ?? []).filter((item) => item.kind === "file");
|
|
309
|
+
if (items.length === 0) return true;
|
|
310
|
+
return items.some((item) => matchesAccept({ type: item.type }, opts.accept));
|
|
311
|
+
}
|
|
312
|
+
function onDragEnter(event) {
|
|
313
|
+
if (!carriesFiles(event.dataTransfer)) return;
|
|
314
|
+
event.preventDefault();
|
|
315
|
+
depth += 1;
|
|
316
|
+
setState({
|
|
317
|
+
over: true,
|
|
318
|
+
invalid: !anyAcceptable(event.dataTransfer)
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
function onDragOver(event) {
|
|
322
|
+
if (!carriesFiles(event.dataTransfer)) return;
|
|
323
|
+
event.preventDefault();
|
|
324
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = opts.disabled || state.invalid ? "none" : "copy";
|
|
325
|
+
if (!state.over) {
|
|
326
|
+
depth = Math.max(depth, 1);
|
|
327
|
+
setState({
|
|
328
|
+
over: true,
|
|
329
|
+
invalid: !anyAcceptable(event.dataTransfer)
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function onDragLeave(event) {
|
|
334
|
+
if (!carriesFiles(event.dataTransfer)) return;
|
|
335
|
+
depth = Math.max(0, depth - 1);
|
|
336
|
+
if (depth === 0) setState({
|
|
337
|
+
over: false,
|
|
338
|
+
invalid: false
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
function onDrop(event) {
|
|
342
|
+
if (!carriesFiles(event.dataTransfer)) return;
|
|
343
|
+
event.preventDefault();
|
|
344
|
+
depth = 0;
|
|
345
|
+
setState({
|
|
346
|
+
over: false,
|
|
347
|
+
invalid: false
|
|
348
|
+
});
|
|
349
|
+
if (opts.disabled) return;
|
|
350
|
+
const dropped = Array.from(event.dataTransfer?.files ?? []);
|
|
351
|
+
const accepted = [];
|
|
352
|
+
const rejected = [];
|
|
353
|
+
for (const file of dropped) if (matchesAccept(file, opts.accept)) accepted.push(file);
|
|
354
|
+
else rejected.push(file);
|
|
355
|
+
if (rejected.length > 0) opts.onReject?.(rejected, event);
|
|
356
|
+
const taken = opts.multiple ? accepted : accepted.slice(0, 1);
|
|
357
|
+
if (taken.length > 0) opts.onDrop?.(taken, event);
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* A drag that ends anywhere else — dropped on another element, cancelled
|
|
361
|
+
* with Esc, taken out of the window — sends no `dragleave` here, and the
|
|
362
|
+
* element would stay marked as a drop target for good.
|
|
363
|
+
*/
|
|
364
|
+
function onDragEndAnywhere() {
|
|
365
|
+
depth = 0;
|
|
366
|
+
setState({
|
|
367
|
+
over: false,
|
|
368
|
+
invalid: false
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
const handlers = {
|
|
372
|
+
dragenter: onDragEnter,
|
|
373
|
+
dragover: onDragOver,
|
|
374
|
+
dragleave: onDragLeave,
|
|
375
|
+
drop: onDrop
|
|
376
|
+
};
|
|
377
|
+
for (const [type, handler] of Object.entries(handlers)) element.addEventListener(type, handler);
|
|
378
|
+
const doc = element.ownerDocument;
|
|
379
|
+
doc?.addEventListener("dragend", onDragEndAnywhere);
|
|
380
|
+
doc?.addEventListener("drop", onDragEndAnywhere);
|
|
381
|
+
return {
|
|
382
|
+
get state() {
|
|
383
|
+
return state;
|
|
384
|
+
},
|
|
385
|
+
update: (next) => {
|
|
386
|
+
opts = {
|
|
387
|
+
...opts,
|
|
388
|
+
...next
|
|
389
|
+
};
|
|
390
|
+
},
|
|
391
|
+
destroy: () => {
|
|
392
|
+
for (const [type, handler] of Object.entries(handlers)) element.removeEventListener(type, handler);
|
|
393
|
+
doc?.removeEventListener("dragend", onDragEndAnywhere);
|
|
394
|
+
doc?.removeEventListener("drop", onDragEndAnywhere);
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
//#endregion
|
|
399
|
+
//#region src/input/modifiers.ts
|
|
400
|
+
/**
|
|
401
|
+
* Checked in this order, and the first one that is both held and configured
|
|
402
|
+
* wins. Fixing an order is what keeps two modifiers held at once from
|
|
403
|
+
* behaving differently between browsers.
|
|
404
|
+
*/
|
|
405
|
+
const MODIFIER_ORDER = [
|
|
406
|
+
"meta",
|
|
407
|
+
"ctrl",
|
|
408
|
+
"alt",
|
|
409
|
+
"shift"
|
|
410
|
+
];
|
|
411
|
+
const MODIFIER_FLAG = {
|
|
412
|
+
meta: "metaKey",
|
|
413
|
+
ctrl: "ctrlKey",
|
|
414
|
+
alt: "altKey",
|
|
415
|
+
shift: "shiftKey"
|
|
416
|
+
};
|
|
417
|
+
/**
|
|
418
|
+
* A map is the only form with a `default` key, which is what tells it apart
|
|
419
|
+
* from a bare setting. Tuples are arrays, so they never match.
|
|
420
|
+
*/
|
|
421
|
+
function isModifierMap(value) {
|
|
422
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && "default" in value;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Pick the setting that applies, given the modifier keys being held.
|
|
426
|
+
*
|
|
427
|
+
* @example
|
|
428
|
+
* selectModifier({ default: 1, shift: 0.1 }, event)
|
|
429
|
+
*/
|
|
430
|
+
function selectModifier(options, modifiers) {
|
|
431
|
+
if (!isModifierMap(options)) return {
|
|
432
|
+
value: options,
|
|
433
|
+
modifier: null
|
|
434
|
+
};
|
|
435
|
+
if (modifiers) for (const modifier of MODIFIER_ORDER) {
|
|
436
|
+
const value = options[modifier];
|
|
437
|
+
if (value !== void 0 && modifiers[MODIFIER_FLAG[modifier]]) return {
|
|
438
|
+
value,
|
|
439
|
+
modifier
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
value: options.default,
|
|
444
|
+
modifier: null
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Turn every entry of a setting into another kind of setting, keeping which
|
|
449
|
+
* modifier each belongs to.
|
|
450
|
+
*
|
|
451
|
+
* A drag sensitivity is a number and a keyboard amount is a tuple, but the two
|
|
452
|
+
* describe the same thing from the caller's side. This carries one over to the
|
|
453
|
+
* other so that a component can hand a sensitivity to {@link applyDelta}
|
|
454
|
+
* without unpicking the modifier map itself — which matters, since naming a
|
|
455
|
+
* modifier is also what takes `step` out of the pipeline.
|
|
456
|
+
*
|
|
457
|
+
* @example
|
|
458
|
+
* mapModifier({ default: 1, shift: 0.1 }, (f) => ['raw', step * f])
|
|
459
|
+
* // { default: ['raw', 1], shift: ['raw', 0.1] }
|
|
460
|
+
*/
|
|
461
|
+
function mapModifier(options, fn) {
|
|
462
|
+
if (!isModifierMap(options)) return fn(options);
|
|
463
|
+
const mapped = { default: fn(options.default) };
|
|
464
|
+
for (const modifier of MODIFIER_ORDER) {
|
|
465
|
+
const value = options[modifier];
|
|
466
|
+
if (value !== void 0) mapped[modifier] = fn(value);
|
|
467
|
+
}
|
|
468
|
+
return mapped;
|
|
469
|
+
}
|
|
470
|
+
//#endregion
|
|
471
|
+
//#region src/input/apply-delta.ts
|
|
472
|
+
/**
|
|
473
|
+
* Move a value by an amount of input, as reported by a wheel or an arrow key.
|
|
474
|
+
*
|
|
475
|
+
* The pipeline matches {@link createDragValue}: scale, then step, then clamp.
|
|
476
|
+
* Which key or which sign of `deltaY` counts as which direction is left to the
|
|
477
|
+
* caller, since it differs per component.
|
|
478
|
+
*
|
|
479
|
+
* @param direction which way, and how many times, to apply the option. The
|
|
480
|
+
* size of one step is `option[1]`, so this is normally `1` or `-1`.
|
|
481
|
+
*
|
|
482
|
+
* @param modifiers the event, for `options` that name a modifier key. See
|
|
483
|
+
* {@link selectModifier}.
|
|
484
|
+
*
|
|
485
|
+
* @example
|
|
486
|
+
* // ArrowDown on a slider whose keyboard option is ['raw', 1]
|
|
487
|
+
* applyDelta(value, -1, keyboard, { min, max, step, scale })
|
|
488
|
+
*
|
|
489
|
+
* @example
|
|
490
|
+
* // Shift+ArrowDown, where `keyboard` is { default: …, shift: ['raw', 0.1] }
|
|
491
|
+
* applyDelta(value, -1, keyboard, range, event)
|
|
492
|
+
*/
|
|
493
|
+
function applyDelta(value, direction, options, { min, max, step, scale = _tremolo_ui_functions.linearScale }, modifiers) {
|
|
494
|
+
if (min >= max) throw new RangeError("requirements: min < max");
|
|
495
|
+
if (step !== void 0 && (!Number.isFinite(step) || step <= 0)) throw new RangeError("applyDelta step: requirements: finite and greater than 0");
|
|
496
|
+
const { value: [mode, amount], modifier } = selectModifier(options, modifiers);
|
|
497
|
+
const x = direction * amount;
|
|
498
|
+
const next = mode === "normalized" ? scale.denormalize(scale.normalize(value, min, max) + x, min, max) : value + x;
|
|
499
|
+
const quantum = modifier === null ? step : void 0;
|
|
500
|
+
const stepped = quantum !== void 0 ? (0, _tremolo_ui_functions.stepValue)(next, quantum) : next;
|
|
501
|
+
return (0, _tremolo_ui_functions.clamp)((0, _tremolo_ui_functions.toPrecision)(stepped), min, max);
|
|
502
|
+
}
|
|
503
|
+
//#endregion
|
|
218
504
|
//#region src/midi/access.ts
|
|
219
505
|
/** @private */
|
|
220
506
|
const PERMISSION_DENIED = "PERMISSION_DENIED";
|
|
221
507
|
/** @private */
|
|
222
508
|
const NOT_SUPPORTED = "NOT_SUPPORTED";
|
|
509
|
+
/** @private */
|
|
510
|
+
const UNAVAILABLE = "UNAVAILABLE";
|
|
223
511
|
const INITIAL_STATE = {
|
|
224
512
|
midiAccess: null,
|
|
225
|
-
error: null
|
|
513
|
+
error: null,
|
|
514
|
+
inputs: []
|
|
226
515
|
};
|
|
227
516
|
/**
|
|
517
|
+
* Which of our errors a rejected `requestMIDIAccess()` amounts to.
|
|
518
|
+
*
|
|
519
|
+
* The spec names the reasons, and they need telling apart: a user who said no
|
|
520
|
+
* can say yes on a second ask, while a browser without the API never will.
|
|
521
|
+
*/
|
|
522
|
+
function toError(reason) {
|
|
523
|
+
const name = typeof reason === "object" && reason !== null && "name" in reason ? String(reason.name) : "";
|
|
524
|
+
if (name === "SecurityError" || name === "NotAllowedError") return PERMISSION_DENIED;
|
|
525
|
+
if (name === "NotSupportedError" || name === "TypeError") return NOT_SUPPORTED;
|
|
526
|
+
return UNAVAILABLE;
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
228
529
|
* Request MIDI access in the browser.
|
|
229
530
|
*
|
|
230
531
|
* The returned instance holds the state and notifies subscribers when it changes,
|
|
@@ -233,13 +534,31 @@ const INITIAL_STATE = {
|
|
|
233
534
|
function createMIDIAccess() {
|
|
234
535
|
let state = INITIAL_STATE;
|
|
235
536
|
let destroyed = false;
|
|
537
|
+
let access = null;
|
|
538
|
+
let requestGeneration = 0;
|
|
236
539
|
const listeners = /* @__PURE__ */ new Set();
|
|
237
540
|
function setState(next) {
|
|
238
541
|
state = next;
|
|
239
542
|
for (const listener of listeners) listener();
|
|
240
543
|
}
|
|
241
|
-
|
|
544
|
+
/**
|
|
545
|
+
* A new array every time, so that `useSyncExternalStore` and its equivalents
|
|
546
|
+
* see the change. The identity of the state object is what they compare.
|
|
547
|
+
*/
|
|
548
|
+
function readInputs() {
|
|
549
|
+
return access ? [...access.inputs.values()] : [];
|
|
550
|
+
}
|
|
551
|
+
function handleStateChange(event) {
|
|
552
|
+
if (destroyed || !access) return;
|
|
553
|
+
if (event && event.port?.type === "output") return;
|
|
554
|
+
setState({
|
|
555
|
+
...state,
|
|
556
|
+
inputs: readInputs()
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
function request(options = {}) {
|
|
242
560
|
if (destroyed) return;
|
|
561
|
+
const generation = ++requestGeneration;
|
|
243
562
|
if (typeof navigator === "undefined" || !navigator.requestMIDIAccess) {
|
|
244
563
|
setState({
|
|
245
564
|
...state,
|
|
@@ -247,17 +566,21 @@ function createMIDIAccess() {
|
|
|
247
566
|
});
|
|
248
567
|
return;
|
|
249
568
|
}
|
|
250
|
-
navigator.requestMIDIAccess().then((
|
|
251
|
-
if (destroyed) return;
|
|
569
|
+
navigator.requestMIDIAccess({ sysex: options.sysex ?? false }).then((granted) => {
|
|
570
|
+
if (destroyed || generation !== requestGeneration) return;
|
|
571
|
+
access?.removeEventListener("statechange", handleStateChange);
|
|
572
|
+
access = granted;
|
|
573
|
+
granted.addEventListener("statechange", handleStateChange);
|
|
252
574
|
setState({
|
|
253
|
-
midiAccess:
|
|
254
|
-
error: null
|
|
575
|
+
midiAccess: granted,
|
|
576
|
+
error: null,
|
|
577
|
+
inputs: readInputs()
|
|
255
578
|
});
|
|
256
|
-
}
|
|
257
|
-
if (destroyed) return;
|
|
579
|
+
}, (reason) => {
|
|
580
|
+
if (destroyed || generation !== requestGeneration) return;
|
|
258
581
|
setState({
|
|
259
582
|
...state,
|
|
260
|
-
error:
|
|
583
|
+
error: toError(reason)
|
|
261
584
|
});
|
|
262
585
|
});
|
|
263
586
|
}
|
|
@@ -273,6 +596,9 @@ function createMIDIAccess() {
|
|
|
273
596
|
},
|
|
274
597
|
destroy: () => {
|
|
275
598
|
destroyed = true;
|
|
599
|
+
requestGeneration += 1;
|
|
600
|
+
access?.removeEventListener("statechange", handleStateChange);
|
|
601
|
+
access = null;
|
|
276
602
|
listeners.clear();
|
|
277
603
|
}
|
|
278
604
|
};
|
|
@@ -282,35 +608,217 @@ function createMIDIAccess() {
|
|
|
282
608
|
/**
|
|
283
609
|
* Listen to raw `midimessage` events on every input of a MIDIAccess.
|
|
284
610
|
*
|
|
611
|
+
* The set of inputs is followed rather than sampled: MIDIAccess fires
|
|
612
|
+
* `statechange` when a device is plugged in or unplugged, and the listeners
|
|
613
|
+
* move with it. A keyboard connected after access was granted works without
|
|
614
|
+
* the caller having to rebuild anything.
|
|
615
|
+
*
|
|
285
616
|
* Use this when you need more detail than {@link createMIDIInput} provides.
|
|
286
617
|
*/
|
|
287
618
|
function createMIDIMessage(midiAccess, onMIDIMessage) {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
619
|
+
let handler = onMIDIMessage;
|
|
620
|
+
/** Inputs this instance currently listens on. */
|
|
621
|
+
const attached = /* @__PURE__ */ new Set();
|
|
622
|
+
const listener = (event) => handler(event);
|
|
623
|
+
function sync() {
|
|
624
|
+
if (!midiAccess) return;
|
|
625
|
+
const connected = new Set(midiAccess.inputs.values());
|
|
626
|
+
for (const input of connected) {
|
|
627
|
+
if (attached.has(input)) continue;
|
|
628
|
+
input.addEventListener("midimessage", listener);
|
|
629
|
+
attached.add(input);
|
|
630
|
+
}
|
|
631
|
+
for (const input of [...attached]) {
|
|
632
|
+
if (connected.has(input)) continue;
|
|
633
|
+
input.removeEventListener("midimessage", listener);
|
|
634
|
+
attached.delete(input);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
sync();
|
|
638
|
+
midiAccess?.addEventListener("statechange", sync);
|
|
639
|
+
return {
|
|
640
|
+
update: (next) => {
|
|
641
|
+
handler = next;
|
|
642
|
+
},
|
|
643
|
+
destroy: () => {
|
|
644
|
+
midiAccess?.removeEventListener("statechange", sync);
|
|
645
|
+
for (const input of attached) input.removeEventListener("midimessage", listener);
|
|
646
|
+
attached.clear();
|
|
647
|
+
}
|
|
648
|
+
};
|
|
294
649
|
}
|
|
295
650
|
//#endregion
|
|
296
651
|
//#region src/midi/input.ts
|
|
297
|
-
|
|
298
|
-
|
|
652
|
+
/** Status byte of each channel message, with the channel nibble cleared. */
|
|
653
|
+
const STATUS = {
|
|
299
654
|
NOTE_OFF: 128,
|
|
655
|
+
NOTE_ON: 144,
|
|
656
|
+
AFTERTOUCH: 160,
|
|
657
|
+
CONTROL_CHANGE: 176,
|
|
658
|
+
PROGRAM_CHANGE: 192,
|
|
659
|
+
CHANNEL_PRESSURE: 208,
|
|
300
660
|
PITCH_BEND: 224
|
|
301
661
|
};
|
|
302
662
|
/**
|
|
303
|
-
*
|
|
304
|
-
*
|
|
663
|
+
* Centre of the 14-bit pitch bend range: no bend.
|
|
664
|
+
*
|
|
665
|
+
* The range is not symmetric — 0 is 8192 below centre and 16383 is 8191 above
|
|
666
|
+
* — so a wheel at rest reports exactly this rather than half of the maximum.
|
|
305
667
|
*/
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
668
|
+
const PITCH_BEND_CENTER = 8192;
|
|
669
|
+
/**
|
|
670
|
+
* Handle the channel voice messages of every connected input. To be used with
|
|
671
|
+
* {@link createMIDIAccess}. Internally uses {@link createMIDIMessage}, so
|
|
672
|
+
* devices plugged in later are picked up.
|
|
673
|
+
*
|
|
674
|
+
* System messages (clock, sysex, and the rest of `0xf0`-`0xff`) are not
|
|
675
|
+
* decoded here; reach for {@link createMIDIMessage} for those.
|
|
676
|
+
*/
|
|
677
|
+
function createMIDIInput(midiAccess, handlers) {
|
|
678
|
+
let current = handlers;
|
|
679
|
+
return {
|
|
680
|
+
update: (next) => {
|
|
681
|
+
current = next;
|
|
682
|
+
},
|
|
683
|
+
destroy: createMIDIMessage(midiAccess, (event) => {
|
|
684
|
+
if (!event.data || event.data.length === 0) return;
|
|
685
|
+
const status = event.data[0];
|
|
686
|
+
if (status >= 240) return;
|
|
687
|
+
const kind = status & 240;
|
|
688
|
+
const channel = status & 15;
|
|
689
|
+
const first = event.data[1] ?? 0;
|
|
690
|
+
const second = event.data[2] ?? 0;
|
|
691
|
+
switch (kind) {
|
|
692
|
+
case STATUS.NOTE_OFF:
|
|
693
|
+
current.onNoteOffEvent?.(first, channel);
|
|
694
|
+
break;
|
|
695
|
+
case STATUS.NOTE_ON:
|
|
696
|
+
if (second === 0) current.onNoteOffEvent?.(first, channel);
|
|
697
|
+
else current.onNoteOnEvent?.(first, second, channel);
|
|
698
|
+
break;
|
|
699
|
+
case STATUS.AFTERTOUCH:
|
|
700
|
+
current.onAftertouchEvent?.(first, second, channel);
|
|
701
|
+
break;
|
|
702
|
+
case STATUS.CONTROL_CHANGE:
|
|
703
|
+
current.onControlChangeEvent?.(first, second, channel);
|
|
704
|
+
break;
|
|
705
|
+
case STATUS.PROGRAM_CHANGE:
|
|
706
|
+
current.onProgramChangeEvent?.(first, channel);
|
|
707
|
+
break;
|
|
708
|
+
case STATUS.CHANNEL_PRESSURE:
|
|
709
|
+
current.onChannelPressureEvent?.(first, channel);
|
|
710
|
+
break;
|
|
711
|
+
case STATUS.PITCH_BEND: current.onPitchBendEvent?.(second << 7 | first, channel);
|
|
712
|
+
}
|
|
713
|
+
}).destroy
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
//#endregion
|
|
717
|
+
//#region src/piano/layout.ts
|
|
718
|
+
/**
|
|
719
|
+
* `[noteRange.first, noteRange.first + 1, ..., noteRange.last]`
|
|
720
|
+
*/
|
|
721
|
+
function getNoteRangeArray(noteRange) {
|
|
722
|
+
return Array.from({ length: noteRange.last - noteRange.first + 1 }, (_, i) => i + noteRange.first);
|
|
723
|
+
}
|
|
724
|
+
const DEFAULT_KEY_GAP = 1;
|
|
725
|
+
const DEFAULT_BLACK_KEY_WIDTH_RATIO = .65;
|
|
726
|
+
const DEFAULT_BLACK_KEY_HEIGHT_RATIO = .6;
|
|
727
|
+
/**
|
|
728
|
+
* How many white keys sit at or before each pitch class, counting from C.
|
|
729
|
+
*
|
|
730
|
+
* A black key shares the number of the white key to its left plus one, which
|
|
731
|
+
* puts it on the boundary between the two; {@link notePosition} then shifts it
|
|
732
|
+
* back by half its width to centre it there.
|
|
733
|
+
*/
|
|
734
|
+
const whiteKeysBefore = {
|
|
735
|
+
C: 0,
|
|
736
|
+
"C#": 1,
|
|
737
|
+
D: 1,
|
|
738
|
+
"D#": 2,
|
|
739
|
+
E: 2,
|
|
740
|
+
F: 3,
|
|
741
|
+
"F#": 4,
|
|
742
|
+
G: 4,
|
|
743
|
+
"G#": 5,
|
|
744
|
+
A: 5,
|
|
745
|
+
"A#": 6,
|
|
746
|
+
B: 6
|
|
747
|
+
};
|
|
748
|
+
/** Width of a black key in pixels. */
|
|
749
|
+
function blackKeyWidth(layout) {
|
|
750
|
+
return layout.whiteKeyWidth * (layout.blackKeyWidthRatio ?? DEFAULT_BLACK_KEY_WIDTH_RATIO);
|
|
751
|
+
}
|
|
752
|
+
function rawNotePosition(note, layout) {
|
|
753
|
+
const slot = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP);
|
|
754
|
+
const target = (0, _tremolo_ui_functions.noteKey)(note);
|
|
755
|
+
const first = (0, _tremolo_ui_functions.noteKey)(layout.noteRange.first);
|
|
756
|
+
const octave = Math.floor((note - layout.noteRange.first) / 12);
|
|
757
|
+
const octaveOffset = _tremolo_ui_functions.noteKeys.indexOf(first) > _tremolo_ui_functions.noteKeys.indexOf(target) ? 1 : 0;
|
|
758
|
+
const whiteKeysIn = whiteKeysBefore[target] - whiteKeysBefore[first] + (octave + octaveOffset) * 7;
|
|
759
|
+
return (0, _tremolo_ui_functions.isBlackKey)(note) ? whiteKeysIn * slot - blackKeyWidth(layout) / 2 : whiteKeysIn * slot;
|
|
760
|
+
}
|
|
761
|
+
function pianoBounds(layout) {
|
|
762
|
+
const notes = getNoteRangeArray(layout.noteRange);
|
|
763
|
+
if (notes.length === 0) return {
|
|
764
|
+
left: 0,
|
|
765
|
+
right: 0
|
|
766
|
+
};
|
|
767
|
+
let left = Infinity;
|
|
768
|
+
let right = -Infinity;
|
|
769
|
+
const whiteWidth = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP);
|
|
770
|
+
for (const note of notes) {
|
|
771
|
+
const noteLeft = rawNotePosition(note, layout);
|
|
772
|
+
const width = (0, _tremolo_ui_functions.isBlackKey)(note) ? blackKeyWidth(layout) : whiteWidth;
|
|
773
|
+
left = Math.min(left, noteLeft);
|
|
774
|
+
right = Math.max(right, noteLeft + width);
|
|
775
|
+
}
|
|
776
|
+
return {
|
|
777
|
+
left,
|
|
778
|
+
right
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
/** Width of the whole keyboard in pixels. */
|
|
782
|
+
function pianoWidth(layout) {
|
|
783
|
+
const { left, right } = pianoBounds(layout);
|
|
784
|
+
return right - left;
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Offset of the left edge of a key from the left edge of the keyboard, in
|
|
788
|
+
* pixels.
|
|
789
|
+
*
|
|
790
|
+
* Notes outside `noteRange` are placed too, so the value is negative below
|
|
791
|
+
* `noteRange.first`.
|
|
792
|
+
*/
|
|
793
|
+
function notePosition(note, layout) {
|
|
794
|
+
return rawNotePosition(note, layout) - pianoBounds(layout).left;
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* The note drawn at a point, or null where there is none.
|
|
798
|
+
*
|
|
799
|
+
* Black keys are tested first, so they win where they overlap a white one. A
|
|
800
|
+
* white key covers its gap as well as its width, so the whole width of the
|
|
801
|
+
* keyboard belongs to some key and a click cannot fall between two.
|
|
802
|
+
*
|
|
803
|
+
* @param x offset from the left edge of the keyboard, in pixels
|
|
804
|
+
* @param y offset from its top edge, in pixels
|
|
805
|
+
* @param height height of the keyboard, in pixels
|
|
806
|
+
*/
|
|
807
|
+
function noteAt(x, y, height, layout) {
|
|
808
|
+
if (x < 0 || x >= pianoWidth(layout) || y < 0 || y >= height) return null;
|
|
809
|
+
const notes = getNoteRangeArray(layout.noteRange);
|
|
810
|
+
if (y < height * (layout.blackKeyHeightRatio ?? DEFAULT_BLACK_KEY_HEIGHT_RATIO)) for (const note of notes) {
|
|
811
|
+
if ((0, _tremolo_ui_functions.isWhiteKey)(note)) continue;
|
|
812
|
+
const left = notePosition(note, layout);
|
|
813
|
+
if (left <= x && x < left + blackKeyWidth(layout)) return note;
|
|
814
|
+
}
|
|
815
|
+
const slot = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP);
|
|
816
|
+
for (const note of notes) {
|
|
817
|
+
if ((0, _tremolo_ui_functions.isBlackKey)(note)) continue;
|
|
818
|
+
const left = notePosition(note, layout);
|
|
819
|
+
if (left <= x && x < left + slot) return note;
|
|
820
|
+
}
|
|
821
|
+
return null;
|
|
314
822
|
}
|
|
315
823
|
//#endregion
|
|
316
824
|
//#region src/pointer/drag.ts
|
|
@@ -327,6 +835,7 @@ const MANAGED_STYLES = [
|
|
|
327
835
|
["-webkit-user-select", "none"],
|
|
328
836
|
["-webkit-touch-callout", "none"]
|
|
329
837
|
];
|
|
838
|
+
const managedStyleStates = /* @__PURE__ */ new WeakMap();
|
|
330
839
|
/**
|
|
331
840
|
* Track a pointer drag on an element.
|
|
332
841
|
*
|
|
@@ -343,10 +852,24 @@ function createDrag(element, options = {}) {
|
|
|
343
852
|
const multiPointer = options.multiPointer ?? false;
|
|
344
853
|
const capture = element;
|
|
345
854
|
const style = element.style;
|
|
346
|
-
|
|
347
|
-
if (style)
|
|
348
|
-
|
|
349
|
-
|
|
855
|
+
let managedStyles = managedStyleStates.get(element);
|
|
856
|
+
if (style) {
|
|
857
|
+
if (managedStyles) managedStyles.count += 1;
|
|
858
|
+
else {
|
|
859
|
+
const previous = /* @__PURE__ */ new Map();
|
|
860
|
+
for (const [property, value] of MANAGED_STYLES) {
|
|
861
|
+
previous.set(property, {
|
|
862
|
+
value: style.getPropertyValue(property),
|
|
863
|
+
priority: style.getPropertyPriority(property)
|
|
864
|
+
});
|
|
865
|
+
style.setProperty(property, value);
|
|
866
|
+
}
|
|
867
|
+
managedStyles = {
|
|
868
|
+
count: 1,
|
|
869
|
+
previous
|
|
870
|
+
};
|
|
871
|
+
managedStyleStates.set(element, managedStyles);
|
|
872
|
+
}
|
|
350
873
|
}
|
|
351
874
|
const pointers = /* @__PURE__ */ new Map();
|
|
352
875
|
/**
|
|
@@ -356,10 +879,14 @@ function createDrag(element, options = {}) {
|
|
|
356
879
|
*/
|
|
357
880
|
const targets = /* @__PURE__ */ new Map();
|
|
358
881
|
let previousCursor;
|
|
882
|
+
/** The pointer that asked for the lock, while it is still down. */
|
|
883
|
+
let lockedPointerId = null;
|
|
884
|
+
let lockRequest = 0;
|
|
885
|
+
let destroyed = false;
|
|
359
886
|
function state(event, pointer, deltaX, deltaY) {
|
|
360
887
|
return {
|
|
361
|
-
x: event.screenX - pointer.startX,
|
|
362
|
-
y: event.screenY - pointer.startY,
|
|
888
|
+
x: pointer.lockBaseX !== void 0 ? pointer.lockBaseX + pointer.lockMoveX : event.screenX - pointer.startX,
|
|
889
|
+
y: pointer.lockBaseY !== void 0 ? pointer.lockBaseY + pointer.lockMoveY : event.screenY - pointer.startY,
|
|
363
890
|
deltaX,
|
|
364
891
|
deltaY,
|
|
365
892
|
clientX: event.clientX,
|
|
@@ -382,6 +909,7 @@ function createDrag(element, options = {}) {
|
|
|
382
909
|
target.addEventListener("pointermove", handlePointerMove);
|
|
383
910
|
target.addEventListener("pointerup", handlePointerUp);
|
|
384
911
|
target.addEventListener("pointercancel", handlePointerUp);
|
|
912
|
+
if (target === element) target.addEventListener("lostpointercapture", handleLostPointerCapture);
|
|
385
913
|
}
|
|
386
914
|
targets.set(target, count + 1);
|
|
387
915
|
}
|
|
@@ -394,13 +922,16 @@ function createDrag(element, options = {}) {
|
|
|
394
922
|
target.removeEventListener("pointermove", handlePointerMove);
|
|
395
923
|
target.removeEventListener("pointerup", handlePointerUp);
|
|
396
924
|
target.removeEventListener("pointercancel", handlePointerUp);
|
|
925
|
+
if (target === element) target.removeEventListener("lostpointercapture", handleLostPointerCapture);
|
|
397
926
|
targets.delete(target);
|
|
398
927
|
}
|
|
399
928
|
function handlePointerDown(event) {
|
|
400
929
|
const pointerEvent = event;
|
|
930
|
+
if (pointerEvent.button !== 0) return;
|
|
401
931
|
const pointerId = pointerEvent.pointerId;
|
|
402
932
|
if (pointers.has(pointerId)) return;
|
|
403
933
|
if (!multiPointer && pointers.size > 0) return;
|
|
934
|
+
if (opts.shouldStart && !opts.shouldStart(pointerEvent)) return;
|
|
404
935
|
const isFirst = pointers.size === 0;
|
|
405
936
|
if (isFirst && opts.cursor && style) {
|
|
406
937
|
previousCursor = style.cursor;
|
|
@@ -413,39 +944,86 @@ function createDrag(element, options = {}) {
|
|
|
413
944
|
startX: pointerEvent.screenX,
|
|
414
945
|
startY: pointerEvent.screenY,
|
|
415
946
|
lastX: pointerEvent.screenX,
|
|
416
|
-
lastY: pointerEvent.screenY
|
|
947
|
+
lastY: pointerEvent.screenY,
|
|
948
|
+
lockMoveX: 0,
|
|
949
|
+
lockMoveY: 0,
|
|
950
|
+
lastEvent: pointerEvent
|
|
417
951
|
};
|
|
418
952
|
pointers.set(pointerId, pointer);
|
|
419
953
|
retainTarget(moveTarget);
|
|
420
954
|
if (isFirst) globalThis.document?.addEventListener("selectstart", preventSelectStart);
|
|
955
|
+
if (isFirst && opts.pointerLock) {
|
|
956
|
+
const requestId = ++lockRequest;
|
|
957
|
+
lockedPointerId = pointerId;
|
|
958
|
+
globalThis.document?.addEventListener("pointerlockchange", handleLockChange);
|
|
959
|
+
try {
|
|
960
|
+
(capture.requestPointerLock?.())?.then?.(() => {
|
|
961
|
+
if (requestId === lockRequest && (!pointers.has(pointerId) || destroyed) && globalThis.document?.pointerLockElement === element) globalThis.document.exitPointerLock?.();
|
|
962
|
+
}, () => {});
|
|
963
|
+
} catch {}
|
|
964
|
+
}
|
|
421
965
|
opts.onDragStart?.(state(pointerEvent, pointer, 0, 0));
|
|
422
966
|
}
|
|
423
967
|
function handlePointerMove(event) {
|
|
424
968
|
const pointerEvent = event;
|
|
425
969
|
const pointer = pointers.get(pointerEvent.pointerId);
|
|
426
970
|
if (!pointer) return;
|
|
427
|
-
const
|
|
428
|
-
const
|
|
971
|
+
const locked = pointer.lockBaseX !== void 0;
|
|
972
|
+
const deltaX = locked ? pointerEvent.movementX ?? 0 : pointerEvent.screenX - pointer.lastX;
|
|
973
|
+
const deltaY = locked ? pointerEvent.movementY ?? 0 : pointerEvent.screenY - pointer.lastY;
|
|
429
974
|
const threshold = opts.threshold ?? 0;
|
|
430
975
|
if (Math.abs(deltaX) < threshold && Math.abs(deltaY) < threshold) return;
|
|
431
976
|
pointer.lastX = pointerEvent.screenX;
|
|
432
977
|
pointer.lastY = pointerEvent.screenY;
|
|
978
|
+
pointer.lastEvent = pointerEvent;
|
|
979
|
+
if (locked) {
|
|
980
|
+
pointer.lockMoveX += deltaX;
|
|
981
|
+
pointer.lockMoveY += deltaY;
|
|
982
|
+
}
|
|
433
983
|
opts.onDrag?.(state(pointerEvent, pointer, deltaX, deltaY));
|
|
434
984
|
}
|
|
985
|
+
function handleLockChange() {
|
|
986
|
+
if (lockedPointerId === null) return;
|
|
987
|
+
const pointer = pointers.get(lockedPointerId);
|
|
988
|
+
if (!pointer) return;
|
|
989
|
+
if (globalThis.document?.pointerLockElement === element) {
|
|
990
|
+
pointer.lockBaseX = pointer.lastX - pointer.startX;
|
|
991
|
+
pointer.lockBaseY = pointer.lastY - pointer.startY;
|
|
992
|
+
pointer.lockMoveX = 0;
|
|
993
|
+
pointer.lockMoveY = 0;
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
if (pointer.lockBaseX === void 0) return;
|
|
997
|
+
finishDrag(lockedPointerId);
|
|
998
|
+
}
|
|
435
999
|
function handlePointerUp(event) {
|
|
436
1000
|
const pointerEvent = event;
|
|
437
|
-
|
|
1001
|
+
finishDrag(pointerEvent.pointerId, pointerEvent);
|
|
1002
|
+
}
|
|
1003
|
+
function handleLostPointerCapture(event) {
|
|
1004
|
+
finishDrag(event.pointerId);
|
|
1005
|
+
}
|
|
1006
|
+
function finishDrag(pointerId, event) {
|
|
1007
|
+
const pointer = pointers.get(pointerId);
|
|
438
1008
|
if (!pointer) return;
|
|
439
|
-
const finalState = state(
|
|
440
|
-
stopTracking(
|
|
1009
|
+
const finalState = event ? state(event, pointer, event.screenX - pointer.lastX, event.screenY - pointer.lastY) : state(pointer.lastEvent, pointer, 0, 0);
|
|
1010
|
+
stopTracking(pointerId);
|
|
441
1011
|
opts.onDragEnd?.(finalState);
|
|
442
1012
|
}
|
|
443
1013
|
function stopTracking(pointerId) {
|
|
444
1014
|
const pointer = pointers.get(pointerId);
|
|
445
1015
|
if (!pointer) return;
|
|
446
|
-
capture.releasePointerCapture?.(pointerId);
|
|
447
|
-
releaseTarget(pointer.moveTarget);
|
|
448
1016
|
pointers.delete(pointerId);
|
|
1017
|
+
releaseTarget(pointer.moveTarget);
|
|
1018
|
+
try {
|
|
1019
|
+
if (capture.hasPointerCapture?.(pointerId) === true) capture.releasePointerCapture?.(pointerId);
|
|
1020
|
+
} catch {}
|
|
1021
|
+
if (lockedPointerId === pointerId) {
|
|
1022
|
+
lockedPointerId = null;
|
|
1023
|
+
const document = globalThis.document;
|
|
1024
|
+
document?.removeEventListener("pointerlockchange", handleLockChange);
|
|
1025
|
+
if (document?.pointerLockElement === element) document.exitPointerLock?.();
|
|
1026
|
+
}
|
|
449
1027
|
if (pointers.size > 0) return;
|
|
450
1028
|
globalThis.document?.removeEventListener("selectstart", preventSelectStart);
|
|
451
1029
|
if (previousCursor !== void 0 && style) {
|
|
@@ -463,18 +1041,23 @@ function createDrag(element, options = {}) {
|
|
|
463
1041
|
};
|
|
464
1042
|
},
|
|
465
1043
|
destroy: () => {
|
|
466
|
-
|
|
1044
|
+
if (destroyed) return;
|
|
1045
|
+
destroyed = true;
|
|
467
1046
|
element.removeEventListener("pointerdown", handlePointerDown);
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
1047
|
+
for (const pointerId of [...pointers.keys()]) finishDrag(pointerId);
|
|
1048
|
+
if (style && managedStyles && --managedStyles.count === 0) {
|
|
1049
|
+
for (const [property] of MANAGED_STYLES) {
|
|
1050
|
+
const previous = managedStyles.previous.get(property);
|
|
1051
|
+
if (previous?.value) style.setProperty(property, previous.value, previous.priority);
|
|
1052
|
+
else style.removeProperty(property);
|
|
1053
|
+
}
|
|
1054
|
+
managedStyleStates.delete(element);
|
|
472
1055
|
}
|
|
473
1056
|
}
|
|
474
1057
|
};
|
|
475
1058
|
}
|
|
476
1059
|
//#endregion
|
|
477
|
-
//#region src/piano/
|
|
1060
|
+
//#region src/piano/index.ts
|
|
478
1061
|
const DEFAULT_SOURCE = "api";
|
|
479
1062
|
/**
|
|
480
1063
|
* Drive a piano keyboard with pointers, and own what is sounding.
|
|
@@ -496,17 +1079,19 @@ function createPianoInput(element, options) {
|
|
|
496
1079
|
return [...held.keys()].sort((a, b) => a - b);
|
|
497
1080
|
}
|
|
498
1081
|
function noteOn(note, { source = DEFAULT_SOURCE, velocity } = {}) {
|
|
1082
|
+
if (!Number.isInteger(note) || note < 0 || note > 127) throw new RangeError("note: requirements: an integer from 0 to 127");
|
|
499
1083
|
if (note > (opts.midiMax ?? 127)) return;
|
|
500
1084
|
const sources = held.get(note);
|
|
501
1085
|
if (sources) {
|
|
502
1086
|
sources.add(source);
|
|
503
1087
|
return;
|
|
504
1088
|
}
|
|
505
|
-
held.set(note, new Set([source]));
|
|
1089
|
+
held.set(note, /* @__PURE__ */ new Set([source]));
|
|
506
1090
|
opts.onPlayNote?.(note, velocity);
|
|
507
1091
|
opts.onActiveNotesChange?.(activeNotes());
|
|
508
1092
|
}
|
|
509
1093
|
function noteOff(note, { source = DEFAULT_SOURCE } = {}) {
|
|
1094
|
+
if (!Number.isInteger(note) || note < 0 || note > 127) throw new RangeError("note: requirements: an integer from 0 to 127");
|
|
510
1095
|
const sources = held.get(note);
|
|
511
1096
|
if (!sources) return;
|
|
512
1097
|
sources.delete(source);
|
|
@@ -518,7 +1103,7 @@ function createPianoInput(element, options) {
|
|
|
518
1103
|
/** The note under a pointer, or null where the pointer is off the keys. */
|
|
519
1104
|
function noteUnder(clientX, clientY) {
|
|
520
1105
|
const { left, top, height } = element.getBoundingClientRect();
|
|
521
|
-
return
|
|
1106
|
+
return noteAt(clientX - left, clientY - top, height, opts.layout);
|
|
522
1107
|
}
|
|
523
1108
|
/** Move a pointer onto a note, releasing whatever it held before. */
|
|
524
1109
|
function movePointer(pointerId, note) {
|
|
@@ -526,7 +1111,7 @@ function createPianoInput(element, options) {
|
|
|
526
1111
|
const previous = pointerNotes.get(pointerId);
|
|
527
1112
|
if (previous === note) return;
|
|
528
1113
|
if (previous !== void 0) noteOff(previous, { source });
|
|
529
|
-
if (note === null) pointerNotes.delete(pointerId);
|
|
1114
|
+
if (note === null || note > (opts.midiMax ?? 127)) pointerNotes.delete(pointerId);
|
|
530
1115
|
else {
|
|
531
1116
|
pointerNotes.set(pointerId, note);
|
|
532
1117
|
noteOn(note, { source });
|
|
@@ -547,18 +1132,28 @@ function createPianoInput(element, options) {
|
|
|
547
1132
|
...opts,
|
|
548
1133
|
...next
|
|
549
1134
|
};
|
|
1135
|
+
const midiMax = opts.midiMax ?? 127;
|
|
1136
|
+
const stoppedNotes = activeNotes().filter((note) => note > midiMax);
|
|
1137
|
+
if (stoppedNotes.length === 0) return;
|
|
1138
|
+
for (const note of stoppedNotes) {
|
|
1139
|
+
held.delete(note);
|
|
1140
|
+
opts.onStopNote?.(note);
|
|
1141
|
+
}
|
|
1142
|
+
for (const [pointerId, note] of pointerNotes) if (note > midiMax) pointerNotes.delete(pointerId);
|
|
1143
|
+
opts.onActiveNotesChange?.(activeNotes());
|
|
550
1144
|
},
|
|
551
1145
|
noteOn,
|
|
552
1146
|
noteOff,
|
|
553
1147
|
activeNotes,
|
|
554
1148
|
destroy: () => {
|
|
555
1149
|
drag.destroy();
|
|
556
|
-
|
|
1150
|
+
const notes = activeNotes();
|
|
1151
|
+
for (const note of notes) {
|
|
557
1152
|
held.delete(note);
|
|
558
1153
|
opts.onStopNote?.(note);
|
|
559
1154
|
}
|
|
560
1155
|
pointerNotes.clear();
|
|
561
|
-
opts.onActiveNotesChange?.([]);
|
|
1156
|
+
if (notes.length > 0) opts.onActiveNotesChange?.([]);
|
|
562
1157
|
}
|
|
563
1158
|
};
|
|
564
1159
|
}
|
|
@@ -581,7 +1176,7 @@ function toXY(value) {
|
|
|
581
1176
|
return isPair(value) ? [value[0], value[1]] : [value, value];
|
|
582
1177
|
}
|
|
583
1178
|
//#endregion
|
|
584
|
-
//#region src/pointer/
|
|
1179
|
+
//#region src/pointer/drag-value.ts
|
|
585
1180
|
/**
|
|
586
1181
|
* Map the pointer onto the bounding rect of an element: the value *is* the
|
|
587
1182
|
* position pointed at, so the middle of the element is 0.5.
|
|
@@ -589,16 +1184,43 @@ function toXY(value) {
|
|
|
589
1184
|
* The element is read on every event, so it may be mounted after the drag is
|
|
590
1185
|
* set up and may change size while a drag is in progress.
|
|
591
1186
|
*/
|
|
592
|
-
function elementMapping(getElement) {
|
|
1187
|
+
function elementMapping(getElement, { sensitivity } = {}) {
|
|
1188
|
+
/** The reported position when the sensitivity last changed. */
|
|
1189
|
+
let origin = [0, 0];
|
|
1190
|
+
/** The raw position at that same moment. */
|
|
1191
|
+
let anchor = [0, 0];
|
|
1192
|
+
/** The previous event, so a sensitivity change can be dated back to it. */
|
|
1193
|
+
let previous = [0, 0];
|
|
1194
|
+
let factor = 1;
|
|
593
1195
|
function positionIn(state) {
|
|
594
1196
|
const element = getElement();
|
|
595
1197
|
if (!element) return null;
|
|
596
1198
|
const { left, top, right, bottom } = element.getBoundingClientRect();
|
|
597
1199
|
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];
|
|
598
1200
|
}
|
|
1201
|
+
const reported = (raw, at) => [origin[0] + (raw[0] - anchor[0]) * at, origin[1] + (raw[1] - anchor[1]) * at];
|
|
599
1202
|
return {
|
|
600
|
-
start:
|
|
601
|
-
|
|
1203
|
+
start: (state) => {
|
|
1204
|
+
const raw = positionIn(state);
|
|
1205
|
+
if (!raw) return null;
|
|
1206
|
+
origin = raw;
|
|
1207
|
+
anchor = raw;
|
|
1208
|
+
previous = raw;
|
|
1209
|
+
factor = sensitivity ? sensitivity(state) : 1;
|
|
1210
|
+
return raw;
|
|
1211
|
+
},
|
|
1212
|
+
move: (state) => {
|
|
1213
|
+
const raw = positionIn(state);
|
|
1214
|
+
if (!raw) return null;
|
|
1215
|
+
const next = sensitivity ? sensitivity(state) : 1;
|
|
1216
|
+
if (next !== factor) {
|
|
1217
|
+
origin = reported(previous, factor);
|
|
1218
|
+
anchor = previous;
|
|
1219
|
+
factor = next;
|
|
1220
|
+
}
|
|
1221
|
+
previous = raw;
|
|
1222
|
+
return reported(raw, factor);
|
|
1223
|
+
}
|
|
602
1224
|
};
|
|
603
1225
|
}
|
|
604
1226
|
/**
|
|
@@ -606,15 +1228,33 @@ function elementMapping(getElement) {
|
|
|
606
1228
|
* distance dragged. The pointer position itself carries no meaning, so the
|
|
607
1229
|
* value can be adjusted from anywhere on the screen.
|
|
608
1230
|
*/
|
|
609
|
-
function relativeMapping({ pixelRange = 100 } = {}) {
|
|
610
|
-
const [
|
|
1231
|
+
function relativeMapping({ pixelRange = 100, sensitivity } = {}) {
|
|
1232
|
+
const [baseX, baseY] = toXY(pixelRange);
|
|
611
1233
|
let origin = [0, 0];
|
|
1234
|
+
/** Where the current sensitivity took over, in drag coordinates. */
|
|
1235
|
+
let anchor = [0, 0];
|
|
1236
|
+
/** The previous event, so a sensitivity change can be dated back to it. */
|
|
1237
|
+
let previous = [0, 0];
|
|
1238
|
+
let factor = 1;
|
|
1239
|
+
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];
|
|
612
1240
|
return {
|
|
613
|
-
start: (
|
|
1241
|
+
start: (state, context) => {
|
|
614
1242
|
origin = context.position();
|
|
1243
|
+
anchor = [0, 0];
|
|
1244
|
+
previous = [0, 0];
|
|
1245
|
+
factor = sensitivity ? sensitivity(state) : 1;
|
|
615
1246
|
return origin;
|
|
616
1247
|
},
|
|
617
|
-
move: (state) =>
|
|
1248
|
+
move: (state) => {
|
|
1249
|
+
const next = sensitivity ? sensitivity(state) : 1;
|
|
1250
|
+
if (next !== factor) {
|
|
1251
|
+
origin = travelled(previous, factor);
|
|
1252
|
+
anchor = previous;
|
|
1253
|
+
factor = next;
|
|
1254
|
+
}
|
|
1255
|
+
previous = [state.x, state.y];
|
|
1256
|
+
return travelled([state.x, state.y], factor);
|
|
1257
|
+
}
|
|
618
1258
|
};
|
|
619
1259
|
}
|
|
620
1260
|
/**
|
|
@@ -627,12 +1267,14 @@ function relativeMapping({ pixelRange = 100 } = {}) {
|
|
|
627
1267
|
function createDragValue(element, options) {
|
|
628
1268
|
let opts = options;
|
|
629
1269
|
let lastValue = [0, 0];
|
|
1270
|
+
let active = false;
|
|
630
1271
|
const axes = () => toXY(opts.axis);
|
|
631
1272
|
function valueOf(position) {
|
|
632
1273
|
return axes().map((axis, i) => {
|
|
633
1274
|
const p = axis.reverse ? 1 - position[i] : position[i];
|
|
634
1275
|
const value = (axis.scale ?? _tremolo_ui_functions.linearScale).denormalize(p, axis.min, axis.max);
|
|
635
|
-
|
|
1276
|
+
const stepped = axis.step ? (0, _tremolo_ui_functions.stepValue)(value, axis.step) : value;
|
|
1277
|
+
return (0, _tremolo_ui_functions.clamp)((0, _tremolo_ui_functions.toPrecision)(stepped), axis.min, axis.max);
|
|
636
1278
|
});
|
|
637
1279
|
}
|
|
638
1280
|
const context = { position: () => {
|
|
@@ -647,21 +1289,35 @@ function createDragValue(element, options) {
|
|
|
647
1289
|
const drag = createDrag(element, {
|
|
648
1290
|
threshold: opts.threshold,
|
|
649
1291
|
cursor: opts.cursor,
|
|
1292
|
+
pointerLock: opts.pointerLock,
|
|
1293
|
+
shouldStart: (event) => opts.shouldStart?.(event) ?? true,
|
|
650
1294
|
onDragStart: (state) => {
|
|
651
1295
|
const position = opts.mapping.start(state, context);
|
|
652
|
-
if (position)
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
1296
|
+
if (!position) return;
|
|
1297
|
+
active = true;
|
|
1298
|
+
lastValue = valueOf(position);
|
|
1299
|
+
if (opts.updateOnPointerDown) opts.onChange?.(lastValue, state);
|
|
656
1300
|
opts.onDragStart?.(lastValue, state);
|
|
657
1301
|
},
|
|
658
1302
|
onDrag: (state) => {
|
|
1303
|
+
if (!active) return;
|
|
659
1304
|
const position = opts.mapping.move(state, context);
|
|
660
1305
|
if (!position) return;
|
|
661
1306
|
lastValue = valueOf(position);
|
|
662
1307
|
opts.onChange?.(lastValue, state);
|
|
663
1308
|
},
|
|
664
|
-
onDragEnd: (state) =>
|
|
1309
|
+
onDragEnd: (state) => {
|
|
1310
|
+
if (!active) return;
|
|
1311
|
+
if (state.event.type === "pointerup" && (state.deltaX !== 0 || state.deltaY !== 0)) {
|
|
1312
|
+
const position = opts.mapping.move(state, context);
|
|
1313
|
+
if (position) {
|
|
1314
|
+
lastValue = valueOf(position);
|
|
1315
|
+
opts.onChange?.(lastValue, state);
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
active = false;
|
|
1319
|
+
opts.onDragEnd?.(lastValue, state);
|
|
1320
|
+
}
|
|
665
1321
|
});
|
|
666
1322
|
return {
|
|
667
1323
|
update: (next) => {
|
|
@@ -672,7 +1328,8 @@ function createDragValue(element, options) {
|
|
|
672
1328
|
};
|
|
673
1329
|
drag.update({
|
|
674
1330
|
threshold: opts.threshold,
|
|
675
|
-
cursor: opts.cursor
|
|
1331
|
+
cursor: opts.cursor,
|
|
1332
|
+
pointerLock: opts.pointerLock
|
|
676
1333
|
});
|
|
677
1334
|
},
|
|
678
1335
|
destroy: () => drag.destroy()
|
|
@@ -687,14 +1344,17 @@ function createDragValue(element, options) {
|
|
|
687
1344
|
* `preventDefault()` to stop the page from scrolling.
|
|
688
1345
|
*/
|
|
689
1346
|
function createWheel(element, onWheel, options = {}) {
|
|
690
|
-
let opts =
|
|
1347
|
+
let opts = {
|
|
1348
|
+
...options,
|
|
1349
|
+
onWheel
|
|
1350
|
+
};
|
|
691
1351
|
function hasFocus() {
|
|
692
1352
|
const active = element.ownerDocument?.activeElement;
|
|
693
1353
|
return !!active && element.contains(active);
|
|
694
1354
|
}
|
|
695
1355
|
const handler = (event) => {
|
|
696
1356
|
if (opts.requireFocus && !hasFocus()) return;
|
|
697
|
-
onWheel(event);
|
|
1357
|
+
opts.onWheel?.(event);
|
|
698
1358
|
};
|
|
699
1359
|
element.addEventListener("wheel", handler, { passive: false });
|
|
700
1360
|
return {
|
|
@@ -710,20 +1370,101 @@ function createWheel(element, onWheel, options = {}) {
|
|
|
710
1370
|
};
|
|
711
1371
|
}
|
|
712
1372
|
//#endregion
|
|
1373
|
+
//#region src/selection/box.ts
|
|
1374
|
+
function rectOf(from, to) {
|
|
1375
|
+
return {
|
|
1376
|
+
x: Math.min(from[0], to[0]),
|
|
1377
|
+
y: Math.min(from[1], to[1]),
|
|
1378
|
+
width: Math.abs(to[0] - from[0]),
|
|
1379
|
+
height: Math.abs(to[1] - from[1])
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
/** Whether the box covers the point, edges included. */
|
|
1383
|
+
function selectionBoxCovers(box, [x, y]) {
|
|
1384
|
+
return x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height;
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Selecting by dragging a box over a set of items.
|
|
1388
|
+
*
|
|
1389
|
+
* The drag itself is not here: the caller owns the pointer, and reports where
|
|
1390
|
+
* it went in the same 0..1 space the items are given in.
|
|
1391
|
+
*/
|
|
1392
|
+
function createSelectionBox(options) {
|
|
1393
|
+
let opts = options;
|
|
1394
|
+
let drag = null;
|
|
1395
|
+
let box = null;
|
|
1396
|
+
function setBox(next) {
|
|
1397
|
+
box = next;
|
|
1398
|
+
opts.onBoxChange?.(next);
|
|
1399
|
+
}
|
|
1400
|
+
function apply(rect, base) {
|
|
1401
|
+
const inside = [];
|
|
1402
|
+
for (const [id, at] of opts.items()) if (selectionBoxCovers(rect, at) && !base.includes(id)) inside.push(id);
|
|
1403
|
+
opts.onSelectionChange?.([...base, ...inside]);
|
|
1404
|
+
}
|
|
1405
|
+
return {
|
|
1406
|
+
update: (next) => {
|
|
1407
|
+
opts = {
|
|
1408
|
+
...opts,
|
|
1409
|
+
...next
|
|
1410
|
+
};
|
|
1411
|
+
},
|
|
1412
|
+
begin: (at, { additive = false, selection = [] } = {}) => {
|
|
1413
|
+
drag = {
|
|
1414
|
+
from: at,
|
|
1415
|
+
base: additive ? [...selection] : []
|
|
1416
|
+
};
|
|
1417
|
+
setBox(rectOf(at, at));
|
|
1418
|
+
if (!additive) opts.onSelectionChange?.([]);
|
|
1419
|
+
},
|
|
1420
|
+
move: (to) => {
|
|
1421
|
+
if (!drag) return;
|
|
1422
|
+
const rect = rectOf(drag.from, to);
|
|
1423
|
+
setBox(rect);
|
|
1424
|
+
apply(rect, drag.base);
|
|
1425
|
+
},
|
|
1426
|
+
end: () => {
|
|
1427
|
+
const running = drag !== null;
|
|
1428
|
+
drag = null;
|
|
1429
|
+
if (box !== null) setBox(null);
|
|
1430
|
+
return running;
|
|
1431
|
+
},
|
|
1432
|
+
box: () => box,
|
|
1433
|
+
destroy: () => {
|
|
1434
|
+
drag = null;
|
|
1435
|
+
box = null;
|
|
1436
|
+
}
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
//#endregion
|
|
713
1440
|
exports.NOT_SUPPORTED = NOT_SUPPORTED;
|
|
714
1441
|
exports.PERMISSION_DENIED = PERMISSION_DENIED;
|
|
1442
|
+
exports.PITCH_BEND_CENTER = PITCH_BEND_CENTER;
|
|
1443
|
+
exports.UNAVAILABLE = UNAVAILABLE;
|
|
1444
|
+
exports.applyDelta = applyDelta;
|
|
1445
|
+
exports.blackKeyWidth = blackKeyWidth;
|
|
715
1446
|
exports.createAnimationCanvas = createAnimationCanvas;
|
|
716
1447
|
exports.createDrag = createDrag;
|
|
717
1448
|
exports.createDragValue = createDragValue;
|
|
1449
|
+
exports.createDropZone = createDropZone;
|
|
718
1450
|
exports.createMIDIAccess = createMIDIAccess;
|
|
719
1451
|
exports.createMIDIInput = createMIDIInput;
|
|
720
1452
|
exports.createMIDIMessage = createMIDIMessage;
|
|
721
1453
|
exports.createPianoInput = createPianoInput;
|
|
1454
|
+
exports.createSelectionBox = createSelectionBox;
|
|
722
1455
|
exports.createWheel = createWheel;
|
|
723
1456
|
exports.drawingState = drawingState;
|
|
724
1457
|
exports.elementMapping = elementMapping;
|
|
1458
|
+
exports.getNoteRangeArray = getNoteRangeArray;
|
|
725
1459
|
exports.isDrawingState = isDrawingState;
|
|
1460
|
+
exports.mapModifier = mapModifier;
|
|
1461
|
+
exports.matchesAccept = matchesAccept;
|
|
1462
|
+
exports.noteAt = noteAt;
|
|
1463
|
+
exports.notePosition = notePosition;
|
|
1464
|
+
exports.pianoWidth = pianoWidth;
|
|
726
1465
|
exports.relativeMapping = relativeMapping;
|
|
1466
|
+
exports.selectModifier = selectModifier;
|
|
1467
|
+
exports.selectionBoxCovers = selectionBoxCovers;
|
|
727
1468
|
exports.toXY = toXY;
|
|
728
1469
|
|
|
729
1470
|
//# sourceMappingURL=index.cjs.map
|