feat: Add drag-and-drop support with visual feedback for sidebar nodes

This commit implements drag-and-drop functionality for sidebar nodes,
adding visual feedback via highlight styling when dragging over
valid drop targets. The CSS introduces new classes to style
drop indicators using the lora-accent color scheme, while the JS
adds event handlers to manage drag operations and update the UI
state accordingly. This improves user interaction by providing
clear visual cues for valid drop areas during file operations.
This commit is contained in:
Will Miao
2025-10-12 06:55:01 +08:00
parent 8f4c02efdc
commit 4e552dcf3e
4 changed files with 320 additions and 10 deletions

View File

@@ -184,6 +184,19 @@
font-weight: 500;
}
.sidebar-tree-node-content.drop-target,
.sidebar-node-content.drop-target {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15);
color: var(--lora-accent);
border-left-color: var(--lora-accent);
}
.sidebar-tree-node-content.drop-target .sidebar-tree-folder-icon,
.sidebar-node-content.drop-target .sidebar-folder-icon {
color: var(--lora-accent);
opacity: 1;
}
.sidebar-tree-expand-icon {
width: 16px;
height: 16px;

View File

@@ -4,6 +4,9 @@
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js';
import { bulkManager } from '../managers/BulkManager.js';
import { showToast } from '../utils/uiHelpers.js';
export class SidebarManager {
constructor() {
@@ -22,7 +25,13 @@ export class SidebarManager {
this.displayMode = 'tree'; // 'tree' or 'list'
this.foldersList = [];
this.recursiveSearchEnabled = true;
this.draggedFilePaths = null;
this.draggedRootPath = null;
this.draggedFromBulk = false;
this.dragHandlersInitialized = false;
this.folderTreeElement = null;
this.currentDropTarget = null;
// Bind methods
this.handleTreeClick = this.handleTreeClick.bind(this);
this.handleBreadcrumbClick = this.handleBreadcrumbClick.bind(this);
@@ -38,6 +47,12 @@ export class SidebarManager {
this.handleDisplayModeToggle = this.handleDisplayModeToggle.bind(this);
this.handleFolderListClick = this.handleFolderListClick.bind(this);
this.handleRecursiveToggle = this.handleRecursiveToggle.bind(this);
this.handleCardDragStart = this.handleCardDragStart.bind(this);
this.handleCardDragEnd = this.handleCardDragEnd.bind(this);
this.handleFolderDragEnter = this.handleFolderDragEnter.bind(this);
this.handleFolderDragOver = this.handleFolderDragOver.bind(this);
this.handleFolderDragLeave = this.handleFolderDragLeave.bind(this);
this.handleFolderDrop = this.handleFolderDrop.bind(this);
}
async initialize(pageControls) {
@@ -54,6 +69,7 @@ export class SidebarManager {
this.setInitialSidebarState();
this.setupEventHandlers();
this.initializeDragAndDrop();
this.updateSidebarTitle();
this.restoreSidebarState();
await this.loadFolderTree();
@@ -80,7 +96,22 @@ export class SidebarManager {
// Clean up event handlers
this.removeEventHandlers();
this.clearAllDropHighlights();
if (this.dragHandlersInitialized) {
document.removeEventListener('dragstart', this.handleCardDragStart);
document.removeEventListener('dragend', this.handleCardDragEnd);
this.dragHandlersInitialized = false;
}
if (this.folderTreeElement) {
this.folderTreeElement.removeEventListener('dragenter', this.handleFolderDragEnter);
this.folderTreeElement.removeEventListener('dragover', this.handleFolderDragOver);
this.folderTreeElement.removeEventListener('dragleave', this.handleFolderDragLeave);
this.folderTreeElement.removeEventListener('drop', this.handleFolderDrop);
this.folderTreeElement = null;
}
this.resetDragState();
// Reset state
this.pageControls = null;
this.pageType = null;
@@ -154,6 +185,271 @@ export class SidebarManager {
}
}
initializeDragAndDrop() {
if (!this.dragHandlersInitialized) {
document.addEventListener('dragstart', this.handleCardDragStart);
document.addEventListener('dragend', this.handleCardDragEnd);
this.dragHandlersInitialized = true;
}
const folderTree = document.getElementById('sidebarFolderTree');
if (folderTree && this.folderTreeElement !== folderTree) {
if (this.folderTreeElement) {
this.folderTreeElement.removeEventListener('dragenter', this.handleFolderDragEnter);
this.folderTreeElement.removeEventListener('dragover', this.handleFolderDragOver);
this.folderTreeElement.removeEventListener('dragleave', this.handleFolderDragLeave);
this.folderTreeElement.removeEventListener('drop', this.handleFolderDrop);
}
folderTree.addEventListener('dragenter', this.handleFolderDragEnter);
folderTree.addEventListener('dragover', this.handleFolderDragOver);
folderTree.addEventListener('dragleave', this.handleFolderDragLeave);
folderTree.addEventListener('drop', this.handleFolderDrop);
this.folderTreeElement = folderTree;
}
}
handleCardDragStart(event) {
const card = event.target.closest('.model-card');
if (!card) return;
const filePath = card.dataset.filepath;
if (!filePath) return;
const selectedSet = state.selectedModels instanceof Set
? state.selectedModels
: new Set(state.selectedModels || []);
const cardIsSelected = card.classList.contains('selected');
const usingBulkSelection = Boolean(state.bulkMode && cardIsSelected && selectedSet && selectedSet.size > 0);
const paths = usingBulkSelection ? Array.from(selectedSet) : [filePath];
const filePaths = Array.from(new Set(paths.filter(Boolean)));
if (filePaths.length === 0) {
return;
}
this.draggedFilePaths = filePaths;
this.draggedRootPath = this.getRootPathFromCard(card);
this.draggedFromBulk = usingBulkSelection;
const dataTransfer = event.dataTransfer;
if (dataTransfer) {
dataTransfer.effectAllowed = 'move';
dataTransfer.setData('text/plain', filePaths.join(','));
try {
dataTransfer.setData('application/json', JSON.stringify({ filePaths }));
} catch (error) {
// Ignore serialization errors
}
}
card.classList.add('dragging');
}
handleCardDragEnd(event) {
const card = event.target.closest('.model-card');
if (card) {
card.classList.remove('dragging');
}
this.clearAllDropHighlights();
this.resetDragState();
}
getRootPathFromCard(card) {
if (!card) return null;
const filePathRaw = card.dataset.filepath || '';
const normalizedFilePath = filePathRaw.replace(/\\/g, '/');
const lastSlashIndex = normalizedFilePath.lastIndexOf('/');
if (lastSlashIndex === -1) {
return null;
}
const directory = normalizedFilePath.substring(0, lastSlashIndex);
let folderValue = card.dataset.folder;
if (!folderValue || folderValue === 'undefined') {
folderValue = '';
}
const normalizedFolder = folderValue.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
if (!normalizedFolder) {
return directory;
}
const suffix = `/${normalizedFolder}`;
if (directory.endsWith(suffix)) {
return directory.slice(0, -suffix.length);
}
return directory;
}
combineRootAndRelativePath(root, relative) {
const normalizedRoot = (root || '').replace(/\\/g, '/').replace(/\/+$/g, '');
const normalizedRelative = (relative || '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
if (!normalizedRoot) {
return normalizedRelative;
}
if (!normalizedRelative) {
return normalizedRoot;
}
return `${normalizedRoot}/${normalizedRelative}`;
}
getFolderElementFromEvent(event) {
const folderTree = this.folderTreeElement || document.getElementById('sidebarFolderTree');
if (!folderTree) return null;
const target = event.target instanceof Element ? event.target.closest('[data-path]') : null;
if (!target || !folderTree.contains(target)) {
return null;
}
return target;
}
setDropTargetHighlight(element, shouldAdd) {
if (!element) return;
let targetElement = element;
if (!targetElement.classList.contains('sidebar-tree-node-content') &&
!targetElement.classList.contains('sidebar-node-content')) {
targetElement = element.querySelector('.sidebar-tree-node-content, .sidebar-node-content');
}
if (targetElement) {
targetElement.classList.toggle('drop-target', shouldAdd);
}
}
handleFolderDragEnter(event) {
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return;
const folderElement = this.getFolderElementFromEvent(event);
if (!folderElement) return;
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
this.setDropTargetHighlight(folderElement, true);
this.currentDropTarget = folderElement;
}
handleFolderDragOver(event) {
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return;
const folderElement = this.getFolderElementFromEvent(event);
if (!folderElement) return;
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
}
handleFolderDragLeave(event) {
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return;
const folderElement = this.getFolderElementFromEvent(event);
if (!folderElement) return;
const relatedTarget = event.relatedTarget instanceof Element ? event.relatedTarget : null;
if (!relatedTarget || !folderElement.contains(relatedTarget)) {
this.setDropTargetHighlight(folderElement, false);
if (this.currentDropTarget === folderElement) {
this.currentDropTarget = null;
}
}
}
async handleFolderDrop(event) {
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return;
const folderElement = this.getFolderElementFromEvent(event);
if (!folderElement) return;
event.preventDefault();
event.stopPropagation();
this.setDropTargetHighlight(folderElement, false);
this.currentDropTarget = null;
const targetPath = folderElement.dataset.path || '';
await this.performDragMove(targetPath);
this.resetDragState();
this.clearAllDropHighlights();
}
async performDragMove(targetRelativePath) {
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) {
return false;
}
if (!this.apiClient) {
this.apiClient = getModelApiClient();
}
const rootPath = this.draggedRootPath ? this.draggedRootPath.replace(/\\/g, '/') : '';
if (!rootPath) {
showToast(
'toast.models.moveFailed',
{ message: translate('sidebar.dragDrop.unableToResolveRoot', {}, 'Unable to determine destination path for move.') },
'error'
);
return false;
}
const destination = this.combineRootAndRelativePath(rootPath, targetRelativePath);
const useBulkMove = this.draggedFromBulk || this.draggedFilePaths.length > 1;
try {
if (useBulkMove) {
await this.apiClient.moveBulkModels(this.draggedFilePaths, destination);
} else {
await this.apiClient.moveSingleModel(this.draggedFilePaths[0], destination);
}
if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') {
await this.pageControls.resetAndReload(true);
} else {
await this.refresh();
}
if (this.draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') {
bulkManager.toggleBulkMode();
}
return true;
} catch (error) {
console.error('Error moving model(s) via drag-and-drop:', error);
showToast('toast.models.moveFailed', { message: error.message || 'Unknown error' }, 'error');
return false;
}
}
resetDragState() {
this.draggedFilePaths = null;
this.draggedRootPath = null;
this.draggedFromBulk = false;
}
clearAllDropHighlights() {
const highlighted = document.querySelectorAll('.sidebar-tree-node-content.drop-target, .sidebar-node-content.drop-target');
highlighted.forEach((element) => element.classList.remove('drop-target'));
this.currentDropTarget = null;
}
async init() {
this.apiClient = getModelApiClient();
@@ -161,6 +457,7 @@ export class SidebarManager {
this.setInitialSidebarState();
this.setupEventHandlers();
this.initializeDragAndDrop();
this.updateSidebarTitle();
this.restoreSidebarState();
await this.loadFolderTree();
@@ -464,6 +761,7 @@ export class SidebarManager {
} else {
this.renderFolderList();
}
this.initializeDragAndDrop();
}
renderTree() {
@@ -490,7 +788,7 @@ export class SidebarManager {
return `
<div class="sidebar-tree-node" data-path="${currentPath}">
<div class="sidebar-tree-node-content ${isSelected ? 'selected' : ''}">
<div class="sidebar-tree-node-content ${isSelected ? 'selected' : ''}" data-path="${currentPath}">
<div class="sidebar-tree-expand-icon ${isExpanded ? 'expanded' : ''}"
style="${hasChildren ? '' : 'opacity: 0; pointer-events: none;'}">
<i class="fas fa-chevron-right"></i>
@@ -535,7 +833,7 @@ export class SidebarManager {
return `
<div class="sidebar-folder-item ${isSelected ? 'selected' : ''}" data-path="${folder}">
<div class="sidebar-node-content">
<div class="sidebar-node-content" data-path="${folder}">
<i class="fas fa-folder sidebar-folder-icon"></i>
<div class="sidebar-folder-name" title="${displayName}">${displayName}</div>
</div>

View File

@@ -364,11 +364,12 @@ function showExampleAccessModal(card, modelType) {
export function createModelCard(model, modelType) {
const card = document.createElement('div');
card.className = 'model-card'; // Reuse the same class for styling
card.draggable = true;
card.dataset.sha256 = model.sha256;
card.dataset.filepath = model.file_path;
card.dataset.name = model.model_name;
card.dataset.file_name = model.file_name;
card.dataset.folder = model.folder;
card.dataset.folder = model.folder || '';
card.dataset.modified = model.modified;
card.dataset.file_size = model.file_size;
card.dataset.from_civitai = model.from_civitai;

View File

@@ -1,8 +1,6 @@
import { modalManager } from '../managers/ModalManager.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
const apiClient = getModelApiClient();
let pendingDeletePath = null;
let pendingExcludePath = null;
@@ -27,7 +25,7 @@ export async function confirmDelete() {
if (!pendingDeletePath) return;
try {
await apiClient.deleteModel(pendingDeletePath);
await getModelApiClient().deleteModel(pendingDeletePath);
closeDeleteModal();
@@ -72,7 +70,7 @@ export async function confirmExclude() {
if (!pendingExcludePath) return;
try {
await apiClient.excludeModel(pendingExcludePath);
await getModelApiClient().excludeModel(pendingExcludePath);
closeExcludeModal();
@@ -82,4 +80,4 @@ export async function confirmExclude() {
} catch (error) {
console.error('Error excluding model:', error);
}
}
}