@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.js CHANGED
@@ -1,9 +1,10 @@
1
- import { clamp, linearScale, normalizeValue, noteAt, stepValue } from "@tremolo-ui/functions";
1
+ import { clamp, linearScale, normalizeValue, noteAt, stepValue, toPrecision } from "@tremolo-ui/functions";
2
2
  //#region src/canvas/context.ts
3
3
  /**
4
- * The properties of a 2D context that survive `save()` / `restore()`, and so
5
- * are what has to be carried across a resize by hand: setting `canvas.width`
6
- * resets the context to its defaults.
4
+ * The assignable parts of a 2D context's drawing state. They have to be
5
+ * carried across a resize by hand because setting `canvas.width` resets the
6
+ * context to its defaults. The transform and line dash are handled separately
7
+ * by {@link DrawingContext} because they are exposed through methods.
7
8
  *
8
9
  * @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/save
9
10
  */
@@ -21,24 +22,37 @@ const drawingState = [
21
22
  "shadowBlur",
22
23
  "shadowColor",
23
24
  "globalCompositeOperation",
25
+ "filter",
24
26
  "font",
27
+ "fontKerning",
28
+ "fontStretch",
29
+ "fontVariantCaps",
25
30
  "textAlign",
26
31
  "textBaseline",
27
32
  "direction",
28
- "imageSmoothingEnabled"
33
+ "letterSpacing",
34
+ "textRendering",
35
+ "wordSpacing",
36
+ "imageSmoothingEnabled",
37
+ "imageSmoothingQuality"
29
38
  ];
30
39
  function isDrawingState(value) {
31
- return typeof value == "string" && drawingState.includes(value);
40
+ return typeof value === "string" && drawingState.includes(value);
32
41
  }
33
42
  /** Copy the drawing state off a context, to put back after a resize. */
34
43
  function readDrawingState(context) {
35
44
  const state = {};
36
45
  for (const property of drawingState) state[property] = context[property];
46
+ state.lineDash = context.getLineDash();
47
+ state.transform = context.getTransform();
37
48
  return state;
38
49
  }
39
50
  /** Put a state read by {@link readDrawingState} back onto a context. */
40
- function writeDrawingState(context, state) {
51
+ function writeDrawingState(context, state, transformScale = 1) {
41
52
  for (const property of drawingState) context[property] = state[property];
53
+ context.setLineDash(state.lineDash);
54
+ const { a, b, c, d, e, f } = state.transform;
55
+ context.setTransform(a * transformScale, b * transformScale, c * transformScale, d * transformScale, e * transformScale, f * transformScale);
42
56
  }
43
57
  /**
44
58
  * Give the canvas a backing store of `width * dpr` by `height * dpr` device
@@ -71,12 +85,13 @@ function createAnimationCanvas(canvas, options) {
71
85
  const context2d = canvas.getContext("2d", opts.contextAttributes);
72
86
  if (!context2d) throw new Error("createAnimationCanvas: cannot get a 2d context");
73
87
  const context = context2d;
74
- const relativeSize = opts.relativeSize ?? false;
88
+ const resizable = opts.resizable ?? false;
75
89
  /** Off-document canvas holding the drawing while the real one is resized. */
76
90
  let memo = null;
77
91
  let memoContext = null;
78
92
  let width = 0;
79
93
  let height = 0;
94
+ let appliedDpr = 0;
80
95
  /** Whether a size has been applied, so a frame can be drawn. */
81
96
  let sized = false;
82
97
  let initialized = false;
@@ -94,17 +109,16 @@ function createAnimationCanvas(canvas, options) {
94
109
  * the copy neither scales nor resamples.
95
110
  */
96
111
  function takeSnapshot() {
97
- if (!(opts.reduceFlickering ?? true)) return null;
112
+ if (!(opts.reduceFlickering ?? true)) return false;
98
113
  if (!memo) {
99
114
  memo = globalThis.document?.createElement("canvas") ?? null;
100
115
  memoContext = memo?.getContext("2d", opts.contextAttributes) ?? null;
101
116
  }
102
- if (!memo || !memoContext) return null;
103
- const state = readDrawingState(context);
117
+ if (!memo || !memoContext) return false;
104
118
  memo.width = canvas.width;
105
119
  memo.height = canvas.height;
106
120
  if (canvas.width > 0 && canvas.height > 0) memoContext.drawImage(canvas, 0, 0);
107
- return state;
121
+ return true;
108
122
  }
109
123
  /**
110
124
  * Put the snapshot back, given the size in CSS pixels it was taken at.
@@ -114,25 +128,32 @@ function createAnimationCanvas(canvas, options) {
114
128
  * stays put that is a 1:1 copy of device pixels, and when it changes the
115
129
  * snapshot is rescaled once, from its full resolution.
116
130
  */
117
- function restoreSnapshot(state, previousWidth, previousHeight) {
118
- if (!state || !memo || !memoContext) return;
131
+ function restoreSnapshot(hasSnapshot, previousWidth, previousHeight) {
132
+ if (!hasSnapshot || !memo) return;
119
133
  if (memo.width <= 0 || memo.height <= 0) return;
120
- writeDrawingState(context, state);
121
134
  context.drawImage(memo, 0, 0, previousWidth, previousHeight);
122
135
  }
123
136
  function applySize(w, h) {
124
137
  const dpr = devicePixelRatio();
125
138
  const previousWidth = width;
126
139
  const previousHeight = height;
127
- const state = takeSnapshot();
140
+ const previousDpr = appliedDpr;
141
+ const state = sized ? readDrawingState(context) : null;
142
+ const hasSnapshot = sized && takeSnapshot();
128
143
  applyDevicePixelRatio(canvas, context, w, h, dpr);
129
144
  width = w;
130
145
  height = h;
146
+ appliedDpr = dpr;
131
147
  sized = true;
132
- restoreSnapshot(state, previousWidth, previousHeight);
148
+ restoreSnapshot(hasSnapshot, previousWidth, previousHeight);
149
+ if (state) writeDrawingState(context, state, dpr / previousDpr);
133
150
  }
134
- function drawFrame() {
151
+ function applyDevicePixelRatioIfNeeded() {
152
+ if (sized && devicePixelRatio() !== appliedDpr) applySize(width, height);
153
+ }
154
+ function drawFrame(timestamp = performance.now()) {
135
155
  if (!sized) return;
156
+ applyDevicePixelRatioIfNeeded();
136
157
  if (!initialized) {
137
158
  initialized = true;
138
159
  opts.init?.(context, {
@@ -140,41 +161,40 @@ function createAnimationCanvas(canvas, options) {
140
161
  height
141
162
  });
142
163
  }
143
- const now = performance.now();
144
- const deltaTime = now - previousTime;
145
- previousTime = now;
164
+ const deltaTime = timestamp - previousTime;
165
+ previousTime = timestamp;
146
166
  count += 1;
147
167
  opts.draw(context, {
148
168
  width,
149
169
  height,
150
170
  count,
151
171
  deltaTime,
152
- elapsedTime: now - startTime,
153
- fps: 1e3 / deltaTime
172
+ elapsedTime: timestamp - startTime,
173
+ fps: deltaTime > 0 ? 1e3 / deltaTime : 0
154
174
  });
155
175
  }
156
- function tick() {
176
+ function tick(timestamp) {
157
177
  frameId = requestAnimationFrame(tick);
158
- drawFrame();
178
+ drawFrame(timestamp);
159
179
  }
160
180
  function startLoop() {
161
- if (frameId != null) return;
181
+ if (frameId !== null) return;
162
182
  previousTime = performance.now();
163
183
  frameId = requestAnimationFrame(tick);
164
184
  }
165
185
  function stopLoop() {
166
- if (frameId == null) return;
186
+ if (frameId === null) return;
167
187
  cancelAnimationFrame(frameId);
168
188
  frameId = null;
169
189
  }
170
190
  let observer = null;
171
- if (relativeSize) {
191
+ if (resizable) {
172
192
  const parent = canvas.parentElement;
173
- if (!parent) throw new Error("createAnimationCanvas: relativeSize needs the canvas to have a parent element");
193
+ if (!parent) throw new Error("createAnimationCanvas: resizable needs the canvas to have a parent element");
174
194
  observer = new ResizeObserver((entries) => {
175
195
  for (const entry of entries) {
176
196
  const { width: w, height: h } = entry.contentRect;
177
- applySize(w, h);
197
+ if (w !== width || h !== height || devicePixelRatio() !== appliedDpr) applySize(w, h);
178
198
  if (!(opts.animate ?? true)) drawFrame();
179
199
  }
180
200
  });
@@ -191,13 +211,14 @@ function createAnimationCanvas(canvas, options) {
191
211
  opts = {
192
212
  ...opts,
193
213
  ...next,
194
- relativeSize,
214
+ resizable,
195
215
  contextAttributes: opts.contextAttributes
196
216
  };
197
- if (!relativeSize) {
217
+ if (!resizable) {
198
218
  const { width: w = 100, height: h = 100 } = opts.size ?? {};
199
- if (w != width || h != height) applySize(w, h);
219
+ if (w !== width || h !== height) applySize(w, h);
200
220
  }
221
+ applyDevicePixelRatioIfNeeded();
201
222
  const animating = opts.animate ?? true;
202
223
  if (animating && !wasAnimating) startLoop();
203
224
  if (!animating && wasAnimating) stopLoop();
@@ -219,11 +240,26 @@ function createAnimationCanvas(canvas, options) {
219
240
  const PERMISSION_DENIED = "PERMISSION_DENIED";
220
241
  /** @private */
221
242
  const NOT_SUPPORTED = "NOT_SUPPORTED";
243
+ /** @private */
244
+ const UNAVAILABLE = "UNAVAILABLE";
222
245
  const INITIAL_STATE = {
223
246
  midiAccess: null,
224
- error: null
247
+ error: null,
248
+ inputs: []
225
249
  };
226
250
  /**
251
+ * Which of our errors a rejected `requestMIDIAccess()` amounts to.
252
+ *
253
+ * The spec names the reasons, and they need telling apart: a user who said no
254
+ * can say yes on a second ask, while a browser without the API never will.
255
+ */
256
+ function toError(reason) {
257
+ const name = typeof reason === "object" && reason !== null && "name" in reason ? String(reason.name) : "";
258
+ if (name === "SecurityError" || name === "NotAllowedError") return PERMISSION_DENIED;
259
+ if (name === "NotSupportedError" || name === "TypeError") return NOT_SUPPORTED;
260
+ return UNAVAILABLE;
261
+ }
262
+ /**
227
263
  * Request MIDI access in the browser.
228
264
  *
229
265
  * The returned instance holds the state and notifies subscribers when it changes,
@@ -232,13 +268,31 @@ const INITIAL_STATE = {
232
268
  function createMIDIAccess() {
233
269
  let state = INITIAL_STATE;
234
270
  let destroyed = false;
271
+ let access = null;
272
+ let requestGeneration = 0;
235
273
  const listeners = /* @__PURE__ */ new Set();
236
274
  function setState(next) {
237
275
  state = next;
238
276
  for (const listener of listeners) listener();
239
277
  }
240
- function request() {
278
+ /**
279
+ * A new array every time, so that `useSyncExternalStore` and its equivalents
280
+ * see the change. The identity of the state object is what they compare.
281
+ */
282
+ function readInputs() {
283
+ return access ? [...access.inputs.values()] : [];
284
+ }
285
+ function handleStateChange(event) {
286
+ if (destroyed || !access) return;
287
+ if (event && event.port?.type === "output") return;
288
+ setState({
289
+ ...state,
290
+ inputs: readInputs()
291
+ });
292
+ }
293
+ function request(options = {}) {
241
294
  if (destroyed) return;
295
+ const generation = ++requestGeneration;
242
296
  if (typeof navigator === "undefined" || !navigator.requestMIDIAccess) {
243
297
  setState({
244
298
  ...state,
@@ -246,17 +300,21 @@ function createMIDIAccess() {
246
300
  });
247
301
  return;
248
302
  }
249
- navigator.requestMIDIAccess().then((access) => {
250
- if (destroyed) return;
303
+ navigator.requestMIDIAccess({ sysex: options.sysex ?? false }).then((granted) => {
304
+ if (destroyed || generation !== requestGeneration) return;
305
+ access?.removeEventListener("statechange", handleStateChange);
306
+ access = granted;
307
+ granted.addEventListener("statechange", handleStateChange);
251
308
  setState({
252
- midiAccess: access,
253
- error: null
309
+ midiAccess: granted,
310
+ error: null,
311
+ inputs: readInputs()
254
312
  });
255
- }).catch(() => {
256
- if (destroyed) return;
313
+ }, (reason) => {
314
+ if (destroyed || generation !== requestGeneration) return;
257
315
  setState({
258
316
  ...state,
259
- error: PERMISSION_DENIED
317
+ error: toError(reason)
260
318
  });
261
319
  });
262
320
  }
@@ -272,6 +330,9 @@ function createMIDIAccess() {
272
330
  },
273
331
  destroy: () => {
274
332
  destroyed = true;
333
+ requestGeneration += 1;
334
+ access?.removeEventListener("statechange", handleStateChange);
335
+ access = null;
275
336
  listeners.clear();
276
337
  }
277
338
  };
@@ -281,35 +342,110 @@ function createMIDIAccess() {
281
342
  /**
282
343
  * Listen to raw `midimessage` events on every input of a MIDIAccess.
283
344
  *
345
+ * The set of inputs is followed rather than sampled: MIDIAccess fires
346
+ * `statechange` when a device is plugged in or unplugged, and the listeners
347
+ * move with it. A keyboard connected after access was granted works without
348
+ * the caller having to rebuild anything.
349
+ *
284
350
  * Use this when you need more detail than {@link createMIDIInput} provides.
285
351
  */
286
352
  function createMIDIMessage(midiAccess, onMIDIMessage) {
287
- if (!midiAccess) return { destroy: () => {} };
288
- const inputs = [...midiAccess.inputs.values()];
289
- for (const input of inputs) input.addEventListener("midimessage", onMIDIMessage);
290
- return { destroy: () => {
291
- for (const input of inputs) input.removeEventListener("midimessage", onMIDIMessage);
292
- } };
353
+ let handler = onMIDIMessage;
354
+ /** Inputs this instance currently listens on. */
355
+ const attached = /* @__PURE__ */ new Set();
356
+ const listener = (event) => handler(event);
357
+ function sync() {
358
+ if (!midiAccess) return;
359
+ const connected = new Set(midiAccess.inputs.values());
360
+ for (const input of connected) {
361
+ if (attached.has(input)) continue;
362
+ input.addEventListener("midimessage", listener);
363
+ attached.add(input);
364
+ }
365
+ for (const input of [...attached]) {
366
+ if (connected.has(input)) continue;
367
+ input.removeEventListener("midimessage", listener);
368
+ attached.delete(input);
369
+ }
370
+ }
371
+ sync();
372
+ midiAccess?.addEventListener("statechange", sync);
373
+ return {
374
+ update: (next) => {
375
+ handler = next;
376
+ },
377
+ destroy: () => {
378
+ midiAccess?.removeEventListener("statechange", sync);
379
+ for (const input of attached) input.removeEventListener("midimessage", listener);
380
+ attached.clear();
381
+ }
382
+ };
293
383
  }
294
384
  //#endregion
295
385
  //#region src/midi/input.ts
296
- const MIDI_EVENT_TO_NUMBER = {
297
- NOTE_ON: 144,
386
+ /** Status byte of each channel message, with the channel nibble cleared. */
387
+ const STATUS = {
298
388
  NOTE_OFF: 128,
389
+ NOTE_ON: 144,
390
+ AFTERTOUCH: 160,
391
+ CONTROL_CHANGE: 176,
392
+ PROGRAM_CHANGE: 192,
393
+ CHANNEL_PRESSURE: 208,
299
394
  PITCH_BEND: 224
300
395
  };
301
396
  /**
302
- * Handle note on/off and pitch bend events. To be used with {@link createMIDIAccess}.
303
- * Internally uses {@link createMIDIMessage}.
397
+ * Centre of the 14-bit pitch bend range: no bend.
398
+ *
399
+ * The range is not symmetric — 0 is 8192 below centre and 16383 is 8191 above
400
+ * — so a wheel at rest reports exactly this rather than half of the maximum.
304
401
  */
305
- function createMIDIInput(midiAccess, { onNoteOnEvent, onNoteOffEvent, onPitchBendEvent }) {
306
- return createMIDIMessage(midiAccess, (event) => {
307
- if (!event.data) return;
308
- const kind = event.data[0] & 240;
309
- if (kind == MIDI_EVENT_TO_NUMBER.NOTE_OFF || kind == MIDI_EVENT_TO_NUMBER.NOTE_ON && event.data[2] == 0) onNoteOffEvent?.(event.data[1]);
310
- else if (kind == MIDI_EVENT_TO_NUMBER.NOTE_ON) onNoteOnEvent?.(event.data[1], event.data[2]);
311
- else if (kind == MIDI_EVENT_TO_NUMBER.PITCH_BEND) onPitchBendEvent?.(event.data[1], event.data[2]);
312
- });
402
+ const PITCH_BEND_CENTER = 8192;
403
+ /**
404
+ * Handle the channel voice messages of every connected input. To be used with
405
+ * {@link createMIDIAccess}. Internally uses {@link createMIDIMessage}, so
406
+ * devices plugged in later are picked up.
407
+ *
408
+ * System messages (clock, sysex, and the rest of `0xf0`-`0xff`) are not
409
+ * decoded here; reach for {@link createMIDIMessage} for those.
410
+ */
411
+ function createMIDIInput(midiAccess, handlers) {
412
+ let current = handlers;
413
+ return {
414
+ update: (next) => {
415
+ current = next;
416
+ },
417
+ destroy: createMIDIMessage(midiAccess, (event) => {
418
+ if (!event.data || event.data.length === 0) return;
419
+ const status = event.data[0];
420
+ if (status >= 240) return;
421
+ const kind = status & 240;
422
+ const channel = status & 15;
423
+ const first = event.data[1] ?? 0;
424
+ const second = event.data[2] ?? 0;
425
+ switch (kind) {
426
+ case STATUS.NOTE_OFF:
427
+ current.onNoteOffEvent?.(first, channel);
428
+ break;
429
+ case STATUS.NOTE_ON:
430
+ if (second === 0) current.onNoteOffEvent?.(first, channel);
431
+ else current.onNoteOnEvent?.(first, second, channel);
432
+ break;
433
+ case STATUS.AFTERTOUCH:
434
+ current.onAftertouchEvent?.(first, second, channel);
435
+ break;
436
+ case STATUS.CONTROL_CHANGE:
437
+ current.onControlChangeEvent?.(first, second, channel);
438
+ break;
439
+ case STATUS.PROGRAM_CHANGE:
440
+ current.onProgramChangeEvent?.(first, channel);
441
+ break;
442
+ case STATUS.CHANNEL_PRESSURE:
443
+ current.onChannelPressureEvent?.(first, channel);
444
+ break;
445
+ case STATUS.PITCH_BEND: current.onPitchBendEvent?.(second << 7 | first, channel);
446
+ }
447
+ }).destroy
448
+ };
313
449
  }
314
450
  //#endregion
315
451
  //#region src/pointer/drag.ts
@@ -326,6 +462,7 @@ const MANAGED_STYLES = [
326
462
  ["-webkit-user-select", "none"],
327
463
  ["-webkit-touch-callout", "none"]
328
464
  ];
465
+ const managedStyleStates = /* @__PURE__ */ new WeakMap();
329
466
  /**
330
467
  * Track a pointer drag on an element.
331
468
  *
@@ -342,10 +479,24 @@ function createDrag(element, options = {}) {
342
479
  const multiPointer = options.multiPointer ?? false;
343
480
  const capture = element;
344
481
  const style = element.style;
345
- const previousStyles = /* @__PURE__ */ new Map();
346
- if (style) for (const [property, value] of MANAGED_STYLES) {
347
- previousStyles.set(property, style.getPropertyValue(property));
348
- style.setProperty(property, value);
482
+ let managedStyles = managedStyleStates.get(element);
483
+ if (style) {
484
+ if (managedStyles) managedStyles.count += 1;
485
+ else {
486
+ const previous = /* @__PURE__ */ new Map();
487
+ for (const [property, value] of MANAGED_STYLES) {
488
+ previous.set(property, {
489
+ value: style.getPropertyValue(property),
490
+ priority: style.getPropertyPriority(property)
491
+ });
492
+ style.setProperty(property, value);
493
+ }
494
+ managedStyles = {
495
+ count: 1,
496
+ previous
497
+ };
498
+ managedStyleStates.set(element, managedStyles);
499
+ }
349
500
  }
350
501
  const pointers = /* @__PURE__ */ new Map();
351
502
  /**
@@ -355,10 +506,14 @@ function createDrag(element, options = {}) {
355
506
  */
356
507
  const targets = /* @__PURE__ */ new Map();
357
508
  let previousCursor;
509
+ /** The pointer that asked for the lock, while it is still down. */
510
+ let lockedPointerId = null;
511
+ let lockRequest = 0;
512
+ let destroyed = false;
358
513
  function state(event, pointer, deltaX, deltaY) {
359
514
  return {
360
- x: event.screenX - pointer.startX,
361
- y: event.screenY - pointer.startY,
515
+ x: pointer.lockBaseX !== void 0 ? pointer.lockBaseX + pointer.lockMoveX : event.screenX - pointer.startX,
516
+ y: pointer.lockBaseY !== void 0 ? pointer.lockBaseY + pointer.lockMoveY : event.screenY - pointer.startY,
362
517
  deltaX,
363
518
  deltaY,
364
519
  clientX: event.clientX,
@@ -381,6 +536,7 @@ function createDrag(element, options = {}) {
381
536
  target.addEventListener("pointermove", handlePointerMove);
382
537
  target.addEventListener("pointerup", handlePointerUp);
383
538
  target.addEventListener("pointercancel", handlePointerUp);
539
+ if (target === element) target.addEventListener("lostpointercapture", handleLostPointerCapture);
384
540
  }
385
541
  targets.set(target, count + 1);
386
542
  }
@@ -393,13 +549,16 @@ function createDrag(element, options = {}) {
393
549
  target.removeEventListener("pointermove", handlePointerMove);
394
550
  target.removeEventListener("pointerup", handlePointerUp);
395
551
  target.removeEventListener("pointercancel", handlePointerUp);
552
+ if (target === element) target.removeEventListener("lostpointercapture", handleLostPointerCapture);
396
553
  targets.delete(target);
397
554
  }
398
555
  function handlePointerDown(event) {
399
556
  const pointerEvent = event;
557
+ if (pointerEvent.button !== 0) return;
400
558
  const pointerId = pointerEvent.pointerId;
401
559
  if (pointers.has(pointerId)) return;
402
560
  if (!multiPointer && pointers.size > 0) return;
561
+ if (opts.shouldStart && !opts.shouldStart(pointerEvent)) return;
403
562
  const isFirst = pointers.size === 0;
404
563
  if (isFirst && opts.cursor && style) {
405
564
  previousCursor = style.cursor;
@@ -412,39 +571,86 @@ function createDrag(element, options = {}) {
412
571
  startX: pointerEvent.screenX,
413
572
  startY: pointerEvent.screenY,
414
573
  lastX: pointerEvent.screenX,
415
- lastY: pointerEvent.screenY
574
+ lastY: pointerEvent.screenY,
575
+ lockMoveX: 0,
576
+ lockMoveY: 0,
577
+ lastEvent: pointerEvent
416
578
  };
417
579
  pointers.set(pointerId, pointer);
418
580
  retainTarget(moveTarget);
419
581
  if (isFirst) globalThis.document?.addEventListener("selectstart", preventSelectStart);
582
+ if (isFirst && opts.pointerLock) {
583
+ const requestId = ++lockRequest;
584
+ lockedPointerId = pointerId;
585
+ globalThis.document?.addEventListener("pointerlockchange", handleLockChange);
586
+ try {
587
+ (capture.requestPointerLock?.())?.then?.(() => {
588
+ if (requestId === lockRequest && (!pointers.has(pointerId) || destroyed) && globalThis.document?.pointerLockElement === element) globalThis.document.exitPointerLock?.();
589
+ }, () => {});
590
+ } catch {}
591
+ }
420
592
  opts.onDragStart?.(state(pointerEvent, pointer, 0, 0));
421
593
  }
422
594
  function handlePointerMove(event) {
423
595
  const pointerEvent = event;
424
596
  const pointer = pointers.get(pointerEvent.pointerId);
425
597
  if (!pointer) return;
426
- const deltaX = pointerEvent.screenX - pointer.lastX;
427
- const deltaY = pointerEvent.screenY - pointer.lastY;
598
+ const locked = pointer.lockBaseX !== void 0;
599
+ const deltaX = locked ? pointerEvent.movementX ?? 0 : pointerEvent.screenX - pointer.lastX;
600
+ const deltaY = locked ? pointerEvent.movementY ?? 0 : pointerEvent.screenY - pointer.lastY;
428
601
  const threshold = opts.threshold ?? 0;
429
602
  if (Math.abs(deltaX) < threshold && Math.abs(deltaY) < threshold) return;
430
603
  pointer.lastX = pointerEvent.screenX;
431
604
  pointer.lastY = pointerEvent.screenY;
605
+ pointer.lastEvent = pointerEvent;
606
+ if (locked) {
607
+ pointer.lockMoveX += deltaX;
608
+ pointer.lockMoveY += deltaY;
609
+ }
432
610
  opts.onDrag?.(state(pointerEvent, pointer, deltaX, deltaY));
433
611
  }
612
+ function handleLockChange() {
613
+ if (lockedPointerId === null) return;
614
+ const pointer = pointers.get(lockedPointerId);
615
+ if (!pointer) return;
616
+ if (globalThis.document?.pointerLockElement === element) {
617
+ pointer.lockBaseX = pointer.lastX - pointer.startX;
618
+ pointer.lockBaseY = pointer.lastY - pointer.startY;
619
+ pointer.lockMoveX = 0;
620
+ pointer.lockMoveY = 0;
621
+ return;
622
+ }
623
+ if (pointer.lockBaseX === void 0) return;
624
+ finishDrag(lockedPointerId);
625
+ }
434
626
  function handlePointerUp(event) {
435
627
  const pointerEvent = event;
436
- const pointer = pointers.get(pointerEvent.pointerId);
628
+ finishDrag(pointerEvent.pointerId, pointerEvent);
629
+ }
630
+ function handleLostPointerCapture(event) {
631
+ finishDrag(event.pointerId);
632
+ }
633
+ function finishDrag(pointerId, event) {
634
+ const pointer = pointers.get(pointerId);
437
635
  if (!pointer) return;
438
- const finalState = state(pointerEvent, pointer, pointerEvent.screenX - pointer.lastX, pointerEvent.screenY - pointer.lastY);
439
- stopTracking(pointerEvent.pointerId);
636
+ const finalState = event ? state(event, pointer, event.screenX - pointer.lastX, event.screenY - pointer.lastY) : state(pointer.lastEvent, pointer, 0, 0);
637
+ stopTracking(pointerId);
440
638
  opts.onDragEnd?.(finalState);
441
639
  }
442
640
  function stopTracking(pointerId) {
443
641
  const pointer = pointers.get(pointerId);
444
642
  if (!pointer) return;
445
- capture.releasePointerCapture?.(pointerId);
446
- releaseTarget(pointer.moveTarget);
447
643
  pointers.delete(pointerId);
644
+ releaseTarget(pointer.moveTarget);
645
+ try {
646
+ if (capture.hasPointerCapture?.(pointerId) === true) capture.releasePointerCapture?.(pointerId);
647
+ } catch {}
648
+ if (lockedPointerId === pointerId) {
649
+ lockedPointerId = null;
650
+ const document = globalThis.document;
651
+ document?.removeEventListener("pointerlockchange", handleLockChange);
652
+ if (document?.pointerLockElement === element) document.exitPointerLock?.();
653
+ }
448
654
  if (pointers.size > 0) return;
449
655
  globalThis.document?.removeEventListener("selectstart", preventSelectStart);
450
656
  if (previousCursor !== void 0 && style) {
@@ -462,18 +668,23 @@ function createDrag(element, options = {}) {
462
668
  };
463
669
  },
464
670
  destroy: () => {
465
- for (const pointerId of [...pointers.keys()]) stopTracking(pointerId);
671
+ if (destroyed) return;
672
+ destroyed = true;
466
673
  element.removeEventListener("pointerdown", handlePointerDown);
467
- if (style) for (const [property] of MANAGED_STYLES) {
468
- const previous = previousStyles.get(property);
469
- if (previous) style.setProperty(property, previous);
470
- else style.removeProperty(property);
674
+ for (const pointerId of [...pointers.keys()]) finishDrag(pointerId);
675
+ if (style && managedStyles && --managedStyles.count === 0) {
676
+ for (const [property] of MANAGED_STYLES) {
677
+ const previous = managedStyles.previous.get(property);
678
+ if (previous?.value) style.setProperty(property, previous.value, previous.priority);
679
+ else style.removeProperty(property);
680
+ }
681
+ managedStyleStates.delete(element);
471
682
  }
472
683
  }
473
684
  };
474
685
  }
475
686
  //#endregion
476
- //#region src/piano/input.ts
687
+ //#region src/piano/index.ts
477
688
  const DEFAULT_SOURCE = "api";
478
689
  /**
479
690
  * Drive a piano keyboard with pointers, and own what is sounding.
@@ -495,17 +706,19 @@ function createPianoInput(element, options) {
495
706
  return [...held.keys()].sort((a, b) => a - b);
496
707
  }
497
708
  function noteOn(note, { source = DEFAULT_SOURCE, velocity } = {}) {
709
+ if (!Number.isInteger(note) || note < 0 || note > 127) throw new RangeError("note: requirements: an integer from 0 to 127");
498
710
  if (note > (opts.midiMax ?? 127)) return;
499
711
  const sources = held.get(note);
500
712
  if (sources) {
501
713
  sources.add(source);
502
714
  return;
503
715
  }
504
- held.set(note, new Set([source]));
716
+ held.set(note, /* @__PURE__ */ new Set([source]));
505
717
  opts.onPlayNote?.(note, velocity);
506
718
  opts.onActiveNotesChange?.(activeNotes());
507
719
  }
508
720
  function noteOff(note, { source = DEFAULT_SOURCE } = {}) {
721
+ if (!Number.isInteger(note) || note < 0 || note > 127) throw new RangeError("note: requirements: an integer from 0 to 127");
509
722
  const sources = held.get(note);
510
723
  if (!sources) return;
511
724
  sources.delete(source);
@@ -525,7 +738,7 @@ function createPianoInput(element, options) {
525
738
  const previous = pointerNotes.get(pointerId);
526
739
  if (previous === note) return;
527
740
  if (previous !== void 0) noteOff(previous, { source });
528
- if (note === null) pointerNotes.delete(pointerId);
741
+ if (note === null || note > (opts.midiMax ?? 127)) pointerNotes.delete(pointerId);
529
742
  else {
530
743
  pointerNotes.set(pointerId, note);
531
744
  noteOn(note, { source });
@@ -546,18 +759,28 @@ function createPianoInput(element, options) {
546
759
  ...opts,
547
760
  ...next
548
761
  };
762
+ const midiMax = opts.midiMax ?? 127;
763
+ const stoppedNotes = activeNotes().filter((note) => note > midiMax);
764
+ if (stoppedNotes.length === 0) return;
765
+ for (const note of stoppedNotes) {
766
+ held.delete(note);
767
+ opts.onStopNote?.(note);
768
+ }
769
+ for (const [pointerId, note] of pointerNotes) if (note > midiMax) pointerNotes.delete(pointerId);
770
+ opts.onActiveNotesChange?.(activeNotes());
549
771
  },
550
772
  noteOn,
551
773
  noteOff,
552
774
  activeNotes,
553
775
  destroy: () => {
554
776
  drag.destroy();
555
- for (const note of activeNotes()) {
777
+ const notes = activeNotes();
778
+ for (const note of notes) {
556
779
  held.delete(note);
557
780
  opts.onStopNote?.(note);
558
781
  }
559
782
  pointerNotes.clear();
560
- opts.onActiveNotesChange?.([]);
783
+ if (notes.length > 0) opts.onActiveNotesChange?.([]);
561
784
  }
562
785
  };
563
786
  }
@@ -580,7 +803,7 @@ function toXY(value) {
580
803
  return isPair(value) ? [value[0], value[1]] : [value, value];
581
804
  }
582
805
  //#endregion
583
- //#region src/pointer/dragValue.ts
806
+ //#region src/pointer/drag-value.ts
584
807
  /**
585
808
  * Map the pointer onto the bounding rect of an element: the value *is* the
586
809
  * position pointed at, so the middle of the element is 0.5.
@@ -588,16 +811,43 @@ function toXY(value) {
588
811
  * The element is read on every event, so it may be mounted after the drag is
589
812
  * set up and may change size while a drag is in progress.
590
813
  */
591
- function elementMapping(getElement) {
814
+ function elementMapping(getElement, { sensitivity } = {}) {
815
+ /** The reported position when the sensitivity last changed. */
816
+ let origin = [0, 0];
817
+ /** The raw position at that same moment. */
818
+ let anchor = [0, 0];
819
+ /** The previous event, so a sensitivity change can be dated back to it. */
820
+ let previous = [0, 0];
821
+ let factor = 1;
592
822
  function positionIn(state) {
593
823
  const element = getElement();
594
824
  if (!element) return null;
595
825
  const { left, top, right, bottom } = element.getBoundingClientRect();
596
826
  return [right > left ? normalizeValue(state.clientX, left, right) : 0, bottom > top ? normalizeValue(state.clientY, top, bottom) : 0];
597
827
  }
828
+ const reported = (raw, at) => [origin[0] + (raw[0] - anchor[0]) * at, origin[1] + (raw[1] - anchor[1]) * at];
598
829
  return {
599
- start: positionIn,
600
- move: positionIn
830
+ start: (state) => {
831
+ const raw = positionIn(state);
832
+ if (!raw) return null;
833
+ origin = raw;
834
+ anchor = raw;
835
+ previous = raw;
836
+ factor = sensitivity ? sensitivity(state) : 1;
837
+ return raw;
838
+ },
839
+ move: (state) => {
840
+ const raw = positionIn(state);
841
+ if (!raw) return null;
842
+ const next = sensitivity ? sensitivity(state) : 1;
843
+ if (next !== factor) {
844
+ origin = reported(previous, factor);
845
+ anchor = previous;
846
+ factor = next;
847
+ }
848
+ previous = raw;
849
+ return reported(raw, factor);
850
+ }
601
851
  };
602
852
  }
603
853
  /**
@@ -605,15 +855,33 @@ function elementMapping(getElement) {
605
855
  * distance dragged. The pointer position itself carries no meaning, so the
606
856
  * value can be adjusted from anywhere on the screen.
607
857
  */
608
- function relativeMapping({ pixelRange = 100 } = {}) {
609
- const [rangeX, rangeY] = toXY(pixelRange);
858
+ function relativeMapping({ pixelRange = 100, sensitivity } = {}) {
859
+ const [baseX, baseY] = toXY(pixelRange);
610
860
  let origin = [0, 0];
861
+ /** Where the current sensitivity took over, in drag coordinates. */
862
+ let anchor = [0, 0];
863
+ /** The previous event, so a sensitivity change can be dated back to it. */
864
+ let previous = [0, 0];
865
+ let factor = 1;
866
+ const travelled = (to, at) => [baseX === 0 ? origin[0] : origin[0] + (to[0] - anchor[0]) * at / baseX, baseY === 0 ? origin[1] : origin[1] + (to[1] - anchor[1]) * at / baseY];
611
867
  return {
612
- start: (_state, context) => {
868
+ start: (state, context) => {
613
869
  origin = context.position();
870
+ anchor = [0, 0];
871
+ previous = [0, 0];
872
+ factor = sensitivity ? sensitivity(state) : 1;
614
873
  return origin;
615
874
  },
616
- move: (state) => [origin[0] + state.x / rangeX, origin[1] + state.y / rangeY]
875
+ move: (state) => {
876
+ const next = sensitivity ? sensitivity(state) : 1;
877
+ if (next !== factor) {
878
+ origin = travelled(previous, factor);
879
+ anchor = previous;
880
+ factor = next;
881
+ }
882
+ previous = [state.x, state.y];
883
+ return travelled([state.x, state.y], factor);
884
+ }
617
885
  };
618
886
  }
619
887
  /**
@@ -626,12 +894,14 @@ function relativeMapping({ pixelRange = 100 } = {}) {
626
894
  function createDragValue(element, options) {
627
895
  let opts = options;
628
896
  let lastValue = [0, 0];
897
+ let active = false;
629
898
  const axes = () => toXY(opts.axis);
630
899
  function valueOf(position) {
631
900
  return axes().map((axis, i) => {
632
901
  const p = axis.reverse ? 1 - position[i] : position[i];
633
902
  const value = (axis.scale ?? linearScale).denormalize(p, axis.min, axis.max);
634
- return clamp(axis.step ? stepValue(value, axis.step) : value, axis.min, axis.max);
903
+ const stepped = axis.step ? stepValue(value, axis.step) : value;
904
+ return clamp(toPrecision(stepped), axis.min, axis.max);
635
905
  });
636
906
  }
637
907
  const context = { position: () => {
@@ -646,21 +916,35 @@ function createDragValue(element, options) {
646
916
  const drag = createDrag(element, {
647
917
  threshold: opts.threshold,
648
918
  cursor: opts.cursor,
919
+ pointerLock: opts.pointerLock,
920
+ shouldStart: (event) => opts.shouldStart?.(event) ?? true,
649
921
  onDragStart: (state) => {
650
922
  const position = opts.mapping.start(state, context);
651
- if (position) {
652
- lastValue = valueOf(position);
653
- if (opts.updateOnPointerDown) opts.onChange?.(lastValue, state);
654
- }
923
+ if (!position) return;
924
+ active = true;
925
+ lastValue = valueOf(position);
926
+ if (opts.updateOnPointerDown) opts.onChange?.(lastValue, state);
655
927
  opts.onDragStart?.(lastValue, state);
656
928
  },
657
929
  onDrag: (state) => {
930
+ if (!active) return;
658
931
  const position = opts.mapping.move(state, context);
659
932
  if (!position) return;
660
933
  lastValue = valueOf(position);
661
934
  opts.onChange?.(lastValue, state);
662
935
  },
663
- onDragEnd: (state) => opts.onDragEnd?.(lastValue, state)
936
+ onDragEnd: (state) => {
937
+ if (!active) return;
938
+ if (state.event.type === "pointerup" && (state.deltaX !== 0 || state.deltaY !== 0)) {
939
+ const position = opts.mapping.move(state, context);
940
+ if (position) {
941
+ lastValue = valueOf(position);
942
+ opts.onChange?.(lastValue, state);
943
+ }
944
+ }
945
+ active = false;
946
+ opts.onDragEnd?.(lastValue, state);
947
+ }
664
948
  });
665
949
  return {
666
950
  update: (next) => {
@@ -671,7 +955,8 @@ function createDragValue(element, options) {
671
955
  };
672
956
  drag.update({
673
957
  threshold: opts.threshold,
674
- cursor: opts.cursor
958
+ cursor: opts.cursor,
959
+ pointerLock: opts.pointerLock
675
960
  });
676
961
  },
677
962
  destroy: () => drag.destroy()
@@ -686,14 +971,17 @@ function createDragValue(element, options) {
686
971
  * `preventDefault()` to stop the page from scrolling.
687
972
  */
688
973
  function createWheel(element, onWheel, options = {}) {
689
- let opts = options;
974
+ let opts = {
975
+ ...options,
976
+ onWheel
977
+ };
690
978
  function hasFocus() {
691
979
  const active = element.ownerDocument?.activeElement;
692
980
  return !!active && element.contains(active);
693
981
  }
694
982
  const handler = (event) => {
695
983
  if (opts.requireFocus && !hasFocus()) return;
696
- onWheel(event);
984
+ opts.onWheel?.(event);
697
985
  };
698
986
  element.addEventListener("wheel", handler, { passive: false });
699
987
  return {
@@ -709,6 +997,73 @@ function createWheel(element, onWheel, options = {}) {
709
997
  };
710
998
  }
711
999
  //#endregion
712
- export { NOT_SUPPORTED, PERMISSION_DENIED, createAnimationCanvas, createDrag, createDragValue, createMIDIAccess, createMIDIInput, createMIDIMessage, createPianoInput, createWheel, drawingState, elementMapping, isDrawingState, relativeMapping, toXY };
1000
+ //#region src/selection/box.ts
1001
+ function rectOf(from, to) {
1002
+ return {
1003
+ x: Math.min(from[0], to[0]),
1004
+ y: Math.min(from[1], to[1]),
1005
+ width: Math.abs(to[0] - from[0]),
1006
+ height: Math.abs(to[1] - from[1])
1007
+ };
1008
+ }
1009
+ /** Whether the box covers the point, edges included. */
1010
+ function selectionBoxCovers(box, [x, y]) {
1011
+ return x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height;
1012
+ }
1013
+ /**
1014
+ * Selecting by dragging a box over a set of items.
1015
+ *
1016
+ * The drag itself is not here: the caller owns the pointer, and reports where
1017
+ * it went in the same 0..1 space the items are given in.
1018
+ */
1019
+ function createSelectionBox(options) {
1020
+ let opts = options;
1021
+ let drag = null;
1022
+ let box = null;
1023
+ function setBox(next) {
1024
+ box = next;
1025
+ opts.onBoxChange?.(next);
1026
+ }
1027
+ function apply(rect, base) {
1028
+ const inside = [];
1029
+ for (const [id, at] of opts.items()) if (selectionBoxCovers(rect, at) && !base.includes(id)) inside.push(id);
1030
+ opts.onSelectionChange?.([...base, ...inside]);
1031
+ }
1032
+ return {
1033
+ update: (next) => {
1034
+ opts = {
1035
+ ...opts,
1036
+ ...next
1037
+ };
1038
+ },
1039
+ begin: (at, { additive = false, selection = [] } = {}) => {
1040
+ drag = {
1041
+ from: at,
1042
+ base: additive ? [...selection] : []
1043
+ };
1044
+ setBox(rectOf(at, at));
1045
+ if (!additive) opts.onSelectionChange?.([]);
1046
+ },
1047
+ move: (to) => {
1048
+ if (!drag) return;
1049
+ const rect = rectOf(drag.from, to);
1050
+ setBox(rect);
1051
+ apply(rect, drag.base);
1052
+ },
1053
+ end: () => {
1054
+ const running = drag !== null;
1055
+ drag = null;
1056
+ if (box !== null) setBox(null);
1057
+ return running;
1058
+ },
1059
+ box: () => box,
1060
+ destroy: () => {
1061
+ drag = null;
1062
+ box = null;
1063
+ }
1064
+ };
1065
+ }
1066
+ //#endregion
1067
+ export { NOT_SUPPORTED, PERMISSION_DENIED, PITCH_BEND_CENTER, UNAVAILABLE, createAnimationCanvas, createDrag, createDragValue, createMIDIAccess, createMIDIInput, createMIDIMessage, createPianoInput, createSelectionBox, createWheel, drawingState, elementMapping, isDrawingState, relativeMapping, selectionBoxCovers, toXY };
713
1068
 
714
1069
  //# sourceMappingURL=index.js.map