641 lines
22 KiB
JavaScript
641 lines
22 KiB
JavaScript
/**
|
|
* NF Preview Selector - Preview Dialog Component
|
|
*/
|
|
|
|
import { NFFloatingWindow } from './floating_window.js';
|
|
import { api } from "../../scripts/api.js";
|
|
import { app } from "../../scripts/app.js";
|
|
|
|
export class NFPreviewDialog {
|
|
constructor() {
|
|
this.window = null;
|
|
this.images = [];
|
|
this.selectedIndices = new Set();
|
|
this.reviewId = null;
|
|
this.uniqueId = null;
|
|
this.timeout = 60;
|
|
this.countdownInterval = null;
|
|
this.remainingTime = 0;
|
|
this.isPinned = false;
|
|
|
|
// Load CSS
|
|
this.loadCSS();
|
|
|
|
// Apply max width setting
|
|
this.applyMaxWidthSetting();
|
|
|
|
// Setup Queue Prompt keybinding integration
|
|
this.setupQueuePromptIntegration();
|
|
}
|
|
|
|
loadCSS() {
|
|
const link = document.createElement('link');
|
|
link.rel = 'stylesheet';
|
|
link.href = new URL("./floating_window.css", import.meta.url).href;
|
|
document.head.appendChild(link);
|
|
}
|
|
|
|
applyMaxWidthSetting() {
|
|
// Get max width setting from ComfyUI settings (try new ID first, fallback to old)
|
|
let maxWidth = app.ui.settings.getSettingValue("NFPreviewSelector.UI.MaxWidth.v2",
|
|
app.ui.settings.getSettingValue("NFPreviewSelector.UI.MaxWidth", 800));
|
|
|
|
// Validate and clamp the value between 400 and 1800
|
|
maxWidth = Math.max(400, Math.min(1800, maxWidth));
|
|
|
|
// Create or update CSS rule for max width
|
|
const style = document.createElement('style');
|
|
style.id = 'nf-preview-max-width-style';
|
|
style.textContent = `
|
|
.nf-float {
|
|
max-width: ${maxWidth}px !important;
|
|
}
|
|
`;
|
|
|
|
// Remove existing style if it exists
|
|
const existingStyle = document.getElementById('nf-preview-max-width-style');
|
|
if (existingStyle) {
|
|
existingStyle.remove();
|
|
}
|
|
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
setupQueuePromptIntegration() {
|
|
// Listen for Queue Prompt keybinding events
|
|
document.addEventListener('keydown', (event) => {
|
|
// Check if it's Ctrl+Enter (Queue Prompt) or Ctrl+Shift+Enter (Queue Prompt to Front)
|
|
if (event.ctrlKey && event.key === 'Enter') {
|
|
// Only handle if preview dialog is visible
|
|
if (this.isVisible()) {
|
|
// Queue Prompt keybinding detected
|
|
|
|
// Stop the dialog processing immediately
|
|
this.stopDialogForQueue();
|
|
|
|
// Allow the event to propagate to ComfyUI's queue system
|
|
// Do not prevent default - let ComfyUI handle the queue
|
|
}
|
|
}
|
|
});
|
|
|
|
// Also listen for programmatic queue events via API
|
|
api.addEventListener("status", (event) => {
|
|
const status = event.detail;
|
|
|
|
// If queue is starting and dialog is visible, stop the dialog
|
|
if (status && status.exec_info && status.exec_info.queue_remaining > 0) {
|
|
if (this.isVisible()) {
|
|
// Queue started programmatically
|
|
this.stopDialogForQueue();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
stopDialogForQueue() {
|
|
// If dialog is pinned, don't stop it
|
|
if (this.isPinned) {
|
|
return;
|
|
}
|
|
|
|
// Stop countdown immediately
|
|
this.stopCountdown();
|
|
|
|
// Stopping dialog processing for queue
|
|
|
|
// Send cancellation response to backend (empty selection, cancelled=true)
|
|
this.sendSelection([], true);
|
|
|
|
// Hide the dialog
|
|
this.hide();
|
|
}
|
|
|
|
isVisible() {
|
|
return this.window && !this.window.classList.contains('hidden');
|
|
}
|
|
|
|
show(data) {
|
|
// Showing review dialog
|
|
|
|
// Apply latest max width setting
|
|
this.applyMaxWidthSetting();
|
|
|
|
// If dialog is already open for this review, don't create another
|
|
if (this.reviewId === data.review_id && this.window) {
|
|
// Dialog already open
|
|
return;
|
|
}
|
|
|
|
// If dialog is pinned and visible, update with new data
|
|
if (this.isPinned && this.isVisible()) {
|
|
this.updateWithNewData(data);
|
|
return;
|
|
}
|
|
|
|
// Stop any existing countdown
|
|
this.stopCountdown();
|
|
|
|
this.images = data.images || [];
|
|
this.reviewId = data.review_id;
|
|
this.uniqueId = data.unique_id;
|
|
this.timeout = data.timeout || 60;
|
|
this.remainingTime = this.timeout;
|
|
this.selectedIndices.clear();
|
|
|
|
this.createWindow();
|
|
this.renderImages();
|
|
this.startCountdown();
|
|
}
|
|
|
|
createWindow() {
|
|
if (this.window) {
|
|
this.window.destroy();
|
|
}
|
|
|
|
// Get saved position or calculate center position
|
|
const rememberPosition = app.ui.settings.getSettingValue("NFPreviewSelector.UI.WindowPosition", true);
|
|
const savedPosition = rememberPosition ? this.getSavedPosition() : null;
|
|
let x, y;
|
|
|
|
if (savedPosition) {
|
|
// Use saved position from previous session
|
|
x = savedPosition.x;
|
|
y = savedPosition.y;
|
|
} else {
|
|
// First time or position memory disabled: calculate center position
|
|
x = Math.max(0, (window.innerWidth - 600) / 2); // 600px is approximate dialog width
|
|
y = Math.max(0, (window.innerHeight - 400) / 2); // 400px is approximate dialog height
|
|
}
|
|
|
|
this.window = new NFFloatingWindow(
|
|
`NF Preview - Select Images (${this.images.length})`,
|
|
x,
|
|
y,
|
|
null,
|
|
this.onWindowMove.bind(this),
|
|
this.onWindowResize.bind(this)
|
|
);
|
|
|
|
this.setupWindowContent();
|
|
this.updateWindowTitle();
|
|
|
|
// Apply saved size if available and position memory is enabled
|
|
if (rememberPosition) {
|
|
const savedSize = this.getSavedSize();
|
|
if (savedSize) {
|
|
this.window.style.width = savedSize.width + 'px';
|
|
this.window.style.height = savedSize.height + 'px';
|
|
}
|
|
}
|
|
|
|
// Center the dialog only if no saved position (first time)
|
|
if (!savedPosition) {
|
|
this.centerDialog();
|
|
}
|
|
}
|
|
|
|
setupWindowContent() {
|
|
// Image grid container
|
|
this.imageGrid = document.createElement('div');
|
|
this.imageGrid.className = 'nf-image-grid';
|
|
this.window.body.appendChild(this.imageGrid);
|
|
|
|
// Controls
|
|
this.controls = document.createElement('div');
|
|
this.controls.className = 'nf-controls';
|
|
|
|
// Selection info
|
|
this.selectionInfo = document.createElement('div');
|
|
this.selectionInfo.className = 'nf-selection-info';
|
|
this.updateSelectionInfo();
|
|
|
|
// Countdown
|
|
this.countdown = document.createElement('div');
|
|
this.countdown.className = 'nf-countdown';
|
|
|
|
// Buttons
|
|
this.buttons = document.createElement('div');
|
|
this.buttons.className = 'nf-buttons';
|
|
|
|
this.confirmButton = document.createElement('button');
|
|
this.confirmButton.className = 'nf-button primary';
|
|
this.confirmButton.textContent = 'Confirm Selection';
|
|
this.confirmButton.onclick = this.confirmSelection.bind(this);
|
|
this.updateConfirmButton();
|
|
|
|
this.cancelButton = document.createElement('button');
|
|
this.cancelButton.className = 'nf-button secondary';
|
|
this.cancelButton.textContent = 'Cancel';
|
|
this.cancelButton.onclick = this.cancelSelection.bind(this);
|
|
|
|
// Pin button
|
|
this.pinButton = document.createElement('button');
|
|
this.pinButton.className = 'nf-button pin';
|
|
this.pinButton.innerHTML = '📌';
|
|
this.pinButton.title = 'Pin dialog to keep open during new generations';
|
|
this.pinButton.onclick = this.togglePin.bind(this);
|
|
this.updatePinButton();
|
|
|
|
this.buttons.appendChild(this.confirmButton);
|
|
this.buttons.appendChild(this.cancelButton);
|
|
this.buttons.appendChild(this.pinButton);
|
|
|
|
this.controls.appendChild(this.selectionInfo);
|
|
this.controls.appendChild(this.countdown);
|
|
this.controls.appendChild(this.buttons);
|
|
|
|
this.window.body.appendChild(this.controls);
|
|
}
|
|
|
|
renderImages() {
|
|
this.imageGrid.innerHTML = '';
|
|
|
|
// Counter for loaded images to determine layout
|
|
let loadedImages = 0;
|
|
let landscapeCount = 0;
|
|
let portraitCount = 0;
|
|
|
|
this.images.forEach((imageInfo, index) => {
|
|
const container = document.createElement('div');
|
|
container.className = 'nf-image-container';
|
|
container.dataset.index = index;
|
|
container.onclick = () => this.toggleImage(index);
|
|
|
|
const img = document.createElement('img');
|
|
img.className = 'nf-image';
|
|
img.src = this.getImageUrl(imageInfo);
|
|
img.alt = `Image ${index + 1}`;
|
|
|
|
// Check image aspect ratio when loaded
|
|
img.onload = () => {
|
|
loadedImages++;
|
|
|
|
const aspectRatio = img.naturalWidth / img.naturalHeight;
|
|
if (aspectRatio > 1.1) { // Landscape (wider than tall)
|
|
landscapeCount++;
|
|
} else if (aspectRatio < 0.9) { // Portrait (taller than wide)
|
|
portraitCount++;
|
|
}
|
|
|
|
// When all images are loaded, determine layout
|
|
if (loadedImages === this.images.length) {
|
|
this.applyImageLayout(landscapeCount, portraitCount);
|
|
}
|
|
};
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'nf-image-overlay';
|
|
overlay.textContent = index + 1;
|
|
|
|
container.appendChild(img);
|
|
container.appendChild(overlay);
|
|
this.imageGrid.appendChild(container);
|
|
});
|
|
}
|
|
|
|
applyImageLayout(landscapeCount, portraitCount) {
|
|
// Always use smart grid layout that adapts to window size
|
|
this.imageGrid.className = 'nf-image-grid smart-grid-layout';
|
|
|
|
// Set CSS custom properties for dynamic grid sizing
|
|
const dialogWidth = this.window.offsetWidth - 48; // Account for padding
|
|
const isLandscapeDominant = landscapeCount > portraitCount;
|
|
|
|
if (isLandscapeDominant) {
|
|
// For landscape images, use slightly wider columns but still allow multiple columns
|
|
const minColumnWidth = Math.max(200, Math.min(300, dialogWidth / 2)); // 200-300px, max 2 columns typically
|
|
this.imageGrid.style.setProperty('--min-column-width', `${minColumnWidth}px`);
|
|
// Using landscape image layout
|
|
} else {
|
|
// For portrait/square images, use standard grid sizing
|
|
const minColumnWidth = Math.max(200, Math.min(250, dialogWidth / 3)); // 200-250px, allow up to 3-4 columns
|
|
this.imageGrid.style.setProperty('--min-column-width', `${minColumnWidth}px`);
|
|
// Using portrait/square image layout
|
|
}
|
|
}
|
|
|
|
getImageUrl(imageInfo) {
|
|
return `/view?filename=${encodeURIComponent(imageInfo.filename)}&type=${imageInfo.type}&subfolder=${encodeURIComponent(imageInfo.subfolder || '')}`;
|
|
}
|
|
|
|
toggleImage(index) {
|
|
const container = this.imageGrid.querySelector(`[data-index="${index}"]`);
|
|
|
|
if (this.selectedIndices.has(index)) {
|
|
this.selectedIndices.delete(index);
|
|
container.classList.remove('selected');
|
|
} else {
|
|
this.selectedIndices.add(index);
|
|
container.classList.add('selected');
|
|
}
|
|
|
|
this.updateSelectionInfo();
|
|
this.updateConfirmButton();
|
|
|
|
// Selected indices updated
|
|
}
|
|
|
|
updateSelectionInfo() {
|
|
const count = this.selectedIndices.size;
|
|
const total = this.images.length;
|
|
this.selectionInfo.textContent = `Selected: ${count} of ${total}`;
|
|
}
|
|
|
|
updateConfirmButton() {
|
|
const hasSelection = this.selectedIndices.size > 0;
|
|
this.confirmButton.disabled = !hasSelection;
|
|
this.confirmButton.textContent = hasSelection
|
|
? `Confirm (${this.selectedIndices.size} selected)`
|
|
: 'Select at least one image';
|
|
}
|
|
|
|
startCountdown() {
|
|
this.updateCountdown();
|
|
this.countdownInterval = setInterval(() => {
|
|
this.remainingTime--;
|
|
this.updateCountdown();
|
|
|
|
if (this.remainingTime <= 0) {
|
|
// Timeout reached
|
|
this.timeoutSelection();
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
updateCountdown() {
|
|
const minutes = Math.floor(this.remainingTime / 60);
|
|
const seconds = this.remainingTime % 60;
|
|
this.countdown.textContent = `Time remaining: ${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
stopCountdown() {
|
|
if (this.countdownInterval) {
|
|
clearInterval(this.countdownInterval);
|
|
this.countdownInterval = null;
|
|
}
|
|
}
|
|
|
|
confirmSelection() {
|
|
this.stopCountdown();
|
|
|
|
const selection = Array.from(this.selectedIndices).sort((a, b) => a - b);
|
|
// Confirming selection
|
|
|
|
this.sendSelection(selection, false);
|
|
|
|
// If pinned, don't hide dialog - just reset for next use
|
|
if (this.isPinned) {
|
|
// Send additional cancel response to unblock backend for next generation
|
|
setTimeout(() => {
|
|
this.sendSelection([], true);
|
|
}, 100);
|
|
this.prepareForNextRequest();
|
|
} else {
|
|
this.hide();
|
|
}
|
|
}
|
|
|
|
cancelSelection() {
|
|
this.stopCountdown();
|
|
|
|
// Cancelling selection
|
|
this.sendSelection([], true);
|
|
|
|
// If pinned, don't hide dialog - just reset for next use
|
|
if (this.isPinned) {
|
|
// Send additional cancel response to unblock backend for next generation
|
|
setTimeout(() => {
|
|
this.sendSelection([], true);
|
|
}, 100);
|
|
this.prepareForNextRequest();
|
|
} else {
|
|
this.hide();
|
|
}
|
|
}
|
|
|
|
prepareForNextRequest() {
|
|
// Reset dialog state for next request while keeping it open
|
|
this.selectedIndices.clear();
|
|
this.updateImageSelection();
|
|
this.updateSelectionInfo();
|
|
this.updateConfirmButton();
|
|
this.stopCountdown();
|
|
|
|
// Clear images and show waiting message
|
|
this.images = [];
|
|
this.reviewId = null;
|
|
this.uniqueId = null;
|
|
|
|
// Show waiting message
|
|
this.showWaitingMessage();
|
|
}
|
|
|
|
showWaitingMessage() {
|
|
if (this.imageGrid) {
|
|
this.imageGrid.innerHTML = '<div class="nf-waiting-message">Waiting for next generation...</div>';
|
|
}
|
|
if (this.countdown) {
|
|
this.countdown.textContent = 'Pinned - waiting for next request';
|
|
}
|
|
}
|
|
|
|
timeoutSelection() {
|
|
this.stopCountdown();
|
|
|
|
// On timeout, select all images or none based on setting
|
|
const selection = []; // Default to empty selection on timeout
|
|
// Timeout - returning empty selection
|
|
|
|
this.sendSelection(selection, false);
|
|
|
|
// If pinned, don't hide dialog - just reset for next use
|
|
if (this.isPinned) {
|
|
// Send additional cancel response to unblock backend for next generation
|
|
setTimeout(() => {
|
|
this.sendSelection([], true);
|
|
}, 100);
|
|
this.prepareForNextRequest();
|
|
} else {
|
|
this.hide();
|
|
}
|
|
}
|
|
|
|
sendSelection(indices, cancelled) {
|
|
const response = {
|
|
review_id: this.reviewId,
|
|
unique_id: this.uniqueId,
|
|
selection: indices,
|
|
cancelled: cancelled
|
|
};
|
|
|
|
// Send response to backend
|
|
fetch('/nf_preview_response', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(response)
|
|
}).catch(error => {
|
|
console.error("NF Preview Dialog: Error sending response:", error);
|
|
});
|
|
}
|
|
|
|
hide() {
|
|
this.stopCountdown();
|
|
|
|
if (this.window) {
|
|
this.window.destroy();
|
|
this.window = null;
|
|
}
|
|
|
|
this.selectedIndices.clear();
|
|
}
|
|
|
|
centerDialog() {
|
|
if (!this.window) return;
|
|
|
|
// Wait for the DOM to update and get actual dimensions
|
|
setTimeout(() => {
|
|
const dialogRect = this.window.getBoundingClientRect();
|
|
const centerX = Math.max(0, (window.innerWidth - dialogRect.width) / 2);
|
|
const centerY = Math.max(0, (window.innerHeight - dialogRect.height) / 2);
|
|
|
|
this.window.moveTo(centerX, centerY);
|
|
}, 10);
|
|
}
|
|
|
|
getSavedPosition() {
|
|
try {
|
|
const saved = localStorage.getItem('nf-preview-dialog-position');
|
|
if (saved) {
|
|
const position = JSON.parse(saved);
|
|
// Validate position is within screen bounds
|
|
if (position.x >= 0 && position.y >= 0 &&
|
|
position.x < window.innerWidth && position.y < window.innerHeight) {
|
|
return position;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn("NF Preview Dialog: Error loading saved position:", e);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
savePosition(x, y) {
|
|
try {
|
|
localStorage.setItem('nf-preview-dialog-position', JSON.stringify({ x, y }));
|
|
} catch (e) {
|
|
console.warn("NF Preview Dialog: Error saving position:", e);
|
|
}
|
|
}
|
|
|
|
getSavedSize() {
|
|
try {
|
|
const saved = localStorage.getItem('nf-preview-dialog-size');
|
|
if (saved) {
|
|
const size = JSON.parse(saved);
|
|
// Validate size is reasonable
|
|
if (size.width > 200 && size.height > 150 &&
|
|
size.width < window.innerWidth + 100 && size.height < window.innerHeight + 100) {
|
|
return size;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn("NF Preview Dialog: Error loading saved size:", e);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
saveSize(width, height) {
|
|
try {
|
|
localStorage.setItem('nf-preview-dialog-size', JSON.stringify({ width, height }));
|
|
} catch (e) {
|
|
console.warn("NF Preview Dialog: Error saving size:", e);
|
|
}
|
|
}
|
|
|
|
onWindowMove(x, y) {
|
|
// Save position when user moves the dialog (only if setting is enabled)
|
|
const rememberPosition = app.ui.settings.getSettingValue("NFPreviewSelector.UI.WindowPosition", true);
|
|
if (rememberPosition) {
|
|
this.savePosition(x, y);
|
|
}
|
|
// Window moved
|
|
}
|
|
|
|
onWindowResize(width, height) {
|
|
// Save size when user resizes the dialog (only if setting is enabled)
|
|
const rememberPosition = app.ui.settings.getSettingValue("NFPreviewSelector.UI.WindowPosition", true);
|
|
if (rememberPosition) {
|
|
this.saveSize(width, height);
|
|
}
|
|
|
|
// Recalculate grid layout for new window size
|
|
if (this.imageGrid && this.imageGrid.children.length > 0) {
|
|
// Get current aspect ratio distribution
|
|
let landscapeCount = 0;
|
|
let portraitCount = 0;
|
|
|
|
for (let container of this.imageGrid.children) {
|
|
const img = container.querySelector('img');
|
|
if (img && img.naturalWidth && img.naturalHeight) {
|
|
const aspectRatio = img.naturalWidth / img.naturalHeight;
|
|
if (aspectRatio > 1.1) {
|
|
landscapeCount++;
|
|
} else if (aspectRatio < 0.9) {
|
|
portraitCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
this.applyImageLayout(landscapeCount, portraitCount);
|
|
}
|
|
|
|
// Window resized
|
|
}
|
|
|
|
togglePin() {
|
|
this.isPinned = !this.isPinned;
|
|
this.updatePinButton();
|
|
this.updateWindowTitle();
|
|
}
|
|
|
|
updatePinButton() {
|
|
if (this.pinButton) {
|
|
this.pinButton.classList.toggle('active', this.isPinned);
|
|
this.pinButton.title = this.isPinned ?
|
|
'Unpin dialog (will close on new generations)' :
|
|
'Pin dialog to keep open during new generations';
|
|
}
|
|
}
|
|
|
|
updateWindowTitle() {
|
|
if (this.window && this.window.titleElement) {
|
|
const baseTitle = 'NF Preview Selector';
|
|
this.window.titleElement.textContent = this.isPinned ? `${baseTitle} (Pinned)` : baseTitle;
|
|
}
|
|
}
|
|
|
|
updateWithNewData(data) {
|
|
// Stop current countdown
|
|
this.stopCountdown();
|
|
|
|
// Update data
|
|
this.reviewId = data.review_id;
|
|
this.uniqueId = data.unique_id;
|
|
this.images = data.images || [];
|
|
this.timeout = data.timeout || 60;
|
|
this.remainingTime = this.timeout;
|
|
this.selectedIndices.clear();
|
|
|
|
// Re-render images and restart countdown
|
|
this.renderImages();
|
|
this.updateSelectionInfo();
|
|
this.updateConfirmButton();
|
|
this.startCountdown();
|
|
}
|
|
}
|
|
|
|
// Global instance
|
|
export const nfPreviewDialog = new NFPreviewDialog(); |