@tremolo-ui/dom 0.6.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 +1590 -145
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1030 -99
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +1030 -99
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1544 -146
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/file/accept.ts +75 -0
- package/src/file/drop-zone.ts +200 -0
- package/src/index.ts +101 -0
- package/src/input/apply-delta.ts +73 -0
- package/src/input/check-steps.ts +144 -0
- package/src/input/defaults.ts +32 -0
- package/src/input/direction.ts +107 -0
- package/src/input/modifiers.ts +121 -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 +130 -4
- package/src/piano/layout.ts +202 -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,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,7 +1,12 @@
|
|
|
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
|
+
import {
|
|
5
|
+
isEditableTarget,
|
|
6
|
+
type KeyboardShortcuts,
|
|
7
|
+
type KeyboardShortcutsScope,
|
|
8
|
+
} from './shortcuts'
|
|
9
|
+
|
|
5
10
|
/**
|
|
6
11
|
* What asked for a note to sound.
|
|
7
12
|
*
|
|
@@ -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()
|
|
@@ -0,0 +1,202 @@
|
|
|
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
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export interface LongPressOptions {
|
|
2
|
+
/** Called once on the press, then again every `interval` after `delay`. */
|
|
3
|
+
onPress: () => void
|
|
4
|
+
/**
|
|
5
|
+
* How long the press has to be held before it starts repeating, in
|
|
6
|
+
* milliseconds.
|
|
7
|
+
*
|
|
8
|
+
* @default 500
|
|
9
|
+
*/
|
|
10
|
+
delay?: number
|
|
11
|
+
/**
|
|
12
|
+
* How often it repeats once it has started, in milliseconds.
|
|
13
|
+
*
|
|
14
|
+
* @default 40
|
|
15
|
+
*/
|
|
16
|
+
interval?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LongPressInstance {
|
|
20
|
+
/**
|
|
21
|
+
* Start a press, from a `pointerdown` or with no event at all. Only the
|
|
22
|
+
* primary button starts one, and a press already in progress is left alone.
|
|
23
|
+
*/
|
|
24
|
+
start: (event?: Pick<PointerEvent, 'button' | 'pointerId'>) => void
|
|
25
|
+
/** End the press, as releasing the pointer would. */
|
|
26
|
+
stop: () => void
|
|
27
|
+
/** Replace the given options. The press in progress picks them up. */
|
|
28
|
+
update: (options: Partial<LongPressOptions>) => void
|
|
29
|
+
/** Whether a press is in progress. */
|
|
30
|
+
pressed: () => boolean
|
|
31
|
+
destroy: () => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Repeat an action while a pointer is held down: once on the press, then
|
|
36
|
+
* again every `interval` after `delay` — the way a stepper button or a
|
|
37
|
+
* key held on a keyboard behaves.
|
|
38
|
+
*
|
|
39
|
+
* The release is listened for on the window, not on the element: the
|
|
40
|
+
* pointer may be let go anywhere. Only the pointer that started the press
|
|
41
|
+
* ends it, so a second finger lifting elsewhere does not. The window losing
|
|
42
|
+
* focus ends it too, since the release would never arrive.
|
|
43
|
+
*/
|
|
44
|
+
export function createLongPress(options: LongPressOptions): LongPressInstance {
|
|
45
|
+
let opts = options
|
|
46
|
+
let pointerId: number | null = null
|
|
47
|
+
let active = false
|
|
48
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
49
|
+
|
|
50
|
+
function clearTimer() {
|
|
51
|
+
if (timer !== null) clearTimeout(timer)
|
|
52
|
+
timer = null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Wait `wait`, fire, and keep going at `interval` from then on. */
|
|
56
|
+
function schedule(wait: number) {
|
|
57
|
+
timer = setTimeout(() => {
|
|
58
|
+
opts.onPress()
|
|
59
|
+
if (active) schedule(opts.interval ?? 40)
|
|
60
|
+
}, wait)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function onPointerEnd(event: PointerEvent) {
|
|
64
|
+
// Pointer events carry an id; a press started without one ends on any.
|
|
65
|
+
if (pointerId !== null && event.pointerId !== pointerId) return
|
|
66
|
+
stop()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function listen(add: boolean) {
|
|
70
|
+
const target = globalThis.window
|
|
71
|
+
if (!target) return
|
|
72
|
+
const method = add ? 'addEventListener' : 'removeEventListener'
|
|
73
|
+
target[method]('pointerup', onPointerEnd as EventListener)
|
|
74
|
+
target[method]('pointercancel', onPointerEnd as EventListener)
|
|
75
|
+
target[method]('blur', stop)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function stop() {
|
|
79
|
+
if (!active) return
|
|
80
|
+
active = false
|
|
81
|
+
pointerId = null
|
|
82
|
+
clearTimer()
|
|
83
|
+
listen(false)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
start: (event) => {
|
|
88
|
+
if (active || (event && event.button !== 0)) return
|
|
89
|
+
active = true
|
|
90
|
+
pointerId = event?.pointerId ?? null
|
|
91
|
+
listen(true)
|
|
92
|
+
opts.onPress()
|
|
93
|
+
// The action may have stopped the press itself.
|
|
94
|
+
if (active) schedule(opts.delay ?? 500)
|
|
95
|
+
},
|
|
96
|
+
stop,
|
|
97
|
+
update: (next) => {
|
|
98
|
+
opts = { ...opts, ...next }
|
|
99
|
+
},
|
|
100
|
+
pressed: () => active,
|
|
101
|
+
destroy: stop,
|
|
102
|
+
}
|
|
103
|
+
}
|