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