wavesurfer.js
Version:
Audio waveform player
147 lines (146 loc) • 5.26 kB
JavaScript
/**
* Reactive drag stream utilities
*
* Provides declarative drag handling using reactive streams.
* Automatically handles mouseup cleanup and supports constraints.
*/
import { signal } from './store.js';
import { cleanup } from './event-streams.js';
/**
* Create a reactive drag stream from an element
*
* Emits drag events (start, move, end) as the user drags the element.
* Automatically handles pointer capture, multi-touch prevention, and cleanup.
*
* @example
* ```typescript
* const dragSignal = createDragStream(element)
*
* effect(() => {
* const drag = dragSignal.value
* if (drag?.type === 'move') {
* console.log('Dragging:', drag.deltaX, drag.deltaY)
* }
* }, [dragSignal])
* ```
*
* @param element - Element to make draggable
* @param options - Drag configuration options
* @returns Signal emitting drag events and cleanup function
*/
export function createDragStream(element, options = {}) {
const { threshold = 3, mouseButton = 0, touchDelay = 100 } = options;
const dragSignal = signal(null);
const activePointers = new Map();
const isTouchDevice = matchMedia('(pointer: coarse)').matches;
let unsubscribeDocument = () => void 0;
const onPointerDown = (event) => {
if (event.button !== mouseButton)
return;
activePointers.set(event.pointerId, event);
if (activePointers.size > 1) {
return;
}
let startX = event.clientX;
let startY = event.clientY;
let isDragging = false;
const touchStartTime = Date.now();
const rect = element.getBoundingClientRect();
const { left, top } = rect;
const onPointerMove = (event) => {
if (event.defaultPrevented || activePointers.size > 1) {
return;
}
if (isTouchDevice && Date.now() - touchStartTime < touchDelay)
return;
const x = event.clientX;
const y = event.clientY;
const dx = x - startX;
const dy = y - startY;
if (isDragging || Math.abs(dx) > threshold || Math.abs(dy) > threshold) {
event.preventDefault();
event.stopPropagation();
if (!isDragging) {
// Emit start event
dragSignal.set({
type: 'start',
x: startX - left,
y: startY - top,
});
isDragging = true;
}
// Emit move event
dragSignal.set({
type: 'move',
x: x - left,
y: y - top,
deltaX: dx,
deltaY: dy,
});
startX = x;
startY = y;
}
};
const onPointerUp = (event) => {
activePointers.delete(event.pointerId);
if (isDragging) {
const x = event.clientX;
const y = event.clientY;
// Emit end event
dragSignal.set({
type: 'end',
x: x - left,
y: y - top,
});
}
unsubscribeDocument();
};
const onPointerLeave = (e) => {
activePointers.delete(e.pointerId);
if (!e.relatedTarget || e.relatedTarget === document.documentElement) {
onPointerUp(e);
}
};
const onClick = (event) => {
if (isDragging) {
event.stopPropagation();
event.preventDefault();
}
};
const onTouchMove = (event) => {
if (event.defaultPrevented || activePointers.size > 1) {
return;
}
if (isDragging) {
event.preventDefault();
}
};
document.addEventListener('pointermove', onPointerMove);
document.addEventListener('pointerup', onPointerUp);
document.addEventListener('pointerout', onPointerLeave);
document.addEventListener('pointercancel', onPointerLeave);
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('click', onClick, { capture: true });
unsubscribeDocument = () => {
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerup', onPointerUp);
document.removeEventListener('pointerout', onPointerLeave);
document.removeEventListener('pointercancel', onPointerLeave);
document.removeEventListener('touchmove', onTouchMove);
setTimeout(() => {
document.removeEventListener('click', onClick, { capture: true });
}, 10);
};
};
element.addEventListener('pointerdown', onPointerDown);
const cleanupFn = () => {
unsubscribeDocument();
element.removeEventListener('pointerdown', onPointerDown);
activePointers.clear();
cleanup(dragSignal);
};
return {
signal: dragSignal,
cleanup: cleanupFn,
};
}