Source: Draggable.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
/**
 * 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 }