feat: drag-and-drop reorder for models, loras, text encoders, and VAEs

Adds HTML5 drag-and-drop reordering within each list in the Config
Builder UI. Drag the ⠿ handle on any card header to change run order.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
JasonHoku
2026-03-06 19:49:42 -08:00
co-authored by Claude Opus 4.6
parent 5e7ad22cc7
commit 8ff48532f9
2 changed files with 117 additions and 0 deletions
@@ -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";
@@ -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; }