559 lines
23 KiB
JavaScript
559 lines
23 KiB
JavaScript
// ComfyUI.CRZ.ImageSelector - Custom Thumbnails
|
||
import { app } from "../../scripts/app.js";
|
||
import {
|
||
IMAGE_LOADER_WIDTH,
|
||
IMAGE_LOADER_HEIGHT,
|
||
THUMBNAIL_SIZE,
|
||
THUMBNAIL_SPACING,
|
||
THUMBNAIL_START_X,
|
||
THUMBNAIL_START_Y,
|
||
UPLOAD_BUTTON_HEIGHT,
|
||
BUTTON_SPACING,
|
||
ROW_SPACING,
|
||
THUMBNAIL_PLACEHOLDER_BG,
|
||
THUMBNAIL_PLACEHOLDER_TEXT,
|
||
THUMBNAIL_BG,
|
||
THUMBNAIL_BORDER,
|
||
THUMBNAIL_GROUP_BG,
|
||
THUMBNAIL_GROUP_BORDER,
|
||
UPLOAD_BUTTON_BG,
|
||
UPLOAD_BUTTON_BORDER,
|
||
UPLOAD_BUTTON_TEXT,
|
||
IMAGE_LABEL_TEXT,
|
||
CLEAR_BUTTON_BG,
|
||
CLEAR_BUTTON_BORDER,
|
||
CLEAR_BUTTON_TEXT,
|
||
CLEAR_BUTTON_FONT_SIZE,
|
||
CLEAR_BUTTON_WIDTH_RATIO
|
||
} from "./CRZConfig.js";
|
||
|
||
app.registerExtension({
|
||
name: "CRZ.ImageSelector",
|
||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||
if (nodeData.name === "CRZImageSelector") {
|
||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||
const onNodeRemoved = nodeType.prototype.onRemoved;
|
||
nodeType.prototype.onNodeCreated = function () {
|
||
if (onNodeCreated) {
|
||
onNodeCreated.apply(this, arguments);
|
||
}
|
||
|
||
// Set size for 2 rows of 3 images
|
||
this.size = [IMAGE_LOADER_WIDTH, IMAGE_LOADER_HEIGHT];
|
||
|
||
// Enable resizing
|
||
this.resizable = true;
|
||
|
||
// Set background color
|
||
this.bgcolor = "#353535";
|
||
|
||
// Initialize thumbnail storage and properties
|
||
this.thumbnails = {};
|
||
this.hoveredImageIndex = -1; // Track which image area is being hovered
|
||
this.properties = this.properties || {};
|
||
this.properties.image_1 = "";
|
||
this.properties.image_2 = "";
|
||
this.properties.image_3 = "";
|
||
this.properties.image_4 = "";
|
||
this.properties.image_5 = "";
|
||
this.properties.image_6 = "";
|
||
|
||
// Move widgets off-screen
|
||
this.widgets_start_y = -2.4e8*LiteGraph.NODE_SLOT_HEIGHT;
|
||
|
||
// Hide all widgets completely
|
||
if (this.widgets) {
|
||
this.widgets.forEach(widget => {
|
||
widget.type = "hidden";
|
||
});
|
||
}
|
||
|
||
// Hide all input sockets
|
||
if (this.inputs) {
|
||
this.inputs.forEach(input => {
|
||
input.type = -1;
|
||
input.name = "";
|
||
});
|
||
}
|
||
|
||
// Clear all output socket labels
|
||
if (this.outputs) {
|
||
this.outputs.forEach(output => {
|
||
output.name = output.localized_name = "";
|
||
});
|
||
}
|
||
|
||
// Load existing thumbnails
|
||
setTimeout(() => {
|
||
this.loadAllThumbnails();
|
||
}, 100);
|
||
|
||
// DOM capture handlers removed - using onMouseDown instead to avoid double file dialogs
|
||
};
|
||
|
||
nodeType.prototype.onRemoved = function () {
|
||
if (onNodeRemoved) onNodeRemoved.apply(this, arguments);
|
||
};
|
||
|
||
// Function to load all thumbnails from properties
|
||
nodeType.prototype.loadAllThumbnails = function() {
|
||
this.loadThumbnail('image_1', this.properties.image_1);
|
||
this.loadThumbnail('image_2', this.properties.image_2);
|
||
this.loadThumbnail('image_3', this.properties.image_3);
|
||
this.loadThumbnail('image_4', this.properties.image_4);
|
||
this.loadThumbnail('image_5', this.properties.image_5);
|
||
this.loadThumbnail('image_6', this.properties.image_6);
|
||
};
|
||
|
||
// Helpers to compute rects on-demand (avoid relying on draw-time caches)
|
||
nodeType.prototype._getThumbRect = function(index) {
|
||
const col = index % 3;
|
||
const row = Math.floor(index / 3);
|
||
|
||
// Leave padding for resize handles and output sockets
|
||
const padding = 4;
|
||
const rightPadding = 20; // Extra space for output sockets
|
||
const availableWidth = this.size[0] - padding - rightPadding;
|
||
const availableHeight = this.size[1] - (padding * 2);
|
||
|
||
// Calculate cell size to fill the available space
|
||
const cellWidth = availableWidth / 3; // 3 columns
|
||
const cellHeight = availableHeight / 2; // 2 rows
|
||
|
||
// Each cell fills exactly 1/6th of the available space
|
||
const x = padding + (col * cellWidth);
|
||
const y = padding + (row * cellHeight);
|
||
|
||
return { x, y, width: cellWidth, height: cellHeight };
|
||
};
|
||
|
||
// Ensure clicks inside our interactive region are NOT treated as title drags
|
||
const __originalIsPointOverTitle = nodeType.prototype.isPointOverTitle;
|
||
nodeType.prototype.isPointOverTitle = function(x, y, graphcanvas) {
|
||
// Convert to local
|
||
const localX = x - this.pos[0];
|
||
const localY = y - this.pos[1];
|
||
// If inside any thumbnail rect (including label/clear button area), it's not title
|
||
for (let i = 0; i < 6; i++) {
|
||
const r = this._getThumbRect(i);
|
||
if (localX >= r.x && localX <= r.x + r.width && localY >= r.y && localY <= r.y + r.height) {
|
||
return false;
|
||
}
|
||
}
|
||
// Fallback to original behavior
|
||
if (typeof __originalIsPointOverTitle === "function") {
|
||
return __originalIsPointOverTitle.apply(this, arguments);
|
||
}
|
||
// Conservative default
|
||
return localY < (LiteGraph.NODE_TITLE_HEIGHT || 20);
|
||
};
|
||
|
||
|
||
|
||
// Handle mouse clicks for image areas and clear buttons
|
||
nodeType.prototype.onMouseDown = function(event, pos, graphcanvas) {
|
||
if (this.flags.collapsed) return false;
|
||
|
||
// Check if we're clicking on a resize handle first
|
||
if (this.isResizing) {
|
||
return false; // Let LiteGraph handle resize
|
||
}
|
||
|
||
const lp = this._toLocal(event, pos, graphcanvas);
|
||
const lx = lp[0], ly = lp[1];
|
||
|
||
const widgetNames = ['image_1', 'image_2', 'image_3', 'image_4', 'image_5', 'image_6'];
|
||
for (let i = 0; i < widgetNames.length; i++) {
|
||
const widgetName = widgetNames[i];
|
||
const thumbRect = this._getThumbRect(i);
|
||
|
||
// If inside thumbnail area
|
||
if (lx >= thumbRect.x && lx <= thumbRect.x + thumbRect.width &&
|
||
ly >= thumbRect.y && ly <= thumbRect.y + thumbRect.height) {
|
||
|
||
// If there is an image, check clear button first
|
||
if (this.thumbnails && this.thumbnails[widgetName]) {
|
||
const clearRect = this._getClearButtonRect(i);
|
||
if (lx >= clearRect.x && lx <= clearRect.x + clearRect.width &&
|
||
ly >= clearRect.y && ly <= clearRect.y + clearRect.height) {
|
||
this.clearImageSlot(widgetName, i + 1);
|
||
if (graphcanvas) graphcanvas._mouse_down_widget = true;
|
||
if (event && event.preventDefault) { try { event.preventDefault(); event.stopPropagation(); } catch(_){} }
|
||
return true; // consume
|
||
}
|
||
}
|
||
|
||
// Open file dialog for (re)upload
|
||
this.openFileDialog(widgetName, i + 1);
|
||
if (event && event.preventDefault) event.preventDefault();
|
||
if (event && event.stopPropagation) event.stopPropagation();
|
||
if (graphcanvas) graphcanvas._mouse_down_widget = true;
|
||
return true; // consume so the node doesn't start dragging
|
||
}
|
||
}
|
||
|
||
// Not a click in our interactive areas: let default behavior happen
|
||
return false;
|
||
};
|
||
|
||
nodeType.prototype._getClearButtonRect = function(index) {
|
||
const rect = this._getThumbRect(index);
|
||
|
||
const clearButtonSize = Math.min(12, Math.min(rect.width, rect.height) * 0.2); // Scale with smaller dimension
|
||
const x = rect.x + rect.width - clearButtonSize - 2;
|
||
const y = rect.y + 2;
|
||
return { x, y, width: clearButtonSize, height: clearButtonSize };
|
||
};
|
||
|
||
// Helper: convert event/canvas coords to node-local coords
|
||
nodeType.prototype._toLocal = function(e, pos, gc) {
|
||
if (e && typeof e.canvasX === "number" && typeof e.canvasY === "number") {
|
||
return [e.canvasX - this.pos[0], e.canvasY - this.pos[1]];
|
||
}
|
||
try {
|
||
if (gc && typeof gc.convertEventToCanvasOffset === "function") {
|
||
const off = gc.convertEventToCanvasOffset(e);
|
||
return [off[0] - this.pos[0], off[1] - this.pos[1]];
|
||
}
|
||
} catch(_) {}
|
||
if (Array.isArray(pos) && pos.length >= 2) return [pos[0], pos[1]];
|
||
return [0, 0];
|
||
};
|
||
|
||
// Function to load individual thumbnail
|
||
nodeType.prototype.loadThumbnail = function(widgetName, filename) {
|
||
if (!filename || filename === "") {
|
||
this.thumbnails[widgetName] = null;
|
||
this.setDirtyCanvas(true);
|
||
return;
|
||
}
|
||
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
// Store the full resolution image directly - no pre-scaling!
|
||
// This gives maximum quality when we scale it down during drawing
|
||
this.thumbnails[widgetName] = img;
|
||
this.setDirtyCanvas(true);
|
||
};
|
||
|
||
img.onerror = () => {
|
||
this.thumbnails[widgetName] = null;
|
||
this.setDirtyCanvas(true);
|
||
};
|
||
|
||
// Load from ComfyUI input directory
|
||
img.src = `/view?filename=${encodeURIComponent(filename)}&type=input`;
|
||
};
|
||
|
||
|
||
|
||
// Override onDrawForeground to draw custom thumbnails with clickable image areas
|
||
nodeType.prototype.onDrawForeground = function(ctx) {
|
||
if (this.flags.collapsed) return;
|
||
|
||
// Draw thumbnails in 2 rows of 3
|
||
const widgetNames = ['image_1', 'image_2', 'image_3', 'image_4', 'image_5', 'image_6'];
|
||
|
||
if (!this.imageAreaBounds) this.imageAreaBounds = [];
|
||
if (!this.clearButtonBounds) this.clearButtonBounds = [];
|
||
|
||
widgetNames.forEach((widgetName, index) => {
|
||
const col = index % 3; // 0, 1, 2
|
||
const row = Math.floor(index / 3); // 0 or 1
|
||
|
||
const rect = this._getThumbRect(index);
|
||
const x = rect.x;
|
||
const y = rect.y;
|
||
const thumbSize = rect.width;
|
||
|
||
// Draw thumbnail or clickable upload placeholder
|
||
if (this.thumbnails[widgetName]) {
|
||
const img = this.thumbnails[widgetName];
|
||
|
||
// Enable highest quality scaling for full-resolution images
|
||
ctx.imageSmoothingEnabled = true;
|
||
ctx.imageSmoothingQuality = 'high';
|
||
|
||
// Draw background first
|
||
ctx.fillStyle = THUMBNAIL_BG;
|
||
ctx.beginPath();
|
||
ctx.roundRect(x, y, thumbSize, rect.height, 8);
|
||
ctx.fill();
|
||
|
||
// Calculate aspect ratio to fit entire image in cell while maintaining aspect ratio
|
||
const aspectRatio = img.width / img.height;
|
||
const cellAspectRatio = rect.width / rect.height;
|
||
let drawWidth, drawHeight, offsetX = 0, offsetY = 0;
|
||
|
||
if (aspectRatio > cellAspectRatio) {
|
||
// Image is wider than cell - fit to cell width, scale height down
|
||
drawWidth = rect.width;
|
||
drawHeight = rect.width / aspectRatio;
|
||
offsetY = (rect.height - drawHeight) / 2;
|
||
} else {
|
||
// Image is taller than cell - fit to cell height, scale width down
|
||
drawHeight = rect.height;
|
||
drawWidth = rect.height * aspectRatio;
|
||
offsetX = (rect.width - drawWidth) / 2;
|
||
}
|
||
|
||
// Clip to rounded rectangle for the image
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.roundRect(x, y, rect.width, rect.height, 8);
|
||
ctx.clip();
|
||
|
||
// Draw full-resolution image scaled down to fit entirely in cell
|
||
ctx.drawImage(img, x + offsetX, y + offsetY, drawWidth, drawHeight);
|
||
ctx.restore();
|
||
|
||
// Draw clear button as overlay on top-right corner of image
|
||
const clearButtonSize = Math.min(12, Math.min(rect.width, rect.height) * 0.2); // Scale with smaller dimension
|
||
const clearButtonX = x + rect.width - clearButtonSize - 2;
|
||
const clearButtonY = y + 2;
|
||
|
||
ctx.fillStyle = CLEAR_BUTTON_BG;
|
||
ctx.beginPath();
|
||
ctx.roundRect(clearButtonX, clearButtonY, clearButtonSize, clearButtonSize, 6);
|
||
ctx.fill();
|
||
|
||
ctx.strokeStyle = CLEAR_BUTTON_BORDER;
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.roundRect(clearButtonX, clearButtonY, clearButtonSize, clearButtonSize, 3);
|
||
ctx.stroke();
|
||
|
||
ctx.fillStyle = CLEAR_BUTTON_TEXT;
|
||
ctx.font = `12px Arial`;
|
||
ctx.textAlign = "center";
|
||
ctx.fillText("×", clearButtonX + clearButtonSize/2, clearButtonY + clearButtonSize/2 + 4);
|
||
|
||
// Store clear button bounds for click detection
|
||
this.clearButtonBounds[index] = {
|
||
x: clearButtonX,
|
||
y: clearButtonY,
|
||
width: clearButtonSize,
|
||
height: clearButtonSize,
|
||
widgetName: widgetName
|
||
};
|
||
} else {
|
||
// Draw clickable upload placeholder with hover effect
|
||
const isHovered = (this.hoveredImageIndex === index);
|
||
ctx.fillStyle = isHovered ? "rgba(48, 48, 48, 1)" : THUMBNAIL_PLACEHOLDER_BG; // Lighter grey on hover
|
||
ctx.beginPath();
|
||
ctx.roundRect(x, y, thumbSize, rect.height, 8);
|
||
ctx.fill();
|
||
|
||
// Draw solid border like other thumbnails
|
||
ctx.strokeStyle = THUMBNAIL_BORDER;
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.roundRect(x, y, thumbSize, rect.height, 8);
|
||
ctx.stroke();
|
||
|
||
ctx.fillStyle = THUMBNAIL_PLACEHOLDER_TEXT;
|
||
ctx.font = `12px Arial`;
|
||
ctx.textAlign = "center";
|
||
ctx.fillText("Upload", x + rect.width/2, y + rect.height/2 + 4);
|
||
|
||
// No clear button for empty slots
|
||
this.clearButtonBounds[index] = null;
|
||
}
|
||
|
||
// Draw solid border with rounded corners for all thumbnails
|
||
ctx.strokeStyle = THUMBNAIL_BORDER;
|
||
ctx.lineWidth = 1;
|
||
ctx.setLineDash([]); // Ensure solid line
|
||
ctx.beginPath();
|
||
ctx.roundRect(x, y, thumbSize, rect.height, 8);
|
||
ctx.stroke();
|
||
|
||
// Labels removed per request
|
||
|
||
// Store image area bounds for click detection (for upload when empty, or re-upload when has image)
|
||
this.imageAreaBounds[index] = {
|
||
x: x,
|
||
y: y,
|
||
width: rect.width,
|
||
height: rect.height,
|
||
widgetName: widgetName
|
||
};
|
||
});
|
||
};
|
||
|
||
// Removed redundant background drawing - handled by the rounded background below
|
||
|
||
|
||
|
||
|
||
|
||
// Override widget mouse handling to prevent widget interactions
|
||
nodeType.prototype.onMouseMove = function(event, pos, graphcanvas) {
|
||
if (this.flags.collapsed) return false;
|
||
const lp = this._toLocal(event, pos, graphcanvas);
|
||
const lx = lp[0], ly = lp[1];
|
||
|
||
let previousHovered = this.hoveredImageIndex;
|
||
this.hoveredImageIndex = -1; // Reset hover state
|
||
|
||
const widgetNames = ['image_1', 'image_2', 'image_3', 'image_4', 'image_5', 'image_6'];
|
||
for (let i = 0; i < widgetNames.length; i++) {
|
||
const widgetName = widgetNames[i];
|
||
const rect = this._getThumbRect(i);
|
||
if (!this.thumbnails[widgetName] &&
|
||
lx >= rect.x && lx <= rect.x + rect.width &&
|
||
ly >= rect.y && ly <= rect.y + rect.height) {
|
||
this.hoveredImageIndex = i;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Redraw if hover state changed
|
||
if (previousHovered !== this.hoveredImageIndex) {
|
||
this.setDirtyCanvas(true);
|
||
}
|
||
|
||
return false;
|
||
};
|
||
|
||
nodeType.prototype.onMouseUp = function(event, pos, graphcanvas) {
|
||
// Don't let widgets handle mouse up
|
||
return false;
|
||
};
|
||
|
||
|
||
|
||
// Open file dialog for specific image slot
|
||
nodeType.prototype.openFileDialog = function(widgetName, imageNumber) {
|
||
const input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = 'image/*';
|
||
input.onchange = (e) => {
|
||
const file = e.target.files[0];
|
||
if (file) {
|
||
this.uploadFileToSlot(file, widgetName, imageNumber);
|
||
}
|
||
};
|
||
input.click();
|
||
};
|
||
|
||
// Upload file to specific slot
|
||
nodeType.prototype.uploadFileToSlot = function(file, widgetName, imageNumber) {
|
||
const formData = new FormData();
|
||
formData.append('image', file);
|
||
formData.append('type', 'input');
|
||
formData.append('subfolder', '');
|
||
|
||
fetch('/upload/image', {
|
||
method: 'POST',
|
||
body: formData
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.name) {
|
||
// Update the property
|
||
this.properties[widgetName] = data.name;
|
||
|
||
// Update the corresponding widget value
|
||
if (this.widgets) {
|
||
const widget = this.widgets.find(w => w.name === widgetName);
|
||
if (widget) {
|
||
widget.value = data.name;
|
||
}
|
||
}
|
||
|
||
// Load thumbnail immediately
|
||
this.loadThumbnail(widgetName, data.name);
|
||
|
||
// Mark node as dirty to trigger redraw
|
||
this.setDirtyCanvas(true, true);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error(`Error uploading to Image ${imageNumber}:`, error);
|
||
alert(`Failed to upload to Image ${imageNumber}`);
|
||
});
|
||
};
|
||
|
||
// Clear image from specific slot
|
||
nodeType.prototype.clearImageSlot = function(widgetName, imageNumber) {
|
||
// Clear the property
|
||
this.properties[widgetName] = "";
|
||
|
||
// Update the corresponding widget value
|
||
if (this.widgets) {
|
||
const widget = this.widgets.find(w => w.name === widgetName);
|
||
if (widget) {
|
||
widget.value = "";
|
||
}
|
||
}
|
||
|
||
// Clear the thumbnail
|
||
this.thumbnails[widgetName] = null;
|
||
|
||
// Mark node as dirty to trigger redraw
|
||
this.setDirtyCanvas(true, true);
|
||
|
||
console.log(`Cleared Image ${imageNumber}`);
|
||
};
|
||
|
||
|
||
|
||
// Let LiteGraph handle the native node background - no custom drawing needed
|
||
|
||
// Override getExtraMenuOptions to prevent widget-related menu items
|
||
nodeType.prototype.getExtraMenuOptions = function(canvas, options) {
|
||
// Don't add widget-related menu options
|
||
return options;
|
||
};
|
||
|
||
// Prevent connections to input sockets
|
||
nodeType.prototype.onConnectInput = function(inputIndex, outputType, outputSlot, outputNode, outputIndex) {
|
||
return false; // Reject all input connections
|
||
};
|
||
|
||
// Override input rendering to make them completely invisible
|
||
nodeType.prototype.onDrawInputs = function(ctx) {
|
||
// Don't draw any inputs
|
||
return;
|
||
};
|
||
|
||
// Override widget drawing to prevent widgets from being drawn
|
||
nodeType.prototype.onDrawWidget = function(ctx, widget, x, y, width, height) {
|
||
// Don't draw any widgets
|
||
return false;
|
||
};
|
||
|
||
// Override the entire widget area drawing
|
||
nodeType.prototype.drawWidgets = function(ctx, node) {
|
||
// Don't draw widgets at all
|
||
return;
|
||
};
|
||
|
||
// Serialize properties to be passed to Python
|
||
nodeType.prototype.onSerialize = function(o) {
|
||
o.properties = this.properties;
|
||
};
|
||
|
||
// Restore properties from serialized data
|
||
nodeType.prototype.onConfigure = function(o) {
|
||
if (o.properties) {
|
||
this.properties = o.properties;
|
||
// Ensure all 6 properties exist
|
||
this.properties.image_1 = this.properties.image_1 || "";
|
||
this.properties.image_2 = this.properties.image_2 || "";
|
||
this.properties.image_3 = this.properties.image_3 || "";
|
||
this.properties.image_4 = this.properties.image_4 || "";
|
||
this.properties.image_5 = this.properties.image_5 || "";
|
||
this.properties.image_6 = this.properties.image_6 || "";
|
||
// Reload thumbnails after configuration
|
||
setTimeout(() => {
|
||
this.loadAllThumbnails();
|
||
}, 100);
|
||
}
|
||
};
|
||
|
||
|
||
}
|
||
}
|
||
});
|