@tremolo-ui/dom 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs 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();
@@ -220,11 +241,26 @@ function createAnimationCanvas(canvas, options) {
220
241
  const PERMISSION_DENIED = "PERMISSION_DENIED";
221
242
  /** @private */
222
243
  const NOT_SUPPORTED = "NOT_SUPPORTED";
244
+ /** @private */
245
+ const UNAVAILABLE = "UNAVAILABLE";
223
246
  const INITIAL_STATE = {
224
247
  midiAccess: null,
225
- error: null
248
+ error: null,
249
+ inputs: []
226
250
  };
227
251
  /**
252
+ * Which of our errors a rejected `requestMIDIAccess()` amounts to.
253
+ *
254
+ * The spec names the reasons, and they need telling apart: a user who said no
255
+ * can say yes on a second ask, while a browser without the API never will.
256
+ */
257
+ function toError(reason) {
258
+ const name = typeof reason === "object" && reason !== null && "name" in reason ? String(reason.name) : "";
259
+ if (name === "SecurityError" || name === "NotAllowedError") return PERMISSION_DENIED;
260
+ if (name === "NotSupportedError" || name === "TypeError") return NOT_SUPPORTED;
261
+ return UNAVAILABLE;
262
+ }
263
+ /**
228
264
  * Request MIDI access in the browser.
229
265
  *
230
266
  * The returned instance holds the state and notifies subscribers when it changes,
@@ -233,13 +269,31 @@ const INITIAL_STATE = {
233
269
  function createMIDIAccess() {
234
270
  let state = INITIAL_STATE;
235
271
  let destroyed = false;
272
+ let access = null;
273
+ let requestGeneration = 0;
236
274
  const listeners = /* @__PURE__ */ new Set();
237
275
  function setState(next) {
238
276
  state = next;
239
277
  for (const listener of listeners) listener();
240
278
  }
241
- function request() {
279
+ /**
280
+ * A new array every time, so that `useSyncExternalStore` and its equivalents
281
+ * see the change. The identity of the state object is what they compare.
282
+ */
283
+ function readInputs() {
284
+ return access ? [...access.inputs.values()] : [];
285
+ }
286
+ function handleStateChange(event) {
287
+ if (destroyed || !access) return;
288
+ if (event && event.port?.type === "output") return;
289
+ setState({
290
+ ...state,
291
+ inputs: readInputs()
292
+ });
293
+ }
294
+ function request(options = {}) {
242
295
  if (destroyed) return;
296
+ const generation = ++requestGeneration;
243
297
  if (typeof navigator === "undefined" || !navigator.requestMIDIAccess) {
244
298
  setState({
245
299
  ...state,
@@ -247,17 +301,21 @@ function createMIDIAccess() {
247
301
  });
248
302
  return;
249
303
  }
250
- navigator.requestMIDIAccess().then((access) => {
251
- if (destroyed) return;
304
+ navigator.requestMIDIAccess({ sysex: options.sysex ?? false }).then((granted) => {
305
+ if (destroyed || generation !== requestGeneration) return;
306
+ access?.removeEventListener("statechange", handleStateChange);
307
+ access = granted;
308
+ granted.addEventListener("statechange", handleStateChange);
252
309
  setState({
253
- midiAccess: access,
254
- error: null
310
+ midiAccess: granted,
311
+ error: null,
312
+ inputs: readInputs()
255
313
  });
256
- }).catch(() => {
257
- if (destroyed) return;
314
+ }, (reason) => {
315
+ if (destroyed || generation !== requestGeneration) return;
258
316
  setState({
259
317
  ...state,
260
- error: PERMISSION_DENIED
318
+ error: toError(reason)
261
319
  });
262
320
  });
263
321
  }
@@ -273,6 +331,9 @@ function createMIDIAccess() {
273
331
  },
274
332
  destroy: () => {
275
333
  destroyed = true;
334
+ requestGeneration += 1;
335
+ access?.removeEventListener("statechange", handleStateChange);
336
+ access = null;
276
337
  listeners.clear();
277
338
  }
278
339
  };
@@ -282,35 +343,110 @@ function createMIDIAccess() {
282
343
  /**
283
344
  * Listen to raw `midimessage` events on every input of a MIDIAccess.
284
345
  *
346
+ * The set of inputs is followed rather than sampled: MIDIAccess fires
347
+ * `statechange` when a device is plugged in or unplugged, and the listeners
348
+ * move with it. A keyboard connected after access was granted works without
349
+ * the caller having to rebuild anything.
350
+ *
285
351
  * Use this when you need more detail than {@link createMIDIInput} provides.
286
352
  */
287
353
  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
- } };
354
+ let handler = onMIDIMessage;
355
+ /** Inputs this instance currently listens on. */
356
+ const attached = /* @__PURE__ */ new Set();
357
+ const listener = (event) => handler(event);
358
+ function sync() {
359
+ if (!midiAccess) return;
360
+ const connected = new Set(midiAccess.inputs.values());
361
+ for (const input of connected) {
362
+ if (attached.has(input)) continue;
363
+ input.addEventListener("midimessage", listener);
364
+ attached.add(input);
365
+ }
366
+ for (const input of [...attached]) {
367
+ if (connected.has(input)) continue;
368
+ input.removeEventListener("midimessage", listener);
369
+ attached.delete(input);
370
+ }
371
+ }
372
+ sync();
373
+ midiAccess?.addEventListener("statechange", sync);
374
+ return {
375
+ update: (next) => {
376
+ handler = next;
377
+ },
378
+ destroy: () => {
379
+ midiAccess?.removeEventListener("statechange", sync);
380
+ for (const input of attached) input.removeEventListener("midimessage", listener);
381
+ attached.clear();
382
+ }
383
+ };
294
384
  }
295
385
  //#endregion
296
386
  //#region src/midi/input.ts
297
- const MIDI_EVENT_TO_NUMBER = {
298
- NOTE_ON: 144,
387
+ /** Status byte of each channel message, with the channel nibble cleared. */
388
+ const STATUS = {
299
389
  NOTE_OFF: 128,
390
+ NOTE_ON: 144,
391
+ AFTERTOUCH: 160,
392
+ CONTROL_CHANGE: 176,
393
+ PROGRAM_CHANGE: 192,
394
+ CHANNEL_PRESSURE: 208,
300
395
  PITCH_BEND: 224
301
396
  };
302
397
  /**
303
- * Handle note on/off and pitch bend events. To be used with {@link createMIDIAccess}.
304
- * Internally uses {@link createMIDIMessage}.
398
+ * Centre of the 14-bit pitch bend range: no bend.
399
+ *
400
+ * The range is not symmetric — 0 is 8192 below centre and 16383 is 8191 above
401
+ * — so a wheel at rest reports exactly this rather than half of the maximum.
305
402
  */
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
- });
403
+ const PITCH_BEND_CENTER = 8192;
404
+ /**
405
+ * Handle the channel voice messages of every connected input. To be used with
406
+ * {@link createMIDIAccess}. Internally uses {@link createMIDIMessage}, so
407
+ * devices plugged in later are picked up.
408
+ *
409
+ * System messages (clock, sysex, and the rest of `0xf0`-`0xff`) are not
410
+ * decoded here; reach for {@link createMIDIMessage} for those.
411
+ */
412
+ function createMIDIInput(midiAccess, handlers) {
413
+ let current = handlers;
414
+ return {
415
+ update: (next) => {
416
+ current = next;
417
+ },
418
+ destroy: createMIDIMessage(midiAccess, (event) => {
419
+ if (!event.data || event.data.length === 0) return;
420
+ const status = event.data[0];
421
+ if (status >= 240) return;
422
+ const kind = status & 240;
423
+ const channel = status & 15;
424
+ const first = event.data[1] ?? 0;
425
+ const second = event.data[2] ?? 0;
426
+ switch (kind) {
427
+ case STATUS.NOTE_OFF:
428
+ current.onNoteOffEvent?.(first, channel);
429
+ break;
430
+ case STATUS.NOTE_ON:
431
+ if (second === 0) current.onNoteOffEvent?.(first, channel);
432
+ else current.onNoteOnEvent?.(first, second, channel);
433
+ break;
434
+ case STATUS.AFTERTOUCH:
435
+ current.onAftertouchEvent?.(first, second, channel);
436
+ break;
437
+ case STATUS.CONTROL_CHANGE:
438
+ current.onControlChangeEvent?.(first, second, channel);
439
+ break;
440
+ case STATUS.PROGRAM_CHANGE:
441
+ current.onProgramChangeEvent?.(first, channel);
442
+ break;
443
+ case STATUS.CHANNEL_PRESSURE:
444
+ current.onChannelPressureEvent?.(first, channel);
445
+ break;
446
+ case STATUS.PITCH_BEND: current.onPitchBendEvent?.(second << 7 | first, channel);
447
+ }
448
+ }).destroy
449
+ };
314
450
  }
315
451
  //#endregion
316
452
  //#region src/pointer/drag.ts
@@ -327,6 +463,7 @@ const MANAGED_STYLES = [
327
463
  ["-webkit-user-select", "none"],
328
464
  ["-webkit-touch-callout", "none"]
329
465
  ];
466
+ const managedStyleStates = /* @__PURE__ */ new WeakMap();
330
467
  /**
331
468
  * Track a pointer drag on an element.
332
469
  *
@@ -343,10 +480,24 @@ function createDrag(element, options = {}) {
343
480
  const multiPointer = options.multiPointer ?? false;
344
481
  const capture = element;
345
482
  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);
483
+ let managedStyles = managedStyleStates.get(element);
484
+ if (style) {
485
+ if (managedStyles) managedStyles.count += 1;
486
+ else {
487
+ const previous = /* @__PURE__ */ new Map();
488
+ for (const [property, value] of MANAGED_STYLES) {
489
+ previous.set(property, {
490
+ value: style.getPropertyValue(property),
491
+ priority: style.getPropertyPriority(property)
492
+ });
493
+ style.setProperty(property, value);
494
+ }
495
+ managedStyles = {
496
+ count: 1,
497
+ previous
498
+ };
499
+ managedStyleStates.set(element, managedStyles);
500
+ }
350
501
  }
351
502
  const pointers = /* @__PURE__ */ new Map();
352
503
  /**
@@ -356,10 +507,14 @@ function createDrag(element, options = {}) {
356
507
  */
357
508
  const targets = /* @__PURE__ */ new Map();
358
509
  let previousCursor;
510
+ /** The pointer that asked for the lock, while it is still down. */
511
+ let lockedPointerId = null;
512
+ let lockRequest = 0;
513
+ let destroyed = false;
359
514
  function state(event, pointer, deltaX, deltaY) {
360
515
  return {
361
- x: event.screenX - pointer.startX,
362
- y: event.screenY - pointer.startY,
516
+ x: pointer.lockBaseX !== void 0 ? pointer.lockBaseX + pointer.lockMoveX : event.screenX - pointer.startX,
517
+ y: pointer.lockBaseY !== void 0 ? pointer.lockBaseY + pointer.lockMoveY : event.screenY - pointer.startY,
363
518
  deltaX,
364
519
  deltaY,
365
520
  clientX: event.clientX,
@@ -382,6 +537,7 @@ function createDrag(element, options = {}) {
382
537
  target.addEventListener("pointermove", handlePointerMove);
383
538
  target.addEventListener("pointerup", handlePointerUp);
384
539
  target.addEventListener("pointercancel", handlePointerUp);
540
+ if (target === element) target.addEventListener("lostpointercapture", handleLostPointerCapture);
385
541
  }
386
542
  targets.set(target, count + 1);
387
543
  }
@@ -394,13 +550,16 @@ function createDrag(element, options = {}) {
394
550
  target.removeEventListener("pointermove", handlePointerMove);
395
551
  target.removeEventListener("pointerup", handlePointerUp);
396
552
  target.removeEventListener("pointercancel", handlePointerUp);
553
+ if (target === element) target.removeEventListener("lostpointercapture", handleLostPointerCapture);
397
554
  targets.delete(target);
398
555
  }
399
556
  function handlePointerDown(event) {
400
557
  const pointerEvent = event;
558
+ if (pointerEvent.button !== 0) return;
401
559
  const pointerId = pointerEvent.pointerId;
402
560
  if (pointers.has(pointerId)) return;
403
561
  if (!multiPointer && pointers.size > 0) return;
562
+ if (opts.shouldStart && !opts.shouldStart(pointerEvent)) return;
404
563
  const isFirst = pointers.size === 0;
405
564
  if (isFirst && opts.cursor && style) {
406
565
  previousCursor = style.cursor;
@@ -413,39 +572,86 @@ function createDrag(element, options = {}) {
413
572
  startX: pointerEvent.screenX,
414
573
  startY: pointerEvent.screenY,
415
574
  lastX: pointerEvent.screenX,
416
- lastY: pointerEvent.screenY
575
+ lastY: pointerEvent.screenY,
576
+ lockMoveX: 0,
577
+ lockMoveY: 0,
578
+ lastEvent: pointerEvent
417
579
  };
418
580
  pointers.set(pointerId, pointer);
419
581
  retainTarget(moveTarget);
420
582
  if (isFirst) globalThis.document?.addEventListener("selectstart", preventSelectStart);
583
+ if (isFirst && opts.pointerLock) {
584
+ const requestId = ++lockRequest;
585
+ lockedPointerId = pointerId;
586
+ globalThis.document?.addEventListener("pointerlockchange", handleLockChange);
587
+ try {
588
+ (capture.requestPointerLock?.())?.then?.(() => {
589
+ if (requestId === lockRequest && (!pointers.has(pointerId) || destroyed) && globalThis.document?.pointerLockElement === element) globalThis.document.exitPointerLock?.();
590
+ }, () => {});
591
+ } catch {}
592
+ }
421
593
  opts.onDragStart?.(state(pointerEvent, pointer, 0, 0));
422
594
  }
423
595
  function handlePointerMove(event) {
424
596
  const pointerEvent = event;
425
597
  const pointer = pointers.get(pointerEvent.pointerId);
426
598
  if (!pointer) return;
427
- const deltaX = pointerEvent.screenX - pointer.lastX;
428
- const deltaY = pointerEvent.screenY - pointer.lastY;
599
+ const locked = pointer.lockBaseX !== void 0;
600
+ const deltaX = locked ? pointerEvent.movementX ?? 0 : pointerEvent.screenX - pointer.lastX;
601
+ const deltaY = locked ? pointerEvent.movementY ?? 0 : pointerEvent.screenY - pointer.lastY;
429
602
  const threshold = opts.threshold ?? 0;
430
603
  if (Math.abs(deltaX) < threshold && Math.abs(deltaY) < threshold) return;
431
604
  pointer.lastX = pointerEvent.screenX;
432
605
  pointer.lastY = pointerEvent.screenY;
606
+ pointer.lastEvent = pointerEvent;
607
+ if (locked) {
608
+ pointer.lockMoveX += deltaX;
609
+ pointer.lockMoveY += deltaY;
610
+ }
433
611
  opts.onDrag?.(state(pointerEvent, pointer, deltaX, deltaY));
434
612
  }
613
+ function handleLockChange() {
614
+ if (lockedPointerId === null) return;
615
+ const pointer = pointers.get(lockedPointerId);
616
+ if (!pointer) return;
617
+ if (globalThis.document?.pointerLockElement === element) {
618
+ pointer.lockBaseX = pointer.lastX - pointer.startX;
619
+ pointer.lockBaseY = pointer.lastY - pointer.startY;
620
+ pointer.lockMoveX = 0;
621
+ pointer.lockMoveY = 0;
622
+ return;
623
+ }
624
+ if (pointer.lockBaseX === void 0) return;
625
+ finishDrag(lockedPointerId);
626
+ }
435
627
  function handlePointerUp(event) {
436
628
  const pointerEvent = event;
437
- const pointer = pointers.get(pointerEvent.pointerId);
629
+ finishDrag(pointerEvent.pointerId, pointerEvent);
630
+ }
631
+ function handleLostPointerCapture(event) {
632
+ finishDrag(event.pointerId);
633
+ }
634
+ function finishDrag(pointerId, event) {
635
+ const pointer = pointers.get(pointerId);
438
636
  if (!pointer) return;
439
- const finalState = state(pointerEvent, pointer, pointerEvent.screenX - pointer.lastX, pointerEvent.screenY - pointer.lastY);
440
- stopTracking(pointerEvent.pointerId);
637
+ const finalState = event ? state(event, pointer, event.screenX - pointer.lastX, event.screenY - pointer.lastY) : state(pointer.lastEvent, pointer, 0, 0);
638
+ stopTracking(pointerId);
441
639
  opts.onDragEnd?.(finalState);
442
640
  }
443
641
  function stopTracking(pointerId) {
444
642
  const pointer = pointers.get(pointerId);
445
643
  if (!pointer) return;
446
- capture.releasePointerCapture?.(pointerId);
447
- releaseTarget(pointer.moveTarget);
448
644
  pointers.delete(pointerId);
645
+ releaseTarget(pointer.moveTarget);
646
+ try {
647
+ if (capture.hasPointerCapture?.(pointerId) === true) capture.releasePointerCapture?.(pointerId);
648
+ } catch {}
649
+ if (lockedPointerId === pointerId) {
650
+ lockedPointerId = null;
651
+ const document = globalThis.document;
652
+ document?.removeEventListener("pointerlockchange", handleLockChange);
653
+ if (document?.pointerLockElement === element) document.exitPointerLock?.();
654
+ }
449
655
  if (pointers.size > 0) return;
450
656
  globalThis.document?.removeEventListener("selectstart", preventSelectStart);
451
657
  if (previousCursor !== void 0 && style) {
@@ -463,18 +669,23 @@ function createDrag(element, options = {}) {
463
669
  };
464
670
  },
465
671
  destroy: () => {
466
- for (const pointerId of [...pointers.keys()]) stopTracking(pointerId);
672
+ if (destroyed) return;
673
+ destroyed = true;
467
674
  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);
675
+ for (const pointerId of [...pointers.keys()]) finishDrag(pointerId);
676
+ if (style && managedStyles && --managedStyles.count === 0) {
677
+ for (const [property] of MANAGED_STYLES) {
678
+ const previous = managedStyles.previous.get(property);
679
+ if (previous?.value) style.setProperty(property, previous.value, previous.priority);
680
+ else style.removeProperty(property);
681
+ }
682
+ managedStyleStates.delete(element);
472
683
  }
473
684
  }
474
685
  };
475
686
  }
476
687
  //#endregion
477
- //#region src/piano/input.ts
688
+ //#region src/piano/index.ts
478
689
  const DEFAULT_SOURCE = "api";
479
690
  /**
480
691
  * Drive a piano keyboard with pointers, and own what is sounding.
@@ -496,17 +707,19 @@ function createPianoInput(element, options) {
496
707
  return [...held.keys()].sort((a, b) => a - b);
497
708
  }
498
709
  function noteOn(note, { source = DEFAULT_SOURCE, velocity } = {}) {
710
+ if (!Number.isInteger(note) || note < 0 || note > 127) throw new RangeError("note: requirements: an integer from 0 to 127");
499
711
  if (note > (opts.midiMax ?? 127)) return;
500
712
  const sources = held.get(note);
501
713
  if (sources) {
502
714
  sources.add(source);
503
715
  return;
504
716
  }
505
- held.set(note, new Set([source]));
717
+ held.set(note, /* @__PURE__ */ new Set([source]));
506
718
  opts.onPlayNote?.(note, velocity);
507
719
  opts.onActiveNotesChange?.(activeNotes());
508
720
  }
509
721
  function noteOff(note, { source = DEFAULT_SOURCE } = {}) {
722
+ if (!Number.isInteger(note) || note < 0 || note > 127) throw new RangeError("note: requirements: an integer from 0 to 127");
510
723
  const sources = held.get(note);
511
724
  if (!sources) return;
512
725
  sources.delete(source);
@@ -526,7 +739,7 @@ function createPianoInput(element, options) {
526
739
  const previous = pointerNotes.get(pointerId);
527
740
  if (previous === note) return;
528
741
  if (previous !== void 0) noteOff(previous, { source });
529
- if (note === null) pointerNotes.delete(pointerId);
742
+ if (note === null || note > (opts.midiMax ?? 127)) pointerNotes.delete(pointerId);
530
743
  else {
531
744
  pointerNotes.set(pointerId, note);
532
745
  noteOn(note, { source });
@@ -547,18 +760,28 @@ function createPianoInput(element, options) {
547
760
  ...opts,
548
761
  ...next
549
762
  };
763
+ const midiMax = opts.midiMax ?? 127;
764
+ const stoppedNotes = activeNotes().filter((note) => note > midiMax);
765
+ if (stoppedNotes.length === 0) return;
766
+ for (const note of stoppedNotes) {
767
+ held.delete(note);
768
+ opts.onStopNote?.(note);
769
+ }
770
+ for (const [pointerId, note] of pointerNotes) if (note > midiMax) pointerNotes.delete(pointerId);
771
+ opts.onActiveNotesChange?.(activeNotes());
550
772
  },
551
773
  noteOn,
552
774
  noteOff,
553
775
  activeNotes,
554
776
  destroy: () => {
555
777
  drag.destroy();
556
- for (const note of activeNotes()) {
778
+ const notes = activeNotes();
779
+ for (const note of notes) {
557
780
  held.delete(note);
558
781
  opts.onStopNote?.(note);
559
782
  }
560
783
  pointerNotes.clear();
561
- opts.onActiveNotesChange?.([]);
784
+ if (notes.length > 0) opts.onActiveNotesChange?.([]);
562
785
  }
563
786
  };
564
787
  }
@@ -581,7 +804,7 @@ function toXY(value) {
581
804
  return isPair(value) ? [value[0], value[1]] : [value, value];
582
805
  }
583
806
  //#endregion
584
- //#region src/pointer/dragValue.ts
807
+ //#region src/pointer/drag-value.ts
585
808
  /**
586
809
  * Map the pointer onto the bounding rect of an element: the value *is* the
587
810
  * position pointed at, so the middle of the element is 0.5.
@@ -589,16 +812,43 @@ function toXY(value) {
589
812
  * The element is read on every event, so it may be mounted after the drag is
590
813
  * set up and may change size while a drag is in progress.
591
814
  */
592
- function elementMapping(getElement) {
815
+ function elementMapping(getElement, { sensitivity } = {}) {
816
+ /** The reported position when the sensitivity last changed. */
817
+ let origin = [0, 0];
818
+ /** The raw position at that same moment. */
819
+ let anchor = [0, 0];
820
+ /** The previous event, so a sensitivity change can be dated back to it. */
821
+ let previous = [0, 0];
822
+ let factor = 1;
593
823
  function positionIn(state) {
594
824
  const element = getElement();
595
825
  if (!element) return null;
596
826
  const { left, top, right, bottom } = element.getBoundingClientRect();
597
827
  return [right > left ? (0, _tremolo_ui_functions.normalizeValue)(state.clientX, left, right) : 0, bottom > top ? (0, _tremolo_ui_functions.normalizeValue)(state.clientY, top, bottom) : 0];
598
828
  }
829
+ const reported = (raw, at) => [origin[0] + (raw[0] - anchor[0]) * at, origin[1] + (raw[1] - anchor[1]) * at];
599
830
  return {
600
- start: positionIn,
601
- move: positionIn
831
+ start: (state) => {
832
+ const raw = positionIn(state);
833
+ if (!raw) return null;
834
+ origin = raw;
835
+ anchor = raw;
836
+ previous = raw;
837
+ factor = sensitivity ? sensitivity(state) : 1;
838
+ return raw;
839
+ },
840
+ move: (state) => {
841
+ const raw = positionIn(state);
842
+ if (!raw) return null;
843
+ const next = sensitivity ? sensitivity(state) : 1;
844
+ if (next !== factor) {
845
+ origin = reported(previous, factor);
846
+ anchor = previous;
847
+ factor = next;
848
+ }
849
+ previous = raw;
850
+ return reported(raw, factor);
851
+ }
602
852
  };
603
853
  }
604
854
  /**
@@ -606,15 +856,33 @@ function elementMapping(getElement) {
606
856
  * distance dragged. The pointer position itself carries no meaning, so the
607
857
  * value can be adjusted from anywhere on the screen.
608
858
  */
609
- function relativeMapping({ pixelRange = 100 } = {}) {
610
- const [rangeX, rangeY] = toXY(pixelRange);
859
+ function relativeMapping({ pixelRange = 100, sensitivity } = {}) {
860
+ const [baseX, baseY] = toXY(pixelRange);
611
861
  let origin = [0, 0];
862
+ /** Where the current sensitivity took over, in drag coordinates. */
863
+ let anchor = [0, 0];
864
+ /** The previous event, so a sensitivity change can be dated back to it. */
865
+ let previous = [0, 0];
866
+ let factor = 1;
867
+ const travelled = (to, at) => [baseX === 0 ? origin[0] : origin[0] + (to[0] - anchor[0]) * at / baseX, baseY === 0 ? origin[1] : origin[1] + (to[1] - anchor[1]) * at / baseY];
612
868
  return {
613
- start: (_state, context) => {
869
+ start: (state, context) => {
614
870
  origin = context.position();
871
+ anchor = [0, 0];
872
+ previous = [0, 0];
873
+ factor = sensitivity ? sensitivity(state) : 1;
615
874
  return origin;
616
875
  },
617
- move: (state) => [origin[0] + state.x / rangeX, origin[1] + state.y / rangeY]
876
+ move: (state) => {
877
+ const next = sensitivity ? sensitivity(state) : 1;
878
+ if (next !== factor) {
879
+ origin = travelled(previous, factor);
880
+ anchor = previous;
881
+ factor = next;
882
+ }
883
+ previous = [state.x, state.y];
884
+ return travelled([state.x, state.y], factor);
885
+ }
618
886
  };
619
887
  }
620
888
  /**
@@ -627,12 +895,14 @@ function relativeMapping({ pixelRange = 100 } = {}) {
627
895
  function createDragValue(element, options) {
628
896
  let opts = options;
629
897
  let lastValue = [0, 0];
898
+ let active = false;
630
899
  const axes = () => toXY(opts.axis);
631
900
  function valueOf(position) {
632
901
  return axes().map((axis, i) => {
633
902
  const p = axis.reverse ? 1 - position[i] : position[i];
634
903
  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);
904
+ const stepped = axis.step ? (0, _tremolo_ui_functions.stepValue)(value, axis.step) : value;
905
+ return (0, _tremolo_ui_functions.clamp)((0, _tremolo_ui_functions.toPrecision)(stepped), axis.min, axis.max);
636
906
  });
637
907
  }
638
908
  const context = { position: () => {
@@ -647,21 +917,35 @@ function createDragValue(element, options) {
647
917
  const drag = createDrag(element, {
648
918
  threshold: opts.threshold,
649
919
  cursor: opts.cursor,
920
+ pointerLock: opts.pointerLock,
921
+ shouldStart: (event) => opts.shouldStart?.(event) ?? true,
650
922
  onDragStart: (state) => {
651
923
  const position = opts.mapping.start(state, context);
652
- if (position) {
653
- lastValue = valueOf(position);
654
- if (opts.updateOnPointerDown) opts.onChange?.(lastValue, state);
655
- }
924
+ if (!position) return;
925
+ active = true;
926
+ lastValue = valueOf(position);
927
+ if (opts.updateOnPointerDown) opts.onChange?.(lastValue, state);
656
928
  opts.onDragStart?.(lastValue, state);
657
929
  },
658
930
  onDrag: (state) => {
931
+ if (!active) return;
659
932
  const position = opts.mapping.move(state, context);
660
933
  if (!position) return;
661
934
  lastValue = valueOf(position);
662
935
  opts.onChange?.(lastValue, state);
663
936
  },
664
- onDragEnd: (state) => opts.onDragEnd?.(lastValue, state)
937
+ onDragEnd: (state) => {
938
+ if (!active) return;
939
+ if (state.event.type === "pointerup" && (state.deltaX !== 0 || state.deltaY !== 0)) {
940
+ const position = opts.mapping.move(state, context);
941
+ if (position) {
942
+ lastValue = valueOf(position);
943
+ opts.onChange?.(lastValue, state);
944
+ }
945
+ }
946
+ active = false;
947
+ opts.onDragEnd?.(lastValue, state);
948
+ }
665
949
  });
666
950
  return {
667
951
  update: (next) => {
@@ -672,7 +956,8 @@ function createDragValue(element, options) {
672
956
  };
673
957
  drag.update({
674
958
  threshold: opts.threshold,
675
- cursor: opts.cursor
959
+ cursor: opts.cursor,
960
+ pointerLock: opts.pointerLock
676
961
  });
677
962
  },
678
963
  destroy: () => drag.destroy()
@@ -687,14 +972,17 @@ function createDragValue(element, options) {
687
972
  * `preventDefault()` to stop the page from scrolling.
688
973
  */
689
974
  function createWheel(element, onWheel, options = {}) {
690
- let opts = options;
975
+ let opts = {
976
+ ...options,
977
+ onWheel
978
+ };
691
979
  function hasFocus() {
692
980
  const active = element.ownerDocument?.activeElement;
693
981
  return !!active && element.contains(active);
694
982
  }
695
983
  const handler = (event) => {
696
984
  if (opts.requireFocus && !hasFocus()) return;
697
- onWheel(event);
985
+ opts.onWheel?.(event);
698
986
  };
699
987
  element.addEventListener("wheel", handler, { passive: false });
700
988
  return {
@@ -710,8 +998,77 @@ function createWheel(element, onWheel, options = {}) {
710
998
  };
711
999
  }
712
1000
  //#endregion
1001
+ //#region src/selection/box.ts
1002
+ function rectOf(from, to) {
1003
+ return {
1004
+ x: Math.min(from[0], to[0]),
1005
+ y: Math.min(from[1], to[1]),
1006
+ width: Math.abs(to[0] - from[0]),
1007
+ height: Math.abs(to[1] - from[1])
1008
+ };
1009
+ }
1010
+ /** Whether the box covers the point, edges included. */
1011
+ function selectionBoxCovers(box, [x, y]) {
1012
+ return x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height;
1013
+ }
1014
+ /**
1015
+ * Selecting by dragging a box over a set of items.
1016
+ *
1017
+ * The drag itself is not here: the caller owns the pointer, and reports where
1018
+ * it went in the same 0..1 space the items are given in.
1019
+ */
1020
+ function createSelectionBox(options) {
1021
+ let opts = options;
1022
+ let drag = null;
1023
+ let box = null;
1024
+ function setBox(next) {
1025
+ box = next;
1026
+ opts.onBoxChange?.(next);
1027
+ }
1028
+ function apply(rect, base) {
1029
+ const inside = [];
1030
+ for (const [id, at] of opts.items()) if (selectionBoxCovers(rect, at) && !base.includes(id)) inside.push(id);
1031
+ opts.onSelectionChange?.([...base, ...inside]);
1032
+ }
1033
+ return {
1034
+ update: (next) => {
1035
+ opts = {
1036
+ ...opts,
1037
+ ...next
1038
+ };
1039
+ },
1040
+ begin: (at, { additive = false, selection = [] } = {}) => {
1041
+ drag = {
1042
+ from: at,
1043
+ base: additive ? [...selection] : []
1044
+ };
1045
+ setBox(rectOf(at, at));
1046
+ if (!additive) opts.onSelectionChange?.([]);
1047
+ },
1048
+ move: (to) => {
1049
+ if (!drag) return;
1050
+ const rect = rectOf(drag.from, to);
1051
+ setBox(rect);
1052
+ apply(rect, drag.base);
1053
+ },
1054
+ end: () => {
1055
+ const running = drag !== null;
1056
+ drag = null;
1057
+ if (box !== null) setBox(null);
1058
+ return running;
1059
+ },
1060
+ box: () => box,
1061
+ destroy: () => {
1062
+ drag = null;
1063
+ box = null;
1064
+ }
1065
+ };
1066
+ }
1067
+ //#endregion
713
1068
  exports.NOT_SUPPORTED = NOT_SUPPORTED;
714
1069
  exports.PERMISSION_DENIED = PERMISSION_DENIED;
1070
+ exports.PITCH_BEND_CENTER = PITCH_BEND_CENTER;
1071
+ exports.UNAVAILABLE = UNAVAILABLE;
715
1072
  exports.createAnimationCanvas = createAnimationCanvas;
716
1073
  exports.createDrag = createDrag;
717
1074
  exports.createDragValue = createDragValue;
@@ -719,11 +1076,13 @@ exports.createMIDIAccess = createMIDIAccess;
719
1076
  exports.createMIDIInput = createMIDIInput;
720
1077
  exports.createMIDIMessage = createMIDIMessage;
721
1078
  exports.createPianoInput = createPianoInput;
1079
+ exports.createSelectionBox = createSelectionBox;
722
1080
  exports.createWheel = createWheel;
723
1081
  exports.drawingState = drawingState;
724
1082
  exports.elementMapping = elementMapping;
725
1083
  exports.isDrawingState = isDrawingState;
726
1084
  exports.relativeMapping = relativeMapping;
1085
+ exports.selectionBoxCovers = selectionBoxCovers;
727
1086
  exports.toXY = toXY;
728
1087
 
729
1088
  //# sourceMappingURL=index.cjs.map