import WebComponentBase from '../../components/web-component-base/web-component-base.js'; import { MixinBase } from '../common/mixin-base.js'; import { PointerCoordinator } from '../common/pointer-coordinator.js'; // DraggableMixin - declarative web component for drag gesture detection // Usage: // //
Drag me!
//
class DraggableMixin extends MixinBase(WebComponentBase) { [key: string]: any; static get observedAttributes() { return ["drag-threshold"]; } constructor() { super(); this._dragStartX = 0; this._dragStartY = 0; this._dragThreshold = 5; // Minimum distance to start dragging this._isDragging = false; this._dragDistance = 0; // simple shadow that just renders children "as-is" this.attachShadow({ mode: 'open' }); const shadowRoot = this.shadowRoot; if (!shadowRoot) { throw new Error('Failed to attach shadow root'); } shadowRoot.innerHTML = ` `; // Pointer event state this._pointerDown = false; this._pointerId = null; } // Private utility functions _validateThreshold(threshold: string | null) { const num = parseInt(threshold ?? ""); return !isNaN(num) && num >= 0; } _calculateDragDistance(currentX: number, currentY: number) { const deltaX = currentX - this._dragStartX; const deltaY = currentY - this._dragStartY; return Math.sqrt(deltaX * deltaX + deltaY * deltaY); } _determineDragDirection(deltaX: number, deltaY: number) { const absX = Math.abs(deltaX); const absY = Math.abs(deltaY); if (absX > absY) { return deltaX > 0 ? "right" : "left"; } else { return deltaY > 0 ? "down" : "up"; } } // Public API setDragThreshold(threshold: string | null) { if (this._validateThreshold(threshold)) { this._dragThreshold = parseInt(threshold ?? ""); } } onDragStart(details: Record) { this.dispatchEvent(new CustomEvent("dragstart", { detail: details, bubbles: true, composed: true, })); } onDragMove(details: Record) { this.dispatchEvent(new CustomEvent("dragmove", { detail: details, bubbles: true, composed: true, })); } onDragEnd(details: Record) { this.dispatchEvent(new CustomEvent("dragend", { detail: details, bubbles: true, composed: true, })); } connectedCallback() { super.connectedCallback(); // Set initial attributes const threshold = this.getAttribute("drag-threshold"); if (this._validateThreshold(threshold)) { this._dragThreshold = parseInt(threshold ?? ""); } // Attach pointer events to the host element this.addEventListener("pointerdown", this._handlePointerDown); this.addEventListener("pointermove", this._handlePointerMove); this.addEventListener("pointerup", this._handlePointerUp); this.addEventListener("pointercancel", this._handlePointerUp); this.addEventListener("pointerleave", this._handlePointerUp); } disconnectedCallback() { super.disconnectedCallback(); this.removeEventListener("pointerdown", this._handlePointerDown); this.removeEventListener("pointermove", this._handlePointerMove); this.removeEventListener("pointerup", this._handlePointerUp); this.removeEventListener("pointercancel", this._handlePointerUp); this.removeEventListener("pointerleave", this._handlePointerUp); } attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { super.attributeChangedCallback(name, oldValue, newValue); if (name === "drag-threshold" && this._validateThreshold(newValue)) { this._dragThreshold = parseInt(newValue ?? ""); } } _handlePointerDown = (event: PointerEvent) => { if (this._pointerDown) return; // Only track one pointer // Try to capture the pointer (only for non-redispatched events) if (!PointerCoordinator.isRedispatchedEvent(event)) { if (!PointerCoordinator.capturePointer(this, event.pointerId)) { return; // Another mixin captured it, we'll listen for redispatched events } // Redispatch the event so other mixins can receive it PointerCoordinator.redispatchPointerEvent(this, event); } // Process the event (only if we captured it OR if it's a redispatched event from another element) if (PointerCoordinator.isRedispatchedEvent(event) || PointerCoordinator.hasPointerCapture(this, event.pointerId)) { // If we captured the pointer, only process direct events (not redispatched ones) if (PointerCoordinator.hasPointerCapture(this, event.pointerId) && PointerCoordinator.isRedispatchedEvent(event)) { return; } this._pointerDown = true; this._pointerId = event.pointerId; this._dragStartX = event.clientX; this._dragStartY = event.clientY; this._isDragging = false; this._dragDistance = 0; } }; _handlePointerMove = (event: PointerEvent) => { if (!this._pointerDown || event.pointerId !== this._pointerId) return; // Only process if we captured the pointer or it's a redispatched event from another element if (!PointerCoordinator.isRedispatchedEvent(event) && !PointerCoordinator.hasPointerCapture(this, event.pointerId)) { return; } // If we captured the pointer, only process direct events (not redispatched ones) if (PointerCoordinator.hasPointerCapture(this, event.pointerId) && PointerCoordinator.isRedispatchedEvent(event)) { return; } // Redispatch the event (only for non-redispatched events) if (!PointerCoordinator.isRedispatchedEvent(event)) { PointerCoordinator.redispatchPointerEvent(this, event); } const currentDistance = this._calculateDragDistance(event.clientX, event.clientY); const deltaX = event.clientX - this._dragStartX; const deltaY = event.clientY - this._dragStartY; // Only prevent scrolling if we're actually processing a gesture if (PointerCoordinator.shouldProcessGesture(deltaX, deltaY, this._dragThreshold)) { event.preventDefault(); } // Check if we should start dragging if (!this._isDragging && currentDistance >= this._dragThreshold) { this._isDragging = true; this.onDragStart({ startX: this._dragStartX, startY: this._dragStartY, currentX: event.clientX, currentY: event.clientY, deltaX, deltaY, distance: currentDistance, direction: this._determineDragDirection(deltaX, deltaY) }); } // If we're dragging, emit drag move events if (this._isDragging) { this._dragDistance = currentDistance; this.onDragMove({ startX: this._dragStartX, startY: this._dragStartY, currentX: event.clientX, currentY: event.clientY, deltaX, deltaY, distance: currentDistance, direction: this._determineDragDirection(deltaX, deltaY), isDragging: true }); } }; _handlePointerUp = (event: PointerEvent) => { if (!this._pointerDown || event.pointerId !== this._pointerId) return; // Only process if we captured the pointer or it's a redispatched event from another element if (!PointerCoordinator.isRedispatchedEvent(event) && !PointerCoordinator.hasPointerCapture(this, event.pointerId)) { return; } // If we captured the pointer, only process direct events (not redispatched ones) if (PointerCoordinator.hasPointerCapture(this, event.pointerId) && PointerCoordinator.isRedispatchedEvent(event)) { return; } // Redispatch the event (only for non-redispatched events) if (!PointerCoordinator.isRedispatchedEvent(event)) { PointerCoordinator.redispatchPointerEvent(this, event); } this._pointerDown = false; PointerCoordinator.releasePointer(this, this._pointerId); const finalDistance = this._calculateDragDistance(event.clientX, event.clientY); const deltaX = event.clientX - this._dragStartX; const deltaY = event.clientY - this._dragStartY; // If we were dragging, emit drag end event if (this._isDragging) { this.onDragEnd({ startX: this._dragStartX, startY: this._dragStartY, endX: event.clientX, endY: event.clientY, deltaX, deltaY, distance: finalDistance, direction: this._determineDragDirection(deltaX, deltaY), wasDragging: true }); } // Reset state this._isDragging = false; this._dragDistance = 0; }; } export { DraggableMixin };