@tremolo-ui/dom 0.4.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 +541 -55
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +366 -12
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +366 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +534 -56
- 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 +37 -1
- package/src/piano/input.ts +185 -0
- package/src/pointer/drag.ts +137 -57
- package/src/pointer/dragValue.ts +229 -0
- package/src/pointer/wheel.ts +34 -1
- 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";
|
|
@@ -119,9 +335,12 @@ const MANAGED_STYLES = [
|
|
|
119
335
|
* element gets `touch-action: none` so that touch dragging does not scroll the
|
|
120
336
|
* page, plus `user-select: none` so that a long press does not start a text
|
|
121
337
|
* selection instead.
|
|
338
|
+
*
|
|
339
|
+
* One pointer at a time by default; see {@link DragOptions.multiPointer}.
|
|
122
340
|
*/
|
|
123
|
-
function createDrag(element,
|
|
124
|
-
|
|
341
|
+
function createDrag(element, options = {}) {
|
|
342
|
+
let opts = options;
|
|
343
|
+
const multiPointer = options.multiPointer ?? false;
|
|
125
344
|
const capture = element;
|
|
126
345
|
const style = element.style;
|
|
127
346
|
const previousStyles = /* @__PURE__ */ new Map();
|
|
@@ -129,22 +348,23 @@ function createDrag(element, { threshold: _threshold = 1, cursor, onDragStart, o
|
|
|
129
348
|
previousStyles.set(property, style.getPropertyValue(property));
|
|
130
349
|
style.setProperty(property, value);
|
|
131
350
|
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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();
|
|
139
358
|
let previousCursor;
|
|
140
|
-
function state(event, deltaX, deltaY) {
|
|
359
|
+
function state(event, pointer, deltaX, deltaY) {
|
|
141
360
|
return {
|
|
142
|
-
x: event.screenX - startX,
|
|
143
|
-
y: event.screenY - startY,
|
|
361
|
+
x: event.screenX - pointer.startX,
|
|
362
|
+
y: event.screenY - pointer.startY,
|
|
144
363
|
deltaX,
|
|
145
364
|
deltaY,
|
|
146
365
|
clientX: event.clientX,
|
|
147
366
|
clientY: event.clientY,
|
|
367
|
+
pointerId: event.pointerId,
|
|
148
368
|
event
|
|
149
369
|
};
|
|
150
370
|
}
|
|
@@ -156,65 +376,307 @@ function createDrag(element, { threshold: _threshold = 1, cursor, onDragStart, o
|
|
|
156
376
|
function preventSelectStart(event) {
|
|
157
377
|
event.preventDefault();
|
|
158
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
|
+
}
|
|
159
399
|
function handlePointerDown(event) {
|
|
160
400
|
const pointerEvent = event;
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (cursor && style) {
|
|
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) {
|
|
166
406
|
previousCursor = style.cursor;
|
|
167
|
-
style.cursor = cursor;
|
|
407
|
+
style.cursor = opts.cursor;
|
|
168
408
|
}
|
|
169
409
|
capture.setPointerCapture?.(pointerId);
|
|
170
|
-
moveTarget = capture.hasPointerCapture?.(pointerId) === true ? element : globalThis.window ?? element;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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));
|
|
176
422
|
}
|
|
177
423
|
function handlePointerMove(event) {
|
|
178
424
|
const pointerEvent = event;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
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;
|
|
184
430
|
if (Math.abs(deltaX) < threshold && Math.abs(deltaY) < threshold) return;
|
|
185
|
-
|
|
431
|
+
pointer.lastX = pointerEvent.screenX;
|
|
432
|
+
pointer.lastY = pointerEvent.screenY;
|
|
433
|
+
opts.onDrag?.(state(pointerEvent, pointer, deltaX, deltaY));
|
|
186
434
|
}
|
|
187
435
|
function handlePointerUp(event) {
|
|
188
436
|
const pointerEvent = event;
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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);
|
|
193
442
|
}
|
|
194
|
-
function stopTracking() {
|
|
195
|
-
|
|
443
|
+
function stopTracking(pointerId) {
|
|
444
|
+
const pointer = pointers.get(pointerId);
|
|
445
|
+
if (!pointer) return;
|
|
196
446
|
capture.releasePointerCapture?.(pointerId);
|
|
197
|
-
moveTarget
|
|
198
|
-
|
|
199
|
-
|
|
447
|
+
releaseTarget(pointer.moveTarget);
|
|
448
|
+
pointers.delete(pointerId);
|
|
449
|
+
if (pointers.size > 0) return;
|
|
200
450
|
globalThis.document?.removeEventListener("selectstart", preventSelectStart);
|
|
201
|
-
if (
|
|
202
|
-
style.cursor = previousCursor
|
|
451
|
+
if (previousCursor !== void 0 && style) {
|
|
452
|
+
style.cursor = previousCursor;
|
|
203
453
|
previousCursor = void 0;
|
|
204
454
|
}
|
|
205
|
-
moveTarget = null;
|
|
206
|
-
pointerId = null;
|
|
207
455
|
}
|
|
208
456
|
element.addEventListener("pointerdown", handlePointerDown);
|
|
209
|
-
return {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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?.([]);
|
|
216
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
|
+
});
|
|
217
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
|
+
};
|
|
218
680
|
}
|
|
219
681
|
//#endregion
|
|
220
682
|
//#region src/pointer/wheel.ts
|
|
@@ -224,20 +686,44 @@ function createDrag(element, { threshold: _threshold = 1, cursor, onDragStart, o
|
|
|
224
686
|
* The listener is registered with `passive: false` so that the handler can call
|
|
225
687
|
* `preventDefault()` to stop the page from scrolling.
|
|
226
688
|
*/
|
|
227
|
-
function createWheel(element, onWheel) {
|
|
228
|
-
|
|
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
|
+
};
|
|
229
699
|
element.addEventListener("wheel", handler, { passive: false });
|
|
230
|
-
return {
|
|
231
|
-
|
|
232
|
-
|
|
700
|
+
return {
|
|
701
|
+
update: (next) => {
|
|
702
|
+
opts = {
|
|
703
|
+
...opts,
|
|
704
|
+
...next
|
|
705
|
+
};
|
|
706
|
+
},
|
|
707
|
+
destroy: () => {
|
|
708
|
+
element.removeEventListener("wheel", handler);
|
|
709
|
+
}
|
|
710
|
+
};
|
|
233
711
|
}
|
|
234
712
|
//#endregion
|
|
235
713
|
exports.NOT_SUPPORTED = NOT_SUPPORTED;
|
|
236
714
|
exports.PERMISSION_DENIED = PERMISSION_DENIED;
|
|
715
|
+
exports.createAnimationCanvas = createAnimationCanvas;
|
|
237
716
|
exports.createDrag = createDrag;
|
|
717
|
+
exports.createDragValue = createDragValue;
|
|
238
718
|
exports.createMIDIAccess = createMIDIAccess;
|
|
239
719
|
exports.createMIDIInput = createMIDIInput;
|
|
240
720
|
exports.createMIDIMessage = createMIDIMessage;
|
|
721
|
+
exports.createPianoInput = createPianoInput;
|
|
241
722
|
exports.createWheel = createWheel;
|
|
723
|
+
exports.drawingState = drawingState;
|
|
724
|
+
exports.elementMapping = elementMapping;
|
|
725
|
+
exports.isDrawingState = isDrawingState;
|
|
726
|
+
exports.relativeMapping = relativeMapping;
|
|
727
|
+
exports.toXY = toXY;
|
|
242
728
|
|
|
243
729
|
//# sourceMappingURL=index.cjs.map
|