diff --git a/src/wwwroot/css/genpage.css b/src/wwwroot/css/genpage.css index d3a23ee9b..9327d95d4 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-entry-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/currentimagehandler.js b/src/wwwroot/js/genpage/gentab/currentimagehandler.js index 460e7091c..dcbee7b50 100644 --- a/src/wwwroot/js/genpage/gentab/currentimagehandler.js +++ b/src/wwwroot/js/genpage/gentab/currentimagehandler.js @@ -436,6 +436,11 @@ function toggleSeparateBatches() { function clickImageInBatch(div) { let imgElem = div.getElementsByTagName('img')[0]; + let multiSelectKey = getImageFullSrc(div.dataset.src); + if (imageHistoryBrowser.enableBrowserMultiSelect && imageHistoryBrowser.multiSelectActive && multiSelectKey && !div.classList.contains('image-block-placeholder')) { + imageHistoryBrowser.toggleBrowserMultiSelectForKey(multiSelectKey); + return; + } if (currentImgSrc == div.dataset.src) { imageFullView.showImage(div.dataset.src, div.dataset.metadata, div.dataset.batch_id); return; @@ -786,6 +791,9 @@ function toggleStar(path, rawSrc) { imageFullView.showImage(rawSrc, JSON.stringify(newMetadata), imageFullView.currentBatchId); imageFullView.pasteState(state); } + if (imageHistoryBrowser.enableBrowserMultiSelect && imageHistoryBrowser.multiSelectActive) { + imageHistoryBrowser.syncBrowserMultiSelectHeader(); + } }); } diff --git a/src/wwwroot/js/genpage/gentab/models.js b/src/wwwroot/js/genpage/gentab/models.js index 4aa7428f6..98bbbb71a 100644 --- a/src/wwwroot/js/genpage/gentab/models.js +++ b/src/wwwroot/js/genpage/gentab/models.js @@ -465,6 +465,7 @@ class ModelBrowserWrapper { let format = subType == 'Wildcards' ? 'Small Cards' : 'Cards'; extraHeader += ` `; this.browser = new GenPageBrowserClass(container, this.listModelFolderAndFiles.bind(this), id, format, this.describeModel.bind(this), this.selectModel.bind(this), extraHeader); + this.browser.enableBrowserMultiSelect = true; this.promptBox = getRequiredElementById('alt_prompt_textbox'); this.models = {}; this.browser.refreshHandler = (callback) => { @@ -717,7 +718,7 @@ class ModelBrowserWrapper { }, can_multi: true }]; } let isStarred = this.isStarred(model.data.name); - let starButton = { label: isStarred ? 'Unstar' : 'Star', onclick: () => { this.toggleStar(model.data.name); } }; + let starButton = { label: isStarred ? 'Unstar' : 'Star', onclick: () => { this.toggleStar(model.data.name); }, can_multi: true }; buttons.push(starButton); let name = cleanModelName(model.data.name); let display = (model.data.display || name).replaceAll('/', ' / '); diff --git a/src/wwwroot/js/genpage/gentab/outputhistory.js b/src/wwwroot/js/genpage/gentab/outputhistory.js index 25088870c..18c2840b4 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) { @@ -72,7 +72,8 @@ function buttonsForImage(fullsrc, src, metadata, isCurrentImage = false) { className: (metadata && metaParsed.is_starred) ? ' star-button button-starred-image' : ' star-button', onclick: (e) => { toggleStar(fullsrc, src); - } + }, + can_multi: true }); buttons.push({ label: 'Enable Starred', @@ -167,6 +168,11 @@ function buttonsForImage(fullsrc, src, metadata, isCurrentImage = false) { if (div) { removeImageBlockFromBatch(div); } + if (imageHistoryBrowser.enableBrowserMultiSelect) { + imageHistoryBrowser.multiSelectedKeys.delete(fullsrc); + imageHistoryBrowser.applyBrowserMultiSelectVisuals(); + imageHistoryBrowser.syncBrowserMultiSelectHeader(); + } }); }, // TODO: Only ask once for the multi-set rather than once per each @@ -182,6 +188,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 +254,62 @@ function selectOutputInHistory(image, div) { let imageHistoryBrowser = new GenPageBrowserClass('image_history', listOutputHistoryFolderAndFiles, 'imagehistorybrowser', 'Thumbnails', describeOutputFile, selectOutputInHistory, ` `); +imageHistoryBrowser.enableBrowserMultiSelect = true; +imageHistoryBrowser.keepBrowserMultiSelectKeyAfterPrune = function(key, namesInCurrentList) { + if (namesInCurrentList.has(key)) { + return true; + } + let currentImageBatchDiv = getRequiredElementById('current_image_batch'); + for (let candidate of currentImageBatchDiv.querySelectorAll('.image-block:not(.image-block-placeholder)')) { + if (getImageFullSrc(candidate.dataset.src) == key) { + return true; + } + } + return false; +}; +imageHistoryBrowser.applyExtraMultiSelectVisuals = function() { + let currentImageBatchDiv = getRequiredElementById('current_image_batch'); + for (let candidate of currentImageBatchDiv.querySelectorAll('.image-block:not(.image-block-placeholder)')) { + let blockKey = getImageFullSrc(candidate.dataset.src); + if (!blockKey) { + continue; + } + let on = this.multiSelectActive && this.multiSelectedKeys.has(blockKey); + candidate.classList.toggle('browser-multiselect-entry-selected', on); + } +}; + +imageHistoryBrowser.getExtraMultiSelectedFiles = function(seen) { + let out = []; + let currentImageBatchDiv = getRequiredElementById('current_image_batch'); + for (let key of this.multiSelectedKeys) { + if (seen.has(key)) { + continue; + } + seen.add(key); + let block = null; + for (let candidate of currentImageBatchDiv.querySelectorAll('.image-block:not(.image-block-placeholder)')) { + if (getImageFullSrc(candidate.dataset.src) == key) { + block = candidate; + break; + } + } + let slash = key.lastIndexOf('/'); + let baseName = slash >= 0 ? key.substring(slash + 1) : key; + let src = block && block.dataset.src ? block.dataset.src : `${getImageOutPrefix()}/${key}`; + let metadata = block && block.dataset.metadata ? block.dataset.metadata : '{}'; + out.push({ + name: key, + data: { + src, + fullsrc: key, + name: baseName, + metadata + } + }); + } + return out; +}; function storeImageToHistoryWithCurrentParams(img) { let data = getGenInput(); diff --git a/src/wwwroot/js/genpage/gentab/presets.js b/src/wwwroot/js/genpage/gentab/presets.js index 9daf9892b..30f338ff2 100644 --- a/src/wwwroot/js/genpage/gentab/presets.js +++ b/src/wwwroot/js/genpage/gentab/presets.js @@ -626,18 +626,18 @@ function listPresetFolderAndFiles(path, isRefresh, callback, depth) { function describePreset(preset) { let buttons = [ { label: 'Toggle', onclick: () => selectPreset(preset) }, - { label: 'Direct Apply', onclick: () => applyOnePreset(preset.data) }, - { label: preset.data.is_starred ? 'Unstar' : 'Star', onclick: () => togglePresetStar(preset) }, + { label: 'Direct Apply', onclick: () => applyOnePreset(preset.data), can_multi: true }, + { label: preset.data.is_starred ? 'Unstar' : 'Star', onclick: () => togglePresetStar(preset), can_multi: true }, { label: 'Edit Preset', onclick: () => editPreset(preset.data) }, { label: 'Duplicate Preset', onclick: () => duplicatePreset(preset.data) }, - { label: 'Export Preset', onclick: () => exportOnePresetButton(preset.data) }, + { label: 'Export Preset', onclick: () => exportOnePresetButton(preset.data), can_multi: true }, { label: 'Delete Preset', onclick: () => { if (confirm("Are you sure want to delete that preset?")) { genericRequest('DeletePreset', { preset: preset.data.title }, data => { loadUserData(); }); } - } } + }, can_multi: true } ]; let paramText = Object.keys(preset.data.param_map).map(key => `${key}: ${preset.data.param_map[key]}`); let description = `${preset.data.title}:\n${preset.data.description}\n\n${paramText.join('\n')}`; @@ -696,6 +696,7 @@ let presetBrowser = new GenPageBrowserClass('preset_list', listPresetFolderAndFi `); +presetBrowser.enableBrowserMultiSelect = true; function importPresetsButton() { getRequiredElementById('import_presets_textarea').value = ''; diff --git a/src/wwwroot/js/genpage/helpers/browsers.js b/src/wwwroot/js/genpage/helpers/browsers.js index 3f38fd88a..96676556a 100644 --- a/src/wwwroot/js/genpage/helpers/browsers.js +++ b/src/wwwroot/js/genpage/helpers/browsers.js @@ -107,6 +107,11 @@ class GenPageBrowserClass { this.runAfterUpdate = []; this.refreshHandler = (callback) => callback(); this.checkIsSmall(); + this.enableBrowserMultiSelect = false; + this.multiSelectActive = false; + this.multiSelectedKeys = new Set(); + this.multiSelectToggleButton = null; + this.multiSelectActionSelect = null; } /** @@ -138,6 +143,10 @@ class GenPageBrowserClass { this.chunksRendered = 0; this.folder = folder; this.selected = null; + if (this.enableBrowserMultiSelect) { + this.multiSelectedKeys.clear(); + this.syncBrowserMultiSelectHeader(); + } this.update(false, callback); } @@ -455,6 +464,9 @@ class GenPageBrowserClass { } let img = document.createElement('img'); img.addEventListener('click', () => { + if (this.handleBrowserMultiSelectTileClick(file, div)) { + return; + } this.select(file, div); }); img.classList.add('image-block-img-inner'); @@ -467,6 +479,20 @@ class GenPageBrowserClass { textBlock.tabIndex = 0; textBlock.innerHTML = desc.description; div.appendChild(textBlock); + div.addEventListener('click', (e) => { + if (!this.enableBrowserMultiSelect || !this.multiSelectActive) { + return; + } + if (e.target.closest('.model-block-menu-button')) { + return; + } + if (e.target.closest('img.image-block-img-inner')) { + return; + } + e.preventDefault(); + e.stopPropagation(); + this.toggleBrowserMultiSelectForFile(file, div); + }); } else if (this.format.includes('Thumbnails')) { div.className += ' image-block image-block-legacy'; @@ -495,6 +521,12 @@ class GenPageBrowserClass { else { textBlock.classList.add('image-preview-text-large'); } + textBlock.addEventListener('click', (e) => { + if (this.handleBrowserMultiSelectTileClick(file, div, e)) { + return; + } + this.select(file, div); + }); div.appendChild(textBlock); } else if (this.format == 'List') { @@ -502,6 +534,9 @@ class GenPageBrowserClass { let textBlock = createSpan(null, 'browser-list-entry-text'); textBlock.innerText = desc.display || desc.name; textBlock.addEventListener('click', () => { + if (this.handleBrowserMultiSelectTileClick(file, div)) { + return; + } this.select(file, div); }); div.appendChild(textBlock); @@ -520,6 +555,9 @@ class GenPageBrowserClass { textBlock.style.width = `calc(${percent}% - ${imgAdj}rem)`; textBlock.innerHTML = detail; textBlock.addEventListener('click', () => { + if (this.handleBrowserMultiSelectTileClick(file, div)) { + return; + } this.select(file, div); }); div.appendChild(textBlock); @@ -704,6 +742,37 @@ class GenPageBrowserClass { this.headerBar.appendChild(formatSelector); this.headerBar.appendChild(buttons); refreshButton.onclick = this.refresh.bind(this); + if (this.enableBrowserMultiSelect) { + 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.setBrowserMultiSelectActive(!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.runBrowserMultiSelectAction(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', () => { @@ -768,7 +837,10 @@ class GenPageBrowserClass { this.buildTreeElements(this.folderTreeDiv, '', this.tree); applyTranslations(this.headerBar); if (!this.noContentUpdates) { + this.pruneBrowserMultiSelectionToCurrentList(); this.buildContentList(this.contentDiv, files); + this.applyBrowserMultiSelectVisuals(); + this.syncBrowserMultiSelectHeader(); browserUtil.makeVisible(this.contentDiv); if (scrollOffset) { this.contentDiv.scrollTop = scrollOffset; @@ -783,4 +855,254 @@ class GenPageBrowserClass { this.builtEvent(); } } + + /** + * Removes all browser multi-select keys. + */ + clearBrowserMultiSelection() { + this.multiSelectedKeys.clear(); + this.syncBrowserMultiSelectHeader(); + this.applyBrowserMultiSelectVisuals(); + } + + /** + * Turns browser multi-select mode on or off; exiting clears the selection. + */ + setBrowserMultiSelectActive(active) { + if (!this.enableBrowserMultiSelect || this.multiSelectActive == active) { + return; + } + this.multiSelectActive = active; + if (!active) { + this.multiSelectedKeys.clear(); + } + this.syncBrowserMultiSelectHeader(); + this.applyBrowserMultiSelectVisuals(); + } + + /** + * Toggles whether a file row is selected for bulk actions. + */ + toggleBrowserMultiSelectForKey(key) { + if (!key) { + return; + } + if (this.multiSelectedKeys.has(key)) { + this.multiSelectedKeys.delete(key); + } + else { + this.multiSelectedKeys.add(key); + } + this.applyBrowserMultiSelectVisuals(); + this.syncBrowserMultiSelectHeader(); + } + + /** + * Toggles whether a file row is selected for bulk actions. + */ + toggleBrowserMultiSelectForFile(file, div) { + this.toggleBrowserMultiSelectForKey(file.name); + } + + handleBrowserMultiSelectTileClick(file, div, event = null) { + if (!this.multiSelectActive || !this.enableBrowserMultiSelect) { + return false; + } + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + this.toggleBrowserMultiSelectForFile(file, div); + return true; + } + + /** + * Returns files in the current listing that are multi-selected. + */ + getMultiSelectedFiles() { + let seen = new Set(); + let out = []; + if (this.lastFiles) { + for (let file of this.lastFiles) { + if (this.multiSelectedKeys.has(file.name)) { + out.push(file); + seen.add(file.name); + } + } + } + return out.concat(this.getExtraMultiSelectedFiles(seen)); + } + + /** + * Returns true if this multi-selected key should remain after pruning. + */ + keepBrowserMultiSelectKeyAfterPrune(key, namesInCurrentList) { + return namesInCurrentList.has(key); + } + + /** + * Removes multi-selected keys for which keepBrowserMultiSelectKeyAfterPrune returns false. + */ + pruneBrowserMultiSelectionToCurrentList() { + if (!this.lastFiles) { + return; + } + let names = new Set(this.lastFiles.map(f => f.name)); + for (let key of [...this.multiSelectedKeys]) { + if (!this.keepBrowserMultiSelectKeyAfterPrune(key, names)) { + this.multiSelectedKeys.delete(key); + } + } + } + + /** + * Labels for bulk actions shared by every selected item, respecting `can_multi` / `multi_only`. + */ + collectCommonBulkActionLabels() { + let files = this.getMultiSelectedFiles(); + let selCount = files.length; + if (selCount == 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 && selCount < 2) { + continue; + } + if (!button.can_multi && !button.multi_only) { + continue; + } + if (button.max_selected != null && selCount > 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: ☑ ☑ + */ + syncBrowserMultiSelectToggleAppearance() { + 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 bulk action dropdown. + */ + syncBrowserMultiSelectHeader() { + this.syncBrowserMultiSelectToggleAppearance(); + if (!this.multiSelectActionSelect) { + return; + } + let show = this.multiSelectActive && this.multiSelectedKeys.size > 0; + this.multiSelectActionSelect.style.display = show ? '' : 'none'; + if (!show) { + return; + } + while (this.multiSelectActionSelect.options.length > 1) { + this.multiSelectActionSelect.remove(1); + } + let labels = this.collectCommonBulkActionLabels(); + for (let label of labels) { + let opt = document.createElement('option'); + opt.value = label; + opt.className = 'translate'; + opt.innerText = translate(label); + this.multiSelectActionSelect.appendChild(opt); + } + applyTranslations(this.multiSelectActionSelect); + } + + /** + * Runs a named bulk action (card popover label) once per selected item. + */ + runBrowserMultiSelectAction(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.`); + } + this.pruneBrowserMultiSelectionToCurrentList(); + this.applyBrowserMultiSelectVisuals(); + this.syncBrowserMultiSelectHeader(); + } + + /** + * Applies multi-select visuals outside the browser content list. + */ + applyExtraMultiSelectVisuals() { + } + + /** + * Returns extra multi-selected files outside the current browser listing. + */ + getExtraMultiSelectedFiles(seen) { + return []; + } + + /** + * Applies multi-select highlight classes to visible rows. + */ + applyBrowserMultiSelectVisuals() { + if (this.contentDiv) { + for (let child of this.contentDiv.children) { + if (!child.dataset || !child.dataset.name) { + continue; + } + let on = this.multiSelectActive && this.multiSelectedKeys.has(child.dataset.name); + child.classList.toggle('browser-multiselect-entry-selected', on); + } + } + this.applyExtraMultiSelectVisuals(); + } }