diff --git a/static/css/components/sidebar.css b/static/css/components/sidebar.css index 983ee674..915a5eac 100644 --- a/static/css/components/sidebar.css +++ b/static/css/components/sidebar.css @@ -693,6 +693,62 @@ background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.02); } +/* Compact drag image shown while moving model cards to a folder. Replaces + the browser's default full-card snapshot so the folder tree stays visible + under the cursor. Parked off-screen; the browser snapshots it for the + drag via setDragImage(). */ +.model-card-drag-chip { + position: fixed; + top: -9999px; + left: -9999px; + display: flex; + align-items: center; + gap: 8px; + max-width: 240px; + padding: 6px 10px; + background: var(--bg-color); + border: 1px solid var(--lora-accent); + border-radius: var(--border-radius-xs); + box-shadow: var(--shadow-lg); + font-size: 0.85em; + color: var(--text-color); + pointer-events: none; + z-index: -1; +} + +.model-card-drag-chip-thumb { + width: 32px; + height: 32px; + object-fit: cover; + border-radius: 4px; + flex-shrink: 0; +} + +.model-card-drag-chip-icon { + width: 32px; + font-size: 18px; + text-align: center; + color: var(--text-muted); + flex-shrink: 0; +} + +.model-card-drag-chip-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 160px; +} + +.model-card-drag-chip-count { + background: var(--lora-accent); + color: var(--text-on-accent); + border-radius: 10px; + padding: 1px 8px; + font-size: 0.85em; + font-weight: 600; + flex-shrink: 0; +} + /* Folder context menu - positioned relative to sidebar */ #sidebarFolderContextMenu { z-index: var(--z-modal, 1002); diff --git a/static/js/components/SidebarManager.js b/static/js/components/SidebarManager.js index ee2e91d0..3933272b 100644 --- a/static/js/components/SidebarManager.js +++ b/static/js/components/SidebarManager.js @@ -19,6 +19,14 @@ import { MODEL_CARD_DRAG_MIME_TYPE } from '../utils/constants.js'; // makes the hidden sidebar discoverable/recoverable. const SIDEBAR_DEFAULT_HIDDEN_PAGES = new Set(['other']); +// Hover a collapsed folder this long while dragging to auto-expand it. +const DRAG_HOVER_EXPAND_DELAY_MS = 600; + +// Distance from the tree container's top/bottom edge that starts +// auto-scrolling during a drag, and the scroll speed per animation frame. +const SIDEBAR_AUTOSCROLL_ZONE_PX = 48; +const SIDEBAR_AUTOSCROLL_SPEED_PX = 12; + export class SidebarManager { constructor() { this.pageControls = null; @@ -63,6 +71,14 @@ export class SidebarManager { // repaint a modal the user has already dismissed or retargeted. this._deleteFolderProbeToken = 0; + // Drag-and-drop transient state: the custom drag image element, the + // pending hover-to-expand timer, and the edge auto-scroll loop. + this._dragPreviewElement = null; + this._dragExpandTimer = null; + this._dragExpandPath = null; + this._autoScrollFrame = null; + this._autoScrollDirection = 0; + // Bind methods this.handleTreeClick = this.handleTreeClick.bind(this); this.handleTreeContextMenu = this.handleTreeContextMenu.bind(this); @@ -278,6 +294,11 @@ export class SidebarManager { } catch (error) { // Ignore serialization errors } + + // Replace the browser's default full-card snapshot with a small + // chip (thumbnail + name + count) so the drag image does not + // cover the folder tree the user is aiming at. + this._setDragPreviewImage(event, card, filePaths.length); } card.classList.add('dragging'); @@ -287,6 +308,8 @@ export class SidebarManager { if (sidebar) { sidebar.classList.add('dragging-active'); } + + this._startSidebarAutoScroll(); } handleCardDragEnd(event) { @@ -294,17 +317,177 @@ export class SidebarManager { if (card) { card.classList.remove('dragging'); } - + // Remove dragging state from sidebar const sidebar = document.getElementById('folderSidebar'); if (sidebar) { sidebar.classList.remove('dragging-active'); } + this._removeDragPreviewImage(); + this._cancelDragExpand(); + this._stopSidebarAutoScroll(); this.clearAllDropHighlights(); this.resetDragState(); } + /** + * Swap the default drag image (a full-size snapshot of the card) for a + * compact chip: preview thumbnail, model name, and a count badge for + * bulk drags. The chip is anchored below-right of the cursor so it never + * covers the folder row the pointer is hovering. + */ + _setDragPreviewImage(event, card, count) { + const dataTransfer = event.dataTransfer; + if (!dataTransfer || typeof dataTransfer.setDragImage !== 'function') return; + + this._removeDragPreviewImage(); + + const chip = document.createElement('div'); + chip.className = 'model-card-drag-chip'; + + // A blurred (mature-content) preview stays hidden in the chip too. + const isBlurred = Boolean(card.querySelector('.card-preview.blurred')); + const previewImg = card.querySelector('.card-preview img'); + if (previewImg && previewImg.src && !isBlurred) { + const thumb = document.createElement('img'); + thumb.className = 'model-card-drag-chip-thumb'; + thumb.src = previewImg.src; + thumb.alt = ''; + chip.appendChild(thumb); + } else { + const icon = document.createElement('i'); + const isVideo = Boolean(card.querySelector('.card-preview video')); + icon.className = `model-card-drag-chip-icon fas ${isVideo ? 'fa-film' : 'fa-image'}`; + chip.appendChild(icon); + } + + const name = document.createElement('span'); + name.className = 'model-card-drag-chip-name'; + name.textContent = card.dataset.name || card.dataset.file_name || ''; + chip.appendChild(name); + + if (count > 1) { + const badge = document.createElement('span'); + badge.className = 'model-card-drag-chip-count'; + badge.textContent = `×${count}`; + chip.appendChild(badge); + } + + document.body.appendChild(chip); + this._dragPreviewElement = chip; + + try { + // Negative offsets place the chip below-right of the cursor. + dataTransfer.setDragImage(chip, -12, -12); + } catch (error) { + // A failed setDragImage keeps the default image; still fine. + } + } + + _removeDragPreviewImage() { + if (this._dragPreviewElement) { + this._dragPreviewElement.remove(); + this._dragPreviewElement = null; + } + } + + /** + * Expand a collapsed tree folder after a short hover during a drag, so + * nested folders can be dropped into without a separate click. + */ + _scheduleDragExpand(folderElement) { + if (this.displayMode !== 'tree') return; + + const treeNode = folderElement.classList.contains('sidebar-tree-node') + ? folderElement + : folderElement.closest('.sidebar-tree-node'); + const path = treeNode?.dataset.path; + if (!treeNode || !path) return; + if (this.expandedNodes.has(path)) return; + if (!treeNode.querySelector(':scope > .sidebar-tree-children')) return; + + if (this._dragExpandTimer && this._dragExpandPath === path) return; + + this._cancelDragExpand(); + this._dragExpandPath = path; + this._dragExpandTimer = setTimeout(() => { + this._dragExpandTimer = null; + this._dragExpandPath = null; + + // Re-validate against the live DOM: the drag may have ended or the + // tree may have re-rendered while the timer was pending. + if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return; + const folderTree = this.folderTreeElement || document.getElementById('sidebarFolderTree'); + const node = folderTree + ? [...folderTree.querySelectorAll('.sidebar-tree-node')].find(el => el.dataset.path === path) + : null; + if (!node || this.expandedNodes.has(path)) return; + + this.expandedNodes.add(path); + // Toggle in place instead of re-rendering so the drop highlight + // and the hover position survive the expansion. + node.querySelector(':scope > .sidebar-tree-node-content > .sidebar-tree-expand-icon') + ?.classList.add('expanded'); + node.querySelector(':scope > .sidebar-tree-children')?.classList.add('expanded'); + this.saveExpandedState(); + }, DRAG_HOVER_EXPAND_DELAY_MS); + } + + _cancelDragExpand() { + if (this._dragExpandTimer) { + clearTimeout(this._dragExpandTimer); + this._dragExpandTimer = null; + } + this._dragExpandPath = null; + } + + _startSidebarAutoScroll() { + this._stopSidebarAutoScroll(); + this._autoScrollDirection = 0; + + if (typeof requestAnimationFrame !== 'function') return; + + const step = () => { + if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) { + this._autoScrollFrame = null; + return; + } + if (this._autoScrollDirection !== 0) { + const container = this.folderTreeElement?.closest('.sidebar-tree-container'); + if (container) { + container.scrollTop += this._autoScrollDirection * SIDEBAR_AUTOSCROLL_SPEED_PX; + } + } + this._autoScrollFrame = requestAnimationFrame(step); + }; + this._autoScrollFrame = requestAnimationFrame(step); + } + + _stopSidebarAutoScroll() { + if (this._autoScrollFrame !== null && typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(this._autoScrollFrame); + } + this._autoScrollFrame = null; + this._autoScrollDirection = 0; + } + + _updateAutoScrollDirection(clientY) { + const container = this.folderTreeElement?.closest('.sidebar-tree-container'); + if (!container || typeof clientY !== 'number') { + this._autoScrollDirection = 0; + return; + } + const rect = container.getBoundingClientRect(); + if (clientY < rect.top + SIDEBAR_AUTOSCROLL_ZONE_PX) { + this._autoScrollDirection = -1; + } else if (clientY > rect.bottom - SIDEBAR_AUTOSCROLL_ZONE_PX) { + this._autoScrollDirection = 1; + } else { + this._autoScrollDirection = 0; + } + } + getRootPathFromCard(card) { if (!card) return null; @@ -389,6 +572,7 @@ export class SidebarManager { this.setDropTargetHighlight(folderElement, true); this.currentDropTarget = folderElement; + this._scheduleDragExpand(folderElement); } handleFolderDragOver(event) { @@ -402,6 +586,8 @@ export class SidebarManager { if (event.dataTransfer) { event.dataTransfer.dropEffect = 'move'; } + + this._updateAutoScrollDirection(event.clientY); } handleFolderDragLeave(event) { @@ -416,6 +602,15 @@ export class SidebarManager { if (this.currentDropTarget === folderElement) { this.currentDropTarget = null; } + if (this._dragExpandPath && folderElement.dataset.path === this._dragExpandPath) { + this._cancelDragExpand(); + } + } + + // Pointer left the tree entirely: stop any edge auto-scroll. + const folderTree = this.folderTreeElement || document.getElementById('sidebarFolderTree'); + if (folderTree && (!relatedTarget || !folderTree.contains(relatedTarget))) { + this._autoScrollDirection = 0; } } @@ -430,6 +625,8 @@ export class SidebarManager { this.setDropTargetHighlight(folderElement, false); this.currentDropTarget = null; + this._cancelDragExpand(); + this._autoScrollDirection = 0; const targetPath = folderElement.dataset.path || ''; diff --git a/tests/frontend/components/sidebarManager.dragDrop.test.js b/tests/frontend/components/sidebarManager.dragDrop.test.js new file mode 100644 index 00000000..723a8be9 --- /dev/null +++ b/tests/frontend/components/sidebarManager.dragDrop.test.js @@ -0,0 +1,234 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const { + SIDEBAR_MANAGER_MODULE, + MODEL_API_FACTORY_MODULE, + I18N_MODULE, + BULK_MANAGER_MODULE, + UI_HELPERS_MODULE, + UPDATE_CHECK_MODULE, + STATE_MODULE, + MODAL_MANAGER_MODULE, +} = vi.hoisted(() => ({ + SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname, + MODEL_API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname, + I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname, + BULK_MANAGER_MODULE: new URL('../../../static/js/managers/BulkManager.js', import.meta.url).pathname, + UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname, + UPDATE_CHECK_MODULE: new URL('../../../static/js/utils/updateCheckHelpers.js', import.meta.url).pathname, + STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname, + MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname, +})); + +vi.mock(MODEL_API_FACTORY_MODULE, () => ({ getModelApiClient: vi.fn() })); +vi.mock(I18N_MODULE, () => ({ translate: (key, _args, fallback) => fallback || key })); +vi.mock(BULK_MANAGER_MODULE, () => ({ bulkManager: {} })); +vi.mock(UI_HELPERS_MODULE, () => ({ showToast: vi.fn(), showActionToast: vi.fn() })); +vi.mock(UPDATE_CHECK_MODULE, () => ({ performFolderUpdateCheck: vi.fn() })); +vi.mock(MODAL_MANAGER_MODULE, () => ({ + modalManager: { showModal: vi.fn(), closeModal: vi.fn() }, +})); + +const { SidebarManager } = await import(SIDEBAR_MANAGER_MODULE); +const { state } = await import(STATE_MODULE); + +function createManager() { + const manager = new SidebarManager(); + manager.pageType = 'loras'; + manager.displayMode = 'tree'; + manager.apiClient = { apiConfig: { config: { supportsMove: true } } }; + manager.pageControls = { pageState: { searchOptions: {} } }; + return manager; +} + +function buildDom() { + document.body.innerHTML = ` +
+ +
+
+
+
+
+
+ `; +} + +function createDragEvent(target) { + return { + target, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + dataTransfer: { + effectAllowed: '', + dropEffect: '', + setData: vi.fn(), + setDragImage: vi.fn(), + }, + }; +} + +describe('SidebarManager drag-and-drop UX', () => { + beforeEach(() => { + localStorage.clear(); + buildDom(); + state.bulkMode = false; + state.selectedModels = new Set(); + }); + + afterEach(() => { + vi.useRealTimers(); + document.body.innerHTML = ''; + }); + + it('replaces the default drag image with a compact chip', () => { + const manager = createManager(); + manager.folderTreeElement = document.getElementById('sidebarFolderTree'); + const card = document.querySelector('.model-card'); + const event = createDragEvent(card); + + manager.handleCardDragStart(event); + + expect(event.dataTransfer.setDragImage).toHaveBeenCalledTimes(1); + const [chip, offsetX, offsetY] = event.dataTransfer.setDragImage.mock.calls[0]; + expect(chip.classList.contains('model-card-drag-chip')).toBe(true); + // Anchored below-right of the cursor so the chip does not cover the + // folder row the pointer is aiming at. + expect(offsetX).toBeLessThan(0); + expect(offsetY).toBeLessThan(0); + expect(chip.querySelector('.model-card-drag-chip-thumb')?.src).toContain('/preview/a.png'); + expect(chip.querySelector('.model-card-drag-chip-name')?.textContent).toBe('Model A'); + expect(chip.querySelector('.model-card-drag-chip-count')).toBeNull(); + expect(chip.isConnected).toBe(true); + + manager.handleCardDragEnd({ target: card }); + }); + + it('shows a count badge for bulk drags and removes the chip on dragend', () => { + const manager = createManager(); + manager.folderTreeElement = document.getElementById('sidebarFolderTree'); + const card = document.querySelector('.model-card'); + card.classList.add('selected'); + state.bulkMode = true; + state.selectedModels = new Set(['/models/loras/a.safetensors', '/models/loras/b.safetensors']); + + const event = createDragEvent(card); + manager.handleCardDragStart(event); + + const [chip] = event.dataTransfer.setDragImage.mock.calls[0]; + expect(chip.querySelector('.model-card-drag-chip-count')?.textContent).toBe('×2'); + + manager.handleCardDragEnd({ target: card }); + expect(chip.isConnected).toBe(false); + expect(manager._dragPreviewElement).toBeNull(); + }); + + it('uses an icon instead of the thumbnail for blurred previews', () => { + const manager = createManager(); + manager.folderTreeElement = document.getElementById('sidebarFolderTree'); + const card = document.querySelector('.model-card'); + card.querySelector('.card-preview').classList.add('blurred'); + + const event = createDragEvent(card); + manager.handleCardDragStart(event); + + const [chip] = event.dataTransfer.setDragImage.mock.calls[0]; + expect(chip.querySelector('.model-card-drag-chip-thumb')).toBeNull(); + expect(chip.querySelector('.model-card-drag-chip-icon')).not.toBeNull(); + + manager.handleCardDragEnd({ target: card }); + }); + + it('auto-expands a collapsed folder after hovering during a drag', () => { + vi.useFakeTimers(); + const manager = createManager(); + manager.folderTreeElement = document.getElementById('sidebarFolderTree'); + const card = document.querySelector('.model-card'); + manager.handleCardDragStart(createDragEvent(card)); + + const nodeContent = document.querySelector('.sidebar-tree-node-content[data-path="parent"]'); + manager.handleFolderDragEnter({ ...createDragEvent(nodeContent), dataTransfer: { dropEffect: '' } }); + + expect(manager.expandedNodes.has('parent')).toBe(false); + + vi.advanceTimersByTime(700); + + expect(manager.expandedNodes.has('parent')).toBe(true); + const node = document.querySelector('.sidebar-tree-node[data-path="parent"]'); + expect(node.querySelector(':scope > .sidebar-tree-node-content > .sidebar-tree-expand-icon') + .classList.contains('expanded')).toBe(true); + expect(node.querySelector(':scope > .sidebar-tree-children').classList.contains('expanded')).toBe(true); + + manager.handleCardDragEnd({ target: card }); + }); + + it('cancels the pending auto-expand when the pointer leaves the folder', () => { + vi.useFakeTimers(); + const manager = createManager(); + manager.folderTreeElement = document.getElementById('sidebarFolderTree'); + const card = document.querySelector('.model-card'); + manager.handleCardDragStart(createDragEvent(card)); + + const nodeContent = document.querySelector('.sidebar-tree-node-content[data-path="parent"]'); + manager.handleFolderDragEnter({ ...createDragEvent(nodeContent), dataTransfer: { dropEffect: '' } }); + manager.handleFolderDragLeave({ + target: nodeContent, + relatedTarget: document.getElementById('modelGrid'), + preventDefault: vi.fn(), + }); + + vi.advanceTimersByTime(700); + + expect(manager.expandedNodes.has('parent')).toBe(false); + const node = document.querySelector('.sidebar-tree-node[data-path="parent"]'); + expect(node.querySelector(':scope > .sidebar-tree-children').classList.contains('expanded')).toBe(false); + + manager.handleCardDragEnd({ target: card }); + }); + + it('does not schedule auto-expand for leaf folders', () => { + vi.useFakeTimers(); + const manager = createManager(); + manager.folderTreeElement = document.getElementById('sidebarFolderTree'); + const card = document.querySelector('.model-card'); + manager.handleCardDragStart(createDragEvent(card)); + + const leafContent = document.querySelector('.sidebar-tree-node-content[data-path="parent/child"]'); + manager.handleFolderDragEnter({ ...createDragEvent(leafContent), dataTransfer: { dropEffect: '' } }); + + expect(manager._dragExpandTimer).toBeNull(); + + manager.handleCardDragEnd({ target: card }); + }); + + it('points the auto-scroll direction at the hovered container edge', () => { + const manager = createManager(); + manager.folderTreeElement = document.getElementById('sidebarFolderTree'); + + // jsdom rects are all zeros: a small clientY is "near the top". + manager._updateAutoScrollDirection(10); + expect(manager._autoScrollDirection).toBe(-1); + + manager._updateAutoScrollDirection(-10); + expect(manager._autoScrollDirection).toBe(-1); + }); +});