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