@tremolo-ui/dom 0.6.0 → 0.7.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 CHANGED
@@ -236,6 +236,271 @@ function createAnimationCanvas(canvas, options) {
236
236
  };
237
237
  }
238
238
  //#endregion
239
+ //#region src/file/accept.ts
240
+ /**
241
+ * Does a file satisfy an `accept` attribute?
242
+ *
243
+ * `accept` is written the way the HTML attribute is: a comma separated list of
244
+ * extensions (`.wav`), MIME types (`audio/wav`) and type groups (`audio/*`).
245
+ * Anything that matches one entry is accepted, and an empty or missing
246
+ * `accept` takes everything.
247
+ *
248
+ * **The browser's own `accept` is only a hint to the file picker.** A person
249
+ * can switch it to "All Files", drag a file in, or pick one the picker was
250
+ * never asked about, so what arrives still has to be checked.
251
+ *
252
+ * A rule that cannot be decided is not treated as a rejection: with no `name`,
253
+ * an extension rule says nothing either way, and `accept=".wav"` reports a
254
+ * match rather than refusing a file it has not seen the name of. The name is
255
+ * there by the time the file is dropped, which is when the answer counts.
256
+ */
257
+ function matchesAccept(candidate, accept) {
258
+ const rules = (accept ?? "").split(",").map((rule) => rule.trim().toLowerCase()).filter(Boolean);
259
+ if (rules.length === 0) return true;
260
+ const name = candidate.name?.toLowerCase();
261
+ const type = candidate.type.toLowerCase();
262
+ let undecided = false;
263
+ for (const rule of rules) if (rule.startsWith(".")) {
264
+ if (name === void 0) undecided = true;
265
+ else if (name.endsWith(rule)) return true;
266
+ } else if (rule.endsWith("/*")) {
267
+ if (type.startsWith(rule.slice(0, -1))) return true;
268
+ } else if (type === rule) return true;
269
+ return undecided;
270
+ }
271
+ //#endregion
272
+ //#region src/file/drop-zone.ts
273
+ /**
274
+ * Take files dropped onto an element.
275
+ *
276
+ * ```ts
277
+ * const zone = createDropZone(element, {
278
+ * accept: 'audio/*',
279
+ * onDrop: (files) => load(files[0]),
280
+ * })
281
+ * ```
282
+ *
283
+ * The element needs no attribute of its own: a drop target is made by
284
+ * cancelling `dragover`, which this does.
285
+ */
286
+ function createDropZone(element, options = {}) {
287
+ let opts = options;
288
+ let state = {
289
+ over: false,
290
+ invalid: false
291
+ };
292
+ /**
293
+ * `dragenter` and `dragleave` fire for descendants too, so moving between
294
+ * two children of the zone leaves before it enters. Counting the pairs is
295
+ * what keeps the state from flickering off in the middle of the element.
296
+ */
297
+ let depth = 0;
298
+ function setState(next) {
299
+ if (next.over === state.over && next.invalid === state.invalid) return;
300
+ state = next;
301
+ opts.onStateChange?.(state);
302
+ }
303
+ function carriesFiles(transfer) {
304
+ return !!transfer?.types.includes("Files");
305
+ }
306
+ /** Whether anything being dragged could still be accepted. */
307
+ function anyAcceptable(transfer) {
308
+ const items = Array.from(transfer?.items ?? []).filter((item) => item.kind === "file");
309
+ if (items.length === 0) return true;
310
+ return items.some((item) => matchesAccept({ type: item.type }, opts.accept));
311
+ }
312
+ function onDragEnter(event) {
313
+ if (!carriesFiles(event.dataTransfer)) return;
314
+ event.preventDefault();
315
+ depth += 1;
316
+ setState({
317
+ over: true,
318
+ invalid: !anyAcceptable(event.dataTransfer)
319
+ });
320
+ }
321
+ function onDragOver(event) {
322
+ if (!carriesFiles(event.dataTransfer)) return;
323
+ event.preventDefault();
324
+ if (event.dataTransfer) event.dataTransfer.dropEffect = opts.disabled || state.invalid ? "none" : "copy";
325
+ if (!state.over) {
326
+ depth = Math.max(depth, 1);
327
+ setState({
328
+ over: true,
329
+ invalid: !anyAcceptable(event.dataTransfer)
330
+ });
331
+ }
332
+ }
333
+ function onDragLeave(event) {
334
+ if (!carriesFiles(event.dataTransfer)) return;
335
+ depth = Math.max(0, depth - 1);
336
+ if (depth === 0) setState({
337
+ over: false,
338
+ invalid: false
339
+ });
340
+ }
341
+ function onDrop(event) {
342
+ if (!carriesFiles(event.dataTransfer)) return;
343
+ event.preventDefault();
344
+ depth = 0;
345
+ setState({
346
+ over: false,
347
+ invalid: false
348
+ });
349
+ if (opts.disabled) return;
350
+ const dropped = Array.from(event.dataTransfer?.files ?? []);
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);
356
+ const taken = opts.multiple ? accepted : accepted.slice(0, 1);
357
+ if (taken.length > 0) opts.onDrop?.(taken, event);
358
+ }
359
+ /**
360
+ * A drag that ends anywhere else — dropped on another element, cancelled
361
+ * with Esc, taken out of the window — sends no `dragleave` here, and the
362
+ * element would stay marked as a drop target for good.
363
+ */
364
+ function onDragEndAnywhere() {
365
+ depth = 0;
366
+ setState({
367
+ over: false,
368
+ invalid: false
369
+ });
370
+ }
371
+ const handlers = {
372
+ dragenter: onDragEnter,
373
+ dragover: onDragOver,
374
+ dragleave: onDragLeave,
375
+ drop: onDrop
376
+ };
377
+ for (const [type, handler] of Object.entries(handlers)) element.addEventListener(type, handler);
378
+ const doc = element.ownerDocument;
379
+ doc?.addEventListener("dragend", onDragEndAnywhere);
380
+ doc?.addEventListener("drop", onDragEndAnywhere);
381
+ return {
382
+ get state() {
383
+ return state;
384
+ },
385
+ update: (next) => {
386
+ opts = {
387
+ ...opts,
388
+ ...next
389
+ };
390
+ },
391
+ destroy: () => {
392
+ for (const [type, handler] of Object.entries(handlers)) element.removeEventListener(type, handler);
393
+ doc?.removeEventListener("dragend", onDragEndAnywhere);
394
+ doc?.removeEventListener("drop", onDragEndAnywhere);
395
+ }
396
+ };
397
+ }
398
+ //#endregion
399
+ //#region src/input/modifiers.ts
400
+ /**
401
+ * Checked in this order, and the first one that is both held and configured
402
+ * wins. Fixing an order is what keeps two modifiers held at once from
403
+ * behaving differently between browsers.
404
+ */
405
+ const MODIFIER_ORDER = [
406
+ "meta",
407
+ "ctrl",
408
+ "alt",
409
+ "shift"
410
+ ];
411
+ const MODIFIER_FLAG = {
412
+ meta: "metaKey",
413
+ ctrl: "ctrlKey",
414
+ alt: "altKey",
415
+ shift: "shiftKey"
416
+ };
417
+ /**
418
+ * A map is the only form with a `default` key, which is what tells it apart
419
+ * from a bare setting. Tuples are arrays, so they never match.
420
+ */
421
+ function isModifierMap(value) {
422
+ return typeof value === "object" && value !== null && !Array.isArray(value) && "default" in value;
423
+ }
424
+ /**
425
+ * Pick the setting that applies, given the modifier keys being held.
426
+ *
427
+ * @example
428
+ * selectModifier({ default: 1, shift: 0.1 }, event)
429
+ */
430
+ function selectModifier(options, modifiers) {
431
+ if (!isModifierMap(options)) return {
432
+ value: options,
433
+ modifier: null
434
+ };
435
+ if (modifiers) for (const modifier of MODIFIER_ORDER) {
436
+ const value = options[modifier];
437
+ if (value !== void 0 && modifiers[MODIFIER_FLAG[modifier]]) return {
438
+ value,
439
+ modifier
440
+ };
441
+ }
442
+ return {
443
+ value: options.default,
444
+ modifier: null
445
+ };
446
+ }
447
+ /**
448
+ * Turn every entry of a setting into another kind of setting, keeping which
449
+ * modifier each belongs to.
450
+ *
451
+ * A drag sensitivity is a number and a keyboard amount is a tuple, but the two
452
+ * describe the same thing from the caller's side. This carries one over to the
453
+ * other so that a component can hand a sensitivity to {@link applyDelta}
454
+ * without unpicking the modifier map itself — which matters, since naming a
455
+ * modifier is also what takes `step` out of the pipeline.
456
+ *
457
+ * @example
458
+ * mapModifier({ default: 1, shift: 0.1 }, (f) => ['raw', step * f])
459
+ * // { default: ['raw', 1], shift: ['raw', 0.1] }
460
+ */
461
+ function mapModifier(options, fn) {
462
+ if (!isModifierMap(options)) return fn(options);
463
+ const mapped = { default: fn(options.default) };
464
+ for (const modifier of MODIFIER_ORDER) {
465
+ const value = options[modifier];
466
+ if (value !== void 0) mapped[modifier] = fn(value);
467
+ }
468
+ return mapped;
469
+ }
470
+ //#endregion
471
+ //#region src/input/apply-delta.ts
472
+ /**
473
+ * Move a value by an amount of input, as reported by a wheel or an arrow key.
474
+ *
475
+ * The pipeline matches {@link createDragValue}: scale, then step, then clamp.
476
+ * Which key or which sign of `deltaY` counts as which direction is left to the
477
+ * caller, since it differs per component.
478
+ *
479
+ * @param direction which way, and how many times, to apply the option. The
480
+ * size of one step is `option[1]`, so this is normally `1` or `-1`.
481
+ *
482
+ * @param modifiers the event, for `options` that name a modifier key. See
483
+ * {@link selectModifier}.
484
+ *
485
+ * @example
486
+ * // ArrowDown on a slider whose keyboard option is ['raw', 1]
487
+ * applyDelta(value, -1, keyboard, { min, max, step, scale })
488
+ *
489
+ * @example
490
+ * // Shift+ArrowDown, where `keyboard` is { default: …, shift: ['raw', 0.1] }
491
+ * applyDelta(value, -1, keyboard, range, event)
492
+ */
493
+ function applyDelta(value, direction, options, { min, max, step, scale = _tremolo_ui_functions.linearScale }, modifiers) {
494
+ if (min >= max) throw new RangeError("requirements: min < max");
495
+ if (step !== void 0 && (!Number.isFinite(step) || step <= 0)) throw new RangeError("applyDelta step: requirements: finite and greater than 0");
496
+ const { value: [mode, amount], modifier } = selectModifier(options, modifiers);
497
+ const x = direction * amount;
498
+ const next = mode === "normalized" ? scale.denormalize(scale.normalize(value, min, max) + x, min, max) : value + x;
499
+ const quantum = modifier === null ? step : void 0;
500
+ const stepped = quantum !== void 0 ? (0, _tremolo_ui_functions.stepValue)(next, quantum) : next;
501
+ return (0, _tremolo_ui_functions.clamp)((0, _tremolo_ui_functions.toPrecision)(stepped), min, max);
502
+ }
503
+ //#endregion
239
504
  //#region src/midi/access.ts
240
505
  /** @private */
241
506
  const PERMISSION_DENIED = "PERMISSION_DENIED";
@@ -449,6 +714,113 @@ function createMIDIInput(midiAccess, handlers) {
449
714
  };
450
715
  }
451
716
  //#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
452
824
  //#region src/pointer/drag.ts
453
825
  /**
454
826
  * Applied to the element for the lifetime of the instance.
@@ -731,7 +1103,7 @@ function createPianoInput(element, options) {
731
1103
  /** The note under a pointer, or null where the pointer is off the keys. */
732
1104
  function noteUnder(clientX, clientY) {
733
1105
  const { left, top, height } = element.getBoundingClientRect();
734
- return (0, _tremolo_ui_functions.noteAt)(clientX - left, clientY - top, height, opts.layout);
1106
+ return noteAt(clientX - left, clientY - top, height, opts.layout);
735
1107
  }
736
1108
  /** Move a pointer onto a note, releasing whatever it held before. */
737
1109
  function movePointer(pointerId, note) {
@@ -1069,9 +1441,12 @@ exports.NOT_SUPPORTED = NOT_SUPPORTED;
1069
1441
  exports.PERMISSION_DENIED = PERMISSION_DENIED;
1070
1442
  exports.PITCH_BEND_CENTER = PITCH_BEND_CENTER;
1071
1443
  exports.UNAVAILABLE = UNAVAILABLE;
1444
+ exports.applyDelta = applyDelta;
1445
+ exports.blackKeyWidth = blackKeyWidth;
1072
1446
  exports.createAnimationCanvas = createAnimationCanvas;
1073
1447
  exports.createDrag = createDrag;
1074
1448
  exports.createDragValue = createDragValue;
1449
+ exports.createDropZone = createDropZone;
1075
1450
  exports.createMIDIAccess = createMIDIAccess;
1076
1451
  exports.createMIDIInput = createMIDIInput;
1077
1452
  exports.createMIDIMessage = createMIDIMessage;
@@ -1080,8 +1455,15 @@ exports.createSelectionBox = createSelectionBox;
1080
1455
  exports.createWheel = createWheel;
1081
1456
  exports.drawingState = drawingState;
1082
1457
  exports.elementMapping = elementMapping;
1458
+ exports.getNoteRangeArray = getNoteRangeArray;
1083
1459
  exports.isDrawingState = isDrawingState;
1460
+ exports.mapModifier = mapModifier;
1461
+ exports.matchesAccept = matchesAccept;
1462
+ exports.noteAt = noteAt;
1463
+ exports.notePosition = notePosition;
1464
+ exports.pianoWidth = pianoWidth;
1084
1465
  exports.relativeMapping = relativeMapping;
1466
+ exports.selectModifier = selectModifier;
1085
1467
  exports.selectionBoxCovers = selectionBoxCovers;
1086
1468
  exports.toXY = toXY;
1087
1469