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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user