Odysseus v1.0
This commit is contained in:
265
static/js/dragSort.js
Normal file
265
static/js/dragSort.js
Normal file
@@ -0,0 +1,265 @@
|
||||
// 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;
|
||||
Reference in New Issue
Block a user