diff --git a/src/wwwroot/css/genpage.css b/src/wwwroot/css/genpage.css index d3a23ee9b..21cf8aefa 100644 --- a/src/wwwroot/css/genpage.css +++ b/src/wwwroot/css/genpage.css @@ -835,6 +835,16 @@ body { margin-left: 0.5rem; opacity: 0.8; } +.browser-multiselect-toggle-active, +.browser-multiselect-item-selected { + outline: 2px solid var(--emphasis) !important; + outline-offset: 1px; +} +.browser-multiselect-action-select { + max-width: 11rem; + margin-left: 0.15rem; + vertical-align: middle; +} .browser-fullcontent-container { margin-left: 0.2rem; display: inline-block; diff --git a/src/wwwroot/js/genpage/gentab/outputhistory.js b/src/wwwroot/js/genpage/gentab/outputhistory.js index 25088870c..e0b2bd181 100644 --- a/src/wwwroot/js/genpage/gentab/outputhistory.js +++ b/src/wwwroot/js/genpage/gentab/outputhistory.js @@ -2,8 +2,8 @@ let registeredMediaButtons = []; /** Registers a media button for extensions. 'mediaTypes' filters by type eg ['audio'], null means all. 'isDefault' promotes to visible (vs More dropdown). 'showInHistory' controls whether button appears in the History panel. */ -function registerMediaButton(name, action, title = '', mediaTypes = null, isDefault = false, showInHistory = true, href = null, is_download = false, can_multi = false, multi_only = false) { - registeredMediaButtons.push({ name, action, title, mediaTypes, isDefault, showInHistory, href, is_download, can_multi, multi_only }); +function registerMediaButton(name, action, title = '', mediaTypes = null, isDefault = false, showInHistory = true, href = null, is_download = false, can_multi = false, multi_only = false, max_selected = null) { + registeredMediaButtons.push({ name, action, title, mediaTypes, isDefault, showInHistory, href, is_download, can_multi, multi_only, max_selected }); } function listOutputHistoryFolderAndFiles(path, isRefresh, callback, depth) { @@ -65,7 +65,15 @@ function buttonsForImage(fullsrc, src, metadata, isCurrentImage = false) { let mediaType = getMediaType(src); buttons = []; if (permissions.hasPermission('user_star_images') && !isDataImage) { - let metaParsed = JSON.parse(metadata); + let getMeta = (metadata) => metadata ? (JSON.parse(metadata) || {}) : {}; + let metaParsed = getMeta(metadata); + let isStarred = (e) => { + let currentMeta = e && e.dataset ? getMeta(e.dataset.metadata) : {}; + if (Object.keys(currentMeta).length == 0) { + currentMeta = metaParsed; + } + return currentMeta.is_starred; + }; buttons.push({ label: (metadata && metaParsed.is_starred) ? 'Unstar' : 'Star', title: 'Star or unstar this image - starred images get moved to a separate folder and highlighted.', @@ -78,7 +86,7 @@ function buttonsForImage(fullsrc, src, metadata, isCurrentImage = false) { label: 'Enable Starred', title: 'Marks all selected images as starred if they are not already', onclick: (e) => { - if (!metaParsed.is_starred) { + if (!isStarred(e)) { toggleStar(fullsrc, src); } }, @@ -86,10 +94,10 @@ function buttonsForImage(fullsrc, src, metadata, isCurrentImage = false) { multi_only: true }); buttons.push({ - label: 'Disabled Starred', + label: 'Disable Starred', title: 'Marks all selected images as NOT starred if they are currently starred', onclick: (e) => { - if (metaParsed.is_starred) { + if (isStarred(e)) { toggleStar(fullsrc, src); } }, @@ -182,6 +190,7 @@ function buttonsForImage(fullsrc, src, metadata, isCurrentImage = false) { is_download: reg.is_download, can_multi: reg.can_multi, multi_only: reg.multi_only, + max_selected: reg.max_selected, onclick: () => reg.action(src) }); } @@ -247,6 +256,7 @@ function selectOutputInHistory(image, div) { let imageHistoryBrowser = new GenPageBrowserClass('image_history', listOutputHistoryFolderAndFiles, 'imagehistorybrowser', 'Thumbnails', describeOutputFile, selectOutputInHistory, ` `); +imageHistoryBrowser.allowMultiSelect = true; function storeImageToHistoryWithCurrentParams(img) { let data = getGenInput(); diff --git a/src/wwwroot/js/genpage/helpers/browsers.js b/src/wwwroot/js/genpage/helpers/browsers.js index 3f38fd88a..c515729b9 100644 --- a/src/wwwroot/js/genpage/helpers/browsers.js +++ b/src/wwwroot/js/genpage/helpers/browsers.js @@ -107,6 +107,10 @@ class GenPageBrowserClass { this.runAfterUpdate = []; this.refreshHandler = (callback) => callback(); this.checkIsSmall(); + this.allowMultiSelect = false; + this.multiSelectActive = false; + this.multiSelectToggleButton = null; + this.multiSelectActionSelect = null; } /** @@ -138,6 +142,7 @@ class GenPageBrowserClass { this.chunksRendered = 0; this.folder = folder; this.selected = null; + this.clearMultiSelection(); this.update(false, callback); } @@ -455,6 +460,9 @@ class GenPageBrowserClass { } let img = document.createElement('img'); img.addEventListener('click', () => { + if (this.handleMultiSelectTileClick(div)) { + return; + } this.select(file, div); }); img.classList.add('image-block-img-inner'); @@ -466,6 +474,12 @@ class GenPageBrowserClass { let textBlock = createDiv(null, 'model-descblock'); textBlock.tabIndex = 0; textBlock.innerHTML = desc.description; + textBlock.addEventListener('click', (e) => { + if (this.handleMultiSelectTileClick(div, e)) { + return; + } + this.select(file, div); + }); div.appendChild(textBlock); } else if (this.format.includes('Thumbnails')) { @@ -495,6 +509,12 @@ class GenPageBrowserClass { else { textBlock.classList.add('image-preview-text-large'); } + textBlock.addEventListener('click', (e) => { + if (this.handleMultiSelectTileClick(div, e)) { + return; + } + this.select(file, div); + }); div.appendChild(textBlock); } else if (this.format == 'List') { @@ -502,6 +522,9 @@ class GenPageBrowserClass { let textBlock = createSpan(null, 'browser-list-entry-text'); textBlock.innerText = desc.display || desc.name; textBlock.addEventListener('click', () => { + if (this.handleMultiSelectTileClick(div)) { + return; + } this.select(file, div); }); div.appendChild(textBlock); @@ -520,6 +543,9 @@ class GenPageBrowserClass { textBlock.style.width = `calc(${percent}% - ${imgAdj}rem)`; textBlock.innerHTML = detail; textBlock.addEventListener('click', () => { + if (this.handleMultiSelectTileClick(div)) { + return; + } this.select(file, div); }); div.appendChild(textBlock); @@ -704,6 +730,37 @@ class GenPageBrowserClass { this.headerBar.appendChild(formatSelector); this.headerBar.appendChild(buttons); refreshButton.onclick = this.refresh.bind(this); + if (this.allowMultiSelect) { + this.multiSelectToggleButton = document.createElement('button'); + this.multiSelectToggleButton.type = 'button'; + this.multiSelectToggleButton.id = `${this.id}_multiselect_toggle`; + this.multiSelectToggleButton.className = 'refresh-button translate translate-no-text browser-multiselect-toggle'; + this.multiSelectToggleButton.title = 'Toggle multi-select mode'; + this.multiSelectToggleButton.innerHTML = '✓'; + this.multiSelectToggleButton.addEventListener('click', () => { + this.setMultiSelectActive(!this.multiSelectActive); + }); + this.multiSelectActionSelect = document.createElement('select'); + this.multiSelectActionSelect.id = `${this.id}_multiselect_action`; + this.multiSelectActionSelect.className = 'browser-format-selector browser-multiselect-action-select'; + this.multiSelectActionSelect.title = 'Bulk action'; + let placeholderOpt = document.createElement('option'); + placeholderOpt.value = ''; + placeholderOpt.className = 'translate'; + placeholderOpt.innerText = translate('Actions...'); + this.multiSelectActionSelect.appendChild(placeholderOpt); + this.multiSelectActionSelect.style.display = 'none'; + this.multiSelectActionSelect.addEventListener('change', () => { + let choice = this.multiSelectActionSelect.value; + if (!choice) { + return; + } + this.runMultiSelectAction(choice); + this.multiSelectActionSelect.value = ''; + }); + this.upButton.insertAdjacentElement('afterend', this.multiSelectToggleButton); + this.multiSelectToggleButton.insertAdjacentElement('afterend', this.multiSelectActionSelect); + } this.fullContentDiv.appendChild(this.headerBar); this.contentDiv = createDiv(`${this.id}-content`, 'browser-content-container'); this.contentDiv.addEventListener('scroll', () => { @@ -769,6 +826,8 @@ class GenPageBrowserClass { applyTranslations(this.headerBar); if (!this.noContentUpdates) { this.buildContentList(this.contentDiv, files); + this.applyMultiSelectVisuals(); + this.syncMultiSelectHeader(); browserUtil.makeVisible(this.contentDiv); if (scrollOffset) { this.contentDiv.scrollTop = scrollOffset; @@ -783,4 +842,216 @@ class GenPageBrowserClass { this.builtEvent(); } } + + /** + * Returns multi-select items. + */ + getMultiSelectedItems() { + if (!this.contentDiv) { + return []; + } + return [...this.contentDiv.querySelectorAll(':scope > .browser-multiselect-item-selected[data-name]')]; + } + + /** + * Clears multi-selected items. + */ + clearMultiSelection() { + if (!this.allowMultiSelect) { + return; + } + for (let item of this.getMultiSelectedItems()) { + item.classList.remove('browser-multiselect-item-selected'); + } + this.syncMultiSelectHeader(); + } + + /** + * Turns multi-select mode on or off; exiting clears the selection. + */ + setMultiSelectActive(active) { + if (!this.allowMultiSelect || this.multiSelectActive == active) { + return; + } + this.multiSelectActive = active; + if (!active) { + this.clearMultiSelection(); + } + else { + this.syncMultiSelectHeader(); + } + this.applyMultiSelectVisuals(); + } + + /** + * Handles an item click while multi-select mode is active. + */ + handleMultiSelectTileClick(div, event = null) { + if (!this.multiSelectActive || !this.allowMultiSelect) { + return false; + } + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + div.classList.toggle('browser-multiselect-item-selected'); + this.syncMultiSelectHeader(); + return true; + } + + /** + * Returns files in the current listing that are multi-selected. + */ + getMultiSelectedFiles() { + if (!this.lastFiles) { + return []; + } + let selectedNames = new Set(this.getMultiSelectedItems().map(entry => entry.dataset.name)); + let out = []; + for (let file of this.lastFiles) { + if (selectedNames.has(file.name)) { + out.push(file); + } + } + return out; + } + + /** + * Labels for bulk actions shared by every selected item, respecting `can_multi` / `multi_only`. + */ + getCommonMultiSelectActionLabels() { + let files = this.getMultiSelectedFiles(); + if (files.length == 0) { + return []; + } + let eligiblePerFile = []; + for (let file of files) { + let desc = this.describe(file); + let labels = new Set(); + for (let button of desc.buttons) { + if (!button.onclick) { + continue; + } + if (button.multi_only && files.length < 2) { + continue; + } + if (!button.can_multi && !button.multi_only) { + continue; + } + if (button.max_selected != null && files.length > button.max_selected) { + continue; + } + labels.add(button.label); + } + eligiblePerFile.push(labels); + } + let first = eligiblePerFile[0]; + let common = []; + for (let label of first) { + if (eligiblePerFile.every(s => s.has(label))) { + common.push(label); + } + } + common.sort((a, b) => a.localeCompare(b)); + return common; + } + + /** + * Off: ✓ ✓ + * On: ☑ ☑ + */ + syncMultiSelectToggleAppearance() { + if (!this.multiSelectToggleButton) { + return; + } + this.multiSelectToggleButton.classList.toggle('browser-multiselect-toggle-active', this.multiSelectActive); + this.multiSelectToggleButton.innerHTML = this.multiSelectActive ? '☑' : '✓'; + } + + /** + * Updates multi-select toggle state and action dropdown. + */ + syncMultiSelectHeader() { + this.syncMultiSelectToggleAppearance(); + if (!this.multiSelectActionSelect) { + return; + } + let show = this.multiSelectActive && this.getMultiSelectedItems().length > 0; + this.multiSelectActionSelect.style.display = show ? '' : 'none'; + if (!show) { + return; + } + while (this.multiSelectActionSelect.options.length > 1) { + this.multiSelectActionSelect.remove(1); + } + this.multiSelectActionSelect.value = ''; + for (let label of this.getCommonMultiSelectActionLabels()) { + let opt = document.createElement('option'); + opt.value = label; + opt.className = 'translate'; + opt.innerText = translate(label); + this.multiSelectActionSelect.appendChild(opt); + } + applyTranslations(this.multiSelectActionSelect); + } + + /** + * Runs a multi-select action once per selected item. + */ + runMultiSelectAction(label) { + let files = this.getMultiSelectedFiles(); + let failed = 0; + for (let file of files) { + let div = this.getVisibleEntry(file.name); + let desc = this.describe(file); + let button = null; + for (let b of desc.buttons) { + if (b.label == label && b.onclick) { + button = b; + break; + } + } + if (!button) { + failed++; + console.error(`No bulk action '${label}' for ${file.name}`); + continue; + } + try { + if (div) { + button.onclick(div); + } + else { + button.onclick(null); + } + } + catch (err) { + console.error('Browser bulk action error:', err); + failed++; + } + } + if (failed > 0) { + showError(`Bulk action finished: ${failed} of ${files.length} failed - see console for details.`); + } + if (label == 'Delete') { + this.setMultiSelectActive(false); + } + else { + this.applyMultiSelectVisuals(); + this.syncMultiSelectHeader(); + } + } + + /** + * Applies multi-select highlight classes to visible rows. + */ + applyMultiSelectVisuals() { + if (this.multiSelectActive || !this.contentDiv) { + return; + } + for (let child of this.contentDiv.children) { + if (child.dataset && child.dataset.name) { + child.classList.remove('browser-multiselect-item-selected'); + } + } + } }