/**
* Draggable class that enables HTML elements to be moved within a parent container.
*
* This class creates a draggable container with a handle and attaches the specified
* element to it. The element can then be dragged around its parent container using
* the handle, providing an interactive UI element for repositioning content.
*
* Features:
* - Flexible positioning using top/bottom and left/right coordinates
* - Customizable handle size, color, and appearance
* - Maintains position relative to parent container edges on window resize
* - Touch-enabled with pointer events support for multi-device compatibility
* - Smooth drag animations with visual feedback during movement
* - Boundary constraints within the parent container
*
* @example
* // Create a draggable element at the bottom-right corner
* const element = document.getElementById('my-element');
* const parent = document.querySelector('.parent-container');
* const draggable = new Draggable(element, parent, {
* bottom: 20,
* right: 20,
* handleColor: 'rgba(100, 150, 200, 0.7)'
* });
*/
class Draggable {
/**
* Creates a new Draggable instance.
*
* @param {HTMLElement} element - The element to be made draggable
* @param {HTMLElement|string} parent - The parent element where the draggable container will be appended.
* Can be either an HTMLElement or a CSS selector string
* @param {Object} [options={}] - Configuration options for the draggable element
* @param {number|null} [options.top=null] - The initial top position in pixels. Mutually exclusive with bottom
* @param {number|null} [options.bottom=20] - The initial bottom position in pixels. Mutually exclusive with top
* @param {number|null} [options.left=null] - The initial left position in pixels. Mutually exclusive with right
* @param {number|null} [options.right=20] - The initial right position in pixels. Mutually exclusive with left
* @param {number} [options.handleSize=10] - The size of the drag handle in pixels
* @param {number} [options.handleGap=5] - The gap between the handle and the draggable content in pixels
* @param {number} [options.zindex=200] - The z-index of the draggable container
* @param {string} [options.handleColor='#f0f0f0b3'] - The background color of the handle (supports rgba)
* @param {number} [options.dragOpacity=0.6] - Opacity of the element while being dragged (between 0 and 1)
*/
constructor(element, parent, options = {}) {
// Set default options
this.options = {
top: null,
bottom: 20,
left: null,
right: 20,
handleSize: 10,
handleGap: 5,
zindex: 200,
handleColor: '#f0f0f0b3', // rgba(240, 240, 240, 0.7)
dragOpacity: 0.6
};
// Merge user options with defaults
Object.assign(this.options, options);
// Store element and parent references
this.element = element;
this.parent = typeof parent === 'string' ? document.querySelector(parent) : parent;
if (!this.element || !this.parent) {
throw new Error('Draggable requires valid element and parent');
}
// Handle positioning priority
if (this.options.left !== null) this.options.right = null;
if (this.options.top !== null) this.options.bottom = null;
// Disable context menu globally if not already disabled
this.setupContextMenu();
// Create container and handle
this.createElements();
// Setup event listeners for dragging
this.setupDragEvents();
// Append element to container
this.appendChild(this.element);
// Setup resize handling
this.setupResizeHandler();
}
/**
* Disables the context menu globally if not already disabled.
* @private
*/
setupContextMenu() {
if (!window.setCtxMenu) {
window.addEventListener("contextmenu", e => e.preventDefault());
window.setCtxMenu = true;
}
}
/**
* Creates the draggable container and handle elements.
* @private
*/
createElements() {
const { handleGap, zindex, handleColor, handleSize } = this.options;
// Create container element
this.container = document.createElement('div');
this.container.classList.add('openlime-draggable');
this.container.style.display = 'flex';
this.container.style.gap = `${handleGap}px`;
this.container.style.position = 'absolute';
this.container.style.zIndex = zindex;
this.container.style.touchAction = 'none';
this.container.style.visibility = 'visible';
// Create handle element
this.handle = document.createElement('div');
this.handle.style.borderRadius = '4px';
this.handle.style.backgroundColor = handleColor;
this.handle.style.padding = '0';
this.handle.style.width = `${handleSize}px`;
this.handle.style.height = `${handleSize}px`;
this.handle.style.zIndex = zindex + 5;
this.handle.style.cursor = 'grab';
// Assemble elements
this.container.appendChild(this.handle);
this.parent.appendChild(this.container);
}
/**
* Sets up event listeners for window resize.
* @private
*/
setupResizeHandler() {
// Use debounced resize handler to improve performance
let resizeTimeout;
window.addEventListener("resize", () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => this.updatePosition(), 100);
});
}
/**
* Sets up the drag event listeners for the handle.
* Manages pointer events for drag operations.
* @private
*/
setupDragEvents() {
let offsetX, offsetY;
let isDragging = false;
// Use bound methods to maintain this context
const dragStart = (e) => {
e.preventDefault();
// Set dragging state
isDragging = true;
this.container.style.opacity = this.options.dragOpacity;
this.handle.style.cursor = 'grabbing';
// Calculate offsets based on pointer position
offsetX = e.clientX - this.container.offsetLeft;
offsetY = e.clientY - this.container.offsetTop;
// Add move event listener
document.addEventListener("pointermove", drag);
};
const drag = (e) => {
if (!isDragging) return;
e.preventDefault();
// Calculate new position
const newLeft = Math.max(0, Math.min(
e.clientX - offsetX,
this.parent.offsetWidth - this.container.offsetWidth
));
const newTop = Math.max(0, Math.min(
e.clientY - offsetY,
this.parent.offsetHeight - this.container.offsetHeight
));
// Update position
this.container.style.left = `${newLeft}px`;
this.container.style.top = `${newTop}px`;
// Update the option values based on new position
this.options.left = newLeft;
this.options.right = null;
this.options.top = newTop;
this.options.bottom = null;
};
const dragEnd = () => {
if (!isDragging) return;
// Reset visual state
this.container.style.opacity = '1.0';
this.handle.style.cursor = 'grab';
// Clear dragging state
isDragging = false;
// Remove move event listener
document.removeEventListener("pointermove", drag);
};
// Attach event listeners
this.handle.addEventListener("pointerdown", dragStart);
document.addEventListener("pointerup", dragEnd);
document.addEventListener("pointercancel", dragEnd);
}
/**
* Appends an HTML element to the draggable container and updates its position.
* @param {HTMLElement} element - The element to append to the draggable container
* @returns {Draggable} This instance for method chaining
*/
appendChild(element) {
if (element) {
// Ensure the element has proper positioning
element.style.position = 'unset';
this.container.appendChild(element);
this.updatePosition();
}
return this;
}
/**
* Updates the position of the draggable container based on its current options and parent dimensions.
* This method is called automatically on window resize and when elements are appended.
* @returns {Draggable} This instance for method chaining
*/
updatePosition() {
const containerWidth = this.container.offsetWidth;
const containerHeight = this.container.offsetHeight;
const parentWidth = this.parent.offsetWidth;
const parentHeight = this.parent.offsetHeight;
let top = 0;
let left = 0;
// Calculate top/bottom position
if (this.options.top !== null) {
top = this.options.top;
} else if (this.options.bottom !== null) {
top = parentHeight - this.options.bottom - containerHeight;
}
// Calculate left/right position
if (this.options.left !== null) {
left = this.options.left;
} else if (this.options.right !== null) {
left = parentWidth - this.options.right - containerWidth;
}
// Ensure the element stays within parent bounds
top = Math.max(0, Math.min(top, parentHeight - containerHeight));
left = Math.max(0, Math.min(left, parentWidth - containerWidth));
// Apply position
this.container.style.top = `${top}px`;
this.container.style.left = `${left}px`;
return this;
}
/**
* Shows the draggable element if it's hidden.
* @returns {Draggable} This instance for method chaining
*/
show() {
this.container.style.visibility = 'visible';
return this;
}
/**
* Hides the draggable element.
* @returns {Draggable} This instance for method chaining
*/
hide() {
this.container.style.visibility = 'hidden';
return this;
}
/**
* Changes the handle color.
* @param {string} color - New color for the handle (hex, rgb, rgba)
* @returns {Draggable} This instance for method chaining
*/
setHandleColor(color) {
this.options.handleColor = color;
this.handle.style.backgroundColor = color;
return this;
}
}
export { Draggable }