Refactor service drag-and-drop to use SortableJS

Replaces custom drag-and-drop logic in ServicesManager with SortableJS for improved reliability and cross-browser support. Updates UI to use a dedicated drag handle, removes legacy drag styles, and adds new CSS for SortableJS integration. Also removes redundant version comments from source files.
This commit is contained in:
xixu-me committed 2025-07-14 16:40:26 +08:00
1 parent 56298322ba
commit 157c1d40d5
8 files changed
+177 -361

No files matched your search

+3
View File
@@ -56,6 +56,9 @@
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
rel="stylesheet"
/>
<!-- SortableJS for drag-and-drop functionality -->
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
</head>
<body>
<!-- Theme Toggle -->
-1
View File
@@ -13,7 +13,6 @@
*
* @author xixu-me
* @license GPL-3.0
* @version 2.0.0
*/
import { OutputManager } from "./components/OutputManager.js";
-1
View File
@@ -9,7 +9,6 @@
*
* @author xixu-me
* @license GPL-3.0
* @version 2.0.0
*/
export class OutputManager {
@@ -7,7 +7,6 @@
*
* @author xixu-me
* @license GPL-3.0
* @version 2.0.0
*/
export class ServiceConfigGenerator {
+87 -244
View File
@@ -9,7 +9,6 @@
*
* @author xixu-me
* @license GPL-3.0
* @version 2.0.0
*/
export class ServicesManager {
@@ -21,19 +20,9 @@ export class ServicesManager {
this.allServices = [];
this.selectedServices = new Set();
this.selectedServicesOrder = []; // Track order for drag functionality
this.selectedServicesOrder = [];
this.searchTimeout = null;
// Drag state
this.dragState = {
isDragging: false,
draggedElement: null,
draggedIndex: -1,
placeholder: null,
initialMouseY: 0,
initialElementY: 0,
mouseOffset: 0,
};
this.sortable = null; // SortableJS instance
this.elements = {
searchInput: document.getElementById("search-input"),
@@ -127,26 +116,6 @@ export class ServicesManager {
this.elements.selectedServices.addEventListener("click", (e) => {
this.handleSelectedServiceClick(e);
});
// Handle drag events
this.setupDragListeners();
}
/**
* Set up drag and drop event listeners
*/
setupDragListeners() {
this.elements.selectedServices.addEventListener("mousedown", (e) => {
this.handleDragStart(e);
});
document.addEventListener("mousemove", (e) => {
this.handleDragMove(e);
});
document.addEventListener("mouseup", (e) => {
this.handleDragEnd(e);
});
}
/**
@@ -299,6 +268,7 @@ export class ServicesManager {
<p>Search for services above to get started</p>
</div>
`;
this.destroySortable();
return;
}
@@ -306,9 +276,11 @@ export class ServicesManager {
this.elements.selectedServices.innerHTML = this.selectedServicesOrder
.map(
(serviceName, index) => `
<div class="selected-service-item" data-index="${index}">
<div class="selected-service-item" data-index="${index}" data-service-name="${serviceName}">
<div class="drag-handle" title="Drag to reorder">
<i class="fas fa-grip-vertical"></i>
</div>
<div class="service-info">
<i class="fas fa-grip-lines service-drag-handle" title="Drag to reorder"></i>
<span class="service-name">${serviceName}</span>
</div>
<button class="service-remove-btn"
@@ -321,6 +293,74 @@ export class ServicesManager {
`
)
.join("");
// Initialize or reinitialize sortable
this.initializeSortable();
}
/**
* Initialize SortableJS for drag-and-drop reordering
*/
initializeSortable() {
// Destroy existing sortable instance if it exists
this.destroySortable();
// Only initialize if we have selected services
if (this.selectedServices.size === 0) {
return;
}
// Initialize SortableJS
this.sortable = new Sortable(this.elements.selectedServices, {
animation: 200, // Animation speed in ms
ghostClass: "sortable-ghost", // Class for the drop placeholder
dragClass: "sortable-drag", // Class for the item being dragged
chosenClass: "sortable-chosen", // Class for the chosen item
handle: ".drag-handle", // Only allow dragging by the handle
forceFallback: true, // Force HTML5 DnD fallback for better cross-browser support
fallbackClass: "sortable-fallback", // Class for fallback
onStart: (evt) => {
// Add dragging state to container
this.elements.selectedServices.classList.add("is-dragging");
},
onEnd: (evt) => {
// Remove dragging state from container
this.elements.selectedServices.classList.remove("is-dragging");
// Update the order if the position changed
if (evt.oldIndex !== evt.newIndex) {
this.updateServiceOrder(evt.oldIndex, evt.newIndex);
}
},
});
}
/**
* Destroy the current SortableJS instance
*/
destroySortable() {
if (this.sortable) {
this.sortable.destroy();
this.sortable = null;
}
}
/**
* Update service order after drag and drop
* @param {number} oldIndex - Previous index
* @param {number} newIndex - New index
*/
updateServiceOrder(oldIndex, newIndex) {
// Move the item in the order array
const movedService = this.selectedServicesOrder.splice(oldIndex, 1)[0];
this.selectedServicesOrder.splice(newIndex, 0, movedService);
// Trigger selection change event for other components
this.triggerSelectionChange();
console.log(
`Moved service "${movedService}" from position ${oldIndex} to ${newIndex}`
);
}
/**
@@ -350,218 +390,21 @@ export class ServicesManager {
}
/**
* Handle drag start
* @param {MouseEvent} event - Mouse event
* Destroy the services manager and cleanup
*/
handleDragStart(event) {
const dragHandle = event.target.closest(".service-drag-handle");
if (!dragHandle) return;
event.preventDefault();
const serviceItem = dragHandle.closest(".selected-service-item");
if (!serviceItem) return;
this.dragState.isDragging = true;
this.dragState.draggedElement = serviceItem;
this.dragState.draggedIndex = parseInt(serviceItem.dataset.index);
// Store initial positions for mouse following
const rect = serviceItem.getBoundingClientRect();
this.dragState.initialMouseY = event.clientY;
this.dragState.initialElementY = rect.top;
this.dragState.mouseOffset = event.clientY - rect.top;
// Create placeholder with smooth appearance
this.dragState.placeholder = document.createElement("div");
this.dragState.placeholder.className =
"selected-service-item drag-placeholder";
this.dragState.placeholder.innerHTML = `
<div class="service-info">
<i class="fas fa-grip-lines service-drag-handle"></i>
<span class="service-name"></span>
</div>
`;
// Add dragging classes with smooth animation
serviceItem.classList.add("dragging");
document.body.classList.add("dragging");
// Insert placeholder with animation
this.dragState.placeholder.style.height = "0px";
this.dragState.placeholder.style.opacity = "0";
this.dragState.placeholder.style.marginBottom = "0px";
serviceItem.parentNode.insertBefore(
this.dragState.placeholder,
serviceItem.nextSibling
);
// Animate placeholder appearance
requestAnimationFrame(() => {
this.dragState.placeholder.style.height = serviceItem.offsetHeight + "px";
this.dragState.placeholder.style.opacity = "1";
this.dragState.placeholder.style.marginBottom = "0.75rem";
});
}
/**
* Handle drag move
* @param {MouseEvent} event - Mouse event
*/
handleDragMove(event) {
if (!this.dragState.isDragging) return;
// Make dragged element follow mouse vertically
const draggedElement = this.dragState.draggedElement;
if (draggedElement) {
const deltaY = event.clientY - this.dragState.initialMouseY;
draggedElement.style.transform = `translateY(${deltaY}px) scale(1.05)`;
draggedElement.style.zIndex = "1000";
draggedElement.style.position = "relative";
destroy() {
// Clear search timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
this.searchTimeout = null;
}
const serviceItems = Array.from(
this.elements.selectedServices.querySelectorAll(
".selected-service-item:not(.dragging):not(.drag-placeholder)"
)
);
// Destroy sortable instance
this.destroySortable();
let targetIndex = serviceItems.length; // Default to end
let closestItem = null;
let closestDistance = Infinity;
// Find the closest item for smoother animation
for (let i = 0; i < serviceItems.length; i++) {
const item = serviceItems[i];
const rect = item.getBoundingClientRect();
const itemCenter = rect.top + rect.height / 2;
const distance = Math.abs(event.clientY - itemCenter);
if (distance < closestDistance) {
closestDistance = distance;
closestItem = item;
}
if (event.clientY < itemCenter) {
targetIndex = i;
break;
}
}
// Add smooth animation classes to other items
serviceItems.forEach((item, index) => {
const rect = item.getBoundingClientRect();
const itemCenter = rect.top + rect.height / 2;
if (event.clientY < itemCenter && index >= targetIndex) {
item.style.transform = "translateY(8px)";
} else if (event.clientY > itemCenter && index < targetIndex) {
item.style.transform = "translateY(-8px)";
} else {
item.style.transform = "translateY(0)";
}
});
// Move placeholder to new position with smooth transition
const targetItem = serviceItems[targetIndex];
if (targetItem) {
targetItem.parentNode.insertBefore(
this.dragState.placeholder,
targetItem
);
} else {
// Insert at end
this.elements.selectedServices.appendChild(this.dragState.placeholder);
}
}
/**
* Handle drag end
* @param {MouseEvent} event - Mouse event
*/
handleDragEnd(event) {
if (!this.dragState.isDragging) return;
// Reset transforms on all items with smooth animation
const allServiceItems = Array.from(
this.elements.selectedServices.querySelectorAll(
".selected-service-item:not(.dragging):not(.drag-placeholder)"
)
);
allServiceItems.forEach((item) => {
item.style.transform = "";
item.style.transition = "transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)";
});
// Calculate new index based on placeholder position
const allItems = Array.from(
this.elements.selectedServices.querySelectorAll(
".selected-service-item, .drag-placeholder"
)
);
const placeholderIndex = allItems.indexOf(this.dragState.placeholder);
const newIndex =
placeholderIndex > this.dragState.draggedIndex
? placeholderIndex - 1
: placeholderIndex;
// Update services order
this.reorderServices(this.dragState.draggedIndex, newIndex);
// Animate placeholder disappearance
if (this.dragState.placeholder && this.dragState.placeholder.parentNode) {
this.dragState.placeholder.style.height = "0px";
this.dragState.placeholder.style.opacity = "0";
this.dragState.placeholder.style.marginBottom = "0px";
setTimeout(() => {
if (
this.dragState.placeholder &&
this.dragState.placeholder.parentNode
) {
this.dragState.placeholder.parentNode.removeChild(
this.dragState.placeholder
);
}
}, 300);
}
// Clean up with smooth animation
this.dragState.draggedElement.classList.remove("dragging");
this.dragState.draggedElement.style.transform = "";
this.dragState.draggedElement.style.zIndex = "";
this.dragState.draggedElement.style.position = "";
document.body.classList.remove("dragging");
// Reset drag state
this.dragState = {
isDragging: false,
draggedElement: null,
draggedIndex: -1,
placeholder: null,
initialMouseY: 0,
initialElementY: 0,
mouseOffset: 0,
};
// Update UI and trigger change event with smooth animation
setTimeout(() => {
this.renderSelectedServices();
this.triggerSelectionChange();
}, 50);
}
/**
* Reorder services array
* @param {number} fromIndex - Original index
* @param {number} toIndex - Target index
*/
reorderServices(fromIndex, toIndex) {
if (fromIndex === toIndex) return;
const item = this.selectedServicesOrder[fromIndex];
this.selectedServicesOrder.splice(fromIndex, 1);
this.selectedServicesOrder.splice(toIndex, 0, item);
// Clear data
this.allServices = [];
this.selectedServices.clear();
this.selectedServicesOrder = [];
}
}
-1
View File
@@ -9,7 +9,6 @@
*
* @author xixu-me
* @license GPL-3.0
* @version 2.0.0
*/
export class ThemeManager {
+87 -112
View File
@@ -11,7 +11,6 @@
*
* @author xixu-me
* @license GPL-3.0
* @version 2.0.0
*/
/* ==========================================================================
@@ -1033,6 +1032,64 @@ body::before {
color: var(--text-primary);
}
/* Drag Handle Styles */
.drag-handle {
color: var(--text-secondary);
cursor: grab;
padding: 0.25rem;
border-radius: 6px;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 24px;
}
.drag-handle:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.1);
}
.drag-handle:active {
cursor: grabbing;
}
/* SortableJS Styles */
.sortable-ghost {
opacity: 0.4;
background: rgba(99, 102, 241, 0.2) !important;
border: 2px dashed rgba(99, 102, 241, 0.5) !important;
transform: scale(0.98);
}
.sortable-drag {
opacity: 0.9;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3) !important;
transform: rotate(3deg) scale(1.02);
z-index: 1000 !important;
}
.sortable-chosen {
cursor: grabbing;
}
.sortable-fallback {
opacity: 0.8;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
transform: rotate(3deg);
}
/* Container state during dragging */
#selected-services.is-dragging {
background: rgba(99, 102, 241, 0.05);
}
#selected-services.is-dragging
.selected-service-item:not(.sortable-drag):not(.sortable-ghost) {
opacity: 0.7;
transition: opacity 0.2s ease;
}
.delete-button {
background: linear-gradient(
135deg,
@@ -1418,21 +1475,6 @@ body::before {
flex: 1;
}
.service-drag-handle {
color: var(--text-muted);
cursor: grab;
font-size: 0.875rem;
transition: color 0.2s ease;
}
.service-drag-handle:hover {
color: var(--text-secondary);
}
.service-drag-handle:active {
cursor: grabbing;
}
.service-name {
font-weight: 500;
color: var(--text-primary);
@@ -1493,6 +1535,26 @@ body::before {
background: rgba(255, 255, 255, 0.06);
}
/* Dark theme drag handle styles */
[data-theme="dark"] .drag-handle {
color: rgba(255, 255, 255, 0.5);
}
[data-theme="dark"] .drag-handle:hover {
color: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.05);
}
/* Dark theme sortable styles */
[data-theme="dark"] .sortable-ghost {
background: rgba(99, 102, 241, 0.15) !important;
border: 2px dashed rgba(99, 102, 241, 0.4) !important;
}
[data-theme="dark"] #selected-services.is-dragging {
background: rgba(99, 102, 241, 0.03);
}
/* Focus Management for Accessibility */
.focus-visible {
outline: 2px solid var(--primary-color);
@@ -1508,6 +1570,15 @@ body::before {
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
/* Disable drag animations for reduced motion */
.sortable-ghost,
.sortable-drag,
.sortable-fallback {
transition: none !important;
animation: none !important;
transform: none !important;
}
}
/* High Contrast Mode Support */
@@ -1572,99 +1643,3 @@ body::before {
.skip-link:focus {
top: 6px;
}
/* ==========================================================================
Drag and drop styles
========================================================================== */
.dragging .selected-service-item {
z-index: 1000;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
transform: scale(1.02);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.drag-placeholder {
border: 2px solid var(--primary-color) !important;
background: linear-gradient(
135deg,
rgba(37, 99, 235, 0.08),
rgba(37, 99, 235, 0.15)
) !important;
opacity: 1;
animation: pulse-glow 1.5s ease-in-out infinite alternate;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes pulse-glow {
0% {
box-shadow: 0 0 5px rgba(37, 99, 235, 0.3);
transform: scale(1);
}
100% {
box-shadow: 0 0 20px rgba(37, 99, 235, 0.6);
transform: scale(1.02);
}
}
.drag-placeholder .service-name {
color: var(--primary-color) !important;
opacity: 0.8;
}
body.dragging {
cursor: grabbing !important;
user-select: none;
}
body.dragging * {
cursor: grabbing !important;
}
.service-drag-handle {
cursor: grab;
opacity: 0.6;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.service-drag-handle:hover {
opacity: 1;
color: var(--primary-color);
transform: scale(1.1);
}
.service-drag-handle:active {
cursor: grabbing;
transform: scale(0.95);
}
.selected-service-item.dragging {
transition: none !important;
z-index: 1000;
position: relative;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.4);
pointer-events: none;
}
/* Smooth animations for reordering */
.selected-service-item:not(.dragging):not(.drag-placeholder) {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Enhanced animations for drag interactions */
.selected-service-item.drag-over-top {
transform: translateY(-8px);
}
.selected-service-item.drag-over-bottom {
transform: translateY(8px);
}
/* Dark theme drag styles */
[data-theme="dark"] .drag-placeholder {
border-color: var(--primary-color) !important;
background: linear-gradient(
135deg,
rgba(37, 99, 235, 0.12),
rgba(37, 99, 235, 0.2)
) !important;
}
-1
View File
@@ -8,7 +8,6 @@
* @source https://github.com/xixu-me/META
* @author xixu-me
* @license GPL-3.0
* @version 2.0.0
*/
const metaTemplate = `// Generated by META.js Customizer (https://metajs.xi-xu.me)