@tremolo-ui/dom 0.7.0 → 0.9.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 +343 -595
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +230 -284
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +230 -284
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +325 -572
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs +530 -0
- package/dist/internal.cjs.map +1 -0
- package/dist/internal.d.cts +365 -0
- package/dist/internal.d.cts.map +1 -0
- package/dist/internal.d.ts +365 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +500 -0
- package/dist/internal.js.map +1 -0
- package/dist/marks-DK7xJmGM.d.cts +305 -0
- package/dist/marks-DK7xJmGM.d.cts.map +1 -0
- package/dist/marks-DK7xJmGM.d.ts +305 -0
- package/dist/marks-DK7xJmGM.d.ts.map +1 -0
- package/dist/xy-5Oc6JeJr.cjs +952 -0
- package/dist/xy-5Oc6JeJr.cjs.map +1 -0
- package/dist/xy-lNFl1CTO.js +827 -0
- package/dist/xy-lNFl1CTO.js.map +1 -0
- package/package.json +12 -2
- package/src/canvas/animation.ts +8 -0
- package/src/canvas/context.ts +0 -5
- package/src/file/accept.ts +17 -0
- package/src/file/drop-zone.ts +8 -10
- package/src/index.ts +44 -32
- package/src/input/change-gesture.ts +104 -0
- package/src/input/check-steps.ts +144 -0
- package/src/input/defaults.ts +32 -0
- package/src/input/direction.ts +107 -0
- package/src/internal.ts +51 -0
- package/src/knob/geometry.ts +100 -0
- package/src/midi/access.ts +16 -15
- package/src/midi/input.ts +2 -9
- package/src/number-input/stepper-drag.ts +147 -0
- package/src/number-input/text.ts +100 -0
- package/src/number-input/value.ts +129 -0
- package/src/options/replace.ts +22 -0
- package/src/piano/index.ts +128 -2
- package/src/piano/layout.ts +19 -0
- package/src/piano/shortcuts.ts +86 -0
- package/src/pointer/drag-value.ts +3 -1
- package/src/pointer/long-press.ts +103 -0
- package/src/pointer/wheel.ts +10 -7
- package/src/points-editor/index.ts +367 -0
- package/src/position.ts +20 -0
- package/src/slider/decimal-digits.ts +23 -0
- package/src/slider/marks.ts +56 -0
- package/src/style.ts +41 -0
package/dist/index.cjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_xy = require("./xy-5Oc6JeJr.cjs");
|
|
2
3
|
let _tremolo_ui_functions = require("@tremolo-ui/functions");
|
|
3
4
|
//#region src/canvas/context.ts
|
|
4
5
|
/**
|
|
@@ -37,9 +38,6 @@ const drawingState = [
|
|
|
37
38
|
"imageSmoothingEnabled",
|
|
38
39
|
"imageSmoothingQuality"
|
|
39
40
|
];
|
|
40
|
-
function isDrawingState(value) {
|
|
41
|
-
return typeof value === "string" && drawingState.includes(value);
|
|
42
|
-
}
|
|
43
41
|
/** Copy the drawing state off a context, to put back after a resize. */
|
|
44
42
|
function readDrawingState(context) {
|
|
45
43
|
const state = {};
|
|
@@ -236,39 +234,6 @@ function createAnimationCanvas(canvas, options) {
|
|
|
236
234
|
};
|
|
237
235
|
}
|
|
238
236
|
//#endregion
|
|
239
|
-
//#region src/file/accept.ts
|
|
240
|
-
/**
|
|
241
|
-
* Does a file satisfy an `accept` attribute?
|
|
242
|
-
*
|
|
243
|
-
* `accept` is written the way the HTML attribute is: a comma separated list of
|
|
244
|
-
* extensions (`.wav`), MIME types (`audio/wav`) and type groups (`audio/*`).
|
|
245
|
-
* Anything that matches one entry is accepted, and an empty or missing
|
|
246
|
-
* `accept` takes everything.
|
|
247
|
-
*
|
|
248
|
-
* **The browser's own `accept` is only a hint to the file picker.** A person
|
|
249
|
-
* can switch it to "All Files", drag a file in, or pick one the picker was
|
|
250
|
-
* never asked about, so what arrives still has to be checked.
|
|
251
|
-
*
|
|
252
|
-
* A rule that cannot be decided is not treated as a rejection: with no `name`,
|
|
253
|
-
* an extension rule says nothing either way, and `accept=".wav"` reports a
|
|
254
|
-
* match rather than refusing a file it has not seen the name of. The name is
|
|
255
|
-
* there by the time the file is dropped, which is when the answer counts.
|
|
256
|
-
*/
|
|
257
|
-
function matchesAccept(candidate, accept) {
|
|
258
|
-
const rules = (accept ?? "").split(",").map((rule) => rule.trim().toLowerCase()).filter(Boolean);
|
|
259
|
-
if (rules.length === 0) return true;
|
|
260
|
-
const name = candidate.name?.toLowerCase();
|
|
261
|
-
const type = candidate.type.toLowerCase();
|
|
262
|
-
let undecided = false;
|
|
263
|
-
for (const rule of rules) if (rule.startsWith(".")) {
|
|
264
|
-
if (name === void 0) undecided = true;
|
|
265
|
-
else if (name.endsWith(rule)) return true;
|
|
266
|
-
} else if (rule.endsWith("/*")) {
|
|
267
|
-
if (type.startsWith(rule.slice(0, -1))) return true;
|
|
268
|
-
} else if (type === rule) return true;
|
|
269
|
-
return undecided;
|
|
270
|
-
}
|
|
271
|
-
//#endregion
|
|
272
237
|
//#region src/file/drop-zone.ts
|
|
273
238
|
/**
|
|
274
239
|
* Take files dropped onto an element.
|
|
@@ -307,7 +272,7 @@ function createDropZone(element, options = {}) {
|
|
|
307
272
|
function anyAcceptable(transfer) {
|
|
308
273
|
const items = Array.from(transfer?.items ?? []).filter((item) => item.kind === "file");
|
|
309
274
|
if (items.length === 0) return true;
|
|
310
|
-
return items.some((item) => matchesAccept({ type: item.type }, opts.accept));
|
|
275
|
+
return items.some((item) => require_xy.matchesAccept({ type: item.type }, opts.accept));
|
|
311
276
|
}
|
|
312
277
|
function onDragEnter(event) {
|
|
313
278
|
if (!carriesFiles(event.dataTransfer)) return;
|
|
@@ -347,14 +312,10 @@ function createDropZone(element, options = {}) {
|
|
|
347
312
|
invalid: false
|
|
348
313
|
});
|
|
349
314
|
if (opts.disabled) return;
|
|
350
|
-
const
|
|
351
|
-
const accepted = [];
|
|
352
|
-
const rejected = [];
|
|
353
|
-
for (const file of dropped) if (matchesAccept(file, opts.accept)) accepted.push(file);
|
|
354
|
-
else rejected.push(file);
|
|
355
|
-
if (rejected.length > 0) opts.onReject?.(rejected, event);
|
|
315
|
+
const { accepted, rejected } = require_xy.partitionByAccept(event.dataTransfer?.files ?? [], opts.accept);
|
|
356
316
|
const taken = opts.multiple ? accepted : accepted.slice(0, 1);
|
|
357
317
|
if (taken.length > 0) opts.onDrop?.(taken, event);
|
|
318
|
+
if (rejected.length > 0) opts.onReject?.(rejected, event);
|
|
358
319
|
}
|
|
359
320
|
/**
|
|
360
321
|
* A drag that ends anywhere else — dropped on another element, cancelled
|
|
@@ -396,118 +357,122 @@ function createDropZone(element, options = {}) {
|
|
|
396
357
|
};
|
|
397
358
|
}
|
|
398
359
|
//#endregion
|
|
399
|
-
//#region src/input/
|
|
360
|
+
//#region src/input/change-gesture.ts
|
|
400
361
|
/**
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
362
|
+
* Track when a change of value starts and ends, whatever made it: a drag, the
|
|
363
|
+
* wheel, the keys or a reset. A host that records automation needs both ends
|
|
364
|
+
* to know when the control is touched.
|
|
404
365
|
*/
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
};
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
366
|
+
function createChangeGesture(options = {}) {
|
|
367
|
+
let opts = options;
|
|
368
|
+
let current = null;
|
|
369
|
+
let timer = null;
|
|
370
|
+
function clearTimer() {
|
|
371
|
+
if (timer !== null) clearTimeout(timer);
|
|
372
|
+
timer = null;
|
|
373
|
+
}
|
|
374
|
+
function end() {
|
|
375
|
+
clearTimer();
|
|
376
|
+
if (!current) return;
|
|
377
|
+
const { source } = current;
|
|
378
|
+
current = null;
|
|
379
|
+
opts.onEnd?.(source);
|
|
380
|
+
}
|
|
381
|
+
function start(source, held) {
|
|
382
|
+
current = {
|
|
383
|
+
source,
|
|
384
|
+
held
|
|
385
|
+
};
|
|
386
|
+
opts.onStart?.(source);
|
|
387
|
+
}
|
|
388
|
+
return {
|
|
389
|
+
hold: (source) => {
|
|
390
|
+
if (current?.held && current.source === source) return;
|
|
391
|
+
end();
|
|
392
|
+
start(source, true);
|
|
393
|
+
},
|
|
394
|
+
pulse: (source) => {
|
|
395
|
+
if (current?.held) return;
|
|
396
|
+
if (!current || current.source !== source) {
|
|
397
|
+
end();
|
|
398
|
+
start(source, false);
|
|
399
|
+
}
|
|
400
|
+
clearTimer();
|
|
401
|
+
timer = setTimeout(end, opts.endDelay ?? 500);
|
|
402
|
+
},
|
|
403
|
+
instant: (source, change) => {
|
|
404
|
+
end();
|
|
405
|
+
start(source, false);
|
|
406
|
+
change();
|
|
407
|
+
end();
|
|
408
|
+
},
|
|
409
|
+
end,
|
|
410
|
+
active: () => current !== null,
|
|
411
|
+
update: (next) => {
|
|
412
|
+
opts = {
|
|
413
|
+
...opts,
|
|
414
|
+
...next
|
|
415
|
+
};
|
|
416
|
+
},
|
|
417
|
+
destroy: end
|
|
418
|
+
};
|
|
423
419
|
}
|
|
420
|
+
//#endregion
|
|
421
|
+
//#region src/knob/geometry.ts
|
|
424
422
|
/**
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
* @example
|
|
428
|
-
* selectModifier({ default: 1, shift: 0.1 }, event)
|
|
423
|
+
* Width and height of the viewBox a knob is drawn in. The arcs and the thumb
|
|
424
|
+
* are laid out in these units, and the SVG scales them to the knob's size.
|
|
429
425
|
*/
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const value = options[modifier];
|
|
437
|
-
if (value !== void 0 && modifiers[MODIFIER_FLAG[modifier]]) return {
|
|
438
|
-
value,
|
|
439
|
-
modifier
|
|
440
|
-
};
|
|
441
|
-
}
|
|
426
|
+
const KNOB_VIEWBOX_SIZE = 100;
|
|
427
|
+
const center = 50;
|
|
428
|
+
function knobAngles({ value, min, max, scale, startValue, angleRange }) {
|
|
429
|
+
const p = scale.normalize(value, min, max);
|
|
430
|
+
const s = scale.normalize(startValue, min, max);
|
|
431
|
+
const r1 = -angleRange / 2;
|
|
442
432
|
return {
|
|
443
|
-
|
|
444
|
-
|
|
433
|
+
p,
|
|
434
|
+
r1,
|
|
435
|
+
r2: r1 + Math.min(p, s) * angleRange,
|
|
436
|
+
r3: r1 + Math.max(p, s) * angleRange,
|
|
437
|
+
r4: angleRange / 2
|
|
445
438
|
};
|
|
446
439
|
}
|
|
447
440
|
/**
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
* A drag sensitivity is a number and a keyboard amount is a tuple, but the two
|
|
452
|
-
* describe the same thing from the caller's side. This carries one over to the
|
|
453
|
-
* other so that a component can hand a sensitivity to {@link applyDelta}
|
|
454
|
-
* without unpicking the modifier map itself — which matters, since naming a
|
|
455
|
-
* modifier is also what takes `step` out of the pipeline.
|
|
441
|
+
* The point at `angle` on a circle of `radius` around the centre of the
|
|
442
|
+
* viewBox.
|
|
456
443
|
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
* // { default: ['raw', 1], shift: ['raw', 0.1] }
|
|
444
|
+
* The radius depends on the stroke of the line being drawn, which each arc
|
|
445
|
+
* has its own of, so the point is found per arc rather than once for the knob.
|
|
460
446
|
*/
|
|
461
|
-
function
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
if (value !== void 0) mapped[modifier] = fn(value);
|
|
467
|
-
}
|
|
468
|
-
return mapped;
|
|
447
|
+
function knobArcPoint(angle, radius) {
|
|
448
|
+
return {
|
|
449
|
+
x: center + radius * Math.cos((0, _tremolo_ui_functions.radian)(angle - 90)),
|
|
450
|
+
y: center + radius * Math.sin((0, _tremolo_ui_functions.radian)(angle - 90))
|
|
451
|
+
};
|
|
469
452
|
}
|
|
470
|
-
//#endregion
|
|
471
|
-
//#region src/input/apply-delta.ts
|
|
472
453
|
/**
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
* The pipeline matches {@link createDragValue}: scale, then step, then clamp.
|
|
476
|
-
* Which key or which sign of `deltaY` counts as which direction is left to the
|
|
477
|
-
* caller, since it differs per component.
|
|
478
|
-
*
|
|
479
|
-
* @param direction which way, and how many times, to apply the option. The
|
|
480
|
-
* size of one step is `option[1]`, so this is normally `1` or `-1`.
|
|
481
|
-
*
|
|
482
|
-
* @param modifiers the event, for `options` that name a modifier key. See
|
|
483
|
-
* {@link selectModifier}.
|
|
484
|
-
*
|
|
485
|
-
* @example
|
|
486
|
-
* // ArrowDown on a slider whose keyboard option is ['raw', 1]
|
|
487
|
-
* applyDelta(value, -1, keyboard, { min, max, step, scale })
|
|
488
|
-
*
|
|
489
|
-
* @example
|
|
490
|
-
* // Shift+ArrowDown, where `keyboard` is { default: …, shift: ['raw', 0.1] }
|
|
491
|
-
* applyDelta(value, -1, keyboard, range, event)
|
|
454
|
+
* The radius that keeps a stroke of `strokeWidth` inside the viewBox: half of
|
|
455
|
+
* the stroke falls outside the path it is drawn along.
|
|
492
456
|
*/
|
|
493
|
-
function
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
const
|
|
500
|
-
const
|
|
501
|
-
|
|
457
|
+
function knobArcRadius(strokeWidth) {
|
|
458
|
+
const width = typeof strokeWidth === "number" ? strokeWidth : Number.parseFloat(String(strokeWidth));
|
|
459
|
+
return Number.isFinite(width) ? center - width / 2 : center;
|
|
460
|
+
}
|
|
461
|
+
/** Build an SVG path for an arc, splitting full turns into drawable segments. */
|
|
462
|
+
function knobArcPath(startAngle, endAngle, radius) {
|
|
463
|
+
const start = knobArcPoint(startAngle, radius);
|
|
464
|
+
const sweep = endAngle - startAngle;
|
|
465
|
+
const segmentCount = Math.max(1, Math.ceil(Math.abs(sweep) / 180));
|
|
466
|
+
const segmentSweep = sweep / segmentCount;
|
|
467
|
+
let path = `M ${start.x} ${start.y}`;
|
|
468
|
+
for (let i = 1; i <= segmentCount; i += 1) {
|
|
469
|
+
const end = knobArcPoint(startAngle + segmentSweep * i, radius);
|
|
470
|
+
path += ` A ${radius} ${radius} 0 0 ${segmentSweep >= 0 ? 1 : 0} ${end.x} ${end.y}`;
|
|
471
|
+
}
|
|
472
|
+
return path;
|
|
502
473
|
}
|
|
503
474
|
//#endregion
|
|
504
475
|
//#region src/midi/access.ts
|
|
505
|
-
/** @private */
|
|
506
|
-
const PERMISSION_DENIED = "PERMISSION_DENIED";
|
|
507
|
-
/** @private */
|
|
508
|
-
const NOT_SUPPORTED = "NOT_SUPPORTED";
|
|
509
|
-
/** @private */
|
|
510
|
-
const UNAVAILABLE = "UNAVAILABLE";
|
|
511
476
|
const INITIAL_STATE = {
|
|
512
477
|
midiAccess: null,
|
|
513
478
|
error: null,
|
|
@@ -521,9 +486,9 @@ const INITIAL_STATE = {
|
|
|
521
486
|
*/
|
|
522
487
|
function toError(reason) {
|
|
523
488
|
const name = typeof reason === "object" && reason !== null && "name" in reason ? String(reason.name) : "";
|
|
524
|
-
if (name === "SecurityError" || name === "NotAllowedError") return PERMISSION_DENIED;
|
|
525
|
-
if (name === "NotSupportedError" || name === "TypeError") return NOT_SUPPORTED;
|
|
526
|
-
return UNAVAILABLE;
|
|
489
|
+
if (name === "SecurityError" || name === "NotAllowedError") return "PERMISSION_DENIED";
|
|
490
|
+
if (name === "NotSupportedError" || name === "TypeError") return "NOT_SUPPORTED";
|
|
491
|
+
return "UNAVAILABLE";
|
|
527
492
|
}
|
|
528
493
|
/**
|
|
529
494
|
* Request MIDI access in the browser.
|
|
@@ -562,7 +527,7 @@ function createMIDIAccess() {
|
|
|
562
527
|
if (typeof navigator === "undefined" || !navigator.requestMIDIAccess) {
|
|
563
528
|
setState({
|
|
564
529
|
...state,
|
|
565
|
-
error: NOT_SUPPORTED
|
|
530
|
+
error: "NOT_SUPPORTED"
|
|
566
531
|
});
|
|
567
532
|
return;
|
|
568
533
|
}
|
|
@@ -660,13 +625,6 @@ const STATUS = {
|
|
|
660
625
|
PITCH_BEND: 224
|
|
661
626
|
};
|
|
662
627
|
/**
|
|
663
|
-
* Centre of the 14-bit pitch bend range: no bend.
|
|
664
|
-
*
|
|
665
|
-
* The range is not symmetric — 0 is 8192 below centre and 16383 is 8191 above
|
|
666
|
-
* — so a wheel at rest reports exactly this rather than half of the maximum.
|
|
667
|
-
*/
|
|
668
|
-
const PITCH_BEND_CENTER = 8192;
|
|
669
|
-
/**
|
|
670
628
|
* Handle the channel voice messages of every connected input. To be used with
|
|
671
629
|
* {@link createMIDIAccess}. Internally uses {@link createMIDIMessage}, so
|
|
672
630
|
* devices plugged in later are picked up.
|
|
@@ -714,347 +672,65 @@ function createMIDIInput(midiAccess, handlers) {
|
|
|
714
672
|
};
|
|
715
673
|
}
|
|
716
674
|
//#endregion
|
|
717
|
-
//#region src/piano/
|
|
718
|
-
/**
|
|
719
|
-
* `[noteRange.first, noteRange.first + 1, ..., noteRange.last]`
|
|
720
|
-
*/
|
|
721
|
-
function getNoteRangeArray(noteRange) {
|
|
722
|
-
return Array.from({ length: noteRange.last - noteRange.first + 1 }, (_, i) => i + noteRange.first);
|
|
723
|
-
}
|
|
724
|
-
const DEFAULT_KEY_GAP = 1;
|
|
725
|
-
const DEFAULT_BLACK_KEY_WIDTH_RATIO = .65;
|
|
726
|
-
const DEFAULT_BLACK_KEY_HEIGHT_RATIO = .6;
|
|
675
|
+
//#region src/piano/shortcuts.ts
|
|
727
676
|
/**
|
|
728
|
-
*
|
|
729
|
-
*
|
|
730
|
-
* A black key shares the number of the white key to its left plus one, which
|
|
731
|
-
* puts it on the boundary between the two; {@link notePosition} then shifts it
|
|
732
|
-
* back by half its width to centre it there.
|
|
677
|
+
* Ready-made keyboard layouts. Both assume `noteRange.first` is a C.
|
|
733
678
|
*/
|
|
734
|
-
const
|
|
735
|
-
C
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
679
|
+
const SHORTCUTS = {
|
|
680
|
+
/** Every semitone from C, over the two rows of a QWERTY keyboard. */
|
|
681
|
+
HOME_ROW: { keys: [
|
|
682
|
+
"a",
|
|
683
|
+
"w",
|
|
684
|
+
"s",
|
|
685
|
+
"e",
|
|
686
|
+
"d",
|
|
687
|
+
"f",
|
|
688
|
+
"t",
|
|
689
|
+
"g",
|
|
690
|
+
"y",
|
|
691
|
+
"h",
|
|
692
|
+
"u",
|
|
693
|
+
"j",
|
|
694
|
+
"k",
|
|
695
|
+
"o",
|
|
696
|
+
"l",
|
|
697
|
+
"p",
|
|
698
|
+
";"
|
|
699
|
+
] },
|
|
700
|
+
/** The white keys only, on the home row. Black keys have no shortcut. */
|
|
701
|
+
HOME_ROW_NATURAL: { keys: [
|
|
702
|
+
"a",
|
|
703
|
+
"",
|
|
704
|
+
"s",
|
|
705
|
+
"",
|
|
706
|
+
"d",
|
|
707
|
+
"f",
|
|
708
|
+
"",
|
|
709
|
+
"g",
|
|
710
|
+
"",
|
|
711
|
+
"h",
|
|
712
|
+
"",
|
|
713
|
+
"j",
|
|
714
|
+
"k",
|
|
715
|
+
"",
|
|
716
|
+
"l",
|
|
717
|
+
"",
|
|
718
|
+
";"
|
|
719
|
+
] }
|
|
747
720
|
};
|
|
748
|
-
/** Width of a black key in pixels. */
|
|
749
|
-
function blackKeyWidth(layout) {
|
|
750
|
-
return layout.whiteKeyWidth * (layout.blackKeyWidthRatio ?? DEFAULT_BLACK_KEY_WIDTH_RATIO);
|
|
751
|
-
}
|
|
752
|
-
function rawNotePosition(note, layout) {
|
|
753
|
-
const slot = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP);
|
|
754
|
-
const target = (0, _tremolo_ui_functions.noteKey)(note);
|
|
755
|
-
const first = (0, _tremolo_ui_functions.noteKey)(layout.noteRange.first);
|
|
756
|
-
const octave = Math.floor((note - layout.noteRange.first) / 12);
|
|
757
|
-
const octaveOffset = _tremolo_ui_functions.noteKeys.indexOf(first) > _tremolo_ui_functions.noteKeys.indexOf(target) ? 1 : 0;
|
|
758
|
-
const whiteKeysIn = whiteKeysBefore[target] - whiteKeysBefore[first] + (octave + octaveOffset) * 7;
|
|
759
|
-
return (0, _tremolo_ui_functions.isBlackKey)(note) ? whiteKeysIn * slot - blackKeyWidth(layout) / 2 : whiteKeysIn * slot;
|
|
760
|
-
}
|
|
761
|
-
function pianoBounds(layout) {
|
|
762
|
-
const notes = getNoteRangeArray(layout.noteRange);
|
|
763
|
-
if (notes.length === 0) return {
|
|
764
|
-
left: 0,
|
|
765
|
-
right: 0
|
|
766
|
-
};
|
|
767
|
-
let left = Infinity;
|
|
768
|
-
let right = -Infinity;
|
|
769
|
-
const whiteWidth = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP);
|
|
770
|
-
for (const note of notes) {
|
|
771
|
-
const noteLeft = rawNotePosition(note, layout);
|
|
772
|
-
const width = (0, _tremolo_ui_functions.isBlackKey)(note) ? blackKeyWidth(layout) : whiteWidth;
|
|
773
|
-
left = Math.min(left, noteLeft);
|
|
774
|
-
right = Math.max(right, noteLeft + width);
|
|
775
|
-
}
|
|
776
|
-
return {
|
|
777
|
-
left,
|
|
778
|
-
right
|
|
779
|
-
};
|
|
780
|
-
}
|
|
781
|
-
/** Width of the whole keyboard in pixels. */
|
|
782
|
-
function pianoWidth(layout) {
|
|
783
|
-
const { left, right } = pianoBounds(layout);
|
|
784
|
-
return right - left;
|
|
785
|
-
}
|
|
786
|
-
/**
|
|
787
|
-
* Offset of the left edge of a key from the left edge of the keyboard, in
|
|
788
|
-
* pixels.
|
|
789
|
-
*
|
|
790
|
-
* Notes outside `noteRange` are placed too, so the value is negative below
|
|
791
|
-
* `noteRange.first`.
|
|
792
|
-
*/
|
|
793
|
-
function notePosition(note, layout) {
|
|
794
|
-
return rawNotePosition(note, layout) - pianoBounds(layout).left;
|
|
795
|
-
}
|
|
796
|
-
/**
|
|
797
|
-
* The note drawn at a point, or null where there is none.
|
|
798
|
-
*
|
|
799
|
-
* Black keys are tested first, so they win where they overlap a white one. A
|
|
800
|
-
* white key covers its gap as well as its width, so the whole width of the
|
|
801
|
-
* keyboard belongs to some key and a click cannot fall between two.
|
|
802
|
-
*
|
|
803
|
-
* @param x offset from the left edge of the keyboard, in pixels
|
|
804
|
-
* @param y offset from its top edge, in pixels
|
|
805
|
-
* @param height height of the keyboard, in pixels
|
|
806
|
-
*/
|
|
807
|
-
function noteAt(x, y, height, layout) {
|
|
808
|
-
if (x < 0 || x >= pianoWidth(layout) || y < 0 || y >= height) return null;
|
|
809
|
-
const notes = getNoteRangeArray(layout.noteRange);
|
|
810
|
-
if (y < height * (layout.blackKeyHeightRatio ?? DEFAULT_BLACK_KEY_HEIGHT_RATIO)) for (const note of notes) {
|
|
811
|
-
if ((0, _tremolo_ui_functions.isWhiteKey)(note)) continue;
|
|
812
|
-
const left = notePosition(note, layout);
|
|
813
|
-
if (left <= x && x < left + blackKeyWidth(layout)) return note;
|
|
814
|
-
}
|
|
815
|
-
const slot = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP);
|
|
816
|
-
for (const note of notes) {
|
|
817
|
-
if ((0, _tremolo_ui_functions.isBlackKey)(note)) continue;
|
|
818
|
-
const left = notePosition(note, layout);
|
|
819
|
-
if (left <= x && x < left + slot) return note;
|
|
820
|
-
}
|
|
821
|
-
return null;
|
|
822
|
-
}
|
|
823
|
-
//#endregion
|
|
824
|
-
//#region src/pointer/drag.ts
|
|
825
|
-
/**
|
|
826
|
-
* Applied to the element for the lifetime of the instance.
|
|
827
|
-
*
|
|
828
|
-
* `touch-action` stops touch dragging from scrolling the page. That alone makes
|
|
829
|
-
* the browser treat a long press as the start of a text selection, so selection
|
|
830
|
-
* and the iOS callout are suppressed here too.
|
|
831
|
-
*/
|
|
832
|
-
const MANAGED_STYLES = [
|
|
833
|
-
["touch-action", "none"],
|
|
834
|
-
["user-select", "none"],
|
|
835
|
-
["-webkit-user-select", "none"],
|
|
836
|
-
["-webkit-touch-callout", "none"]
|
|
837
|
-
];
|
|
838
|
-
const managedStyleStates = /* @__PURE__ */ new WeakMap();
|
|
839
721
|
/**
|
|
840
|
-
*
|
|
841
|
-
*
|
|
842
|
-
* Uses Pointer Events only, and pointer capture so that the drag keeps working
|
|
843
|
-
* once the pointer leaves the element. For the lifetime of the instance the
|
|
844
|
-
* element gets `touch-action: none` so that touch dragging does not scroll the
|
|
845
|
-
* page, plus `user-select: none` so that a long press does not start a text
|
|
846
|
-
* selection instead.
|
|
847
|
-
*
|
|
848
|
-
* One pointer at a time by default; see {@link DragOptions.multiPointer}.
|
|
722
|
+
* Whether a key press was typed into something: shortcuts must not play a
|
|
723
|
+
* note for a letter that is going into a text field.
|
|
849
724
|
*/
|
|
850
|
-
function
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
if (managedStyles) managedStyles.count += 1;
|
|
858
|
-
else {
|
|
859
|
-
const previous = /* @__PURE__ */ new Map();
|
|
860
|
-
for (const [property, value] of MANAGED_STYLES) {
|
|
861
|
-
previous.set(property, {
|
|
862
|
-
value: style.getPropertyValue(property),
|
|
863
|
-
priority: style.getPropertyPriority(property)
|
|
864
|
-
});
|
|
865
|
-
style.setProperty(property, value);
|
|
866
|
-
}
|
|
867
|
-
managedStyles = {
|
|
868
|
-
count: 1,
|
|
869
|
-
previous
|
|
870
|
-
};
|
|
871
|
-
managedStyleStates.set(element, managedStyles);
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
const pointers = /* @__PURE__ */ new Map();
|
|
875
|
-
/**
|
|
876
|
-
* How many pointers each target carries. Adding the same listener twice is a
|
|
877
|
-
* no-op and removing it once removes it for good, so a target is subscribed
|
|
878
|
-
* to while at least one pointer is on it and no longer.
|
|
879
|
-
*/
|
|
880
|
-
const targets = /* @__PURE__ */ new Map();
|
|
881
|
-
let previousCursor;
|
|
882
|
-
/** The pointer that asked for the lock, while it is still down. */
|
|
883
|
-
let lockedPointerId = null;
|
|
884
|
-
let lockRequest = 0;
|
|
885
|
-
let destroyed = false;
|
|
886
|
-
function state(event, pointer, deltaX, deltaY) {
|
|
887
|
-
return {
|
|
888
|
-
x: pointer.lockBaseX !== void 0 ? pointer.lockBaseX + pointer.lockMoveX : event.screenX - pointer.startX,
|
|
889
|
-
y: pointer.lockBaseY !== void 0 ? pointer.lockBaseY + pointer.lockMoveY : event.screenY - pointer.startY,
|
|
890
|
-
deltaX,
|
|
891
|
-
deltaY,
|
|
892
|
-
clientX: event.clientX,
|
|
893
|
-
clientY: event.clientY,
|
|
894
|
-
pointerId: event.pointerId,
|
|
895
|
-
event
|
|
896
|
-
};
|
|
897
|
-
}
|
|
898
|
-
/**
|
|
899
|
-
* `user-select: none` only makes the element's own text unselectable; the
|
|
900
|
-
* browser still starts a selection on long press and grabs whatever text it
|
|
901
|
-
* finds nearby. Cancelling the selection outright is what actually stops it.
|
|
902
|
-
*/
|
|
903
|
-
function preventSelectStart(event) {
|
|
904
|
-
event.preventDefault();
|
|
905
|
-
}
|
|
906
|
-
function retainTarget(target) {
|
|
907
|
-
const count = targets.get(target) ?? 0;
|
|
908
|
-
if (count === 0) {
|
|
909
|
-
target.addEventListener("pointermove", handlePointerMove);
|
|
910
|
-
target.addEventListener("pointerup", handlePointerUp);
|
|
911
|
-
target.addEventListener("pointercancel", handlePointerUp);
|
|
912
|
-
if (target === element) target.addEventListener("lostpointercapture", handleLostPointerCapture);
|
|
913
|
-
}
|
|
914
|
-
targets.set(target, count + 1);
|
|
725
|
+
function isEditableTarget(target) {
|
|
726
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
727
|
+
if (target.matches("input, textarea, select")) return true;
|
|
728
|
+
for (let element = target; element;) {
|
|
729
|
+
const contentEditable = element.getAttribute("contenteditable");
|
|
730
|
+
if (contentEditable !== null) return contentEditable.toLowerCase() !== "false";
|
|
731
|
+
element = element.parentElement;
|
|
915
732
|
}
|
|
916
|
-
|
|
917
|
-
const count = targets.get(target) ?? 0;
|
|
918
|
-
if (count > 1) {
|
|
919
|
-
targets.set(target, count - 1);
|
|
920
|
-
return;
|
|
921
|
-
}
|
|
922
|
-
target.removeEventListener("pointermove", handlePointerMove);
|
|
923
|
-
target.removeEventListener("pointerup", handlePointerUp);
|
|
924
|
-
target.removeEventListener("pointercancel", handlePointerUp);
|
|
925
|
-
if (target === element) target.removeEventListener("lostpointercapture", handleLostPointerCapture);
|
|
926
|
-
targets.delete(target);
|
|
927
|
-
}
|
|
928
|
-
function handlePointerDown(event) {
|
|
929
|
-
const pointerEvent = event;
|
|
930
|
-
if (pointerEvent.button !== 0) return;
|
|
931
|
-
const pointerId = pointerEvent.pointerId;
|
|
932
|
-
if (pointers.has(pointerId)) return;
|
|
933
|
-
if (!multiPointer && pointers.size > 0) return;
|
|
934
|
-
if (opts.shouldStart && !opts.shouldStart(pointerEvent)) return;
|
|
935
|
-
const isFirst = pointers.size === 0;
|
|
936
|
-
if (isFirst && opts.cursor && style) {
|
|
937
|
-
previousCursor = style.cursor;
|
|
938
|
-
style.cursor = opts.cursor;
|
|
939
|
-
}
|
|
940
|
-
capture.setPointerCapture?.(pointerId);
|
|
941
|
-
const moveTarget = capture.hasPointerCapture?.(pointerId) === true ? element : globalThis.window ?? element;
|
|
942
|
-
const pointer = {
|
|
943
|
-
moveTarget,
|
|
944
|
-
startX: pointerEvent.screenX,
|
|
945
|
-
startY: pointerEvent.screenY,
|
|
946
|
-
lastX: pointerEvent.screenX,
|
|
947
|
-
lastY: pointerEvent.screenY,
|
|
948
|
-
lockMoveX: 0,
|
|
949
|
-
lockMoveY: 0,
|
|
950
|
-
lastEvent: pointerEvent
|
|
951
|
-
};
|
|
952
|
-
pointers.set(pointerId, pointer);
|
|
953
|
-
retainTarget(moveTarget);
|
|
954
|
-
if (isFirst) globalThis.document?.addEventListener("selectstart", preventSelectStart);
|
|
955
|
-
if (isFirst && opts.pointerLock) {
|
|
956
|
-
const requestId = ++lockRequest;
|
|
957
|
-
lockedPointerId = pointerId;
|
|
958
|
-
globalThis.document?.addEventListener("pointerlockchange", handleLockChange);
|
|
959
|
-
try {
|
|
960
|
-
(capture.requestPointerLock?.())?.then?.(() => {
|
|
961
|
-
if (requestId === lockRequest && (!pointers.has(pointerId) || destroyed) && globalThis.document?.pointerLockElement === element) globalThis.document.exitPointerLock?.();
|
|
962
|
-
}, () => {});
|
|
963
|
-
} catch {}
|
|
964
|
-
}
|
|
965
|
-
opts.onDragStart?.(state(pointerEvent, pointer, 0, 0));
|
|
966
|
-
}
|
|
967
|
-
function handlePointerMove(event) {
|
|
968
|
-
const pointerEvent = event;
|
|
969
|
-
const pointer = pointers.get(pointerEvent.pointerId);
|
|
970
|
-
if (!pointer) return;
|
|
971
|
-
const locked = pointer.lockBaseX !== void 0;
|
|
972
|
-
const deltaX = locked ? pointerEvent.movementX ?? 0 : pointerEvent.screenX - pointer.lastX;
|
|
973
|
-
const deltaY = locked ? pointerEvent.movementY ?? 0 : pointerEvent.screenY - pointer.lastY;
|
|
974
|
-
const threshold = opts.threshold ?? 0;
|
|
975
|
-
if (Math.abs(deltaX) < threshold && Math.abs(deltaY) < threshold) return;
|
|
976
|
-
pointer.lastX = pointerEvent.screenX;
|
|
977
|
-
pointer.lastY = pointerEvent.screenY;
|
|
978
|
-
pointer.lastEvent = pointerEvent;
|
|
979
|
-
if (locked) {
|
|
980
|
-
pointer.lockMoveX += deltaX;
|
|
981
|
-
pointer.lockMoveY += deltaY;
|
|
982
|
-
}
|
|
983
|
-
opts.onDrag?.(state(pointerEvent, pointer, deltaX, deltaY));
|
|
984
|
-
}
|
|
985
|
-
function handleLockChange() {
|
|
986
|
-
if (lockedPointerId === null) return;
|
|
987
|
-
const pointer = pointers.get(lockedPointerId);
|
|
988
|
-
if (!pointer) return;
|
|
989
|
-
if (globalThis.document?.pointerLockElement === element) {
|
|
990
|
-
pointer.lockBaseX = pointer.lastX - pointer.startX;
|
|
991
|
-
pointer.lockBaseY = pointer.lastY - pointer.startY;
|
|
992
|
-
pointer.lockMoveX = 0;
|
|
993
|
-
pointer.lockMoveY = 0;
|
|
994
|
-
return;
|
|
995
|
-
}
|
|
996
|
-
if (pointer.lockBaseX === void 0) return;
|
|
997
|
-
finishDrag(lockedPointerId);
|
|
998
|
-
}
|
|
999
|
-
function handlePointerUp(event) {
|
|
1000
|
-
const pointerEvent = event;
|
|
1001
|
-
finishDrag(pointerEvent.pointerId, pointerEvent);
|
|
1002
|
-
}
|
|
1003
|
-
function handleLostPointerCapture(event) {
|
|
1004
|
-
finishDrag(event.pointerId);
|
|
1005
|
-
}
|
|
1006
|
-
function finishDrag(pointerId, event) {
|
|
1007
|
-
const pointer = pointers.get(pointerId);
|
|
1008
|
-
if (!pointer) return;
|
|
1009
|
-
const finalState = event ? state(event, pointer, event.screenX - pointer.lastX, event.screenY - pointer.lastY) : state(pointer.lastEvent, pointer, 0, 0);
|
|
1010
|
-
stopTracking(pointerId);
|
|
1011
|
-
opts.onDragEnd?.(finalState);
|
|
1012
|
-
}
|
|
1013
|
-
function stopTracking(pointerId) {
|
|
1014
|
-
const pointer = pointers.get(pointerId);
|
|
1015
|
-
if (!pointer) return;
|
|
1016
|
-
pointers.delete(pointerId);
|
|
1017
|
-
releaseTarget(pointer.moveTarget);
|
|
1018
|
-
try {
|
|
1019
|
-
if (capture.hasPointerCapture?.(pointerId) === true) capture.releasePointerCapture?.(pointerId);
|
|
1020
|
-
} catch {}
|
|
1021
|
-
if (lockedPointerId === pointerId) {
|
|
1022
|
-
lockedPointerId = null;
|
|
1023
|
-
const document = globalThis.document;
|
|
1024
|
-
document?.removeEventListener("pointerlockchange", handleLockChange);
|
|
1025
|
-
if (document?.pointerLockElement === element) document.exitPointerLock?.();
|
|
1026
|
-
}
|
|
1027
|
-
if (pointers.size > 0) return;
|
|
1028
|
-
globalThis.document?.removeEventListener("selectstart", preventSelectStart);
|
|
1029
|
-
if (previousCursor !== void 0 && style) {
|
|
1030
|
-
style.cursor = previousCursor;
|
|
1031
|
-
previousCursor = void 0;
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
element.addEventListener("pointerdown", handlePointerDown);
|
|
1035
|
-
return {
|
|
1036
|
-
update: (next) => {
|
|
1037
|
-
opts = {
|
|
1038
|
-
...opts,
|
|
1039
|
-
...next,
|
|
1040
|
-
multiPointer
|
|
1041
|
-
};
|
|
1042
|
-
},
|
|
1043
|
-
destroy: () => {
|
|
1044
|
-
if (destroyed) return;
|
|
1045
|
-
destroyed = true;
|
|
1046
|
-
element.removeEventListener("pointerdown", handlePointerDown);
|
|
1047
|
-
for (const pointerId of [...pointers.keys()]) finishDrag(pointerId);
|
|
1048
|
-
if (style && managedStyles && --managedStyles.count === 0) {
|
|
1049
|
-
for (const [property] of MANAGED_STYLES) {
|
|
1050
|
-
const previous = managedStyles.previous.get(property);
|
|
1051
|
-
if (previous?.value) style.setProperty(property, previous.value, previous.priority);
|
|
1052
|
-
else style.removeProperty(property);
|
|
1053
|
-
}
|
|
1054
|
-
managedStyleStates.delete(element);
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
};
|
|
733
|
+
return false;
|
|
1058
734
|
}
|
|
1059
735
|
//#endregion
|
|
1060
736
|
//#region src/piano/index.ts
|
|
@@ -1075,6 +751,11 @@ function createPianoInput(element, options) {
|
|
|
1075
751
|
const held = /* @__PURE__ */ new Map();
|
|
1076
752
|
/** The note each pointer is currently on. */
|
|
1077
753
|
const pointerNotes = /* @__PURE__ */ new Map();
|
|
754
|
+
/**
|
|
755
|
+
* The note each held shortcut key is playing, by `code` — the physical key,
|
|
756
|
+
* so that releasing it with a different modifier or layout still matches.
|
|
757
|
+
*/
|
|
758
|
+
const shortcutNotes = /* @__PURE__ */ new Map();
|
|
1078
759
|
function activeNotes() {
|
|
1079
760
|
return [...held.keys()].sort((a, b) => a - b);
|
|
1080
761
|
}
|
|
@@ -1103,7 +784,7 @@ function createPianoInput(element, options) {
|
|
|
1103
784
|
/** The note under a pointer, or null where the pointer is off the keys. */
|
|
1104
785
|
function noteUnder(clientX, clientY) {
|
|
1105
786
|
const { left, top, height } = element.getBoundingClientRect();
|
|
1106
|
-
return noteAt(clientX - left, clientY - top, height, opts.layout);
|
|
787
|
+
return require_xy.noteAt(clientX - left, clientY - top, height, opts.layout);
|
|
1107
788
|
}
|
|
1108
789
|
/** Move a pointer onto a note, releasing whatever it held before. */
|
|
1109
790
|
function movePointer(pointerId, note) {
|
|
@@ -1117,7 +798,81 @@ function createPianoInput(element, options) {
|
|
|
1117
798
|
noteOn(note, { source });
|
|
1118
799
|
}
|
|
1119
800
|
}
|
|
1120
|
-
|
|
801
|
+
/** The note a shortcut key plays, or null when it has none. */
|
|
802
|
+
function shortcutNote(key) {
|
|
803
|
+
const keys = opts.keyboardShortcuts?.keys;
|
|
804
|
+
if (!keys || key === "") return null;
|
|
805
|
+
const { first, last } = opts.layout.noteRange;
|
|
806
|
+
const index = keys.indexOf(key);
|
|
807
|
+
const note = first + index;
|
|
808
|
+
return index === -1 || note > last ? null : note;
|
|
809
|
+
}
|
|
810
|
+
function onKeyDown(event) {
|
|
811
|
+
const id = event.code || event.key;
|
|
812
|
+
if (event.repeat || shortcutNotes.has(id)) return;
|
|
813
|
+
if (isEditableTarget(event.target)) return;
|
|
814
|
+
const note = shortcutNote(event.key);
|
|
815
|
+
if (note === null) return;
|
|
816
|
+
shortcutNotes.set(id, note);
|
|
817
|
+
noteOn(note, { source: `keyboard:${id}` });
|
|
818
|
+
}
|
|
819
|
+
function onKeyUp(event) {
|
|
820
|
+
const id = event.code || event.key;
|
|
821
|
+
const note = shortcutNotes.get(id);
|
|
822
|
+
if (note === void 0) return;
|
|
823
|
+
shortcutNotes.delete(id);
|
|
824
|
+
noteOff(note, { source: `keyboard:${id}` });
|
|
825
|
+
}
|
|
826
|
+
function releaseShortcuts() {
|
|
827
|
+
for (const [id, note] of shortcutNotes) noteOff(note, { source: `keyboard:${id}` });
|
|
828
|
+
shortcutNotes.clear();
|
|
829
|
+
}
|
|
830
|
+
function onFocusOut(event) {
|
|
831
|
+
const next = event.relatedTarget;
|
|
832
|
+
if (next instanceof Node && element.contains(next)) return;
|
|
833
|
+
releaseShortcuts();
|
|
834
|
+
}
|
|
835
|
+
/** The scope the listeners are bound for, or null when none are. */
|
|
836
|
+
let boundScope = null;
|
|
837
|
+
function bindShortcuts() {
|
|
838
|
+
const scope = opts.keyboardShortcuts ? opts.keyboardShortcutsScope ?? "root" : null;
|
|
839
|
+
if (scope === boundScope) return;
|
|
840
|
+
unbindShortcuts();
|
|
841
|
+
const win = globalThis.window;
|
|
842
|
+
if (!scope || !win) return;
|
|
843
|
+
const target = scope === "window" ? win : element;
|
|
844
|
+
target.addEventListener("keydown", onKeyDown);
|
|
845
|
+
target.addEventListener("keyup", onKeyUp);
|
|
846
|
+
if (scope === "root") element.addEventListener("focusout", onFocusOut);
|
|
847
|
+
win.addEventListener("blur", releaseShortcuts);
|
|
848
|
+
boundScope = scope;
|
|
849
|
+
}
|
|
850
|
+
function unbindShortcuts() {
|
|
851
|
+
const win = globalThis.window;
|
|
852
|
+
if (!boundScope || !win) return;
|
|
853
|
+
const target = boundScope === "window" ? win : element;
|
|
854
|
+
target.removeEventListener("keydown", onKeyDown);
|
|
855
|
+
target.removeEventListener("keyup", onKeyUp);
|
|
856
|
+
element.removeEventListener("focusout", onFocusOut);
|
|
857
|
+
win.removeEventListener("blur", releaseShortcuts);
|
|
858
|
+
boundScope = null;
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* What the held keys were started against. Compared by value: a caller
|
|
862
|
+
* who writes the keys inline hands over a new array on every update, and
|
|
863
|
+
* releasing on that would stop a note as soon as anything re-renders.
|
|
864
|
+
*/
|
|
865
|
+
function shortcutMapping() {
|
|
866
|
+
const { first, last } = opts.layout.noteRange;
|
|
867
|
+
return [
|
|
868
|
+
opts.keyboardShortcuts?.keys.join("\0") ?? "",
|
|
869
|
+
opts.keyboardShortcutsScope ?? "root",
|
|
870
|
+
first,
|
|
871
|
+
last
|
|
872
|
+
].join("");
|
|
873
|
+
}
|
|
874
|
+
bindShortcuts();
|
|
875
|
+
const drag = require_xy.createDrag(element, {
|
|
1121
876
|
multiPointer: true,
|
|
1122
877
|
onDragStart: (state) => movePointer(state.pointerId, noteUnder(state.clientX, state.clientY)),
|
|
1123
878
|
onDrag: (state) => {
|
|
@@ -1128,10 +883,13 @@ function createPianoInput(element, options) {
|
|
|
1128
883
|
});
|
|
1129
884
|
return {
|
|
1130
885
|
update: (next) => {
|
|
886
|
+
const mapping = shortcutMapping();
|
|
1131
887
|
opts = {
|
|
1132
888
|
...opts,
|
|
1133
889
|
...next
|
|
1134
890
|
};
|
|
891
|
+
if (shortcutMapping() !== mapping) releaseShortcuts();
|
|
892
|
+
bindShortcuts();
|
|
1135
893
|
const midiMax = opts.midiMax ?? 127;
|
|
1136
894
|
const stoppedNotes = activeNotes().filter((note) => note > midiMax);
|
|
1137
895
|
if (stoppedNotes.length === 0) return;
|
|
@@ -1147,6 +905,8 @@ function createPianoInput(element, options) {
|
|
|
1147
905
|
activeNotes,
|
|
1148
906
|
destroy: () => {
|
|
1149
907
|
drag.destroy();
|
|
908
|
+
unbindShortcuts();
|
|
909
|
+
shortcutNotes.clear();
|
|
1150
910
|
const notes = activeNotes();
|
|
1151
911
|
for (const note of notes) {
|
|
1152
912
|
held.delete(note);
|
|
@@ -1158,24 +918,6 @@ function createPianoInput(element, options) {
|
|
|
1158
918
|
};
|
|
1159
919
|
}
|
|
1160
920
|
//#endregion
|
|
1161
|
-
//#region src/xy.ts
|
|
1162
|
-
function isPair(value) {
|
|
1163
|
-
return Array.isArray(value);
|
|
1164
|
-
}
|
|
1165
|
-
/**
|
|
1166
|
-
* Spread a setting that may have been given as a single value.
|
|
1167
|
-
*
|
|
1168
|
-
* The parameter is written out rather than taken as `XYInput<T>`: a
|
|
1169
|
-
* conditional type cannot be narrowed, so the constraint stays where it is
|
|
1170
|
-
* declared and this takes both forms.
|
|
1171
|
-
*
|
|
1172
|
-
* The pair is copied rather than passed along, so that the result is a tuple
|
|
1173
|
-
* the caller owns even when a readonly one was given.
|
|
1174
|
-
*/
|
|
1175
|
-
function toXY(value) {
|
|
1176
|
-
return isPair(value) ? [value[0], value[1]] : [value, value];
|
|
1177
|
-
}
|
|
1178
|
-
//#endregion
|
|
1179
921
|
//#region src/pointer/drag-value.ts
|
|
1180
922
|
/**
|
|
1181
923
|
* Map the pointer onto the bounding rect of an element: the value *is* the
|
|
@@ -1229,7 +971,7 @@ function elementMapping(getElement, { sensitivity } = {}) {
|
|
|
1229
971
|
* value can be adjusted from anywhere on the screen.
|
|
1230
972
|
*/
|
|
1231
973
|
function relativeMapping({ pixelRange = 100, sensitivity } = {}) {
|
|
1232
|
-
const [baseX, baseY] = toXY(pixelRange);
|
|
974
|
+
const [baseX, baseY] = require_xy.toXY(pixelRange);
|
|
1233
975
|
let origin = [0, 0];
|
|
1234
976
|
/** Where the current sensitivity took over, in drag coordinates. */
|
|
1235
977
|
let anchor = [0, 0];
|
|
@@ -1268,7 +1010,7 @@ function createDragValue(element, options) {
|
|
|
1268
1010
|
let opts = options;
|
|
1269
1011
|
let lastValue = [0, 0];
|
|
1270
1012
|
let active = false;
|
|
1271
|
-
const axes = () => toXY(opts.axis);
|
|
1013
|
+
const axes = () => require_xy.toXY(opts.axis);
|
|
1272
1014
|
function valueOf(position) {
|
|
1273
1015
|
return axes().map((axis, i) => {
|
|
1274
1016
|
const p = axis.reverse ? 1 - position[i] : position[i];
|
|
@@ -1286,7 +1028,7 @@ function createDragValue(element, options) {
|
|
|
1286
1028
|
return axis.reverse ? 1 - n : n;
|
|
1287
1029
|
});
|
|
1288
1030
|
} };
|
|
1289
|
-
const drag = createDrag(element, {
|
|
1031
|
+
const drag = require_xy.createDrag(element, {
|
|
1290
1032
|
threshold: opts.threshold,
|
|
1291
1033
|
cursor: opts.cursor,
|
|
1292
1034
|
pointerLock: opts.pointerLock,
|
|
@@ -1296,8 +1038,8 @@ function createDragValue(element, options) {
|
|
|
1296
1038
|
if (!position) return;
|
|
1297
1039
|
active = true;
|
|
1298
1040
|
lastValue = valueOf(position);
|
|
1299
|
-
if (opts.updateOnPointerDown) opts.onChange?.(lastValue, state);
|
|
1300
1041
|
opts.onDragStart?.(lastValue, state);
|
|
1042
|
+
if (opts.updateOnPointerDown) opts.onChange?.(lastValue, state);
|
|
1301
1043
|
},
|
|
1302
1044
|
onDrag: (state) => {
|
|
1303
1045
|
if (!active) return;
|
|
@@ -1336,6 +1078,73 @@ function createDragValue(element, options) {
|
|
|
1336
1078
|
};
|
|
1337
1079
|
}
|
|
1338
1080
|
//#endregion
|
|
1081
|
+
//#region src/pointer/long-press.ts
|
|
1082
|
+
/**
|
|
1083
|
+
* Repeat an action while a pointer is held down: once on the press, then
|
|
1084
|
+
* again every `interval` after `delay` — the way a stepper button or a
|
|
1085
|
+
* key held on a keyboard behaves.
|
|
1086
|
+
*
|
|
1087
|
+
* The release is listened for on the window, not on the element: the
|
|
1088
|
+
* pointer may be let go anywhere. Only the pointer that started the press
|
|
1089
|
+
* ends it, so a second finger lifting elsewhere does not. The window losing
|
|
1090
|
+
* focus ends it too, since the release would never arrive.
|
|
1091
|
+
*/
|
|
1092
|
+
function createLongPress(options) {
|
|
1093
|
+
let opts = options;
|
|
1094
|
+
let pointerId = null;
|
|
1095
|
+
let active = false;
|
|
1096
|
+
let timer = null;
|
|
1097
|
+
function clearTimer() {
|
|
1098
|
+
if (timer !== null) clearTimeout(timer);
|
|
1099
|
+
timer = null;
|
|
1100
|
+
}
|
|
1101
|
+
/** Wait `wait`, fire, and keep going at `interval` from then on. */
|
|
1102
|
+
function schedule(wait) {
|
|
1103
|
+
timer = setTimeout(() => {
|
|
1104
|
+
opts.onPress();
|
|
1105
|
+
if (active) schedule(opts.interval ?? 40);
|
|
1106
|
+
}, wait);
|
|
1107
|
+
}
|
|
1108
|
+
function onPointerEnd(event) {
|
|
1109
|
+
if (pointerId !== null && event.pointerId !== pointerId) return;
|
|
1110
|
+
stop();
|
|
1111
|
+
}
|
|
1112
|
+
function listen(add) {
|
|
1113
|
+
const target = globalThis.window;
|
|
1114
|
+
if (!target) return;
|
|
1115
|
+
const method = add ? "addEventListener" : "removeEventListener";
|
|
1116
|
+
target[method]("pointerup", onPointerEnd);
|
|
1117
|
+
target[method]("pointercancel", onPointerEnd);
|
|
1118
|
+
target[method]("blur", stop);
|
|
1119
|
+
}
|
|
1120
|
+
function stop() {
|
|
1121
|
+
if (!active) return;
|
|
1122
|
+
active = false;
|
|
1123
|
+
pointerId = null;
|
|
1124
|
+
clearTimer();
|
|
1125
|
+
listen(false);
|
|
1126
|
+
}
|
|
1127
|
+
return {
|
|
1128
|
+
start: (event) => {
|
|
1129
|
+
if (active || event && event.button !== 0) return;
|
|
1130
|
+
active = true;
|
|
1131
|
+
pointerId = event?.pointerId ?? null;
|
|
1132
|
+
listen(true);
|
|
1133
|
+
opts.onPress();
|
|
1134
|
+
if (active) schedule(opts.delay ?? 500);
|
|
1135
|
+
},
|
|
1136
|
+
stop,
|
|
1137
|
+
update: (next) => {
|
|
1138
|
+
opts = {
|
|
1139
|
+
...opts,
|
|
1140
|
+
...next
|
|
1141
|
+
};
|
|
1142
|
+
},
|
|
1143
|
+
pressed: () => active,
|
|
1144
|
+
destroy: stop
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
//#endregion
|
|
1339
1148
|
//#region src/pointer/wheel.ts
|
|
1340
1149
|
/**
|
|
1341
1150
|
* Listen to wheel events on an element.
|
|
@@ -1343,18 +1152,15 @@ function createDragValue(element, options) {
|
|
|
1343
1152
|
* The listener is registered with `passive: false` so that the handler can call
|
|
1344
1153
|
* `preventDefault()` to stop the page from scrolling.
|
|
1345
1154
|
*/
|
|
1346
|
-
function createWheel(element,
|
|
1347
|
-
let opts =
|
|
1348
|
-
...options,
|
|
1349
|
-
onWheel
|
|
1350
|
-
};
|
|
1155
|
+
function createWheel(element, options) {
|
|
1156
|
+
let opts = options;
|
|
1351
1157
|
function hasFocus() {
|
|
1352
1158
|
const active = element.ownerDocument?.activeElement;
|
|
1353
1159
|
return !!active && element.contains(active);
|
|
1354
1160
|
}
|
|
1355
1161
|
const handler = (event) => {
|
|
1356
1162
|
if (opts.requireFocus && !hasFocus()) return;
|
|
1357
|
-
opts.onWheel
|
|
1163
|
+
opts.onWheel(event);
|
|
1358
1164
|
};
|
|
1359
1165
|
element.addEventListener("wheel", handler, { passive: false });
|
|
1360
1166
|
return {
|
|
@@ -1370,101 +1176,43 @@ function createWheel(element, onWheel, options = {}) {
|
|
|
1370
1176
|
};
|
|
1371
1177
|
}
|
|
1372
1178
|
//#endregion
|
|
1373
|
-
//#region src/
|
|
1374
|
-
function rectOf(from, to) {
|
|
1375
|
-
return {
|
|
1376
|
-
x: Math.min(from[0], to[0]),
|
|
1377
|
-
y: Math.min(from[1], to[1]),
|
|
1378
|
-
width: Math.abs(to[0] - from[0]),
|
|
1379
|
-
height: Math.abs(to[1] - from[1])
|
|
1380
|
-
};
|
|
1381
|
-
}
|
|
1382
|
-
/** Whether the box covers the point, edges included. */
|
|
1383
|
-
function selectionBoxCovers(box, [x, y]) {
|
|
1384
|
-
return x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height;
|
|
1385
|
-
}
|
|
1179
|
+
//#region src/position.ts
|
|
1386
1180
|
/**
|
|
1387
|
-
*
|
|
1181
|
+
* Where a value sits along a track, as a whole percentage from its start.
|
|
1388
1182
|
*
|
|
1389
|
-
*
|
|
1390
|
-
*
|
|
1183
|
+
* Normalized along the scale, so a thumb, the fill behind it and the marks
|
|
1184
|
+
* all sit on the curve the drag follows. `reversed` measures from the other
|
|
1185
|
+
* end — for a track that grows upwards or leftwards on screen, since CSS
|
|
1186
|
+
* places things from the top and the left.
|
|
1391
1187
|
*/
|
|
1392
|
-
function
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
let box = null;
|
|
1396
|
-
function setBox(next) {
|
|
1397
|
-
box = next;
|
|
1398
|
-
opts.onBoxChange?.(next);
|
|
1399
|
-
}
|
|
1400
|
-
function apply(rect, base) {
|
|
1401
|
-
const inside = [];
|
|
1402
|
-
for (const [id, at] of opts.items()) if (selectionBoxCovers(rect, at) && !base.includes(id)) inside.push(id);
|
|
1403
|
-
opts.onSelectionChange?.([...base, ...inside]);
|
|
1404
|
-
}
|
|
1405
|
-
return {
|
|
1406
|
-
update: (next) => {
|
|
1407
|
-
opts = {
|
|
1408
|
-
...opts,
|
|
1409
|
-
...next
|
|
1410
|
-
};
|
|
1411
|
-
},
|
|
1412
|
-
begin: (at, { additive = false, selection = [] } = {}) => {
|
|
1413
|
-
drag = {
|
|
1414
|
-
from: at,
|
|
1415
|
-
base: additive ? [...selection] : []
|
|
1416
|
-
};
|
|
1417
|
-
setBox(rectOf(at, at));
|
|
1418
|
-
if (!additive) opts.onSelectionChange?.([]);
|
|
1419
|
-
},
|
|
1420
|
-
move: (to) => {
|
|
1421
|
-
if (!drag) return;
|
|
1422
|
-
const rect = rectOf(drag.from, to);
|
|
1423
|
-
setBox(rect);
|
|
1424
|
-
apply(rect, drag.base);
|
|
1425
|
-
},
|
|
1426
|
-
end: () => {
|
|
1427
|
-
const running = drag !== null;
|
|
1428
|
-
drag = null;
|
|
1429
|
-
if (box !== null) setBox(null);
|
|
1430
|
-
return running;
|
|
1431
|
-
},
|
|
1432
|
-
box: () => box,
|
|
1433
|
-
destroy: () => {
|
|
1434
|
-
drag = null;
|
|
1435
|
-
box = null;
|
|
1436
|
-
}
|
|
1437
|
-
};
|
|
1188
|
+
function valuePercent(value, { min, max, scale = _tremolo_ui_functions.linearScale }, reversed = false) {
|
|
1189
|
+
const normalized = scale.normalize(value, min, max);
|
|
1190
|
+
return (0, _tremolo_ui_functions.toFixed)((reversed ? 1 - normalized : normalized) * 100);
|
|
1438
1191
|
}
|
|
1439
1192
|
//#endregion
|
|
1440
|
-
exports.
|
|
1441
|
-
exports.
|
|
1442
|
-
exports.
|
|
1443
|
-
exports.
|
|
1444
|
-
exports.
|
|
1445
|
-
exports.blackKeyWidth = blackKeyWidth;
|
|
1193
|
+
exports.DEFAULT_DRAG_SENSITIVITY = require_xy.DEFAULT_DRAG_SENSITIVITY;
|
|
1194
|
+
exports.DEFAULT_KEYBOARD_OPTIONS = require_xy.DEFAULT_KEYBOARD_OPTIONS;
|
|
1195
|
+
exports.DEFAULT_WHEEL_OPTIONS = require_xy.DEFAULT_WHEEL_OPTIONS;
|
|
1196
|
+
exports.KNOB_VIEWBOX_SIZE = KNOB_VIEWBOX_SIZE;
|
|
1197
|
+
exports.SHORTCUTS = SHORTCUTS;
|
|
1446
1198
|
exports.createAnimationCanvas = createAnimationCanvas;
|
|
1447
|
-
exports.
|
|
1199
|
+
exports.createChangeGesture = createChangeGesture;
|
|
1200
|
+
exports.createDrag = require_xy.createDrag;
|
|
1448
1201
|
exports.createDragValue = createDragValue;
|
|
1449
1202
|
exports.createDropZone = createDropZone;
|
|
1203
|
+
exports.createLongPress = createLongPress;
|
|
1450
1204
|
exports.createMIDIAccess = createMIDIAccess;
|
|
1451
1205
|
exports.createMIDIInput = createMIDIInput;
|
|
1452
1206
|
exports.createMIDIMessage = createMIDIMessage;
|
|
1453
1207
|
exports.createPianoInput = createPianoInput;
|
|
1454
|
-
exports.
|
|
1208
|
+
exports.createPointsEditor = require_xy.createPointsEditor;
|
|
1455
1209
|
exports.createWheel = createWheel;
|
|
1456
|
-
exports.drawingState = drawingState;
|
|
1457
1210
|
exports.elementMapping = elementMapping;
|
|
1458
|
-
exports.
|
|
1459
|
-
exports.
|
|
1460
|
-
exports.
|
|
1461
|
-
exports.
|
|
1462
|
-
exports.noteAt = noteAt;
|
|
1463
|
-
exports.notePosition = notePosition;
|
|
1464
|
-
exports.pianoWidth = pianoWidth;
|
|
1211
|
+
exports.knobAngles = knobAngles;
|
|
1212
|
+
exports.knobArcPath = knobArcPath;
|
|
1213
|
+
exports.knobArcRadius = knobArcRadius;
|
|
1214
|
+
exports.notePosition = require_xy.notePosition;
|
|
1465
1215
|
exports.relativeMapping = relativeMapping;
|
|
1466
|
-
exports.
|
|
1467
|
-
exports.selectionBoxCovers = selectionBoxCovers;
|
|
1468
|
-
exports.toXY = toXY;
|
|
1216
|
+
exports.valuePercent = valuePercent;
|
|
1469
1217
|
|
|
1470
1218
|
//# sourceMappingURL=index.cjs.map
|