diff --git a/static/js/components/RecipeCard.js b/static/js/components/RecipeCard.js index a934e9d2..bc6acee2 100644 --- a/static/js/components/RecipeCard.js +++ b/static/js/components/RecipeCard.js @@ -240,9 +240,12 @@ class RecipeCard { // Recipe card click event - only attach if not in duplicates mode if (!isDuplicatesMode) { - card.addEventListener('click', () => { + card.addEventListener('click', (e) => { if (state.bulkMode) { - bulkManager.toggleCardSelection(card); + if (e.shiftKey) { + e.preventDefault(); + } + bulkManager.toggleCardSelection(card, e.shiftKey); return; } this.clickHandler(this.recipe); diff --git a/static/js/components/shared/ModelCard.js b/static/js/components/shared/ModelCard.js index 6bdde42d..59d3495a 100644 --- a/static/js/components/shared/ModelCard.js +++ b/static/js/components/shared/ModelCard.js @@ -108,7 +108,10 @@ function handleModelCardEvent_internal(event, modelType) { } // 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) } @@ -288,12 +291,12 @@ function handleViewLocalVersionsFromCard(card, modelType) { } } -function handleCardClick(card, modelType) { +function handleCardClick(card, modelType, extendSelection = false) { const pageState = getCurrentPageState(); if (state.bulkMode) { // Toggle selection using the bulk manager - bulkManager.toggleCardSelection(card); + bulkManager.toggleCardSelection(card, extendSelection); } else if (pageState && pageState.duplicatesMode) { // In duplicates mode, don't open modal when clicking cards return; diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index 207b7ea5..e67393aa 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -26,6 +26,10 @@ export class BulkManager { this.marqueeElement = null; this.initialSelectedModels = new Set(); + // Shift+click range anchor: last plain-clicked filepath. Set in + // toggleCardSelection, cleared in clearSelection. + this.bulkAnchorFilepath = null; + // Drag detection properties 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 @@ -351,6 +355,7 @@ export class BulkManager { card.classList.remove('selected'); }); state.selectedModels.clear(); + this.bulkAnchorFilepath = null; // Update context menu header if visible if (this.bulkContextMenu) { @@ -358,9 +363,13 @@ export class BulkManager { } } - toggleCardSelection(card) { + toggleCardSelection(card, extendSelection = false) { const filepath = card.dataset.filepath; + if (extendSelection && this.selectRangeFromAnchor(filepath)) { + return; + } + if (card.classList.contains('selected')) { card.classList.remove('selected'); state.selectedModels.delete(filepath); @@ -372,12 +381,78 @@ export class BulkManager { this.updateMetadataCacheFromCard(filepath, card); } + this.bulkAnchorFilepath = filepath; + // Update context menu header if visible if (this.bulkContextMenu) { 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() { const currentType = state.currentPageType; const pageState = getCurrentPageState(); diff --git a/tests/frontend/managers/BulkManager.shiftRange.test.js b/tests/frontend/managers/BulkManager.shiftRange.test.js new file mode 100644 index 00000000..12af074c --- /dev/null +++ b/tests/frontend/managers/BulkManager.shiftRange.test.js @@ -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); + }); +});