266 lines
7.7 KiB
JavaScript
266 lines
7.7 KiB
JavaScript
// 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;
|