class InforDraggable extends HTMLElement { static callbacks = {}; constructor() { super(); this.grabbed_item = null; this.grabbed_copy = null; this.offset_x = 0; this.offset_y = 0; this.container = null; this.movement_x_delta = 0; this.movement_y_delta = 0; this.appendStyle(); this.onMouseDown = this.onMouseDown.bind(this); this.onMouseMove = this.onMouseMove.bind(this); this.onMouseUp = this.onMouseUp.bind(this); } static get observedAttributes() { return ['id', 'onchange', 'wire:onchange']; } connectedCallback(){ if(!this.id){ this.id = 'infor-draggable-' + Math.random().toString(36).substring(2, 15); } this.addEventListener('mousedown', this.onMouseDown); } disconnectedCallback() { this.removeEventListener('mousedown', this.onMouseDown); } onMouseDown(event){ if(event.button !== 0){ return; } const item = event.target.closest('infor-draggable-item'); if(!item){ return; } const parent = item.parentElement; if(parent.tagName !== 'INFOR-DRAGGABLE' || parent !== this){ return; } event.preventDefault(); event.stopPropagation(); const container = parent const rect = item.getBoundingClientRect(); this.offset_x = event.clientX - rect.left; this.offset_y = event.clientY - rect.top; this.container = container; this.grabbed_item = item; document.addEventListener('mousemove', this.onMouseMove); document.addEventListener('mouseup', this.onMouseUp); this.current_values = this.values; this.createDraggableCopy() this.updateDraggableCopyPosition(event); } onMouseMove(event){ this.setMoveDelta(event); this.updateDraggableCopyPosition(event); this.placeItemIntoBestPosition(); } onMouseUp(){ document.removeEventListener('mousemove', this.onMouseMove); document.removeEventListener('mouseup', this.onMouseUp); this.destroyDraggableCopy(); if(this.valuesChanged()){ this.callback(this.values, this.id); this.onchange(this.values, this.id); this.livewireCallback(this.values, this.id); } } appendStyle(){ if(InforDraggable.style_appended){ return; } InforDraggable.style_appended = true; const style = document.createElement('style'); style.textContent = ` infor-draggable{ position: relative; display: flex; flex-direction: column; gap: .5rem; } infor-draggable-item{ display: block; cursor: grab; } `; document.head.appendChild(style); } get values(){ return this.items.map(el => el.value); } get items(){ return Array.from(this.children) .filter(el => { return el.tagName === 'INFOR-DRAGGABLE-ITEM' && !el.hasAttribute('data-copy'); }); } set current_values(values){ this._current_values = values; } get current_values(){ return this._current_values; } valuesChanged(){ return JSON.stringify(this.current_values) !== JSON.stringify(this.values); } get id(){ return this.getAttribute('id'); } set id(id){ this.setAttribute('id', id); } get callback(){ return InforDraggable.callbacks[this.id] || ((values) => {}); } get onchange(){ const onchange = this.getAttribute('onchange'); if(!onchange || typeof window[onchange] !== 'function'){ return (values) => { }; } return window[onchange]; } get livewireCallback(){ const onchange = this.getAttribute('wire:onchange'); if(!window.Livewire || !onchange){ return (values) => { } } return (values, id) => { window.Livewire.emit(onchange, values, id); } } setMoveDelta(event){ this.movement_x_delta = event.movementX; this.movement_y_delta = event.movementY; } createDraggableCopy() { const copy = this.grabbed_item.cloneNode(true); const rect = this.grabbed_item.getBoundingClientRect(); const width = rect.width; const height = rect.height; copy.style.position = 'absolute'; copy.style.width = `${width}px`; copy.style.height = `${height}px`; copy.style.cursor = 'grabbing'; copy.setAttribute('data-copy', 'true'); this.container.appendChild(copy); this.grabbed_copy = copy; this.grabbed_item.style.opacity = '0'; } destroyDraggableCopy(){ this.grabbed_copy.remove(); this.grabbed_copy = null; this.grabbed_item.style.opacity = '1'; } updateDraggableCopyPosition(event){ const container_rect = this.container.getBoundingClientRect(); const container_left = container_rect.left + window.scrollX; const container_top = container_rect.top + window.scrollY; const container_width = this.container.clientWidth; const container_height = this.container.clientHeight; const container_style = getComputedStyle(this.container); const container_padding_left = parseFloat(container_style.paddingLeft); const container_padding_top = parseFloat(container_style.paddingTop); const item_rect = this.grabbed_item.getBoundingClientRect(); const item_width = item_rect.width; const item_height = item_rect.height; let x = event.pageX - container_left - this.offset_x; let y = event.pageY - container_top - this.offset_y; //Clamp to container if overflows horizontally if(x + item_width > container_width + container_padding_left){ x = container_width - item_width + container_padding_left; } else if(x < container_padding_left){ x = container_padding_left; } //Clamp to container if overflows vertically if(y + item_height > container_height + container_padding_top){ y = container_height - item_height + container_padding_top; } else if(y < container_padding_top){ y = container_padding_top; } this.grabbed_copy.style.position = 'absolute'; this.grabbed_copy.style.left = `${x}px`; this.grabbed_copy.style.top = `${y}px`; } placeItemIntoBestPosition(){ if(!this.grabbed_item || !this.grabbed_copy){ return; } let items = this.items.filter(el => el !== this.grabbed_item); // const items = this.items; const copy_bounding_rect = this.grabbed_copy.getBoundingClientRect(); //This is necessary to avoid mutation while iterating if (this.movement_y_delta < 0) { items = items.reverse(); } items.forEach((item, index) => { const item_bounding_rect = item.getBoundingClientRect(); const distance_below = Math.abs(copy_bounding_rect.bottom - item_bounding_rect.top); const distance_above = Math.abs(copy_bounding_rect.top - item_bounding_rect.bottom); //Mouse movement to the bottom, and copy bottom i 5px below next item top if( this.movement_y_delta > 0 && copy_bounding_rect.bottom > item_bounding_rect.top && distance_below > 5 ){ item.after(this.grabbed_item); } //Mouse movement to the top, and copy top is 5px above item bottom if( this.movement_y_delta < 0 && copy_bounding_rect.top < item_bounding_rect.bottom && distance_above > 5 ){ item.before(this.grabbed_item); } }) } static onChange(id, callback){ InforDraggable.callbacks[id] = callback; } static values(id){ return document.getElementById(id).values; } } class InforDraggableItem extends HTMLElement { constructor() { super(); } static get observedAttributes() { return ['value']; } get value() { return this.getAttribute('value'); } set value(val) { this.setAttribute('value', val); } } customElements.define('infor-draggable', InforDraggable); customElements.define('infor-draggable-item', InforDraggableItem);