@tremolo-ui/dom 0.5.0 → 0.6.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 +459 -100
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +234 -39
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +234 -39
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +457 -102
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/src/canvas/animation.ts +49 -31
- package/src/canvas/context.ts +32 -5
- package/src/index.ts +13 -2
- package/src/midi/access.ts +83 -13
- package/src/midi/input.ts +97 -26
- package/src/midi/message.ts +37 -8
- package/src/piano/{input.ts → index.ts} +23 -3
- package/src/pointer/{dragValue.ts → drag-value.ts} +155 -19
- package/src/pointer/drag.ts +215 -20
- package/src/pointer/wheel.ts +4 -2
- package/src/selection/box.ts +136 -0
package/src/canvas/context.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The assignable parts of a 2D context's drawing state. They have to be
|
|
3
|
+
* carried across a resize by hand because setting `canvas.width` resets the
|
|
4
|
+
* context to its defaults. The transform and line dash are handled separately
|
|
5
|
+
* by {@link DrawingContext} because they are exposed through methods.
|
|
5
6
|
*
|
|
6
7
|
* @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/save
|
|
7
8
|
*/
|
|
@@ -19,20 +20,33 @@ export const drawingState = [
|
|
|
19
20
|
'shadowBlur',
|
|
20
21
|
'shadowColor',
|
|
21
22
|
'globalCompositeOperation',
|
|
23
|
+
'filter',
|
|
22
24
|
'font',
|
|
25
|
+
'fontKerning',
|
|
26
|
+
'fontStretch',
|
|
27
|
+
'fontVariantCaps',
|
|
23
28
|
'textAlign',
|
|
24
29
|
'textBaseline',
|
|
25
30
|
'direction',
|
|
31
|
+
'letterSpacing',
|
|
32
|
+
'textRendering',
|
|
33
|
+
'wordSpacing',
|
|
26
34
|
'imageSmoothingEnabled',
|
|
35
|
+
'imageSmoothingQuality',
|
|
27
36
|
] as const
|
|
28
37
|
|
|
29
38
|
export type DrawingState = (typeof drawingState)[number]
|
|
30
39
|
export type DrawingStateValue = CanvasRenderingContext2D[DrawingState]
|
|
31
|
-
export type DrawingContext = Pick<CanvasRenderingContext2D, DrawingState>
|
|
40
|
+
export type DrawingContext = Pick<CanvasRenderingContext2D, DrawingState> & {
|
|
41
|
+
/** The current line dash sequence. */
|
|
42
|
+
lineDash: number[]
|
|
43
|
+
/** The current transformation matrix. */
|
|
44
|
+
transform: DOMMatrix
|
|
45
|
+
}
|
|
32
46
|
|
|
33
47
|
export function isDrawingState(value: unknown): value is DrawingState {
|
|
34
48
|
const names: readonly string[] = drawingState
|
|
35
|
-
return typeof value
|
|
49
|
+
return typeof value === 'string' && names.includes(value)
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
/** Copy the drawing state off a context, to put back after a resize. */
|
|
@@ -43,6 +57,8 @@ export function readDrawingState(
|
|
|
43
57
|
for (const property of drawingState) {
|
|
44
58
|
;(state[property] as DrawingStateValue) = context[property]
|
|
45
59
|
}
|
|
60
|
+
state.lineDash = context.getLineDash()
|
|
61
|
+
state.transform = context.getTransform()
|
|
46
62
|
return state
|
|
47
63
|
}
|
|
48
64
|
|
|
@@ -50,10 +66,21 @@ export function readDrawingState(
|
|
|
50
66
|
export function writeDrawingState(
|
|
51
67
|
context: CanvasRenderingContext2D,
|
|
52
68
|
state: DrawingContext,
|
|
69
|
+
transformScale = 1,
|
|
53
70
|
) {
|
|
54
71
|
for (const property of drawingState) {
|
|
55
72
|
;(context[property] as DrawingStateValue) = state[property]
|
|
56
73
|
}
|
|
74
|
+
context.setLineDash(state.lineDash)
|
|
75
|
+
const { a, b, c, d, e, f } = state.transform
|
|
76
|
+
context.setTransform(
|
|
77
|
+
a * transformScale,
|
|
78
|
+
b * transformScale,
|
|
79
|
+
c * transformScale,
|
|
80
|
+
d * transformScale,
|
|
81
|
+
e * transformScale,
|
|
82
|
+
f * transformScale,
|
|
83
|
+
)
|
|
57
84
|
}
|
|
58
85
|
|
|
59
86
|
/**
|
package/src/index.ts
CHANGED
|
@@ -17,12 +17,15 @@ export {
|
|
|
17
17
|
createMIDIAccess,
|
|
18
18
|
NOT_SUPPORTED,
|
|
19
19
|
PERMISSION_DENIED,
|
|
20
|
+
UNAVAILABLE,
|
|
20
21
|
type MIDIAccessError,
|
|
21
22
|
type MIDIAccessInstance,
|
|
23
|
+
type MIDIAccessOptions,
|
|
22
24
|
type MIDIAccessState,
|
|
23
25
|
} from './midi/access'
|
|
24
26
|
export {
|
|
25
27
|
createMIDIInput,
|
|
28
|
+
PITCH_BEND_CENTER,
|
|
26
29
|
type MIDIInputHandlers,
|
|
27
30
|
type MIDIInputInstance,
|
|
28
31
|
} from './midi/input'
|
|
@@ -32,7 +35,7 @@ export {
|
|
|
32
35
|
type NoteSource,
|
|
33
36
|
type PianoInputInstance,
|
|
34
37
|
type PianoInputOptions,
|
|
35
|
-
} from './piano
|
|
38
|
+
} from './piano'
|
|
36
39
|
export {
|
|
37
40
|
createDrag,
|
|
38
41
|
type DragInstance,
|
|
@@ -48,10 +51,18 @@ export {
|
|
|
48
51
|
type DragValueMapping,
|
|
49
52
|
type DragValueOptions,
|
|
50
53
|
type MappingContext,
|
|
51
|
-
} from './pointer/
|
|
54
|
+
} from './pointer/drag-value'
|
|
52
55
|
export {
|
|
53
56
|
createWheel,
|
|
54
57
|
type WheelInstance,
|
|
55
58
|
type WheelOptions,
|
|
56
59
|
} from './pointer/wheel'
|
|
60
|
+
export {
|
|
61
|
+
createSelectionBox,
|
|
62
|
+
selectionBoxCovers,
|
|
63
|
+
type SelectionBoxBeginOptions,
|
|
64
|
+
type SelectionBoxInstance,
|
|
65
|
+
type SelectionBoxOptions,
|
|
66
|
+
type SelectionBoxRect,
|
|
67
|
+
} from './selection/box'
|
|
57
68
|
export { toXY, type XY, type XYInput } from './xy'
|
package/src/midi/access.ts
CHANGED
|
@@ -2,23 +2,48 @@
|
|
|
2
2
|
export const PERMISSION_DENIED = 'PERMISSION_DENIED'
|
|
3
3
|
/** @private */
|
|
4
4
|
export const NOT_SUPPORTED = 'NOT_SUPPORTED'
|
|
5
|
+
/** @private */
|
|
6
|
+
export const UNAVAILABLE = 'UNAVAILABLE'
|
|
5
7
|
|
|
6
8
|
/** @private */
|
|
7
|
-
export type MIDIAccessError =
|
|
9
|
+
export type MIDIAccessError =
|
|
10
|
+
| typeof PERMISSION_DENIED
|
|
11
|
+
| typeof NOT_SUPPORTED
|
|
12
|
+
| typeof UNAVAILABLE
|
|
13
|
+
|
|
14
|
+
export type MIDIAccessOptions = {
|
|
15
|
+
/**
|
|
16
|
+
* Ask for system exclusive messages as well.
|
|
17
|
+
*
|
|
18
|
+
* Browsers treat this as a separate, more sensitive permission, so leave it
|
|
19
|
+
* off unless the app actually reads or sends sysex.
|
|
20
|
+
*
|
|
21
|
+
* @default false
|
|
22
|
+
*/
|
|
23
|
+
sysex?: boolean
|
|
24
|
+
}
|
|
8
25
|
|
|
9
26
|
export type MIDIAccessState = {
|
|
10
27
|
readonly midiAccess: MIDIAccess | null
|
|
11
28
|
readonly error: MIDIAccessError | null
|
|
29
|
+
/**
|
|
30
|
+
* The inputs currently connected, in the order MIDIAccess lists them.
|
|
31
|
+
*
|
|
32
|
+
* Kept up to date as devices are plugged in and unplugged, so a UI listing
|
|
33
|
+
* the devices does not have to watch `statechange` itself.
|
|
34
|
+
*/
|
|
35
|
+
readonly inputs: readonly MIDIInput[]
|
|
12
36
|
}
|
|
13
37
|
|
|
14
38
|
const INITIAL_STATE: MIDIAccessState = {
|
|
15
39
|
midiAccess: null,
|
|
16
40
|
error: null,
|
|
41
|
+
inputs: [],
|
|
17
42
|
}
|
|
18
43
|
|
|
19
44
|
export interface MIDIAccessInstance {
|
|
20
45
|
/** Request MIDI access. Safe to call more than once. */
|
|
21
|
-
request: () => void
|
|
46
|
+
request: (options?: MIDIAccessOptions) => void
|
|
22
47
|
getState: () => MIDIAccessState
|
|
23
48
|
/** Snapshot for server side rendering. Always the initial state. */
|
|
24
49
|
getServerState: () => MIDIAccessState
|
|
@@ -27,6 +52,28 @@ export interface MIDIAccessInstance {
|
|
|
27
52
|
destroy: () => void
|
|
28
53
|
}
|
|
29
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Which of our errors a rejected `requestMIDIAccess()` amounts to.
|
|
57
|
+
*
|
|
58
|
+
* The spec names the reasons, and they need telling apart: a user who said no
|
|
59
|
+
* can say yes on a second ask, while a browser without the API never will.
|
|
60
|
+
*/
|
|
61
|
+
function toError(reason: unknown): MIDIAccessError {
|
|
62
|
+
const name =
|
|
63
|
+
typeof reason === 'object' && reason !== null && 'name' in reason
|
|
64
|
+
? String((reason as { name: unknown }).name)
|
|
65
|
+
: ''
|
|
66
|
+
|
|
67
|
+
if (name === 'SecurityError' || name === 'NotAllowedError') {
|
|
68
|
+
return PERMISSION_DENIED
|
|
69
|
+
}
|
|
70
|
+
if (name === 'NotSupportedError' || name === 'TypeError') {
|
|
71
|
+
return NOT_SUPPORTED
|
|
72
|
+
}
|
|
73
|
+
// AbortError, InvalidStateError, and anything a browser makes up.
|
|
74
|
+
return UNAVAILABLE
|
|
75
|
+
}
|
|
76
|
+
|
|
30
77
|
/**
|
|
31
78
|
* Request MIDI access in the browser.
|
|
32
79
|
*
|
|
@@ -36,6 +83,8 @@ export interface MIDIAccessInstance {
|
|
|
36
83
|
export function createMIDIAccess(): MIDIAccessInstance {
|
|
37
84
|
let state = INITIAL_STATE
|
|
38
85
|
let destroyed = false
|
|
86
|
+
let access: MIDIAccess | null = null
|
|
87
|
+
let requestGeneration = 0
|
|
39
88
|
const listeners = new Set<() => void>()
|
|
40
89
|
|
|
41
90
|
function setState(next: MIDIAccessState) {
|
|
@@ -43,22 +92,40 @@ export function createMIDIAccess(): MIDIAccessInstance {
|
|
|
43
92
|
for (const listener of listeners) listener()
|
|
44
93
|
}
|
|
45
94
|
|
|
46
|
-
|
|
95
|
+
/**
|
|
96
|
+
* A new array every time, so that `useSyncExternalStore` and its equivalents
|
|
97
|
+
* see the change. The identity of the state object is what they compare.
|
|
98
|
+
*/
|
|
99
|
+
function readInputs() {
|
|
100
|
+
return access ? [...access.inputs.values()] : []
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function handleStateChange(event?: Event) {
|
|
104
|
+
if (destroyed || !access) return
|
|
105
|
+
if (event && (event as MIDIConnectionEvent).port?.type === 'output') return
|
|
106
|
+
setState({ ...state, inputs: readInputs() })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function request(options: MIDIAccessOptions = {}) {
|
|
47
110
|
if (destroyed) return
|
|
111
|
+
const generation = ++requestGeneration
|
|
48
112
|
if (typeof navigator === 'undefined' || !navigator.requestMIDIAccess) {
|
|
49
113
|
setState({ ...state, error: NOT_SUPPORTED })
|
|
50
114
|
return
|
|
51
115
|
}
|
|
52
|
-
navigator
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
116
|
+
navigator.requestMIDIAccess({ sysex: options.sysex ?? false }).then(
|
|
117
|
+
(granted) => {
|
|
118
|
+
if (destroyed || generation !== requestGeneration) return
|
|
119
|
+
access?.removeEventListener('statechange', handleStateChange)
|
|
120
|
+
access = granted
|
|
121
|
+
granted.addEventListener('statechange', handleStateChange)
|
|
122
|
+
setState({ midiAccess: granted, error: null, inputs: readInputs() })
|
|
123
|
+
},
|
|
124
|
+
(reason: unknown) => {
|
|
125
|
+
if (destroyed || generation !== requestGeneration) return
|
|
126
|
+
setState({ ...state, error: toError(reason) })
|
|
127
|
+
},
|
|
128
|
+
)
|
|
62
129
|
}
|
|
63
130
|
|
|
64
131
|
return {
|
|
@@ -73,6 +140,9 @@ export function createMIDIAccess(): MIDIAccessInstance {
|
|
|
73
140
|
},
|
|
74
141
|
destroy: () => {
|
|
75
142
|
destroyed = true
|
|
143
|
+
requestGeneration += 1
|
|
144
|
+
access?.removeEventListener('statechange', handleStateChange)
|
|
145
|
+
access = null
|
|
76
146
|
listeners.clear()
|
|
77
147
|
},
|
|
78
148
|
}
|
package/src/midi/input.ts
CHANGED
|
@@ -1,43 +1,114 @@
|
|
|
1
|
-
import { createMIDIMessage
|
|
1
|
+
import { createMIDIMessage } from './message'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
/** Status byte of each channel message, with the channel nibble cleared. */
|
|
4
|
+
const STATUS = {
|
|
5
5
|
NOTE_OFF: 0x80,
|
|
6
|
+
NOTE_ON: 0x90,
|
|
7
|
+
AFTERTOUCH: 0xa0,
|
|
8
|
+
CONTROL_CHANGE: 0xb0,
|
|
9
|
+
PROGRAM_CHANGE: 0xc0,
|
|
10
|
+
CHANNEL_PRESSURE: 0xd0,
|
|
6
11
|
PITCH_BEND: 0xe0,
|
|
7
12
|
}
|
|
8
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Centre of the 14-bit pitch bend range: no bend.
|
|
16
|
+
*
|
|
17
|
+
* The range is not symmetric — 0 is 8192 below centre and 16383 is 8191 above
|
|
18
|
+
* — so a wheel at rest reports exactly this rather than half of the maximum.
|
|
19
|
+
*/
|
|
20
|
+
export const PITCH_BEND_CENTER = 8192
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Every handler is given the channel last, as 0-15. MIDI channels are written
|
|
24
|
+
* 1-16 on hardware, so add one before showing it to anyone.
|
|
25
|
+
*/
|
|
9
26
|
export type MIDIInputHandlers = {
|
|
10
|
-
onNoteOnEvent?: (note: number, velocity: number) => void
|
|
11
|
-
onNoteOffEvent?: (note: number) => void
|
|
12
|
-
|
|
27
|
+
onNoteOnEvent?: (note: number, velocity: number, channel: number) => void
|
|
28
|
+
onNoteOffEvent?: (note: number, channel: number) => void
|
|
29
|
+
/**
|
|
30
|
+
* The 14-bit bend, 0-16383, centred at {@link PITCH_BEND_CENTER}.
|
|
31
|
+
*
|
|
32
|
+
* The two data bytes are little-endian — the first carries the low 7 bits —
|
|
33
|
+
* which is the other way round from every other message.
|
|
34
|
+
*/
|
|
35
|
+
onPitchBendEvent?: (value: number, channel: number) => void
|
|
36
|
+
/** `controller` is the CC number, `value` is 0-127. */
|
|
37
|
+
onControlChangeEvent?: (
|
|
38
|
+
controller: number,
|
|
39
|
+
value: number,
|
|
40
|
+
channel: number,
|
|
41
|
+
) => void
|
|
42
|
+
onProgramChangeEvent?: (program: number, channel: number) => void
|
|
43
|
+
/** Pressure for one held note (polyphonic aftertouch). */
|
|
44
|
+
onAftertouchEvent?: (note: number, pressure: number, channel: number) => void
|
|
45
|
+
/** Pressure for the whole channel, sent by keyboards with one sensor. */
|
|
46
|
+
onChannelPressureEvent?: (pressure: number, channel: number) => void
|
|
13
47
|
}
|
|
14
48
|
|
|
15
|
-
export
|
|
49
|
+
export interface MIDIInputInstance {
|
|
50
|
+
/** Replace the handlers, keeping the listeners in place. */
|
|
51
|
+
update: (handlers: MIDIInputHandlers) => void
|
|
52
|
+
destroy: () => void
|
|
53
|
+
}
|
|
16
54
|
|
|
17
55
|
/**
|
|
18
|
-
* Handle
|
|
19
|
-
* Internally uses {@link createMIDIMessage}
|
|
56
|
+
* Handle the channel voice messages of every connected input. To be used with
|
|
57
|
+
* {@link createMIDIAccess}. Internally uses {@link createMIDIMessage}, so
|
|
58
|
+
* devices plugged in later are picked up.
|
|
59
|
+
*
|
|
60
|
+
* System messages (clock, sysex, and the rest of `0xf0`-`0xff`) are not
|
|
61
|
+
* decoded here; reach for {@link createMIDIMessage} for those.
|
|
20
62
|
*/
|
|
21
63
|
export function createMIDIInput(
|
|
22
64
|
midiAccess: MIDIAccess | null,
|
|
23
|
-
|
|
65
|
+
handlers: MIDIInputHandlers,
|
|
24
66
|
): MIDIInputInstance {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
67
|
+
let current = handlers
|
|
68
|
+
|
|
69
|
+
const message = createMIDIMessage(midiAccess, (event) => {
|
|
70
|
+
if (!event.data || event.data.length === 0) return
|
|
71
|
+
|
|
72
|
+
const status = event.data[0]
|
|
73
|
+
// 0xf0 and above are system messages, which carry no channel.
|
|
74
|
+
if (status >= 0xf0) return
|
|
75
|
+
|
|
76
|
+
const kind = status & 0xf0
|
|
77
|
+
const channel = status & 0x0f
|
|
78
|
+
const first = event.data[1] ?? 0
|
|
79
|
+
const second = event.data[2] ?? 0
|
|
80
|
+
|
|
81
|
+
switch (kind) {
|
|
82
|
+
case STATUS.NOTE_OFF:
|
|
83
|
+
current.onNoteOffEvent?.(first, channel)
|
|
84
|
+
break
|
|
85
|
+
case STATUS.NOTE_ON:
|
|
86
|
+
// A note on with zero velocity is how most devices say note off.
|
|
87
|
+
if (second === 0) current.onNoteOffEvent?.(first, channel)
|
|
88
|
+
else current.onNoteOnEvent?.(first, second, channel)
|
|
89
|
+
break
|
|
90
|
+
case STATUS.AFTERTOUCH:
|
|
91
|
+
current.onAftertouchEvent?.(first, second, channel)
|
|
92
|
+
break
|
|
93
|
+
case STATUS.CONTROL_CHANGE:
|
|
94
|
+
current.onControlChangeEvent?.(first, second, channel)
|
|
95
|
+
break
|
|
96
|
+
case STATUS.PROGRAM_CHANGE:
|
|
97
|
+
current.onProgramChangeEvent?.(first, channel)
|
|
98
|
+
break
|
|
99
|
+
case STATUS.CHANNEL_PRESSURE:
|
|
100
|
+
current.onChannelPressureEvent?.(first, channel)
|
|
101
|
+
break
|
|
102
|
+
case STATUS.PITCH_BEND:
|
|
103
|
+
current.onPitchBendEvent?.((second << 7) | first, channel)
|
|
104
|
+
break
|
|
41
105
|
}
|
|
42
106
|
})
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
update: (next) => {
|
|
110
|
+
current = next
|
|
111
|
+
},
|
|
112
|
+
destroy: message.destroy,
|
|
113
|
+
}
|
|
43
114
|
}
|
package/src/midi/message.ts
CHANGED
|
@@ -1,30 +1,59 @@
|
|
|
1
1
|
export interface MIDIMessageInstance {
|
|
2
|
+
/** Replace the handler, keeping the listeners in place. */
|
|
3
|
+
update: (onMIDIMessage: (event: MIDIMessageEvent) => void) => void
|
|
2
4
|
destroy: () => void
|
|
3
5
|
}
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Listen to raw `midimessage` events on every input of a MIDIAccess.
|
|
7
9
|
*
|
|
10
|
+
* The set of inputs is followed rather than sampled: MIDIAccess fires
|
|
11
|
+
* `statechange` when a device is plugged in or unplugged, and the listeners
|
|
12
|
+
* move with it. A keyboard connected after access was granted works without
|
|
13
|
+
* the caller having to rebuild anything.
|
|
14
|
+
*
|
|
8
15
|
* Use this when you need more detail than {@link createMIDIInput} provides.
|
|
9
16
|
*/
|
|
10
17
|
export function createMIDIMessage(
|
|
11
18
|
midiAccess: MIDIAccess | null,
|
|
12
19
|
onMIDIMessage: (event: MIDIMessageEvent) => void,
|
|
13
20
|
): MIDIMessageInstance {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
21
|
+
let handler = onMIDIMessage
|
|
22
|
+
|
|
23
|
+
/** Inputs this instance currently listens on. */
|
|
24
|
+
const attached = new Set<MIDIInput>()
|
|
25
|
+
|
|
26
|
+
const listener = (event: Event) => handler(event as MIDIMessageEvent)
|
|
17
27
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
28
|
+
function sync() {
|
|
29
|
+
if (!midiAccess) return
|
|
30
|
+
const connected = new Set(midiAccess.inputs.values())
|
|
31
|
+
|
|
32
|
+
for (const input of connected) {
|
|
33
|
+
if (attached.has(input)) continue
|
|
34
|
+
input.addEventListener('midimessage', listener)
|
|
35
|
+
attached.add(input)
|
|
36
|
+
}
|
|
37
|
+
for (const input of [...attached]) {
|
|
38
|
+
if (connected.has(input)) continue
|
|
39
|
+
input.removeEventListener('midimessage', listener)
|
|
40
|
+
attached.delete(input)
|
|
41
|
+
}
|
|
21
42
|
}
|
|
22
43
|
|
|
44
|
+
sync()
|
|
45
|
+
midiAccess?.addEventListener('statechange', sync)
|
|
46
|
+
|
|
23
47
|
return {
|
|
48
|
+
update: (next) => {
|
|
49
|
+
handler = next
|
|
50
|
+
},
|
|
24
51
|
destroy: () => {
|
|
25
|
-
|
|
26
|
-
|
|
52
|
+
midiAccess?.removeEventListener('statechange', sync)
|
|
53
|
+
for (const input of attached) {
|
|
54
|
+
input.removeEventListener('midimessage', listener)
|
|
27
55
|
}
|
|
56
|
+
attached.clear()
|
|
28
57
|
},
|
|
29
58
|
}
|
|
30
59
|
}
|
|
@@ -99,6 +99,9 @@ export function createPianoInput(
|
|
|
99
99
|
velocity,
|
|
100
100
|
}: { source?: NoteSource; velocity?: number } = {},
|
|
101
101
|
) {
|
|
102
|
+
if (!Number.isInteger(note) || note < 0 || note > 127) {
|
|
103
|
+
throw new RangeError('note: requirements: an integer from 0 to 127')
|
|
104
|
+
}
|
|
102
105
|
if (note > (opts.midiMax ?? 127)) return
|
|
103
106
|
|
|
104
107
|
const sources = held.get(note)
|
|
@@ -117,6 +120,9 @@ export function createPianoInput(
|
|
|
117
120
|
note: number,
|
|
118
121
|
{ source = DEFAULT_SOURCE }: { source?: NoteSource } = {},
|
|
119
122
|
) {
|
|
123
|
+
if (!Number.isInteger(note) || note < 0 || note > 127) {
|
|
124
|
+
throw new RangeError('note: requirements: an integer from 0 to 127')
|
|
125
|
+
}
|
|
120
126
|
const sources = held.get(note)
|
|
121
127
|
if (!sources) return
|
|
122
128
|
|
|
@@ -142,7 +148,7 @@ export function createPianoInput(
|
|
|
142
148
|
|
|
143
149
|
if (previous !== undefined) noteOff(previous, { source })
|
|
144
150
|
|
|
145
|
-
if (note === null) {
|
|
151
|
+
if (note === null || note > (opts.midiMax ?? 127)) {
|
|
146
152
|
pointerNotes.delete(pointerId)
|
|
147
153
|
} else {
|
|
148
154
|
pointerNotes.set(pointerId, note)
|
|
@@ -166,6 +172,19 @@ export function createPianoInput(
|
|
|
166
172
|
return {
|
|
167
173
|
update: (next) => {
|
|
168
174
|
opts = { ...opts, ...next }
|
|
175
|
+
|
|
176
|
+
const midiMax = opts.midiMax ?? 127
|
|
177
|
+
const stoppedNotes = activeNotes().filter((note) => note > midiMax)
|
|
178
|
+
if (stoppedNotes.length === 0) return
|
|
179
|
+
|
|
180
|
+
for (const note of stoppedNotes) {
|
|
181
|
+
held.delete(note)
|
|
182
|
+
opts.onStopNote?.(note)
|
|
183
|
+
}
|
|
184
|
+
for (const [pointerId, note] of pointerNotes) {
|
|
185
|
+
if (note > midiMax) pointerNotes.delete(pointerId)
|
|
186
|
+
}
|
|
187
|
+
opts.onActiveNotesChange?.(activeNotes())
|
|
169
188
|
},
|
|
170
189
|
noteOn,
|
|
171
190
|
noteOff,
|
|
@@ -174,12 +193,13 @@ export function createPianoInput(
|
|
|
174
193
|
drag.destroy()
|
|
175
194
|
// Anything still held is released, so a caller that mirrors these
|
|
176
195
|
// callbacks into a synth is not left with a stuck note.
|
|
177
|
-
|
|
196
|
+
const notes = activeNotes()
|
|
197
|
+
for (const note of notes) {
|
|
178
198
|
held.delete(note)
|
|
179
199
|
opts.onStopNote?.(note)
|
|
180
200
|
}
|
|
181
201
|
pointerNotes.clear()
|
|
182
|
-
opts.onActiveNotesChange?.([])
|
|
202
|
+
if (notes.length > 0) opts.onActiveNotesChange?.([])
|
|
183
203
|
},
|
|
184
204
|
}
|
|
185
205
|
}
|