@tremolo-ui/dom 0.4.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,13 +1,265 @@
1
+ import { clamp, linearScale, normalizeValue, noteAt, stepValue, toPrecision } from "@tremolo-ui/functions";
2
+ //#region src/canvas/context.ts
3
+ /**
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.
8
+ *
9
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/save
10
+ */
11
+ const drawingState = [
12
+ "strokeStyle",
13
+ "fillStyle",
14
+ "globalAlpha",
15
+ "lineWidth",
16
+ "lineCap",
17
+ "lineJoin",
18
+ "miterLimit",
19
+ "lineDashOffset",
20
+ "shadowOffsetX",
21
+ "shadowOffsetY",
22
+ "shadowBlur",
23
+ "shadowColor",
24
+ "globalCompositeOperation",
25
+ "filter",
26
+ "font",
27
+ "fontKerning",
28
+ "fontStretch",
29
+ "fontVariantCaps",
30
+ "textAlign",
31
+ "textBaseline",
32
+ "direction",
33
+ "letterSpacing",
34
+ "textRendering",
35
+ "wordSpacing",
36
+ "imageSmoothingEnabled",
37
+ "imageSmoothingQuality"
38
+ ];
39
+ function isDrawingState(value) {
40
+ return typeof value === "string" && drawingState.includes(value);
41
+ }
42
+ /** Copy the drawing state off a context, to put back after a resize. */
43
+ function readDrawingState(context) {
44
+ const state = {};
45
+ for (const property of drawingState) state[property] = context[property];
46
+ state.lineDash = context.getLineDash();
47
+ state.transform = context.getTransform();
48
+ return state;
49
+ }
50
+ /** Put a state read by {@link readDrawingState} back onto a context. */
51
+ function writeDrawingState(context, state, transformScale = 1) {
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);
56
+ }
57
+ /**
58
+ * Give the canvas a backing store of `width * dpr` by `height * dpr` device
59
+ * pixels while keeping it `width` by `height` in CSS pixels, and scale the
60
+ * context so that drawing code can work in CSS pixels throughout.
61
+ *
62
+ * Assigning to `canvas.width` clears the canvas and resets the context, so
63
+ * this both resizes and re-establishes the transform.
64
+ */
65
+ function applyDevicePixelRatio(canvas, context, width, height, dpr) {
66
+ canvas.width = width * dpr;
67
+ canvas.height = height * dpr;
68
+ context.setTransform(1, 0, 0, 1, 0, 0);
69
+ context.scale(dpr, dpr);
70
+ canvas.style.width = `${width}px`;
71
+ canvas.style.height = `${height}px`;
72
+ }
73
+ //#endregion
74
+ //#region src/canvas/animation.ts
75
+ /**
76
+ * Drive a canvas from `requestAnimationFrame`, keeping its backing store in
77
+ * step with the device pixel ratio and, optionally, with the size of its
78
+ * parent.
79
+ *
80
+ * Drawing code works in CSS pixels: the context is scaled by the device pixel
81
+ * ratio, and `width` / `height` of each {@link AnimationFrame} are CSS pixels.
82
+ */
83
+ function createAnimationCanvas(canvas, options) {
84
+ let opts = options;
85
+ const context2d = canvas.getContext("2d", opts.contextAttributes);
86
+ if (!context2d) throw new Error("createAnimationCanvas: cannot get a 2d context");
87
+ const context = context2d;
88
+ const resizable = opts.resizable ?? false;
89
+ /** Off-document canvas holding the drawing while the real one is resized. */
90
+ let memo = null;
91
+ let memoContext = null;
92
+ let width = 0;
93
+ let height = 0;
94
+ let appliedDpr = 0;
95
+ /** Whether a size has been applied, so a frame can be drawn. */
96
+ let sized = false;
97
+ let initialized = false;
98
+ let frameId = null;
99
+ let count = -1;
100
+ const startTime = performance.now();
101
+ let previousTime = startTime;
102
+ function devicePixelRatio() {
103
+ return globalThis.devicePixelRatio || 1;
104
+ }
105
+ /**
106
+ * Copy the canvas onto the memo canvas at its full device resolution, 1:1.
107
+ *
108
+ * Assigning to `memo.width` resets the memo's transform to the identity, so
109
+ * the copy neither scales nor resamples.
110
+ */
111
+ function takeSnapshot() {
112
+ if (!(opts.reduceFlickering ?? true)) return false;
113
+ if (!memo) {
114
+ memo = globalThis.document?.createElement("canvas") ?? null;
115
+ memoContext = memo?.getContext("2d", opts.contextAttributes) ?? null;
116
+ }
117
+ if (!memo || !memoContext) return false;
118
+ memo.width = canvas.width;
119
+ memo.height = canvas.height;
120
+ if (canvas.width > 0 && canvas.height > 0) memoContext.drawImage(canvas, 0, 0);
121
+ return true;
122
+ }
123
+ /**
124
+ * Put the snapshot back, given the size in CSS pixels it was taken at.
125
+ *
126
+ * The context is scaled to CSS pixels, so drawing the snapshot at its old
127
+ * CSS size leaves it exactly where it was. While the device pixel ratio
128
+ * stays put that is a 1:1 copy of device pixels, and when it changes the
129
+ * snapshot is rescaled once, from its full resolution.
130
+ */
131
+ function restoreSnapshot(hasSnapshot, previousWidth, previousHeight) {
132
+ if (!hasSnapshot || !memo) return;
133
+ if (memo.width <= 0 || memo.height <= 0) return;
134
+ context.drawImage(memo, 0, 0, previousWidth, previousHeight);
135
+ }
136
+ function applySize(w, h) {
137
+ const dpr = devicePixelRatio();
138
+ const previousWidth = width;
139
+ const previousHeight = height;
140
+ const previousDpr = appliedDpr;
141
+ const state = sized ? readDrawingState(context) : null;
142
+ const hasSnapshot = sized && takeSnapshot();
143
+ applyDevicePixelRatio(canvas, context, w, h, dpr);
144
+ width = w;
145
+ height = h;
146
+ appliedDpr = dpr;
147
+ sized = true;
148
+ restoreSnapshot(hasSnapshot, previousWidth, previousHeight);
149
+ if (state) writeDrawingState(context, state, dpr / previousDpr);
150
+ }
151
+ function applyDevicePixelRatioIfNeeded() {
152
+ if (sized && devicePixelRatio() !== appliedDpr) applySize(width, height);
153
+ }
154
+ function drawFrame(timestamp = performance.now()) {
155
+ if (!sized) return;
156
+ applyDevicePixelRatioIfNeeded();
157
+ if (!initialized) {
158
+ initialized = true;
159
+ opts.init?.(context, {
160
+ width,
161
+ height
162
+ });
163
+ }
164
+ const deltaTime = timestamp - previousTime;
165
+ previousTime = timestamp;
166
+ count += 1;
167
+ opts.draw(context, {
168
+ width,
169
+ height,
170
+ count,
171
+ deltaTime,
172
+ elapsedTime: timestamp - startTime,
173
+ fps: deltaTime > 0 ? 1e3 / deltaTime : 0
174
+ });
175
+ }
176
+ function tick(timestamp) {
177
+ frameId = requestAnimationFrame(tick);
178
+ drawFrame(timestamp);
179
+ }
180
+ function startLoop() {
181
+ if (frameId !== null) return;
182
+ previousTime = performance.now();
183
+ frameId = requestAnimationFrame(tick);
184
+ }
185
+ function stopLoop() {
186
+ if (frameId === null) return;
187
+ cancelAnimationFrame(frameId);
188
+ frameId = null;
189
+ }
190
+ let observer = null;
191
+ if (resizable) {
192
+ const parent = canvas.parentElement;
193
+ if (!parent) throw new Error("createAnimationCanvas: resizable needs the canvas to have a parent element");
194
+ observer = new ResizeObserver((entries) => {
195
+ for (const entry of entries) {
196
+ const { width: w, height: h } = entry.contentRect;
197
+ if (w !== width || h !== height || devicePixelRatio() !== appliedDpr) applySize(w, h);
198
+ if (!(opts.animate ?? true)) drawFrame();
199
+ }
200
+ });
201
+ observer.observe(parent);
202
+ } else {
203
+ const { width: w = 100, height: h = 100 } = opts.size ?? {};
204
+ applySize(w, h);
205
+ }
206
+ if (opts.animate ?? true) startLoop();
207
+ else if (sized) drawFrame();
208
+ return {
209
+ update: (next) => {
210
+ const wasAnimating = opts.animate ?? true;
211
+ opts = {
212
+ ...opts,
213
+ ...next,
214
+ resizable,
215
+ contextAttributes: opts.contextAttributes
216
+ };
217
+ if (!resizable) {
218
+ const { width: w = 100, height: h = 100 } = opts.size ?? {};
219
+ if (w !== width || h !== height) applySize(w, h);
220
+ }
221
+ applyDevicePixelRatioIfNeeded();
222
+ const animating = opts.animate ?? true;
223
+ if (animating && !wasAnimating) startLoop();
224
+ if (!animating && wasAnimating) stopLoop();
225
+ if (!animating) drawFrame();
226
+ },
227
+ redraw: drawFrame,
228
+ destroy: () => {
229
+ stopLoop();
230
+ observer?.disconnect();
231
+ observer = null;
232
+ memo = null;
233
+ memoContext = null;
234
+ }
235
+ };
236
+ }
237
+ //#endregion
1
238
  //#region src/midi/access.ts
2
239
  /** @private */
3
240
  const PERMISSION_DENIED = "PERMISSION_DENIED";
4
241
  /** @private */
5
242
  const NOT_SUPPORTED = "NOT_SUPPORTED";
243
+ /** @private */
244
+ const UNAVAILABLE = "UNAVAILABLE";
6
245
  const INITIAL_STATE = {
7
246
  midiAccess: null,
8
- error: null
247
+ error: null,
248
+ inputs: []
9
249
  };
10
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
+ /**
11
263
  * Request MIDI access in the browser.
12
264
  *
13
265
  * The returned instance holds the state and notifies subscribers when it changes,
@@ -16,13 +268,31 @@ const INITIAL_STATE = {
16
268
  function createMIDIAccess() {
17
269
  let state = INITIAL_STATE;
18
270
  let destroyed = false;
271
+ let access = null;
272
+ let requestGeneration = 0;
19
273
  const listeners = /* @__PURE__ */ new Set();
20
274
  function setState(next) {
21
275
  state = next;
22
276
  for (const listener of listeners) listener();
23
277
  }
24
- 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 = {}) {
25
294
  if (destroyed) return;
295
+ const generation = ++requestGeneration;
26
296
  if (typeof navigator === "undefined" || !navigator.requestMIDIAccess) {
27
297
  setState({
28
298
  ...state,
@@ -30,17 +300,21 @@ function createMIDIAccess() {
30
300
  });
31
301
  return;
32
302
  }
33
- navigator.requestMIDIAccess().then((access) => {
34
- 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);
35
308
  setState({
36
- midiAccess: access,
37
- error: null
309
+ midiAccess: granted,
310
+ error: null,
311
+ inputs: readInputs()
38
312
  });
39
- }).catch(() => {
40
- if (destroyed) return;
313
+ }, (reason) => {
314
+ if (destroyed || generation !== requestGeneration) return;
41
315
  setState({
42
316
  ...state,
43
- error: PERMISSION_DENIED
317
+ error: toError(reason)
44
318
  });
45
319
  });
46
320
  }
@@ -56,6 +330,9 @@ function createMIDIAccess() {
56
330
  },
57
331
  destroy: () => {
58
332
  destroyed = true;
333
+ requestGeneration += 1;
334
+ access?.removeEventListener("statechange", handleStateChange);
335
+ access = null;
59
336
  listeners.clear();
60
337
  }
61
338
  };
@@ -65,35 +342,110 @@ function createMIDIAccess() {
65
342
  /**
66
343
  * Listen to raw `midimessage` events on every input of a MIDIAccess.
67
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
+ *
68
350
  * Use this when you need more detail than {@link createMIDIInput} provides.
69
351
  */
70
352
  function createMIDIMessage(midiAccess, onMIDIMessage) {
71
- if (!midiAccess) return { destroy: () => {} };
72
- const inputs = [...midiAccess.inputs.values()];
73
- for (const input of inputs) input.addEventListener("midimessage", onMIDIMessage);
74
- return { destroy: () => {
75
- for (const input of inputs) input.removeEventListener("midimessage", onMIDIMessage);
76
- } };
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
+ };
77
383
  }
78
384
  //#endregion
79
385
  //#region src/midi/input.ts
80
- const MIDI_EVENT_TO_NUMBER = {
81
- NOTE_ON: 144,
386
+ /** Status byte of each channel message, with the channel nibble cleared. */
387
+ const STATUS = {
82
388
  NOTE_OFF: 128,
389
+ NOTE_ON: 144,
390
+ AFTERTOUCH: 160,
391
+ CONTROL_CHANGE: 176,
392
+ PROGRAM_CHANGE: 192,
393
+ CHANNEL_PRESSURE: 208,
83
394
  PITCH_BEND: 224
84
395
  };
85
396
  /**
86
- * Handle note on/off and pitch bend events. To be used with {@link createMIDIAccess}.
87
- * 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.
88
401
  */
89
- function createMIDIInput(midiAccess, { onNoteOnEvent, onNoteOffEvent, onPitchBendEvent }) {
90
- return createMIDIMessage(midiAccess, (event) => {
91
- if (!event.data) return;
92
- const kind = event.data[0] & 240;
93
- if (kind == MIDI_EVENT_TO_NUMBER.NOTE_OFF || kind == MIDI_EVENT_TO_NUMBER.NOTE_ON && event.data[2] == 0) onNoteOffEvent?.(event.data[1]);
94
- else if (kind == MIDI_EVENT_TO_NUMBER.NOTE_ON) onNoteOnEvent?.(event.data[1], event.data[2]);
95
- else if (kind == MIDI_EVENT_TO_NUMBER.PITCH_BEND) onPitchBendEvent?.(event.data[1], event.data[2]);
96
- });
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
+ };
97
449
  }
98
450
  //#endregion
99
451
  //#region src/pointer/drag.ts
@@ -110,6 +462,7 @@ const MANAGED_STYLES = [
110
462
  ["-webkit-user-select", "none"],
111
463
  ["-webkit-touch-callout", "none"]
112
464
  ];
465
+ const managedStyleStates = /* @__PURE__ */ new WeakMap();
113
466
  /**
114
467
  * Track a pointer drag on an element.
115
468
  *
@@ -118,32 +471,54 @@ const MANAGED_STYLES = [
118
471
  * element gets `touch-action: none` so that touch dragging does not scroll the
119
472
  * page, plus `user-select: none` so that a long press does not start a text
120
473
  * selection instead.
474
+ *
475
+ * One pointer at a time by default; see {@link DragOptions.multiPointer}.
121
476
  */
122
- function createDrag(element, { threshold: _threshold = 1, cursor, onDragStart, onDrag, onDragEnd } = {}) {
123
- const threshold = Math.max(_threshold, 1);
477
+ function createDrag(element, options = {}) {
478
+ let opts = options;
479
+ const multiPointer = options.multiPointer ?? false;
124
480
  const capture = element;
125
481
  const style = element.style;
126
- const previousStyles = /* @__PURE__ */ new Map();
127
- if (style) for (const [property, value] of MANAGED_STYLES) {
128
- previousStyles.set(property, style.getPropertyValue(property));
129
- style.setProperty(property, value);
130
- }
131
- let pointerId = null;
132
- /** Where the listeners for the current drag live. */
133
- let moveTarget = null;
134
- let startX = 0;
135
- let startY = 0;
136
- let lastX = 0;
137
- let lastY = 0;
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
+ }
500
+ }
501
+ const pointers = /* @__PURE__ */ new Map();
502
+ /**
503
+ * How many pointers each target carries. Adding the same listener twice is a
504
+ * no-op and removing it once removes it for good, so a target is subscribed
505
+ * to while at least one pointer is on it and no longer.
506
+ */
507
+ const targets = /* @__PURE__ */ new Map();
138
508
  let previousCursor;
139
- function state(event, deltaX, deltaY) {
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;
513
+ function state(event, pointer, deltaX, deltaY) {
140
514
  return {
141
- x: event.screenX - startX,
142
- y: event.screenY - 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,
143
517
  deltaX,
144
518
  deltaY,
145
519
  clientX: event.clientX,
146
520
  clientY: event.clientY,
521
+ pointerId: event.pointerId,
147
522
  event
148
523
  };
149
524
  }
@@ -155,65 +530,437 @@ function createDrag(element, { threshold: _threshold = 1, cursor, onDragStart, o
155
530
  function preventSelectStart(event) {
156
531
  event.preventDefault();
157
532
  }
533
+ function retainTarget(target) {
534
+ const count = targets.get(target) ?? 0;
535
+ if (count === 0) {
536
+ target.addEventListener("pointermove", handlePointerMove);
537
+ target.addEventListener("pointerup", handlePointerUp);
538
+ target.addEventListener("pointercancel", handlePointerUp);
539
+ if (target === element) target.addEventListener("lostpointercapture", handleLostPointerCapture);
540
+ }
541
+ targets.set(target, count + 1);
542
+ }
543
+ function releaseTarget(target) {
544
+ const count = targets.get(target) ?? 0;
545
+ if (count > 1) {
546
+ targets.set(target, count - 1);
547
+ return;
548
+ }
549
+ target.removeEventListener("pointermove", handlePointerMove);
550
+ target.removeEventListener("pointerup", handlePointerUp);
551
+ target.removeEventListener("pointercancel", handlePointerUp);
552
+ if (target === element) target.removeEventListener("lostpointercapture", handleLostPointerCapture);
553
+ targets.delete(target);
554
+ }
158
555
  function handlePointerDown(event) {
159
556
  const pointerEvent = event;
160
- if (pointerId !== null) return;
161
- pointerId = pointerEvent.pointerId;
162
- startX = lastX = pointerEvent.screenX;
163
- startY = lastY = pointerEvent.screenY;
164
- if (cursor && style) {
557
+ if (pointerEvent.button !== 0) return;
558
+ const pointerId = pointerEvent.pointerId;
559
+ if (pointers.has(pointerId)) return;
560
+ if (!multiPointer && pointers.size > 0) return;
561
+ if (opts.shouldStart && !opts.shouldStart(pointerEvent)) return;
562
+ const isFirst = pointers.size === 0;
563
+ if (isFirst && opts.cursor && style) {
165
564
  previousCursor = style.cursor;
166
- style.cursor = cursor;
565
+ style.cursor = opts.cursor;
167
566
  }
168
567
  capture.setPointerCapture?.(pointerId);
169
- moveTarget = capture.hasPointerCapture?.(pointerId) === true ? element : globalThis.window ?? element;
170
- moveTarget.addEventListener("pointermove", handlePointerMove);
171
- moveTarget.addEventListener("pointerup", handlePointerUp);
172
- moveTarget.addEventListener("pointercancel", handlePointerUp);
173
- globalThis.document?.addEventListener("selectstart", preventSelectStart);
174
- onDragStart?.(state(pointerEvent, 0, 0));
568
+ const moveTarget = capture.hasPointerCapture?.(pointerId) === true ? element : globalThis.window ?? element;
569
+ const pointer = {
570
+ moveTarget,
571
+ startX: pointerEvent.screenX,
572
+ startY: pointerEvent.screenY,
573
+ lastX: pointerEvent.screenX,
574
+ lastY: pointerEvent.screenY,
575
+ lockMoveX: 0,
576
+ lockMoveY: 0,
577
+ lastEvent: pointerEvent
578
+ };
579
+ pointers.set(pointerId, pointer);
580
+ retainTarget(moveTarget);
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
+ }
592
+ opts.onDragStart?.(state(pointerEvent, pointer, 0, 0));
175
593
  }
176
594
  function handlePointerMove(event) {
177
595
  const pointerEvent = event;
178
- if (pointerEvent.pointerId !== pointerId) return;
179
- const deltaX = pointerEvent.screenX - lastX;
180
- const deltaY = pointerEvent.screenY - lastY;
181
- lastX = pointerEvent.screenX;
182
- lastY = pointerEvent.screenY;
596
+ const pointer = pointers.get(pointerEvent.pointerId);
597
+ if (!pointer) return;
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;
601
+ const threshold = opts.threshold ?? 0;
183
602
  if (Math.abs(deltaX) < threshold && Math.abs(deltaY) < threshold) return;
184
- onDrag?.(state(pointerEvent, deltaX, deltaY));
603
+ pointer.lastX = pointerEvent.screenX;
604
+ pointer.lastY = pointerEvent.screenY;
605
+ pointer.lastEvent = pointerEvent;
606
+ if (locked) {
607
+ pointer.lockMoveX += deltaX;
608
+ pointer.lockMoveY += deltaY;
609
+ }
610
+ opts.onDrag?.(state(pointerEvent, pointer, deltaX, deltaY));
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);
185
625
  }
186
626
  function handlePointerUp(event) {
187
627
  const pointerEvent = event;
188
- if (pointerEvent.pointerId !== pointerId) return;
189
- const finalState = state(pointerEvent, pointerEvent.screenX - lastX, pointerEvent.screenY - lastY);
190
- stopTracking();
191
- onDragEnd?.(finalState);
192
- }
193
- function stopTracking() {
194
- if (pointerId === null) return;
195
- capture.releasePointerCapture?.(pointerId);
196
- moveTarget?.removeEventListener("pointermove", handlePointerMove);
197
- moveTarget?.removeEventListener("pointerup", handlePointerUp);
198
- moveTarget?.removeEventListener("pointercancel", handlePointerUp);
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);
635
+ if (!pointer) return;
636
+ const finalState = event ? state(event, pointer, event.screenX - pointer.lastX, event.screenY - pointer.lastY) : state(pointer.lastEvent, pointer, 0, 0);
637
+ stopTracking(pointerId);
638
+ opts.onDragEnd?.(finalState);
639
+ }
640
+ function stopTracking(pointerId) {
641
+ const pointer = pointers.get(pointerId);
642
+ if (!pointer) return;
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
+ }
654
+ if (pointers.size > 0) return;
199
655
  globalThis.document?.removeEventListener("selectstart", preventSelectStart);
200
- if (cursor && style) {
201
- style.cursor = previousCursor ?? "";
656
+ if (previousCursor !== void 0 && style) {
657
+ style.cursor = previousCursor;
202
658
  previousCursor = void 0;
203
659
  }
204
- moveTarget = null;
205
- pointerId = null;
206
660
  }
207
661
  element.addEventListener("pointerdown", handlePointerDown);
208
- return { destroy: () => {
209
- stopTracking();
210
- element.removeEventListener("pointerdown", handlePointerDown);
211
- if (style) for (const [property] of MANAGED_STYLES) {
212
- const previous = previousStyles.get(property);
213
- if (previous) style.setProperty(property, previous);
214
- else style.removeProperty(property);
662
+ return {
663
+ update: (next) => {
664
+ opts = {
665
+ ...opts,
666
+ ...next,
667
+ multiPointer
668
+ };
669
+ },
670
+ destroy: () => {
671
+ if (destroyed) return;
672
+ destroyed = true;
673
+ element.removeEventListener("pointerdown", handlePointerDown);
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);
682
+ }
683
+ }
684
+ };
685
+ }
686
+ //#endregion
687
+ //#region src/piano/index.ts
688
+ const DEFAULT_SOURCE = "api";
689
+ /**
690
+ * Drive a piano keyboard with pointers, and own what is sounding.
691
+ *
692
+ * Every way of playing a note goes through the one instance — pointers here,
693
+ * keyboard shortcuts and MIDI through {@link PianoInputInstance.noteOn} — so
694
+ * there is a single answer to what is currently held, and drawing the keys is
695
+ * left entirely to the wrapper.
696
+ *
697
+ * Tracks every pointer at once, so a chord can be played with several fingers.
698
+ */
699
+ function createPianoInput(element, options) {
700
+ let opts = options;
701
+ /** Which sources are holding each sounding note. */
702
+ const held = /* @__PURE__ */ new Map();
703
+ /** The note each pointer is currently on. */
704
+ const pointerNotes = /* @__PURE__ */ new Map();
705
+ function activeNotes() {
706
+ return [...held.keys()].sort((a, b) => a - b);
707
+ }
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");
710
+ if (note > (opts.midiMax ?? 127)) return;
711
+ const sources = held.get(note);
712
+ if (sources) {
713
+ sources.add(source);
714
+ return;
715
+ }
716
+ held.set(note, /* @__PURE__ */ new Set([source]));
717
+ opts.onPlayNote?.(note, velocity);
718
+ opts.onActiveNotesChange?.(activeNotes());
719
+ }
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");
722
+ const sources = held.get(note);
723
+ if (!sources) return;
724
+ sources.delete(source);
725
+ if (sources.size > 0) return;
726
+ held.delete(note);
727
+ opts.onStopNote?.(note);
728
+ opts.onActiveNotesChange?.(activeNotes());
729
+ }
730
+ /** The note under a pointer, or null where the pointer is off the keys. */
731
+ function noteUnder(clientX, clientY) {
732
+ const { left, top, height } = element.getBoundingClientRect();
733
+ return noteAt(clientX - left, clientY - top, height, opts.layout);
734
+ }
735
+ /** Move a pointer onto a note, releasing whatever it held before. */
736
+ function movePointer(pointerId, note) {
737
+ const source = `pointer:${pointerId}`;
738
+ const previous = pointerNotes.get(pointerId);
739
+ if (previous === note) return;
740
+ if (previous !== void 0) noteOff(previous, { source });
741
+ if (note === null || note > (opts.midiMax ?? 127)) pointerNotes.delete(pointerId);
742
+ else {
743
+ pointerNotes.set(pointerId, note);
744
+ noteOn(note, { source });
745
+ }
746
+ }
747
+ const drag = createDrag(element, {
748
+ multiPointer: true,
749
+ onDragStart: (state) => movePointer(state.pointerId, noteUnder(state.clientX, state.clientY)),
750
+ onDrag: (state) => {
751
+ if (opts.glissando === false) return;
752
+ movePointer(state.pointerId, noteUnder(state.clientX, state.clientY));
753
+ },
754
+ onDragEnd: (state) => movePointer(state.pointerId, null)
755
+ });
756
+ return {
757
+ update: (next) => {
758
+ opts = {
759
+ ...opts,
760
+ ...next
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());
771
+ },
772
+ noteOn,
773
+ noteOff,
774
+ activeNotes,
775
+ destroy: () => {
776
+ drag.destroy();
777
+ const notes = activeNotes();
778
+ for (const note of notes) {
779
+ held.delete(note);
780
+ opts.onStopNote?.(note);
781
+ }
782
+ pointerNotes.clear();
783
+ if (notes.length > 0) opts.onActiveNotesChange?.([]);
784
+ }
785
+ };
786
+ }
787
+ //#endregion
788
+ //#region src/xy.ts
789
+ function isPair(value) {
790
+ return Array.isArray(value);
791
+ }
792
+ /**
793
+ * Spread a setting that may have been given as a single value.
794
+ *
795
+ * The parameter is written out rather than taken as `XYInput<T>`: a
796
+ * conditional type cannot be narrowed, so the constraint stays where it is
797
+ * declared and this takes both forms.
798
+ *
799
+ * The pair is copied rather than passed along, so that the result is a tuple
800
+ * the caller owns even when a readonly one was given.
801
+ */
802
+ function toXY(value) {
803
+ return isPair(value) ? [value[0], value[1]] : [value, value];
804
+ }
805
+ //#endregion
806
+ //#region src/pointer/drag-value.ts
807
+ /**
808
+ * Map the pointer onto the bounding rect of an element: the value *is* the
809
+ * position pointed at, so the middle of the element is 0.5.
810
+ *
811
+ * The element is read on every event, so it may be mounted after the drag is
812
+ * set up and may change size while a drag is in progress.
813
+ */
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;
822
+ function positionIn(state) {
823
+ const element = getElement();
824
+ if (!element) return null;
825
+ const { left, top, right, bottom } = element.getBoundingClientRect();
826
+ return [right > left ? normalizeValue(state.clientX, left, right) : 0, bottom > top ? normalizeValue(state.clientY, top, bottom) : 0];
827
+ }
828
+ const reported = (raw, at) => [origin[0] + (raw[0] - anchor[0]) * at, origin[1] + (raw[1] - anchor[1]) * at];
829
+ return {
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
+ }
851
+ };
852
+ }
853
+ /**
854
+ * Move the value away from where it stood when the drag started, by the
855
+ * distance dragged. The pointer position itself carries no meaning, so the
856
+ * value can be adjusted from anywhere on the screen.
857
+ */
858
+ function relativeMapping({ pixelRange = 100, sensitivity } = {}) {
859
+ const [baseX, baseY] = toXY(pixelRange);
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];
867
+ return {
868
+ start: (state, context) => {
869
+ origin = context.position();
870
+ anchor = [0, 0];
871
+ previous = [0, 0];
872
+ factor = sensitivity ? sensitivity(state) : 1;
873
+ return origin;
874
+ },
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);
215
884
  }
885
+ };
886
+ }
887
+ /**
888
+ * Drive a value with a pointer drag.
889
+ *
890
+ * Combines {@link createDrag} with the scaling of `@tremolo-ui/functions`: the
891
+ * mapping decides where the pointer sits on the 0-1 travel of each axis, and
892
+ * the axis options turn that into a value.
893
+ */
894
+ function createDragValue(element, options) {
895
+ let opts = options;
896
+ let lastValue = [0, 0];
897
+ let active = false;
898
+ const axes = () => toXY(opts.axis);
899
+ function valueOf(position) {
900
+ return axes().map((axis, i) => {
901
+ const p = axis.reverse ? 1 - position[i] : position[i];
902
+ const value = (axis.scale ?? linearScale).denormalize(p, axis.min, axis.max);
903
+ const stepped = axis.step ? stepValue(value, axis.step) : value;
904
+ return clamp(toPrecision(stepped), axis.min, axis.max);
905
+ });
906
+ }
907
+ const context = { position: () => {
908
+ const getValue = opts.getValue;
909
+ if (!getValue) throw new Error("createDragValue: getValue is required by the given mapping");
910
+ const value = getValue();
911
+ return axes().map((axis, i) => {
912
+ const n = (axis.scale ?? linearScale).normalize(value[i], axis.min, axis.max);
913
+ return axis.reverse ? 1 - n : n;
914
+ });
216
915
  } };
916
+ const drag = createDrag(element, {
917
+ threshold: opts.threshold,
918
+ cursor: opts.cursor,
919
+ pointerLock: opts.pointerLock,
920
+ shouldStart: (event) => opts.shouldStart?.(event) ?? true,
921
+ onDragStart: (state) => {
922
+ const position = opts.mapping.start(state, context);
923
+ if (!position) return;
924
+ active = true;
925
+ lastValue = valueOf(position);
926
+ if (opts.updateOnPointerDown) opts.onChange?.(lastValue, state);
927
+ opts.onDragStart?.(lastValue, state);
928
+ },
929
+ onDrag: (state) => {
930
+ if (!active) return;
931
+ const position = opts.mapping.move(state, context);
932
+ if (!position) return;
933
+ lastValue = valueOf(position);
934
+ opts.onChange?.(lastValue, state);
935
+ },
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
+ }
948
+ });
949
+ return {
950
+ update: (next) => {
951
+ opts = {
952
+ ...opts,
953
+ ...next,
954
+ mapping: opts.mapping
955
+ };
956
+ drag.update({
957
+ threshold: opts.threshold,
958
+ cursor: opts.cursor,
959
+ pointerLock: opts.pointerLock
960
+ });
961
+ },
962
+ destroy: () => drag.destroy()
963
+ };
217
964
  }
218
965
  //#endregion
219
966
  //#region src/pointer/wheel.ts
@@ -223,14 +970,100 @@ function createDrag(element, { threshold: _threshold = 1, cursor, onDragStart, o
223
970
  * The listener is registered with `passive: false` so that the handler can call
224
971
  * `preventDefault()` to stop the page from scrolling.
225
972
  */
226
- function createWheel(element, onWheel) {
227
- const handler = (event) => onWheel(event);
973
+ function createWheel(element, onWheel, options = {}) {
974
+ let opts = {
975
+ ...options,
976
+ onWheel
977
+ };
978
+ function hasFocus() {
979
+ const active = element.ownerDocument?.activeElement;
980
+ return !!active && element.contains(active);
981
+ }
982
+ const handler = (event) => {
983
+ if (opts.requireFocus && !hasFocus()) return;
984
+ opts.onWheel?.(event);
985
+ };
228
986
  element.addEventListener("wheel", handler, { passive: false });
229
- return { destroy: () => {
230
- element.removeEventListener("wheel", handler);
231
- } };
987
+ return {
988
+ update: (next) => {
989
+ opts = {
990
+ ...opts,
991
+ ...next
992
+ };
993
+ },
994
+ destroy: () => {
995
+ element.removeEventListener("wheel", handler);
996
+ }
997
+ };
998
+ }
999
+ //#endregion
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
+ };
232
1065
  }
233
1066
  //#endregion
234
- export { NOT_SUPPORTED, PERMISSION_DENIED, createDrag, createMIDIAccess, createMIDIInput, createMIDIMessage, createWheel };
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 };
235
1068
 
236
1069
  //# sourceMappingURL=index.js.map