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();
+ }
}