@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.
Files changed (53) hide show
  1. package/dist/index.cjs +343 -595
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +230 -284
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.ts +230 -284
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +325 -572
  8. package/dist/index.js.map +1 -1
  9. package/dist/internal.cjs +530 -0
  10. package/dist/internal.cjs.map +1 -0
  11. package/dist/internal.d.cts +365 -0
  12. package/dist/internal.d.cts.map +1 -0
  13. package/dist/internal.d.ts +365 -0
  14. package/dist/internal.d.ts.map +1 -0
  15. package/dist/internal.js +500 -0
  16. package/dist/internal.js.map +1 -0
  17. package/dist/marks-DK7xJmGM.d.cts +305 -0
  18. package/dist/marks-DK7xJmGM.d.cts.map +1 -0
  19. package/dist/marks-DK7xJmGM.d.ts +305 -0
  20. package/dist/marks-DK7xJmGM.d.ts.map +1 -0
  21. package/dist/xy-5Oc6JeJr.cjs +952 -0
  22. package/dist/xy-5Oc6JeJr.cjs.map +1 -0
  23. package/dist/xy-lNFl1CTO.js +827 -0
  24. package/dist/xy-lNFl1CTO.js.map +1 -0
  25. package/package.json +12 -2
  26. package/src/canvas/animation.ts +8 -0
  27. package/src/canvas/context.ts +0 -5
  28. package/src/file/accept.ts +17 -0
  29. package/src/file/drop-zone.ts +8 -10
  30. package/src/index.ts +44 -32
  31. package/src/input/change-gesture.ts +104 -0
  32. package/src/input/check-steps.ts +144 -0
  33. package/src/input/defaults.ts +32 -0
  34. package/src/input/direction.ts +107 -0
  35. package/src/internal.ts +51 -0
  36. package/src/knob/geometry.ts +100 -0
  37. package/src/midi/access.ts +16 -15
  38. package/src/midi/input.ts +2 -9
  39. package/src/number-input/stepper-drag.ts +147 -0
  40. package/src/number-input/text.ts +100 -0
  41. package/src/number-input/value.ts +129 -0
  42. package/src/options/replace.ts +22 -0
  43. package/src/piano/index.ts +128 -2
  44. package/src/piano/layout.ts +19 -0
  45. package/src/piano/shortcuts.ts +86 -0
  46. package/src/pointer/drag-value.ts +3 -1
  47. package/src/pointer/long-press.ts +103 -0
  48. package/src/pointer/wheel.ts +10 -7
  49. package/src/points-editor/index.ts +367 -0
  50. package/src/position.ts +20 -0
  51. package/src/slider/decimal-digits.ts +23 -0
  52. package/src/slider/marks.ts +56 -0
  53. package/src/style.ts +41 -0
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
- import { clamp, isBlackKey, isWhiteKey, linearScale, normalizeValue, noteKey, noteKeys, stepValue, toPrecision } from "@tremolo-ui/functions";
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 dropped = Array.from(event.dataTransfer?.files ?? []);
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/modifiers.ts
359
+ //#region src/input/change-gesture.ts
399
360
  /**
400
- * Checked in this order, and the first one that is both held and configured
401
- * wins. Fixing an order is what keeps two modifiers held at once from
402
- * behaving differently between browsers.
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
- const MODIFIER_ORDER = [
405
- "meta",
406
- "ctrl",
407
- "alt",
408
- "shift"
409
- ];
410
- const MODIFIER_FLAG = {
411
- meta: "metaKey",
412
- ctrl: "ctrlKey",
413
- alt: "altKey",
414
- shift: "shiftKey"
415
- };
416
- /**
417
- * A map is the only form with a `default` key, which is what tells it apart
418
- * from a bare setting. Tuples are arrays, so they never match.
419
- */
420
- function isModifierMap(value) {
421
- return typeof value === "object" && value !== null && !Array.isArray(value) && "default" in value;
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
- * Pick the setting that applies, given the modifier keys being held.
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
- function selectModifier(options, modifiers) {
430
- if (!isModifierMap(options)) return {
431
- value: options,
432
- modifier: null
433
- };
434
- if (modifiers) for (const modifier of MODIFIER_ORDER) {
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
- value: options.default,
443
- modifier: null
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
- * Turn every entry of a setting into another kind of setting, keeping which
448
- * modifier each belongs to.
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
- * @example
457
- * mapModifier({ default: 1, shift: 0.1 }, (f) => ['raw', step * f])
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 mapModifier(options, fn) {
461
- if (!isModifierMap(options)) return fn(options);
462
- const mapped = { default: fn(options.default) };
463
- for (const modifier of MODIFIER_ORDER) {
464
- const value = options[modifier];
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
- * Move a value by an amount of input, as reported by a wheel or an arrow key.
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 applyDelta(value, direction, options, { min, max, step, scale = linearScale }, modifiers) {
493
- if (min >= max) throw new RangeError("requirements: min < max");
494
- if (step !== void 0 && (!Number.isFinite(step) || step <= 0)) throw new RangeError("applyDelta step: requirements: finite and greater than 0");
495
- const { value: [mode, amount], modifier } = selectModifier(options, modifiers);
496
- const x = direction * amount;
497
- const next = mode === "normalized" ? scale.denormalize(scale.normalize(value, min, max) + x, min, max) : value + x;
498
- const quantum = modifier === null ? step : void 0;
499
- const stepped = quantum !== void 0 ? stepValue(next, quantum) : next;
500
- return clamp(toPrecision(stepped), min, max);
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/layout.ts
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
- * How many white keys sit at or before each pitch class, counting from C.
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 whiteKeysBefore = {
734
- C: 0,
735
- "C#": 1,
736
- D: 1,
737
- "D#": 2,
738
- E: 2,
739
- F: 3,
740
- "F#": 4,
741
- G: 4,
742
- "G#": 5,
743
- A: 5,
744
- "A#": 6,
745
- B: 6
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
- * Track a pointer drag on an element.
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 createDrag(element, options = {}) {
850
- let opts = options;
851
- const multiPointer = options.multiPointer ?? false;
852
- const capture = element;
853
- const style = element.style;
854
- let managedStyles = managedStyleStates.get(element);
855
- if (style) {
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
- function releaseTarget(target) {
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, onWheel, options = {}) {
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?.(event);
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/selection/box.ts
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
- * Selecting by dragging a box over a set of items.
1180
+ * Where a value sits along a track, as a whole percentage from its start.
1387
1181
  *
1388
- * The drag itself is not here: the caller owns the pointer, and reports where
1389
- * it went in the same 0..1 space the items are given in.
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 createSelectionBox(options) {
1392
- let opts = options;
1393
- let drag = null;
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 { NOT_SUPPORTED, PERMISSION_DENIED, PITCH_BEND_CENTER, UNAVAILABLE, applyDelta, blackKeyWidth, createAnimationCanvas, createDrag, createDragValue, createDropZone, createMIDIAccess, createMIDIInput, createMIDIMessage, createPianoInput, createSelectionBox, createWheel, drawingState, elementMapping, getNoteRangeArray, isDrawingState, mapModifier, matchesAccept, noteAt, notePosition, pianoWidth, relativeMapping, selectModifier, selectionBoxCovers, toXY };
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