@tremolo-ui/dom 0.7.0 → 0.9.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.
Files changed (53) hide show
  1. package/dist/index.cjs +343 -595
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +230 -284
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.ts +230 -284
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +325 -572
  8. package/dist/index.js.map +1 -1
  9. package/dist/internal.cjs +530 -0
  10. package/dist/internal.cjs.map +1 -0
  11. package/dist/internal.d.cts +365 -0
  12. package/dist/internal.d.cts.map +1 -0
  13. package/dist/internal.d.ts +365 -0
  14. package/dist/internal.d.ts.map +1 -0
  15. package/dist/internal.js +500 -0
  16. package/dist/internal.js.map +1 -0
  17. package/dist/marks-DK7xJmGM.d.cts +305 -0
  18. package/dist/marks-DK7xJmGM.d.cts.map +1 -0
  19. package/dist/marks-DK7xJmGM.d.ts +305 -0
  20. package/dist/marks-DK7xJmGM.d.ts.map +1 -0
  21. package/dist/xy-5Oc6JeJr.cjs +952 -0
  22. package/dist/xy-5Oc6JeJr.cjs.map +1 -0
  23. package/dist/xy-lNFl1CTO.js +827 -0
  24. package/dist/xy-lNFl1CTO.js.map +1 -0
  25. package/package.json +12 -2
  26. package/src/canvas/animation.ts +8 -0
  27. package/src/canvas/context.ts +0 -5
  28. package/src/file/accept.ts +17 -0
  29. package/src/file/drop-zone.ts +8 -10
  30. package/src/index.ts +44 -32
  31. package/src/input/change-gesture.ts +104 -0
  32. package/src/input/check-steps.ts +144 -0
  33. package/src/input/defaults.ts +32 -0
  34. package/src/input/direction.ts +107 -0
  35. package/src/internal.ts +51 -0
  36. package/src/knob/geometry.ts +100 -0
  37. package/src/midi/access.ts +16 -15
  38. package/src/midi/input.ts +2 -9
  39. package/src/number-input/stepper-drag.ts +147 -0
  40. package/src/number-input/text.ts +100 -0
  41. package/src/number-input/value.ts +129 -0
  42. package/src/options/replace.ts +22 -0
  43. package/src/piano/index.ts +128 -2
  44. package/src/piano/layout.ts +19 -0
  45. package/src/piano/shortcuts.ts +86 -0
  46. package/src/pointer/drag-value.ts +3 -1
  47. package/src/pointer/long-press.ts +103 -0
  48. package/src/pointer/wheel.ts +10 -7
  49. package/src/points-editor/index.ts +367 -0
  50. package/src/position.ts +20 -0
  51. package/src/slider/decimal-digits.ts +23 -0
  52. package/src/slider/marks.ts +56 -0
  53. package/src/style.ts +41 -0
@@ -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
+ }
@@ -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: a keyboard shortcut, a
53
- * MIDI message, an imperative call.
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()
@@ -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
+ }
@@ -324,8 +324,10 @@ export function createDragValue(
324
324
  if (!position) return
325
325
  active = true
326
326
  lastValue = valueOf(position)
327
- if (opts.updateOnPointerDown) opts.onChange?.(lastValue, state)
327
+ // Reported before the value it pressed: whoever records the drag hears
328
+ // that it began before the first value of it arrives.
328
329
  opts.onDragStart?.(lastValue, state)
330
+ if (opts.updateOnPointerDown) opts.onChange?.(lastValue, state)
329
331
  },
330
332
  onDrag: (state) => {
331
333
  if (!active) return
@@ -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
+ }
@@ -1,6 +1,10 @@
1
1
  export interface WheelOptions {
2
- /** Replace the callback through {@link WheelInstance.update}. */
3
- onWheel?: (event: WheelEvent) => void
2
+ /**
3
+ * Called for every wheel event that `requireFocus` lets through. The
4
+ * listener is not passive, so this may call `preventDefault()` to keep the
5
+ * page from scrolling.
6
+ */
7
+ onWheel: (event: WheelEvent) => void
4
8
  /**
5
9
  * Only report events while the focus is inside the element.
6
10
  *
@@ -19,7 +23,7 @@ export interface WheelOptions {
19
23
 
20
24
  export interface WheelInstance {
21
25
  /** Replace the given options, keeping the listener in place. */
22
- update: (options: WheelOptions) => void
26
+ update: (options: Partial<WheelOptions>) => void
23
27
  destroy: () => void
24
28
  }
25
29
 
@@ -31,10 +35,9 @@ export interface WheelInstance {
31
35
  */
32
36
  export function createWheel(
33
37
  element: Element,
34
- onWheel: (event: WheelEvent) => void,
35
- options: WheelOptions = {},
38
+ options: WheelOptions,
36
39
  ): WheelInstance {
37
- let opts = { ...options, onWheel }
40
+ let opts = options
38
41
 
39
42
  function hasFocus() {
40
43
  const active = element.ownerDocument?.activeElement
@@ -43,7 +46,7 @@ export function createWheel(
43
46
 
44
47
  const handler = (event: Event) => {
45
48
  if (opts.requireFocus && !hasFocus()) return
46
- opts.onWheel?.(event as WheelEvent)
49
+ opts.onWheel(event as WheelEvent)
47
50
  }
48
51
 
49
52
  element.addEventListener('wheel', handler, { passive: false })