@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 +383 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +269 -21
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +269 -21
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +374 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/file/accept.ts +58 -0
- package/src/file/drop-zone.ts +202 -0
- package/src/index.ts +26 -0
- package/src/input/apply-delta.ts +73 -0
- package/src/input/modifiers.ts +121 -0
- package/src/piano/index.ts +2 -2
- package/src/piano/layout.ts +183 -0
|
@@ -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
|
+
}
|
package/src/piano/index.ts
CHANGED
|
@@ -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
|
+
}
|