Files
CoreyCorza-ComfyUI-CRZnodes/js/CustomDropdown.js
T
2025-09-05 23:49:38 +10:00

434 lines
20 KiB
JavaScript

// ComfyUI.CRZ.CustomDropdown
import { app } from "../../scripts/app.js";
import {
NODE_WIDTH,
NODE_HEIGHT_MULTIPLIER,
LABEL_LEFT_PADDING,
TRACK_RIGHT_PADDING,
TRACK_WIDTH,
VALUE_RIGHT_PADDING,
NODE_PADDING,
NODE_BACKGROUND_COLOR,
LABEL_COLOR,
INACTIVE_LABEL_COLOR,
VALUE_COLOR,
INACTIVE_VALUE_COLOR,
TRACK_COLOR,
INACTIVE_TRACK_COLOR,
INACTIVE_HANDLE_COLOR,
DROPDOWN_BG_COLOR,
DROPDOWN_BG_COLOR_INACTIVE
} from "./CRZConfig.js";
// Layout constants
const TRACK_VERTICAL_OFFSET = 10;
class CRZCustomDropdown {
constructor(node) {
this.node = node;
// Initialize properties
this.node.properties = this.node.properties || {};
this.node.properties.dropdown_name = this.node.properties.dropdown_name ?? "Custom Dropdown";
this.node.properties.dropdown_value = this.node.properties.dropdown_value ?? "";
this.node.properties.dropdown_options = this.node.properties.dropdown_options ?? ["Option 1", "Option 2", "Option 3"];
this.node.properties.dropdown_selected_index = this.node.properties.dropdown_selected_index ?? 0;
this.node.size = [NODE_WIDTH, Math.floor(LiteGraph.NODE_SLOT_HEIGHT * NODE_HEIGHT_MULTIPLIER)];
// Set initial value to first option
if (this.node.properties.dropdown_options.length > 0) {
this.node.properties.dropdown_value = this.node.properties.dropdown_options[this.node.properties.dropdown_selected_index];
}
// Property change handler
this.node.onPropertyChanged = function(propName) {
if (!this.configured) return;
// Update widget with current value
if (this.widgets && this.widgets[0]) {
if (typeof this.properties.dropdown_value === 'string') {
this.widgets[0].value = this.properties.dropdown_value;
} else {
this.widgets[0].value = this.properties.dropdown_value;
}
}
};
// Tooltip state
this.node.showTooltip = false;
this.node.tooltipText = "";
this.node.isTruncated = false;
// Mouse move handler for tooltip
this.node.onMouseMove = function(e) {
if (e.canvasY - this.pos[1] < 0) return false;
const dropdownWidth = TRACK_WIDTH + 45;
const dropdownLeft = this.size[0] - TRACK_RIGHT_PADDING;
const sliderY = NODE_PADDING;
const dropdownY = sliderY + TRACK_VERTICAL_OFFSET - 8;
const dropdownHeight = 16;
const isInDropdown = e.canvasX >= this.pos[0] + dropdownLeft &&
e.canvasX <= this.pos[0] + dropdownLeft + dropdownWidth &&
e.canvasY >= this.pos[1] + dropdownY &&
e.canvasY <= this.pos[1] + dropdownY + dropdownHeight;
if (isInDropdown) {
// Only show tooltip if text is actually truncated
this.showTooltip = this.isTruncated;
this.tooltipText = this.properties.dropdown_value || "";
// Store the mouse event for later use
this.lastMouseEvent = e;
} else {
this.showTooltip = false;
}
return false;
};
// Mouse leave handler
this.node.onMouseLeave = function(e) {
this.showTooltip = false;
if (this.tooltipElement) {
// Trigger fade out
this.tooltipElement.style.opacity = '0';
// Hide after transition completes
setTimeout(() => {
if (this.tooltipElement) {
this.tooltipElement.style.display = 'none';
}
}, 200);
}
return false;
};
// Cleanup tooltip when node is removed
this.node.onRemoved = function() {
if (this.tooltipElement) {
this.tooltipElement.remove();
this.tooltipElement = null;
}
};
// Mouse event handler for dropdown click
this.node.onMouseDown = function(e) {
if (e.canvasY - this.pos[1] < 0) return false;
const dropdownWidth = TRACK_WIDTH + 45;
const dropdownLeft = this.size[0] - TRACK_RIGHT_PADDING;
const sliderY = NODE_PADDING;
const dropdownY = sliderY + TRACK_VERTICAL_OFFSET - 8;
const dropdownHeight = 16;
// Check if click is in dropdown area
if (e.canvasX < this.pos[0] + dropdownLeft || e.canvasX > this.pos[0] + dropdownLeft + dropdownWidth) return false;
if (e.canvasY < this.pos[1] + dropdownY || e.canvasY > this.pos[1] + dropdownY + dropdownHeight) return false;
// Track click timing for double-click detection
const now = Date.now();
const timeSinceLastClick = now - (this.lastClickTime || 0);
this.lastClickTime = now;
// If this is potentially a double-click, wait longer
const isDoubleClick = timeSinceLastClick < 300;
const delay = isDoubleClick ? 350 : 200;
// Cancel any existing timeout
if (this.clickTimeout) {
clearTimeout(this.clickTimeout);
}
// Delay single-click to allow double-click detection
this.clickTimeout = setTimeout(() => {
// Check if we were cancelled by double-click
if (this.doubleClickHandled) {
this.doubleClickHandled = false;
return;
}
// Show dropdown context menu (single-click)
const options = this.properties.dropdown_options || ["Option 1"];
if (options.length > 0) {
const menu = new LiteGraph.ContextMenu(options, {
event: e,
callback: (value) => {
this.properties.dropdown_selected_index = options.indexOf(value);
this.properties.dropdown_value = value;
this.onPropertyChanged('dropdown_value');
// Update widget
if (this.widgets && this.widgets[0]) {
this.widgets[0].value = value;
}
// Mark the graph as changed
if (this.graph && this.graph.setDirtyCanvas) {
this.graph.setDirtyCanvas(true);
}
}
});
}
// Reset double-click flag after handling single-click
this.doubleClickHandled = false;
}, delay);
return true;
};
// Double-click handler for configuration and renaming
this.node.onDblClick = function(e, pos, canvas) {
if (this.flags.collapsed || !this.crzCustomDropdown) return false;
// Cancel single-click timeout if double-click detected
if (this.clickTimeout) {
clearTimeout(this.clickTimeout);
this.clickTimeout = null;
}
// Mark that double-click was handled
this.doubleClickHandled = true;
const sliderY = this.pos[1] + NODE_PADDING;
const dropdownWidth = TRACK_WIDTH + 45;
const dropdownLeft = this.pos[0] + this.size[0] - TRACK_RIGHT_PADDING;
const dropdownY = sliderY + TRACK_VERTICAL_OFFSET - 8;
const dropdownHeight = 16;
// Double-click on dropdown area to configure options
if (e.canvasX > dropdownLeft && e.canvasX < dropdownLeft + dropdownWidth &&
e.canvasY > dropdownY && e.canvasY < dropdownY + dropdownHeight) {
const currentOptions = this.properties.dropdown_options.join(', ');
canvas.prompt(
"Configure Dropdown Options (comma-separated)",
currentOptions,
function(newOptions) {
if (newOptions && newOptions.trim()) {
// Parse the options
const options = newOptions.split(',').map(opt => {
const trimmed = opt.trim();
// Try to convert to number if it looks like a number
const num = parseFloat(trimmed);
if (!isNaN(num) && trimmed === num.toString()) {
return num;
}
return trimmed;
}).filter(opt => opt !== ''); // Remove empty options
if (options.length > 0) {
this.properties.dropdown_options = options;
// Reset to first option
this.properties.dropdown_selected_index = 0;
this.properties.dropdown_value = options[0];
this.onPropertyChanged('dropdown_value');
}
}
// Reset double-click flag after configuration is complete
this.doubleClickHandled = false;
}.bind(this),
e
);
return true;
}
// Double-click on label area to rename
if (e.canvasX > this.pos[0] + LABEL_LEFT_PADDING &&
e.canvasX < dropdownLeft - 10 &&
e.canvasY > sliderY && e.canvasY < sliderY + 20) {
canvas.prompt("Dropdown Name", this.properties.dropdown_name, function(newName) {
if (newName && newName.trim()) {
this.properties.dropdown_name = newName.trim();
}
}.bind(this), e);
return true;
}
return false;
};
}
}
app.registerExtension({
name: "CRZCustomDropdown",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "CRZCustomDropdown") {
// Hide the title bar
nodeType.title_mode = LiteGraph.NO_TITLE;
nodeType.prototype.getTitle = function() { return ""; };
nodeType.prototype.onDrawTitle = function() { return false; };
// Set minimum size
nodeType.min_size = [NODE_WIDTH, Math.floor(LiteGraph.NODE_SLOT_HEIGHT * NODE_HEIGHT_MULTIPLIER)];
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
onNodeCreated?.apply(this, arguments);
this.bgcolor = NODE_BACKGROUND_COLOR;
// Initialize and hide the widget
if (this.widgets && this.widgets[0]) {
this.widgets[0].value = this.properties.dropdown_value || "";
this.widgets[0].hidden = true;
this.widgets[0].type = "hidden";
} else {
// Manually create the widget if it doesn't exist
this.addWidget("text", "dropdown", "", function(v) {
this.properties.dropdown_value = v;
});
if (this.widgets && this.widgets[0]) {
this.widgets[0].hidden = true;
this.widgets[0].type = "hidden";
}
}
// Hide widgets off-screen
this.onAdded = function() {
this.outputs[0].name = this.outputs[0].localized_name = "";
this.widgets_start_y = -2.4e8*LiteGraph.NODE_SLOT_HEIGHT;
if (this.widgets && this.widgets[0]) {
this.widgets[0].hidden = true;
this.widgets[0].type = "hidden";
}
};
this.crzCustomDropdown = new CRZCustomDropdown(this);
// Mark as CRZ node for connection hiding
this.isCRZNode = true;
};
// Force the node to use explicit width
nodeType.prototype.computeSize = () => [NODE_WIDTH-100, Math.floor(LiteGraph.NODE_SLOT_HEIGHT * NODE_HEIGHT_MULTIPLIER)];
nodeType.prototype.onDrawForeground = function(ctx) {
if (this.flags.collapsed || !this.crzCustomDropdown) return;
// Height snapping - prevent vertical resize
if (this.size[1] > LiteGraph.NODE_SLOT_HEIGHT * NODE_HEIGHT_MULTIPLIER) {
this.size[1] = LiteGraph.NODE_SLOT_HEIGHT * NODE_HEIGHT_MULTIPLIER;
}
// Mark as configured
this.configured = true;
const name = this.properties.dropdown_name || "Custom Dropdown";
const options = this.properties.dropdown_options || ["Option 1"];
const selectedIndex = this.properties.dropdown_selected_index || 0;
const selectedValue = options[selectedIndex] !== undefined ? options[selectedIndex] : (options[0] !== undefined ? options[0] : "");
const sliderY = NODE_PADDING;
// Check if this dropdown should be active (not greyed out)
const isConnected = this.outputs && this.outputs[0] &&
this.outputs[0].links && this.outputs[0].links.length > 0;
const hasCustomName = name !== "Custom Dropdown";
const isActive = isConnected || hasCustomName;
// Choose colors based on active status
const labelColor = isActive ? LABEL_COLOR : INACTIVE_LABEL_COLOR;
const valueColor = isActive ? VALUE_COLOR : INACTIVE_VALUE_COLOR;
// Draw name on the left
ctx.fillStyle = labelColor;
ctx.font = LiteGraph.NODE_SUBTEXT_SIZE + "px Arial";
ctx.textAlign = "left";
ctx.fillText(name, LABEL_LEFT_PADDING, sliderY + 15);
// Draw dropdown area
const dropdownWidth = TRACK_WIDTH + 50;
const dropdownLeft = this.size[0] - TRACK_RIGHT_PADDING;
const dropdownY = sliderY + TRACK_VERTICAL_OFFSET - 8;
const dropdownHeight = 16;
const cornerRadius = 3;
// Draw rounded dropdown background
ctx.fillStyle = isActive ? DROPDOWN_BG_COLOR : DROPDOWN_BG_COLOR_INACTIVE;
ctx.beginPath();
ctx.roundRect(dropdownLeft, dropdownY, dropdownWidth, dropdownHeight, cornerRadius);
ctx.fill();
// Draw current value (centered in dropdown)
ctx.fillStyle = valueColor;
ctx.font = LiteGraph.NODE_SUBTEXT_SIZE + "px Arial";
ctx.textAlign = "center";
const maxWidth = dropdownWidth - 20;
let truncatedValue = String(selectedValue);
// Check if text is truncated and store the state
this.isTruncated = ctx.measureText(truncatedValue).width > maxWidth;
if (this.isTruncated) {
truncatedValue = truncatedValue.substring(0, 8) + "..";
}
ctx.fillText(truncatedValue, dropdownLeft + dropdownWidth/2, sliderY + 14);
// Draw tooltip using same method as other CRZ nodes
if (this.showTooltip && this.tooltipText) {
if (!this.tooltipElement) {
this.tooltipElement = document.createElement("div");
this.tooltipElement.style.cssText = `
position: fixed;
background: rgba(24, 24, 24, 0.95);
color: #868686;
padding: 4px 8px;
border-radius: 4px;
font-size: 16px;
pointer-events: none;
z-index: 10000;
border: 1px solid rgba(41, 41, 41, 0.9);
white-space: nowrap;
opacity: 0;
transition: opacity 0.2s ease-in-out;
`;
document.body.appendChild(this.tooltipElement);
}
// Only update if tooltip text changed or not visible
if (this.tooltipElement.textContent !== this.tooltipText || this.tooltipElement.style.opacity === '0') {
// Get screen coordinates from the stored mouse event
let screenX = 0;
let screenY = 0;
if (this.lastMouseEvent) {
// Try to get client coordinates from the original event
const originalEvent = this.lastMouseEvent.originalEvent || this.lastMouseEvent;
screenX = originalEvent.clientX || 0;
screenY = originalEvent.clientY || 0;
}
this.tooltipElement.textContent = this.tooltipText;
this.tooltipElement.style.display = 'block';
// Position off-screen first to calculate width
this.tooltipElement.style.left = '-9999px';
this.tooltipElement.style.top = '-9999px';
this.tooltipElement.style.opacity = '0';
// Position correctly after width is calculated
requestAnimationFrame(() => {
this.tooltipElement.style.left = (screenX - this.tooltipElement.offsetWidth/2) + 'px';
this.tooltipElement.style.top = (screenY - 60) + 'px';
// Then fade in
requestAnimationFrame(() => {
this.tooltipElement.style.opacity = '1';
});
});
}
} else if (this.tooltipElement && this.tooltipElement.style.opacity !== '0') {
// Trigger fade out
this.tooltipElement.style.opacity = '0';
// Hide after transition completes
setTimeout(() => {
if (this.tooltipElement) {
this.tooltipElement.style.display = 'none';
}
}, 200);
}
};
}
}
});