@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.js CHANGED
@@ -1,9 +1,10 @@
1
- import { clamp, linearScale, normalizeValue, noteAt, stepValue } from "@tremolo-ui/functions";
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 properties of a 2D context that survive `save()` / `restore()`, and so
5
- * are what has to be carried across a resize by hand: setting `canvas.width`
6
- * resets the context to its defaults.
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
- "imageSmoothingEnabled"
33
+ "letterSpacing",
34
+ "textRendering",
35
+ "wordSpacing",
36
+ "imageSmoothingEnabled",
37
+ "imageSmoothingQuality"
29
38
  ];
30
39
  function isDrawingState(value) {
31
- return typeof value == "string" && drawingState.includes(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 relativeSize = opts.relativeSize ?? false;
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 null;
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 null;
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 state;
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(state, previousWidth, previousHeight) {
118
- if (!state || !memo || !memoContext) return;
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 state = takeSnapshot();
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(state, previousWidth, previousHeight);
148
+ restoreSnapshot(hasSnapshot, previousWidth, previousHeight);
149
+ if (state) writeDrawingState(context, state, dpr / previousDpr);
133
150
  }
134
- function drawFrame() {
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 now = performance.now();
144
- const deltaTime = now - previousTime;
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: now - startTime,
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 != null) return;
181
+ if (frameId !== null) return;
162
182
  previousTime = performance.now();
163
183
  frameId = requestAnimationFrame(tick);
164
184
  }
165
185
  function stopLoop() {
166
- if (frameId == null) return;
186
+ if (frameId === null) return;
167
187
  cancelAnimationFrame(frameId);
168
188
  frameId = null;
169
189
  }
170
190
  let observer = null;
171
- if (relativeSize) {
191
+ if (resizable) {
172
192
  const parent = canvas.parentElement;
173
- if (!parent) throw new Error("createAnimationCanvas: relativeSize needs the canvas to have a parent element");
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
- relativeSize,
214
+ resizable,
195
215
  contextAttributes: opts.contextAttributes
196
216
  };
197
- if (!relativeSize) {
217
+ if (!resizable) {
198
218
  const { width: w = 100, height: h = 100 } = opts.size ?? {};
199
- if (w != width || h != height) applySize(w, h);
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
- function request() {
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((access) => {
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: access,
253
- error: null
574
+ midiAccess: granted,
575
+ error: null,
576
+ inputs: readInputs()
254
577
  });
255
- }).catch(() => {
256
- if (destroyed) return;
578
+ }, (reason) => {
579
+ if (destroyed || generation !== requestGeneration) return;
257
580
  setState({
258
581
  ...state,
259
- error: PERMISSION_DENIED
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
- if (!midiAccess) return { destroy: () => {} };
288
- const inputs = [...midiAccess.inputs.values()];
289
- for (const input of inputs) input.addEventListener("midimessage", onMIDIMessage);
290
- return { destroy: () => {
291
- for (const input of inputs) input.removeEventListener("midimessage", onMIDIMessage);
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
- const MIDI_EVENT_TO_NUMBER = {
297
- NOTE_ON: 144,
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
- * Handle note on/off and pitch bend events. To be used with {@link createMIDIAccess}.
303
- * Internally uses {@link createMIDIMessage}.
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
- function createMIDIInput(midiAccess, { onNoteOnEvent, onNoteOffEvent, onPitchBendEvent }) {
306
- return createMIDIMessage(midiAccess, (event) => {
307
- if (!event.data) return;
308
- const kind = event.data[0] & 240;
309
- if (kind == MIDI_EVENT_TO_NUMBER.NOTE_OFF || kind == MIDI_EVENT_TO_NUMBER.NOTE_ON && event.data[2] == 0) onNoteOffEvent?.(event.data[1]);
310
- else if (kind == MIDI_EVENT_TO_NUMBER.NOTE_ON) onNoteOnEvent?.(event.data[1], event.data[2]);
311
- else if (kind == MIDI_EVENT_TO_NUMBER.PITCH_BEND) onPitchBendEvent?.(event.data[1], event.data[2]);
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
- const previousStyles = /* @__PURE__ */ new Map();
346
- if (style) for (const [property, value] of MANAGED_STYLES) {
347
- previousStyles.set(property, style.getPropertyValue(property));
348
- style.setProperty(property, value);
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 deltaX = pointerEvent.screenX - pointer.lastX;
427
- const deltaY = pointerEvent.screenY - pointer.lastY;
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
- const pointer = pointers.get(pointerEvent.pointerId);
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(pointerEvent, pointer, pointerEvent.screenX - pointer.lastX, pointerEvent.screenY - pointer.lastY);
439
- stopTracking(pointerEvent.pointerId);
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
- for (const pointerId of [...pointers.keys()]) stopTracking(pointerId);
1043
+ if (destroyed) return;
1044
+ destroyed = true;
466
1045
  element.removeEventListener("pointerdown", handlePointerDown);
467
- if (style) for (const [property] of MANAGED_STYLES) {
468
- const previous = previousStyles.get(property);
469
- if (previous) style.setProperty(property, previous);
470
- else style.removeProperty(property);
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/input.ts
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
- for (const note of activeNotes()) {
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/dragValue.ts
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: positionIn,
600
- move: positionIn
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 [rangeX, rangeY] = toXY(pixelRange);
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: (_state, context) => {
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) => [origin[0] + state.x / rangeX, origin[1] + state.y / rangeY]
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
- return clamp(axis.step ? stepValue(value, axis.step) : value, axis.min, axis.max);
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
- lastValue = valueOf(position);
653
- if (opts.updateOnPointerDown) opts.onChange?.(lastValue, state);
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) => opts.onDragEnd?.(lastValue, 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 = options;
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
- export { NOT_SUPPORTED, PERMISSION_DENIED, createAnimationCanvas, createDrag, createDragValue, createMIDIAccess, createMIDIInput, createMIDIMessage, createPianoInput, createWheel, drawingState, elementMapping, isDrawingState, relativeMapping, toXY };
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