@tremolo-ui/dom 0.3.0 → 0.5.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 +623 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +416 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +416 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +614 -1
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
- package/src/canvas/animation.ts +304 -0
- package/src/canvas/context.ts +81 -0
- package/src/index.ts +43 -0
- package/src/piano/input.ts +185 -0
- package/src/pointer/drag.ts +295 -0
- package/src/pointer/dragValue.ts +229 -0
- package/src/pointer/wheel.ts +58 -0
- package/src/xy.ts +38 -0
package/dist/index.cjs
CHANGED
|
@@ -1,4 +1,220 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let _tremolo_ui_functions = require("@tremolo-ui/functions");
|
|
3
|
+
//#region src/canvas/context.ts
|
|
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.
|
|
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
|
+
"font",
|
|
26
|
+
"textAlign",
|
|
27
|
+
"textBaseline",
|
|
28
|
+
"direction",
|
|
29
|
+
"imageSmoothingEnabled"
|
|
30
|
+
];
|
|
31
|
+
function isDrawingState(value) {
|
|
32
|
+
return typeof value == "string" && drawingState.includes(value);
|
|
33
|
+
}
|
|
34
|
+
/** Copy the drawing state off a context, to put back after a resize. */
|
|
35
|
+
function readDrawingState(context) {
|
|
36
|
+
const state = {};
|
|
37
|
+
for (const property of drawingState) state[property] = context[property];
|
|
38
|
+
return state;
|
|
39
|
+
}
|
|
40
|
+
/** Put a state read by {@link readDrawingState} back onto a context. */
|
|
41
|
+
function writeDrawingState(context, state) {
|
|
42
|
+
for (const property of drawingState) context[property] = state[property];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Give the canvas a backing store of `width * dpr` by `height * dpr` device
|
|
46
|
+
* pixels while keeping it `width` by `height` in CSS pixels, and scale the
|
|
47
|
+
* context so that drawing code can work in CSS pixels throughout.
|
|
48
|
+
*
|
|
49
|
+
* Assigning to `canvas.width` clears the canvas and resets the context, so
|
|
50
|
+
* this both resizes and re-establishes the transform.
|
|
51
|
+
*/
|
|
52
|
+
function applyDevicePixelRatio(canvas, context, width, height, dpr) {
|
|
53
|
+
canvas.width = width * dpr;
|
|
54
|
+
canvas.height = height * dpr;
|
|
55
|
+
context.setTransform(1, 0, 0, 1, 0, 0);
|
|
56
|
+
context.scale(dpr, dpr);
|
|
57
|
+
canvas.style.width = `${width}px`;
|
|
58
|
+
canvas.style.height = `${height}px`;
|
|
59
|
+
}
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region src/canvas/animation.ts
|
|
62
|
+
/**
|
|
63
|
+
* Drive a canvas from `requestAnimationFrame`, keeping its backing store in
|
|
64
|
+
* step with the device pixel ratio and, optionally, with the size of its
|
|
65
|
+
* parent.
|
|
66
|
+
*
|
|
67
|
+
* Drawing code works in CSS pixels: the context is scaled by the device pixel
|
|
68
|
+
* ratio, and `width` / `height` of each {@link AnimationFrame} are CSS pixels.
|
|
69
|
+
*/
|
|
70
|
+
function createAnimationCanvas(canvas, options) {
|
|
71
|
+
let opts = options;
|
|
72
|
+
const context2d = canvas.getContext("2d", opts.contextAttributes);
|
|
73
|
+
if (!context2d) throw new Error("createAnimationCanvas: cannot get a 2d context");
|
|
74
|
+
const context = context2d;
|
|
75
|
+
const relativeSize = opts.relativeSize ?? false;
|
|
76
|
+
/** Off-document canvas holding the drawing while the real one is resized. */
|
|
77
|
+
let memo = null;
|
|
78
|
+
let memoContext = null;
|
|
79
|
+
let width = 0;
|
|
80
|
+
let height = 0;
|
|
81
|
+
/** Whether a size has been applied, so a frame can be drawn. */
|
|
82
|
+
let sized = false;
|
|
83
|
+
let initialized = false;
|
|
84
|
+
let frameId = null;
|
|
85
|
+
let count = -1;
|
|
86
|
+
const startTime = performance.now();
|
|
87
|
+
let previousTime = startTime;
|
|
88
|
+
function devicePixelRatio() {
|
|
89
|
+
return globalThis.devicePixelRatio || 1;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Copy the canvas onto the memo canvas at its full device resolution, 1:1.
|
|
93
|
+
*
|
|
94
|
+
* Assigning to `memo.width` resets the memo's transform to the identity, so
|
|
95
|
+
* the copy neither scales nor resamples.
|
|
96
|
+
*/
|
|
97
|
+
function takeSnapshot() {
|
|
98
|
+
if (!(opts.reduceFlickering ?? true)) return null;
|
|
99
|
+
if (!memo) {
|
|
100
|
+
memo = globalThis.document?.createElement("canvas") ?? null;
|
|
101
|
+
memoContext = memo?.getContext("2d", opts.contextAttributes) ?? null;
|
|
102
|
+
}
|
|
103
|
+
if (!memo || !memoContext) return null;
|
|
104
|
+
const state = readDrawingState(context);
|
|
105
|
+
memo.width = canvas.width;
|
|
106
|
+
memo.height = canvas.height;
|
|
107
|
+
if (canvas.width > 0 && canvas.height > 0) memoContext.drawImage(canvas, 0, 0);
|
|
108
|
+
return state;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Put the snapshot back, given the size in CSS pixels it was taken at.
|
|
112
|
+
*
|
|
113
|
+
* The context is scaled to CSS pixels, so drawing the snapshot at its old
|
|
114
|
+
* CSS size leaves it exactly where it was. While the device pixel ratio
|
|
115
|
+
* stays put that is a 1:1 copy of device pixels, and when it changes the
|
|
116
|
+
* snapshot is rescaled once, from its full resolution.
|
|
117
|
+
*/
|
|
118
|
+
function restoreSnapshot(state, previousWidth, previousHeight) {
|
|
119
|
+
if (!state || !memo || !memoContext) return;
|
|
120
|
+
if (memo.width <= 0 || memo.height <= 0) return;
|
|
121
|
+
writeDrawingState(context, state);
|
|
122
|
+
context.drawImage(memo, 0, 0, previousWidth, previousHeight);
|
|
123
|
+
}
|
|
124
|
+
function applySize(w, h) {
|
|
125
|
+
const dpr = devicePixelRatio();
|
|
126
|
+
const previousWidth = width;
|
|
127
|
+
const previousHeight = height;
|
|
128
|
+
const state = takeSnapshot();
|
|
129
|
+
applyDevicePixelRatio(canvas, context, w, h, dpr);
|
|
130
|
+
width = w;
|
|
131
|
+
height = h;
|
|
132
|
+
sized = true;
|
|
133
|
+
restoreSnapshot(state, previousWidth, previousHeight);
|
|
134
|
+
}
|
|
135
|
+
function drawFrame() {
|
|
136
|
+
if (!sized) return;
|
|
137
|
+
if (!initialized) {
|
|
138
|
+
initialized = true;
|
|
139
|
+
opts.init?.(context, {
|
|
140
|
+
width,
|
|
141
|
+
height
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
const now = performance.now();
|
|
145
|
+
const deltaTime = now - previousTime;
|
|
146
|
+
previousTime = now;
|
|
147
|
+
count += 1;
|
|
148
|
+
opts.draw(context, {
|
|
149
|
+
width,
|
|
150
|
+
height,
|
|
151
|
+
count,
|
|
152
|
+
deltaTime,
|
|
153
|
+
elapsedTime: now - startTime,
|
|
154
|
+
fps: 1e3 / deltaTime
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
function tick() {
|
|
158
|
+
frameId = requestAnimationFrame(tick);
|
|
159
|
+
drawFrame();
|
|
160
|
+
}
|
|
161
|
+
function startLoop() {
|
|
162
|
+
if (frameId != null) return;
|
|
163
|
+
previousTime = performance.now();
|
|
164
|
+
frameId = requestAnimationFrame(tick);
|
|
165
|
+
}
|
|
166
|
+
function stopLoop() {
|
|
167
|
+
if (frameId == null) return;
|
|
168
|
+
cancelAnimationFrame(frameId);
|
|
169
|
+
frameId = null;
|
|
170
|
+
}
|
|
171
|
+
let observer = null;
|
|
172
|
+
if (relativeSize) {
|
|
173
|
+
const parent = canvas.parentElement;
|
|
174
|
+
if (!parent) throw new Error("createAnimationCanvas: relativeSize needs the canvas to have a parent element");
|
|
175
|
+
observer = new ResizeObserver((entries) => {
|
|
176
|
+
for (const entry of entries) {
|
|
177
|
+
const { width: w, height: h } = entry.contentRect;
|
|
178
|
+
applySize(w, h);
|
|
179
|
+
if (!(opts.animate ?? true)) drawFrame();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
observer.observe(parent);
|
|
183
|
+
} else {
|
|
184
|
+
const { width: w = 100, height: h = 100 } = opts.size ?? {};
|
|
185
|
+
applySize(w, h);
|
|
186
|
+
}
|
|
187
|
+
if (opts.animate ?? true) startLoop();
|
|
188
|
+
else if (sized) drawFrame();
|
|
189
|
+
return {
|
|
190
|
+
update: (next) => {
|
|
191
|
+
const wasAnimating = opts.animate ?? true;
|
|
192
|
+
opts = {
|
|
193
|
+
...opts,
|
|
194
|
+
...next,
|
|
195
|
+
relativeSize,
|
|
196
|
+
contextAttributes: opts.contextAttributes
|
|
197
|
+
};
|
|
198
|
+
if (!relativeSize) {
|
|
199
|
+
const { width: w = 100, height: h = 100 } = opts.size ?? {};
|
|
200
|
+
if (w != width || h != height) applySize(w, h);
|
|
201
|
+
}
|
|
202
|
+
const animating = opts.animate ?? true;
|
|
203
|
+
if (animating && !wasAnimating) startLoop();
|
|
204
|
+
if (!animating && wasAnimating) stopLoop();
|
|
205
|
+
if (!animating) drawFrame();
|
|
206
|
+
},
|
|
207
|
+
redraw: drawFrame,
|
|
208
|
+
destroy: () => {
|
|
209
|
+
stopLoop();
|
|
210
|
+
observer?.disconnect();
|
|
211
|
+
observer = null;
|
|
212
|
+
memo = null;
|
|
213
|
+
memoContext = null;
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
//#endregion
|
|
2
218
|
//#region src/midi/access.ts
|
|
3
219
|
/** @private */
|
|
4
220
|
const PERMISSION_DENIED = "PERMISSION_DENIED";
|
|
@@ -97,10 +313,417 @@ function createMIDIInput(midiAccess, { onNoteOnEvent, onNoteOffEvent, onPitchBen
|
|
|
97
313
|
});
|
|
98
314
|
}
|
|
99
315
|
//#endregion
|
|
316
|
+
//#region src/pointer/drag.ts
|
|
317
|
+
/**
|
|
318
|
+
* Applied to the element for the lifetime of the instance.
|
|
319
|
+
*
|
|
320
|
+
* `touch-action` stops touch dragging from scrolling the page. That alone makes
|
|
321
|
+
* the browser treat a long press as the start of a text selection, so selection
|
|
322
|
+
* and the iOS callout are suppressed here too.
|
|
323
|
+
*/
|
|
324
|
+
const MANAGED_STYLES = [
|
|
325
|
+
["touch-action", "none"],
|
|
326
|
+
["user-select", "none"],
|
|
327
|
+
["-webkit-user-select", "none"],
|
|
328
|
+
["-webkit-touch-callout", "none"]
|
|
329
|
+
];
|
|
330
|
+
/**
|
|
331
|
+
* Track a pointer drag on an element.
|
|
332
|
+
*
|
|
333
|
+
* Uses Pointer Events only, and pointer capture so that the drag keeps working
|
|
334
|
+
* once the pointer leaves the element. For the lifetime of the instance the
|
|
335
|
+
* element gets `touch-action: none` so that touch dragging does not scroll the
|
|
336
|
+
* page, plus `user-select: none` so that a long press does not start a text
|
|
337
|
+
* selection instead.
|
|
338
|
+
*
|
|
339
|
+
* One pointer at a time by default; see {@link DragOptions.multiPointer}.
|
|
340
|
+
*/
|
|
341
|
+
function createDrag(element, options = {}) {
|
|
342
|
+
let opts = options;
|
|
343
|
+
const multiPointer = options.multiPointer ?? false;
|
|
344
|
+
const capture = element;
|
|
345
|
+
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);
|
|
350
|
+
}
|
|
351
|
+
const pointers = /* @__PURE__ */ new Map();
|
|
352
|
+
/**
|
|
353
|
+
* How many pointers each target carries. Adding the same listener twice is a
|
|
354
|
+
* no-op and removing it once removes it for good, so a target is subscribed
|
|
355
|
+
* to while at least one pointer is on it and no longer.
|
|
356
|
+
*/
|
|
357
|
+
const targets = /* @__PURE__ */ new Map();
|
|
358
|
+
let previousCursor;
|
|
359
|
+
function state(event, pointer, deltaX, deltaY) {
|
|
360
|
+
return {
|
|
361
|
+
x: event.screenX - pointer.startX,
|
|
362
|
+
y: event.screenY - pointer.startY,
|
|
363
|
+
deltaX,
|
|
364
|
+
deltaY,
|
|
365
|
+
clientX: event.clientX,
|
|
366
|
+
clientY: event.clientY,
|
|
367
|
+
pointerId: event.pointerId,
|
|
368
|
+
event
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* `user-select: none` only makes the element's own text unselectable; the
|
|
373
|
+
* browser still starts a selection on long press and grabs whatever text it
|
|
374
|
+
* finds nearby. Cancelling the selection outright is what actually stops it.
|
|
375
|
+
*/
|
|
376
|
+
function preventSelectStart(event) {
|
|
377
|
+
event.preventDefault();
|
|
378
|
+
}
|
|
379
|
+
function retainTarget(target) {
|
|
380
|
+
const count = targets.get(target) ?? 0;
|
|
381
|
+
if (count === 0) {
|
|
382
|
+
target.addEventListener("pointermove", handlePointerMove);
|
|
383
|
+
target.addEventListener("pointerup", handlePointerUp);
|
|
384
|
+
target.addEventListener("pointercancel", handlePointerUp);
|
|
385
|
+
}
|
|
386
|
+
targets.set(target, count + 1);
|
|
387
|
+
}
|
|
388
|
+
function releaseTarget(target) {
|
|
389
|
+
const count = targets.get(target) ?? 0;
|
|
390
|
+
if (count > 1) {
|
|
391
|
+
targets.set(target, count - 1);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
target.removeEventListener("pointermove", handlePointerMove);
|
|
395
|
+
target.removeEventListener("pointerup", handlePointerUp);
|
|
396
|
+
target.removeEventListener("pointercancel", handlePointerUp);
|
|
397
|
+
targets.delete(target);
|
|
398
|
+
}
|
|
399
|
+
function handlePointerDown(event) {
|
|
400
|
+
const pointerEvent = event;
|
|
401
|
+
const pointerId = pointerEvent.pointerId;
|
|
402
|
+
if (pointers.has(pointerId)) return;
|
|
403
|
+
if (!multiPointer && pointers.size > 0) return;
|
|
404
|
+
const isFirst = pointers.size === 0;
|
|
405
|
+
if (isFirst && opts.cursor && style) {
|
|
406
|
+
previousCursor = style.cursor;
|
|
407
|
+
style.cursor = opts.cursor;
|
|
408
|
+
}
|
|
409
|
+
capture.setPointerCapture?.(pointerId);
|
|
410
|
+
const moveTarget = capture.hasPointerCapture?.(pointerId) === true ? element : globalThis.window ?? element;
|
|
411
|
+
const pointer = {
|
|
412
|
+
moveTarget,
|
|
413
|
+
startX: pointerEvent.screenX,
|
|
414
|
+
startY: pointerEvent.screenY,
|
|
415
|
+
lastX: pointerEvent.screenX,
|
|
416
|
+
lastY: pointerEvent.screenY
|
|
417
|
+
};
|
|
418
|
+
pointers.set(pointerId, pointer);
|
|
419
|
+
retainTarget(moveTarget);
|
|
420
|
+
if (isFirst) globalThis.document?.addEventListener("selectstart", preventSelectStart);
|
|
421
|
+
opts.onDragStart?.(state(pointerEvent, pointer, 0, 0));
|
|
422
|
+
}
|
|
423
|
+
function handlePointerMove(event) {
|
|
424
|
+
const pointerEvent = event;
|
|
425
|
+
const pointer = pointers.get(pointerEvent.pointerId);
|
|
426
|
+
if (!pointer) return;
|
|
427
|
+
const deltaX = pointerEvent.screenX - pointer.lastX;
|
|
428
|
+
const deltaY = pointerEvent.screenY - pointer.lastY;
|
|
429
|
+
const threshold = opts.threshold ?? 0;
|
|
430
|
+
if (Math.abs(deltaX) < threshold && Math.abs(deltaY) < threshold) return;
|
|
431
|
+
pointer.lastX = pointerEvent.screenX;
|
|
432
|
+
pointer.lastY = pointerEvent.screenY;
|
|
433
|
+
opts.onDrag?.(state(pointerEvent, pointer, deltaX, deltaY));
|
|
434
|
+
}
|
|
435
|
+
function handlePointerUp(event) {
|
|
436
|
+
const pointerEvent = event;
|
|
437
|
+
const pointer = pointers.get(pointerEvent.pointerId);
|
|
438
|
+
if (!pointer) return;
|
|
439
|
+
const finalState = state(pointerEvent, pointer, pointerEvent.screenX - pointer.lastX, pointerEvent.screenY - pointer.lastY);
|
|
440
|
+
stopTracking(pointerEvent.pointerId);
|
|
441
|
+
opts.onDragEnd?.(finalState);
|
|
442
|
+
}
|
|
443
|
+
function stopTracking(pointerId) {
|
|
444
|
+
const pointer = pointers.get(pointerId);
|
|
445
|
+
if (!pointer) return;
|
|
446
|
+
capture.releasePointerCapture?.(pointerId);
|
|
447
|
+
releaseTarget(pointer.moveTarget);
|
|
448
|
+
pointers.delete(pointerId);
|
|
449
|
+
if (pointers.size > 0) return;
|
|
450
|
+
globalThis.document?.removeEventListener("selectstart", preventSelectStart);
|
|
451
|
+
if (previousCursor !== void 0 && style) {
|
|
452
|
+
style.cursor = previousCursor;
|
|
453
|
+
previousCursor = void 0;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
element.addEventListener("pointerdown", handlePointerDown);
|
|
457
|
+
return {
|
|
458
|
+
update: (next) => {
|
|
459
|
+
opts = {
|
|
460
|
+
...opts,
|
|
461
|
+
...next,
|
|
462
|
+
multiPointer
|
|
463
|
+
};
|
|
464
|
+
},
|
|
465
|
+
destroy: () => {
|
|
466
|
+
for (const pointerId of [...pointers.keys()]) stopTracking(pointerId);
|
|
467
|
+
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);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
//#endregion
|
|
477
|
+
//#region src/piano/input.ts
|
|
478
|
+
const DEFAULT_SOURCE = "api";
|
|
479
|
+
/**
|
|
480
|
+
* Drive a piano keyboard with pointers, and own what is sounding.
|
|
481
|
+
*
|
|
482
|
+
* Every way of playing a note goes through the one instance — pointers here,
|
|
483
|
+
* keyboard shortcuts and MIDI through {@link PianoInputInstance.noteOn} — so
|
|
484
|
+
* there is a single answer to what is currently held, and drawing the keys is
|
|
485
|
+
* left entirely to the wrapper.
|
|
486
|
+
*
|
|
487
|
+
* Tracks every pointer at once, so a chord can be played with several fingers.
|
|
488
|
+
*/
|
|
489
|
+
function createPianoInput(element, options) {
|
|
490
|
+
let opts = options;
|
|
491
|
+
/** Which sources are holding each sounding note. */
|
|
492
|
+
const held = /* @__PURE__ */ new Map();
|
|
493
|
+
/** The note each pointer is currently on. */
|
|
494
|
+
const pointerNotes = /* @__PURE__ */ new Map();
|
|
495
|
+
function activeNotes() {
|
|
496
|
+
return [...held.keys()].sort((a, b) => a - b);
|
|
497
|
+
}
|
|
498
|
+
function noteOn(note, { source = DEFAULT_SOURCE, velocity } = {}) {
|
|
499
|
+
if (note > (opts.midiMax ?? 127)) return;
|
|
500
|
+
const sources = held.get(note);
|
|
501
|
+
if (sources) {
|
|
502
|
+
sources.add(source);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
held.set(note, new Set([source]));
|
|
506
|
+
opts.onPlayNote?.(note, velocity);
|
|
507
|
+
opts.onActiveNotesChange?.(activeNotes());
|
|
508
|
+
}
|
|
509
|
+
function noteOff(note, { source = DEFAULT_SOURCE } = {}) {
|
|
510
|
+
const sources = held.get(note);
|
|
511
|
+
if (!sources) return;
|
|
512
|
+
sources.delete(source);
|
|
513
|
+
if (sources.size > 0) return;
|
|
514
|
+
held.delete(note);
|
|
515
|
+
opts.onStopNote?.(note);
|
|
516
|
+
opts.onActiveNotesChange?.(activeNotes());
|
|
517
|
+
}
|
|
518
|
+
/** The note under a pointer, or null where the pointer is off the keys. */
|
|
519
|
+
function noteUnder(clientX, clientY) {
|
|
520
|
+
const { left, top, height } = element.getBoundingClientRect();
|
|
521
|
+
return (0, _tremolo_ui_functions.noteAt)(clientX - left, clientY - top, height, opts.layout);
|
|
522
|
+
}
|
|
523
|
+
/** Move a pointer onto a note, releasing whatever it held before. */
|
|
524
|
+
function movePointer(pointerId, note) {
|
|
525
|
+
const source = `pointer:${pointerId}`;
|
|
526
|
+
const previous = pointerNotes.get(pointerId);
|
|
527
|
+
if (previous === note) return;
|
|
528
|
+
if (previous !== void 0) noteOff(previous, { source });
|
|
529
|
+
if (note === null) pointerNotes.delete(pointerId);
|
|
530
|
+
else {
|
|
531
|
+
pointerNotes.set(pointerId, note);
|
|
532
|
+
noteOn(note, { source });
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
const drag = createDrag(element, {
|
|
536
|
+
multiPointer: true,
|
|
537
|
+
onDragStart: (state) => movePointer(state.pointerId, noteUnder(state.clientX, state.clientY)),
|
|
538
|
+
onDrag: (state) => {
|
|
539
|
+
if (opts.glissando === false) return;
|
|
540
|
+
movePointer(state.pointerId, noteUnder(state.clientX, state.clientY));
|
|
541
|
+
},
|
|
542
|
+
onDragEnd: (state) => movePointer(state.pointerId, null)
|
|
543
|
+
});
|
|
544
|
+
return {
|
|
545
|
+
update: (next) => {
|
|
546
|
+
opts = {
|
|
547
|
+
...opts,
|
|
548
|
+
...next
|
|
549
|
+
};
|
|
550
|
+
},
|
|
551
|
+
noteOn,
|
|
552
|
+
noteOff,
|
|
553
|
+
activeNotes,
|
|
554
|
+
destroy: () => {
|
|
555
|
+
drag.destroy();
|
|
556
|
+
for (const note of activeNotes()) {
|
|
557
|
+
held.delete(note);
|
|
558
|
+
opts.onStopNote?.(note);
|
|
559
|
+
}
|
|
560
|
+
pointerNotes.clear();
|
|
561
|
+
opts.onActiveNotesChange?.([]);
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
//#endregion
|
|
566
|
+
//#region src/xy.ts
|
|
567
|
+
function isPair(value) {
|
|
568
|
+
return Array.isArray(value);
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Spread a setting that may have been given as a single value.
|
|
572
|
+
*
|
|
573
|
+
* The parameter is written out rather than taken as `XYInput<T>`: a
|
|
574
|
+
* conditional type cannot be narrowed, so the constraint stays where it is
|
|
575
|
+
* declared and this takes both forms.
|
|
576
|
+
*
|
|
577
|
+
* The pair is copied rather than passed along, so that the result is a tuple
|
|
578
|
+
* the caller owns even when a readonly one was given.
|
|
579
|
+
*/
|
|
580
|
+
function toXY(value) {
|
|
581
|
+
return isPair(value) ? [value[0], value[1]] : [value, value];
|
|
582
|
+
}
|
|
583
|
+
//#endregion
|
|
584
|
+
//#region src/pointer/dragValue.ts
|
|
585
|
+
/**
|
|
586
|
+
* Map the pointer onto the bounding rect of an element: the value *is* the
|
|
587
|
+
* position pointed at, so the middle of the element is 0.5.
|
|
588
|
+
*
|
|
589
|
+
* The element is read on every event, so it may be mounted after the drag is
|
|
590
|
+
* set up and may change size while a drag is in progress.
|
|
591
|
+
*/
|
|
592
|
+
function elementMapping(getElement) {
|
|
593
|
+
function positionIn(state) {
|
|
594
|
+
const element = getElement();
|
|
595
|
+
if (!element) return null;
|
|
596
|
+
const { left, top, right, bottom } = element.getBoundingClientRect();
|
|
597
|
+
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
|
+
}
|
|
599
|
+
return {
|
|
600
|
+
start: positionIn,
|
|
601
|
+
move: positionIn
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Move the value away from where it stood when the drag started, by the
|
|
606
|
+
* distance dragged. The pointer position itself carries no meaning, so the
|
|
607
|
+
* value can be adjusted from anywhere on the screen.
|
|
608
|
+
*/
|
|
609
|
+
function relativeMapping({ pixelRange = 100 } = {}) {
|
|
610
|
+
const [rangeX, rangeY] = toXY(pixelRange);
|
|
611
|
+
let origin = [0, 0];
|
|
612
|
+
return {
|
|
613
|
+
start: (_state, context) => {
|
|
614
|
+
origin = context.position();
|
|
615
|
+
return origin;
|
|
616
|
+
},
|
|
617
|
+
move: (state) => [origin[0] + state.x / rangeX, origin[1] + state.y / rangeY]
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Drive a value with a pointer drag.
|
|
622
|
+
*
|
|
623
|
+
* Combines {@link createDrag} with the scaling of `@tremolo-ui/functions`: the
|
|
624
|
+
* mapping decides where the pointer sits on the 0-1 travel of each axis, and
|
|
625
|
+
* the axis options turn that into a value.
|
|
626
|
+
*/
|
|
627
|
+
function createDragValue(element, options) {
|
|
628
|
+
let opts = options;
|
|
629
|
+
let lastValue = [0, 0];
|
|
630
|
+
const axes = () => toXY(opts.axis);
|
|
631
|
+
function valueOf(position) {
|
|
632
|
+
return axes().map((axis, i) => {
|
|
633
|
+
const p = axis.reverse ? 1 - position[i] : position[i];
|
|
634
|
+
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);
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
const context = { position: () => {
|
|
639
|
+
const getValue = opts.getValue;
|
|
640
|
+
if (!getValue) throw new Error("createDragValue: getValue is required by the given mapping");
|
|
641
|
+
const value = getValue();
|
|
642
|
+
return axes().map((axis, i) => {
|
|
643
|
+
const n = (axis.scale ?? _tremolo_ui_functions.linearScale).normalize(value[i], axis.min, axis.max);
|
|
644
|
+
return axis.reverse ? 1 - n : n;
|
|
645
|
+
});
|
|
646
|
+
} };
|
|
647
|
+
const drag = createDrag(element, {
|
|
648
|
+
threshold: opts.threshold,
|
|
649
|
+
cursor: opts.cursor,
|
|
650
|
+
onDragStart: (state) => {
|
|
651
|
+
const position = opts.mapping.start(state, context);
|
|
652
|
+
if (position) {
|
|
653
|
+
lastValue = valueOf(position);
|
|
654
|
+
if (opts.updateOnPointerDown) opts.onChange?.(lastValue, state);
|
|
655
|
+
}
|
|
656
|
+
opts.onDragStart?.(lastValue, state);
|
|
657
|
+
},
|
|
658
|
+
onDrag: (state) => {
|
|
659
|
+
const position = opts.mapping.move(state, context);
|
|
660
|
+
if (!position) return;
|
|
661
|
+
lastValue = valueOf(position);
|
|
662
|
+
opts.onChange?.(lastValue, state);
|
|
663
|
+
},
|
|
664
|
+
onDragEnd: (state) => opts.onDragEnd?.(lastValue, state)
|
|
665
|
+
});
|
|
666
|
+
return {
|
|
667
|
+
update: (next) => {
|
|
668
|
+
opts = {
|
|
669
|
+
...opts,
|
|
670
|
+
...next,
|
|
671
|
+
mapping: opts.mapping
|
|
672
|
+
};
|
|
673
|
+
drag.update({
|
|
674
|
+
threshold: opts.threshold,
|
|
675
|
+
cursor: opts.cursor
|
|
676
|
+
});
|
|
677
|
+
},
|
|
678
|
+
destroy: () => drag.destroy()
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
//#endregion
|
|
682
|
+
//#region src/pointer/wheel.ts
|
|
683
|
+
/**
|
|
684
|
+
* Listen to wheel events on an element.
|
|
685
|
+
*
|
|
686
|
+
* The listener is registered with `passive: false` so that the handler can call
|
|
687
|
+
* `preventDefault()` to stop the page from scrolling.
|
|
688
|
+
*/
|
|
689
|
+
function createWheel(element, onWheel, options = {}) {
|
|
690
|
+
let opts = options;
|
|
691
|
+
function hasFocus() {
|
|
692
|
+
const active = element.ownerDocument?.activeElement;
|
|
693
|
+
return !!active && element.contains(active);
|
|
694
|
+
}
|
|
695
|
+
const handler = (event) => {
|
|
696
|
+
if (opts.requireFocus && !hasFocus()) return;
|
|
697
|
+
onWheel(event);
|
|
698
|
+
};
|
|
699
|
+
element.addEventListener("wheel", handler, { passive: false });
|
|
700
|
+
return {
|
|
701
|
+
update: (next) => {
|
|
702
|
+
opts = {
|
|
703
|
+
...opts,
|
|
704
|
+
...next
|
|
705
|
+
};
|
|
706
|
+
},
|
|
707
|
+
destroy: () => {
|
|
708
|
+
element.removeEventListener("wheel", handler);
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
//#endregion
|
|
100
713
|
exports.NOT_SUPPORTED = NOT_SUPPORTED;
|
|
101
714
|
exports.PERMISSION_DENIED = PERMISSION_DENIED;
|
|
715
|
+
exports.createAnimationCanvas = createAnimationCanvas;
|
|
716
|
+
exports.createDrag = createDrag;
|
|
717
|
+
exports.createDragValue = createDragValue;
|
|
102
718
|
exports.createMIDIAccess = createMIDIAccess;
|
|
103
719
|
exports.createMIDIInput = createMIDIInput;
|
|
104
720
|
exports.createMIDIMessage = createMIDIMessage;
|
|
721
|
+
exports.createPianoInput = createPianoInput;
|
|
722
|
+
exports.createWheel = createWheel;
|
|
723
|
+
exports.drawingState = drawingState;
|
|
724
|
+
exports.elementMapping = elementMapping;
|
|
725
|
+
exports.isDrawingState = isDrawingState;
|
|
726
|
+
exports.relativeMapping = relativeMapping;
|
|
727
|
+
exports.toXY = toXY;
|
|
105
728
|
|
|
106
729
|
//# sourceMappingURL=index.cjs.map
|