@tremolo-ui/dom 0.7.0 → 0.8.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 +1298 -235
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +767 -84
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +767 -84
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1263 -237
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/file/accept.ts +17 -0
- package/src/file/drop-zone.ts +8 -10
- package/src/index.ts +76 -1
- package/src/input/check-steps.ts +144 -0
- package/src/input/defaults.ts +32 -0
- package/src/input/direction.ts +107 -0
- package/src/knob/geometry.ts +100 -0
- package/src/number-input/stepper-drag.ts +139 -0
- package/src/number-input/text.ts +100 -0
- package/src/number-input/value.ts +129 -0
- package/src/options/replace.ts +22 -0
- package/src/piano/index.ts +128 -2
- package/src/piano/layout.ts +19 -0
- package/src/piano/shortcuts.ts +86 -0
- package/src/pointer/long-press.ts +103 -0
- package/src/points-editor/index.ts +361 -0
- package/src/position.ts +20 -0
- package/src/slider/decimal-digits.ts +23 -0
- package/src/slider/marks.ts +56 -0
- package/src/style.ts +41 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { type ValueRange } from '@tremolo-ui/functions'
|
|
2
|
+
|
|
3
|
+
import { applyDelta } from '../input/apply-delta'
|
|
4
|
+
import { DEFAULT_DRAG_SENSITIVITY } from '../input/defaults'
|
|
5
|
+
import {
|
|
6
|
+
mapModifier,
|
|
7
|
+
selectModifier,
|
|
8
|
+
type InputEventOption,
|
|
9
|
+
type ModifierValue,
|
|
10
|
+
} from '../input/modifiers'
|
|
11
|
+
import { createDrag } from '../pointer/drag'
|
|
12
|
+
|
|
13
|
+
export interface StepperDragOptions {
|
|
14
|
+
/** The value now, read when the drag starts moving it. */
|
|
15
|
+
getValue: () => number
|
|
16
|
+
/**
|
|
17
|
+
* The range the value moves across. Its `step` is what one step of the drag
|
|
18
|
+
* is worth; see `numberInputRanges` for the one a number input uses.
|
|
19
|
+
*/
|
|
20
|
+
range: ValueRange
|
|
21
|
+
/**
|
|
22
|
+
* How many pixels of vertical movement make one step.
|
|
23
|
+
*
|
|
24
|
+
* @default 1
|
|
25
|
+
*/
|
|
26
|
+
pixels?: number
|
|
27
|
+
/**
|
|
28
|
+
* How much a step is worth, per modifier key: `0.1` makes the same movement
|
|
29
|
+
* count a tenth as much. Pressing or releasing the key mid-drag does not
|
|
30
|
+
* move the value.
|
|
31
|
+
*
|
|
32
|
+
* @default { default: 1, shift: 0.1 }
|
|
33
|
+
*/
|
|
34
|
+
sensitivity?: ModifierValue<number>
|
|
35
|
+
/** Hide the pointer and keep it from hitting the edge of the screen. */
|
|
36
|
+
pointerLock?: boolean
|
|
37
|
+
/** Called with the new value whenever the drag moves it. */
|
|
38
|
+
onChange: (value: number) => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface StepperDragInstance {
|
|
42
|
+
/** Replace the given options. `pointerLock` reaches the next drag. */
|
|
43
|
+
update: (options: Partial<StepperDragOptions>) => void
|
|
44
|
+
/**
|
|
45
|
+
* Whether the drag in progress has moved the value. A stepper button's
|
|
46
|
+
* press-and-hold repeat stands down once it has, so the value is not moved
|
|
47
|
+
* twice.
|
|
48
|
+
*/
|
|
49
|
+
moved: () => boolean
|
|
50
|
+
destroy: () => void
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Drag up and down on the steppers of a number input to move its value, one
|
|
55
|
+
* `step` every `pixels` — up raises it, as on a knob.
|
|
56
|
+
*
|
|
57
|
+
* Counted from where the drag started moving rather than added up per
|
|
58
|
+
* event, so rounding cannot accumulate. The start is taken on the first
|
|
59
|
+
* move, not on pointerdown: a stepper button acts on pointerdown, so by then
|
|
60
|
+
* the value may already have been nudged once, and the drag carries on from
|
|
61
|
+
* there.
|
|
62
|
+
*/
|
|
63
|
+
export function createStepperDrag(
|
|
64
|
+
element: Element,
|
|
65
|
+
options: StepperDragOptions,
|
|
66
|
+
): StepperDragInstance {
|
|
67
|
+
let opts = options
|
|
68
|
+
let origin: { y: number; value: number } | null = null
|
|
69
|
+
let moved = false
|
|
70
|
+
/**
|
|
71
|
+
* Which sensitivity the drag is counting at, and where the previous event
|
|
72
|
+
* was — a key produces no pointer event of its own, so a change is only
|
|
73
|
+
* seen on the next move and has to be dated back to the one before it.
|
|
74
|
+
*/
|
|
75
|
+
let factor = 1
|
|
76
|
+
let previousY = 0
|
|
77
|
+
|
|
78
|
+
const drag = createDrag(element, {
|
|
79
|
+
threshold: 1,
|
|
80
|
+
cursor: 'ns-resize',
|
|
81
|
+
pointerLock: opts.pointerLock,
|
|
82
|
+
onDragStart: (state) => {
|
|
83
|
+
origin = null
|
|
84
|
+
moved = false
|
|
85
|
+
factor = selectModifier(
|
|
86
|
+
opts.sensitivity ?? DEFAULT_DRAG_SENSITIVITY,
|
|
87
|
+
state.event,
|
|
88
|
+
).value
|
|
89
|
+
},
|
|
90
|
+
onDrag: (state) => {
|
|
91
|
+
const { y } = state
|
|
92
|
+
if (!origin) {
|
|
93
|
+
origin = { y, value: opts.getValue() }
|
|
94
|
+
previousY = y
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const sensitivity = opts.sensitivity ?? DEFAULT_DRAG_SENSITIVITY
|
|
99
|
+
// Pressing or releasing the key mid-drag must not move the value, so the
|
|
100
|
+
// travel so far is folded into the origin and measuring starts again
|
|
101
|
+
// from the previous event: that event's own distance belongs to the new
|
|
102
|
+
// sensitivity.
|
|
103
|
+
const next = selectModifier(sensitivity, state.event).value
|
|
104
|
+
if (next !== factor) {
|
|
105
|
+
origin = { y: previousY, value: opts.getValue() }
|
|
106
|
+
factor = next
|
|
107
|
+
}
|
|
108
|
+
previousY = y
|
|
109
|
+
|
|
110
|
+
const steps = Math.round(-(y - origin.y) / (opts.pixels ?? 1))
|
|
111
|
+
if (steps === 0) return
|
|
112
|
+
moved = true
|
|
113
|
+
|
|
114
|
+
// The sensitivity as an amount per step, carried over as a modifier map
|
|
115
|
+
// rather than resolved here: that keeps `step` out of the pipeline for
|
|
116
|
+
// a modifier entry, since naming one is a request to move off the grid.
|
|
117
|
+
const step = opts.range.step ?? 1
|
|
118
|
+
const amounts = mapModifier(sensitivity, (f): InputEventOption => [
|
|
119
|
+
'raw',
|
|
120
|
+
step * f,
|
|
121
|
+
])
|
|
122
|
+
opts.onChange(
|
|
123
|
+
applyDelta(origin.value, steps, amounts, opts.range, state.event),
|
|
124
|
+
)
|
|
125
|
+
},
|
|
126
|
+
onDragEnd: () => {
|
|
127
|
+
moved = false
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
update: (next) => {
|
|
133
|
+
opts = { ...opts, ...next }
|
|
134
|
+
if ('pointerLock' in next) drag.update({ pointerLock: opts.pointerLock })
|
|
135
|
+
},
|
|
136
|
+
moved: () => moved,
|
|
137
|
+
destroy: () => drag.destroy(),
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a number out of the text of a number input, and keeping the caret
|
|
3
|
+
* in place while the number under it changes.
|
|
4
|
+
*
|
|
5
|
+
* The text is whatever `format` made of the value — `"440 Hz"`, `"-6.0 dB"` —
|
|
6
|
+
* or a half-typed entry, so none of this assumes the text is a number alone.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The signs a number can carry. U+2212 is what some `Intl.NumberFormat` locales write. */
|
|
10
|
+
const SIGNS = '+-\u2212'
|
|
11
|
+
|
|
12
|
+
/** Where the number is in the text; the rest, on either side, is the unit. */
|
|
13
|
+
export interface NumberSpan {
|
|
14
|
+
start: number
|
|
15
|
+
end: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Where the number is in the text: from its first digit to its last, with the
|
|
20
|
+
* sign and the decimal point in front of it. Whatever is left on either side
|
|
21
|
+
* is taken for the unit, so it does not matter whether a space separates them,
|
|
22
|
+
* or what the number looks like — `+6.0`, `1e+21`, `1,000` and `1:30` are each
|
|
23
|
+
* one number. `null` when the text has no digit at all.
|
|
24
|
+
*/
|
|
25
|
+
export function numberSpan(text: string): NumberSpan | null {
|
|
26
|
+
const first = text.search(/\d/)
|
|
27
|
+
if (first === -1) return null
|
|
28
|
+
let start = first
|
|
29
|
+
if (text[start - 1] === '.') start -= 1
|
|
30
|
+
if (start > 0 && SIGNS.includes(text[start - 1])) start -= 1
|
|
31
|
+
return { start, end: text.search(/\d\D*$/) + 1 }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A plain number, and nothing else: no grouping, no other separators. */
|
|
35
|
+
const PLAIN_NUMBER =
|
|
36
|
+
/^[+\-\u2212]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+\-\u2212]?\d+)?$/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The number in the text, with the unit after it ignored: the default `parse`
|
|
40
|
+
* of a number input.
|
|
41
|
+
*
|
|
42
|
+
* `NaN`, which leaves the value alone, whenever the number cannot be read
|
|
43
|
+
* safely, rather than a part of it: text with no number, a number that is not
|
|
44
|
+
* a plain one (`1,000` would otherwise read as 1, and `1:30` as 1), and text
|
|
45
|
+
* with something in front of the number, which can change what it means (the
|
|
46
|
+
* `L` of a pan reading `L 30`). A `format` that writes any of those needs its
|
|
47
|
+
* own `parse`.
|
|
48
|
+
*/
|
|
49
|
+
export function parseNumberText(text: string): number {
|
|
50
|
+
const span = numberSpan(text)
|
|
51
|
+
if (!span || text.slice(0, span.start).trim() !== '') return NaN
|
|
52
|
+
const number = text.slice(span.start, span.end)
|
|
53
|
+
return PLAIN_NUMBER.test(number)
|
|
54
|
+
? Number(number.replace(/\u2212/g, '-'))
|
|
55
|
+
: NaN
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The index the caret is measured against: the decimal point, or where one
|
|
60
|
+
* would go if the number has none — in front of an exponent, if there is one.
|
|
61
|
+
*
|
|
62
|
+
* Measuring from an end instead would slide the caret across a digit whenever
|
|
63
|
+
* the number changed length — `9.9` to `10.0` gains a character in front, `10`
|
|
64
|
+
* to `9` loses one, and so does `9e+9` to `1e+10` behind — which is exactly
|
|
65
|
+
* what stepping does.
|
|
66
|
+
*/
|
|
67
|
+
function decimalAnchor(text: string, span: NumberSpan) {
|
|
68
|
+
const number = text.slice(span.start, span.end)
|
|
69
|
+
const exponent = number.search(/[eE][+\-\u2212]?\d/)
|
|
70
|
+
const mantissa = exponent === -1 ? number : number.slice(0, exponent)
|
|
71
|
+
const dot = mantissa.indexOf('.')
|
|
72
|
+
return span.start + (dot === -1 ? mantissa.length : dot)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const NO_NUMBER: NumberSpan = { start: 0, end: 0 }
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Where the caret is, relative to the decimal point, so that it can be put
|
|
79
|
+
* back at the same digit once the value has changed. See
|
|
80
|
+
* {@link caretAtDecimalOffset}.
|
|
81
|
+
*/
|
|
82
|
+
export function caretDecimalOffset(text: string, caret: number): number {
|
|
83
|
+
return caret - decimalAnchor(text, numberSpan(text) ?? NO_NUMBER)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The caret position `offset` characters from the decimal point of the new
|
|
88
|
+
* text, kept within the number.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* // The caret sits in front of the point of "9.9" when ArrowUp turns it
|
|
92
|
+
* // into "10.0"
|
|
93
|
+
* const offset = caretDecimalOffset('9.9', 1) // 0
|
|
94
|
+
* caretAtDecimalOffset('10.0', offset) // 2: still in front of the point
|
|
95
|
+
*/
|
|
96
|
+
export function caretAtDecimalOffset(text: string, offset: number): number {
|
|
97
|
+
const span = numberSpan(text) ?? NO_NUMBER
|
|
98
|
+
const place = decimalAnchor(text, span) + offset
|
|
99
|
+
return Math.max(span.start, Math.min(place, span.end))
|
|
100
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { type Scale, type ValueRange } from '@tremolo-ui/functions'
|
|
2
|
+
|
|
3
|
+
import { applyDelta } from '../input/apply-delta'
|
|
4
|
+
import {
|
|
5
|
+
selectModifier,
|
|
6
|
+
type InputEventOption,
|
|
7
|
+
type ModifierState,
|
|
8
|
+
type ModifierValue,
|
|
9
|
+
} from '../input/modifiers'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The value a number input edits, and how far it may go.
|
|
13
|
+
*
|
|
14
|
+
* Unlike a slider, either end may be left open, and `clampValue: false` lets
|
|
15
|
+
* the value past the ends that are set.
|
|
16
|
+
*/
|
|
17
|
+
export interface NumberInputValueOptions {
|
|
18
|
+
min?: number
|
|
19
|
+
max?: number
|
|
20
|
+
step?: number
|
|
21
|
+
scale?: Scale
|
|
22
|
+
/**
|
|
23
|
+
* Keep the value between `min` and `max`.
|
|
24
|
+
*
|
|
25
|
+
* @default true
|
|
26
|
+
*/
|
|
27
|
+
clampValue?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The ranges {@link nudgeNumberInput} moves a value across. */
|
|
31
|
+
export interface NumberInputRanges {
|
|
32
|
+
/** For a `normalized` amount, which needs a finite span to take a share of. */
|
|
33
|
+
normalized: ValueRange
|
|
34
|
+
/** For a `raw` amount, which does not. */
|
|
35
|
+
raw: ValueRange
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The ranges a number input moves its value across, with the open ends
|
|
40
|
+
* filled in.
|
|
41
|
+
*
|
|
42
|
+
* A normalized amount needs a finite span even when an end is unbounded or
|
|
43
|
+
* clamping is off. Safe integers provide one without overflowing the span a
|
|
44
|
+
* scale calculates. A raw amount needs no span, so its open ends can cover
|
|
45
|
+
* every finite number instead of stopping at the safe-integer range.
|
|
46
|
+
*/
|
|
47
|
+
export function numberInputRanges({
|
|
48
|
+
min,
|
|
49
|
+
max,
|
|
50
|
+
step,
|
|
51
|
+
scale,
|
|
52
|
+
clampValue = true,
|
|
53
|
+
}: NumberInputValueOptions): NumberInputRanges {
|
|
54
|
+
const lo = clampValue ? min : undefined
|
|
55
|
+
const hi = clampValue ? max : undefined
|
|
56
|
+
return {
|
|
57
|
+
normalized: {
|
|
58
|
+
min: lo ?? Number.MIN_SAFE_INTEGER,
|
|
59
|
+
max: hi ?? Number.MAX_SAFE_INTEGER,
|
|
60
|
+
step,
|
|
61
|
+
scale,
|
|
62
|
+
},
|
|
63
|
+
raw: {
|
|
64
|
+
min: lo ?? -Number.MAX_VALUE,
|
|
65
|
+
max: hi ?? Number.MAX_VALUE,
|
|
66
|
+
step,
|
|
67
|
+
scale,
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Move a number input's value by one press of a key, a wheel notch or a
|
|
74
|
+
* stepper, picking the range that suits the kind of amount. See `applyDelta`.
|
|
75
|
+
*/
|
|
76
|
+
export function nudgeNumberInput(
|
|
77
|
+
value: number,
|
|
78
|
+
direction: number,
|
|
79
|
+
options: ModifierValue<InputEventOption>,
|
|
80
|
+
ranges: NumberInputRanges,
|
|
81
|
+
modifiers?: ModifierState,
|
|
82
|
+
): number {
|
|
83
|
+
const [mode] = selectModifier(options, modifiers).value
|
|
84
|
+
return applyDelta(
|
|
85
|
+
value,
|
|
86
|
+
direction,
|
|
87
|
+
options,
|
|
88
|
+
mode === 'raw' ? ranges.raw : ranges.normalized,
|
|
89
|
+
modifiers,
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Where the value stands against the ends: whether it can go no further
|
|
95
|
+
* down or up, and whether it lies outside them — which only an unclamped
|
|
96
|
+
* input, or a value set from outside, can do.
|
|
97
|
+
*/
|
|
98
|
+
export function numberInputBounds(
|
|
99
|
+
value: number,
|
|
100
|
+
{ min, max, clampValue = true }: NumberInputValueOptions,
|
|
101
|
+
) {
|
|
102
|
+
return {
|
|
103
|
+
atMin: clampValue && min !== undefined && value <= min,
|
|
104
|
+
atMax: clampValue && max !== undefined && value >= max,
|
|
105
|
+
outOfRange:
|
|
106
|
+
(min !== undefined && value < min) || (max !== undefined && value > max),
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The value typed text commits to, or `null` when there is no number in it.
|
|
112
|
+
*
|
|
113
|
+
* Text with no number is not a value: the input should go back to what it
|
|
114
|
+
* was showing rather than commit a zero the user never typed. What is read
|
|
115
|
+
* is clamped here and not while typing, since clamping as the user types
|
|
116
|
+
* would make "1500" impossible to enter into an input whose max is 100.
|
|
117
|
+
*/
|
|
118
|
+
export function commitNumberInputText(
|
|
119
|
+
text: string,
|
|
120
|
+
parse: (text: string) => number,
|
|
121
|
+
{ min, max, clampValue = true }: NumberInputValueOptions,
|
|
122
|
+
): number | null {
|
|
123
|
+
const parsed = parse(text)
|
|
124
|
+
if (!Number.isFinite(parsed)) return null
|
|
125
|
+
let committed = parsed
|
|
126
|
+
if (clampValue && min !== undefined) committed = Math.max(committed, min)
|
|
127
|
+
if (clampValue && max !== undefined) committed = Math.min(committed, max)
|
|
128
|
+
return committed
|
|
129
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `update()` argument that leaves an instance with exactly `next` as its
|
|
3
|
+
* options.
|
|
4
|
+
*
|
|
5
|
+
* `update()` merges into what the instance has, which suits a wrapper that
|
|
6
|
+
* pushes one setting at a time. A wrapper handed the whole new set instead —
|
|
7
|
+
* a Svelte action, a Vue composable — has to clear what the new set no longer
|
|
8
|
+
* carries, or a handler taken away, or an option left to its default, keeps
|
|
9
|
+
* working.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* instance.update(replaceOptions(previous, next))
|
|
13
|
+
*/
|
|
14
|
+
export function replaceOptions<T extends object>(
|
|
15
|
+
previous: T | undefined,
|
|
16
|
+
next: T | undefined,
|
|
17
|
+
): Partial<T> {
|
|
18
|
+
const cleared = Object.fromEntries(
|
|
19
|
+
Object.keys(previous ?? {}).map((key) => [key, undefined]),
|
|
20
|
+
) as Partial<T>
|
|
21
|
+
return { ...cleared, ...next }
|
|
22
|
+
}
|
package/src/piano/index.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { createDrag } from '../pointer/drag'
|
|
2
2
|
|
|
3
3
|
import { noteAt, type PianoLayout } from './layout'
|
|
4
|
+
import {
|
|
5
|
+
isEditableTarget,
|
|
6
|
+
type KeyboardShortcuts,
|
|
7
|
+
type KeyboardShortcutsScope,
|
|
8
|
+
} from './shortcuts'
|
|
4
9
|
|
|
5
10
|
/**
|
|
6
11
|
* What asked for a note to sound.
|
|
@@ -33,6 +38,26 @@ export interface PianoInputOptions {
|
|
|
33
38
|
*/
|
|
34
39
|
midiMax?: number
|
|
35
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Play notes from the computer keyboard: `keys[i]` plays
|
|
43
|
+
* `layout.noteRange.first + i`. `SHORTCUTS` has ready-made layouts.
|
|
44
|
+
*
|
|
45
|
+
* A held key keeps its note until it is released, the focus leaves (with
|
|
46
|
+
* `root`), or the window loses focus. Changing the keys, the scope or the
|
|
47
|
+
* note range releases every note a key is holding, since the key would
|
|
48
|
+
* mean something else on release.
|
|
49
|
+
*/
|
|
50
|
+
keyboardShortcuts?: KeyboardShortcuts
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Where keyboard shortcuts listen: on the element (`root`), which has to
|
|
54
|
+
* be focusable, or anywhere on the page (`window`). Keys typed into an
|
|
55
|
+
* editable element are ignored either way.
|
|
56
|
+
*
|
|
57
|
+
* @default 'root'
|
|
58
|
+
*/
|
|
59
|
+
keyboardShortcutsScope?: KeyboardShortcutsScope
|
|
60
|
+
|
|
36
61
|
/** Called when a note starts sounding, not for each source that asks. */
|
|
37
62
|
onPlayNote?: (note: number, velocity?: number) => void
|
|
38
63
|
/** Called once the last source holding a note has let go. */
|
|
@@ -49,8 +74,8 @@ export interface PianoInputInstance {
|
|
|
49
74
|
update: (options: Partial<PianoInputOptions>) => void
|
|
50
75
|
|
|
51
76
|
/**
|
|
52
|
-
* Start a note from something other than a pointer
|
|
53
|
-
*
|
|
77
|
+
* Start a note from something other than a pointer or a shortcut: a MIDI
|
|
78
|
+
* message, an imperative call.
|
|
54
79
|
*/
|
|
55
80
|
noteOn: (
|
|
56
81
|
note: number,
|
|
@@ -87,6 +112,11 @@ export function createPianoInput(
|
|
|
87
112
|
const held = new Map<number, Set<NoteSource>>()
|
|
88
113
|
/** The note each pointer is currently on. */
|
|
89
114
|
const pointerNotes = new Map<number, number>()
|
|
115
|
+
/**
|
|
116
|
+
* The note each held shortcut key is playing, by `code` — the physical key,
|
|
117
|
+
* so that releasing it with a different modifier or layout still matches.
|
|
118
|
+
*/
|
|
119
|
+
const shortcutNotes = new Map<string, number>()
|
|
90
120
|
|
|
91
121
|
function activeNotes(): number[] {
|
|
92
122
|
return [...held.keys()].sort((a, b) => a - b)
|
|
@@ -156,6 +186,97 @@ export function createPianoInput(
|
|
|
156
186
|
}
|
|
157
187
|
}
|
|
158
188
|
|
|
189
|
+
/** The note a shortcut key plays, or null when it has none. */
|
|
190
|
+
function shortcutNote(key: string) {
|
|
191
|
+
const keys = opts.keyboardShortcuts?.keys
|
|
192
|
+
if (!keys || key === '') return null
|
|
193
|
+
const { first, last } = opts.layout.noteRange
|
|
194
|
+
const index = keys.indexOf(key)
|
|
195
|
+
const note = first + index
|
|
196
|
+
return index === -1 || note > last ? null : note
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function onKeyDown(event: KeyboardEvent) {
|
|
200
|
+
const id = event.code || event.key
|
|
201
|
+
if (event.repeat || shortcutNotes.has(id)) return
|
|
202
|
+
if (isEditableTarget(event.target)) return
|
|
203
|
+
const note = shortcutNote(event.key)
|
|
204
|
+
if (note === null) return
|
|
205
|
+
shortcutNotes.set(id, note)
|
|
206
|
+
noteOn(note, { source: `keyboard:${id}` })
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function onKeyUp(event: KeyboardEvent) {
|
|
210
|
+
const id = event.code || event.key
|
|
211
|
+
const note = shortcutNotes.get(id)
|
|
212
|
+
if (note === undefined) return
|
|
213
|
+
shortcutNotes.delete(id)
|
|
214
|
+
noteOff(note, { source: `keyboard:${id}` })
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function releaseShortcuts() {
|
|
218
|
+
for (const [id, note] of shortcutNotes) {
|
|
219
|
+
noteOff(note, { source: `keyboard:${id}` })
|
|
220
|
+
}
|
|
221
|
+
shortcutNotes.clear()
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function onFocusOut(event: Event) {
|
|
225
|
+
const next = (event as FocusEvent).relatedTarget
|
|
226
|
+
// Moving between the keys and whatever else is inside keeps them held.
|
|
227
|
+
if (next instanceof Node && element.contains(next)) return
|
|
228
|
+
releaseShortcuts()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The scope the listeners are bound for, or null when none are. */
|
|
232
|
+
let boundScope: KeyboardShortcutsScope | null = null
|
|
233
|
+
|
|
234
|
+
function bindShortcuts() {
|
|
235
|
+
const scope = opts.keyboardShortcuts
|
|
236
|
+
? (opts.keyboardShortcutsScope ?? 'root')
|
|
237
|
+
: null
|
|
238
|
+
if (scope === boundScope) return
|
|
239
|
+
unbindShortcuts()
|
|
240
|
+
const win = globalThis.window
|
|
241
|
+
if (!scope || !win) return
|
|
242
|
+
const target: EventTarget = scope === 'window' ? win : element
|
|
243
|
+
target.addEventListener('keydown', onKeyDown as EventListener)
|
|
244
|
+
target.addEventListener('keyup', onKeyUp as EventListener)
|
|
245
|
+
if (scope === 'root') element.addEventListener('focusout', onFocusOut)
|
|
246
|
+
// A key released while the window is in the background never sends its
|
|
247
|
+
// keyup, so everything is let go when the focus leaves the page.
|
|
248
|
+
win.addEventListener('blur', releaseShortcuts)
|
|
249
|
+
boundScope = scope
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function unbindShortcuts() {
|
|
253
|
+
const win = globalThis.window
|
|
254
|
+
if (!boundScope || !win) return
|
|
255
|
+
const target: EventTarget = boundScope === 'window' ? win : element
|
|
256
|
+
target.removeEventListener('keydown', onKeyDown as EventListener)
|
|
257
|
+
target.removeEventListener('keyup', onKeyUp as EventListener)
|
|
258
|
+
element.removeEventListener('focusout', onFocusOut)
|
|
259
|
+
win.removeEventListener('blur', releaseShortcuts)
|
|
260
|
+
boundScope = null
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* What the held keys were started against. Compared by value: a caller
|
|
265
|
+
* who writes the keys inline hands over a new array on every update, and
|
|
266
|
+
* releasing on that would stop a note as soon as anything re-renders.
|
|
267
|
+
*/
|
|
268
|
+
function shortcutMapping() {
|
|
269
|
+
const { first, last } = opts.layout.noteRange
|
|
270
|
+
return [
|
|
271
|
+
opts.keyboardShortcuts?.keys.join('\u0000') ?? '',
|
|
272
|
+
opts.keyboardShortcutsScope ?? 'root',
|
|
273
|
+
first,
|
|
274
|
+
last,
|
|
275
|
+
].join('\u0001')
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
bindShortcuts()
|
|
279
|
+
|
|
159
280
|
const drag = createDrag(element, {
|
|
160
281
|
multiPointer: true,
|
|
161
282
|
onDragStart: (state) =>
|
|
@@ -171,7 +292,10 @@ export function createPianoInput(
|
|
|
171
292
|
|
|
172
293
|
return {
|
|
173
294
|
update: (next) => {
|
|
295
|
+
const mapping = shortcutMapping()
|
|
174
296
|
opts = { ...opts, ...next }
|
|
297
|
+
if (shortcutMapping() !== mapping) releaseShortcuts()
|
|
298
|
+
bindShortcuts()
|
|
175
299
|
|
|
176
300
|
const midiMax = opts.midiMax ?? 127
|
|
177
301
|
const stoppedNotes = activeNotes().filter((note) => note > midiMax)
|
|
@@ -191,6 +315,8 @@ export function createPianoInput(
|
|
|
191
315
|
activeNotes,
|
|
192
316
|
destroy: () => {
|
|
193
317
|
drag.destroy()
|
|
318
|
+
unbindShortcuts()
|
|
319
|
+
shortcutNotes.clear()
|
|
194
320
|
// Anything still held is released, so a caller that mirrors these
|
|
195
321
|
// callbacks into a synth is not left with a stuck note.
|
|
196
322
|
const notes = activeNotes()
|
package/src/piano/layout.ts
CHANGED
|
@@ -181,3 +181,22 @@ export function noteAt(
|
|
|
181
181
|
|
|
182
182
|
return null
|
|
183
183
|
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The white key width that makes the keyboard exactly `width` wide, for a
|
|
187
|
+
* keyboard that follows the size of its container.
|
|
188
|
+
*
|
|
189
|
+
* Solved from {@link pianoWidth} rather than by dividing among the white
|
|
190
|
+
* keys, so that a range that starts or ends on a black key — which sticks out
|
|
191
|
+
* by a fraction of a white key — still fills the container. The width grows
|
|
192
|
+
* linearly with the white key width, so two samples pin it down.
|
|
193
|
+
*/
|
|
194
|
+
export function fitWhiteKeyWidth(
|
|
195
|
+
width: number,
|
|
196
|
+
layout: Omit<PianoLayout, 'whiteKeyWidth'>,
|
|
197
|
+
): number {
|
|
198
|
+
const at = (whiteKeyWidth: number) => pianoWidth({ ...layout, whiteKeyWidth })
|
|
199
|
+
const slope = at(2) - at(1)
|
|
200
|
+
if (slope <= 0) return 0
|
|
201
|
+
return (width - (at(1) - slope)) / slope
|
|
202
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export type KeyboardShortcuts = {
|
|
2
|
+
/**
|
|
3
|
+
* Keys laid out from `noteRange.first`, one entry per semitone.
|
|
4
|
+
*
|
|
5
|
+
* An empty string leaves that note without a shortcut: `KeyboardEvent.key` is
|
|
6
|
+
* never empty, so the entry can never match. Use it to skip the black keys
|
|
7
|
+
* (see {@link SHORTCUTS.HOME_ROW_NATURAL}) and keep the remaining entries
|
|
8
|
+
* lined up with the notes.
|
|
9
|
+
*/
|
|
10
|
+
keys: string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Ready-made keyboard layouts. Both assume `noteRange.first` is a C.
|
|
15
|
+
*/
|
|
16
|
+
export const SHORTCUTS = {
|
|
17
|
+
/** Every semitone from C, over the two rows of a QWERTY keyboard. */
|
|
18
|
+
HOME_ROW: {
|
|
19
|
+
keys: [
|
|
20
|
+
'a',
|
|
21
|
+
'w',
|
|
22
|
+
's',
|
|
23
|
+
'e',
|
|
24
|
+
'd',
|
|
25
|
+
'f',
|
|
26
|
+
't',
|
|
27
|
+
'g',
|
|
28
|
+
'y',
|
|
29
|
+
'h',
|
|
30
|
+
'u',
|
|
31
|
+
'j',
|
|
32
|
+
'k',
|
|
33
|
+
'o',
|
|
34
|
+
'l',
|
|
35
|
+
'p',
|
|
36
|
+
';',
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
/** The white keys only, on the home row. Black keys have no shortcut. */
|
|
40
|
+
HOME_ROW_NATURAL: {
|
|
41
|
+
keys: [
|
|
42
|
+
'a',
|
|
43
|
+
'',
|
|
44
|
+
's',
|
|
45
|
+
'',
|
|
46
|
+
'd',
|
|
47
|
+
'f',
|
|
48
|
+
'',
|
|
49
|
+
'g',
|
|
50
|
+
'',
|
|
51
|
+
'h',
|
|
52
|
+
'',
|
|
53
|
+
'j',
|
|
54
|
+
'k',
|
|
55
|
+
'',
|
|
56
|
+
'l',
|
|
57
|
+
'',
|
|
58
|
+
';',
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Where keyboard shortcuts listen. `root` handles keys only while the piano
|
|
65
|
+
* or one of its descendants has focus; `window` handles them anywhere on the
|
|
66
|
+
* page except in editable elements.
|
|
67
|
+
*/
|
|
68
|
+
export type KeyboardShortcutsScope = 'root' | 'window'
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether a key press was typed into something: shortcuts must not play a
|
|
72
|
+
* note for a letter that is going into a text field.
|
|
73
|
+
*/
|
|
74
|
+
export function isEditableTarget(target: EventTarget | null) {
|
|
75
|
+
if (!(target instanceof HTMLElement)) return false
|
|
76
|
+
if (target.matches('input, textarea, select')) return true
|
|
77
|
+
|
|
78
|
+
for (let element: HTMLElement | null = target; element;) {
|
|
79
|
+
const contentEditable = element.getAttribute('contenteditable')
|
|
80
|
+
if (contentEditable !== null)
|
|
81
|
+
return contentEditable.toLowerCase() !== 'false'
|
|
82
|
+
element = element.parentElement
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return false
|
|
86
|
+
}
|