@tremolo-ui/dom 0.3.0 → 0.4.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 CHANGED
@@ -97,10 +97,147 @@ function createMIDIInput(midiAccess, { onNoteOnEvent, onNoteOffEvent, onPitchBen
97
97
  });
98
98
  }
99
99
  //#endregion
100
+ //#region src/pointer/drag.ts
101
+ /**
102
+ * Applied to the element for the lifetime of the instance.
103
+ *
104
+ * `touch-action` stops touch dragging from scrolling the page. That alone makes
105
+ * the browser treat a long press as the start of a text selection, so selection
106
+ * and the iOS callout are suppressed here too.
107
+ */
108
+ const MANAGED_STYLES = [
109
+ ["touch-action", "none"],
110
+ ["user-select", "none"],
111
+ ["-webkit-user-select", "none"],
112
+ ["-webkit-touch-callout", "none"]
113
+ ];
114
+ /**
115
+ * Track a pointer drag on an element.
116
+ *
117
+ * Uses Pointer Events only, and pointer capture so that the drag keeps working
118
+ * once the pointer leaves the element. For the lifetime of the instance the
119
+ * element gets `touch-action: none` so that touch dragging does not scroll the
120
+ * page, plus `user-select: none` so that a long press does not start a text
121
+ * selection instead.
122
+ */
123
+ function createDrag(element, { threshold: _threshold = 1, cursor, onDragStart, onDrag, onDragEnd } = {}) {
124
+ const threshold = Math.max(_threshold, 1);
125
+ const capture = element;
126
+ const style = element.style;
127
+ const previousStyles = /* @__PURE__ */ new Map();
128
+ if (style) for (const [property, value] of MANAGED_STYLES) {
129
+ previousStyles.set(property, style.getPropertyValue(property));
130
+ style.setProperty(property, value);
131
+ }
132
+ let pointerId = null;
133
+ /** Where the listeners for the current drag live. */
134
+ let moveTarget = null;
135
+ let startX = 0;
136
+ let startY = 0;
137
+ let lastX = 0;
138
+ let lastY = 0;
139
+ let previousCursor;
140
+ function state(event, deltaX, deltaY) {
141
+ return {
142
+ x: event.screenX - startX,
143
+ y: event.screenY - startY,
144
+ deltaX,
145
+ deltaY,
146
+ clientX: event.clientX,
147
+ clientY: event.clientY,
148
+ event
149
+ };
150
+ }
151
+ /**
152
+ * `user-select: none` only makes the element's own text unselectable; the
153
+ * browser still starts a selection on long press and grabs whatever text it
154
+ * finds nearby. Cancelling the selection outright is what actually stops it.
155
+ */
156
+ function preventSelectStart(event) {
157
+ event.preventDefault();
158
+ }
159
+ function handlePointerDown(event) {
160
+ const pointerEvent = event;
161
+ if (pointerId !== null) return;
162
+ pointerId = pointerEvent.pointerId;
163
+ startX = lastX = pointerEvent.screenX;
164
+ startY = lastY = pointerEvent.screenY;
165
+ if (cursor && style) {
166
+ previousCursor = style.cursor;
167
+ style.cursor = cursor;
168
+ }
169
+ capture.setPointerCapture?.(pointerId);
170
+ moveTarget = capture.hasPointerCapture?.(pointerId) === true ? element : globalThis.window ?? element;
171
+ moveTarget.addEventListener("pointermove", handlePointerMove);
172
+ moveTarget.addEventListener("pointerup", handlePointerUp);
173
+ moveTarget.addEventListener("pointercancel", handlePointerUp);
174
+ globalThis.document?.addEventListener("selectstart", preventSelectStart);
175
+ onDragStart?.(state(pointerEvent, 0, 0));
176
+ }
177
+ function handlePointerMove(event) {
178
+ const pointerEvent = event;
179
+ if (pointerEvent.pointerId !== pointerId) return;
180
+ const deltaX = pointerEvent.screenX - lastX;
181
+ const deltaY = pointerEvent.screenY - lastY;
182
+ lastX = pointerEvent.screenX;
183
+ lastY = pointerEvent.screenY;
184
+ if (Math.abs(deltaX) < threshold && Math.abs(deltaY) < threshold) return;
185
+ onDrag?.(state(pointerEvent, deltaX, deltaY));
186
+ }
187
+ function handlePointerUp(event) {
188
+ const pointerEvent = event;
189
+ if (pointerEvent.pointerId !== pointerId) return;
190
+ const finalState = state(pointerEvent, pointerEvent.screenX - lastX, pointerEvent.screenY - lastY);
191
+ stopTracking();
192
+ onDragEnd?.(finalState);
193
+ }
194
+ function stopTracking() {
195
+ if (pointerId === null) return;
196
+ capture.releasePointerCapture?.(pointerId);
197
+ moveTarget?.removeEventListener("pointermove", handlePointerMove);
198
+ moveTarget?.removeEventListener("pointerup", handlePointerUp);
199
+ moveTarget?.removeEventListener("pointercancel", handlePointerUp);
200
+ globalThis.document?.removeEventListener("selectstart", preventSelectStart);
201
+ if (cursor && style) {
202
+ style.cursor = previousCursor ?? "";
203
+ previousCursor = void 0;
204
+ }
205
+ moveTarget = null;
206
+ pointerId = null;
207
+ }
208
+ element.addEventListener("pointerdown", handlePointerDown);
209
+ return { destroy: () => {
210
+ stopTracking();
211
+ element.removeEventListener("pointerdown", handlePointerDown);
212
+ if (style) for (const [property] of MANAGED_STYLES) {
213
+ const previous = previousStyles.get(property);
214
+ if (previous) style.setProperty(property, previous);
215
+ else style.removeProperty(property);
216
+ }
217
+ } };
218
+ }
219
+ //#endregion
220
+ //#region src/pointer/wheel.ts
221
+ /**
222
+ * Listen to wheel events on an element.
223
+ *
224
+ * The listener is registered with `passive: false` so that the handler can call
225
+ * `preventDefault()` to stop the page from scrolling.
226
+ */
227
+ function createWheel(element, onWheel) {
228
+ const handler = (event) => onWheel(event);
229
+ element.addEventListener("wheel", handler, { passive: false });
230
+ return { destroy: () => {
231
+ element.removeEventListener("wheel", handler);
232
+ } };
233
+ }
234
+ //#endregion
100
235
  exports.NOT_SUPPORTED = NOT_SUPPORTED;
101
236
  exports.PERMISSION_DENIED = PERMISSION_DENIED;
237
+ exports.createDrag = createDrag;
102
238
  exports.createMIDIAccess = createMIDIAccess;
103
239
  exports.createMIDIInput = createMIDIInput;
104
240
  exports.createMIDIMessage = createMIDIMessage;
241
+ exports.createWheel = createWheel;
105
242
 
106
243
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":[],"sources":["../src/midi/access.ts","../src/midi/message.ts","../src/midi/input.ts"],"sourcesContent":["/** @private */\nexport const PERMISSION_DENIED = 'PERMISSION_DENIED'\n/** @private */\nexport const NOT_SUPPORTED = 'NOT_SUPPORTED'\n\n/** @private */\nexport type MIDIAccessError = typeof PERMISSION_DENIED | typeof NOT_SUPPORTED\n\nexport type MIDIAccessState = {\n readonly midiAccess: MIDIAccess | null\n readonly error: MIDIAccessError | null\n}\n\nconst INITIAL_STATE: MIDIAccessState = {\n midiAccess: null,\n error: null,\n}\n\nexport interface MIDIAccessInstance {\n /** Request MIDI access. Safe to call more than once. */\n request: () => void\n getState: () => MIDIAccessState\n /** Snapshot for server side rendering. Always the initial state. */\n getServerState: () => MIDIAccessState\n /** @returns unsubscribe function */\n subscribe: (listener: () => void) => () => void\n destroy: () => void\n}\n\n/**\n * Request MIDI access in the browser.\n *\n * The returned instance holds the state and notifies subscribers when it changes,\n * so it can be consumed from any framework.\n */\nexport function createMIDIAccess(): MIDIAccessInstance {\n let state = INITIAL_STATE\n let destroyed = false\n const listeners = new Set<() => void>()\n\n function setState(next: MIDIAccessState) {\n state = next\n for (const listener of listeners) listener()\n }\n\n function request() {\n if (destroyed) return\n if (typeof navigator === 'undefined' || !navigator.requestMIDIAccess) {\n setState({ ...state, error: NOT_SUPPORTED })\n return\n }\n navigator\n .requestMIDIAccess()\n .then((access) => {\n if (destroyed) return\n setState({ midiAccess: access, error: null })\n })\n .catch(() => {\n if (destroyed) return\n setState({ ...state, error: PERMISSION_DENIED })\n })\n }\n\n return {\n request,\n getState: () => state,\n getServerState: () => INITIAL_STATE,\n subscribe: (listener) => {\n listeners.add(listener)\n return () => {\n listeners.delete(listener)\n }\n },\n destroy: () => {\n destroyed = true\n listeners.clear()\n },\n }\n}\n","export interface MIDIMessageInstance {\n destroy: () => void\n}\n\n/**\n * Listen to raw `midimessage` events on every input of a MIDIAccess.\n *\n * Use this when you need more detail than {@link createMIDIInput} provides.\n */\nexport function createMIDIMessage(\n midiAccess: MIDIAccess | null,\n onMIDIMessage: (event: MIDIMessageEvent) => void,\n): MIDIMessageInstance {\n if (!midiAccess) {\n return { destroy: () => {} }\n }\n\n const inputs = [...midiAccess.inputs.values()]\n for (const input of inputs) {\n input.addEventListener('midimessage', onMIDIMessage)\n }\n\n return {\n destroy: () => {\n for (const input of inputs) {\n input.removeEventListener('midimessage', onMIDIMessage)\n }\n },\n }\n}\n","import { createMIDIMessage, type MIDIMessageInstance } from './message'\n\nconst MIDI_EVENT_TO_NUMBER = {\n NOTE_ON: 0x90,\n NOTE_OFF: 0x80,\n PITCH_BEND: 0xe0,\n}\n\nexport type MIDIInputHandlers = {\n onNoteOnEvent?: (note: number, velocity: number) => void\n onNoteOffEvent?: (note: number) => void\n onPitchBendEvent?: (msb: number, lsb: number) => void\n}\n\nexport type MIDIInputInstance = MIDIMessageInstance\n\n/**\n * Handle note on/off and pitch bend events. To be used with {@link createMIDIAccess}.\n * Internally uses {@link createMIDIMessage}.\n */\nexport function createMIDIInput(\n midiAccess: MIDIAccess | null,\n { onNoteOnEvent, onNoteOffEvent, onPitchBendEvent }: MIDIInputHandlers,\n): MIDIInputInstance {\n return createMIDIMessage(midiAccess, (event) => {\n if (!event.data) return\n // event.data[0] ... command\n // event.data[1] ... note, MSB (Most Significant Byte)\n // event.data[2] ... velocity, LSB (Least Significant Byte)\n const kind = event.data[0] & 0xf0\n\n if (\n kind == MIDI_EVENT_TO_NUMBER.NOTE_OFF ||\n (kind == MIDI_EVENT_TO_NUMBER.NOTE_ON && event.data[2] == 0)\n ) {\n onNoteOffEvent?.(event.data[1])\n } else if (kind == MIDI_EVENT_TO_NUMBER.NOTE_ON) {\n onNoteOnEvent?.(event.data[1], event.data[2])\n } else if (kind == MIDI_EVENT_TO_NUMBER.PITCH_BEND) {\n onPitchBendEvent?.(event.data[1], event.data[2])\n }\n })\n}\n"],"mappings":";;;AACA,MAAa,oBAAoB;;AAEjC,MAAa,gBAAgB;AAU7B,MAAM,gBAAiC;CACrC,YAAY;CACZ,OAAO;CACR;;;;;;;AAmBD,SAAgB,mBAAuC;CACrD,IAAI,QAAQ;CACZ,IAAI,YAAY;CAChB,MAAM,4BAAY,IAAI,KAAiB;CAEvC,SAAS,SAAS,MAAuB;AACvC,UAAQ;AACR,OAAK,MAAM,YAAY,UAAW,WAAU;;CAG9C,SAAS,UAAU;AACjB,MAAI,UAAW;AACf,MAAI,OAAO,cAAc,eAAe,CAAC,UAAU,mBAAmB;AACpE,YAAS;IAAE,GAAG;IAAO,OAAO;IAAe,CAAC;AAC5C;;AAEF,YACG,mBAAmB,CACnB,MAAM,WAAW;AAChB,OAAI,UAAW;AACf,YAAS;IAAE,YAAY;IAAQ,OAAO;IAAM,CAAC;IAC7C,CACD,YAAY;AACX,OAAI,UAAW;AACf,YAAS;IAAE,GAAG;IAAO,OAAO;IAAmB,CAAC;IAChD;;AAGN,QAAO;EACL;EACA,gBAAgB;EAChB,sBAAsB;EACtB,YAAY,aAAa;AACvB,aAAU,IAAI,SAAS;AACvB,gBAAa;AACX,cAAU,OAAO,SAAS;;;EAG9B,eAAe;AACb,eAAY;AACZ,aAAU,OAAO;;EAEpB;;;;;;;;;ACpEH,SAAgB,kBACd,YACA,eACqB;AACrB,KAAI,CAAC,WACH,QAAO,EAAE,eAAe,IAAI;CAG9B,MAAM,SAAS,CAAC,GAAG,WAAW,OAAO,QAAQ,CAAC;AAC9C,MAAK,MAAM,SAAS,OAClB,OAAM,iBAAiB,eAAe,cAAc;AAGtD,QAAO,EACL,eAAe;AACb,OAAK,MAAM,SAAS,OAClB,OAAM,oBAAoB,eAAe,cAAc;IAG5D;;;;AC1BH,MAAM,uBAAuB;CAC3B,SAAS;CACT,UAAU;CACV,YAAY;CACb;;;;;AAcD,SAAgB,gBACd,YACA,EAAE,eAAe,gBAAgB,oBACd;AACnB,QAAO,kBAAkB,aAAa,UAAU;AAC9C,MAAI,CAAC,MAAM,KAAM;EAIjB,MAAM,OAAO,MAAM,KAAK,KAAK;AAE7B,MACE,QAAQ,qBAAqB,YAC5B,QAAQ,qBAAqB,WAAW,MAAM,KAAK,MAAM,EAE1D,kBAAiB,MAAM,KAAK,GAAG;WACtB,QAAQ,qBAAqB,QACtC,iBAAgB,MAAM,KAAK,IAAI,MAAM,KAAK,GAAG;WACpC,QAAQ,qBAAqB,WACtC,oBAAmB,MAAM,KAAK,IAAI,MAAM,KAAK,GAAG;GAElD"}
1
+ {"version":3,"file":"index.cjs","names":[],"sources":["../src/midi/access.ts","../src/midi/message.ts","../src/midi/input.ts","../src/pointer/drag.ts","../src/pointer/wheel.ts"],"sourcesContent":["/** @private */\nexport const PERMISSION_DENIED = 'PERMISSION_DENIED'\n/** @private */\nexport const NOT_SUPPORTED = 'NOT_SUPPORTED'\n\n/** @private */\nexport type MIDIAccessError = typeof PERMISSION_DENIED | typeof NOT_SUPPORTED\n\nexport type MIDIAccessState = {\n readonly midiAccess: MIDIAccess | null\n readonly error: MIDIAccessError | null\n}\n\nconst INITIAL_STATE: MIDIAccessState = {\n midiAccess: null,\n error: null,\n}\n\nexport interface MIDIAccessInstance {\n /** Request MIDI access. Safe to call more than once. */\n request: () => void\n getState: () => MIDIAccessState\n /** Snapshot for server side rendering. Always the initial state. */\n getServerState: () => MIDIAccessState\n /** @returns unsubscribe function */\n subscribe: (listener: () => void) => () => void\n destroy: () => void\n}\n\n/**\n * Request MIDI access in the browser.\n *\n * The returned instance holds the state and notifies subscribers when it changes,\n * so it can be consumed from any framework.\n */\nexport function createMIDIAccess(): MIDIAccessInstance {\n let state = INITIAL_STATE\n let destroyed = false\n const listeners = new Set<() => void>()\n\n function setState(next: MIDIAccessState) {\n state = next\n for (const listener of listeners) listener()\n }\n\n function request() {\n if (destroyed) return\n if (typeof navigator === 'undefined' || !navigator.requestMIDIAccess) {\n setState({ ...state, error: NOT_SUPPORTED })\n return\n }\n navigator\n .requestMIDIAccess()\n .then((access) => {\n if (destroyed) return\n setState({ midiAccess: access, error: null })\n })\n .catch(() => {\n if (destroyed) return\n setState({ ...state, error: PERMISSION_DENIED })\n })\n }\n\n return {\n request,\n getState: () => state,\n getServerState: () => INITIAL_STATE,\n subscribe: (listener) => {\n listeners.add(listener)\n return () => {\n listeners.delete(listener)\n }\n },\n destroy: () => {\n destroyed = true\n listeners.clear()\n },\n }\n}\n","export interface MIDIMessageInstance {\n destroy: () => void\n}\n\n/**\n * Listen to raw `midimessage` events on every input of a MIDIAccess.\n *\n * Use this when you need more detail than {@link createMIDIInput} provides.\n */\nexport function createMIDIMessage(\n midiAccess: MIDIAccess | null,\n onMIDIMessage: (event: MIDIMessageEvent) => void,\n): MIDIMessageInstance {\n if (!midiAccess) {\n return { destroy: () => {} }\n }\n\n const inputs = [...midiAccess.inputs.values()]\n for (const input of inputs) {\n input.addEventListener('midimessage', onMIDIMessage)\n }\n\n return {\n destroy: () => {\n for (const input of inputs) {\n input.removeEventListener('midimessage', onMIDIMessage)\n }\n },\n }\n}\n","import { createMIDIMessage, type MIDIMessageInstance } from './message'\n\nconst MIDI_EVENT_TO_NUMBER = {\n NOTE_ON: 0x90,\n NOTE_OFF: 0x80,\n PITCH_BEND: 0xe0,\n}\n\nexport type MIDIInputHandlers = {\n onNoteOnEvent?: (note: number, velocity: number) => void\n onNoteOffEvent?: (note: number) => void\n onPitchBendEvent?: (msb: number, lsb: number) => void\n}\n\nexport type MIDIInputInstance = MIDIMessageInstance\n\n/**\n * Handle note on/off and pitch bend events. To be used with {@link createMIDIAccess}.\n * Internally uses {@link createMIDIMessage}.\n */\nexport function createMIDIInput(\n midiAccess: MIDIAccess | null,\n { onNoteOnEvent, onNoteOffEvent, onPitchBendEvent }: MIDIInputHandlers,\n): MIDIInputInstance {\n return createMIDIMessage(midiAccess, (event) => {\n if (!event.data) return\n // event.data[0] ... command\n // event.data[1] ... note, MSB (Most Significant Byte)\n // event.data[2] ... velocity, LSB (Least Significant Byte)\n const kind = event.data[0] & 0xf0\n\n if (\n kind == MIDI_EVENT_TO_NUMBER.NOTE_OFF ||\n (kind == MIDI_EVENT_TO_NUMBER.NOTE_ON && event.data[2] == 0)\n ) {\n onNoteOffEvent?.(event.data[1])\n } else if (kind == MIDI_EVENT_TO_NUMBER.NOTE_ON) {\n onNoteOnEvent?.(event.data[1], event.data[2])\n } else if (kind == MIDI_EVENT_TO_NUMBER.PITCH_BEND) {\n onPitchBendEvent?.(event.data[1], event.data[2])\n }\n })\n}\n","export type DragState = {\n /** Total movement from the drag start, in screen coordinates. */\n x: number\n y: number\n /** Movement since the previous event, in screen coordinates. */\n deltaX: number\n deltaY: number\n /** Pointer position in viewport coordinates. */\n clientX: number\n clientY: number\n event: PointerEvent\n}\n\nexport type DragOptions = {\n /**\n * Minimum movement in pixels before `onDrag` fires.\n * Prevents `onDrag` from firing on, for example, a double click.\n * Values below 1 are clamped to 1.\n *\n * @default 1\n */\n threshold?: number\n\n /**\n * CSS cursor to show while dragging. Applied to the element itself: pointer\n * capture keeps it in effect even once the pointer leaves the element, so\n * there is no need to touch the document.\n */\n cursor?: string\n\n onDragStart?: (state: DragState) => void\n onDrag?: (state: DragState) => void\n onDragEnd?: (state: DragState) => void\n}\n\nexport interface DragInstance {\n destroy: () => void\n}\n\n/**\n * Applied to the element for the lifetime of the instance.\n *\n * `touch-action` stops touch dragging from scrolling the page. That alone makes\n * the browser treat a long press as the start of a text selection, so selection\n * and the iOS callout are suppressed here too.\n */\nconst MANAGED_STYLES = [\n ['touch-action', 'none'],\n ['user-select', 'none'],\n ['-webkit-user-select', 'none'],\n ['-webkit-touch-callout', 'none'],\n] as const\n\ntype CaptureTarget = {\n setPointerCapture?: (pointerId: number) => void\n releasePointerCapture?: (pointerId: number) => void\n hasPointerCapture?: (pointerId: number) => boolean\n}\n\n/**\n * Track a pointer drag on an element.\n *\n * Uses Pointer Events only, and pointer capture so that the drag keeps working\n * once the pointer leaves the element. For the lifetime of the instance the\n * element gets `touch-action: none` so that touch dragging does not scroll the\n * page, plus `user-select: none` so that a long press does not start a text\n * selection instead.\n */\nexport function createDrag(\n element: Element,\n {\n threshold: _threshold = 1,\n cursor,\n onDragStart,\n onDrag,\n onDragEnd,\n }: DragOptions = {},\n): DragInstance {\n const threshold = Math.max(_threshold, 1)\n const capture = element as CaptureTarget\n const style = (element as Partial<HTMLElement>).style\n\n const previousStyles = new Map<string, string>()\n if (style) {\n for (const [property, value] of MANAGED_STYLES) {\n previousStyles.set(property, style.getPropertyValue(property))\n style.setProperty(property, value)\n }\n }\n\n let pointerId: number | null = null\n /** Where the listeners for the current drag live. */\n let moveTarget: EventTarget | null = null\n let startX = 0\n let startY = 0\n let lastX = 0\n let lastY = 0\n let previousCursor: string | undefined\n\n function state(\n event: PointerEvent,\n deltaX: number,\n deltaY: number,\n ): DragState {\n return {\n x: event.screenX - startX,\n y: event.screenY - startY,\n deltaX,\n deltaY,\n clientX: event.clientX,\n clientY: event.clientY,\n event,\n }\n }\n\n /**\n * `user-select: none` only makes the element's own text unselectable; the\n * browser still starts a selection on long press and grabs whatever text it\n * finds nearby. Cancelling the selection outright is what actually stops it.\n */\n function preventSelectStart(event: Event) {\n event.preventDefault()\n }\n\n function handlePointerDown(event: Event) {\n const pointerEvent = event as PointerEvent\n // Only one pointer drives the drag; ignore additional touches.\n if (pointerId !== null) return\n\n pointerId = pointerEvent.pointerId\n startX = lastX = pointerEvent.screenX\n startY = lastY = pointerEvent.screenY\n\n if (cursor && style) {\n previousCursor = style.cursor\n style.cursor = cursor\n }\n\n capture.setPointerCapture?.(pointerId)\n // With pointer capture the element receives the rest of the gesture.\n // Without it (older engines, jsdom) fall back to the window.\n moveTarget =\n capture.hasPointerCapture?.(pointerId) === true\n ? element\n : (globalThis.window ?? element)\n\n moveTarget.addEventListener('pointermove', handlePointerMove)\n moveTarget.addEventListener('pointerup', handlePointerUp)\n moveTarget.addEventListener('pointercancel', handlePointerUp)\n globalThis.document?.addEventListener('selectstart', preventSelectStart)\n\n onDragStart?.(state(pointerEvent, 0, 0))\n }\n\n function handlePointerMove(event: Event) {\n const pointerEvent = event as PointerEvent\n if (pointerEvent.pointerId !== pointerId) return\n\n const deltaX = pointerEvent.screenX - lastX\n const deltaY = pointerEvent.screenY - lastY\n lastX = pointerEvent.screenX\n lastY = pointerEvent.screenY\n\n // Movement below the threshold is dropped rather than accumulated.\n if (Math.abs(deltaX) < threshold && Math.abs(deltaY) < threshold) return\n\n onDrag?.(state(pointerEvent, deltaX, deltaY))\n }\n\n function handlePointerUp(event: Event) {\n const pointerEvent = event as PointerEvent\n if (pointerEvent.pointerId !== pointerId) return\n\n const deltaX = pointerEvent.screenX - lastX\n const deltaY = pointerEvent.screenY - lastY\n const finalState = state(pointerEvent, deltaX, deltaY)\n\n stopTracking()\n onDragEnd?.(finalState)\n }\n\n function stopTracking() {\n if (pointerId === null) return\n capture.releasePointerCapture?.(pointerId)\n moveTarget?.removeEventListener('pointermove', handlePointerMove)\n moveTarget?.removeEventListener('pointerup', handlePointerUp)\n moveTarget?.removeEventListener('pointercancel', handlePointerUp)\n globalThis.document?.removeEventListener('selectstart', preventSelectStart)\n if (cursor && style) {\n style.cursor = previousCursor ?? ''\n previousCursor = undefined\n }\n moveTarget = null\n pointerId = null\n }\n\n element.addEventListener('pointerdown', handlePointerDown)\n\n return {\n destroy: () => {\n stopTracking()\n element.removeEventListener('pointerdown', handlePointerDown)\n if (style) {\n for (const [property] of MANAGED_STYLES) {\n const previous = previousStyles.get(property)\n if (previous) {\n style.setProperty(property, previous)\n } else {\n style.removeProperty(property)\n }\n }\n }\n },\n }\n}\n","export interface WheelInstance {\n destroy: () => void\n}\n\n/**\n * Listen to wheel events on an element.\n *\n * The listener is registered with `passive: false` so that the handler can call\n * `preventDefault()` to stop the page from scrolling.\n */\nexport function createWheel(\n element: Element,\n onWheel: (event: WheelEvent) => void,\n): WheelInstance {\n const handler = (event: Event) => onWheel(event as WheelEvent)\n\n element.addEventListener('wheel', handler, { passive: false })\n\n return {\n destroy: () => {\n // Only `capture` matters when removing, and it is false here.\n element.removeEventListener('wheel', handler)\n },\n }\n}\n"],"mappings":"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"}
package/dist/index.d.cts CHANGED
@@ -55,5 +55,66 @@ declare function createMIDIInput(midiAccess: MIDIAccess | null, {
55
55
  onPitchBendEvent
56
56
  }: MIDIInputHandlers): MIDIInputInstance;
57
57
  //#endregion
58
- export { type MIDIAccessError, type MIDIAccessInstance, type MIDIAccessState, type MIDIInputHandlers, type MIDIInputInstance, type MIDIMessageInstance, NOT_SUPPORTED, PERMISSION_DENIED, createMIDIAccess, createMIDIInput, createMIDIMessage };
58
+ //#region src/pointer/drag.d.ts
59
+ type DragState = {
60
+ /** Total movement from the drag start, in screen coordinates. */x: number;
61
+ y: number; /** Movement since the previous event, in screen coordinates. */
62
+ deltaX: number;
63
+ deltaY: number; /** Pointer position in viewport coordinates. */
64
+ clientX: number;
65
+ clientY: number;
66
+ event: PointerEvent;
67
+ };
68
+ type DragOptions = {
69
+ /**
70
+ * Minimum movement in pixels before `onDrag` fires.
71
+ * Prevents `onDrag` from firing on, for example, a double click.
72
+ * Values below 1 are clamped to 1.
73
+ *
74
+ * @default 1
75
+ */
76
+ threshold?: number;
77
+ /**
78
+ * CSS cursor to show while dragging. Applied to the element itself: pointer
79
+ * capture keeps it in effect even once the pointer leaves the element, so
80
+ * there is no need to touch the document.
81
+ */
82
+ cursor?: string;
83
+ onDragStart?: (state: DragState) => void;
84
+ onDrag?: (state: DragState) => void;
85
+ onDragEnd?: (state: DragState) => void;
86
+ };
87
+ interface DragInstance {
88
+ destroy: () => void;
89
+ }
90
+ /**
91
+ * Track a pointer drag on an element.
92
+ *
93
+ * Uses Pointer Events only, and pointer capture so that the drag keeps working
94
+ * once the pointer leaves the element. For the lifetime of the instance the
95
+ * element gets `touch-action: none` so that touch dragging does not scroll the
96
+ * page, plus `user-select: none` so that a long press does not start a text
97
+ * selection instead.
98
+ */
99
+ declare function createDrag(element: Element, {
100
+ threshold: _threshold,
101
+ cursor,
102
+ onDragStart,
103
+ onDrag,
104
+ onDragEnd
105
+ }?: DragOptions): DragInstance;
106
+ //#endregion
107
+ //#region src/pointer/wheel.d.ts
108
+ interface WheelInstance {
109
+ destroy: () => void;
110
+ }
111
+ /**
112
+ * Listen to wheel events on an element.
113
+ *
114
+ * The listener is registered with `passive: false` so that the handler can call
115
+ * `preventDefault()` to stop the page from scrolling.
116
+ */
117
+ declare function createWheel(element: Element, onWheel: (event: WheelEvent) => void): WheelInstance;
118
+ //#endregion
119
+ export { type DragInstance, type DragOptions, type DragState, type MIDIAccessError, type MIDIAccessInstance, type MIDIAccessState, type MIDIInputHandlers, type MIDIInputInstance, type MIDIMessageInstance, NOT_SUPPORTED, PERMISSION_DENIED, type WheelInstance, createDrag, createMIDIAccess, createMIDIInput, createMIDIMessage, createWheel };
59
120
  //# sourceMappingURL=index.d.cts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.cts","names":[],"sources":["../src/midi/access.ts","../src/midi/message.ts","../src/midi/input.ts"],"mappings":";;cACa,iBAAA;;cAEA,aAAA;;KAGD,eAAA,UAAyB,iBAAA,UAA2B,aAAA;AAAA,KAEpD,eAAA;EAAA,SACD,UAAA,EAAY,UAAA;EAAA,SACZ,KAAA,EAAO,eAAA;AAAA;AAAA,UAQD,kBAAA;EAZL;EAcV,OAAA;EACA,QAAA,QAAgB,eAAA;EAfmB;EAiBnC,cAAA,QAAsB,eAAA;EAfZ;EAiBV,SAAA,GAAY,QAAA;EACZ,OAAA;AAAA;;;;;;;iBASc,gBAAA,CAAA,GAAoB,kBAAA;;;UCnCnB,mBAAA;EACf,OAAA;AAAA;;;;ADEF;;iBCMgB,iBAAA,CACd,UAAA,EAAY,UAAA,SACZ,aAAA,GAAgB,KAAA,EAAO,gBAAA,YACtB,mBAAA;;;KCJS,iBAAA;EACV,aAAA,IAAiB,IAAA,UAAc,QAAA;EAC/B,cAAA,IAAkB,IAAA;EAClB,gBAAA,IAAoB,GAAA,UAAa,GAAA;AAAA;AAAA,KAGvB,iBAAA,GAAoB,mBAAA;;;;;iBAMhB,eAAA,CACd,UAAA,EAAY,UAAA;EACV,aAAA;EAAe,cAAA;EAAgB;AAAA,GAAoB,iBAAA,GACpD,iBAAA"}
1
+ {"version":3,"file":"index.d.cts","names":[],"sources":["../src/midi/access.ts","../src/midi/message.ts","../src/midi/input.ts","../src/pointer/drag.ts","../src/pointer/wheel.ts"],"mappings":";;cACa,iBAAA;;cAEA,aAAA;;KAGD,eAAA,UAAyB,iBAAA,UAA2B,aAAA;AAAA,KAEpD,eAAA;EAAA,SACD,UAAA,EAAY,UAAA;EAAA,SACZ,KAAA,EAAO,eAAA;AAAA;AAAA,UAQD,kBAAA;EAZL;EAcV,OAAA;EACA,QAAA,QAAgB,eAAA;EAfmB;EAiBnC,cAAA,QAAsB,eAAA;EAfZ;EAiBV,SAAA,GAAY,QAAA;EACZ,OAAA;AAAA;;;;;;;iBASc,gBAAA,CAAA,GAAoB,kBAAA;;;UCnCnB,mBAAA;EACf,OAAA;AAAA;;;;ADEF;;iBCMgB,iBAAA,CACd,UAAA,EAAY,UAAA,SACZ,aAAA,GAAgB,KAAA,EAAO,gBAAA,YACtB,mBAAA;;;KCJS,iBAAA;EACV,aAAA,IAAiB,IAAA,UAAc,QAAA;EAC/B,cAAA,IAAkB,IAAA;EAClB,gBAAA,IAAoB,GAAA,UAAa,GAAA;AAAA;AAAA,KAGvB,iBAAA,GAAoB,mBAAA;;;;;iBAMhB,eAAA,CACd,UAAA,EAAY,UAAA;EACV,aAAA;EAAe,cAAA;EAAgB;AAAA,GAAoB,iBAAA,GACpD,iBAAA;;;KCvBS,SAAA;EHCC,iEGCX,CAAA;EACA,CAAA,UHF4B;EGI5B,MAAA;EACA,MAAA,UHHwB;EGKxB,OAAA;EACA,OAAA;EACA,KAAA,EAAO,YAAA;AAAA;AAAA,KAGG,WAAA;;;;AHLZ;;;;EGaE,SAAA;EHZqB;;;;;EGmBrB,MAAA;EAEA,WAAA,IAAe,KAAA,EAAO,SAAA;EACtB,MAAA,IAAU,KAAA,EAAO,SAAA;EACjB,SAAA,IAAa,KAAA,EAAO,SAAA;AAAA;AAAA,UAGL,YAAA;EACf,OAAA;AAAA;;;;;;;AHDF;;;iBGiCgB,UAAA,CACd,OAAA,EAAS,OAAA;EAEP,SAAA,EAAW,UAAA;EACX,MAAA;EACA,WAAA;EACA,MAAA;EACA;AAAA,IACC,WAAA,GACF,YAAA;;;UC7Ec,aAAA;EACf,OAAA;AAAA;;;;AJEF;;;iBIOgB,WAAA,CACd,OAAA,EAAS,OAAA,EACT,OAAA,GAAU,KAAA,EAAO,UAAA,YAChB,aAAA"}
package/dist/index.d.ts CHANGED
@@ -55,5 +55,66 @@ declare function createMIDIInput(midiAccess: MIDIAccess | null, {
55
55
  onPitchBendEvent
56
56
  }: MIDIInputHandlers): MIDIInputInstance;
57
57
  //#endregion
58
- export { type MIDIAccessError, type MIDIAccessInstance, type MIDIAccessState, type MIDIInputHandlers, type MIDIInputInstance, type MIDIMessageInstance, NOT_SUPPORTED, PERMISSION_DENIED, createMIDIAccess, createMIDIInput, createMIDIMessage };
58
+ //#region src/pointer/drag.d.ts
59
+ type DragState = {
60
+ /** Total movement from the drag start, in screen coordinates. */x: number;
61
+ y: number; /** Movement since the previous event, in screen coordinates. */
62
+ deltaX: number;
63
+ deltaY: number; /** Pointer position in viewport coordinates. */
64
+ clientX: number;
65
+ clientY: number;
66
+ event: PointerEvent;
67
+ };
68
+ type DragOptions = {
69
+ /**
70
+ * Minimum movement in pixels before `onDrag` fires.
71
+ * Prevents `onDrag` from firing on, for example, a double click.
72
+ * Values below 1 are clamped to 1.
73
+ *
74
+ * @default 1
75
+ */
76
+ threshold?: number;
77
+ /**
78
+ * CSS cursor to show while dragging. Applied to the element itself: pointer
79
+ * capture keeps it in effect even once the pointer leaves the element, so
80
+ * there is no need to touch the document.
81
+ */
82
+ cursor?: string;
83
+ onDragStart?: (state: DragState) => void;
84
+ onDrag?: (state: DragState) => void;
85
+ onDragEnd?: (state: DragState) => void;
86
+ };
87
+ interface DragInstance {
88
+ destroy: () => void;
89
+ }
90
+ /**
91
+ * Track a pointer drag on an element.
92
+ *
93
+ * Uses Pointer Events only, and pointer capture so that the drag keeps working
94
+ * once the pointer leaves the element. For the lifetime of the instance the
95
+ * element gets `touch-action: none` so that touch dragging does not scroll the
96
+ * page, plus `user-select: none` so that a long press does not start a text
97
+ * selection instead.
98
+ */
99
+ declare function createDrag(element: Element, {
100
+ threshold: _threshold,
101
+ cursor,
102
+ onDragStart,
103
+ onDrag,
104
+ onDragEnd
105
+ }?: DragOptions): DragInstance;
106
+ //#endregion
107
+ //#region src/pointer/wheel.d.ts
108
+ interface WheelInstance {
109
+ destroy: () => void;
110
+ }
111
+ /**
112
+ * Listen to wheel events on an element.
113
+ *
114
+ * The listener is registered with `passive: false` so that the handler can call
115
+ * `preventDefault()` to stop the page from scrolling.
116
+ */
117
+ declare function createWheel(element: Element, onWheel: (event: WheelEvent) => void): WheelInstance;
118
+ //#endregion
119
+ export { type DragInstance, type DragOptions, type DragState, type MIDIAccessError, type MIDIAccessInstance, type MIDIAccessState, type MIDIInputHandlers, type MIDIInputInstance, type MIDIMessageInstance, NOT_SUPPORTED, PERMISSION_DENIED, type WheelInstance, createDrag, createMIDIAccess, createMIDIInput, createMIDIMessage, createWheel };
59
120
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../src/midi/access.ts","../src/midi/message.ts","../src/midi/input.ts"],"mappings":";;cACa,iBAAA;;cAEA,aAAA;;KAGD,eAAA,UAAyB,iBAAA,UAA2B,aAAA;AAAA,KAEpD,eAAA;EAAA,SACD,UAAA,EAAY,UAAA;EAAA,SACZ,KAAA,EAAO,eAAA;AAAA;AAAA,UAQD,kBAAA;EAZL;EAcV,OAAA;EACA,QAAA,QAAgB,eAAA;EAfmB;EAiBnC,cAAA,QAAsB,eAAA;EAfZ;EAiBV,SAAA,GAAY,QAAA;EACZ,OAAA;AAAA;;;;;;;iBASc,gBAAA,CAAA,GAAoB,kBAAA;;;UCnCnB,mBAAA;EACf,OAAA;AAAA;;;;ADEF;;iBCMgB,iBAAA,CACd,UAAA,EAAY,UAAA,SACZ,aAAA,GAAgB,KAAA,EAAO,gBAAA,YACtB,mBAAA;;;KCJS,iBAAA;EACV,aAAA,IAAiB,IAAA,UAAc,QAAA;EAC/B,cAAA,IAAkB,IAAA;EAClB,gBAAA,IAAoB,GAAA,UAAa,GAAA;AAAA;AAAA,KAGvB,iBAAA,GAAoB,mBAAA;;;;;iBAMhB,eAAA,CACd,UAAA,EAAY,UAAA;EACV,aAAA;EAAe,cAAA;EAAgB;AAAA,GAAoB,iBAAA,GACpD,iBAAA"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/midi/access.ts","../src/midi/message.ts","../src/midi/input.ts","../src/pointer/drag.ts","../src/pointer/wheel.ts"],"mappings":";;cACa,iBAAA;;cAEA,aAAA;;KAGD,eAAA,UAAyB,iBAAA,UAA2B,aAAA;AAAA,KAEpD,eAAA;EAAA,SACD,UAAA,EAAY,UAAA;EAAA,SACZ,KAAA,EAAO,eAAA;AAAA;AAAA,UAQD,kBAAA;EAZL;EAcV,OAAA;EACA,QAAA,QAAgB,eAAA;EAfmB;EAiBnC,cAAA,QAAsB,eAAA;EAfZ;EAiBV,SAAA,GAAY,QAAA;EACZ,OAAA;AAAA;;;;;;;iBASc,gBAAA,CAAA,GAAoB,kBAAA;;;UCnCnB,mBAAA;EACf,OAAA;AAAA;;;;ADEF;;iBCMgB,iBAAA,CACd,UAAA,EAAY,UAAA,SACZ,aAAA,GAAgB,KAAA,EAAO,gBAAA,YACtB,mBAAA;;;KCJS,iBAAA;EACV,aAAA,IAAiB,IAAA,UAAc,QAAA;EAC/B,cAAA,IAAkB,IAAA;EAClB,gBAAA,IAAoB,GAAA,UAAa,GAAA;AAAA;AAAA,KAGvB,iBAAA,GAAoB,mBAAA;;;;;iBAMhB,eAAA,CACd,UAAA,EAAY,UAAA;EACV,aAAA;EAAe,cAAA;EAAgB;AAAA,GAAoB,iBAAA,GACpD,iBAAA;;;KCvBS,SAAA;EHCC,iEGCX,CAAA;EACA,CAAA,UHF4B;EGI5B,MAAA;EACA,MAAA,UHHwB;EGKxB,OAAA;EACA,OAAA;EACA,KAAA,EAAO,YAAA;AAAA;AAAA,KAGG,WAAA;;;;AHLZ;;;;EGaE,SAAA;EHZqB;;;;;EGmBrB,MAAA;EAEA,WAAA,IAAe,KAAA,EAAO,SAAA;EACtB,MAAA,IAAU,KAAA,EAAO,SAAA;EACjB,SAAA,IAAa,KAAA,EAAO,SAAA;AAAA;AAAA,UAGL,YAAA;EACf,OAAA;AAAA;;;;;;;AHDF;;;iBGiCgB,UAAA,CACd,OAAA,EAAS,OAAA;EAEP,SAAA,EAAW,UAAA;EACX,MAAA;EACA,WAAA;EACA,MAAA;EACA;AAAA,IACC,WAAA,GACF,YAAA;;;UC7Ec,aAAA;EACf,OAAA;AAAA;;;;AJEF;;;iBIOgB,WAAA,CACd,OAAA,EAAS,OAAA,EACT,OAAA,GAAU,KAAA,EAAO,UAAA,YAChB,aAAA"}
package/dist/index.js CHANGED
@@ -96,6 +96,141 @@ function createMIDIInput(midiAccess, { onNoteOnEvent, onNoteOffEvent, onPitchBen
96
96
  });
97
97
  }
98
98
  //#endregion
99
- export { NOT_SUPPORTED, PERMISSION_DENIED, createMIDIAccess, createMIDIInput, createMIDIMessage };
99
+ //#region src/pointer/drag.ts
100
+ /**
101
+ * Applied to the element for the lifetime of the instance.
102
+ *
103
+ * `touch-action` stops touch dragging from scrolling the page. That alone makes
104
+ * the browser treat a long press as the start of a text selection, so selection
105
+ * and the iOS callout are suppressed here too.
106
+ */
107
+ const MANAGED_STYLES = [
108
+ ["touch-action", "none"],
109
+ ["user-select", "none"],
110
+ ["-webkit-user-select", "none"],
111
+ ["-webkit-touch-callout", "none"]
112
+ ];
113
+ /**
114
+ * Track a pointer drag on an element.
115
+ *
116
+ * Uses Pointer Events only, and pointer capture so that the drag keeps working
117
+ * once the pointer leaves the element. For the lifetime of the instance the
118
+ * element gets `touch-action: none` so that touch dragging does not scroll the
119
+ * page, plus `user-select: none` so that a long press does not start a text
120
+ * selection instead.
121
+ */
122
+ function createDrag(element, { threshold: _threshold = 1, cursor, onDragStart, onDrag, onDragEnd } = {}) {
123
+ const threshold = Math.max(_threshold, 1);
124
+ const capture = element;
125
+ const style = element.style;
126
+ const previousStyles = /* @__PURE__ */ new Map();
127
+ if (style) for (const [property, value] of MANAGED_STYLES) {
128
+ previousStyles.set(property, style.getPropertyValue(property));
129
+ style.setProperty(property, value);
130
+ }
131
+ let pointerId = null;
132
+ /** Where the listeners for the current drag live. */
133
+ let moveTarget = null;
134
+ let startX = 0;
135
+ let startY = 0;
136
+ let lastX = 0;
137
+ let lastY = 0;
138
+ let previousCursor;
139
+ function state(event, deltaX, deltaY) {
140
+ return {
141
+ x: event.screenX - startX,
142
+ y: event.screenY - startY,
143
+ deltaX,
144
+ deltaY,
145
+ clientX: event.clientX,
146
+ clientY: event.clientY,
147
+ event
148
+ };
149
+ }
150
+ /**
151
+ * `user-select: none` only makes the element's own text unselectable; the
152
+ * browser still starts a selection on long press and grabs whatever text it
153
+ * finds nearby. Cancelling the selection outright is what actually stops it.
154
+ */
155
+ function preventSelectStart(event) {
156
+ event.preventDefault();
157
+ }
158
+ function handlePointerDown(event) {
159
+ const pointerEvent = event;
160
+ if (pointerId !== null) return;
161
+ pointerId = pointerEvent.pointerId;
162
+ startX = lastX = pointerEvent.screenX;
163
+ startY = lastY = pointerEvent.screenY;
164
+ if (cursor && style) {
165
+ previousCursor = style.cursor;
166
+ style.cursor = cursor;
167
+ }
168
+ capture.setPointerCapture?.(pointerId);
169
+ moveTarget = capture.hasPointerCapture?.(pointerId) === true ? element : globalThis.window ?? element;
170
+ moveTarget.addEventListener("pointermove", handlePointerMove);
171
+ moveTarget.addEventListener("pointerup", handlePointerUp);
172
+ moveTarget.addEventListener("pointercancel", handlePointerUp);
173
+ globalThis.document?.addEventListener("selectstart", preventSelectStart);
174
+ onDragStart?.(state(pointerEvent, 0, 0));
175
+ }
176
+ function handlePointerMove(event) {
177
+ const pointerEvent = event;
178
+ if (pointerEvent.pointerId !== pointerId) return;
179
+ const deltaX = pointerEvent.screenX - lastX;
180
+ const deltaY = pointerEvent.screenY - lastY;
181
+ lastX = pointerEvent.screenX;
182
+ lastY = pointerEvent.screenY;
183
+ if (Math.abs(deltaX) < threshold && Math.abs(deltaY) < threshold) return;
184
+ onDrag?.(state(pointerEvent, deltaX, deltaY));
185
+ }
186
+ function handlePointerUp(event) {
187
+ const pointerEvent = event;
188
+ if (pointerEvent.pointerId !== pointerId) return;
189
+ const finalState = state(pointerEvent, pointerEvent.screenX - lastX, pointerEvent.screenY - lastY);
190
+ stopTracking();
191
+ onDragEnd?.(finalState);
192
+ }
193
+ function stopTracking() {
194
+ if (pointerId === null) return;
195
+ capture.releasePointerCapture?.(pointerId);
196
+ moveTarget?.removeEventListener("pointermove", handlePointerMove);
197
+ moveTarget?.removeEventListener("pointerup", handlePointerUp);
198
+ moveTarget?.removeEventListener("pointercancel", handlePointerUp);
199
+ globalThis.document?.removeEventListener("selectstart", preventSelectStart);
200
+ if (cursor && style) {
201
+ style.cursor = previousCursor ?? "";
202
+ previousCursor = void 0;
203
+ }
204
+ moveTarget = null;
205
+ pointerId = null;
206
+ }
207
+ element.addEventListener("pointerdown", handlePointerDown);
208
+ return { destroy: () => {
209
+ stopTracking();
210
+ element.removeEventListener("pointerdown", handlePointerDown);
211
+ if (style) for (const [property] of MANAGED_STYLES) {
212
+ const previous = previousStyles.get(property);
213
+ if (previous) style.setProperty(property, previous);
214
+ else style.removeProperty(property);
215
+ }
216
+ } };
217
+ }
218
+ //#endregion
219
+ //#region src/pointer/wheel.ts
220
+ /**
221
+ * Listen to wheel events on an element.
222
+ *
223
+ * The listener is registered with `passive: false` so that the handler can call
224
+ * `preventDefault()` to stop the page from scrolling.
225
+ */
226
+ function createWheel(element, onWheel) {
227
+ const handler = (event) => onWheel(event);
228
+ element.addEventListener("wheel", handler, { passive: false });
229
+ return { destroy: () => {
230
+ element.removeEventListener("wheel", handler);
231
+ } };
232
+ }
233
+ //#endregion
234
+ export { NOT_SUPPORTED, PERMISSION_DENIED, createDrag, createMIDIAccess, createMIDIInput, createMIDIMessage, createWheel };
100
235
 
101
236
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../src/midi/access.ts","../src/midi/message.ts","../src/midi/input.ts"],"sourcesContent":["/** @private */\nexport const PERMISSION_DENIED = 'PERMISSION_DENIED'\n/** @private */\nexport const NOT_SUPPORTED = 'NOT_SUPPORTED'\n\n/** @private */\nexport type MIDIAccessError = typeof PERMISSION_DENIED | typeof NOT_SUPPORTED\n\nexport type MIDIAccessState = {\n readonly midiAccess: MIDIAccess | null\n readonly error: MIDIAccessError | null\n}\n\nconst INITIAL_STATE: MIDIAccessState = {\n midiAccess: null,\n error: null,\n}\n\nexport interface MIDIAccessInstance {\n /** Request MIDI access. Safe to call more than once. */\n request: () => void\n getState: () => MIDIAccessState\n /** Snapshot for server side rendering. Always the initial state. */\n getServerState: () => MIDIAccessState\n /** @returns unsubscribe function */\n subscribe: (listener: () => void) => () => void\n destroy: () => void\n}\n\n/**\n * Request MIDI access in the browser.\n *\n * The returned instance holds the state and notifies subscribers when it changes,\n * so it can be consumed from any framework.\n */\nexport function createMIDIAccess(): MIDIAccessInstance {\n let state = INITIAL_STATE\n let destroyed = false\n const listeners = new Set<() => void>()\n\n function setState(next: MIDIAccessState) {\n state = next\n for (const listener of listeners) listener()\n }\n\n function request() {\n if (destroyed) return\n if (typeof navigator === 'undefined' || !navigator.requestMIDIAccess) {\n setState({ ...state, error: NOT_SUPPORTED })\n return\n }\n navigator\n .requestMIDIAccess()\n .then((access) => {\n if (destroyed) return\n setState({ midiAccess: access, error: null })\n })\n .catch(() => {\n if (destroyed) return\n setState({ ...state, error: PERMISSION_DENIED })\n })\n }\n\n return {\n request,\n getState: () => state,\n getServerState: () => INITIAL_STATE,\n subscribe: (listener) => {\n listeners.add(listener)\n return () => {\n listeners.delete(listener)\n }\n },\n destroy: () => {\n destroyed = true\n listeners.clear()\n },\n }\n}\n","export interface MIDIMessageInstance {\n destroy: () => void\n}\n\n/**\n * Listen to raw `midimessage` events on every input of a MIDIAccess.\n *\n * Use this when you need more detail than {@link createMIDIInput} provides.\n */\nexport function createMIDIMessage(\n midiAccess: MIDIAccess | null,\n onMIDIMessage: (event: MIDIMessageEvent) => void,\n): MIDIMessageInstance {\n if (!midiAccess) {\n return { destroy: () => {} }\n }\n\n const inputs = [...midiAccess.inputs.values()]\n for (const input of inputs) {\n input.addEventListener('midimessage', onMIDIMessage)\n }\n\n return {\n destroy: () => {\n for (const input of inputs) {\n input.removeEventListener('midimessage', onMIDIMessage)\n }\n },\n }\n}\n","import { createMIDIMessage, type MIDIMessageInstance } from './message'\n\nconst MIDI_EVENT_TO_NUMBER = {\n NOTE_ON: 0x90,\n NOTE_OFF: 0x80,\n PITCH_BEND: 0xe0,\n}\n\nexport type MIDIInputHandlers = {\n onNoteOnEvent?: (note: number, velocity: number) => void\n onNoteOffEvent?: (note: number) => void\n onPitchBendEvent?: (msb: number, lsb: number) => void\n}\n\nexport type MIDIInputInstance = MIDIMessageInstance\n\n/**\n * Handle note on/off and pitch bend events. To be used with {@link createMIDIAccess}.\n * Internally uses {@link createMIDIMessage}.\n */\nexport function createMIDIInput(\n midiAccess: MIDIAccess | null,\n { onNoteOnEvent, onNoteOffEvent, onPitchBendEvent }: MIDIInputHandlers,\n): MIDIInputInstance {\n return createMIDIMessage(midiAccess, (event) => {\n if (!event.data) return\n // event.data[0] ... command\n // event.data[1] ... note, MSB (Most Significant Byte)\n // event.data[2] ... velocity, LSB (Least Significant Byte)\n const kind = event.data[0] & 0xf0\n\n if (\n kind == MIDI_EVENT_TO_NUMBER.NOTE_OFF ||\n (kind == MIDI_EVENT_TO_NUMBER.NOTE_ON && event.data[2] == 0)\n ) {\n onNoteOffEvent?.(event.data[1])\n } else if (kind == MIDI_EVENT_TO_NUMBER.NOTE_ON) {\n onNoteOnEvent?.(event.data[1], event.data[2])\n } else if (kind == MIDI_EVENT_TO_NUMBER.PITCH_BEND) {\n onPitchBendEvent?.(event.data[1], event.data[2])\n }\n })\n}\n"],"mappings":";;AACA,MAAa,oBAAoB;;AAEjC,MAAa,gBAAgB;AAU7B,MAAM,gBAAiC;CACrC,YAAY;CACZ,OAAO;CACR;;;;;;;AAmBD,SAAgB,mBAAuC;CACrD,IAAI,QAAQ;CACZ,IAAI,YAAY;CAChB,MAAM,4BAAY,IAAI,KAAiB;CAEvC,SAAS,SAAS,MAAuB;AACvC,UAAQ;AACR,OAAK,MAAM,YAAY,UAAW,WAAU;;CAG9C,SAAS,UAAU;AACjB,MAAI,UAAW;AACf,MAAI,OAAO,cAAc,eAAe,CAAC,UAAU,mBAAmB;AACpE,YAAS;IAAE,GAAG;IAAO,OAAO;IAAe,CAAC;AAC5C;;AAEF,YACG,mBAAmB,CACnB,MAAM,WAAW;AAChB,OAAI,UAAW;AACf,YAAS;IAAE,YAAY;IAAQ,OAAO;IAAM,CAAC;IAC7C,CACD,YAAY;AACX,OAAI,UAAW;AACf,YAAS;IAAE,GAAG;IAAO,OAAO;IAAmB,CAAC;IAChD;;AAGN,QAAO;EACL;EACA,gBAAgB;EAChB,sBAAsB;EACtB,YAAY,aAAa;AACvB,aAAU,IAAI,SAAS;AACvB,gBAAa;AACX,cAAU,OAAO,SAAS;;;EAG9B,eAAe;AACb,eAAY;AACZ,aAAU,OAAO;;EAEpB;;;;;;;;;ACpEH,SAAgB,kBACd,YACA,eACqB;AACrB,KAAI,CAAC,WACH,QAAO,EAAE,eAAe,IAAI;CAG9B,MAAM,SAAS,CAAC,GAAG,WAAW,OAAO,QAAQ,CAAC;AAC9C,MAAK,MAAM,SAAS,OAClB,OAAM,iBAAiB,eAAe,cAAc;AAGtD,QAAO,EACL,eAAe;AACb,OAAK,MAAM,SAAS,OAClB,OAAM,oBAAoB,eAAe,cAAc;IAG5D;;;;AC1BH,MAAM,uBAAuB;CAC3B,SAAS;CACT,UAAU;CACV,YAAY;CACb;;;;;AAcD,SAAgB,gBACd,YACA,EAAE,eAAe,gBAAgB,oBACd;AACnB,QAAO,kBAAkB,aAAa,UAAU;AAC9C,MAAI,CAAC,MAAM,KAAM;EAIjB,MAAM,OAAO,MAAM,KAAK,KAAK;AAE7B,MACE,QAAQ,qBAAqB,YAC5B,QAAQ,qBAAqB,WAAW,MAAM,KAAK,MAAM,EAE1D,kBAAiB,MAAM,KAAK,GAAG;WACtB,QAAQ,qBAAqB,QACtC,iBAAgB,MAAM,KAAK,IAAI,MAAM,KAAK,GAAG;WACpC,QAAQ,qBAAqB,WACtC,oBAAmB,MAAM,KAAK,IAAI,MAAM,KAAK,GAAG;GAElD"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/midi/access.ts","../src/midi/message.ts","../src/midi/input.ts","../src/pointer/drag.ts","../src/pointer/wheel.ts"],"sourcesContent":["/** @private */\nexport const PERMISSION_DENIED = 'PERMISSION_DENIED'\n/** @private */\nexport const NOT_SUPPORTED = 'NOT_SUPPORTED'\n\n/** @private */\nexport type MIDIAccessError = typeof PERMISSION_DENIED | typeof NOT_SUPPORTED\n\nexport type MIDIAccessState = {\n readonly midiAccess: MIDIAccess | null\n readonly error: MIDIAccessError | null\n}\n\nconst INITIAL_STATE: MIDIAccessState = {\n midiAccess: null,\n error: null,\n}\n\nexport interface MIDIAccessInstance {\n /** Request MIDI access. Safe to call more than once. */\n request: () => void\n getState: () => MIDIAccessState\n /** Snapshot for server side rendering. Always the initial state. */\n getServerState: () => MIDIAccessState\n /** @returns unsubscribe function */\n subscribe: (listener: () => void) => () => void\n destroy: () => void\n}\n\n/**\n * Request MIDI access in the browser.\n *\n * The returned instance holds the state and notifies subscribers when it changes,\n * so it can be consumed from any framework.\n */\nexport function createMIDIAccess(): MIDIAccessInstance {\n let state = INITIAL_STATE\n let destroyed = false\n const listeners = new Set<() => void>()\n\n function setState(next: MIDIAccessState) {\n state = next\n for (const listener of listeners) listener()\n }\n\n function request() {\n if (destroyed) return\n if (typeof navigator === 'undefined' || !navigator.requestMIDIAccess) {\n setState({ ...state, error: NOT_SUPPORTED })\n return\n }\n navigator\n .requestMIDIAccess()\n .then((access) => {\n if (destroyed) return\n setState({ midiAccess: access, error: null })\n })\n .catch(() => {\n if (destroyed) return\n setState({ ...state, error: PERMISSION_DENIED })\n })\n }\n\n return {\n request,\n getState: () => state,\n getServerState: () => INITIAL_STATE,\n subscribe: (listener) => {\n listeners.add(listener)\n return () => {\n listeners.delete(listener)\n }\n },\n destroy: () => {\n destroyed = true\n listeners.clear()\n },\n }\n}\n","export interface MIDIMessageInstance {\n destroy: () => void\n}\n\n/**\n * Listen to raw `midimessage` events on every input of a MIDIAccess.\n *\n * Use this when you need more detail than {@link createMIDIInput} provides.\n */\nexport function createMIDIMessage(\n midiAccess: MIDIAccess | null,\n onMIDIMessage: (event: MIDIMessageEvent) => void,\n): MIDIMessageInstance {\n if (!midiAccess) {\n return { destroy: () => {} }\n }\n\n const inputs = [...midiAccess.inputs.values()]\n for (const input of inputs) {\n input.addEventListener('midimessage', onMIDIMessage)\n }\n\n return {\n destroy: () => {\n for (const input of inputs) {\n input.removeEventListener('midimessage', onMIDIMessage)\n }\n },\n }\n}\n","import { createMIDIMessage, type MIDIMessageInstance } from './message'\n\nconst MIDI_EVENT_TO_NUMBER = {\n NOTE_ON: 0x90,\n NOTE_OFF: 0x80,\n PITCH_BEND: 0xe0,\n}\n\nexport type MIDIInputHandlers = {\n onNoteOnEvent?: (note: number, velocity: number) => void\n onNoteOffEvent?: (note: number) => void\n onPitchBendEvent?: (msb: number, lsb: number) => void\n}\n\nexport type MIDIInputInstance = MIDIMessageInstance\n\n/**\n * Handle note on/off and pitch bend events. To be used with {@link createMIDIAccess}.\n * Internally uses {@link createMIDIMessage}.\n */\nexport function createMIDIInput(\n midiAccess: MIDIAccess | null,\n { onNoteOnEvent, onNoteOffEvent, onPitchBendEvent }: MIDIInputHandlers,\n): MIDIInputInstance {\n return createMIDIMessage(midiAccess, (event) => {\n if (!event.data) return\n // event.data[0] ... command\n // event.data[1] ... note, MSB (Most Significant Byte)\n // event.data[2] ... velocity, LSB (Least Significant Byte)\n const kind = event.data[0] & 0xf0\n\n if (\n kind == MIDI_EVENT_TO_NUMBER.NOTE_OFF ||\n (kind == MIDI_EVENT_TO_NUMBER.NOTE_ON && event.data[2] == 0)\n ) {\n onNoteOffEvent?.(event.data[1])\n } else if (kind == MIDI_EVENT_TO_NUMBER.NOTE_ON) {\n onNoteOnEvent?.(event.data[1], event.data[2])\n } else if (kind == MIDI_EVENT_TO_NUMBER.PITCH_BEND) {\n onPitchBendEvent?.(event.data[1], event.data[2])\n }\n })\n}\n","export type DragState = {\n /** Total movement from the drag start, in screen coordinates. */\n x: number\n y: number\n /** Movement since the previous event, in screen coordinates. */\n deltaX: number\n deltaY: number\n /** Pointer position in viewport coordinates. */\n clientX: number\n clientY: number\n event: PointerEvent\n}\n\nexport type DragOptions = {\n /**\n * Minimum movement in pixels before `onDrag` fires.\n * Prevents `onDrag` from firing on, for example, a double click.\n * Values below 1 are clamped to 1.\n *\n * @default 1\n */\n threshold?: number\n\n /**\n * CSS cursor to show while dragging. Applied to the element itself: pointer\n * capture keeps it in effect even once the pointer leaves the element, so\n * there is no need to touch the document.\n */\n cursor?: string\n\n onDragStart?: (state: DragState) => void\n onDrag?: (state: DragState) => void\n onDragEnd?: (state: DragState) => void\n}\n\nexport interface DragInstance {\n destroy: () => void\n}\n\n/**\n * Applied to the element for the lifetime of the instance.\n *\n * `touch-action` stops touch dragging from scrolling the page. That alone makes\n * the browser treat a long press as the start of a text selection, so selection\n * and the iOS callout are suppressed here too.\n */\nconst MANAGED_STYLES = [\n ['touch-action', 'none'],\n ['user-select', 'none'],\n ['-webkit-user-select', 'none'],\n ['-webkit-touch-callout', 'none'],\n] as const\n\ntype CaptureTarget = {\n setPointerCapture?: (pointerId: number) => void\n releasePointerCapture?: (pointerId: number) => void\n hasPointerCapture?: (pointerId: number) => boolean\n}\n\n/**\n * Track a pointer drag on an element.\n *\n * Uses Pointer Events only, and pointer capture so that the drag keeps working\n * once the pointer leaves the element. For the lifetime of the instance the\n * element gets `touch-action: none` so that touch dragging does not scroll the\n * page, plus `user-select: none` so that a long press does not start a text\n * selection instead.\n */\nexport function createDrag(\n element: Element,\n {\n threshold: _threshold = 1,\n cursor,\n onDragStart,\n onDrag,\n onDragEnd,\n }: DragOptions = {},\n): DragInstance {\n const threshold = Math.max(_threshold, 1)\n const capture = element as CaptureTarget\n const style = (element as Partial<HTMLElement>).style\n\n const previousStyles = new Map<string, string>()\n if (style) {\n for (const [property, value] of MANAGED_STYLES) {\n previousStyles.set(property, style.getPropertyValue(property))\n style.setProperty(property, value)\n }\n }\n\n let pointerId: number | null = null\n /** Where the listeners for the current drag live. */\n let moveTarget: EventTarget | null = null\n let startX = 0\n let startY = 0\n let lastX = 0\n let lastY = 0\n let previousCursor: string | undefined\n\n function state(\n event: PointerEvent,\n deltaX: number,\n deltaY: number,\n ): DragState {\n return {\n x: event.screenX - startX,\n y: event.screenY - startY,\n deltaX,\n deltaY,\n clientX: event.clientX,\n clientY: event.clientY,\n event,\n }\n }\n\n /**\n * `user-select: none` only makes the element's own text unselectable; the\n * browser still starts a selection on long press and grabs whatever text it\n * finds nearby. Cancelling the selection outright is what actually stops it.\n */\n function preventSelectStart(event: Event) {\n event.preventDefault()\n }\n\n function handlePointerDown(event: Event) {\n const pointerEvent = event as PointerEvent\n // Only one pointer drives the drag; ignore additional touches.\n if (pointerId !== null) return\n\n pointerId = pointerEvent.pointerId\n startX = lastX = pointerEvent.screenX\n startY = lastY = pointerEvent.screenY\n\n if (cursor && style) {\n previousCursor = style.cursor\n style.cursor = cursor\n }\n\n capture.setPointerCapture?.(pointerId)\n // With pointer capture the element receives the rest of the gesture.\n // Without it (older engines, jsdom) fall back to the window.\n moveTarget =\n capture.hasPointerCapture?.(pointerId) === true\n ? element\n : (globalThis.window ?? element)\n\n moveTarget.addEventListener('pointermove', handlePointerMove)\n moveTarget.addEventListener('pointerup', handlePointerUp)\n moveTarget.addEventListener('pointercancel', handlePointerUp)\n globalThis.document?.addEventListener('selectstart', preventSelectStart)\n\n onDragStart?.(state(pointerEvent, 0, 0))\n }\n\n function handlePointerMove(event: Event) {\n const pointerEvent = event as PointerEvent\n if (pointerEvent.pointerId !== pointerId) return\n\n const deltaX = pointerEvent.screenX - lastX\n const deltaY = pointerEvent.screenY - lastY\n lastX = pointerEvent.screenX\n lastY = pointerEvent.screenY\n\n // Movement below the threshold is dropped rather than accumulated.\n if (Math.abs(deltaX) < threshold && Math.abs(deltaY) < threshold) return\n\n onDrag?.(state(pointerEvent, deltaX, deltaY))\n }\n\n function handlePointerUp(event: Event) {\n const pointerEvent = event as PointerEvent\n if (pointerEvent.pointerId !== pointerId) return\n\n const deltaX = pointerEvent.screenX - lastX\n const deltaY = pointerEvent.screenY - lastY\n const finalState = state(pointerEvent, deltaX, deltaY)\n\n stopTracking()\n onDragEnd?.(finalState)\n }\n\n function stopTracking() {\n if (pointerId === null) return\n capture.releasePointerCapture?.(pointerId)\n moveTarget?.removeEventListener('pointermove', handlePointerMove)\n moveTarget?.removeEventListener('pointerup', handlePointerUp)\n moveTarget?.removeEventListener('pointercancel', handlePointerUp)\n globalThis.document?.removeEventListener('selectstart', preventSelectStart)\n if (cursor && style) {\n style.cursor = previousCursor ?? ''\n previousCursor = undefined\n }\n moveTarget = null\n pointerId = null\n }\n\n element.addEventListener('pointerdown', handlePointerDown)\n\n return {\n destroy: () => {\n stopTracking()\n element.removeEventListener('pointerdown', handlePointerDown)\n if (style) {\n for (const [property] of MANAGED_STYLES) {\n const previous = previousStyles.get(property)\n if (previous) {\n style.setProperty(property, previous)\n } else {\n style.removeProperty(property)\n }\n }\n }\n },\n }\n}\n","export interface WheelInstance {\n destroy: () => void\n}\n\n/**\n * Listen to wheel events on an element.\n *\n * The listener is registered with `passive: false` so that the handler can call\n * `preventDefault()` to stop the page from scrolling.\n */\nexport function createWheel(\n element: Element,\n onWheel: (event: WheelEvent) => void,\n): WheelInstance {\n const handler = (event: Event) => onWheel(event as WheelEvent)\n\n element.addEventListener('wheel', handler, { passive: false })\n\n return {\n destroy: () => {\n // Only `capture` matters when removing, and it is false here.\n element.removeEventListener('wheel', handler)\n },\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tremolo-ui/dom",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "framework-agnostic DOM layer used in @tremolo-ui/*",
5
5
  "type": "module",
6
6
  "sideEffects": false,
package/src/index.ts CHANGED
@@ -12,3 +12,10 @@ export {
12
12
  type MIDIInputInstance,
13
13
  } from './midi/input'
14
14
  export { createMIDIMessage, type MIDIMessageInstance } from './midi/message'
15
+ export {
16
+ createDrag,
17
+ type DragInstance,
18
+ type DragOptions,
19
+ type DragState,
20
+ } from './pointer/drag'
21
+ export { createWheel, type WheelInstance } from './pointer/wheel'
@@ -0,0 +1,215 @@
1
+ export type DragState = {
2
+ /** Total movement from the drag start, in screen coordinates. */
3
+ x: number
4
+ y: number
5
+ /** Movement since the previous event, in screen coordinates. */
6
+ deltaX: number
7
+ deltaY: number
8
+ /** Pointer position in viewport coordinates. */
9
+ clientX: number
10
+ clientY: number
11
+ event: PointerEvent
12
+ }
13
+
14
+ export type DragOptions = {
15
+ /**
16
+ * Minimum movement in pixels before `onDrag` fires.
17
+ * Prevents `onDrag` from firing on, for example, a double click.
18
+ * Values below 1 are clamped to 1.
19
+ *
20
+ * @default 1
21
+ */
22
+ threshold?: number
23
+
24
+ /**
25
+ * CSS cursor to show while dragging. Applied to the element itself: pointer
26
+ * capture keeps it in effect even once the pointer leaves the element, so
27
+ * there is no need to touch the document.
28
+ */
29
+ cursor?: string
30
+
31
+ onDragStart?: (state: DragState) => void
32
+ onDrag?: (state: DragState) => void
33
+ onDragEnd?: (state: DragState) => void
34
+ }
35
+
36
+ export interface DragInstance {
37
+ destroy: () => void
38
+ }
39
+
40
+ /**
41
+ * Applied to the element for the lifetime of the instance.
42
+ *
43
+ * `touch-action` stops touch dragging from scrolling the page. That alone makes
44
+ * the browser treat a long press as the start of a text selection, so selection
45
+ * and the iOS callout are suppressed here too.
46
+ */
47
+ const MANAGED_STYLES = [
48
+ ['touch-action', 'none'],
49
+ ['user-select', 'none'],
50
+ ['-webkit-user-select', 'none'],
51
+ ['-webkit-touch-callout', 'none'],
52
+ ] as const
53
+
54
+ type CaptureTarget = {
55
+ setPointerCapture?: (pointerId: number) => void
56
+ releasePointerCapture?: (pointerId: number) => void
57
+ hasPointerCapture?: (pointerId: number) => boolean
58
+ }
59
+
60
+ /**
61
+ * Track a pointer drag on an element.
62
+ *
63
+ * Uses Pointer Events only, and pointer capture so that the drag keeps working
64
+ * once the pointer leaves the element. For the lifetime of the instance the
65
+ * element gets `touch-action: none` so that touch dragging does not scroll the
66
+ * page, plus `user-select: none` so that a long press does not start a text
67
+ * selection instead.
68
+ */
69
+ export function createDrag(
70
+ element: Element,
71
+ {
72
+ threshold: _threshold = 1,
73
+ cursor,
74
+ onDragStart,
75
+ onDrag,
76
+ onDragEnd,
77
+ }: DragOptions = {},
78
+ ): DragInstance {
79
+ const threshold = Math.max(_threshold, 1)
80
+ const capture = element as CaptureTarget
81
+ const style = (element as Partial<HTMLElement>).style
82
+
83
+ const previousStyles = new Map<string, string>()
84
+ if (style) {
85
+ for (const [property, value] of MANAGED_STYLES) {
86
+ previousStyles.set(property, style.getPropertyValue(property))
87
+ style.setProperty(property, value)
88
+ }
89
+ }
90
+
91
+ let pointerId: number | null = null
92
+ /** Where the listeners for the current drag live. */
93
+ let moveTarget: EventTarget | null = null
94
+ let startX = 0
95
+ let startY = 0
96
+ let lastX = 0
97
+ let lastY = 0
98
+ let previousCursor: string | undefined
99
+
100
+ function state(
101
+ event: PointerEvent,
102
+ deltaX: number,
103
+ deltaY: number,
104
+ ): DragState {
105
+ return {
106
+ x: event.screenX - startX,
107
+ y: event.screenY - startY,
108
+ deltaX,
109
+ deltaY,
110
+ clientX: event.clientX,
111
+ clientY: event.clientY,
112
+ event,
113
+ }
114
+ }
115
+
116
+ /**
117
+ * `user-select: none` only makes the element's own text unselectable; the
118
+ * browser still starts a selection on long press and grabs whatever text it
119
+ * finds nearby. Cancelling the selection outright is what actually stops it.
120
+ */
121
+ function preventSelectStart(event: Event) {
122
+ event.preventDefault()
123
+ }
124
+
125
+ function handlePointerDown(event: Event) {
126
+ const pointerEvent = event as PointerEvent
127
+ // Only one pointer drives the drag; ignore additional touches.
128
+ if (pointerId !== null) return
129
+
130
+ pointerId = pointerEvent.pointerId
131
+ startX = lastX = pointerEvent.screenX
132
+ startY = lastY = pointerEvent.screenY
133
+
134
+ if (cursor && style) {
135
+ previousCursor = style.cursor
136
+ style.cursor = cursor
137
+ }
138
+
139
+ capture.setPointerCapture?.(pointerId)
140
+ // With pointer capture the element receives the rest of the gesture.
141
+ // Without it (older engines, jsdom) fall back to the window.
142
+ moveTarget =
143
+ capture.hasPointerCapture?.(pointerId) === true
144
+ ? element
145
+ : (globalThis.window ?? element)
146
+
147
+ moveTarget.addEventListener('pointermove', handlePointerMove)
148
+ moveTarget.addEventListener('pointerup', handlePointerUp)
149
+ moveTarget.addEventListener('pointercancel', handlePointerUp)
150
+ globalThis.document?.addEventListener('selectstart', preventSelectStart)
151
+
152
+ onDragStart?.(state(pointerEvent, 0, 0))
153
+ }
154
+
155
+ function handlePointerMove(event: Event) {
156
+ const pointerEvent = event as PointerEvent
157
+ if (pointerEvent.pointerId !== pointerId) return
158
+
159
+ const deltaX = pointerEvent.screenX - lastX
160
+ const deltaY = pointerEvent.screenY - lastY
161
+ lastX = pointerEvent.screenX
162
+ lastY = pointerEvent.screenY
163
+
164
+ // Movement below the threshold is dropped rather than accumulated.
165
+ if (Math.abs(deltaX) < threshold && Math.abs(deltaY) < threshold) return
166
+
167
+ onDrag?.(state(pointerEvent, deltaX, deltaY))
168
+ }
169
+
170
+ function handlePointerUp(event: Event) {
171
+ const pointerEvent = event as PointerEvent
172
+ if (pointerEvent.pointerId !== pointerId) return
173
+
174
+ const deltaX = pointerEvent.screenX - lastX
175
+ const deltaY = pointerEvent.screenY - lastY
176
+ const finalState = state(pointerEvent, deltaX, deltaY)
177
+
178
+ stopTracking()
179
+ onDragEnd?.(finalState)
180
+ }
181
+
182
+ function stopTracking() {
183
+ if (pointerId === null) return
184
+ capture.releasePointerCapture?.(pointerId)
185
+ moveTarget?.removeEventListener('pointermove', handlePointerMove)
186
+ moveTarget?.removeEventListener('pointerup', handlePointerUp)
187
+ moveTarget?.removeEventListener('pointercancel', handlePointerUp)
188
+ globalThis.document?.removeEventListener('selectstart', preventSelectStart)
189
+ if (cursor && style) {
190
+ style.cursor = previousCursor ?? ''
191
+ previousCursor = undefined
192
+ }
193
+ moveTarget = null
194
+ pointerId = null
195
+ }
196
+
197
+ element.addEventListener('pointerdown', handlePointerDown)
198
+
199
+ return {
200
+ destroy: () => {
201
+ stopTracking()
202
+ element.removeEventListener('pointerdown', handlePointerDown)
203
+ if (style) {
204
+ for (const [property] of MANAGED_STYLES) {
205
+ const previous = previousStyles.get(property)
206
+ if (previous) {
207
+ style.setProperty(property, previous)
208
+ } else {
209
+ style.removeProperty(property)
210
+ }
211
+ }
212
+ }
213
+ },
214
+ }
215
+ }
@@ -0,0 +1,25 @@
1
+ export interface WheelInstance {
2
+ destroy: () => void
3
+ }
4
+
5
+ /**
6
+ * Listen to wheel events on an element.
7
+ *
8
+ * The listener is registered with `passive: false` so that the handler can call
9
+ * `preventDefault()` to stop the page from scrolling.
10
+ */
11
+ export function createWheel(
12
+ element: Element,
13
+ onWheel: (event: WheelEvent) => void,
14
+ ): WheelInstance {
15
+ const handler = (event: Event) => onWheel(event as WheelEvent)
16
+
17
+ element.addEventListener('wheel', handler, { passive: false })
18
+
19
+ return {
20
+ destroy: () => {
21
+ // Only `capture` matters when removing, and it is false here.
22
+ element.removeEventListener('wheel', handler)
23
+ },
24
+ }
25
+ }