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 };