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