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