@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.
@@ -0,0 +1,202 @@
1
+ import { matchesAccept } from './accept'
2
+
3
+ /** What is in the air over the element. */
4
+ export interface DropZoneState {
5
+ /** Files are being dragged over the element. */
6
+ over: boolean
7
+ /**
8
+ * None of what is being dragged matches `accept`, as far as can be told
9
+ * before the drop.
10
+ *
11
+ * The browser reports the type of what is being dragged but withholds the
12
+ * name, so a rule written as an extension cannot be decided yet and is not
13
+ * counted against the drag. It is decided on the drop, where the name is.
14
+ */
15
+ invalid: boolean
16
+ }
17
+
18
+ export interface DropZoneOptions {
19
+ /**
20
+ * Which files to take, written the way the `accept` attribute of a file
21
+ * input is: a comma separated list of extensions (`.wav`), MIME types
22
+ * (`audio/wav`) and type groups (`audio/*`).
23
+ */
24
+ accept?: string
25
+ /**
26
+ * Take more than one file from a single drop. With it off, only the first
27
+ * accepted file is reported, as a file input without `multiple` does.
28
+ *
29
+ * @default false
30
+ */
31
+ multiple?: boolean
32
+ /**
33
+ * Refuse the drop. The drag is still swallowed rather than let through: an
34
+ * unhandled drop makes the browser leave the page and open the file.
35
+ *
36
+ * @default false
37
+ */
38
+ disabled?: boolean
39
+
40
+ /** Called with the dropped files that match `accept`. */
41
+ onDrop?: (files: File[], event: DragEvent) => void
42
+ /**
43
+ * Called with the dropped files that do not match `accept`, so that the
44
+ * reason can be shown.
45
+ */
46
+ onReject?: (files: File[], event: DragEvent) => void
47
+ /** Called whenever {@link DropZoneInstance.state} would change. */
48
+ onStateChange?: (state: DropZoneState) => void
49
+ }
50
+
51
+ export interface DropZoneInstance {
52
+ /** What is in the air over the element, right now. */
53
+ readonly state: DropZoneState
54
+ /** Replace the given options, keeping the listeners in place. */
55
+ update: (options: DropZoneOptions) => void
56
+ destroy: () => void
57
+ }
58
+
59
+ /**
60
+ * Take files dropped onto an element.
61
+ *
62
+ * ```ts
63
+ * const zone = createDropZone(element, {
64
+ * accept: 'audio/*',
65
+ * onDrop: (files) => load(files[0]),
66
+ * })
67
+ * ```
68
+ *
69
+ * The element needs no attribute of its own: a drop target is made by
70
+ * cancelling `dragover`, which this does.
71
+ */
72
+ export function createDropZone(
73
+ element: Element,
74
+ options: DropZoneOptions = {},
75
+ ): DropZoneInstance {
76
+ let opts = options
77
+ let state: DropZoneState = { over: false, invalid: false }
78
+
79
+ /**
80
+ * `dragenter` and `dragleave` fire for descendants too, so moving between
81
+ * two children of the zone leaves before it enters. Counting the pairs is
82
+ * what keeps the state from flickering off in the middle of the element.
83
+ */
84
+ let depth = 0
85
+
86
+ function setState(next: DropZoneState) {
87
+ if (next.over === state.over && next.invalid === state.invalid) return
88
+ state = next
89
+ opts.onStateChange?.(state)
90
+ }
91
+
92
+ function carriesFiles(transfer: DataTransfer | null) {
93
+ // `types` is the only thing that can be trusted during a drag; `files` is
94
+ // empty until the drop.
95
+ return !!transfer?.types.includes('Files')
96
+ }
97
+
98
+ /** Whether anything being dragged could still be accepted. */
99
+ function anyAcceptable(transfer: DataTransfer | null) {
100
+ const items = Array.from(transfer?.items ?? []).filter(
101
+ (item) => item.kind === 'file',
102
+ )
103
+ // Some browsers hand over no items at all, only the `Files` type. Nothing
104
+ // is known, so nothing is refused.
105
+ if (items.length === 0) return true
106
+ return items.some((item) => matchesAccept({ type: item.type }, opts.accept))
107
+ }
108
+
109
+ function onDragEnter(event: DragEvent) {
110
+ if (!carriesFiles(event.dataTransfer)) return
111
+ // Cancelled as well as `dragover`: a target that only cancels one of the
112
+ // two is not a drop target in every browser.
113
+ event.preventDefault()
114
+ depth += 1
115
+ setState({ over: true, invalid: !anyAcceptable(event.dataTransfer) })
116
+ }
117
+
118
+ function onDragOver(event: DragEvent) {
119
+ if (!carriesFiles(event.dataTransfer)) return
120
+ event.preventDefault()
121
+ if (event.dataTransfer) {
122
+ // Decides the cursor the pointer shows, and whether a drop is offered.
123
+ event.dataTransfer.dropEffect =
124
+ opts.disabled || state.invalid ? 'none' : 'copy'
125
+ }
126
+ // A drag that began outside the document can arrive without a `dragenter`
127
+ // the listener saw, so the state is settled here too.
128
+ if (!state.over) {
129
+ depth = Math.max(depth, 1)
130
+ setState({ over: true, invalid: !anyAcceptable(event.dataTransfer) })
131
+ }
132
+ }
133
+
134
+ function onDragLeave(event: DragEvent) {
135
+ if (!carriesFiles(event.dataTransfer)) return
136
+ depth = Math.max(0, depth - 1)
137
+ if (depth === 0) setState({ over: false, invalid: false })
138
+ }
139
+
140
+ function onDrop(event: DragEvent) {
141
+ if (!carriesFiles(event.dataTransfer)) return
142
+ // Always cancelled, even while disabled: an unhandled drop makes the
143
+ // browser leave the page and open the file.
144
+ event.preventDefault()
145
+ depth = 0
146
+ setState({ over: false, invalid: false })
147
+ if (opts.disabled) return
148
+
149
+ const dropped = Array.from(event.dataTransfer?.files ?? [])
150
+ const accepted: File[] = []
151
+ const rejected: File[] = []
152
+ for (const file of dropped) {
153
+ if (matchesAccept(file, opts.accept)) accepted.push(file)
154
+ else rejected.push(file)
155
+ }
156
+
157
+ if (rejected.length > 0) opts.onReject?.(rejected, event)
158
+ const taken = opts.multiple ? accepted : accepted.slice(0, 1)
159
+ if (taken.length > 0) opts.onDrop?.(taken, event)
160
+ }
161
+
162
+ /**
163
+ * A drag that ends anywhere else — dropped on another element, cancelled
164
+ * with Esc, taken out of the window — sends no `dragleave` here, and the
165
+ * element would stay marked as a drop target for good.
166
+ */
167
+ function onDragEndAnywhere() {
168
+ depth = 0
169
+ setState({ over: false, invalid: false })
170
+ }
171
+
172
+ const handlers = {
173
+ dragenter: onDragEnter,
174
+ dragover: onDragOver,
175
+ dragleave: onDragLeave,
176
+ drop: onDrop,
177
+ } as const
178
+
179
+ for (const [type, handler] of Object.entries(handlers)) {
180
+ element.addEventListener(type, handler as EventListener)
181
+ }
182
+
183
+ const doc = element.ownerDocument
184
+ doc?.addEventListener('dragend', onDragEndAnywhere)
185
+ doc?.addEventListener('drop', onDragEndAnywhere)
186
+
187
+ return {
188
+ get state() {
189
+ return state
190
+ },
191
+ update: (next) => {
192
+ opts = { ...opts, ...next }
193
+ },
194
+ destroy: () => {
195
+ for (const [type, handler] of Object.entries(handlers)) {
196
+ element.removeEventListener(type, handler as EventListener)
197
+ }
198
+ doc?.removeEventListener('dragend', onDragEndAnywhere)
199
+ doc?.removeEventListener('drop', onDragEndAnywhere)
200
+ },
201
+ }
202
+ }
package/src/index.ts CHANGED
@@ -13,6 +13,23 @@ export {
13
13
  type DrawingState,
14
14
  type DrawingStateValue,
15
15
  } from './canvas/context'
16
+ export { matchesAccept, type AcceptCandidate } from './file/accept'
17
+ export {
18
+ createDropZone,
19
+ type DropZoneInstance,
20
+ type DropZoneOptions,
21
+ type DropZoneState,
22
+ } from './file/drop-zone'
23
+ export { applyDelta } from './input/apply-delta'
24
+ export {
25
+ mapModifier,
26
+ selectModifier,
27
+ type InputEventOption,
28
+ type Modifier,
29
+ type ModifierMap,
30
+ type ModifierState,
31
+ type ModifierValue,
32
+ } from './input/modifiers'
16
33
  export {
17
34
  createMIDIAccess,
18
35
  NOT_SUPPORTED,
@@ -30,6 +47,15 @@ export {
30
47
  type MIDIInputInstance,
31
48
  } from './midi/input'
32
49
  export { createMIDIMessage, type MIDIMessageInstance } from './midi/message'
50
+ export {
51
+ blackKeyWidth,
52
+ getNoteRangeArray,
53
+ noteAt,
54
+ notePosition,
55
+ pianoWidth,
56
+ type NoteRange,
57
+ type PianoLayout,
58
+ } from './piano/layout'
33
59
  export {
34
60
  createPianoInput,
35
61
  type NoteSource,
@@ -0,0 +1,73 @@
1
+ import {
2
+ clamp,
3
+ linearScale,
4
+ stepValue,
5
+ toPrecision,
6
+ type ValueRange,
7
+ } from '@tremolo-ui/functions'
8
+
9
+ import {
10
+ selectModifier,
11
+ type InputEventOption,
12
+ type ModifierState,
13
+ type ModifierValue,
14
+ } from './modifiers'
15
+
16
+ /**
17
+ * Move a value by an amount of input, as reported by a wheel or an arrow key.
18
+ *
19
+ * The pipeline matches {@link createDragValue}: scale, then step, then clamp.
20
+ * Which key or which sign of `deltaY` counts as which direction is left to the
21
+ * caller, since it differs per component.
22
+ *
23
+ * @param direction which way, and how many times, to apply the option. The
24
+ * size of one step is `option[1]`, so this is normally `1` or `-1`.
25
+ *
26
+ * @param modifiers the event, for `options` that name a modifier key. See
27
+ * {@link selectModifier}.
28
+ *
29
+ * @example
30
+ * // ArrowDown on a slider whose keyboard option is ['raw', 1]
31
+ * applyDelta(value, -1, keyboard, { min, max, step, scale })
32
+ *
33
+ * @example
34
+ * // Shift+ArrowDown, where `keyboard` is { default: …, shift: ['raw', 0.1] }
35
+ * applyDelta(value, -1, keyboard, range, event)
36
+ */
37
+ export function applyDelta(
38
+ value: number,
39
+ direction: number,
40
+ options: ModifierValue<InputEventOption>,
41
+ { min, max, step, scale = linearScale }: ValueRange,
42
+ modifiers?: ModifierState,
43
+ ): number {
44
+ if (min >= max) throw new RangeError('requirements: min < max')
45
+ if (step !== undefined && (!Number.isFinite(step) || step <= 0)) {
46
+ throw new RangeError(
47
+ 'applyDelta step: requirements: finite and greater than 0',
48
+ )
49
+ }
50
+
51
+ const {
52
+ value: [mode, amount],
53
+ modifier,
54
+ } = selectModifier(options, modifiers)
55
+
56
+ const x = direction * amount
57
+ const next =
58
+ mode === 'normalized'
59
+ ? scale.denormalize(scale.normalize(value, min, max) + x, min, max)
60
+ : value + x
61
+
62
+ // Naming a modifier is a deliberate request to move off the grid, so `step`
63
+ // does not apply to it. Without this a finer amount would round straight
64
+ // back to where it started: `stepValue(3 + 0.1, 1)` is 3.
65
+ const quantum = modifier === null ? step : undefined
66
+ const stepped = quantum !== undefined ? stepValue(next, quantum) : next
67
+
68
+ // Rounded before the clamp, so that `min` and `max` still have the last
69
+ // word and the value can land on them exactly. Without this the artefact
70
+ // accumulates: with no `step` to round it back, twelve presses of a 0.1
71
+ // modifier amount reach 5.699999999999998 rather than 5.7.
72
+ return clamp(toPrecision(stepped), min, max)
73
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Options for setting the amount of keyboard and mouse wheel changes.
3
+ */
4
+ export type InputEventOption = readonly ['normalized' | 'raw', number]
5
+
6
+ /**
7
+ * A modifier key that can carry an amount of its own.
8
+ *
9
+ * `ctrl` and `meta` are kept apart rather than folded into one "command" key:
10
+ * a plugin UI that mirrors a desktop host usually wants the same physical key
11
+ * on every platform, not the platform's own convention.
12
+ */
13
+ export type Modifier = 'shift' | 'alt' | 'ctrl' | 'meta'
14
+
15
+ /** The modifier flags of a `WheelEvent` or a `KeyboardEvent`. */
16
+ export interface ModifierState {
17
+ shiftKey: boolean
18
+ altKey: boolean
19
+ ctrlKey: boolean
20
+ metaKey: boolean
21
+ }
22
+
23
+ /** One setting per modifier key, with `default` for none of them. */
24
+ type ModifierSetting = number | InputEventOption
25
+
26
+ export type ModifierMap<T extends ModifierSetting> = { default: T } & Partial<
27
+ Record<Modifier, T>
28
+ >
29
+
30
+ /**
31
+ * A single setting, or one per modifier key.
32
+ *
33
+ * @example
34
+ * ['raw', 1]
35
+ * { default: ['raw', 1], shift: ['raw', 0.1] }
36
+ */
37
+ export type ModifierValue<T extends ModifierSetting> = T | ModifierMap<T>
38
+
39
+ /**
40
+ * Checked in this order, and the first one that is both held and configured
41
+ * wins. Fixing an order is what keeps two modifiers held at once from
42
+ * behaving differently between browsers.
43
+ */
44
+ const MODIFIER_ORDER = ['meta', 'ctrl', 'alt', 'shift'] as const
45
+
46
+ const MODIFIER_FLAG = {
47
+ meta: 'metaKey',
48
+ ctrl: 'ctrlKey',
49
+ alt: 'altKey',
50
+ shift: 'shiftKey',
51
+ } as const satisfies Record<Modifier, keyof ModifierState>
52
+
53
+ /**
54
+ * A map is the only form with a `default` key, which is what tells it apart
55
+ * from a bare setting. Tuples are arrays, so they never match.
56
+ */
57
+ function isModifierMap<T extends ModifierSetting>(
58
+ value: ModifierValue<T>,
59
+ ): value is ModifierMap<T> {
60
+ return (
61
+ typeof value === 'object' &&
62
+ value !== null &&
63
+ !Array.isArray(value) &&
64
+ 'default' in value
65
+ )
66
+ }
67
+
68
+ /**
69
+ * Pick the setting that applies, given the modifier keys being held.
70
+ *
71
+ * @example
72
+ * selectModifier({ default: 1, shift: 0.1 }, event)
73
+ */
74
+ export function selectModifier<T extends ModifierSetting>(
75
+ options: ModifierValue<T>,
76
+ modifiers?: ModifierState,
77
+ ): { value: T; modifier: Modifier | null } {
78
+ if (!isModifierMap(options)) {
79
+ // TypeScript cannot subtract the map from `ModifierValue<T>` while `T` is
80
+ // still a type parameter, so the other half has to be spelled out.
81
+ return { value: options as T, modifier: null }
82
+ }
83
+ if (modifiers) {
84
+ for (const modifier of MODIFIER_ORDER) {
85
+ const value = options[modifier]
86
+ // Compared against undefined rather than checked for truthiness: 0 is a
87
+ // legitimate setting.
88
+ if (value !== undefined && modifiers[MODIFIER_FLAG[modifier]]) {
89
+ return { value, modifier }
90
+ }
91
+ }
92
+ }
93
+ return { value: options.default, modifier: null }
94
+ }
95
+
96
+ /**
97
+ * Turn every entry of a setting into another kind of setting, keeping which
98
+ * modifier each belongs to.
99
+ *
100
+ * A drag sensitivity is a number and a keyboard amount is a tuple, but the two
101
+ * describe the same thing from the caller's side. This carries one over to the
102
+ * other so that a component can hand a sensitivity to {@link applyDelta}
103
+ * without unpicking the modifier map itself — which matters, since naming a
104
+ * modifier is also what takes `step` out of the pipeline.
105
+ *
106
+ * @example
107
+ * mapModifier({ default: 1, shift: 0.1 }, (f) => ['raw', step * f])
108
+ * // { default: ['raw', 1], shift: ['raw', 0.1] }
109
+ */
110
+ export function mapModifier<
111
+ T extends ModifierSetting,
112
+ U extends ModifierSetting,
113
+ >(options: ModifierValue<T>, fn: (value: T) => U): ModifierValue<U> {
114
+ if (!isModifierMap(options)) return fn(options as T)
115
+ const mapped = { default: fn(options.default) } as ModifierMap<U>
116
+ for (const modifier of MODIFIER_ORDER) {
117
+ const value = options[modifier]
118
+ if (value !== undefined) mapped[modifier] = fn(value)
119
+ }
120
+ return mapped
121
+ }
@@ -1,7 +1,7 @@
1
- import { noteAt, type PianoLayout } from '@tremolo-ui/functions'
2
-
3
1
  import { createDrag } from '../pointer/drag'
4
2
 
3
+ import { noteAt, type PianoLayout } from './layout'
4
+
5
5
  /**
6
6
  * What asked for a note to sound.
7
7
  *
@@ -0,0 +1,183 @@
1
+ import {
2
+ isBlackKey,
3
+ isWhiteKey,
4
+ noteKey,
5
+ noteKeys,
6
+ type NoteKey,
7
+ } from '@tremolo-ui/functions'
8
+
9
+ export type NoteRange = {
10
+ first: number
11
+ last: number
12
+ }
13
+
14
+ /**
15
+ * `[noteRange.first, noteRange.first + 1, ..., noteRange.last]`
16
+ */
17
+ export function getNoteRangeArray(noteRange: NoteRange): number[] {
18
+ return Array.from(
19
+ { length: noteRange.last - noteRange.first + 1 },
20
+ (_, i) => i + noteRange.first,
21
+ )
22
+ }
23
+
24
+ /**
25
+ * The geometry of a drawn keyboard.
26
+ *
27
+ * One description is shared by the drawing and the hit testing, so a key cannot
28
+ * be drawn somewhere other than where it responds.
29
+ */
30
+ export interface PianoLayout {
31
+ noteRange: NoteRange
32
+
33
+ /** Width of a white key, excluding {@link PianoLayout.keyGap}. */
34
+ whiteKeyWidth: number
35
+
36
+ /**
37
+ * Space between two white keys. Part of the slot a white key occupies, so it
38
+ * still belongs to one of the keys for the purpose of hit testing.
39
+ *
40
+ * @default 1
41
+ */
42
+ keyGap?: number
43
+
44
+ /**
45
+ * Width of a black key, as a fraction of {@link PianoLayout.whiteKeyWidth}.
46
+ *
47
+ * @default 0.65
48
+ */
49
+ blackKeyWidthRatio?: number
50
+
51
+ /**
52
+ * Height of a black key, as a fraction of the height of the keyboard.
53
+ *
54
+ * @default 0.6
55
+ */
56
+ blackKeyHeightRatio?: number
57
+ }
58
+
59
+ const DEFAULT_KEY_GAP = 1
60
+ const DEFAULT_BLACK_KEY_WIDTH_RATIO = 0.65
61
+ const DEFAULT_BLACK_KEY_HEIGHT_RATIO = 0.6
62
+
63
+ /**
64
+ * How many white keys sit at or before each pitch class, counting from C.
65
+ *
66
+ * A black key shares the number of the white key to its left plus one, which
67
+ * puts it on the boundary between the two; {@link notePosition} then shifts it
68
+ * back by half its width to centre it there.
69
+ */
70
+ const whiteKeysBefore: Record<NoteKey, number> = {
71
+ C: 0,
72
+ 'C#': 1,
73
+ D: 1,
74
+ 'D#': 2,
75
+ E: 2,
76
+ F: 3,
77
+ 'F#': 4,
78
+ G: 4,
79
+ 'G#': 5,
80
+ A: 5,
81
+ 'A#': 6,
82
+ B: 6,
83
+ }
84
+
85
+ /** Width of a black key in pixels. */
86
+ export function blackKeyWidth(layout: PianoLayout): number {
87
+ return (
88
+ layout.whiteKeyWidth *
89
+ (layout.blackKeyWidthRatio ?? DEFAULT_BLACK_KEY_WIDTH_RATIO)
90
+ )
91
+ }
92
+
93
+ function rawNotePosition(note: number, layout: PianoLayout): number {
94
+ const slot = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP)
95
+ const target = noteKey(note)
96
+ const first = noteKey(layout.noteRange.first)
97
+
98
+ const octave = Math.floor((note - layout.noteRange.first) / 12)
99
+ const octaveOffset =
100
+ noteKeys.indexOf(first) > noteKeys.indexOf(target) ? 1 : 0
101
+ const whiteKeysIn =
102
+ whiteKeysBefore[target] -
103
+ whiteKeysBefore[first] +
104
+ (octave + octaveOffset) * 7
105
+
106
+ return isBlackKey(note)
107
+ ? whiteKeysIn * slot - blackKeyWidth(layout) / 2
108
+ : whiteKeysIn * slot
109
+ }
110
+
111
+ function pianoBounds(layout: PianoLayout) {
112
+ const notes = getNoteRangeArray(layout.noteRange)
113
+ if (notes.length === 0) return { left: 0, right: 0 }
114
+
115
+ let left = Infinity
116
+ let right = -Infinity
117
+ const whiteWidth = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP)
118
+ for (const note of notes) {
119
+ const noteLeft = rawNotePosition(note, layout)
120
+ const width = isBlackKey(note) ? blackKeyWidth(layout) : whiteWidth
121
+ left = Math.min(left, noteLeft)
122
+ right = Math.max(right, noteLeft + width)
123
+ }
124
+ return { left, right }
125
+ }
126
+
127
+ /** Width of the whole keyboard in pixels. */
128
+ export function pianoWidth(layout: PianoLayout): number {
129
+ const { left, right } = pianoBounds(layout)
130
+ return right - left
131
+ }
132
+
133
+ /**
134
+ * Offset of the left edge of a key from the left edge of the keyboard, in
135
+ * pixels.
136
+ *
137
+ * Notes outside `noteRange` are placed too, so the value is negative below
138
+ * `noteRange.first`.
139
+ */
140
+ export function notePosition(note: number, layout: PianoLayout): number {
141
+ return rawNotePosition(note, layout) - pianoBounds(layout).left
142
+ }
143
+
144
+ /**
145
+ * The note drawn at a point, or null where there is none.
146
+ *
147
+ * Black keys are tested first, so they win where they overlap a white one. A
148
+ * white key covers its gap as well as its width, so the whole width of the
149
+ * keyboard belongs to some key and a click cannot fall between two.
150
+ *
151
+ * @param x offset from the left edge of the keyboard, in pixels
152
+ * @param y offset from its top edge, in pixels
153
+ * @param height height of the keyboard, in pixels
154
+ */
155
+ export function noteAt(
156
+ x: number,
157
+ y: number,
158
+ height: number,
159
+ layout: PianoLayout,
160
+ ): number | null {
161
+ if (x < 0 || x >= pianoWidth(layout) || y < 0 || y >= height) return null
162
+
163
+ const notes = getNoteRangeArray(layout.noteRange)
164
+ const blackHeight =
165
+ height * (layout.blackKeyHeightRatio ?? DEFAULT_BLACK_KEY_HEIGHT_RATIO)
166
+
167
+ if (y < blackHeight) {
168
+ for (const note of notes) {
169
+ if (isWhiteKey(note)) continue
170
+ const left = notePosition(note, layout)
171
+ if (left <= x && x < left + blackKeyWidth(layout)) return note
172
+ }
173
+ }
174
+
175
+ const slot = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP)
176
+ for (const note of notes) {
177
+ if (isBlackKey(note)) continue
178
+ const left = notePosition(note, layout)
179
+ if (left <= x && x < left + slot) return note
180
+ }
181
+
182
+ return null
183
+ }