feat(sidebar): compact drag image, hover auto-expand, edge auto-scroll for card drags

Replace the browser's default full-card drag snapshot with a small chip
(thumbnail + name + count badge) anchored below-right of the cursor, so
the folder tree stays visible while aiming. Collapsed folders now
auto-expand after a short hover during a drag, and the tree auto-scrolls
when the pointer nears its top/bottom edge.
This commit is contained in:
Will Miao
2026-10-17 18:20:42 +08:00
parent 1276673e0e
commit f940daae29
3 changed files with 488 additions and 1 deletions
+56
View File
@@ -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);
+198 -1
View File
@@ -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 || '';
@@ -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 = `
<div class="folder-sidebar" id="folderSidebar">
<div class="sidebar-tree-container">
<div class="sidebar-tree" id="sidebarFolderTree">
<div class="sidebar-tree-node" data-path="parent">
<div class="sidebar-tree-node-content" data-path="parent">
<div class="sidebar-tree-expand-icon"><i class="fas fa-chevron-right"></i></div>
<i class="fas fa-folder sidebar-tree-folder-icon"></i>
<div class="sidebar-tree-folder-name">parent</div>
</div>
<div class="sidebar-tree-children">
<div class="sidebar-tree-node" data-path="parent/child">
<div class="sidebar-tree-node-content" data-path="parent/child">
<div class="sidebar-tree-expand-icon" style="opacity: 0; pointer-events: none;"><i class="fas fa-chevron-right"></i></div>
<i class="fas fa-folder sidebar-tree-folder-icon"></i>
<div class="sidebar-tree-folder-name">child</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="modelGrid">
<div class="model-card" data-filepath="/models/loras/a.safetensors" data-name="Model A" data-file_name="a.safetensors">
<div class="card-preview"><img src="/preview/a.png" alt=""></div>
</div>
</div>
`;
}
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);
});
});