@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.
@@ -1,7 +1,8 @@
1
1
  /**
2
- * The properties of a 2D context that survive `save()` / `restore()`, and so
3
- * are what has to be carried across a resize by hand: setting `canvas.width`
4
- * resets the context to its defaults.
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 == 'string' && names.includes(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/input'
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/dragValue'
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'
@@ -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 = typeof PERMISSION_DENIED | typeof NOT_SUPPORTED
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
- function request() {
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
- .requestMIDIAccess()
54
- .then((access) => {
55
- if (destroyed) return
56
- setState({ midiAccess: access, error: null })
57
- })
58
- .catch(() => {
59
- if (destroyed) return
60
- setState({ ...state, error: PERMISSION_DENIED })
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, type MIDIMessageInstance } from './message'
1
+ import { createMIDIMessage } from './message'
2
2
 
3
- const MIDI_EVENT_TO_NUMBER = {
4
- NOTE_ON: 0x90,
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
- onPitchBendEvent?: (msb: number, lsb: number) => void
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 type MIDIInputInstance = MIDIMessageInstance
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 note on/off and pitch bend events. To be used with {@link createMIDIAccess}.
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
- { onNoteOnEvent, onNoteOffEvent, onPitchBendEvent }: MIDIInputHandlers,
65
+ handlers: MIDIInputHandlers,
24
66
  ): MIDIInputInstance {
25
- return createMIDIMessage(midiAccess, (event) => {
26
- if (!event.data) return
27
- // event.data[0] ... command
28
- // event.data[1] ... note, MSB (Most Significant Byte)
29
- // event.data[2] ... velocity, LSB (Least Significant Byte)
30
- const kind = event.data[0] & 0xf0
31
-
32
- if (
33
- kind == MIDI_EVENT_TO_NUMBER.NOTE_OFF ||
34
- (kind == MIDI_EVENT_TO_NUMBER.NOTE_ON && event.data[2] == 0)
35
- ) {
36
- onNoteOffEvent?.(event.data[1])
37
- } else if (kind == MIDI_EVENT_TO_NUMBER.NOTE_ON) {
38
- onNoteOnEvent?.(event.data[1], event.data[2])
39
- } else if (kind == MIDI_EVENT_TO_NUMBER.PITCH_BEND) {
40
- onPitchBendEvent?.(event.data[1], event.data[2])
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
  }
@@ -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
- if (!midiAccess) {
15
- return { destroy: () => {} }
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
- const inputs = [...midiAccess.inputs.values()]
19
- for (const input of inputs) {
20
- input.addEventListener('midimessage', onMIDIMessage)
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
- for (const input of inputs) {
26
- input.removeEventListener('midimessage', onMIDIMessage)
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
- for (const note of activeNotes()) {
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
  }