diff --git a/web/conf_builder/conf-builder-config-management.js b/web/conf_builder/conf-builder-config-management.js index 2bd2271..42654ca 100644 --- a/web/conf_builder/conf-builder-config-management.js +++ b/web/conf_builder/conf-builder-config-management.js @@ -59,6 +59,92 @@ function debouncedRenderUI(node) { }, 150); } +// --- DRAG-AND-DROP REORDER HELPER --- +// Makes item cards within a list reorderable by dragging their header bar. +// stateArray: the backing array in node.state (e.g. config_arrays[arrayIdx].loras) +// arrayIdx: which config array this belongs to +// itemIdx: the index of this item in the array +// node: the config builder node (for saveState/renderUI) +function setupDragReorder(cardDiv, headerBar, stateArrayGetter, itemIdx, node) { + headerBar.draggable = true; + headerBar.style.cursor = "grab"; + + // Drag handle icon prepended to header + const handle = document.createElement("span"); + handle.textContent = "⠿"; + handle.style.cssText = "color: #666; font-size: 14px; margin-right: 6px; cursor: grab; user-select: none;"; + handle.className = "cb-drag-handle"; + headerBar.insertBefore(handle, headerBar.firstChild); + + headerBar.addEventListener("dragstart", (e) => { + e.stopPropagation(); + cardDiv.classList.add("cb-dragging"); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", String(itemIdx)); + // Store which list this came from so we don't mix lists + e.dataTransfer.setData("application/x-cb-list-id", cardDiv.parentElement?.id || ""); + }); + + headerBar.addEventListener("dragend", (e) => { + cardDiv.classList.remove("cb-dragging"); + // Clean up all drop indicators + document.querySelectorAll(".cb-drop-above, .cb-drop-below").forEach(el => { + el.classList.remove("cb-drop-above", "cb-drop-below"); + }); + }); + + cardDiv.addEventListener("dragover", (e) => { + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = "move"; + // Show drop indicator based on mouse position relative to card center + const rect = cardDiv.getBoundingClientRect(); + const midY = rect.top + rect.height / 2; + cardDiv.classList.remove("cb-drop-above", "cb-drop-below"); + if (e.clientY < midY) { + cardDiv.classList.add("cb-drop-above"); + } else { + cardDiv.classList.add("cb-drop-below"); + } + }); + + cardDiv.addEventListener("dragleave", (e) => { + cardDiv.classList.remove("cb-drop-above", "cb-drop-below"); + }); + + cardDiv.addEventListener("drop", (e) => { + e.preventDefault(); + e.stopPropagation(); + cardDiv.classList.remove("cb-drop-above", "cb-drop-below"); + + // Verify same list + const sourceListId = e.dataTransfer.getData("application/x-cb-list-id"); + if (sourceListId && sourceListId !== (cardDiv.parentElement?.id || "")) return; + + const fromIdx = parseInt(e.dataTransfer.getData("text/plain")); + if (isNaN(fromIdx) || fromIdx === itemIdx) return; + + // Determine insert position based on mouse position + const rect = cardDiv.getBoundingClientRect(); + const midY = rect.top + rect.height / 2; + let toIdx = e.clientY < midY ? itemIdx : itemIdx + 1; + // Adjust for removal shifting indices + if (fromIdx < toIdx) toIdx--; + + if (fromIdx === toIdx) return; + + const arr = stateArrayGetter(); + if (!arr || fromIdx >= arr.length) return; + + // Splice: remove from old position, insert at new + const [item] = arr.splice(fromIdx, 1); + arr.splice(toIdx, 0, item); + + node.saveState(); + debouncedRenderUI(node); + }); +} + // --- SESSION SECTION RENDERER --- export function renderSessionSection(node, container, availableSessions, refreshAllConfigBuilders) { @@ -762,6 +848,9 @@ export function createModelElement(node, modelEntry, arrayIdx, modelIdx, modelLi header.appendChild(deleteBtn); div.appendChild(header); + // Drag-and-drop reorder + setupDragReorder(div, header, () => node.state.config_arrays[arrayIdx].models, modelIdx, node); + // Apply bypass visual state if (isBypassed) { div.style.opacity = "0.5"; @@ -1032,6 +1121,9 @@ export function createLoraElement(node, loraStr, arrayIdx, loraIdx, availableLor header.appendChild(deleteBtn); div.appendChild(header); + // Drag-and-drop reorder + setupDragReorder(div, header, () => node.state.config_arrays[arrayIdx].loras, loraIdx, node); + // Apply bypass visual state if (isBypassed) { div.style.opacity = "0.5"; @@ -2185,8 +2277,19 @@ function renderTextEncodersSection(node, container, configArray, arrayIdx, model const isTeBypassed = node.state.config_arrays[arrayIdx].te_bypass_states[tePath] || false; const teRow = document.createElement("div"); + teRow.className = "cb-te-row"; teRow.style.cssText = "display: flex; gap: 4px; align-items: center; margin-bottom: 4px;"; + // Drag handle for text encoder reorder + const teDragHandle = document.createElement("span"); + teDragHandle.textContent = "⠿"; + teDragHandle.className = "cb-drag-handle"; + teDragHandle.draggable = true; + teDragHandle.style.cssText = "color: #666; font-size: 14px; cursor: grab; user-select: none; padding: 0 4px;"; + teRow.appendChild(teDragHandle); + // Reuse drag reorder with the handle as the draggable trigger and the row as the card + setupDragReorder(teRow, teDragHandle, () => node.state.config_arrays[arrayIdx].text_encoders, teIdx, node); + // Apply bypass visual state if (isTeBypassed) { teRow.style.opacity = "0.5"; @@ -2850,6 +2953,9 @@ function createVAEElement(node, vaeName, arrayIdx, vaeIdx, vaeList, vFolders) { header.appendChild(deleteBtn); div.appendChild(header); + // Drag-and-drop reorder + setupDragReorder(div, header, () => node.state.config_arrays[arrayIdx].vaes, vaeIdx, node); + // Apply bypass visual state if (isBypassed) { div.style.opacity = "0.5"; diff --git a/web/conf_builder/conf-builder-ui-components.js b/web/conf_builder/conf-builder-ui-components.js index 4a6b082..3cd80be 100644 --- a/web/conf_builder/conf-builder-ui-components.js +++ b/web/conf_builder/conf-builder-ui-components.js @@ -235,6 +235,17 @@ export function getStyles() { .cb-list-grid { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; padding-top: 10px; border-top: 1px dashed #444; } .cb-item-card { background: #2a2a2a; border-radius: 4px; padding: 10px; border-left: 3px solid #0066cc; flex: 1 1 300px; min-width: 250px; display: flex; flex-direction: column; gap: 6px; } .cb-item-card.model-card { border-left-color: #cc6600; } + + /* Drag-and-drop reorder styles */ + .cb-item-card.cb-dragging { opacity: 0.4; } + .cb-item-card.cb-drop-above { border-top: 2px solid #0088ff; } + .cb-item-card.cb-drop-below { border-bottom: 2px solid #0088ff; } + .cb-drag-handle { opacity: 0.4; transition: opacity 0.15s; } + .cb-header-bar:hover .cb-drag-handle { opacity: 1; } + .cb-te-row.cb-dragging { opacity: 0.4; } + .cb-te-row.cb-drop-above { border-top: 2px solid #0088ff; } + .cb-te-row.cb-drop-below { border-bottom: 2px solid #0088ff; } + .cb-slider-container { display: flex; align-items: center; gap: 10px; } .cb-slider { flex: 1; height: 6px; background: #1a1a1a; border-radius: 3px; outline: none; }