import { createEffect, onCleanup, type Accessor } from "solid-js"; type PointerDragStart = { clientX: number; clientY: number; pointerId: number; }; type PointerDragMove = PointerDragStart & { dx: number; dy: number; velocityX: number; velocityY: number; }; export type PointerDragEnd = PointerDragMove; type PointerDragTap = PointerDragEnd & { startTarget: EventTarget | null; }; type PointerDragAxis = "any" | "x" | "y"; type PointerHoldResult = "consume" | "drag"; type PointerPinchStart = { clientX: number; clientY: number; distance: number; }; type PointerPinchMove = PointerPinchStart & { scale: number; }; const DEFAULT_TAP_MOVE_THRESHOLD_PX = 8; const DEFAULT_DRAG_START_THRESHOLD_PX = 8; const DEFAULT_DRAG_INTENT_RATIO = 1; const MOUSE_POINTER_ID = -1; export type PointerGestureCallbacks = { dragAxis?: PointerDragAxis; dragIntentRatio?: number; dragStartThreshold?: number; holdDelay?: number; shouldCaptureDrag?: (event: PointerEvent | MouseEvent) => boolean; shouldObserveTap?: (event: PointerEvent | MouseEvent) => boolean; onStart?: (info: PointerDragStart, event: PointerEvent | MouseEvent) => void; onMove?: (info: PointerDragMove, event: PointerEvent | MouseEvent) => void; onEnd?: (info: PointerDragEnd, event: PointerEvent | MouseEvent) => void; onHold?: ( info: PointerDragStart, event: PointerEvent | MouseEvent, ) => PointerHoldResult | false; onTap?: (info: PointerDragTap, event: PointerEvent | MouseEvent) => void; onPinchStart?: (info: PointerPinchStart, event: PointerEvent) => boolean; onPinchMove?: (info: PointerPinchMove, event: PointerEvent) => void; onPinchEnd?: () => void; tapMoveThreshold?: number; }; class PointerGesture { private readonly pinchPointers = new Map(); private drag: GesturePointer | null = null; private holdTimer: number | null = null; private suppressClick = false; private suppressClickTimer: number | null = null; private pinch: { startDistance: number; } | null = null; constructor( private readonly target: HTMLElement, private readonly callbacks: Accessor, ) { this.setDragging(false); target.addEventListener("pointerdown", this.onPointerDown); target.addEventListener("mousedown", this.onMouseDown); target.addEventListener("dragstart", this.onDragStart); target.addEventListener("contextmenu", this.onContextMenu); } dispose(): void { if (this.drag) { this.releaseCapture(this.drag); } this.drag = null; this.clearHold(); this.setDragging(false); this.clearPinch(); this.removePointerListeners(); this.removeMouseListeners(); this.target.removeEventListener("pointerdown", this.onPointerDown); this.target.removeEventListener("mousedown", this.onMouseDown); this.target.removeEventListener("dragstart", this.onDragStart); this.target.removeEventListener("contextmenu", this.onContextMenu); this.clearClickSuppression(); } isDragging(): boolean { return this.drag?.active === true; } cancel(preservePinchPointers = false): void { const drag = this.drag; if (!drag) { return; } this.releaseCapture(drag); if (!preservePinchPointers) { this.pinchPointers.delete(drag.pointerId); } this.drag = null; this.clearHold(); this.setDragging(false); this.removePointerListeners(); this.removeMouseListeners(); } private onDragStart = (event: DragEvent): void => { if (this.drag?.canDrag) { event.preventDefault(); } }; private onClick = (event: MouseEvent): void => { if (!this.suppressClick) { return; } this.clearClickSuppression(); event.preventDefault(); event.stopImmediatePropagation(); }; private onClickSuppressionPointerDown = (): void => { this.clearClickSuppression(); }; private onContextMenu = (): void => { if (!this.drag?.active) { this.cancel(); this.clearPinch(); } }; private onPointerDown = (event: PointerEvent): void => { if (event.pointerType === "mouse" && event.button !== 0) { return; } if (this.trackPinchPointerDown(event)) { return; } if (this.pinch) { return; } if (this.drag) { return; } const callbacks = this.callbacks(); const canDrag = callbacks.shouldCaptureDrag?.(event) ?? true; const observeTap = canDrag || (callbacks.shouldObserveTap?.(event) ?? false); if (!observeTap) { return; } this.start(event.pointerId, event.pointerType, event.clientX, event.clientY, event, canDrag); if (event.pointerType === "mouse") { this.addMouseListeners(); } }; private onMouseDown = (event: MouseEvent): void => { if (event.button !== 0 || typeof PointerEvent !== "undefined" || this.drag) { return; } const canDrag = this.callbacks().shouldCaptureDrag?.(event) ?? true; if (!canDrag) { return; } this.start(MOUSE_POINTER_ID, "mouse", event.clientX, event.clientY, event, true); this.addMouseListeners(); }; private start( pointerId: number, pointerType: string, clientX: number, clientY: number, event: PointerEvent | MouseEvent, canDrag: boolean, ): void { this.drag = { active: false, canDrag, captureTarget: null, pointerId, pointerType, startClientX: clientX, startClientY: clientY, lastClientX: clientX, lastClientY: clientY, lastMoveTime: event.timeStamp, holdConsumed: false, startTarget: event.target, tapCancelled: false, velocityX: 0, velocityY: 0, }; const captureTarget = event.target as Element | null; if (canDrag && "pointerId" in event && typeof captureTarget?.setPointerCapture === "function") { captureTarget.setPointerCapture(pointerId); this.drag.captureTarget = captureTarget; } this.addPointerListeners(); this.startHold(this.drag, event); } private onPointerMove = (event: PointerEvent): void => { if (!this.drag || event.pointerId !== this.drag.pointerId || this.drag.pointerType === "mouse") { return; } this.move(event.clientX, event.clientY, event); }; private onPointerUp = (event: PointerEvent): void => { if (!this.drag || event.pointerId !== this.drag.pointerId) { return; } this.finish(event.clientX, event.clientY, event); this.releasePinchPointer(event); }; private onPointerCancel = (event: PointerEvent): void => { if (!this.drag || event.pointerId !== this.drag.pointerId) { return; } this.finish(event.clientX, event.clientY, event, true); this.releasePinchPointer(event); }; private onMouseMove = (event: MouseEvent): void => { if (!this.drag || this.drag.pointerType !== "mouse") { return; } this.move(event.clientX, event.clientY, event); }; private onMouseUp = (event: MouseEvent): void => { if (!this.drag || this.drag.pointerType !== "mouse") { return; } this.finish(event.clientX, event.clientY, event); }; private move(clientX: number, clientY: number, event: PointerEvent | MouseEvent): void { const drag = this.drag; if (!drag) { return; } if (drag.holdConsumed) { event.preventDefault(); return; } const dx = clientX - drag.startClientX; const dy = clientY - drag.startClientY; const tapMoveThreshold = this.tapMoveThreshold(); if (Math.abs(dx) >= tapMoveThreshold || Math.abs(dy) >= tapMoveThreshold) { drag.tapCancelled = true; this.clearHold(); } if (!drag.canDrag) { this.updateLastMove(drag, clientX, clientY, event); return; } const intent = this.dragIntent(dx, dy); if (!drag.active && intent === "cancel") { this.cancel(); return; } if (!drag.active && intent !== "start") { this.updateLastMove(drag, clientX, clientY, event); return; } if (!drag.active) { this.activateDrag(drag, event); } const elapsed = Math.max(1, event.timeStamp - drag.lastMoveTime); drag.velocityX = (clientX - drag.lastClientX) / elapsed; drag.velocityY = (clientY - drag.lastClientY) / elapsed; drag.lastClientX = clientX; drag.lastClientY = clientY; drag.lastMoveTime = event.timeStamp; this.callbacks().onMove?.( { pointerId: drag.pointerId, clientX, clientY, dx: clientX - drag.startClientX, dy: clientY - drag.startClientY, velocityX: drag.velocityX, velocityY: drag.velocityY, }, event, ); event.preventDefault(); } private finish(clientX: number, clientY: number, event: PointerEvent | MouseEvent, cancelled = false): void { const drag = this.drag; if (!drag) { return; } this.drag = null; this.clearHold(); this.setDragging(false); this.releaseCapture(drag); this.removePointerListeners(); this.removeMouseListeners(); const info = { pointerId: drag.pointerId, clientX, clientY, dx: clientX - drag.startClientX, dy: clientY - drag.startClientY, velocityX: drag.velocityX, velocityY: drag.velocityY, }; if (drag.holdConsumed) { this.suppressNextClick(); return; } const tapMoveThreshold = this.tapMoveThreshold(); const isTap = !drag.tapCancelled && Math.abs(info.dx) < tapMoveThreshold && Math.abs(info.dy) < tapMoveThreshold; if (!cancelled && !drag.active && isTap) { this.callbacks().onTap?.({ ...info, startTarget: drag.startTarget }, event); } if (drag.active) { if (cancelled) { this.callbacks().onEnd?.({ ...info, dx: 0, dy: 0, velocityX: 0, velocityY: 0 }, event); return; } this.suppressNextClick(); this.callbacks().onEnd?.(info, event); } } private addPointerListeners(): void { document.addEventListener("pointermove", this.onPointerMove, true); document.addEventListener("pointerup", this.onPointerUp, true); document.addEventListener("pointercancel", this.onPointerCancel, true); } private removePointerListeners(): void { document.removeEventListener("pointermove", this.onPointerMove, true); document.removeEventListener("pointerup", this.onPointerUp, true); document.removeEventListener("pointercancel", this.onPointerCancel, true); } private addMouseListeners(): void { window.addEventListener("mousemove", this.onMouseMove, true); window.addEventListener("mouseup", this.onMouseUp, true); } private removeMouseListeners(): void { window.removeEventListener("mousemove", this.onMouseMove, true); window.removeEventListener("mouseup", this.onMouseUp, true); } private trackPinchPointerDown(event: PointerEvent): boolean { const callbacks = this.callbacks(); if (!callbacks.onPinchStart || event.pointerType === "mouse") { return false; } this.pinchPointers.set(event.pointerId, { clientX: event.clientX, clientY: event.clientY, }); if (this.pinch || this.pinchPointers.size !== 2) { return false; } const snapshot = this.pinchSnapshot(); if (!snapshot) { return false; } const started = callbacks.onPinchStart(snapshot, event); if (!started) { this.pinchPointers.delete(event.pointerId); return false; } this.cancel(true); this.pinch = { startDistance: snapshot.distance, }; this.addPinchListeners(); event.preventDefault(); event.stopPropagation(); return true; } private onPinchPointerMove = (event: PointerEvent): void => { if (!this.pinch || !this.pinchPointers.has(event.pointerId)) { return; } this.pinchPointers.set(event.pointerId, { clientX: event.clientX, clientY: event.clientY, }); const snapshot = this.pinchSnapshot(); if (!snapshot) { return; } this.callbacks().onPinchMove?.( { ...snapshot, scale: snapshot.distance / this.pinch.startDistance, }, event, ); event.preventDefault(); }; private onPinchPointerEnd = (event: PointerEvent): void => { if (!this.pinchPointers.has(event.pointerId)) { return; } this.pinchPointers.delete(event.pointerId); if (!this.pinch || this.pinchPointers.size >= 2) { return; } this.callbacks().onPinchEnd?.(); this.clearPinch(); event.preventDefault(); }; private addPinchListeners(): void { document.addEventListener("pointermove", this.onPinchPointerMove, true); document.addEventListener("pointerup", this.onPinchPointerEnd, true); document.addEventListener("pointercancel", this.onPinchPointerEnd, true); } private removePinchListeners(): void { document.removeEventListener("pointermove", this.onPinchPointerMove, true); document.removeEventListener("pointerup", this.onPinchPointerEnd, true); document.removeEventListener("pointercancel", this.onPinchPointerEnd, true); } private clearPinch(): void { this.pinch = null; this.pinchPointers.clear(); this.removePinchListeners(); } private releasePinchPointer(event: PointerEvent): void { if (!this.pinch) { this.pinchPointers.delete(event.pointerId); } } private pinchSnapshot(): PointerPinchStart | null { const points = Array.from(this.pinchPointers.values()); const first = points[0]; const second = points[1]; if (!first || !second) { return null; } const dx = second.clientX - first.clientX; const dy = second.clientY - first.clientY; return { clientX: (first.clientX + second.clientX) / 2, clientY: (first.clientY + second.clientY) / 2, distance: Math.hypot(dx, dy), }; } private tapMoveThreshold(): number { return this.callbacks().tapMoveThreshold ?? DEFAULT_TAP_MOVE_THRESHOLD_PX; } private startHold(drag: GesturePointer, event: PointerEvent | MouseEvent): void { const callbacks = this.callbacks(); if (callbacks.holdDelay === undefined || !callbacks.onHold) { return; } this.holdTimer = window.setTimeout(() => { this.holdTimer = null; if (this.drag !== drag || drag.active || drag.tapCancelled) { return; } const result = this.callbacks().onHold?.({ clientX: drag.startClientX, clientY: drag.startClientY, pointerId: drag.pointerId, }, event); if (!result) { return; } drag.tapCancelled = true; if (result === "consume") { drag.holdConsumed = true; } else { this.activateDrag(drag, event); } }, callbacks.holdDelay); } private clearHold(): void { if (this.holdTimer !== null) { window.clearTimeout(this.holdTimer); this.holdTimer = null; } } private dragStartThreshold(): number { return this.callbacks().dragStartThreshold ?? DEFAULT_DRAG_START_THRESHOLD_PX; } private dragIntentRatio(): number { return this.callbacks().dragIntentRatio ?? DEFAULT_DRAG_INTENT_RATIO; } private dragAxis(): PointerDragAxis { return this.callbacks().dragAxis ?? "any"; } private dragIntent(dx: number, dy: number): "pending" | "start" | "cancel" { const absX = Math.abs(dx); const absY = Math.abs(dy); const threshold = this.dragStartThreshold(); const ratio = this.dragIntentRatio(); if (this.dragAxis() === "x") { if (absY >= threshold && absY > absX) { return "cancel"; } return absX >= threshold && absX >= absY * ratio ? "start" : "pending"; } if (this.dragAxis() === "y") { if (absX >= threshold && absX > absY) { return "cancel"; } return absY >= threshold && absY >= absX * ratio ? "start" : "pending"; } return Math.hypot(dx, dy) >= threshold ? "start" : "pending"; } private activateDrag(drag: GesturePointer, event: PointerEvent | MouseEvent): void { drag.active = true; this.setDragging(true); if (drag.pointerType === "mouse") { window.getSelection()?.removeAllRanges(); } this.callbacks().onStart?.( { pointerId: drag.pointerId, clientX: drag.startClientX, clientY: drag.startClientY, }, event, ); event.preventDefault(); } private updateLastMove(drag: GesturePointer, clientX: number, clientY: number, event: PointerEvent | MouseEvent): void { const elapsed = Math.max(1, event.timeStamp - drag.lastMoveTime); drag.velocityX = (clientX - drag.lastClientX) / elapsed; drag.velocityY = (clientY - drag.lastClientY) / elapsed; drag.lastClientX = clientX; drag.lastClientY = clientY; drag.lastMoveTime = event.timeStamp; } private suppressNextClick(): void { this.suppressClick = true; this.target.addEventListener("click", this.onClick, true); this.target.addEventListener("mousedown", this.onClickSuppressionPointerDown, true); this.target.addEventListener("pointerdown", this.onClickSuppressionPointerDown, true); if (this.suppressClickTimer !== null) { window.clearTimeout(this.suppressClickTimer); } this.suppressClickTimer = window.setTimeout(() => { this.clearClickSuppression(); }, 400); } private clearClickSuppression(): void { this.suppressClick = false; this.target.removeEventListener("click", this.onClick, true); this.target.removeEventListener("mousedown", this.onClickSuppressionPointerDown, true); this.target.removeEventListener("pointerdown", this.onClickSuppressionPointerDown, true); if (this.suppressClickTimer !== null) { window.clearTimeout(this.suppressClickTimer); this.suppressClickTimer = null; } } private setDragging(dragging: boolean): void { this.target.dataset.dragging = String(dragging); } private releaseCapture(drag: GesturePointer): void { if (drag.captureTarget?.hasPointerCapture(drag.pointerId)) { drag.captureTarget.releasePointerCapture(drag.pointerId); } } } type GesturePointer = { active: boolean; canDrag: boolean; captureTarget: Element | null; holdConsumed: boolean; pointerId: number; pointerType: string; startClientX: number; startClientY: number; lastClientX: number; lastClientY: number; lastMoveTime: number; startTarget: EventTarget | null; tapCancelled: boolean; velocityX: number; velocityY: number; }; export function createPointerGestureElement( target: Accessor, callbacks: Accessor, ): Accessor { let gesture: PointerGesture | null = null; createEffect(() => { const element = target(); if (!element) { return; } gesture = new PointerGesture(element, callbacks); onCleanup(() => { gesture?.dispose(); gesture = null; }); }); return () => gesture?.isDragging() ?? false; }