// static/js/dragSort.js /** * Vertical drag-and-drop sorting with magnetic snap behavior */ import Storage from './storage.js'; const instances = new Map(); /** * Make a container's children sortable via vertical drag */ export function enable(containerId, itemSelector, options = {}) { const container = document.getElementById(containerId); if (!container) { console.warn('[DragSort] container not found:', containerId); return; } // Allow multiple instances per container via instanceKey const key = options.instanceKey || containerId; // Clean up previous instance if (instances.has(key)) { instances.get(key).cleanup(); instances.delete(key); } const config = { onReorder: options.onReorder || null, handleSelector: options.handleSelector || null, excludeSelector: options.excludeSelector || null, storageKey: options.storageKey || null, }; let draggedEl = null; let placeholder = null; let offsetY = 0; let items = []; function getItems() { let all = Array.from(container.querySelectorAll(itemSelector)); if (config.excludeSelector) { all = all.filter(el => !el.matches(config.excludeSelector)); } return all; } function createPlaceholder(height) { const ph = document.createElement('div'); ph.className = 'drag-placeholder'; ph.style.height = height + 'px'; ph.style.margin = '4px 0'; return ph; } // --- Shared drag logic --- function startDrag(clientY, item) { const rect = item.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); const relativeTop = rect.top - containerRect.top + container.scrollTop; const relativeLeft = rect.left - containerRect.left; offsetY = clientY - rect.top; items = getItems(); draggedEl = item; if (getComputedStyle(container).position === 'static') { container.style.position = 'relative'; } placeholder = createPlaceholder(rect.height); item.parentNode.insertBefore(placeholder, item); item.classList.add('dragging'); Object.assign(item.style, { position: 'absolute', width: rect.width + 'px', left: relativeLeft + 'px', top: relativeTop + 'px', zIndex: '9999', pointerEvents: 'none', margin: '0', boxSizing: 'border-box', transition: 'none' }); } function moveDrag(clientY) { if (!draggedEl) return; const containerRect = container.getBoundingClientRect(); const newTop = clientY - offsetY - containerRect.top + container.scrollTop; draggedEl.style.top = newTop + 'px'; const otherItems = items.filter(i => i !== draggedEl); const dragRect = draggedEl.getBoundingClientRect(); const dragCenter = dragRect.top + dragRect.height / 2; let insertBefore = null; for (const item of otherItems) { const rect = item.getBoundingClientRect(); const itemCenter = rect.top + rect.height / 2; if (dragCenter < itemCenter) { insertBefore = item; break; } } if (insertBefore) { if (placeholder.nextElementSibling !== insertBefore) { container.insertBefore(placeholder, insertBefore); } } else if (otherItems.length > 0) { const lastItem = otherItems[otherItems.length - 1]; if (placeholder !== lastItem.nextElementSibling) { container.insertBefore(placeholder, lastItem.nextElementSibling); } } } function endDrag() { if (!draggedEl) return; const phRect = placeholder.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); const snapTop = phRect.top - containerRect.top + container.scrollTop; draggedEl.style.transition = 'top 0.08s ease-out'; draggedEl.style.top = snapTop + 'px'; setTimeout(() => { placeholder.parentNode.insertBefore(draggedEl, placeholder); placeholder.remove(); draggedEl.classList.remove('dragging'); draggedEl.style.cssText = ''; if (config.storageKey) { const ids = getItems().map(el => el.dataset.sessionId || el.dataset.modelId || el.dataset.filePath ).filter(Boolean); if (ids.length) { Storage.setJSON(config.storageKey, ids); } } if (config.onReorder) { config.onReorder(getItems()); } draggedEl = null; placeholder = null; items = []; }, 80); } // --- Mouse events --- function onMouseDown(e) { if (e.button !== 0) return; if (config.handleSelector && !e.target.closest(config.handleSelector)) return; const item = e.target.closest(itemSelector); if (!item || !container.contains(item)) return; if (config.excludeSelector && item.matches(config.excludeSelector)) return; e.preventDefault(); e.stopPropagation(); startDrag(e.clientY, item); document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); } function onMouseMove(e) { moveDrag(e.clientY); } function onMouseUp() { document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); endDrag(); } // --- Touch events (long-press to start) --- let _touchTimer = null; let _touchStartY = 0; let _touchItem = null; function onTouchStart(e) { // Don't start on buttons/inputs. if (e.target.closest('button, input, select, a')) return; // Respect handleSelector on touch too — long-press anywhere was // unintentionally letting users start a reorder from the whole row. if (config.handleSelector && !e.target.closest(config.handleSelector)) return; const item = e.target.closest(itemSelector); if (!item || !container.contains(item)) return; if (config.excludeSelector && item.matches(config.excludeSelector)) return; _touchItem = item; const startY = e.touches[0].clientY; _touchStartY = startY; // Long-press: 400ms hold to initiate drag _touchTimer = setTimeout(() => { _touchTimer = null; if (!_touchItem) return; // Haptic feedback if available if (navigator.vibrate) navigator.vibrate(30); startDrag(startY, _touchItem); _touchItem.classList.add('touch-dragging'); }, 400); } function onTouchMove(e) { // If long-press hasn't fired yet, cancel if finger moved too much if (_touchTimer) { const dy = Math.abs(e.touches[0].clientY - _touchStartY); if (dy > 10) { clearTimeout(_touchTimer); _touchTimer = null; _touchItem = null; } return; } // If dragging, prevent scroll and move if (draggedEl) { e.preventDefault(); moveDrag(e.touches[0].clientY); } } function onTouchEnd() { if (_touchTimer) { clearTimeout(_touchTimer); _touchTimer = null; _touchItem = null; return; } if (draggedEl) { draggedEl.classList.remove('touch-dragging'); endDrag(); } } container.addEventListener('mousedown', onMouseDown); container.addEventListener('touchstart', onTouchStart, { passive: true }); container.addEventListener('touchmove', onTouchMove, { passive: false }); container.addEventListener('touchend', onTouchEnd); container.addEventListener('touchcancel', onTouchEnd); const instance = { cleanup: () => { container.removeEventListener('mousedown', onMouseDown); container.removeEventListener('touchstart', onTouchStart); container.removeEventListener('touchmove', onTouchMove); container.removeEventListener('touchend', onTouchEnd); container.removeEventListener('touchcancel', onTouchEnd); }, refresh: () => { items = getItems(); } }; instances.set(key, instance); return instance; } const dragSortModule = { enable }; export default dragSortModule; window.dragSortModule = dragSortModule;