feat(bulk): add shift+click range selection in bulk mode

This commit is contained in:
Will Miao
2026-08-26 10:09:35 +08:00
parent 0b08ad283a
commit 4ed9f775f6
4 changed files with 255 additions and 6 deletions
+5 -2
View File
@@ -240,9 +240,12 @@ class RecipeCard {
// Recipe card click event - only attach if not in duplicates mode // Recipe card click event - only attach if not in duplicates mode
if (!isDuplicatesMode) { if (!isDuplicatesMode) {
card.addEventListener('click', () => { card.addEventListener('click', (e) => {
if (state.bulkMode) { if (state.bulkMode) {
bulkManager.toggleCardSelection(card); if (e.shiftKey) {
e.preventDefault();
}
bulkManager.toggleCardSelection(card, e.shiftKey);
return; return;
} }
this.clickHandler(this.recipe); this.clickHandler(this.recipe);
+6 -3
View File
@@ -108,7 +108,10 @@ function handleModelCardEvent_internal(event, modelType) {
} }
// If no specific element was clicked, handle the card click (show modal or toggle selection) // If no specific element was clicked, handle the card click (show modal or toggle selection)
handleCardClick(card, modelType); if (state.bulkMode && event.shiftKey) {
event.preventDefault(); // keep shift+click from extending a text selection
}
handleCardClick(card, modelType, event.shiftKey);
return false; // Continue with other handlers (e.g., bulk selection) return false; // Continue with other handlers (e.g., bulk selection)
} }
@@ -288,12 +291,12 @@ function handleViewLocalVersionsFromCard(card, modelType) {
} }
} }
function handleCardClick(card, modelType) { function handleCardClick(card, modelType, extendSelection = false) {
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
if (state.bulkMode) { if (state.bulkMode) {
// Toggle selection using the bulk manager // Toggle selection using the bulk manager
bulkManager.toggleCardSelection(card); bulkManager.toggleCardSelection(card, extendSelection);
} else if (pageState && pageState.duplicatesMode) { } else if (pageState && pageState.duplicatesMode) {
// In duplicates mode, don't open modal when clicking cards // In duplicates mode, don't open modal when clicking cards
return; return;
+76 -1
View File
@@ -26,6 +26,10 @@ export class BulkManager {
this.marqueeElement = null; this.marqueeElement = null;
this.initialSelectedModels = new Set(); this.initialSelectedModels = new Set();
// Shift+click range anchor: last plain-clicked filepath. Set in
// toggleCardSelection, cleared in clearSelection.
this.bulkAnchorFilepath = null;
// Drag detection properties // Drag detection properties
this.dragThreshold = 5; // Pixels to move before considering it a drag this.dragThreshold = 5; // Pixels to move before considering it a drag
this.dragDelayMs = 100; // Minimum hold time before a drag is treated as a marquee this.dragDelayMs = 100; // Minimum hold time before a drag is treated as a marquee
@@ -351,6 +355,7 @@ export class BulkManager {
card.classList.remove('selected'); card.classList.remove('selected');
}); });
state.selectedModels.clear(); state.selectedModels.clear();
this.bulkAnchorFilepath = null;
// Update context menu header if visible // Update context menu header if visible
if (this.bulkContextMenu) { if (this.bulkContextMenu) {
@@ -358,9 +363,13 @@ export class BulkManager {
} }
} }
toggleCardSelection(card) { toggleCardSelection(card, extendSelection = false) {
const filepath = card.dataset.filepath; const filepath = card.dataset.filepath;
if (extendSelection && this.selectRangeFromAnchor(filepath)) {
return;
}
if (card.classList.contains('selected')) { if (card.classList.contains('selected')) {
card.classList.remove('selected'); card.classList.remove('selected');
state.selectedModels.delete(filepath); state.selectedModels.delete(filepath);
@@ -372,12 +381,78 @@ export class BulkManager {
this.updateMetadataCacheFromCard(filepath, card); this.updateMetadataCacheFromCard(filepath, card);
} }
this.bulkAnchorFilepath = filepath;
// Update context menu header if visible // Update context menu header if visible
if (this.bulkContextMenu) { if (this.bulkContextMenu) {
this.bulkContextMenu.updateSelectedCountHeader(); this.bulkContextMenu.updateSelectedCountHeader();
} }
} }
/**
* Select exactly the items between the shift anchor and the target
* (inclusive), following list order. Explorer-style range semantics:
* selections outside the new range are dropped, and consecutive shifts
* re-derive the range from the same anchor. Returns false when there is
* no usable anchor so the caller can fall back to a single-card toggle.
*/
selectRangeFromAnchor(targetFilepath) {
const scroller = state.virtualScroller;
if (!scroller || !scroller.items || !this.bulkAnchorFilepath) {
return false;
}
const anchorIndex = scroller.findIndexByFilePath(this.bulkAnchorFilepath);
const targetIndex = scroller.findIndexByFilePath(targetFilepath);
if (anchorIndex === -1 || targetIndex === -1) {
return false;
}
const startIndex = Math.min(anchorIndex, targetIndex);
const endIndex = Math.max(anchorIndex, targetIndex);
const metadataCache = this.getMetadataCache();
const rangePaths = new Set();
for (let i = startIndex; i <= endIndex; i++) {
const item = scroller.items[i];
if (!item || !item.file_path) {
continue;
}
rangePaths.add(item.file_path);
if (!metadataCache.has(item.file_path)) {
const modelId = this.parseModelId(item?.civitai?.modelId);
metadataCache.set(item.file_path, {
fileName: item.file_name,
folder: item.folder || '',
usageTips: item.usage_tips || '{}',
modelName: item.name || item.file_name,
...(modelId !== null ? { modelId } : {})
});
}
state.selectedModels.add(item.file_path);
}
for (const filepath of [...state.selectedModels]) {
if (!rangePaths.has(filepath)) {
state.selectedModels.delete(filepath);
}
}
this.applySelectionState();
if (this.bulkContextMenu) {
this.bulkContextMenu.updateSelectedCountHeader();
}
if (this.isStripVisible) {
this.updateThumbnailStrip();
}
return true;
}
getMetadataCache() { getMetadataCache() {
const currentType = state.currentPageType; const currentType = state.currentPageType;
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
@@ -0,0 +1,168 @@
import { describe, it, beforeEach, afterEach, expect } from 'vitest';
import { state } from '../../../static/js/state/index.js';
import { MODEL_TYPES } from '../../../static/js/api/apiConfig.js';
import { eventManager } from '../../../static/js/utils/EventManager.js';
import { BulkManager } from '../../../static/js/managers/BulkManager.js';
function createCard(filePath) {
const card = document.createElement('div');
card.className = 'model-card';
card.dataset.filepath = filePath;
document.body.appendChild(card);
return card;
}
describe('BulkManager shift+click range selection', () => {
let bulk;
beforeEach(() => {
eventManager.cleanup();
state.currentPageType = MODEL_TYPES.LORA;
state.bulkMode = true;
state.selectedModels.clear();
state.loraMetadataCache.clear();
document.body.innerHTML = '';
bulk = new BulkManager();
});
afterEach(() => {
eventManager.cleanup();
delete state.virtualScroller;
document.body.innerHTML = '';
});
function attachFakeScroller(filePaths) {
const items = filePaths.map((fp) => ({
file_path: fp,
file_name: fp.split('/').pop(),
folder: '',
usage_tips: '{}',
}));
state.virtualScroller = {
items,
findIndexByFilePath: (fp) => items.findIndex((item) => item.file_path === fp),
};
return items;
}
it('a plain click selects a single card and sets it as the shift anchor', () => {
const cardA = createCard('/models/a.safetensors');
bulk.toggleCardSelection(cardA);
expect(state.selectedModels.has('/models/a.safetensors')).toBe(true);
expect(bulk.bulkAnchorFilepath).toBe('/models/a.safetensors');
});
it('shift+click selects every item between anchor and target', () => {
attachFakeScroller(['/models/a', '/models/b', '/models/c', '/models/d']);
const cards = ['/models/a', '/models/b', '/models/c', '/models/d'].map(createCard);
bulk.toggleCardSelection(cards[0]);
bulk.toggleCardSelection(cards[3], true);
expect([...state.selectedModels].sort()).toEqual(['/models/a', '/models/b', '/models/c', '/models/d']);
cards.forEach((card) => expect(card.classList.contains('selected')).toBe(true));
// Anchor stays on the original plain-clicked card
expect(bulk.bulkAnchorFilepath).toBe('/models/a');
});
it('shift+click works when the target precedes the anchor', () => {
attachFakeScroller(['/models/a', '/models/b', '/models/c', '/models/d']);
const cards = ['/models/a', '/models/b', '/models/c', '/models/d'].map(createCard);
bulk.toggleCardSelection(cards[3]);
bulk.toggleCardSelection(cards[1], true);
expect([...state.selectedModels].sort()).toEqual(['/models/b', '/models/c', '/models/d']);
});
it('consecutive shift+clicks re-derive the range from the same anchor', () => {
attachFakeScroller(['/models/a', '/models/b', '/models/c', '/models/d', '/models/e']);
const cards = ['/models/a', '/models/b', '/models/c', '/models/d', '/models/e'].map(createCard);
bulk.toggleCardSelection(cards[0]);
bulk.toggleCardSelection(cards[2], true);
expect([...state.selectedModels].sort()).toEqual(['/models/a', '/models/b', '/models/c']);
bulk.toggleCardSelection(cards[4], true);
expect([...state.selectedModels].sort()).toEqual([
'/models/a', '/models/b', '/models/c', '/models/d', '/models/e',
]);
bulk.toggleCardSelection(cards[1], true);
expect([...state.selectedModels].sort()).toEqual(['/models/a', '/models/b']);
expect(bulk.bulkAnchorFilepath).toBe('/models/a');
});
it('shift+click drops selections that fall outside the new range', () => {
attachFakeScroller(['/models/a', '/models/b', '/models/c', '/models/d', '/models/e']);
const cards = ['/models/a', '/models/b', '/models/c', '/models/d', '/models/e'].map(createCard);
bulk.toggleCardSelection(cards[0]);
// Simulate an out-of-range selection (e.g. from a previous marquee)
state.selectedModels.add('/models/e');
cards[4].classList.add('selected');
bulk.toggleCardSelection(cards[2], true);
expect([...state.selectedModels].sort()).toEqual(['/models/a', '/models/b', '/models/c']);
expect(cards[4].classList.contains('selected')).toBe(false);
expect(bulk.bulkAnchorFilepath).toBe('/models/a');
});
it('shift+click without an anchor falls back to a plain toggle', () => {
attachFakeScroller(['/models/a', '/models/b', '/models/c']);
const cardC = createCard('/models/c');
bulk.toggleCardSelection(cardC, true);
expect([...state.selectedModels]).toEqual(['/models/c']);
expect(cardC.classList.contains('selected')).toBe(true);
// The fallback click becomes the new anchor
expect(bulk.bulkAnchorFilepath).toBe('/models/c');
});
it('shift+click falls back to a plain toggle when the anchor is no longer listed', () => {
const items = attachFakeScroller(['/models/a', '/models/b', '/models/c', '/models/d']);
const cards = ['/models/a', '/models/b', '/models/c', '/models/d'].map(createCard);
bulk.toggleCardSelection(cards[0]);
// Simulate a filter change removing the anchor from the loaded items
items.splice(0, 1);
bulk.toggleCardSelection(cards[3], true);
expect([...state.selectedModels].sort()).toEqual(['/models/a', '/models/d']);
expect(bulk.bulkAnchorFilepath).toBe('/models/d');
});
it('range selection populates metadata cache for items without rendered cards', () => {
attachFakeScroller(['/models/a', '/models/b', '/models/c', '/models/d']);
// Only the endpoints exist in the DOM (b, c are virtualized away)
const cardA = createCard('/models/a');
const cardD = createCard('/models/d');
bulk.toggleCardSelection(cardA);
bulk.toggleCardSelection(cardD, true);
expect([...state.selectedModels].sort()).toEqual(['/models/a', '/models/b', '/models/c', '/models/d']);
const cacheB = state.loraMetadataCache.get('/models/b');
expect(cacheB.fileName).toBe('b');
});
it('exiting bulk mode clears the anchor along with the selection', () => {
const cardA = createCard('/models/a');
bulk.toggleCardSelection(cardA);
expect(bulk.bulkAnchorFilepath).toBe('/models/a');
bulk.toggleBulkMode();
expect(bulk.bulkAnchorFilepath).toBeNull();
expect(state.selectedModels.size).toBe(0);
});
});