@tremolo-ui/dom 0.7.0 → 0.8.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 +1298 -235
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +767 -84
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +767 -84
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1263 -237
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/file/accept.ts +17 -0
- package/src/file/drop-zone.ts +8 -10
- package/src/index.ts +76 -1
- package/src/input/check-steps.ts +144 -0
- package/src/input/defaults.ts +32 -0
- package/src/input/direction.ts +107 -0
- package/src/knob/geometry.ts +100 -0
- package/src/number-input/stepper-drag.ts +139 -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/long-press.ts +103 -0
- package/src/points-editor/index.ts +361 -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
|
@@ -268,6 +268,20 @@ function matchesAccept(candidate, accept) {
|
|
|
268
268
|
} else if (type === rule) return true;
|
|
269
269
|
return undecided;
|
|
270
270
|
}
|
|
271
|
+
/**
|
|
272
|
+
* Split files into those that satisfy `accept` and those that do not, keeping
|
|
273
|
+
* their order. See {@link matchesAccept}.
|
|
274
|
+
*/
|
|
275
|
+
function partitionByAccept(files, accept) {
|
|
276
|
+
const accepted = [];
|
|
277
|
+
const rejected = [];
|
|
278
|
+
for (const file of files) if (matchesAccept(file, accept)) accepted.push(file);
|
|
279
|
+
else rejected.push(file);
|
|
280
|
+
return {
|
|
281
|
+
accepted,
|
|
282
|
+
rejected
|
|
283
|
+
};
|
|
284
|
+
}
|
|
271
285
|
//#endregion
|
|
272
286
|
//#region src/file/drop-zone.ts
|
|
273
287
|
/**
|
|
@@ -347,14 +361,10 @@ function createDropZone(element, options = {}) {
|
|
|
347
361
|
invalid: false
|
|
348
362
|
});
|
|
349
363
|
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);
|
|
364
|
+
const { accepted, rejected } = partitionByAccept(event.dataTransfer?.files ?? [], opts.accept);
|
|
356
365
|
const taken = opts.multiple ? accepted : accepted.slice(0, 1);
|
|
357
366
|
if (taken.length > 0) opts.onDrop?.(taken, event);
|
|
367
|
+
if (rejected.length > 0) opts.onReject?.(rejected, event);
|
|
358
368
|
}
|
|
359
369
|
/**
|
|
360
370
|
* A drag that ends anywhere else — dropped on another element, cancelled
|
|
@@ -501,6 +511,247 @@ function applyDelta(value, direction, options, { min, max, step, scale = _tremol
|
|
|
501
511
|
return (0, _tremolo_ui_functions.clamp)((0, _tremolo_ui_functions.toPrecision)(stepped), min, max);
|
|
502
512
|
}
|
|
503
513
|
//#endregion
|
|
514
|
+
//#region src/input/check-steps.ts
|
|
515
|
+
/** Positions probed across the travel. The ends are left out so that the
|
|
516
|
+
* clamp at `min` and `max` cannot be mistaken for a press that does nothing. */
|
|
517
|
+
const PROBES = [
|
|
518
|
+
.1,
|
|
519
|
+
.2,
|
|
520
|
+
.3,
|
|
521
|
+
.4,
|
|
522
|
+
.5,
|
|
523
|
+
.6,
|
|
524
|
+
.7,
|
|
525
|
+
.8,
|
|
526
|
+
.9
|
|
527
|
+
];
|
|
528
|
+
const NONE = {
|
|
529
|
+
shiftKey: false,
|
|
530
|
+
altKey: false,
|
|
531
|
+
ctrlKey: false,
|
|
532
|
+
metaKey: false
|
|
533
|
+
};
|
|
534
|
+
const MODIFIER_STATE = {
|
|
535
|
+
shift: {
|
|
536
|
+
...NONE,
|
|
537
|
+
shiftKey: true
|
|
538
|
+
},
|
|
539
|
+
alt: {
|
|
540
|
+
...NONE,
|
|
541
|
+
altKey: true
|
|
542
|
+
},
|
|
543
|
+
ctrl: {
|
|
544
|
+
...NONE,
|
|
545
|
+
ctrlKey: true
|
|
546
|
+
},
|
|
547
|
+
meta: {
|
|
548
|
+
...NONE,
|
|
549
|
+
metaKey: true
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
/** The held keys that select each entry of a modifier-aware input option. */
|
|
553
|
+
function entries(options) {
|
|
554
|
+
if (Array.isArray(options)) return [NONE];
|
|
555
|
+
return Object.keys(options).map((key) => MODIFIER_STATE[key] ?? NONE);
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Press every entry of `options` at nine points along the travel and report
|
|
559
|
+
* whether anything came of it.
|
|
560
|
+
*
|
|
561
|
+
* Run against `applyDelta` itself rather than against a reading of `step`:
|
|
562
|
+
* the whole point is that the amount, the step and the scale interact, and
|
|
563
|
+
* the pipeline is the only thing that knows how.
|
|
564
|
+
*/
|
|
565
|
+
function probe(options, range, format) {
|
|
566
|
+
const { min, max, scale = _tremolo_ui_functions.linearScale } = range;
|
|
567
|
+
const outcome = {
|
|
568
|
+
moved: false,
|
|
569
|
+
visible: false
|
|
570
|
+
};
|
|
571
|
+
for (const modifiers of entries(options)) for (const position of PROBES) {
|
|
572
|
+
const value = scale.denormalize(position, min, max);
|
|
573
|
+
const up = applyDelta(value, 1, options, range, modifiers);
|
|
574
|
+
const down = applyDelta(value, -1, options, range, modifiers);
|
|
575
|
+
if (up !== value || down !== value) outcome.moved = true;
|
|
576
|
+
if (!format) continue;
|
|
577
|
+
const shown = format(value);
|
|
578
|
+
if (format(up) !== shown || format(down) !== shown) outcome.visible = true;
|
|
579
|
+
}
|
|
580
|
+
return outcome;
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* The warnings to show when a key press or a wheel notch cannot produce a
|
|
584
|
+
* change the user can see. Empty when there is nothing to say.
|
|
585
|
+
*
|
|
586
|
+
* Two settings that are each fine on their own can cancel out, and nothing
|
|
587
|
+
* fails when they do — the control simply sits there:
|
|
588
|
+
*
|
|
589
|
+
* - **`step` coarser than the amount.** `keyboard={['raw', 0.1]}` with
|
|
590
|
+
* `step={1}` rounds every press straight back to where it started
|
|
591
|
+
* - **the display coarser than the amount.** A `format` showing two decimals
|
|
592
|
+
* of a kHz value cannot show a press worth 1 Hz
|
|
593
|
+
*
|
|
594
|
+
* The second is only reported when the press is invisible at *every* point
|
|
595
|
+
* along the travel. A display that rounds is a deliberate choice and is
|
|
596
|
+
* normally right — it is being too coarse everywhere that makes it a mistake.
|
|
597
|
+
*
|
|
598
|
+
* **Meant for development builds only.** It probes the whole travel, so call
|
|
599
|
+
* it behind an inline `process.env.NODE_ENV` check: the bundler then drops
|
|
600
|
+
* the call, and this function and its messages with it.
|
|
601
|
+
*/
|
|
602
|
+
function checkSteps({ component, axis, range, keyboard, wheel, format }) {
|
|
603
|
+
if (!range || !(range.min < range.max)) return [];
|
|
604
|
+
const where = axis ? `${component} (${axis})` : component;
|
|
605
|
+
const warnings = [];
|
|
606
|
+
for (const [name, options] of [["keyboard", keyboard], ["wheel", wheel]]) {
|
|
607
|
+
if (!options) continue;
|
|
608
|
+
const { moved, visible } = probe(options, range, format);
|
|
609
|
+
if (!moved) warnings.push(`[tremolo-ui] ${where}: \`${name}\` cannot move the value.` + (range.step !== void 0 ? ` Each press is smaller than \`step\` (${range.step}), so it rounds` : " Each press rounds") + " straight back to where it started.");
|
|
610
|
+
else if (format && !visible) warnings.push(`[tremolo-ui] ${where}: \`${name}\` moves the value, but \`format\` shows the same text before and after, everywhere in the range. The display is too coarse for it to be seen.`);
|
|
611
|
+
}
|
|
612
|
+
return warnings;
|
|
613
|
+
}
|
|
614
|
+
//#endregion
|
|
615
|
+
//#region src/input/defaults.ts
|
|
616
|
+
/**
|
|
617
|
+
* The keyboard amount used by Knob, NumberInput, Slider, and XYPad by default:
|
|
618
|
+
* 1 per press in the units of the value, and 0.1 with shift held. That is one
|
|
619
|
+
* `step` only while `step` is 1; a coarser `step` rounds 1 straight back, which
|
|
620
|
+
* `checkSteps` warns about.
|
|
621
|
+
*
|
|
622
|
+
* A modifier entry is not snapped to `step`, which is what lets the finer
|
|
623
|
+
* amount move at all — see `applyDelta`.
|
|
624
|
+
*/
|
|
625
|
+
const DEFAULT_KEYBOARD_OPTIONS = {
|
|
626
|
+
default: ["raw", 1],
|
|
627
|
+
shift: ["raw", .1]
|
|
628
|
+
};
|
|
629
|
+
/**
|
|
630
|
+
* The wheel amount used by Knob, NumberInput, Slider, and XYPad by default.
|
|
631
|
+
*
|
|
632
|
+
* Browsers turn shift+wheel into horizontal scrolling, which empties `deltaY`
|
|
633
|
+
* and fills `deltaX`, so no modifier is bound here.
|
|
634
|
+
*/
|
|
635
|
+
const DEFAULT_WHEEL_OPTIONS = ["raw", 1];
|
|
636
|
+
/**
|
|
637
|
+
* The drag sensitivity used by value controls by default. Shift makes the
|
|
638
|
+
* same movement cover a tenth of the range, matching the arrow keys.
|
|
639
|
+
*/
|
|
640
|
+
const DEFAULT_DRAG_SENSITIVITY = {
|
|
641
|
+
default: 1,
|
|
642
|
+
shift: .1
|
|
643
|
+
};
|
|
644
|
+
//#endregion
|
|
645
|
+
//#region src/input/direction.ts
|
|
646
|
+
/** Is `key` one of the four arrow keys? */
|
|
647
|
+
function isArrowKey(key) {
|
|
648
|
+
return key === "ArrowRight" || key === "ArrowLeft" || key === "ArrowUp" || key === "ArrowDown";
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* The direction an arrow key moves a single value: right and up raise it.
|
|
652
|
+
* `null` for any other key.
|
|
653
|
+
*/
|
|
654
|
+
function arrowKeyDirection(key) {
|
|
655
|
+
if (!isArrowKey(key)) return null;
|
|
656
|
+
return key === "ArrowRight" || key === "ArrowUp" ? 1 : -1;
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* The axis and direction an arrow key moves a position on screen. `null` for
|
|
660
|
+
* any other key.
|
|
661
|
+
*
|
|
662
|
+
* The key picks the axis, whichever element inside the control holds the
|
|
663
|
+
* focus: a two-dimensional control is one control to the person moving it.
|
|
664
|
+
*/
|
|
665
|
+
function arrowKeyMove(key) {
|
|
666
|
+
if (!isArrowKey(key)) return null;
|
|
667
|
+
return {
|
|
668
|
+
axis: key === "ArrowRight" || key === "ArrowLeft" ? 0 : 1,
|
|
669
|
+
direction: key === "ArrowLeft" || key === "ArrowUp" ? -1 : 1
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* The direction one wheel event moves a single value: scrolling up raises it.
|
|
674
|
+
* `null` when the event carries no movement the control reads.
|
|
675
|
+
*/
|
|
676
|
+
function wheelDirection(event, { horizontal = false } = {}) {
|
|
677
|
+
if (horizontal && event.deltaX !== 0) return event.deltaX > 0 ? 1 : -1;
|
|
678
|
+
if (event.deltaY === 0) return null;
|
|
679
|
+
return event.deltaY > 0 ? -1 : 1;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* The axis and direction one wheel event moves a position on screen. `null`
|
|
683
|
+
* when the event carries no movement.
|
|
684
|
+
*
|
|
685
|
+
* Scrolling moves y, and shift switches to x. Browsers turn shift+wheel into
|
|
686
|
+
* horizontal scrolling: `deltaY` comes out empty and `deltaX` carries the
|
|
687
|
+
* movement. Reading whichever axis moved keeps shift working as the x-axis
|
|
688
|
+
* modifier — and picks up a trackpad's own horizontal gesture, which never
|
|
689
|
+
* had a modifier.
|
|
690
|
+
*/
|
|
691
|
+
function wheelMove(event) {
|
|
692
|
+
const horizontal = event.deltaX !== 0;
|
|
693
|
+
const delta = horizontal ? event.deltaX : event.deltaY;
|
|
694
|
+
if (delta === 0) return null;
|
|
695
|
+
return {
|
|
696
|
+
axis: horizontal || event.shiftKey ? 0 : 1,
|
|
697
|
+
direction: delta < 0 ? -1 : 1
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
//#endregion
|
|
701
|
+
//#region src/knob/geometry.ts
|
|
702
|
+
/**
|
|
703
|
+
* Width and height of the viewBox a knob is drawn in. The arcs and the thumb
|
|
704
|
+
* are laid out in these units, and the SVG scales them to the knob's size.
|
|
705
|
+
*/
|
|
706
|
+
const KNOB_VIEWBOX_SIZE = 100;
|
|
707
|
+
const center = 50;
|
|
708
|
+
function knobAngles({ value, min, max, scale, startValue, angleRange }) {
|
|
709
|
+
const p = scale.normalize(value, min, max);
|
|
710
|
+
const s = scale.normalize(startValue, min, max);
|
|
711
|
+
const r1 = -angleRange / 2;
|
|
712
|
+
return {
|
|
713
|
+
p,
|
|
714
|
+
r1,
|
|
715
|
+
r2: r1 + Math.min(p, s) * angleRange,
|
|
716
|
+
r3: r1 + Math.max(p, s) * angleRange,
|
|
717
|
+
r4: angleRange / 2
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* The point at `angle` on a circle of `radius` around the centre of the
|
|
722
|
+
* viewBox.
|
|
723
|
+
*
|
|
724
|
+
* The radius depends on the stroke of the line being drawn, which each arc
|
|
725
|
+
* has its own of, so the point is found per arc rather than once for the knob.
|
|
726
|
+
*/
|
|
727
|
+
function knobArcPoint(angle, radius) {
|
|
728
|
+
return {
|
|
729
|
+
x: center + radius * Math.cos((0, _tremolo_ui_functions.radian)(angle - 90)),
|
|
730
|
+
y: center + radius * Math.sin((0, _tremolo_ui_functions.radian)(angle - 90))
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* The radius that keeps a stroke of `strokeWidth` inside the viewBox: half of
|
|
735
|
+
* the stroke falls outside the path it is drawn along.
|
|
736
|
+
*/
|
|
737
|
+
function knobArcRadius(strokeWidth) {
|
|
738
|
+
const width = typeof strokeWidth === "number" ? strokeWidth : Number.parseFloat(String(strokeWidth));
|
|
739
|
+
return Number.isFinite(width) ? center - width / 2 : center;
|
|
740
|
+
}
|
|
741
|
+
/** Build an SVG path for an arc, splitting full turns into drawable segments. */
|
|
742
|
+
function knobArcPath(startAngle, endAngle, radius) {
|
|
743
|
+
const start = knobArcPoint(startAngle, radius);
|
|
744
|
+
const sweep = endAngle - startAngle;
|
|
745
|
+
const segmentCount = Math.max(1, Math.ceil(Math.abs(sweep) / 180));
|
|
746
|
+
const segmentSweep = sweep / segmentCount;
|
|
747
|
+
let path = `M ${start.x} ${start.y}`;
|
|
748
|
+
for (let i = 1; i <= segmentCount; i += 1) {
|
|
749
|
+
const end = knobArcPoint(startAngle + segmentSweep * i, radius);
|
|
750
|
+
path += ` A ${radius} ${radius} 0 0 ${segmentSweep >= 0 ? 1 : 0} ${end.x} ${end.y}`;
|
|
751
|
+
}
|
|
752
|
+
return path;
|
|
753
|
+
}
|
|
754
|
+
//#endregion
|
|
504
755
|
//#region src/midi/access.ts
|
|
505
756
|
/** @private */
|
|
506
757
|
const PERMISSION_DENIED = "PERMISSION_DENIED";
|
|
@@ -714,113 +965,6 @@ function createMIDIInput(midiAccess, handlers) {
|
|
|
714
965
|
};
|
|
715
966
|
}
|
|
716
967
|
//#endregion
|
|
717
|
-
//#region src/piano/layout.ts
|
|
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;
|
|
727
|
-
/**
|
|
728
|
-
* How many white keys sit at or before each pitch class, counting from C.
|
|
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.
|
|
733
|
-
*/
|
|
734
|
-
const whiteKeysBefore = {
|
|
735
|
-
C: 0,
|
|
736
|
-
"C#": 1,
|
|
737
|
-
D: 1,
|
|
738
|
-
"D#": 2,
|
|
739
|
-
E: 2,
|
|
740
|
-
F: 3,
|
|
741
|
-
"F#": 4,
|
|
742
|
-
G: 4,
|
|
743
|
-
"G#": 5,
|
|
744
|
-
A: 5,
|
|
745
|
-
"A#": 6,
|
|
746
|
-
B: 6
|
|
747
|
-
};
|
|
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
968
|
//#region src/pointer/drag.ts
|
|
825
969
|
/**
|
|
826
970
|
* Applied to the element for the lifetime of the instance.
|
|
@@ -1057,74 +1201,67 @@ function createDrag(element, options = {}) {
|
|
|
1057
1201
|
};
|
|
1058
1202
|
}
|
|
1059
1203
|
//#endregion
|
|
1060
|
-
//#region src/
|
|
1061
|
-
const DEFAULT_SOURCE = "api";
|
|
1204
|
+
//#region src/number-input/stepper-drag.ts
|
|
1062
1205
|
/**
|
|
1063
|
-
*
|
|
1064
|
-
*
|
|
1065
|
-
* Every way of playing a note goes through the one instance — pointers here,
|
|
1066
|
-
* keyboard shortcuts and MIDI through {@link PianoInputInstance.noteOn} — so
|
|
1067
|
-
* there is a single answer to what is currently held, and drawing the keys is
|
|
1068
|
-
* left entirely to the wrapper.
|
|
1206
|
+
* Drag up and down on the steppers of a number input to move its value, one
|
|
1207
|
+
* `step` every `pixels` — up raises it, as on a knob.
|
|
1069
1208
|
*
|
|
1070
|
-
*
|
|
1209
|
+
* Counted from where the drag started moving rather than added up per
|
|
1210
|
+
* event, so rounding cannot accumulate. The start is taken on the first
|
|
1211
|
+
* move, not on pointerdown: a stepper button acts on pointerdown, so by then
|
|
1212
|
+
* the value may already have been nudged once, and the drag carries on from
|
|
1213
|
+
* there.
|
|
1071
1214
|
*/
|
|
1072
|
-
function
|
|
1215
|
+
function createStepperDrag(element, options) {
|
|
1073
1216
|
let opts = options;
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
/**
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
if (note > (opts.midiMax ?? 127)) return;
|
|
1084
|
-
const sources = held.get(note);
|
|
1085
|
-
if (sources) {
|
|
1086
|
-
sources.add(source);
|
|
1087
|
-
return;
|
|
1088
|
-
}
|
|
1089
|
-
held.set(note, /* @__PURE__ */ new Set([source]));
|
|
1090
|
-
opts.onPlayNote?.(note, velocity);
|
|
1091
|
-
opts.onActiveNotesChange?.(activeNotes());
|
|
1092
|
-
}
|
|
1093
|
-
function noteOff(note, { source = DEFAULT_SOURCE } = {}) {
|
|
1094
|
-
if (!Number.isInteger(note) || note < 0 || note > 127) throw new RangeError("note: requirements: an integer from 0 to 127");
|
|
1095
|
-
const sources = held.get(note);
|
|
1096
|
-
if (!sources) return;
|
|
1097
|
-
sources.delete(source);
|
|
1098
|
-
if (sources.size > 0) return;
|
|
1099
|
-
held.delete(note);
|
|
1100
|
-
opts.onStopNote?.(note);
|
|
1101
|
-
opts.onActiveNotesChange?.(activeNotes());
|
|
1102
|
-
}
|
|
1103
|
-
/** The note under a pointer, or null where the pointer is off the keys. */
|
|
1104
|
-
function noteUnder(clientX, clientY) {
|
|
1105
|
-
const { left, top, height } = element.getBoundingClientRect();
|
|
1106
|
-
return noteAt(clientX - left, clientY - top, height, opts.layout);
|
|
1107
|
-
}
|
|
1108
|
-
/** Move a pointer onto a note, releasing whatever it held before. */
|
|
1109
|
-
function movePointer(pointerId, note) {
|
|
1110
|
-
const source = `pointer:${pointerId}`;
|
|
1111
|
-
const previous = pointerNotes.get(pointerId);
|
|
1112
|
-
if (previous === note) return;
|
|
1113
|
-
if (previous !== void 0) noteOff(previous, { source });
|
|
1114
|
-
if (note === null || note > (opts.midiMax ?? 127)) pointerNotes.delete(pointerId);
|
|
1115
|
-
else {
|
|
1116
|
-
pointerNotes.set(pointerId, note);
|
|
1117
|
-
noteOn(note, { source });
|
|
1118
|
-
}
|
|
1119
|
-
}
|
|
1217
|
+
let origin = null;
|
|
1218
|
+
let moved = false;
|
|
1219
|
+
/**
|
|
1220
|
+
* Which sensitivity the drag is counting at, and where the previous event
|
|
1221
|
+
* was — a key produces no pointer event of its own, so a change is only
|
|
1222
|
+
* seen on the next move and has to be dated back to the one before it.
|
|
1223
|
+
*/
|
|
1224
|
+
let factor = 1;
|
|
1225
|
+
let previousY = 0;
|
|
1120
1226
|
const drag = createDrag(element, {
|
|
1121
|
-
|
|
1122
|
-
|
|
1227
|
+
threshold: 1,
|
|
1228
|
+
cursor: "ns-resize",
|
|
1229
|
+
pointerLock: opts.pointerLock,
|
|
1230
|
+
onDragStart: (state) => {
|
|
1231
|
+
origin = null;
|
|
1232
|
+
moved = false;
|
|
1233
|
+
factor = selectModifier(opts.sensitivity ?? DEFAULT_DRAG_SENSITIVITY, state.event).value;
|
|
1234
|
+
},
|
|
1123
1235
|
onDrag: (state) => {
|
|
1124
|
-
|
|
1125
|
-
|
|
1236
|
+
const { y } = state;
|
|
1237
|
+
if (!origin) {
|
|
1238
|
+
origin = {
|
|
1239
|
+
y,
|
|
1240
|
+
value: opts.getValue()
|
|
1241
|
+
};
|
|
1242
|
+
previousY = y;
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
const sensitivity = opts.sensitivity ?? DEFAULT_DRAG_SENSITIVITY;
|
|
1246
|
+
const next = selectModifier(sensitivity, state.event).value;
|
|
1247
|
+
if (next !== factor) {
|
|
1248
|
+
origin = {
|
|
1249
|
+
y: previousY,
|
|
1250
|
+
value: opts.getValue()
|
|
1251
|
+
};
|
|
1252
|
+
factor = next;
|
|
1253
|
+
}
|
|
1254
|
+
previousY = y;
|
|
1255
|
+
const steps = Math.round(-(y - origin.y) / (opts.pixels ?? 1));
|
|
1256
|
+
if (steps === 0) return;
|
|
1257
|
+
moved = true;
|
|
1258
|
+
const step = opts.range.step ?? 1;
|
|
1259
|
+
const amounts = mapModifier(sensitivity, (f) => ["raw", step * f]);
|
|
1260
|
+
opts.onChange(applyDelta(origin.value, steps, amounts, opts.range, state.event));
|
|
1126
1261
|
},
|
|
1127
|
-
onDragEnd: (
|
|
1262
|
+
onDragEnd: () => {
|
|
1263
|
+
moved = false;
|
|
1264
|
+
}
|
|
1128
1265
|
});
|
|
1129
1266
|
return {
|
|
1130
1267
|
update: (next) => {
|
|
@@ -1132,8 +1269,535 @@ function createPianoInput(element, options) {
|
|
|
1132
1269
|
...opts,
|
|
1133
1270
|
...next
|
|
1134
1271
|
};
|
|
1135
|
-
|
|
1136
|
-
|
|
1272
|
+
if ("pointerLock" in next) drag.update({ pointerLock: opts.pointerLock });
|
|
1273
|
+
},
|
|
1274
|
+
moved: () => moved,
|
|
1275
|
+
destroy: () => drag.destroy()
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
//#endregion
|
|
1279
|
+
//#region src/number-input/value.ts
|
|
1280
|
+
/**
|
|
1281
|
+
* The ranges a number input moves its value across, with the open ends
|
|
1282
|
+
* filled in.
|
|
1283
|
+
*
|
|
1284
|
+
* A normalized amount needs a finite span even when an end is unbounded or
|
|
1285
|
+
* clamping is off. Safe integers provide one without overflowing the span a
|
|
1286
|
+
* scale calculates. A raw amount needs no span, so its open ends can cover
|
|
1287
|
+
* every finite number instead of stopping at the safe-integer range.
|
|
1288
|
+
*/
|
|
1289
|
+
function numberInputRanges({ min, max, step, scale, clampValue = true }) {
|
|
1290
|
+
const lo = clampValue ? min : void 0;
|
|
1291
|
+
const hi = clampValue ? max : void 0;
|
|
1292
|
+
return {
|
|
1293
|
+
normalized: {
|
|
1294
|
+
min: lo ?? Number.MIN_SAFE_INTEGER,
|
|
1295
|
+
max: hi ?? Number.MAX_SAFE_INTEGER,
|
|
1296
|
+
step,
|
|
1297
|
+
scale
|
|
1298
|
+
},
|
|
1299
|
+
raw: {
|
|
1300
|
+
min: lo ?? -Number.MAX_VALUE,
|
|
1301
|
+
max: hi ?? Number.MAX_VALUE,
|
|
1302
|
+
step,
|
|
1303
|
+
scale
|
|
1304
|
+
}
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Move a number input's value by one press of a key, a wheel notch or a
|
|
1309
|
+
* stepper, picking the range that suits the kind of amount. See `applyDelta`.
|
|
1310
|
+
*/
|
|
1311
|
+
function nudgeNumberInput(value, direction, options, ranges, modifiers) {
|
|
1312
|
+
const [mode] = selectModifier(options, modifiers).value;
|
|
1313
|
+
return applyDelta(value, direction, options, mode === "raw" ? ranges.raw : ranges.normalized, modifiers);
|
|
1314
|
+
}
|
|
1315
|
+
/**
|
|
1316
|
+
* Where the value stands against the ends: whether it can go no further
|
|
1317
|
+
* down or up, and whether it lies outside them — which only an unclamped
|
|
1318
|
+
* input, or a value set from outside, can do.
|
|
1319
|
+
*/
|
|
1320
|
+
function numberInputBounds(value, { min, max, clampValue = true }) {
|
|
1321
|
+
return {
|
|
1322
|
+
atMin: clampValue && min !== void 0 && value <= min,
|
|
1323
|
+
atMax: clampValue && max !== void 0 && value >= max,
|
|
1324
|
+
outOfRange: min !== void 0 && value < min || max !== void 0 && value > max
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
/**
|
|
1328
|
+
* The value typed text commits to, or `null` when there is no number in it.
|
|
1329
|
+
*
|
|
1330
|
+
* Text with no number is not a value: the input should go back to what it
|
|
1331
|
+
* was showing rather than commit a zero the user never typed. What is read
|
|
1332
|
+
* is clamped here and not while typing, since clamping as the user types
|
|
1333
|
+
* would make "1500" impossible to enter into an input whose max is 100.
|
|
1334
|
+
*/
|
|
1335
|
+
function commitNumberInputText(text, parse, { min, max, clampValue = true }) {
|
|
1336
|
+
const parsed = parse(text);
|
|
1337
|
+
if (!Number.isFinite(parsed)) return null;
|
|
1338
|
+
let committed = parsed;
|
|
1339
|
+
if (clampValue && min !== void 0) committed = Math.max(committed, min);
|
|
1340
|
+
if (clampValue && max !== void 0) committed = Math.min(committed, max);
|
|
1341
|
+
return committed;
|
|
1342
|
+
}
|
|
1343
|
+
//#endregion
|
|
1344
|
+
//#region src/number-input/text.ts
|
|
1345
|
+
/**
|
|
1346
|
+
* Reading a number out of the text of a number input, and keeping the caret
|
|
1347
|
+
* in place while the number under it changes.
|
|
1348
|
+
*
|
|
1349
|
+
* The text is whatever `format` made of the value — `"440 Hz"`, `"-6.0 dB"` —
|
|
1350
|
+
* or a half-typed entry, so none of this assumes the text is a number alone.
|
|
1351
|
+
*/
|
|
1352
|
+
/** The signs a number can carry. U+2212 is what some `Intl.NumberFormat` locales write. */
|
|
1353
|
+
const SIGNS = "+-−";
|
|
1354
|
+
/**
|
|
1355
|
+
* Where the number is in the text: from its first digit to its last, with the
|
|
1356
|
+
* sign and the decimal point in front of it. Whatever is left on either side
|
|
1357
|
+
* is taken for the unit, so it does not matter whether a space separates them,
|
|
1358
|
+
* or what the number looks like — `+6.0`, `1e+21`, `1,000` and `1:30` are each
|
|
1359
|
+
* one number. `null` when the text has no digit at all.
|
|
1360
|
+
*/
|
|
1361
|
+
function numberSpan(text) {
|
|
1362
|
+
const first = text.search(/\d/);
|
|
1363
|
+
if (first === -1) return null;
|
|
1364
|
+
let start = first;
|
|
1365
|
+
if (text[start - 1] === ".") start -= 1;
|
|
1366
|
+
if (start > 0 && SIGNS.includes(text[start - 1])) start -= 1;
|
|
1367
|
+
return {
|
|
1368
|
+
start,
|
|
1369
|
+
end: text.search(/\d\D*$/) + 1
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
/** A plain number, and nothing else: no grouping, no other separators. */
|
|
1373
|
+
const PLAIN_NUMBER = /^[+\-\u2212]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+\-\u2212]?\d+)?$/;
|
|
1374
|
+
/**
|
|
1375
|
+
* The number in the text, with the unit after it ignored: the default `parse`
|
|
1376
|
+
* of a number input.
|
|
1377
|
+
*
|
|
1378
|
+
* `NaN`, which leaves the value alone, whenever the number cannot be read
|
|
1379
|
+
* safely, rather than a part of it: text with no number, a number that is not
|
|
1380
|
+
* a plain one (`1,000` would otherwise read as 1, and `1:30` as 1), and text
|
|
1381
|
+
* with something in front of the number, which can change what it means (the
|
|
1382
|
+
* `L` of a pan reading `L 30`). A `format` that writes any of those needs its
|
|
1383
|
+
* own `parse`.
|
|
1384
|
+
*/
|
|
1385
|
+
function parseNumberText(text) {
|
|
1386
|
+
const span = numberSpan(text);
|
|
1387
|
+
if (!span || text.slice(0, span.start).trim() !== "") return NaN;
|
|
1388
|
+
const number = text.slice(span.start, span.end);
|
|
1389
|
+
return PLAIN_NUMBER.test(number) ? Number(number.replace(/\u2212/g, "-")) : NaN;
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* The index the caret is measured against: the decimal point, or where one
|
|
1393
|
+
* would go if the number has none — in front of an exponent, if there is one.
|
|
1394
|
+
*
|
|
1395
|
+
* Measuring from an end instead would slide the caret across a digit whenever
|
|
1396
|
+
* the number changed length — `9.9` to `10.0` gains a character in front, `10`
|
|
1397
|
+
* to `9` loses one, and so does `9e+9` to `1e+10` behind — which is exactly
|
|
1398
|
+
* what stepping does.
|
|
1399
|
+
*/
|
|
1400
|
+
function decimalAnchor(text, span) {
|
|
1401
|
+
const number = text.slice(span.start, span.end);
|
|
1402
|
+
const exponent = number.search(/[eE][+\-\u2212]?\d/);
|
|
1403
|
+
const mantissa = exponent === -1 ? number : number.slice(0, exponent);
|
|
1404
|
+
const dot = mantissa.indexOf(".");
|
|
1405
|
+
return span.start + (dot === -1 ? mantissa.length : dot);
|
|
1406
|
+
}
|
|
1407
|
+
const NO_NUMBER = {
|
|
1408
|
+
start: 0,
|
|
1409
|
+
end: 0
|
|
1410
|
+
};
|
|
1411
|
+
/**
|
|
1412
|
+
* Where the caret is, relative to the decimal point, so that it can be put
|
|
1413
|
+
* back at the same digit once the value has changed. See
|
|
1414
|
+
* {@link caretAtDecimalOffset}.
|
|
1415
|
+
*/
|
|
1416
|
+
function caretDecimalOffset(text, caret) {
|
|
1417
|
+
return caret - decimalAnchor(text, numberSpan(text) ?? NO_NUMBER);
|
|
1418
|
+
}
|
|
1419
|
+
/**
|
|
1420
|
+
* The caret position `offset` characters from the decimal point of the new
|
|
1421
|
+
* text, kept within the number.
|
|
1422
|
+
*
|
|
1423
|
+
* @example
|
|
1424
|
+
* // The caret sits in front of the point of "9.9" when ArrowUp turns it
|
|
1425
|
+
* // into "10.0"
|
|
1426
|
+
* const offset = caretDecimalOffset('9.9', 1) // 0
|
|
1427
|
+
* caretAtDecimalOffset('10.0', offset) // 2: still in front of the point
|
|
1428
|
+
*/
|
|
1429
|
+
function caretAtDecimalOffset(text, offset) {
|
|
1430
|
+
const span = numberSpan(text) ?? NO_NUMBER;
|
|
1431
|
+
const place = decimalAnchor(text, span) + offset;
|
|
1432
|
+
return Math.max(span.start, Math.min(place, span.end));
|
|
1433
|
+
}
|
|
1434
|
+
//#endregion
|
|
1435
|
+
//#region src/options/replace.ts
|
|
1436
|
+
/**
|
|
1437
|
+
* The `update()` argument that leaves an instance with exactly `next` as its
|
|
1438
|
+
* options.
|
|
1439
|
+
*
|
|
1440
|
+
* `update()` merges into what the instance has, which suits a wrapper that
|
|
1441
|
+
* pushes one setting at a time. A wrapper handed the whole new set instead —
|
|
1442
|
+
* a Svelte action, a Vue composable — has to clear what the new set no longer
|
|
1443
|
+
* carries, or a handler taken away, or an option left to its default, keeps
|
|
1444
|
+
* working.
|
|
1445
|
+
*
|
|
1446
|
+
* @example
|
|
1447
|
+
* instance.update(replaceOptions(previous, next))
|
|
1448
|
+
*/
|
|
1449
|
+
function replaceOptions(previous, next) {
|
|
1450
|
+
return {
|
|
1451
|
+
...Object.fromEntries(Object.keys(previous ?? {}).map((key) => [key, void 0])),
|
|
1452
|
+
...next
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
//#endregion
|
|
1456
|
+
//#region src/piano/layout.ts
|
|
1457
|
+
/**
|
|
1458
|
+
* `[noteRange.first, noteRange.first + 1, ..., noteRange.last]`
|
|
1459
|
+
*/
|
|
1460
|
+
function getNoteRangeArray(noteRange) {
|
|
1461
|
+
return Array.from({ length: noteRange.last - noteRange.first + 1 }, (_, i) => i + noteRange.first);
|
|
1462
|
+
}
|
|
1463
|
+
const DEFAULT_KEY_GAP = 1;
|
|
1464
|
+
const DEFAULT_BLACK_KEY_WIDTH_RATIO = .65;
|
|
1465
|
+
const DEFAULT_BLACK_KEY_HEIGHT_RATIO = .6;
|
|
1466
|
+
/**
|
|
1467
|
+
* How many white keys sit at or before each pitch class, counting from C.
|
|
1468
|
+
*
|
|
1469
|
+
* A black key shares the number of the white key to its left plus one, which
|
|
1470
|
+
* puts it on the boundary between the two; {@link notePosition} then shifts it
|
|
1471
|
+
* back by half its width to centre it there.
|
|
1472
|
+
*/
|
|
1473
|
+
const whiteKeysBefore = {
|
|
1474
|
+
C: 0,
|
|
1475
|
+
"C#": 1,
|
|
1476
|
+
D: 1,
|
|
1477
|
+
"D#": 2,
|
|
1478
|
+
E: 2,
|
|
1479
|
+
F: 3,
|
|
1480
|
+
"F#": 4,
|
|
1481
|
+
G: 4,
|
|
1482
|
+
"G#": 5,
|
|
1483
|
+
A: 5,
|
|
1484
|
+
"A#": 6,
|
|
1485
|
+
B: 6
|
|
1486
|
+
};
|
|
1487
|
+
/** Width of a black key in pixels. */
|
|
1488
|
+
function blackKeyWidth(layout) {
|
|
1489
|
+
return layout.whiteKeyWidth * (layout.blackKeyWidthRatio ?? DEFAULT_BLACK_KEY_WIDTH_RATIO);
|
|
1490
|
+
}
|
|
1491
|
+
function rawNotePosition(note, layout) {
|
|
1492
|
+
const slot = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP);
|
|
1493
|
+
const target = (0, _tremolo_ui_functions.noteKey)(note);
|
|
1494
|
+
const first = (0, _tremolo_ui_functions.noteKey)(layout.noteRange.first);
|
|
1495
|
+
const octave = Math.floor((note - layout.noteRange.first) / 12);
|
|
1496
|
+
const octaveOffset = _tremolo_ui_functions.noteKeys.indexOf(first) > _tremolo_ui_functions.noteKeys.indexOf(target) ? 1 : 0;
|
|
1497
|
+
const whiteKeysIn = whiteKeysBefore[target] - whiteKeysBefore[first] + (octave + octaveOffset) * 7;
|
|
1498
|
+
return (0, _tremolo_ui_functions.isBlackKey)(note) ? whiteKeysIn * slot - blackKeyWidth(layout) / 2 : whiteKeysIn * slot;
|
|
1499
|
+
}
|
|
1500
|
+
function pianoBounds(layout) {
|
|
1501
|
+
const notes = getNoteRangeArray(layout.noteRange);
|
|
1502
|
+
if (notes.length === 0) return {
|
|
1503
|
+
left: 0,
|
|
1504
|
+
right: 0
|
|
1505
|
+
};
|
|
1506
|
+
let left = Infinity;
|
|
1507
|
+
let right = -Infinity;
|
|
1508
|
+
const whiteWidth = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP);
|
|
1509
|
+
for (const note of notes) {
|
|
1510
|
+
const noteLeft = rawNotePosition(note, layout);
|
|
1511
|
+
const width = (0, _tremolo_ui_functions.isBlackKey)(note) ? blackKeyWidth(layout) : whiteWidth;
|
|
1512
|
+
left = Math.min(left, noteLeft);
|
|
1513
|
+
right = Math.max(right, noteLeft + width);
|
|
1514
|
+
}
|
|
1515
|
+
return {
|
|
1516
|
+
left,
|
|
1517
|
+
right
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
/** Width of the whole keyboard in pixels. */
|
|
1521
|
+
function pianoWidth(layout) {
|
|
1522
|
+
const { left, right } = pianoBounds(layout);
|
|
1523
|
+
return right - left;
|
|
1524
|
+
}
|
|
1525
|
+
/**
|
|
1526
|
+
* Offset of the left edge of a key from the left edge of the keyboard, in
|
|
1527
|
+
* pixels.
|
|
1528
|
+
*
|
|
1529
|
+
* Notes outside `noteRange` are placed too, so the value is negative below
|
|
1530
|
+
* `noteRange.first`.
|
|
1531
|
+
*/
|
|
1532
|
+
function notePosition(note, layout) {
|
|
1533
|
+
return rawNotePosition(note, layout) - pianoBounds(layout).left;
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* The note drawn at a point, or null where there is none.
|
|
1537
|
+
*
|
|
1538
|
+
* Black keys are tested first, so they win where they overlap a white one. A
|
|
1539
|
+
* white key covers its gap as well as its width, so the whole width of the
|
|
1540
|
+
* keyboard belongs to some key and a click cannot fall between two.
|
|
1541
|
+
*
|
|
1542
|
+
* @param x offset from the left edge of the keyboard, in pixels
|
|
1543
|
+
* @param y offset from its top edge, in pixels
|
|
1544
|
+
* @param height height of the keyboard, in pixels
|
|
1545
|
+
*/
|
|
1546
|
+
function noteAt(x, y, height, layout) {
|
|
1547
|
+
if (x < 0 || x >= pianoWidth(layout) || y < 0 || y >= height) return null;
|
|
1548
|
+
const notes = getNoteRangeArray(layout.noteRange);
|
|
1549
|
+
if (y < height * (layout.blackKeyHeightRatio ?? DEFAULT_BLACK_KEY_HEIGHT_RATIO)) for (const note of notes) {
|
|
1550
|
+
if ((0, _tremolo_ui_functions.isWhiteKey)(note)) continue;
|
|
1551
|
+
const left = notePosition(note, layout);
|
|
1552
|
+
if (left <= x && x < left + blackKeyWidth(layout)) return note;
|
|
1553
|
+
}
|
|
1554
|
+
const slot = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP);
|
|
1555
|
+
for (const note of notes) {
|
|
1556
|
+
if ((0, _tremolo_ui_functions.isBlackKey)(note)) continue;
|
|
1557
|
+
const left = notePosition(note, layout);
|
|
1558
|
+
if (left <= x && x < left + slot) return note;
|
|
1559
|
+
}
|
|
1560
|
+
return null;
|
|
1561
|
+
}
|
|
1562
|
+
/**
|
|
1563
|
+
* The white key width that makes the keyboard exactly `width` wide, for a
|
|
1564
|
+
* keyboard that follows the size of its container.
|
|
1565
|
+
*
|
|
1566
|
+
* Solved from {@link pianoWidth} rather than by dividing among the white
|
|
1567
|
+
* keys, so that a range that starts or ends on a black key — which sticks out
|
|
1568
|
+
* by a fraction of a white key — still fills the container. The width grows
|
|
1569
|
+
* linearly with the white key width, so two samples pin it down.
|
|
1570
|
+
*/
|
|
1571
|
+
function fitWhiteKeyWidth(width, layout) {
|
|
1572
|
+
const at = (whiteKeyWidth) => pianoWidth({
|
|
1573
|
+
...layout,
|
|
1574
|
+
whiteKeyWidth
|
|
1575
|
+
});
|
|
1576
|
+
const slope = at(2) - at(1);
|
|
1577
|
+
if (slope <= 0) return 0;
|
|
1578
|
+
return (width - (at(1) - slope)) / slope;
|
|
1579
|
+
}
|
|
1580
|
+
//#endregion
|
|
1581
|
+
//#region src/piano/shortcuts.ts
|
|
1582
|
+
/**
|
|
1583
|
+
* Ready-made keyboard layouts. Both assume `noteRange.first` is a C.
|
|
1584
|
+
*/
|
|
1585
|
+
const SHORTCUTS = {
|
|
1586
|
+
/** Every semitone from C, over the two rows of a QWERTY keyboard. */
|
|
1587
|
+
HOME_ROW: { keys: [
|
|
1588
|
+
"a",
|
|
1589
|
+
"w",
|
|
1590
|
+
"s",
|
|
1591
|
+
"e",
|
|
1592
|
+
"d",
|
|
1593
|
+
"f",
|
|
1594
|
+
"t",
|
|
1595
|
+
"g",
|
|
1596
|
+
"y",
|
|
1597
|
+
"h",
|
|
1598
|
+
"u",
|
|
1599
|
+
"j",
|
|
1600
|
+
"k",
|
|
1601
|
+
"o",
|
|
1602
|
+
"l",
|
|
1603
|
+
"p",
|
|
1604
|
+
";"
|
|
1605
|
+
] },
|
|
1606
|
+
/** The white keys only, on the home row. Black keys have no shortcut. */
|
|
1607
|
+
HOME_ROW_NATURAL: { keys: [
|
|
1608
|
+
"a",
|
|
1609
|
+
"",
|
|
1610
|
+
"s",
|
|
1611
|
+
"",
|
|
1612
|
+
"d",
|
|
1613
|
+
"f",
|
|
1614
|
+
"",
|
|
1615
|
+
"g",
|
|
1616
|
+
"",
|
|
1617
|
+
"h",
|
|
1618
|
+
"",
|
|
1619
|
+
"j",
|
|
1620
|
+
"k",
|
|
1621
|
+
"",
|
|
1622
|
+
"l",
|
|
1623
|
+
"",
|
|
1624
|
+
";"
|
|
1625
|
+
] }
|
|
1626
|
+
};
|
|
1627
|
+
/**
|
|
1628
|
+
* Whether a key press was typed into something: shortcuts must not play a
|
|
1629
|
+
* note for a letter that is going into a text field.
|
|
1630
|
+
*/
|
|
1631
|
+
function isEditableTarget(target) {
|
|
1632
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
1633
|
+
if (target.matches("input, textarea, select")) return true;
|
|
1634
|
+
for (let element = target; element;) {
|
|
1635
|
+
const contentEditable = element.getAttribute("contenteditable");
|
|
1636
|
+
if (contentEditable !== null) return contentEditable.toLowerCase() !== "false";
|
|
1637
|
+
element = element.parentElement;
|
|
1638
|
+
}
|
|
1639
|
+
return false;
|
|
1640
|
+
}
|
|
1641
|
+
//#endregion
|
|
1642
|
+
//#region src/piano/index.ts
|
|
1643
|
+
const DEFAULT_SOURCE = "api";
|
|
1644
|
+
/**
|
|
1645
|
+
* Drive a piano keyboard with pointers, and own what is sounding.
|
|
1646
|
+
*
|
|
1647
|
+
* Every way of playing a note goes through the one instance — pointers here,
|
|
1648
|
+
* keyboard shortcuts and MIDI through {@link PianoInputInstance.noteOn} — so
|
|
1649
|
+
* there is a single answer to what is currently held, and drawing the keys is
|
|
1650
|
+
* left entirely to the wrapper.
|
|
1651
|
+
*
|
|
1652
|
+
* Tracks every pointer at once, so a chord can be played with several fingers.
|
|
1653
|
+
*/
|
|
1654
|
+
function createPianoInput(element, options) {
|
|
1655
|
+
let opts = options;
|
|
1656
|
+
/** Which sources are holding each sounding note. */
|
|
1657
|
+
const held = /* @__PURE__ */ new Map();
|
|
1658
|
+
/** The note each pointer is currently on. */
|
|
1659
|
+
const pointerNotes = /* @__PURE__ */ new Map();
|
|
1660
|
+
/**
|
|
1661
|
+
* The note each held shortcut key is playing, by `code` — the physical key,
|
|
1662
|
+
* so that releasing it with a different modifier or layout still matches.
|
|
1663
|
+
*/
|
|
1664
|
+
const shortcutNotes = /* @__PURE__ */ new Map();
|
|
1665
|
+
function activeNotes() {
|
|
1666
|
+
return [...held.keys()].sort((a, b) => a - b);
|
|
1667
|
+
}
|
|
1668
|
+
function noteOn(note, { source = DEFAULT_SOURCE, velocity } = {}) {
|
|
1669
|
+
if (!Number.isInteger(note) || note < 0 || note > 127) throw new RangeError("note: requirements: an integer from 0 to 127");
|
|
1670
|
+
if (note > (opts.midiMax ?? 127)) return;
|
|
1671
|
+
const sources = held.get(note);
|
|
1672
|
+
if (sources) {
|
|
1673
|
+
sources.add(source);
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
held.set(note, /* @__PURE__ */ new Set([source]));
|
|
1677
|
+
opts.onPlayNote?.(note, velocity);
|
|
1678
|
+
opts.onActiveNotesChange?.(activeNotes());
|
|
1679
|
+
}
|
|
1680
|
+
function noteOff(note, { source = DEFAULT_SOURCE } = {}) {
|
|
1681
|
+
if (!Number.isInteger(note) || note < 0 || note > 127) throw new RangeError("note: requirements: an integer from 0 to 127");
|
|
1682
|
+
const sources = held.get(note);
|
|
1683
|
+
if (!sources) return;
|
|
1684
|
+
sources.delete(source);
|
|
1685
|
+
if (sources.size > 0) return;
|
|
1686
|
+
held.delete(note);
|
|
1687
|
+
opts.onStopNote?.(note);
|
|
1688
|
+
opts.onActiveNotesChange?.(activeNotes());
|
|
1689
|
+
}
|
|
1690
|
+
/** The note under a pointer, or null where the pointer is off the keys. */
|
|
1691
|
+
function noteUnder(clientX, clientY) {
|
|
1692
|
+
const { left, top, height } = element.getBoundingClientRect();
|
|
1693
|
+
return noteAt(clientX - left, clientY - top, height, opts.layout);
|
|
1694
|
+
}
|
|
1695
|
+
/** Move a pointer onto a note, releasing whatever it held before. */
|
|
1696
|
+
function movePointer(pointerId, note) {
|
|
1697
|
+
const source = `pointer:${pointerId}`;
|
|
1698
|
+
const previous = pointerNotes.get(pointerId);
|
|
1699
|
+
if (previous === note) return;
|
|
1700
|
+
if (previous !== void 0) noteOff(previous, { source });
|
|
1701
|
+
if (note === null || note > (opts.midiMax ?? 127)) pointerNotes.delete(pointerId);
|
|
1702
|
+
else {
|
|
1703
|
+
pointerNotes.set(pointerId, note);
|
|
1704
|
+
noteOn(note, { source });
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
/** The note a shortcut key plays, or null when it has none. */
|
|
1708
|
+
function shortcutNote(key) {
|
|
1709
|
+
const keys = opts.keyboardShortcuts?.keys;
|
|
1710
|
+
if (!keys || key === "") return null;
|
|
1711
|
+
const { first, last } = opts.layout.noteRange;
|
|
1712
|
+
const index = keys.indexOf(key);
|
|
1713
|
+
const note = first + index;
|
|
1714
|
+
return index === -1 || note > last ? null : note;
|
|
1715
|
+
}
|
|
1716
|
+
function onKeyDown(event) {
|
|
1717
|
+
const id = event.code || event.key;
|
|
1718
|
+
if (event.repeat || shortcutNotes.has(id)) return;
|
|
1719
|
+
if (isEditableTarget(event.target)) return;
|
|
1720
|
+
const note = shortcutNote(event.key);
|
|
1721
|
+
if (note === null) return;
|
|
1722
|
+
shortcutNotes.set(id, note);
|
|
1723
|
+
noteOn(note, { source: `keyboard:${id}` });
|
|
1724
|
+
}
|
|
1725
|
+
function onKeyUp(event) {
|
|
1726
|
+
const id = event.code || event.key;
|
|
1727
|
+
const note = shortcutNotes.get(id);
|
|
1728
|
+
if (note === void 0) return;
|
|
1729
|
+
shortcutNotes.delete(id);
|
|
1730
|
+
noteOff(note, { source: `keyboard:${id}` });
|
|
1731
|
+
}
|
|
1732
|
+
function releaseShortcuts() {
|
|
1733
|
+
for (const [id, note] of shortcutNotes) noteOff(note, { source: `keyboard:${id}` });
|
|
1734
|
+
shortcutNotes.clear();
|
|
1735
|
+
}
|
|
1736
|
+
function onFocusOut(event) {
|
|
1737
|
+
const next = event.relatedTarget;
|
|
1738
|
+
if (next instanceof Node && element.contains(next)) return;
|
|
1739
|
+
releaseShortcuts();
|
|
1740
|
+
}
|
|
1741
|
+
/** The scope the listeners are bound for, or null when none are. */
|
|
1742
|
+
let boundScope = null;
|
|
1743
|
+
function bindShortcuts() {
|
|
1744
|
+
const scope = opts.keyboardShortcuts ? opts.keyboardShortcutsScope ?? "root" : null;
|
|
1745
|
+
if (scope === boundScope) return;
|
|
1746
|
+
unbindShortcuts();
|
|
1747
|
+
const win = globalThis.window;
|
|
1748
|
+
if (!scope || !win) return;
|
|
1749
|
+
const target = scope === "window" ? win : element;
|
|
1750
|
+
target.addEventListener("keydown", onKeyDown);
|
|
1751
|
+
target.addEventListener("keyup", onKeyUp);
|
|
1752
|
+
if (scope === "root") element.addEventListener("focusout", onFocusOut);
|
|
1753
|
+
win.addEventListener("blur", releaseShortcuts);
|
|
1754
|
+
boundScope = scope;
|
|
1755
|
+
}
|
|
1756
|
+
function unbindShortcuts() {
|
|
1757
|
+
const win = globalThis.window;
|
|
1758
|
+
if (!boundScope || !win) return;
|
|
1759
|
+
const target = boundScope === "window" ? win : element;
|
|
1760
|
+
target.removeEventListener("keydown", onKeyDown);
|
|
1761
|
+
target.removeEventListener("keyup", onKeyUp);
|
|
1762
|
+
element.removeEventListener("focusout", onFocusOut);
|
|
1763
|
+
win.removeEventListener("blur", releaseShortcuts);
|
|
1764
|
+
boundScope = null;
|
|
1765
|
+
}
|
|
1766
|
+
/**
|
|
1767
|
+
* What the held keys were started against. Compared by value: a caller
|
|
1768
|
+
* who writes the keys inline hands over a new array on every update, and
|
|
1769
|
+
* releasing on that would stop a note as soon as anything re-renders.
|
|
1770
|
+
*/
|
|
1771
|
+
function shortcutMapping() {
|
|
1772
|
+
const { first, last } = opts.layout.noteRange;
|
|
1773
|
+
return [
|
|
1774
|
+
opts.keyboardShortcuts?.keys.join("\0") ?? "",
|
|
1775
|
+
opts.keyboardShortcutsScope ?? "root",
|
|
1776
|
+
first,
|
|
1777
|
+
last
|
|
1778
|
+
].join("");
|
|
1779
|
+
}
|
|
1780
|
+
bindShortcuts();
|
|
1781
|
+
const drag = createDrag(element, {
|
|
1782
|
+
multiPointer: true,
|
|
1783
|
+
onDragStart: (state) => movePointer(state.pointerId, noteUnder(state.clientX, state.clientY)),
|
|
1784
|
+
onDrag: (state) => {
|
|
1785
|
+
if (opts.glissando === false) return;
|
|
1786
|
+
movePointer(state.pointerId, noteUnder(state.clientX, state.clientY));
|
|
1787
|
+
},
|
|
1788
|
+
onDragEnd: (state) => movePointer(state.pointerId, null)
|
|
1789
|
+
});
|
|
1790
|
+
return {
|
|
1791
|
+
update: (next) => {
|
|
1792
|
+
const mapping = shortcutMapping();
|
|
1793
|
+
opts = {
|
|
1794
|
+
...opts,
|
|
1795
|
+
...next
|
|
1796
|
+
};
|
|
1797
|
+
if (shortcutMapping() !== mapping) releaseShortcuts();
|
|
1798
|
+
bindShortcuts();
|
|
1799
|
+
const midiMax = opts.midiMax ?? 127;
|
|
1800
|
+
const stoppedNotes = activeNotes().filter((note) => note > midiMax);
|
|
1137
1801
|
if (stoppedNotes.length === 0) return;
|
|
1138
1802
|
for (const note of stoppedNotes) {
|
|
1139
1803
|
held.delete(note);
|
|
@@ -1147,6 +1811,8 @@ function createPianoInput(element, options) {
|
|
|
1147
1811
|
activeNotes,
|
|
1148
1812
|
destroy: () => {
|
|
1149
1813
|
drag.destroy();
|
|
1814
|
+
unbindShortcuts();
|
|
1815
|
+
shortcutNotes.clear();
|
|
1150
1816
|
const notes = activeNotes();
|
|
1151
1817
|
for (const note of notes) {
|
|
1152
1818
|
held.delete(note);
|
|
@@ -1158,6 +1824,267 @@ function createPianoInput(element, options) {
|
|
|
1158
1824
|
};
|
|
1159
1825
|
}
|
|
1160
1826
|
//#endregion
|
|
1827
|
+
//#region src/selection/box.ts
|
|
1828
|
+
function rectOf(from, to) {
|
|
1829
|
+
return {
|
|
1830
|
+
x: Math.min(from[0], to[0]),
|
|
1831
|
+
y: Math.min(from[1], to[1]),
|
|
1832
|
+
width: Math.abs(to[0] - from[0]),
|
|
1833
|
+
height: Math.abs(to[1] - from[1])
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
/** Whether the box covers the point, edges included. */
|
|
1837
|
+
function selectionBoxCovers(box, [x, y]) {
|
|
1838
|
+
return x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height;
|
|
1839
|
+
}
|
|
1840
|
+
/**
|
|
1841
|
+
* Selecting by dragging a box over a set of items.
|
|
1842
|
+
*
|
|
1843
|
+
* The drag itself is not here: the caller owns the pointer, and reports where
|
|
1844
|
+
* it went in the same 0..1 space the items are given in.
|
|
1845
|
+
*/
|
|
1846
|
+
function createSelectionBox(options) {
|
|
1847
|
+
let opts = options;
|
|
1848
|
+
let drag = null;
|
|
1849
|
+
let box = null;
|
|
1850
|
+
function setBox(next) {
|
|
1851
|
+
box = next;
|
|
1852
|
+
opts.onBoxChange?.(next);
|
|
1853
|
+
}
|
|
1854
|
+
function apply(rect, base) {
|
|
1855
|
+
const inside = [];
|
|
1856
|
+
for (const [id, at] of opts.items()) if (selectionBoxCovers(rect, at) && !base.includes(id)) inside.push(id);
|
|
1857
|
+
opts.onSelectionChange?.([...base, ...inside]);
|
|
1858
|
+
}
|
|
1859
|
+
return {
|
|
1860
|
+
update: (next) => {
|
|
1861
|
+
opts = {
|
|
1862
|
+
...opts,
|
|
1863
|
+
...next
|
|
1864
|
+
};
|
|
1865
|
+
},
|
|
1866
|
+
begin: (at, { additive = false, selection = [] } = {}) => {
|
|
1867
|
+
drag = {
|
|
1868
|
+
from: at,
|
|
1869
|
+
base: additive ? [...selection] : []
|
|
1870
|
+
};
|
|
1871
|
+
setBox(rectOf(at, at));
|
|
1872
|
+
if (!additive) opts.onSelectionChange?.([]);
|
|
1873
|
+
},
|
|
1874
|
+
move: (to) => {
|
|
1875
|
+
if (!drag) return;
|
|
1876
|
+
const rect = rectOf(drag.from, to);
|
|
1877
|
+
setBox(rect);
|
|
1878
|
+
apply(rect, drag.base);
|
|
1879
|
+
},
|
|
1880
|
+
end: () => {
|
|
1881
|
+
const running = drag !== null;
|
|
1882
|
+
drag = null;
|
|
1883
|
+
if (box !== null) setBox(null);
|
|
1884
|
+
return running;
|
|
1885
|
+
},
|
|
1886
|
+
box: () => box,
|
|
1887
|
+
destroy: () => {
|
|
1888
|
+
drag = null;
|
|
1889
|
+
box = null;
|
|
1890
|
+
}
|
|
1891
|
+
};
|
|
1892
|
+
}
|
|
1893
|
+
//#endregion
|
|
1894
|
+
//#region src/points-editor/index.ts
|
|
1895
|
+
/**
|
|
1896
|
+
* A point's value is its position in the editor, so its range is the editor:
|
|
1897
|
+
* no scaling, and no rounding to a step.
|
|
1898
|
+
*/
|
|
1899
|
+
const POINT_AXIS = {
|
|
1900
|
+
min: 0,
|
|
1901
|
+
max: 1
|
|
1902
|
+
};
|
|
1903
|
+
/**
|
|
1904
|
+
* How much one wheel notch moves a point by default: a hundredth of the
|
|
1905
|
+
* editor, whatever its pixel size.
|
|
1906
|
+
*/
|
|
1907
|
+
const POINTS_EDITOR_DEFAULT_WHEEL = ["normalized", .01];
|
|
1908
|
+
/**
|
|
1909
|
+
* How much one arrow key press moves a point by default. Shift is the
|
|
1910
|
+
* fine-adjustment key everywhere else, so it is bound here too — but only on
|
|
1911
|
+
* the keyboard. On the wheel it already means the x axis, and browsers hand
|
|
1912
|
+
* shift+wheel over as horizontal scrolling anyway.
|
|
1913
|
+
*/
|
|
1914
|
+
const POINTS_EDITOR_DEFAULT_KEYBOARD = {
|
|
1915
|
+
default: ["normalized", .01],
|
|
1916
|
+
shift: ["normalized", .001]
|
|
1917
|
+
};
|
|
1918
|
+
/** Keep a point within its range. An axis left out runs from 0 to 1. */
|
|
1919
|
+
function clampPoint(point, min, max) {
|
|
1920
|
+
return {
|
|
1921
|
+
x: (0, _tremolo_ui_functions.clamp)(point.x, min?.x ?? 0, max?.x ?? 1),
|
|
1922
|
+
y: (0, _tremolo_ui_functions.clamp)(point.y, min?.y ?? 0, max?.y ?? 1)
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
const EMPTY = [];
|
|
1926
|
+
/**
|
|
1927
|
+
* How far a group may move before something in it leaves its range.
|
|
1928
|
+
*
|
|
1929
|
+
* Clamping each point on its own would break the shape of the selection: the
|
|
1930
|
+
* one that reached the edge would stop while the rest carried on. One amount
|
|
1931
|
+
* for all of them means the whole selection stops together.
|
|
1932
|
+
*/
|
|
1933
|
+
function allowedDelta(delta, entries) {
|
|
1934
|
+
let loX = -Infinity;
|
|
1935
|
+
let hiX = Infinity;
|
|
1936
|
+
let loY = -Infinity;
|
|
1937
|
+
let hiY = Infinity;
|
|
1938
|
+
for (const { start, point } of entries) {
|
|
1939
|
+
if (point.readonly) continue;
|
|
1940
|
+
loX = Math.max(loX, (point.min?.x ?? 0) - start.x);
|
|
1941
|
+
hiX = Math.min(hiX, (point.max?.x ?? 1) - start.x);
|
|
1942
|
+
loY = Math.max(loY, (point.min?.y ?? 0) - start.y);
|
|
1943
|
+
hiY = Math.min(hiY, (point.max?.y ?? 1) - start.y);
|
|
1944
|
+
}
|
|
1945
|
+
return {
|
|
1946
|
+
x: hiX < loX ? 0 : (0, _tremolo_ui_functions.clamp)(delta.x, loX, hiX),
|
|
1947
|
+
y: hiY < loY ? 0 : (0, _tremolo_ui_functions.clamp)(delta.y, loY, hiY)
|
|
1948
|
+
};
|
|
1949
|
+
}
|
|
1950
|
+
/**
|
|
1951
|
+
* The selection and the moves of a points editor: which points a press or a
|
|
1952
|
+
* box selects, and how a selection moves as one.
|
|
1953
|
+
*
|
|
1954
|
+
* The points stay with the wrapper, which registers each one; the editor only
|
|
1955
|
+
* reads them when it needs to, so a point's value can change on every frame
|
|
1956
|
+
* of a drag without anything being re-registered.
|
|
1957
|
+
*/
|
|
1958
|
+
function createPointsEditor(options = {}) {
|
|
1959
|
+
let opts = options;
|
|
1960
|
+
const points = /* @__PURE__ */ new Map();
|
|
1961
|
+
/** What the current drag picked up, and where those points started. */
|
|
1962
|
+
let dragged = [];
|
|
1963
|
+
const selection = () => opts.selectable ? opts.selection ?? EMPTY : EMPTY;
|
|
1964
|
+
function changeSelection(next) {
|
|
1965
|
+
opts = {
|
|
1966
|
+
...opts,
|
|
1967
|
+
selection: next
|
|
1968
|
+
};
|
|
1969
|
+
opts.onSelectionChange?.(next);
|
|
1970
|
+
}
|
|
1971
|
+
function snapshot(ids) {
|
|
1972
|
+
return ids.flatMap((id) => {
|
|
1973
|
+
const point = points.get(id)?.();
|
|
1974
|
+
return point ? [{
|
|
1975
|
+
id,
|
|
1976
|
+
start: { ...point.value }
|
|
1977
|
+
}] : [];
|
|
1978
|
+
});
|
|
1979
|
+
}
|
|
1980
|
+
function moveFrom(entries, delta) {
|
|
1981
|
+
const withPoint = entries.flatMap((entry) => {
|
|
1982
|
+
const point = points.get(entry.id)?.();
|
|
1983
|
+
return point ? [{
|
|
1984
|
+
...entry,
|
|
1985
|
+
point
|
|
1986
|
+
}] : [];
|
|
1987
|
+
});
|
|
1988
|
+
const allowed = allowedDelta(delta, withPoint);
|
|
1989
|
+
for (const { start, point } of withPoint) {
|
|
1990
|
+
if (point.readonly) continue;
|
|
1991
|
+
point.onChange?.({
|
|
1992
|
+
x: (0, _tremolo_ui_functions.toPrecision)(start.x + allowed.x),
|
|
1993
|
+
y: (0, _tremolo_ui_functions.toPrecision)(start.y + allowed.y)
|
|
1994
|
+
});
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
function nudgeSelection(id, delta) {
|
|
1998
|
+
const current = selection();
|
|
1999
|
+
moveFrom(snapshot(current.includes(id) ? current : [id]), delta);
|
|
2000
|
+
}
|
|
2001
|
+
function nudgePoint(id, axis, direction, option, modifiers) {
|
|
2002
|
+
const point = points.get(id)?.();
|
|
2003
|
+
if (!point) return;
|
|
2004
|
+
const { value } = point;
|
|
2005
|
+
const next = applyDelta(value[axis], direction, option, POINT_AXIS, modifiers);
|
|
2006
|
+
nudgeSelection(id, {
|
|
2007
|
+
x: axis === "x" ? next - value.x : 0,
|
|
2008
|
+
y: axis === "y" ? next - value.y : 0
|
|
2009
|
+
});
|
|
2010
|
+
}
|
|
2011
|
+
const box = createSelectionBox({
|
|
2012
|
+
*items() {
|
|
2013
|
+
for (const [id, read] of points) {
|
|
2014
|
+
const { x, y } = read().value;
|
|
2015
|
+
yield [id, [x, y]];
|
|
2016
|
+
}
|
|
2017
|
+
},
|
|
2018
|
+
onBoxChange: (rect) => opts.onSelectionBoxChange?.(rect),
|
|
2019
|
+
onSelectionChange: changeSelection
|
|
2020
|
+
});
|
|
2021
|
+
return {
|
|
2022
|
+
update: (next) => {
|
|
2023
|
+
opts = {
|
|
2024
|
+
...opts,
|
|
2025
|
+
...next
|
|
2026
|
+
};
|
|
2027
|
+
},
|
|
2028
|
+
registerPoint: (id, read) => {
|
|
2029
|
+
points.set(id, read);
|
|
2030
|
+
return () => {
|
|
2031
|
+
if (points.get(id) === read) points.delete(id);
|
|
2032
|
+
};
|
|
2033
|
+
},
|
|
2034
|
+
isPointElement: (element) => {
|
|
2035
|
+
if (!element) return false;
|
|
2036
|
+
for (const read of points.values()) if (read().element?.contains(element)) return true;
|
|
2037
|
+
return false;
|
|
2038
|
+
},
|
|
2039
|
+
beginPointDrag: (id, modifiers) => {
|
|
2040
|
+
if (!opts.selectable) {
|
|
2041
|
+
dragged = snapshot([id]);
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
const current = selection();
|
|
2045
|
+
const additive = modifiers.ctrlKey || modifiers.metaKey;
|
|
2046
|
+
let next;
|
|
2047
|
+
if (additive) next = current.includes(id) ? current.filter((x) => x !== id) : [...current, id];
|
|
2048
|
+
else if (current.includes(id)) next = current;
|
|
2049
|
+
else next = [id];
|
|
2050
|
+
changeSelection([...next]);
|
|
2051
|
+
dragged = next.includes(id) ? snapshot(next) : [];
|
|
2052
|
+
},
|
|
2053
|
+
movePointDrag: (delta) => moveFrom(dragged, delta),
|
|
2054
|
+
nudgeSelection,
|
|
2055
|
+
nudgePoint,
|
|
2056
|
+
nudgeFocusedPoint: (axis, direction, modifiers) => {
|
|
2057
|
+
for (const [id, read] of points) {
|
|
2058
|
+
const { element, wheel, readonly, onChange } = read();
|
|
2059
|
+
const active = element?.ownerDocument.activeElement;
|
|
2060
|
+
if (!active || !element?.contains(active)) continue;
|
|
2061
|
+
if (!wheel || readonly || !onChange) return false;
|
|
2062
|
+
nudgePoint(id, axis, direction, wheel, modifiers);
|
|
2063
|
+
return true;
|
|
2064
|
+
}
|
|
2065
|
+
return false;
|
|
2066
|
+
},
|
|
2067
|
+
beginSelectionBox: (at, modifiers) => {
|
|
2068
|
+
if (!opts.selectable) return;
|
|
2069
|
+
box.begin([at.x, at.y], {
|
|
2070
|
+
additive: modifiers.ctrlKey || modifiers.metaKey,
|
|
2071
|
+
selection: selection()
|
|
2072
|
+
});
|
|
2073
|
+
},
|
|
2074
|
+
moveSelectionBox: (to) => box.move([to.x, to.y]),
|
|
2075
|
+
endSelectionBox: () => {
|
|
2076
|
+
if (!box.end()) return;
|
|
2077
|
+
const [first] = selection();
|
|
2078
|
+
const element = first ? points.get(first)?.().element : null;
|
|
2079
|
+
if (element && "focus" in element) element.focus();
|
|
2080
|
+
},
|
|
2081
|
+
destroy: () => {
|
|
2082
|
+
box.destroy();
|
|
2083
|
+
dragged = [];
|
|
2084
|
+
}
|
|
2085
|
+
};
|
|
2086
|
+
}
|
|
2087
|
+
//#endregion
|
|
1161
2088
|
//#region src/xy.ts
|
|
1162
2089
|
function isPair(value) {
|
|
1163
2090
|
return Array.isArray(value);
|
|
@@ -1336,6 +2263,73 @@ function createDragValue(element, options) {
|
|
|
1336
2263
|
};
|
|
1337
2264
|
}
|
|
1338
2265
|
//#endregion
|
|
2266
|
+
//#region src/pointer/long-press.ts
|
|
2267
|
+
/**
|
|
2268
|
+
* Repeat an action while a pointer is held down: once on the press, then
|
|
2269
|
+
* again every `interval` after `delay` — the way a stepper button or a
|
|
2270
|
+
* key held on a keyboard behaves.
|
|
2271
|
+
*
|
|
2272
|
+
* The release is listened for on the window, not on the element: the
|
|
2273
|
+
* pointer may be let go anywhere. Only the pointer that started the press
|
|
2274
|
+
* ends it, so a second finger lifting elsewhere does not. The window losing
|
|
2275
|
+
* focus ends it too, since the release would never arrive.
|
|
2276
|
+
*/
|
|
2277
|
+
function createLongPress(options) {
|
|
2278
|
+
let opts = options;
|
|
2279
|
+
let pointerId = null;
|
|
2280
|
+
let active = false;
|
|
2281
|
+
let timer = null;
|
|
2282
|
+
function clearTimer() {
|
|
2283
|
+
if (timer !== null) clearTimeout(timer);
|
|
2284
|
+
timer = null;
|
|
2285
|
+
}
|
|
2286
|
+
/** Wait `wait`, fire, and keep going at `interval` from then on. */
|
|
2287
|
+
function schedule(wait) {
|
|
2288
|
+
timer = setTimeout(() => {
|
|
2289
|
+
opts.onPress();
|
|
2290
|
+
if (active) schedule(opts.interval ?? 40);
|
|
2291
|
+
}, wait);
|
|
2292
|
+
}
|
|
2293
|
+
function onPointerEnd(event) {
|
|
2294
|
+
if (pointerId !== null && event.pointerId !== pointerId) return;
|
|
2295
|
+
stop();
|
|
2296
|
+
}
|
|
2297
|
+
function listen(add) {
|
|
2298
|
+
const target = globalThis.window;
|
|
2299
|
+
if (!target) return;
|
|
2300
|
+
const method = add ? "addEventListener" : "removeEventListener";
|
|
2301
|
+
target[method]("pointerup", onPointerEnd);
|
|
2302
|
+
target[method]("pointercancel", onPointerEnd);
|
|
2303
|
+
target[method]("blur", stop);
|
|
2304
|
+
}
|
|
2305
|
+
function stop() {
|
|
2306
|
+
if (!active) return;
|
|
2307
|
+
active = false;
|
|
2308
|
+
pointerId = null;
|
|
2309
|
+
clearTimer();
|
|
2310
|
+
listen(false);
|
|
2311
|
+
}
|
|
2312
|
+
return {
|
|
2313
|
+
start: (event) => {
|
|
2314
|
+
if (active || event && event.button !== 0) return;
|
|
2315
|
+
active = true;
|
|
2316
|
+
pointerId = event?.pointerId ?? null;
|
|
2317
|
+
listen(true);
|
|
2318
|
+
opts.onPress();
|
|
2319
|
+
if (active) schedule(opts.delay ?? 500);
|
|
2320
|
+
},
|
|
2321
|
+
stop,
|
|
2322
|
+
update: (next) => {
|
|
2323
|
+
opts = {
|
|
2324
|
+
...opts,
|
|
2325
|
+
...next
|
|
2326
|
+
};
|
|
2327
|
+
},
|
|
2328
|
+
pressed: () => active,
|
|
2329
|
+
destroy: stop
|
|
2330
|
+
};
|
|
2331
|
+
}
|
|
2332
|
+
//#endregion
|
|
1339
2333
|
//#region src/pointer/wheel.ts
|
|
1340
2334
|
/**
|
|
1341
2335
|
* Listen to wheel events on an element.
|
|
@@ -1370,101 +2364,170 @@ function createWheel(element, onWheel, options = {}) {
|
|
|
1370
2364
|
};
|
|
1371
2365
|
}
|
|
1372
2366
|
//#endregion
|
|
1373
|
-
//#region src/
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
2367
|
+
//#region src/position.ts
|
|
2368
|
+
/**
|
|
2369
|
+
* Where a value sits along a track, as a whole percentage from its start.
|
|
2370
|
+
*
|
|
2371
|
+
* Normalized along the scale, so a thumb, the fill behind it and the marks
|
|
2372
|
+
* all sit on the curve the drag follows. `reversed` measures from the other
|
|
2373
|
+
* end — for a track that grows upwards or leftwards on screen, since CSS
|
|
2374
|
+
* places things from the top and the left.
|
|
2375
|
+
*/
|
|
2376
|
+
function valuePercent(value, { min, max, scale = _tremolo_ui_functions.linearScale }, reversed = false) {
|
|
2377
|
+
const normalized = scale.normalize(value, min, max);
|
|
2378
|
+
return (0, _tremolo_ui_functions.toFixed)((reversed ? 1 - normalized : normalized) * 100);
|
|
1381
2379
|
}
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
2380
|
+
//#endregion
|
|
2381
|
+
//#region src/slider/decimal-digits.ts
|
|
2382
|
+
/**
|
|
2383
|
+
* How many digits a number carries after the decimal point.
|
|
2384
|
+
*
|
|
2385
|
+
* `Slider.Marks` builds its labels by multiplying an interval, which leaves
|
|
2386
|
+
* binary debris in the last digits (`0.1 * 3` is `0.30000000000000004`). The
|
|
2387
|
+
* count is what tells `toFixed` how far to round that back.
|
|
2388
|
+
*
|
|
2389
|
+
* The exponent form has to be handled separately: `String(1e-7)` is `'1e-7'`,
|
|
2390
|
+
* which has no decimal point at all, so reading the text after the point
|
|
2391
|
+
* reports no digits and the interval rounds away to whole numbers.
|
|
2392
|
+
*/
|
|
2393
|
+
function decimalDigits(x) {
|
|
2394
|
+
if (!Number.isFinite(x)) return 0;
|
|
2395
|
+
const text = String(x);
|
|
2396
|
+
const e = text.indexOf("e");
|
|
2397
|
+
if (e === -1) return text.split(".")[1]?.length ?? 0;
|
|
2398
|
+
const fraction = text.slice(0, e).split(".")[1]?.length ?? 0;
|
|
2399
|
+
return Math.max(0, fraction - Number(text.slice(e + 1)));
|
|
1385
2400
|
}
|
|
2401
|
+
//#endregion
|
|
2402
|
+
//#region src/slider/marks.ts
|
|
1386
2403
|
/**
|
|
1387
|
-
*
|
|
2404
|
+
* The marks `options` asks for between `min` and `max`, in ascending order.
|
|
1388
2405
|
*
|
|
1389
|
-
*
|
|
1390
|
-
*
|
|
2406
|
+
* Each value is a whole multiple of the interval, rounded to the digits the
|
|
2407
|
+
* interval has: stepping by 0.1 would otherwise put binary debris in the
|
|
2408
|
+
* labels (`0.1 * 3` is `0.30000000000000004`).
|
|
1391
2409
|
*/
|
|
1392
|
-
function
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
2410
|
+
function sliderMarks(options, min, max, step) {
|
|
2411
|
+
const { per, mark = true, label = true } = typeof options === "object" ? options : { per: options };
|
|
2412
|
+
const optionsList = [];
|
|
2413
|
+
const interval = per === "step" ? step : per;
|
|
2414
|
+
const count = Math.floor(max / interval) - Math.ceil(min / interval) + 1;
|
|
2415
|
+
for (let i = 0; i < count; i++) {
|
|
2416
|
+
const value = (0, _tremolo_ui_functions.toFixed)(interval * (Math.ceil(min / interval) + i), decimalDigits(interval));
|
|
2417
|
+
optionsList.push({
|
|
2418
|
+
value,
|
|
2419
|
+
mark,
|
|
2420
|
+
label
|
|
2421
|
+
});
|
|
1404
2422
|
}
|
|
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
|
-
};
|
|
2423
|
+
return optionsList;
|
|
1438
2424
|
}
|
|
1439
2425
|
//#endregion
|
|
2426
|
+
//#region src/style.ts
|
|
2427
|
+
/**
|
|
2428
|
+
* A number as a CSS length, for a custom property.
|
|
2429
|
+
*
|
|
2430
|
+
* A custom property takes whatever text it is given, so `--size: 50` comes out
|
|
2431
|
+
* as the invalid `50` rather than `50px`. React appends `px` to a bare number
|
|
2432
|
+
* only for the properties it knows take a length, and a custom property is
|
|
2433
|
+
* never one of them; Vue and Svelte append nothing at all. Anything already a
|
|
2434
|
+
* string is passed through, so `'3rem'` and `'100%'` still work.
|
|
2435
|
+
*/
|
|
2436
|
+
function cssLength(value) {
|
|
2437
|
+
return typeof value === "number" ? `${value}px` : value;
|
|
2438
|
+
}
|
|
2439
|
+
/**
|
|
2440
|
+
* Take an element out of sight while leaving it in the accessibility tree and
|
|
2441
|
+
* in the tab order.
|
|
2442
|
+
*
|
|
2443
|
+
* `display: none` and `visibility: hidden` would remove it from both, and the
|
|
2444
|
+
* native control underneath a headless component is what carries the ARIA and
|
|
2445
|
+
* the keyboard behaviour. `pointer-events: none` is safe because nothing is
|
|
2446
|
+
* ever clicked here directly: a `<label>` forwards its click, and a drag is
|
|
2447
|
+
* handled by the part that is visible.
|
|
2448
|
+
*
|
|
2449
|
+
* Every value is a string with its unit, so the object can be handed to any
|
|
2450
|
+
* framework's `style` binding as it is.
|
|
2451
|
+
*/
|
|
2452
|
+
const visuallyHiddenStyle = {
|
|
2453
|
+
position: "absolute",
|
|
2454
|
+
width: "1px",
|
|
2455
|
+
height: "1px",
|
|
2456
|
+
padding: "0",
|
|
2457
|
+
margin: "-1px",
|
|
2458
|
+
overflow: "hidden",
|
|
2459
|
+
clip: "rect(0, 0, 0, 0)",
|
|
2460
|
+
clipPath: "inset(50%)",
|
|
2461
|
+
whiteSpace: "nowrap",
|
|
2462
|
+
border: "0",
|
|
2463
|
+
pointerEvents: "none"
|
|
2464
|
+
};
|
|
2465
|
+
//#endregion
|
|
2466
|
+
exports.DEFAULT_DRAG_SENSITIVITY = DEFAULT_DRAG_SENSITIVITY;
|
|
2467
|
+
exports.DEFAULT_KEYBOARD_OPTIONS = DEFAULT_KEYBOARD_OPTIONS;
|
|
2468
|
+
exports.DEFAULT_WHEEL_OPTIONS = DEFAULT_WHEEL_OPTIONS;
|
|
2469
|
+
exports.KNOB_VIEWBOX_SIZE = KNOB_VIEWBOX_SIZE;
|
|
1440
2470
|
exports.NOT_SUPPORTED = NOT_SUPPORTED;
|
|
1441
2471
|
exports.PERMISSION_DENIED = PERMISSION_DENIED;
|
|
1442
2472
|
exports.PITCH_BEND_CENTER = PITCH_BEND_CENTER;
|
|
2473
|
+
exports.POINTS_EDITOR_DEFAULT_KEYBOARD = POINTS_EDITOR_DEFAULT_KEYBOARD;
|
|
2474
|
+
exports.POINTS_EDITOR_DEFAULT_WHEEL = POINTS_EDITOR_DEFAULT_WHEEL;
|
|
2475
|
+
exports.POINT_AXIS = POINT_AXIS;
|
|
2476
|
+
exports.SHORTCUTS = SHORTCUTS;
|
|
1443
2477
|
exports.UNAVAILABLE = UNAVAILABLE;
|
|
1444
2478
|
exports.applyDelta = applyDelta;
|
|
2479
|
+
exports.arrowKeyDirection = arrowKeyDirection;
|
|
2480
|
+
exports.arrowKeyMove = arrowKeyMove;
|
|
1445
2481
|
exports.blackKeyWidth = blackKeyWidth;
|
|
2482
|
+
exports.caretAtDecimalOffset = caretAtDecimalOffset;
|
|
2483
|
+
exports.caretDecimalOffset = caretDecimalOffset;
|
|
2484
|
+
exports.checkSteps = checkSteps;
|
|
2485
|
+
exports.clampPoint = clampPoint;
|
|
2486
|
+
exports.commitNumberInputText = commitNumberInputText;
|
|
1446
2487
|
exports.createAnimationCanvas = createAnimationCanvas;
|
|
1447
2488
|
exports.createDrag = createDrag;
|
|
1448
2489
|
exports.createDragValue = createDragValue;
|
|
1449
2490
|
exports.createDropZone = createDropZone;
|
|
2491
|
+
exports.createLongPress = createLongPress;
|
|
1450
2492
|
exports.createMIDIAccess = createMIDIAccess;
|
|
1451
2493
|
exports.createMIDIInput = createMIDIInput;
|
|
1452
2494
|
exports.createMIDIMessage = createMIDIMessage;
|
|
1453
2495
|
exports.createPianoInput = createPianoInput;
|
|
2496
|
+
exports.createPointsEditor = createPointsEditor;
|
|
1454
2497
|
exports.createSelectionBox = createSelectionBox;
|
|
2498
|
+
exports.createStepperDrag = createStepperDrag;
|
|
1455
2499
|
exports.createWheel = createWheel;
|
|
2500
|
+
exports.cssLength = cssLength;
|
|
1456
2501
|
exports.drawingState = drawingState;
|
|
1457
2502
|
exports.elementMapping = elementMapping;
|
|
2503
|
+
exports.fitWhiteKeyWidth = fitWhiteKeyWidth;
|
|
1458
2504
|
exports.getNoteRangeArray = getNoteRangeArray;
|
|
2505
|
+
exports.isArrowKey = isArrowKey;
|
|
1459
2506
|
exports.isDrawingState = isDrawingState;
|
|
2507
|
+
exports.knobAngles = knobAngles;
|
|
2508
|
+
exports.knobArcPath = knobArcPath;
|
|
2509
|
+
exports.knobArcPoint = knobArcPoint;
|
|
2510
|
+
exports.knobArcRadius = knobArcRadius;
|
|
1460
2511
|
exports.mapModifier = mapModifier;
|
|
1461
2512
|
exports.matchesAccept = matchesAccept;
|
|
1462
2513
|
exports.noteAt = noteAt;
|
|
1463
2514
|
exports.notePosition = notePosition;
|
|
2515
|
+
exports.nudgeNumberInput = nudgeNumberInput;
|
|
2516
|
+
exports.numberInputBounds = numberInputBounds;
|
|
2517
|
+
exports.numberInputRanges = numberInputRanges;
|
|
2518
|
+
exports.numberSpan = numberSpan;
|
|
2519
|
+
exports.parseNumberText = parseNumberText;
|
|
2520
|
+
exports.partitionByAccept = partitionByAccept;
|
|
1464
2521
|
exports.pianoWidth = pianoWidth;
|
|
1465
2522
|
exports.relativeMapping = relativeMapping;
|
|
2523
|
+
exports.replaceOptions = replaceOptions;
|
|
1466
2524
|
exports.selectModifier = selectModifier;
|
|
1467
2525
|
exports.selectionBoxCovers = selectionBoxCovers;
|
|
2526
|
+
exports.sliderMarks = sliderMarks;
|
|
1468
2527
|
exports.toXY = toXY;
|
|
2528
|
+
exports.valuePercent = valuePercent;
|
|
2529
|
+
exports.visuallyHiddenStyle = visuallyHiddenStyle;
|
|
2530
|
+
exports.wheelDirection = wheelDirection;
|
|
2531
|
+
exports.wheelMove = wheelMove;
|
|
1469
2532
|
|
|
1470
2533
|
//# sourceMappingURL=index.cjs.map
|