Improve preview dialog layout and update to v1.2.0

Enhanced the image grid layout in the preview dialog for better ComfyUI-style appearance and responsive sizing.
This commit is contained in:
NyaFu
2025-07-18 22:56:18 +09:00
parent 6a8fd596d0
commit dd1b240470
7 changed files with 133 additions and 127 deletions
+1 -1
View File
@@ -8,4 +8,4 @@ from .nf_preview_selector import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
WEB_DIRECTORY = "./js"
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY']
pass # NF Preview Selector loaded
pass
+27
View File
@@ -72,12 +72,32 @@
align-items: start;
}
/* ComfyUI-style layout with optimized calculations */
.nf-image-grid.comfyui-layout {
display: grid;
grid-template-columns: repeat(var(--max-columns, auto-fit), var(--min-column-width, 150px));
grid-template-rows: repeat(var(--max-rows, auto-fit), var(--cell-height, auto));
gap: 8px;
align-items: center;
justify-content: center;
align-content: start;
}
.nf-image-container {
position: relative;
border: 2px solid transparent;
border-radius: 4px;
cursor: pointer;
transition: border-color 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
/* ComfyUI-style image containers with fixed dimensions */
.nf-image-grid.comfyui-layout .nf-image-container {
width: var(--min-column-width, 150px);
height: var(--cell-height, 150px);
}
/* Responsive image containers */
@@ -102,6 +122,13 @@
border-radius: 2px;
}
/* ComfyUI-style images with fixed dimensions */
.nf-image-grid.comfyui-layout .nf-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.nf-image-overlay {
position: absolute;
top: 4px;
-6
View File
@@ -10,7 +10,6 @@ export class NFFloatingWindow extends HTMLDivElement {
this.moveCallback = moveCallback;
this.resizeCallback = resizeCallback;
// Setup window structure
this.classList.add('nf-float');
// Header
@@ -24,7 +23,6 @@ export class NFFloatingWindow extends HTMLDivElement {
this.body.classList.add('nf-float-body');
this.appendChild(this.body);
// Setup dragging
this.dragging = false;
this.position = { x: x, y: y };
@@ -33,17 +31,14 @@ export class NFFloatingWindow extends HTMLDivElement {
document.addEventListener('mousemove', this.drag.bind(this));
document.addEventListener('mouseleave', this.stopDrag.bind(this));
// Position window
this.moveTo(x, y);
// Add to parent or body
if (parent) {
parent.appendChild(this);
} else {
document.body.appendChild(this);
}
// Setup resize observer for size changes
this.setupResizeObserver();
}
@@ -110,5 +105,4 @@ export class NFFloatingWindow extends HTMLDivElement {
}
}
// Register custom element
customElements.define('nf-floating-window', NFFloatingWindow, { extends: 'div' });
-18
View File
@@ -8,28 +8,20 @@ import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { nfPreviewDialog } from './preview_dialog.js';
// NF Preview Selector loading
app.registerExtension({
name: "nf.preview_selector",
async setup() {
// Setting up extension
// Register message handler using ComfyUI's system
if (api.addEventListener) {
// Register for our custom event type
api.addEventListener("nf_preview_request", (event) => {
// Received review request
nfPreviewDialog.show(event.detail);
});
// Event listener registered
}
// Event system is working correctly
// Add settings
app.ui.settings.addSetting({
id: "NFPreviewSelector.UI.WindowPosition",
name: "Remember window position and size",
@@ -46,41 +38,31 @@ app.registerExtension({
tooltip: "Maximum width of the preview dialog in pixels (400-1800)"
});
// Auto-select first image setting removed - this is available in node UI
// Setup complete
},
async nodeCreated(node) {
if (node.comfyClass === "NFPreviewSelector") {
// Node created
// Set node title
node.title = "NF Preview Selector";
// Add any node-specific setup here
node.onResize = function() {
// Handle node resize if needed
};
}
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "NFPreviewSelector") {
// Registering node definition
// Add any node definition modifications here
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
if (onNodeCreated) {
onNodeCreated.apply(this, arguments);
}
// Node-specific initialization
this.setProperty("review_position", { x: 100, y: 100 });
};
}
}
});
// Extension loaded successfully
+90 -87
View File
@@ -22,7 +22,6 @@ export class NFPreviewDialog {
// Load CSS
this.loadCSS();
// Apply max width setting
this.applyMaxWidthSetting();
// Setup Queue Prompt keybinding integration
@@ -37,14 +36,11 @@ export class NFPreviewDialog {
}
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 = `
@@ -53,7 +49,6 @@ export class NFPreviewDialog {
}
`;
// Remove existing style if it exists
const existingStyle = document.getElementById('nf-preview-max-width-style');
if (existingStyle) {
existingStyle.remove();
@@ -65,29 +60,19 @@ export class NFPreviewDialog {
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();
}
}
@@ -95,15 +80,10 @@ export class NFPreviewDialog {
}
stopDialogForQueue() {
// Stop countdown immediately
this.stopCountdown();
// Stopping dialog processing for queue
// Send cancellation response to backend (empty selection, cancelled=true)
this.sendSelection([], true);
// If dialog is pinned, don't hide it - just reset for next use
if (this.isPinned) {
this.prepareForNextRequest();
} else {
@@ -116,24 +96,18 @@ export class NFPreviewDialog {
}
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 || [];
@@ -153,19 +127,16 @@ export class NFPreviewDialog {
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
x = Math.max(0, (window.innerWidth - 600) / 2);
y = Math.max(0, (window.innerHeight - 400) / 2);
}
this.window = new NFFloatingWindow(
@@ -180,7 +151,6 @@ export class NFPreviewDialog {
this.setupWindowContent();
this.updateWindowTitle();
// Apply saved size if available and position memory is enabled
if (rememberPosition) {
const savedSize = this.getSavedSize();
if (savedSize) {
@@ -189,7 +159,6 @@ export class NFPreviewDialog {
}
}
// Center the dialog only if no saved position (first time)
if (!savedPosition) {
this.centerDialog();
}
@@ -205,12 +174,10 @@ export class NFPreviewDialog {
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';
@@ -229,7 +196,6 @@ export class NFPreviewDialog {
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 = '📌';
@@ -251,7 +217,6 @@ export class NFPreviewDialog {
renderImages() {
this.imageGrid.innerHTML = '';
// Counter for loaded images to determine layout
let loadedImages = 0;
let landscapeCount = 0;
let portraitCount = 0;
@@ -267,18 +232,16 @@ export class NFPreviewDialog {
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)
if (aspectRatio > 1.1) {
landscapeCount++;
} else if (aspectRatio < 0.9) { // Portrait (taller than wide)
} else if (aspectRatio < 0.9) {
portraitCount++;
}
// When all images are loaded, determine layout
if (loadedImages === this.images.length) {
this.applyImageLayout(landscapeCount, portraitCount);
}
@@ -295,24 +258,94 @@ export class NFPreviewDialog {
}
applyImageLayout(landscapeCount, portraitCount) {
// Always use smart grid layout that adapts to window size
this.imageGrid.className = 'nf-image-grid smart-grid-layout';
this.imageGrid.className = 'nf-image-grid comfyui-layout';
// Set CSS custom properties for dynamic grid sizing
const dialogWidth = this.window.offsetWidth - 48; // Account for padding
const dialogWidth = this.window.offsetWidth - 48;
const dialogHeight = this.window.offsetHeight - 140;
const layoutResult = this.calculateComfyUILayout(this.images, dialogWidth, dialogHeight);
this.imageGrid.style.setProperty('--min-column-width', `${layoutResult.cellWidth}px`);
this.imageGrid.style.setProperty('--cell-height', `${layoutResult.cellHeight}px`);
this.imageGrid.style.setProperty('--max-columns', layoutResult.cols);
this.imageGrid.style.setProperty('--max-rows', layoutResult.rows);
}
calculateComfyUILayout(images, availableWidth, availableHeight) {
if (!images || images.length === 0) {
return { cellWidth: 200, cellHeight: 200, cols: 1, rows: 1 };
}
let imageWidth = 512;
let imageHeight = 512;
const firstImageContainer = this.imageGrid.querySelector('.nf-image-container img');
if (firstImageContainer && firstImageContainer.naturalWidth > 0) {
imageWidth = firstImageContainer.naturalWidth;
imageHeight = firstImageContainer.naturalHeight;
}
const numImages = images.length;
let bestArea = 0;
let bestResult = { cellWidth: 200, cellHeight: 200, cols: 1, rows: 1 };
for (let cols = 1; cols <= numImages; cols++) {
const rows = Math.ceil(numImages / cols);
const cellWidth_available = availableWidth / cols;
const cellHeight_available = availableHeight / rows;
const scaleX = cellWidth_available / imageWidth;
const scaleY = cellHeight_available / imageHeight;
const scale = Math.min(scaleX, scaleY, 1);
const cellWidth = imageWidth * scale;
const cellHeight = imageHeight * scale;
const totalDisplayArea = cellWidth * cellHeight * numImages;
if (totalDisplayArea > bestArea) {
bestArea = totalDisplayArea;
bestResult = {
cellWidth: Math.round(cellWidth),
cellHeight: Math.round(cellHeight),
cols,
rows
};
}
}
return bestResult;
}
calculateResponsiveColumnWidth(landscapeCount, portraitCount) {
const dialogWidth = this.window.offsetWidth - 48;
const isLandscapeDominant = landscapeCount > portraitCount;
if (isLandscapeDominant) {
// For landscape images, use slightly wider columns but still allow multiple columns
const minColumnWidth = Math.max(250, Math.min(350, 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(120, 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
}
const devicePixelRatio = window.devicePixelRatio || 1;
const dpiMultiplier = Math.min(Math.max(devicePixelRatio, 1), 2);
const baseRatio = isLandscapeDominant ? 0.45 : 0.30; // 45% for landscape, 30% for portrait
const maxColumns = isLandscapeDominant ? 2 : 4;
const baseColumnWidth = dialogWidth * baseRatio;
const dpiAwareColumnWidth = baseColumnWidth * dpiMultiplier;
const minWidthForColumns = dialogWidth / maxColumns;
const absoluteMin = isLandscapeDominant ? 200 : 120;
const absoluteMax = isLandscapeDominant ? 600 : 400;
const finalColumnWidth = Math.max(
absoluteMin,
Math.min(
absoluteMax,
Math.max(minWidthForColumns, dpiAwareColumnWidth)
)
);
return Math.round(finalColumnWidth);
}
getImageUrl(imageInfo) {
@@ -333,7 +366,6 @@ export class NFPreviewDialog {
this.updateSelectionInfo();
this.updateConfirmButton();
// Selected indices updated
}
updateSelectionInfo() {
@@ -357,7 +389,6 @@ export class NFPreviewDialog {
this.updateCountdown();
if (this.remainingTime <= 0) {
// Timeout reached
this.timeoutSelection();
}
}, 1000);
@@ -380,11 +411,9 @@ export class NFPreviewDialog {
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) {
this.prepareForNextRequest();
} else {
@@ -395,10 +424,8 @@ export class NFPreviewDialog {
cancelSelection() {
this.stopCountdown();
// Cancelling selection
this.sendSelection([], true);
// If pinned, don't hide dialog - just reset for next use
if (this.isPinned) {
this.prepareForNextRequest();
} else {
@@ -407,19 +434,16 @@ export class NFPreviewDialog {
}
prepareForNextRequest() {
// Reset dialog state for next request while keeping it open
this.selectedIndices.clear();
this.clearImageSelection();
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();
}
@@ -435,13 +459,10 @@ export class NFPreviewDialog {
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
const selection = [];
this.sendSelection(selection, false);
// If pinned, don't hide dialog - just reset for next use
if (this.isPinned) {
this.prepareForNextRequest();
} else {
@@ -457,7 +478,6 @@ export class NFPreviewDialog {
cancelled: cancelled
};
// Send response to backend
fetch('/nf_preview_response', {
method: 'POST',
headers: {
@@ -465,7 +485,6 @@ export class NFPreviewDialog {
},
body: JSON.stringify(response)
}).catch(error => {
console.error("NF Preview Dialog: Error sending response:", error);
});
}
@@ -483,7 +502,6 @@ export class NFPreviewDialog {
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);
@@ -498,14 +516,12 @@ export class NFPreviewDialog {
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;
}
@@ -514,7 +530,6 @@ export class NFPreviewDialog {
try {
localStorage.setItem('nf-preview-dialog-position', JSON.stringify({ x, y }));
} catch (e) {
console.warn("NF Preview Dialog: Error saving position:", e);
}
}
@@ -523,14 +538,12 @@ export class NFPreviewDialog {
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;
}
@@ -539,29 +552,23 @@ export class NFPreviewDialog {
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;
@@ -580,7 +587,6 @@ export class NFPreviewDialog {
this.applyImageLayout(landscapeCount, portraitCount);
}
// Window resized
}
togglePin() {
@@ -606,10 +612,8 @@ export class NFPreviewDialog {
}
updateWithNewData(data) {
// Stop current countdown
this.stopCountdown();
// Update data
this.reviewId = data.review_id;
this.uniqueId = data.unique_id;
this.images = data.images || [];
@@ -617,7 +621,6 @@ export class NFPreviewDialog {
this.remainingTime = this.timeout;
this.selectedIndices.clear();
// Re-render images and restart countdown
this.renderImages();
this.updateSelectionInfo();
this.updateConfirmButton();
+14 -14
View File
@@ -26,7 +26,7 @@ async def handle_review_response(request):
selection = data.get('selection', [])
cancelled = data.get('cancelled', False)
pass # Response received
pass
# Store response
review_responses[review_id] = {
@@ -36,7 +36,7 @@ async def handle_review_response(request):
return web.json_response({'status': 'success'})
except Exception as e:
pass # Error handled
pass
return web.json_response({'status': 'error', 'message': str(e)}, status=500)
# Register API endpoint
@@ -78,7 +78,7 @@ class NFPreviewSelector(PreviewImage):
def preview_images(self, images, mode, timeout, unique_id=None, latents=None, selection_indices="", prompt=None, extra_pnginfo=None):
"""Main function for image preview and selection"""
pass # Processing images
pass
# No flags needed anymore
@@ -115,9 +115,9 @@ class NFPreviewSelector(PreviewImage):
# Use ComfyUI's message dispatch system
try:
PromptServer.instance.send_sync("nf_preview_request", message_data)
pass # Request sent
pass
except Exception as e:
pass # Fallback to direct WebSocket
pass
# Fallback to direct WebSocket
message = {
"type": "nf_preview_request",
@@ -129,20 +129,20 @@ class NFPreviewSelector(PreviewImage):
try:
await ws.send_str(json.dumps(message))
except Exception as e:
pass # Send failed
pass
if PromptServer.instance.loop:
asyncio.run_coroutine_threadsafe(send_message(), PromptServer.instance.loop)
pass # Request sent
pass
# Wait for response from frontend
result = self.wait_for_response(review_id, timeout)
if result == "CANCELLED":
pass # Cancelled
pass
raise InterruptProcessingException()
elif result == "TIMEOUT":
pass # Timed out
pass
raise InterruptProcessingException()
else:
selected_indices = result
@@ -154,7 +154,7 @@ class NFPreviewSelector(PreviewImage):
# Process selection
if not selected_indices:
# For timeout or other cases, return empty tensors
pass # No selection
pass
empty_image = torch.zeros((1, images.shape[1], images.shape[2], images.shape[3]))
empty_latent = {"samples": torch.zeros((1, 4, 64, 64))} if latents is None else {"samples": torch.zeros((1, latents["samples"].shape[1], latents["samples"].shape[2], latents["samples"].shape[3]))}
return (empty_image, empty_latent, "")
@@ -172,7 +172,7 @@ class NFPreviewSelector(PreviewImage):
indices_str = ",".join(str(i) for i in selected_indices)
pass # Selection made
pass
return {
"result": (selected_images, selected_latents, indices_str),
@@ -190,16 +190,16 @@ class NFPreviewSelector(PreviewImage):
response = review_responses.pop(review_id)
if response['cancelled']:
pass # Review cancelled
pass
return "CANCELLED"
else:
selection = response['selection']
pass # Review completed
pass
return selection
time.sleep(0.1) # Poll every 100ms
pass # Review timed out
pass
return "TIMEOUT" # Indicate timeout occurred
# Node registration
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "Preview_Selector"
description = "A floating dialog-based image preview and selection system for ComfyUI."
version = "1.1.1"
version = "1.2.0"
license = {file = "LICENSE"}