@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.js CHANGED
@@ -1,4 +1,4 @@
1
- import { clamp, linearScale, normalizeValue, noteAt, stepValue, toPrecision } from "@tremolo-ui/functions";
1
+ import { clamp, isBlackKey, isWhiteKey, linearScale, normalizeValue, noteKey, noteKeys, stepValue, toPrecision } from "@tremolo-ui/functions";
2
2
  //#region src/canvas/context.ts
3
3
  /**
4
4
  * The assignable parts of a 2D context's drawing state. They have to be
@@ -235,6 +235,271 @@ function createAnimationCanvas(canvas, options) {
235
235
  };
236
236
  }
237
237
  //#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
+ //#region src/file/drop-zone.ts
272
+ /**
273
+ * Take files dropped onto an element.
274
+ *
275
+ * ```ts
276
+ * const zone = createDropZone(element, {
277
+ * accept: 'audio/*',
278
+ * onDrop: (files) => load(files[0]),
279
+ * })
280
+ * ```
281
+ *
282
+ * The element needs no attribute of its own: a drop target is made by
283
+ * cancelling `dragover`, which this does.
284
+ */
285
+ function createDropZone(element, options = {}) {
286
+ let opts = options;
287
+ let state = {
288
+ over: false,
289
+ invalid: false
290
+ };
291
+ /**
292
+ * `dragenter` and `dragleave` fire for descendants too, so moving between
293
+ * two children of the zone leaves before it enters. Counting the pairs is
294
+ * what keeps the state from flickering off in the middle of the element.
295
+ */
296
+ let depth = 0;
297
+ function setState(next) {
298
+ if (next.over === state.over && next.invalid === state.invalid) return;
299
+ state = next;
300
+ opts.onStateChange?.(state);
301
+ }
302
+ function carriesFiles(transfer) {
303
+ return !!transfer?.types.includes("Files");
304
+ }
305
+ /** Whether anything being dragged could still be accepted. */
306
+ function anyAcceptable(transfer) {
307
+ const items = Array.from(transfer?.items ?? []).filter((item) => item.kind === "file");
308
+ if (items.length === 0) return true;
309
+ return items.some((item) => matchesAccept({ type: item.type }, opts.accept));
310
+ }
311
+ function onDragEnter(event) {
312
+ if (!carriesFiles(event.dataTransfer)) return;
313
+ event.preventDefault();
314
+ depth += 1;
315
+ setState({
316
+ over: true,
317
+ invalid: !anyAcceptable(event.dataTransfer)
318
+ });
319
+ }
320
+ function onDragOver(event) {
321
+ if (!carriesFiles(event.dataTransfer)) return;
322
+ event.preventDefault();
323
+ if (event.dataTransfer) event.dataTransfer.dropEffect = opts.disabled || state.invalid ? "none" : "copy";
324
+ if (!state.over) {
325
+ depth = Math.max(depth, 1);
326
+ setState({
327
+ over: true,
328
+ invalid: !anyAcceptable(event.dataTransfer)
329
+ });
330
+ }
331
+ }
332
+ function onDragLeave(event) {
333
+ if (!carriesFiles(event.dataTransfer)) return;
334
+ depth = Math.max(0, depth - 1);
335
+ if (depth === 0) setState({
336
+ over: false,
337
+ invalid: false
338
+ });
339
+ }
340
+ function onDrop(event) {
341
+ if (!carriesFiles(event.dataTransfer)) return;
342
+ event.preventDefault();
343
+ depth = 0;
344
+ setState({
345
+ over: false,
346
+ invalid: false
347
+ });
348
+ 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);
355
+ const taken = opts.multiple ? accepted : accepted.slice(0, 1);
356
+ if (taken.length > 0) opts.onDrop?.(taken, event);
357
+ }
358
+ /**
359
+ * A drag that ends anywhere else — dropped on another element, cancelled
360
+ * with Esc, taken out of the window — sends no `dragleave` here, and the
361
+ * element would stay marked as a drop target for good.
362
+ */
363
+ function onDragEndAnywhere() {
364
+ depth = 0;
365
+ setState({
366
+ over: false,
367
+ invalid: false
368
+ });
369
+ }
370
+ const handlers = {
371
+ dragenter: onDragEnter,
372
+ dragover: onDragOver,
373
+ dragleave: onDragLeave,
374
+ drop: onDrop
375
+ };
376
+ for (const [type, handler] of Object.entries(handlers)) element.addEventListener(type, handler);
377
+ const doc = element.ownerDocument;
378
+ doc?.addEventListener("dragend", onDragEndAnywhere);
379
+ doc?.addEventListener("drop", onDragEndAnywhere);
380
+ return {
381
+ get state() {
382
+ return state;
383
+ },
384
+ update: (next) => {
385
+ opts = {
386
+ ...opts,
387
+ ...next
388
+ };
389
+ },
390
+ destroy: () => {
391
+ for (const [type, handler] of Object.entries(handlers)) element.removeEventListener(type, handler);
392
+ doc?.removeEventListener("dragend", onDragEndAnywhere);
393
+ doc?.removeEventListener("drop", onDragEndAnywhere);
394
+ }
395
+ };
396
+ }
397
+ //#endregion
398
+ //#region src/input/modifiers.ts
399
+ /**
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.
403
+ */
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;
422
+ }
423
+ /**
424
+ * Pick the setting that applies, given the modifier keys being held.
425
+ *
426
+ * @example
427
+ * selectModifier({ default: 1, shift: 0.1 }, event)
428
+ */
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
+ }
441
+ return {
442
+ value: options.default,
443
+ modifier: null
444
+ };
445
+ }
446
+ /**
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.
455
+ *
456
+ * @example
457
+ * mapModifier({ default: 1, shift: 0.1 }, (f) => ['raw', step * f])
458
+ * // { default: ['raw', 1], shift: ['raw', 0.1] }
459
+ */
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;
468
+ }
469
+ //#endregion
470
+ //#region src/input/apply-delta.ts
471
+ /**
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)
491
+ */
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);
501
+ }
502
+ //#endregion
238
503
  //#region src/midi/access.ts
239
504
  /** @private */
240
505
  const PERMISSION_DENIED = "PERMISSION_DENIED";
@@ -448,6 +713,113 @@ function createMIDIInput(midiAccess, handlers) {
448
713
  };
449
714
  }
450
715
  //#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;
726
+ /**
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.
732
+ */
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
746
+ };
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
451
823
  //#region src/pointer/drag.ts
452
824
  /**
453
825
  * Applied to the element for the lifetime of the instance.
@@ -1064,6 +1436,6 @@ function createSelectionBox(options) {
1064
1436
  };
1065
1437
  }
1066
1438
  //#endregion
1067
- export { NOT_SUPPORTED, PERMISSION_DENIED, PITCH_BEND_CENTER, UNAVAILABLE, createAnimationCanvas, createDrag, createDragValue, createMIDIAccess, createMIDIInput, createMIDIMessage, createPianoInput, createSelectionBox, createWheel, drawingState, elementMapping, isDrawingState, relativeMapping, selectionBoxCovers, toXY };
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 };
1068
1440
 
1069
1441
  //# sourceMappingURL=index.js.map