-
Apply & Close
-
Preview
-
Reset to Defaults
-
Cancel
+
+
+ Apply
+ Preview
+ Reset
+ πΎ Save as Default
+ Cancel
`;
-
- document.body.appendChild(backdrop);
+
document.body.appendChild(dialog);
-
- // ESC key handler - restore original values on escape
- function escHandler(e) {
- if (e.key === 'Escape') {
- restoreSettings(dialogOpenValues);
- backdrop.remove();
- dialog.remove();
- document.removeEventListener('keydown', escHandler);
- }
- }
- document.addEventListener('keydown', escHandler);
-
- // Set up event handlers
- setupDialogHandlers(dialog, backdrop);
+ makeDraggable(dialog, dialog.querySelector('#drag-bar'));
+ setupDialogHandlers(dialog);
+ currentDialog = dialog;
- // Set font family to current value
+ // Store the values when dialog opens for cancel functionality
+ dialogOpenValues = { ...currentValues };
+
+ // Set current values in the dialog
+ updateDialogValues(dialog);
+
+ // Live theme updating without closing dialog
+ unregisterThemeCallback = onThemeChange(() => {
+ if (currentDialog) {
+ updateDialogTheme();
+ }
+ });
+ }
+
+ function createStyleCSS(colors) {
+ return `
+ .fontifier-setting {
+ margin-bottom: 10px;
+ }
+ .fontifier-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+ .fontifier-row input[type="range"] {
+ flex-grow: 1;
+ }
+ .fontifier-row input[type="number"] {
+ width: 50px;
+ background: ${colors.inputBg || '#222'};
+ border: 1px solid ${colors.border || '#999'};
+ color: ${colors.inputText || '#ddd'};
+ border-radius: 4px;
+ padding: 2px;
+ }
+ #fontifier-dialog button {
+ flex-grow: 1;
+ padding: 6px;
+ background: ${colors.inputBg || '#222'};
+ border: 1px solid ${colors.border || '#999'};
+ color: ${colors.inputText || '#ddd'};
+ border-radius: 4px;
+ cursor: pointer;
+ }
+ #fontifier-dialog button:hover {
+ background: ${colors.buttonHoverBg || colors.hoverBg || blendColors(colors.inputBg || '#222', '#ffffff', 0.1)};
+ }
+ #fontifier-dialog #apply-btn {
+ background: ${toRGBA(colors.accent || '#4CAF50', 0.3)};
+ border-color: ${colors.accent || '#4CAF50'};
+ }
+ #fontifier-dialog #cancel-btn {
+ background: ${toRGBA(colors.errorText || '#f44336', 0.3)};
+ border-color: ${colors.errorText || '#f44336'};
+ }
+ #fontifier-dialog select {
+ background: ${colors.inputBg || '#222'};
+ border: 1px solid ${colors.border || '#999'};
+ color: ${colors.inputText || '#ddd'};
+ border-radius: 4px;
+ padding: 4px;
+ }
+ `;
+ }
+
+ function updateDialogTheme() {
+ if (!currentDialog) return;
+
+ const newColors = getComfyUIColors();
+
+ // Update main dialog styling
+ currentDialog.style.background = newColors.dialogBg || newColors.menu || 'rgba(20, 20, 20, 0.95)';
+ currentDialog.style.color = newColors.inputText || '#fff';
+ currentDialog.style.borderColor = newColors.border || '#999';
+ currentDialog.style.boxShadow = newColors.shadow || '0 0 20px rgba(0,0,0,0.5)';
+
+ // Update drag bar
+ const dragBar = currentDialog.querySelector('#drag-bar');
+ if (dragBar) {
+ dragBar.style.background = newColors.menuSecondary || '#2a2a2a';
+ }
+
+ // Update preview indicator
+ const previewIndicator = currentDialog.querySelector('#preview-indicator');
+ if (previewIndicator) {
+ previewIndicator.style.color = newColors.accent || '#4CAF50';
+ }
+
+ // Update the style tag with new colors
+ const styleTag = document.getElementById('fontifier-dialog-style');
+ if (styleTag) {
+ styleTag.textContent = createStyleCSS(newColors);
+ }
+ }
+
+ function updateDialogValues(dialog) {
+ dialog.querySelector('#global-scale').value = currentValues.GLOBAL_SCALE || 1;
+ dialog.querySelector('#global-scale-num').value = currentValues.GLOBAL_SCALE || 1;
+ dialog.querySelector('#node-text-size').value = currentValues.NODE_TEXT_SIZE;
+ dialog.querySelector('#node-text-size-num').value = currentValues.NODE_TEXT_SIZE;
+ dialog.querySelector('#node-subtext-size').value = currentValues.NODE_SUBTEXT_SIZE;
+ dialog.querySelector('#node-subtext-size-num').value = currentValues.NODE_SUBTEXT_SIZE;
+ dialog.querySelector('#title-height').value = currentValues.NODE_TITLE_HEIGHT;
+ dialog.querySelector('#title-height-num').value = currentValues.NODE_TITLE_HEIGHT;
+ dialog.querySelector('#slot-height').value = currentValues.NODE_SLOT_HEIGHT;
+ dialog.querySelector('#slot-height-num').value = currentValues.NODE_SLOT_HEIGHT;
+ dialog.querySelector('#group-font-size').value = currentValues.DEFAULT_GROUP_FONT;
+ dialog.querySelector('#group-font-size-num').value = currentValues.DEFAULT_GROUP_FONT;
+ dialog.querySelector('#widget-text-size').value = currentValues.WIDGET_TEXT_SIZE;
+ dialog.querySelector('#widget-text-size-num').value = currentValues.WIDGET_TEXT_SIZE;
dialog.querySelector('#font-family').value = currentValues.NODE_FONT;
}
-
- // Function to restore settings
- function restoreSettings(settings) {
- applySettingsToComfyUI(settings);
- console.log('π Settings restored:', settings);
- }
-
- // Separated function to apply settings to ComfyUI
- function applySettingsToComfyUI(settings) {
- if (typeof LiteGraph !== 'undefined') {
- LiteGraph.NODE_TEXT_SIZE = settings.NODE_TEXT_SIZE;
- LiteGraph.NODE_SUBTEXT_SIZE = settings.NODE_SUBTEXT_SIZE;
- LiteGraph.NODE_TITLE_HEIGHT = settings.NODE_TITLE_HEIGHT;
- LiteGraph.NODE_SLOT_HEIGHT = settings.NODE_SLOT_HEIGHT;
- LiteGraph.NODE_WIDGET_HEIGHT = settings.NODE_SLOT_HEIGHT;
- LiteGraph.DEFAULT_GROUP_FONT = settings.DEFAULT_GROUP_FONT;
- LiteGraph.DEFAULT_GROUP_FONT_SIZE = settings.DEFAULT_GROUP_FONT;
- LiteGraph.NODE_FONT = settings.NODE_FONT || settings.FONT_FAMILY;
- LiteGraph.DEFAULT_FONT = settings.NODE_FONT || settings.FONT_FAMILY;
- LiteGraph.GROUP_FONT = settings.NODE_FONT || settings.FONT_FAMILY;
- if (typeof app !== 'undefined' && app.canvas) {
- app.canvas.setDirty(true, true);
- if (app.canvas.draw) {
- setTimeout(() => app.canvas.draw(true, true), 100);
- }
- }
+ function setupDialogHandlers(dialog) {
+ if (handlersSetup) return;
+ handlersSetup = true;
- const canvases = document.querySelectorAll('canvas');
- canvases.forEach(canvas => {
- if (canvas.getContext) {
- const ctx = canvas.getContext('2d');
- const originalWidth = canvas.width;
- canvas.width = originalWidth + 1;
- canvas.width = originalWidth;
- }
- });
- }
+ addButtonHoverEffects(dialog);
- // Apply widget font size to CSS
- let styleTag = document.getElementById('fontifier-widget-text-style');
- if (!styleTag) {
- styleTag = document.createElement('style');
- styleTag.id = 'fontifier-widget-text-style';
- document.head.appendChild(styleTag);
- }
- styleTag.textContent = `
- canvas ~ * .widget input, canvas ~ * .widget select, canvas ~ * .widget textarea,
- canvas ~ * .comfy-multiline-input, canvas ~ * .comfy-input,
- canvas ~ * input.comfy-multiline-input, canvas ~ * textarea.comfy-multiline-input,
- canvas ~ * [class*="comfy-input"], canvas ~ * [class*="comfy-multiline"],
- canvas ~ * .comfyui-widget input, canvas ~ * .comfyui-widget select, canvas ~ * .comfyui-widget textarea,
- canvas ~ * [class*="widget"] input, canvas ~ * [class*="widget"] select, canvas ~ * [class*="widget"] textarea,
- canvas ~ * .litegraph input, canvas ~ * .litegraph select, canvas ~ * .litegraph textarea,
- .litegraph input, .litegraph select, .litegraph textarea {
- font-size: ${settings.WIDGET_TEXT_SIZE}px !important;
- font-family: ${settings.NODE_FONT || settings.FONT_FAMILY} !important;
- }
-
- /* Exclude the fontifier dialog itself */
- #fontifier-dialog input, #fontifier-dialog select, #fontifier-dialog textarea {
- font-size: 14px !important;
- font-family: Arial !important;
- }
- `;
- }
-
- function setupDialogHandlers(dialog, backdrop) {
- makeDraggable(dialog);
-
- // Sync sliders with number inputs
const elements = [
'global-scale',
- 'node-text-size',
- 'node-subtext-size',
- 'title-height',
+ 'node-text-size',
+ 'node-subtext-size',
+ 'title-height',
'slot-height',
'group-font-size',
'widget-text-size'
];
-
+
elements.forEach(id => {
const slider = dialog.querySelector(`#${id}`);
const numberInput = dialog.querySelector(`#${id}-num`);
-
- slider.oninput = () => {
- numberInput.value = slider.value;
- if (id === 'global-scale') {
- const globalScaleNum = dialog.querySelector('#global-scale-num');
- globalScaleNum.value = slider.value;
- }
- };
- numberInput.oninput = () => {
- slider.value = numberInput.value;
- };
+ if (slider && numberInput) {
+ slider.oninput = () => {
+ numberInput.value = slider.value;
+ if (isPreviewMode) showPreviewIndicator();
+ };
+ numberInput.oninput = () => {
+ // Enforce min/max constraints
+ const min = parseFloat(numberInput.min);
+ const max = parseFloat(numberInput.max);
+ let value = parseFloat(numberInput.value);
+ if (value < min) value = min;
+ if (value > max) value = max;
+ numberInput.value = value;
+ slider.value = value;
+ if (isPreviewMode) showPreviewIndicator();
+ };
+ }
});
-
- // Global scale handler - now includes widget text size
- const globalScale = dialog.querySelector('#global-scale');
- const globalScaleNum = dialog.querySelector('#global-scale-num');
-
- function updateGlobalScale() {
- const scale = parseFloat(globalScale.value);
- globalScaleNum.value = scale;
-
- // Update all individual controls including widget text size
- const updates = [
- ['node-text-size', originalValues.NODE_TEXT_SIZE],
- ['node-subtext-size', originalValues.NODE_SUBTEXT_SIZE],
- ['title-height', originalValues.NODE_TITLE_HEIGHT],
- ['slot-height', originalValues.NODE_SLOT_HEIGHT],
- ['group-font-size', originalValues.DEFAULT_GROUP_FONT],
- ['widget-text-size', originalValues.WIDGET_TEXT_SIZE] // Added widget text size to global scaling
- ];
-
- updates.forEach(([id, originalValue]) => {
- const newValue = Math.round(originalValue * scale);
- dialog.querySelector(`#${id}`).value = newValue;
- dialog.querySelector(`#${id}-num`).value = newValue;
- });
+
+ const saveBtn = dialog.querySelector('#save-defaults-btn');
+ if (saveBtn) {
+ saveBtn.onclick = () => {
+ localStorage.setItem("endless_fontifier_defaults", JSON.stringify(currentValues));
+ alert("π Fontifier defaults saved! They'll auto-load next time.");
+ };
}
-
- globalScale.oninput = updateGlobalScale;
- globalScaleNum.oninput = () => {
- globalScale.value = globalScaleNum.value;
- updateGlobalScale();
+
+ dialog.querySelector('#apply-btn').onclick = () => {
+ applyChanges(dialog, true);
+ hidePreviewIndicator();
+ closeDialog();
};
- // Button handlers
- dialog.querySelector('#close-dialog').onclick = () => {
- restoreSettings(dialogOpenValues);
- backdrop.remove();
- dialog.remove();
+ dialog.querySelector('#preview-btn').onclick = () => {
+ applyChanges(dialog, false);
+ showPreviewIndicator();
};
dialog.querySelector('#reset-btn').onclick = () => {
- dialog.querySelector('#global-scale').value = 1;
- dialog.querySelector('#global-scale-num').value = 1;
- dialog.querySelector('#node-text-size').value = originalValues.NODE_TEXT_SIZE;
- dialog.querySelector('#node-text-size-num').value = originalValues.NODE_TEXT_SIZE;
- dialog.querySelector('#node-subtext-size').value = originalValues.NODE_SUBTEXT_SIZE;
- dialog.querySelector('#node-subtext-size-num').value = originalValues.NODE_SUBTEXT_SIZE;
- dialog.querySelector('#title-height').value = originalValues.NODE_TITLE_HEIGHT;
- dialog.querySelector('#title-height-num').value = originalValues.NODE_TITLE_HEIGHT;
- dialog.querySelector('#slot-height').value = originalValues.NODE_SLOT_HEIGHT;
- dialog.querySelector('#slot-height-num').value = originalValues.NODE_SLOT_HEIGHT;
- dialog.querySelector('#group-font-size').value = originalValues.DEFAULT_GROUP_FONT;
- dialog.querySelector('#group-font-size-num').value = originalValues.DEFAULT_GROUP_FONT;
- dialog.querySelector('#widget-text-size').value = originalValues.WIDGET_TEXT_SIZE;
- dialog.querySelector('#widget-text-size-num').value = originalValues.WIDGET_TEXT_SIZE;
- dialog.querySelector('#font-family').value = 'Arial';
- };
-
- dialog.querySelector('#preview-btn').onclick = () => applyChanges(dialog, false);
-
- dialog.querySelector('#apply-btn').onclick = () => {
- applyChanges(dialog, true);
- backdrop.remove();
- dialog.remove();
+ localStorage.removeItem("endless_fontifier_defaults");
+ currentValues = { ...originalValues };
+ applySettingsToComfyUI(originalValues);
+ updateDialogValues(dialog);
+ hidePreviewIndicator();
+ alert("π Fontifier reset to ComfyUI defaults.");
};
dialog.querySelector('#cancel-btn').onclick = () => {
- restoreSettings(dialogOpenValues);
- backdrop.remove();
- dialog.remove();
+ applySettingsToComfyUI(dialogOpenValues);
+ hidePreviewIndicator();
+ closeDialog();
};
- // Add hover effects to buttons
- const buttons = dialog.querySelectorAll('button');
- buttons.forEach(button => {
- button.style.boxSizing = 'border-box';
- button.style.minWidth = button.offsetWidth + 'px';
- button.addEventListener('mouseenter', () => {
- button.style.borderWidth = '2px';
- button.style.padding = '7px 15px';
- });
- button.addEventListener('mouseleave', () => {
- button.style.borderWidth = '1px';
- button.style.padding = '8px 16px';
- });
- });
+ escHandler = e => {
+ if (e.key === 'Escape') {
+ applySettingsToComfyUI(dialogOpenValues);
+ hidePreviewIndicator();
+ closeDialog();
+ }
+ };
+ document.addEventListener('keydown', escHandler);
}
-
+
+ function showPreviewIndicator() {
+ if (!currentDialog) return;
+ isPreviewMode = true;
+ const indicator = currentDialog.querySelector('#preview-indicator');
+ if (indicator) indicator.style.display = 'block';
+ }
+
+ function hidePreviewIndicator() {
+ if (!currentDialog) return;
+ isPreviewMode = false;
+ const indicator = currentDialog.querySelector('#preview-indicator');
+ if (indicator) indicator.style.display = 'none';
+ }
+
function applyChanges(dialog, permanent = false) {
- const newValues = {
+ const globalScale = parseFloat(dialog.querySelector('#global-scale').value);
+
+ const baseValues = {
NODE_TEXT_SIZE: parseInt(dialog.querySelector('#node-text-size').value),
NODE_SUBTEXT_SIZE: parseInt(dialog.querySelector('#node-subtext-size').value),
NODE_TITLE_HEIGHT: parseInt(dialog.querySelector('#title-height').value),
@@ -444,17 +383,87 @@ import { registerEndlessTool } from './endless_ui_helpers.js';
DEFAULT_GROUP_FONT: parseInt(dialog.querySelector('#group-font-size').value),
FONT_FAMILY: dialog.querySelector('#font-family').value,
NODE_FONT: dialog.querySelector('#font-family').value,
- WIDGET_TEXT_SIZE: parseInt(dialog.querySelector('#widget-text-size').value)
+ WIDGET_TEXT_SIZE: parseInt(dialog.querySelector('#widget-text-size').value),
+ GLOBAL_SCALE: globalScale
};
- applySettingsToComfyUI(newValues);
- console.log('πβ¨ Fontifier applied:', newValues);
+ // Apply global scaling to font sizes
+ const scaledValues = {
+ ...baseValues,
+ NODE_TEXT_SIZE: Math.round(baseValues.NODE_TEXT_SIZE * globalScale),
+ NODE_SUBTEXT_SIZE: Math.round(baseValues.NODE_SUBTEXT_SIZE * globalScale),
+ DEFAULT_GROUP_FONT: Math.round(baseValues.DEFAULT_GROUP_FONT * globalScale),
+ WIDGET_TEXT_SIZE: Math.round(baseValues.WIDGET_TEXT_SIZE * globalScale)
+ };
+ applySettingsToComfyUI(scaledValues);
if (permanent) {
- currentValues = { ...newValues };
- console.log('πβ¨ Fontifier changes applied permanently (until page refresh)');
+ currentValues = { ...baseValues }; // Store unscaled values
+ isPreviewMode = false;
}
}
+ function applySettingsToComfyUI(settings) {
+ LiteGraph.NODE_TEXT_SIZE = settings.NODE_TEXT_SIZE;
+ LiteGraph.NODE_SUBTEXT_SIZE = settings.NODE_SUBTEXT_SIZE;
+ LiteGraph.NODE_TITLE_HEIGHT = settings.NODE_TITLE_HEIGHT;
+ LiteGraph.NODE_SLOT_HEIGHT = settings.NODE_SLOT_HEIGHT;
+ LiteGraph.DEFAULT_GROUP_FONT = settings.DEFAULT_GROUP_FONT;
+ LiteGraph.DEFAULT_GROUP_FONT_SIZE = settings.DEFAULT_GROUP_FONT;
+ LiteGraph.NODE_FONT = settings.NODE_FONT;
+ LiteGraph.DEFAULT_FONT = settings.NODE_FONT;
+ LiteGraph.GROUP_FONT = settings.NODE_FONT;
+
+ if (window.app?.canvas) {
+ window.app.canvas.setDirty(true, true);
+ setTimeout(() => window.app.canvas.draw(true, true), 100);
+ }
+
+ const styleId = "fontifier-widget-text-style";
+ let styleTag = document.getElementById(styleId);
+ if (!styleTag) {
+ styleTag = document.createElement('style');
+ styleTag.id = styleId;
+ document.head.appendChild(styleTag);
+ }
+ styleTag.textContent = `
+ .litegraph input, .litegraph select, .litegraph textarea {
+ font-size: ${settings.WIDGET_TEXT_SIZE}px !important;
+ font-family: ${settings.NODE_FONT} !important;
+ }
+ #fontifier-dialog input, #fontifier-dialog select, #fontifier-dialog textarea {
+ font-size: 14px !important;
+ font-family: Arial !important;
+ }
+ `;
+ }
+
+ function closeDialog() {
+ if (currentDialog) currentDialog.remove();
+ if (escHandler) document.removeEventListener('keydown', escHandler);
+ if (unregisterThemeCallback) unregisterThemeCallback();
+ // Clean up the style tag
+ const styleTag = document.getElementById('fontifier-dialog-style');
+ if (styleTag) styleTag.remove();
+ currentDialog = null;
+ handlersSetup = false;
+ escHandler = null;
+ unregisterThemeCallback = null;
+ isPreviewMode = false;
+ }
+
+ // Wait for app to be ready before initializing
+ function waitForApp() {
+ if (typeof window.app !== 'undefined' && window.app?.canvas) {
+ // Initialize with saved defaults once app is ready
+ applySettingsToComfyUI(currentValues);
+ return;
+ }
+ setTimeout(waitForApp, 100);
+ }
+
+ waitForApp();
+
+ // Register into Endless Tools menu
registerEndlessTool("Fontifier", createFontifierDialog);
})();
\ No newline at end of file
diff --git a/web/endless_minimap.js b/web/endless_minimap.js
new file mode 100644
index 0000000..884f03f
--- /dev/null
+++ b/web/endless_minimap.js
@@ -0,0 +1,764 @@
+// ComfyUI Endless πβ¨ Minimap - Optimized Version
+
+(function waitForHelpers() {
+ if (typeof window.EndlessHelpers === 'undefined') {
+ console.warn("β³ Waiting for EndlessHelpers to be ready...");
+ setTimeout(waitForHelpers, 100);
+ return;
+ }
+
+ const {
+ registerEndlessTool,
+ onThemeChange,
+ getComfyUIColors,
+ toRGBA,
+ makeDraggable
+ } = window.EndlessHelpers;
+
+ console.log("β
Endless Minimap loaded.");
+
+ // State variables
+ let currentDialog = null;
+ let animationId = null;
+ let unregisterThemeCallback = null;
+ let resizeObserver = null;
+
+ // Canvas state
+ let panX = 0, panY = 0, zoom = 1;
+ let isDragging = false;
+ let dragStartTime = 0, dragStartX = 0, dragStartY = 0;
+ const DRAG_THRESHOLD = 5;
+
+ // Size constants
+ const BASE_WIDTH = 300, BASE_HEIGHT = 400;
+ const MAX_WIDTH = 500, MAX_HEIGHT = 600;
+ const MIN_WIDTH = 200, MIN_HEIGHT = 150;
+
+ // Node type colors - comprehensive mapping
+ const NODE_COLORS = {
+ // Image Processing (Blue family)
+ 'LoadImage': '#5DADE2', 'SaveImage': '#3498DB', 'PreviewImage': '#2E86AB',
+ 'ImageScale': '#85C1E9', 'ImageCrop': '#7FB3D3', 'ImageBlend': '#6BB6FF',
+
+ // Latent Processing (Purple family)
+ 'KSampler': '#8E44AD', 'KSamplerAdvanced': '#9B59B6', 'EmptyLatentImage': '#A569BD',
+ 'LatentUpscale': '#BB8FCE', 'LatentBlend': '#D2B4DE',
+
+ // VAE (Green family)
+ 'VAEDecode': '#27AE60', 'VAEEncode': '#2ECC71', 'VAELoader': '#58D68D',
+
+ // Model/Checkpoint (Teal family)
+ 'CheckpointLoaderSimple': '#17A2B8', 'CheckpointLoader': '#148A99',
+ 'ModelMergeSimple': '#1ABC9C', 'UNETLoader': '#5DADE2',
+
+ // CLIP/Text (Orange family)
+ 'CLIPTextEncode': '#E67E22', 'CLIPTextEncodeSDXL': '#F39C12',
+ 'CLIPLoader': '#F8C471', 'CLIPVisionEncode': '#D68910',
+
+ // LoRA (Yellow family)
+ 'LoraLoader': '#F1C40F', 'LoraLoaderModelOnly': '#F4D03F',
+
+ // ControlNet (Pink family)
+ 'ControlNetLoader': '#E91E63', 'ControlNetApply': '#F06292',
+ 'CannyEdgePreprocessor': '#EC407A', 'OpenposePreprocessor': '#F8BBD9',
+
+ // Conditioning (Coral family)
+ 'ConditioningAverage': '#FF6B35', 'ConditioningCombine': '#FF8C42',
+
+ // Utility (Gray family)
+ 'PrimitiveNode': '#95A5A6', 'Note': '#BDC3C7', 'Reroute': '#85929E',
+
+ // Upscaling (Lime family)
+ 'UpscaleModelLoader': '#7FB069', 'ImageUpscaleWithModel': '#8BC34A',
+
+ // Masks (Red family)
+ 'MaskComposite': '#E53935', 'MaskToImage': '#F44336', 'ImageToMask': '#EF5350',
+
+ 'default': 'rgba(200, 200, 200, 0.7)'
+ };
+
+ function getApp() {
+ return window.app || window.comfyApp || document.querySelector('#app')?.__vue__?.$root || null;
+ }
+
+ function getCanvasAspectRatio() {
+ const mainCanvas = document.querySelector('canvas') ||
+ document.querySelector('#graph-canvas') ||
+ document.querySelector('.litegraph');
+
+ if (mainCanvas) {
+ const rect = mainCanvas.getBoundingClientRect();
+ return rect.width / rect.height;
+ }
+
+ return window.innerWidth / window.innerHeight;
+ }
+
+ function calculateDimensions() {
+ const aspectRatio = getCanvasAspectRatio();
+ let containerWidth, containerHeight, canvasWidth, canvasHeight;
+
+ if (aspectRatio > 1) {
+ containerWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, BASE_WIDTH * aspectRatio));
+ containerHeight = BASE_HEIGHT;
+ canvasWidth = containerWidth;
+ canvasHeight = BASE_HEIGHT - 50;
+ } else {
+ containerWidth = BASE_WIDTH;
+ containerHeight = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, BASE_HEIGHT / aspectRatio));
+ canvasWidth = containerWidth;
+ canvasHeight = containerHeight - 50;
+ }
+
+ return { containerWidth, containerHeight, canvasWidth, canvasHeight };
+ }
+
+ function getNodeColor(node) {
+ const nodeType = node.type || node.constructor?.name || 'default';
+
+ if (NODE_COLORS[nodeType]) return NODE_COLORS[nodeType];
+
+ // Pattern matching for common types
+ const patterns = [
+ ['Sampler', NODE_COLORS['KSampler']],
+ ['CLIP', NODE_COLORS['CLIPTextEncode']],
+ ['VAE', NODE_COLORS['VAEDecode']],
+ ['ControlNet', NODE_COLORS['ControlNetLoader']],
+ ['Lora', NODE_COLORS['LoraLoader']],
+ ['Image.*Load', NODE_COLORS['LoadImage']],
+ ['Image.*Save', NODE_COLORS['SaveImage']],
+ ['Checkpoint', NODE_COLORS['CheckpointLoaderSimple']],
+ ['Upscale', NODE_COLORS['UpscaleModelLoader']],
+ ['Mask', NODE_COLORS['MaskComposite']]
+ ];
+
+ for (const [pattern, color] of patterns) {
+ if (new RegExp(pattern, 'i').test(nodeType)) return color;
+ }
+
+ return NODE_COLORS.default;
+ }
+
+ function getNodes() {
+ const app = getApp();
+ if (!app) {
+ console.log("App not found, trying DOM fallback...");
+ const nodeElements = document.querySelectorAll('[class*="node"], .comfy-node, .litegraph-node');
+ if (nodeElements.length > 0) {
+ return Array.from(nodeElements).map((el, i) => ({
+ pos: [i * 150, i * 100],
+ size: [100, 60],
+ type: 'Unknown',
+ title: `Node ${i + 1}`
+ }));
+ }
+ return null;
+ }
+
+ const nodes = app.graph?._nodes ||
+ app.graph?.nodes ||
+ app.canvas?.graph?._nodes ||
+ app.canvas?.graph?.nodes ||
+ [];
+
+ return nodes;
+ }
+
+ function createStyleCSS(colors) {
+ return `
+ #endless-minimap button {
+ background: none;
+ border: none;
+ color: ${colors.inputText};
+ cursor: pointer;
+ padding: 2px 6px;
+ font-size: 18px;
+ border-radius: 3px;
+ transition: background 0.2s ease;
+ }
+ #endless-minimap button:hover {
+ background: ${toRGBA(colors.inputText, 0.1)};
+ }
+ #endless-minimap .drag-bar {
+ padding: 4px 8px;
+ background: ${toRGBA(colors.inputText, 0.05)};
+ cursor: move;
+ font-size: 14px;
+ user-select: none;
+ border-bottom: 1px solid ${colors.border};
+ flex-shrink: 0;
+ }
+ #endless-minimap .legend {
+ position: absolute;
+ top: 5px;
+ left: 5px;
+ background: ${colors.menu};
+ color: ${colors.inputText};
+ padding: 8px;
+ border: 1px solid ${colors.border};
+ border-radius: 4px;
+ font-size: 10px;
+ max-height: 200px;
+ overflow-y: auto;
+ display: none;
+ z-index: 1;
+ }
+ #endless-minimap .pan-info {
+ padding: 2px 8px;
+ font-size: 10px;
+ background: ${colors.menuSecondary};
+ color: ${colors.inputText};
+ border-top: 1px solid ${colors.border};
+ text-align: center;
+ flex-shrink: 0;
+ }
+ `;
+ }
+
+ function updateTheme() {
+ if (!currentDialog) return;
+
+ const colors = getComfyUIColors();
+
+ // Update container colors only, not size
+ currentDialog.style.background = colors.menu;
+ currentDialog.style.color = colors.inputText;
+ currentDialog.style.borderColor = colors.accent;
+
+ // Update style tag
+ const styleTag = document.getElementById('minimap-style');
+ if (styleTag) {
+ styleTag.textContent = createStyleCSS(colors);
+ }
+
+ drawMinimap();
+ }
+
+ function updateLegend() {
+ const legend = currentDialog.querySelector('.legend');
+ if (!legend || legend.style.display === 'none') return;
+
+ const nodes = getNodes();
+ if (!nodes) return;
+
+ const typeCounts = {};
+ nodes.forEach(n => {
+ const nodeType = n.type || n.constructor?.name || 'default';
+ typeCounts[nodeType] = (typeCounts[nodeType] || 0) + 1;
+ });
+
+ legend.innerHTML = Object.entries(typeCounts)
+ .sort((a, b) => b[1] - a[1])
+ .map(([type, count]) => {
+ const color = NODE_COLORS[type] || NODE_COLORS.default;
+ return `
`;
+ }).join('');
+ }
+
+ // Get current transform state for coordinate conversions
+ function getTransformState() {
+ const canvas = currentDialog?.querySelector('canvas');
+ if (!canvas) return null;
+
+ const nodes = getNodes();
+ if (!nodes?.length) return null;
+
+ // Calculate bounds (same as in drawMinimap)
+ const bounds = nodes.reduce((acc, n) => {
+ const x = n.pos?.[0] ?? n.x ?? 0;
+ const y = n.pos?.[1] ?? n.y ?? 0;
+ const w = n.size?.[0] ?? n.width ?? 100;
+ const h = n.size?.[1] ?? n.height ?? 60;
+
+ return {
+ minX: Math.min(acc.minX, x),
+ minY: Math.min(acc.minY, y),
+ maxX: Math.max(acc.maxX, x + w),
+ maxY: Math.max(acc.maxY, y + h)
+ };
+ }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
+
+ const width = bounds.maxX - bounds.minX;
+ const height = bounds.maxY - bounds.minY;
+ if (width <= 0 || height <= 0) return null;
+
+ const baseScale = Math.min(canvas.width / Math.max(width, 1000), canvas.height / Math.max(height, 1000));
+ const scale = baseScale * zoom;
+
+ return {
+ bounds,
+ width,
+ height,
+ scale,
+ canvas
+ };
+ }
+
+ // Convert canvas coordinates to world coordinates
+ function canvasToWorld(canvasX, canvasY) {
+ const transform = getTransformState();
+ if (!transform) return null;
+
+ const { bounds, width, height, scale, canvas } = transform;
+
+ // Inverse of the transform used in drawMinimap
+ const worldX = (canvasX - canvas.width / 2 - panX) / scale + (bounds.minX + width / 2);
+ const worldY = (canvasY - canvas.height / 2 - panY) / scale + (bounds.minY + height / 2);
+
+ return { x: worldX, y: worldY };
+ }
+
+ // Convert world coordinates to canvas coordinates
+ function worldToCanvas(worldX, worldY) {
+ const transform = getTransformState();
+ if (!transform) return null;
+
+ const { bounds, width, height, scale, canvas } = transform;
+
+ // Same transform as used in drawMinimap
+ const canvasX = (worldX - (bounds.minX + width / 2)) * scale + canvas.width / 2 + panX;
+ const canvasY = (worldY - (bounds.minY + height / 2)) * scale + canvas.height / 2 + panY;
+
+ return { x: canvasX, y: canvasY };
+ }
+
+ function drawMinimap() {
+ if (!currentDialog) return;
+
+ const canvas = currentDialog.querySelector('canvas');
+ const panInfo = currentDialog.querySelector('.pan-info');
+ if (!canvas || !panInfo) return;
+
+ const ctx = canvas.getContext('2d');
+ const colors = getComfyUIColors();
+
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+ const nodes = getNodes();
+ if (!nodes || !nodes.length) {
+ ctx.fillStyle = colors.inputText;
+ ctx.font = '12px Arial';
+ ctx.textAlign = 'center';
+ ctx.fillText('No nodes found', canvas.width / 2, canvas.height / 2);
+ ctx.fillText('or graph not loaded', canvas.width / 2, canvas.height / 2 + 15);
+ return;
+ }
+
+ // Calculate bounds
+ const bounds = nodes.reduce((acc, n) => {
+ const x = n.pos?.[0] ?? n.x ?? 0;
+ const y = n.pos?.[1] ?? n.y ?? 0;
+ const w = n.size?.[0] ?? n.width ?? 100;
+ const h = n.size?.[1] ?? n.height ?? 60;
+
+ return {
+ minX: Math.min(acc.minX, x),
+ minY: Math.min(acc.minY, y),
+ maxX: Math.max(acc.maxX, x + w),
+ maxY: Math.max(acc.maxY, y + h)
+ };
+ }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
+
+ const width = bounds.maxX - bounds.minX;
+ const height = bounds.maxY - bounds.minY;
+ if (width <= 0 || height <= 0) return;
+
+ const baseScale = Math.min(canvas.width / Math.max(width, 1000), canvas.height / Math.max(height, 1000));
+ const scale = baseScale * zoom;
+
+ ctx.save();
+ ctx.translate(canvas.width / 2 + panX, canvas.height / 2 + panY);
+ ctx.scale(scale, scale);
+ ctx.translate(-(bounds.minX + width/2), -(bounds.minY + height/2));
+
+ // Draw grid
+ ctx.strokeStyle = toRGBA(colors.inputText, 0.1);
+ ctx.lineWidth = 1 / scale;
+ const gridSize = 100;
+ for (let x = Math.floor(bounds.minX / gridSize) * gridSize; x <= bounds.maxX; x += gridSize) {
+ ctx.beginPath();
+ ctx.moveTo(x, bounds.minY);
+ ctx.lineTo(x, bounds.maxY);
+ ctx.stroke();
+ }
+ for (let y = Math.floor(bounds.minY / gridSize) * gridSize; y <= bounds.maxY; y += gridSize) {
+ ctx.beginPath();
+ ctx.moveTo(bounds.minX, y);
+ ctx.lineTo(bounds.maxX, y);
+ ctx.stroke();
+ }
+
+ // Draw nodes
+ nodes.forEach((n, index) => {
+ const x = n.pos?.[0] ?? n.x ?? 0;
+ const y = n.pos?.[1] ?? n.y ?? 0;
+ const w = n.size?.[0] ?? n.width ?? 100;
+ const h = n.size?.[1] ?? n.height ?? 60;
+
+ ctx.fillStyle = getNodeColor(n);
+ ctx.fillRect(x, y, w, h);
+
+ ctx.strokeStyle = toRGBA(colors.inputText, 0.8);
+ ctx.lineWidth = 1 / scale;
+ ctx.strokeRect(x, y, w, h);
+
+ if (scale > 0.3) {
+ ctx.fillStyle = colors.inputText;
+ ctx.font = `${Math.max(10, 12 / scale)}px Arial`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ const title = n.title || n.type || `Node ${index + 1}`;
+ ctx.fillText(title.substring(0, 15), x + w / 2, y + h / 2);
+ }
+ });
+
+ // Draw viewport indicator
+ const app = getApp();
+ if (app?.canvas?.ds) {
+ const ds = app.canvas.ds;
+ const mainCanvas = document.querySelector('canvas');
+
+ if (mainCanvas) {
+ const viewportX = -ds.offset[0];
+ const viewportY = -ds.offset[1];
+ const viewportW = mainCanvas.width / ds.scale;
+ const viewportH = mainCanvas.height / ds.scale;
+
+ ctx.fillStyle = toRGBA(colors.accent || '#4a90e2', 0.12);
+ ctx.fillRect(viewportX, viewportY, viewportW, viewportH);
+
+ ctx.strokeStyle = toRGBA(colors.accent || '#4a90e2', 0.8);
+ ctx.lineWidth = 2 / scale;
+ ctx.strokeRect(viewportX, viewportY, viewportW, viewportH);
+ }
+ }
+
+ ctx.restore();
+ panInfo.textContent = `Nodes: ${nodes.length} | Zoom: ${(zoom * 100).toFixed(0)}% | Pan: ${panX.toFixed(0)}, ${panY.toFixed(0)}`;
+ }
+
+ function navigateToPosition(canvasX, canvasY) {
+ const worldPos = canvasToWorld(canvasX, canvasY);
+ if (!worldPos) return;
+
+ const app = getApp();
+ const mainCanvas = document.querySelector('canvas');
+ if (!app?.canvas?.ds || !mainCanvas) return;
+
+ try {
+ // Center the main canvas on the clicked world position
+ app.canvas.ds.offset[0] = -worldPos.x + mainCanvas.width / 2;
+ app.canvas.ds.offset[1] = -worldPos.y + mainCanvas.height / 2;
+ app.canvas.setDirty(true, true);
+
+ // Update minimap to reflect the change
+ setTimeout(() => drawMinimap(), 50);
+ } catch (err) {
+ console.log("β Navigation error:", err);
+ }
+ }
+
+ function isClickInViewport(canvasX, canvasY) {
+ const app = getApp();
+ if (!app?.canvas?.ds) return false;
+
+ const ds = app.canvas.ds;
+ const mainCanvas = document.querySelector('canvas');
+ if (!mainCanvas) return false;
+
+ const worldPos = canvasToWorld(canvasX, canvasY);
+ if (!worldPos) return false;
+
+ // Check if click is inside viewport rectangle in world coordinates
+ const viewportX = -ds.offset[0];
+ const viewportY = -ds.offset[1];
+ const viewportW = mainCanvas.width / ds.scale;
+ const viewportH = mainCanvas.height / ds.scale;
+
+ return worldPos.x >= viewportX && worldPos.x <= viewportX + viewportW &&
+ worldPos.y >= viewportY && worldPos.y <= viewportY + viewportH;
+ }
+
+ function adjustPanToKeepNodesVisible() {
+ const nodes = getNodes();
+ if (!nodes?.length) return;
+
+ const canvas = currentDialog?.querySelector('canvas');
+ if (!canvas) return;
+
+ // Calculate bounds
+ const bounds = nodes.reduce((acc, n) => {
+ const x = n.pos?.[0] ?? n.x ?? 0;
+ const y = n.pos?.[1] ?? n.y ?? 0;
+ const w = n.size?.[0] ?? n.width ?? 100;
+ const h = n.size?.[1] ?? n.height ?? 60;
+
+ return {
+ minX: Math.min(acc.minX, x),
+ minY: Math.min(acc.minY, y),
+ maxX: Math.max(acc.maxX, x + w),
+ maxY: Math.max(acc.maxY, y + h)
+ };
+ }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
+
+ const width = bounds.maxX - bounds.minX;
+ const height = bounds.maxY - bounds.minY;
+ if (width <= 0 || height <= 0) return;
+
+ const baseScale = Math.min(canvas.width / Math.max(width, 1000), canvas.height / Math.max(height, 1000));
+ const scale = baseScale * zoom;
+
+ // If zoomed in too much, adjust pan to keep nodes centered
+ if (zoom > 2) {
+ const maxPanX = canvas.width / 4;
+ const maxPanY = canvas.height / 4;
+ panX = Math.max(-maxPanX, Math.min(maxPanX, panX));
+ panY = Math.max(-maxPanY, Math.min(maxPanY, panY));
+ }
+ }
+
+ function setupEventHandlers() {
+ const canvas = currentDialog.querySelector('canvas');
+ let isViewportDragging = false;
+
+ // Mouse handlers
+ canvas.addEventListener('mousedown', (e) => {
+ dragStartTime = Date.now();
+ dragStartX = e.clientX;
+ dragStartY = e.clientY;
+
+ const rect = canvas.getBoundingClientRect();
+ const canvasX = e.clientX - rect.left;
+ const canvasY = e.clientY - rect.top;
+
+ // Check if clicking inside viewport indicator
+ isViewportDragging = isClickInViewport(canvasX, canvasY);
+
+ if (isViewportDragging) {
+ canvas.style.cursor = 'grab';
+ }
+ });
+
+ canvas.addEventListener('mousemove', (e) => {
+ if (!dragStartTime) return;
+
+ const totalDelta = Math.abs(e.clientX - dragStartX) + Math.abs(e.clientY - dragStartY);
+
+ if (totalDelta > DRAG_THRESHOLD && !isDragging) {
+ isDragging = true;
+ if (isViewportDragging) {
+ canvas.style.cursor = 'grabbing';
+ } else {
+ canvas.style.cursor = 'move';
+ }
+ }
+
+ if (isDragging) {
+ if (isViewportDragging) {
+ // Move the viewport - convert movement to world coordinates
+ const app = getApp();
+ if (app?.canvas?.ds) {
+ const transform = getTransformState();
+ if (transform) {
+ // Scale movement by the inverse of the minimap scale
+ const movementScale = 1 / transform.scale;
+ app.canvas.ds.offset[0] -= e.movementX * movementScale;
+ app.canvas.ds.offset[1] -= e.movementY * movementScale;
+ app.canvas.setDirty(true, true);
+ }
+ }
+ } else {
+ // Pan the minimap view
+ panX += e.movementX;
+ panY += e.movementY;
+ }
+ drawMinimap();
+ }
+ });
+
+ canvas.addEventListener('mouseup', (e) => {
+ const clickDuration = Date.now() - dragStartTime;
+ const totalMovement = Math.abs(e.clientX - dragStartX) + Math.abs(e.clientY - dragStartY);
+
+ if (!isDragging && clickDuration < 500 && totalMovement < DRAG_THRESHOLD) {
+ if (!isViewportDragging) {
+ // Regular click-to-navigate (only if not clicking viewport)
+ const rect = canvas.getBoundingClientRect();
+ navigateToPosition(e.clientX - rect.left, e.clientY - rect.top);
+ }
+ }
+
+ isDragging = false;
+ isViewportDragging = false;
+ dragStartTime = 0;
+ canvas.style.cursor = 'crosshair';
+ });
+
+ canvas.addEventListener('wheel', (e) => {
+ e.preventDefault();
+ const oldZoom = zoom;
+ zoom = Math.max(0.1, Math.min(5, zoom * (e.deltaY > 0 ? 0.9 : 1.1)));
+
+ // Adjust pan to keep content centered when zooming
+ if (zoom !== oldZoom) {
+ adjustPanToKeepNodesVisible();
+ }
+
+ drawMinimap();
+ });
+
+ // Button handlers
+ currentDialog.querySelector('#close-btn').onclick = () => closeDialog();
+ currentDialog.querySelector('#legend-btn').onclick = () => {
+ const legend = currentDialog.querySelector('.legend');
+ const isVisible = legend.style.display !== 'none';
+ legend.style.display = isVisible ? 'none' : 'block';
+ if (!isVisible) updateLegend();
+ };
+ currentDialog.querySelector('#zoom-in').onclick = () => {
+ zoom = Math.min(zoom * 1.2, 5);
+ adjustPanToKeepNodesVisible();
+ drawMinimap();
+ };
+ currentDialog.querySelector('#zoom-out').onclick = () => {
+ zoom = Math.max(zoom / 1.2, 0.1);
+ adjustPanToKeepNodesVisible();
+ drawMinimap();
+ };
+ currentDialog.querySelector('#zoom-reset').onclick = () => {
+ zoom = 1;
+ panX = panY = 0;
+ drawMinimap();
+ };
+
+ // ESC key
+ const escHandler = (e) => e.key === 'Escape' && closeDialog();
+ document.addEventListener('keydown', escHandler);
+
+ return escHandler;
+ }
+
+ function createMinimapDialog() {
+ if (currentDialog) return;
+
+ const colors = getComfyUIColors();
+ const { containerWidth, containerHeight, canvasWidth, canvasHeight } = calculateDimensions();
+
+ // Clean up existing style
+ document.getElementById('minimap-style')?.remove();
+
+ // Create style tag
+ const style = document.createElement('style');
+ style.id = 'minimap-style';
+ style.textContent = createStyleCSS(colors);
+ document.head.appendChild(style);
+
+ // Create container
+ const container = document.createElement('div');
+ container.id = 'endless-minimap';
+ container.style.cssText = `
+ position: fixed;
+ top: 100px;
+ right: 20px;
+ width: ${containerWidth}px;
+ height: ${containerHeight}px;
+ background: ${colors.menu};
+ color: ${colors.inputText};
+ border: 1px solid ${colors.accent};
+ border-radius: 8px;
+ box-shadow: ${colors.shadow || '0 4px 12px rgba(0, 0, 0, 0.25)'};
+ z-index: 99999;
+ padding: 0;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ `;
+
+ container.innerHTML = `
+
+
Endless πβ¨ Minimap
+
+ π¨
+ β«οΈ
+ π
+ β¬
+ β
+
+
+
+
Nodes: 0 | Zoom: 100% | Pan: 0, 0
+ `;
+
+ document.body.appendChild(container);
+ currentDialog = container;
+
+ // Setup dragging
+ makeDraggable(container, container.querySelector('.drag-bar'));
+
+ // Setup event handlers
+ const escHandler = setupEventHandlers();
+
+ // Setup resize observer
+ resizeObserver = new ResizeObserver(() => {
+ const newAspectRatio = getCanvasAspectRatio();
+ const canvas = container.querySelector('canvas');
+ const currentAspectRatio = canvas.width / canvas.height;
+
+ if (Math.abs(newAspectRatio - currentAspectRatio) > 0.1) {
+ const { containerWidth: newContainerWidth, containerHeight: newContainerHeight, canvasWidth: newCanvasWidth, canvasHeight: newCanvasHeight } = calculateDimensions();
+
+ // Update container size
+ container.style.width = `${newContainerWidth}px`;
+ container.style.height = `${newContainerHeight}px`;
+
+ // Update canvas size
+ canvas.width = newCanvasWidth;
+ canvas.height = newCanvasHeight;
+
+ drawMinimap();
+ }
+ });
+
+ const mainCanvas = document.querySelector('canvas');
+ if (mainCanvas) resizeObserver.observe(mainCanvas);
+ resizeObserver.observe(document.body);
+
+ // Setup theme updates
+ unregisterThemeCallback = onThemeChange(updateTheme);
+
+ // Start animation loop
+ function updateLoop() {
+ drawMinimap();
+ animationId = setTimeout(updateLoop, 1000);
+ }
+ updateLoop();
+
+ // Setup cleanup
+ const originalRemove = container.remove.bind(container);
+ container.remove = function() {
+ clearTimeout(animationId);
+ document.removeEventListener('keydown', escHandler);
+ resizeObserver?.disconnect();
+ unregisterThemeCallback?.();
+ document.getElementById('minimap-style')?.remove();
+ currentDialog = null;
+ animationId = null;
+ unregisterThemeCallback = null;
+ resizeObserver = null;
+ originalRemove();
+ };
+ }
+
+ function closeDialog() {
+ currentDialog?.remove();
+ }
+
+ // Register tool
+ registerEndlessTool("Minimap", createMinimapDialog);
+})();
\ No newline at end of file
diff --git a/web/endless_node_loader.js b/web/endless_node_loader.js
index 67c62db..24eba94 100644
--- a/web/endless_node_loader.js
+++ b/web/endless_node_loader.js
@@ -1,1020 +1,870 @@
-import { registerEndlessTool } from './endless_ui_helpers.js';
+// ComfyUI Endless πβ¨ Node Spawner - Optimized Version
-console.log("β
Endless Node Loader loaded.");
+(function waitForHelpers() {
+ if (typeof window.EndlessHelpers === 'undefined') {
+ console.warn("β³ Waiting for EndlessHelpers to be ready...");
+ setTimeout(waitForHelpers, 100);
+ return;
+ }
-let dialogInstance = null;
+ const {
+ registerEndlessTool,
+ onThemeChange,
+ getComfyUIColors,
+ toRGBA,
+ makeDraggable,
+ addButtonHoverEffects
+ } = window.EndlessHelpers;
-// Set search and recent Nodes
+ console.log("β
Endless Node Spawner loaded.");
-let recentlyUsedNodes = JSON.parse(localStorage.getItem('endlessNodeLoader_recentlyUsed') || '[]');
-let searchHistory = JSON.parse(localStorage.getItem('endlessNodeLoader_searchHistory') || '[]');
+ // State management
+ let currentDialog = null;
+ let unregisterThemeCallback = null;
+ let allNodesData = [];
+ let currentFilter = '';
+ let searchTimeout = null;
+ let hoverTimeout = null;
-// Timeout for search history drop down
-let searchTimeout;
-let hoverTimeout;
+ // Persistent data
+ let recentlyUsedNodes = JSON.parse(localStorage.getItem('endlessNodeLoader_recentlyUsed') || '[]');
+ let searchHistory = JSON.parse(localStorage.getItem('endlessNodeLoader_searchHistory') || '[]');
-// Dragging state
-let isDragging = false;
-let dragOffset = { x: 0, y: 0 };
+ // Constants
+ const MAX_RECENT = 15;
+ const MAX_HISTORY = 15;
+ const DEFAULT_SPACING = { x: 300, y: 150 };
+ const NODE_PADDING = 20;
-// Filter state
-let currentFilter = '';
-let allNodesData = [];
+ function createStyleCSS(colors) {
+ return `
+ .dialog-container {
+ display: flex;
+ flex-direction: column;
+ height: 60vh;
+ width: 35vw;
+ min-width: 400px;
+ min-height: 300px;
+ background: ${colors.menu};
+ color: ${colors.inputText};
+ padding: 10px;
+ border: 1px solid ${colors.border};
+ border-radius: 8px;
+ box-shadow: ${colors.shadow || '0 4px 20px rgba(0,0,0,0.5)'};
+ z-index: 9999;
+ overflow: hidden;
+ box-sizing: border-box;
+ }
+ .dialog-title {
+ margin: 0 0 15px 0;
+ cursor: move;
+ user-select: none;
+ padding: 6px;
+ background: ${colors.menuSecondary};
+ color: ${colors.inputText};
+ border-radius: 4px;
+ font-size: 14px;
+ border-bottom: 1px solid ${colors.border};
+ }
+ .filter-section {
+ flex: 0 0 auto;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-bottom: 8px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid ${colors.border};
+ }
+ .filter-row {
+ position: relative;
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ }
+ .filter-input {
+ flex: 1;
+ background: ${colors.inputBg};
+ color: ${colors.inputText};
+ border: 1px solid ${colors.border};
+ border-radius: 4px;
+ padding: 6px 8px;
+ font-size: 12px;
+ }
+ .filter-input:focus {
+ outline: none;
+ border-color: ${colors.accent};
+ }
+ .expand-btn {
+ background: ${colors.inputBg};
+ color: ${colors.inputText};
+ border: 1px solid ${colors.border};
+ border-radius: 4px;
+ padding: 6px 12px;
+ font-size: 12px;
+ cursor: pointer;
+ }
+ .expand-btn:hover {
+ background: ${colors.hoverBg};
+ border-color: ${colors.accent};
+ }
+ .search-dropdown {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 80px;
+ background: ${colors.menu};
+ border: 1px solid ${colors.border};
+ border-radius: 4px;
+ max-height: 150px;
+ overflow-y: auto;
+ z-index: 10000;
+ display: none;
+ }
+ .search-item {
+ padding: 6px 8px;
+ cursor: pointer;
+ font-size: 12px;
+ border-bottom: 1px solid ${colors.border};
+ }
+ .search-item:last-child { border-bottom: none; }
+ .search-item:hover { background: ${colors.hoverBg}; }
+ .counters {
+ display: flex;
+ justify-content: space-between;
+ font-size: 11px;
+ color: ${colors.descriptionText};
+ }
+ .counter-selected {
+ color: ${colors.accent};
+ font-weight: bold;
+ }
+ .recent-section {
+ flex: 0 0 auto;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ min-height: 30px;
+ max-height: 15%;
+ overflow-y: auto;
+ border-bottom: 1px solid ${colors.border};
+ padding-bottom: 6px;
+ margin-bottom: 6px;
+ }
+ .recent-chip {
+ background: ${toRGBA(colors.accent, 0.1)};
+ color: ${colors.inputText};
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 11px;
+ cursor: pointer;
+ border: 1px solid transparent;
+ transition: all 0.2s ease;
+ }
+ .recent-chip:hover {
+ border-color: ${colors.accent};
+ background: ${toRGBA(colors.accent, 0.2)};
+ }
+ .node-list {
+ flex: 1 1 auto;
+ overflow-y: auto;
+ border-bottom: 1px solid ${colors.border};
+ padding-bottom: 6px;
+ margin-bottom: 6px;
+ }
+ .category {
+ margin-bottom: 4px;
+ }
+ .category > summary {
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 0;
+ list-style: none;
+ color: ${colors.inputText};
+ }
+ .category > summary::-webkit-details-marker { display: none; }
+ .category > summary::before {
+ content: "βΆ";
+ width: 12px;
+ text-align: center;
+ color: ${colors.descriptionText};
+ font-size: 10px;
+ transition: transform 0.2s ease;
+ }
+ .category[open] > summary::before {
+ transform: rotate(90deg);
+ color: ${colors.inputText};
+ }
+ .category > summary:hover {
+ background: ${colors.hoverBg};
+ border-radius: 4px;
+ }
+ .category ul {
+ margin: 4px 0;
+ padding-left: 1em;
+ }
+ .category li:hover {
+ background: ${colors.hoverBg};
+ border-radius: 4px;
+ }
+ .category input[type="checkbox"] {
+ accent-color: ${colors.accent};
+ }
+ .cat-btn {
+ background: ${colors.inputBg};
+ color: ${colors.inputText};
+ border: 1px solid ${colors.border};
+ font-size: 10px;
+ padding: 2px 6px;
+ border-radius: 3px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ }
+ .cat-btn:hover {
+ background: ${colors.hoverBg};
+ border-color: ${colors.accent};
+ }
+ .cat-btn.select { background: ${toRGBA('#4CAF50', 0.1)}; }
+ .cat-btn.deselect { background: ${toRGBA('#f44336', 0.1)}; }
+ .footer {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ }
+ .btn-group {
+ display: flex;
+ gap: 8px;
+ }
+ .dialog-btn {
+ background: ${colors.inputBg};
+ color: ${colors.inputText};
+ border: 1px solid ${colors.border};
+ border-radius: 4px;
+ padding: 6px 12px;
+ font-size: 12px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ }
+ .dialog-btn:hover {
+ background: ${colors.hoverBg};
+ border-color: ${colors.accent};
+ }
+ .dialog-btn.primary {
+ background: ${toRGBA(colors.accent || '#4CAF50', 0.2)};
+ border-color: ${colors.accent || '#4CAF50'};
+ }
+ .dialog-btn.secondary {
+ background: ${toRGBA(colors.errorText || '#f44336', 0.2)};
+ border-color: ${colors.errorText || '#f44336'};
+ }
+ .hidden { display: none !important; }
+ `;
+ }
-function handleDrag(e) {
- if (!isDragging) return;
- const container = dialogInstance;
- if (!container) return;
- const x = e.clientX - dragOffset.x;
- const y = e.clientY - dragOffset.y;
- container.style.left = `${Math.max(0, Math.min(window.innerWidth - container.offsetWidth, x))}px`;
- container.style.top = `${Math.max(0, Math.min(window.innerHeight - container.offsetHeight, y))}px`;
-}
+ function updateTheme() {
+ if (!currentDialog) return;
+
+ const colors = getComfyUIColors();
+ const styleTag = document.getElementById('node-loader-style');
+ if (styleTag) {
+ styleTag.textContent = createStyleCSS(colors);
+ }
+ }
-function handleDragEnd() {
- isDragging = false;
- document.removeEventListener("mousemove", handleDrag);
- document.removeEventListener("mouseup", handleDragEnd);
- document.body.style.userSelect = "";
-}
+ function getApp() {
+ return window.app || window.comfyApp;
+ }
-// Get ComfyUI themes //
+ function getExistingNodePositions() {
+ const app = getApp();
+ const positions = [];
+
+ if (app?.graph?.nodes) {
+ app.graph.nodes.forEach(node => {
+ if (node.pos) {
+ positions.push({
+ x: node.pos[0],
+ y: node.pos[1],
+ width: node.size?.[0] || 200,
+ height: node.size?.[1] || 100
+ });
+ }
+ });
+ }
+ return positions;
+ }
-function getComfyUIThemeColors() {
- const computed = getComputedStyle(document.documentElement);
+ function findNonOverlappingPosition(startX, startY, width, height, existingPositions, spacingX, spacingY) {
+ let x = startX;
+ let y = startY;
+
+ while (true) {
+ let overlaps = false;
+
+ for (const pos of existingPositions) {
+ if (!(x + width + NODE_PADDING < pos.x ||
+ x - NODE_PADDING > pos.x + pos.width ||
+ y + height + NODE_PADDING < pos.y ||
+ y - NODE_PADDING > pos.y + pos.height)) {
+ overlaps = true;
+ break;
+ }
+ }
+
+ if (!overlaps) return { x, y };
+
+ x += spacingX;
+ if (x > startX + spacingX * 5) {
+ x = startX;
+ y += spacingY;
+ }
+ }
+ }
- const getVar = name => computed.getPropertyValue(name).trim() || null;
+ function spawnNodes(types, spacingX = DEFAULT_SPACING.x, spacingY = DEFAULT_SPACING.y) {
+ const app = getApp();
+ if (!app?.graph?.add) {
+ alert("ComfyUI graph not available.");
+ return;
+ }
- return {
- background: getVar("--bg-color"),
- surface: getVar("--comfy-menu-bg"),
- surfaceSecondary: getVar("--comfy-menu-secondary-bg"),
- inputBg: getVar("--comfy-input-bg"),
- inputText: getVar("--input-text"),
- textSecondary: getVar("--descrip-text"),
- border: getVar("--border-color"),
- accent: getVar("--comfy-accent") || "#4a90e2",
- hoverBg: getVar("--content-hover-bg"),
- hoverFg: getVar("--content-hover-fg"),
- shadow: getVar("--bar-shadow")
- };
-}
+ const startX = -app.canvas.ds.offset[0] + 50;
+ const startY = -app.canvas.ds.offset[1] + 50;
+ const existingPositions = getExistingNodePositions();
+
+ const spawnedNodes = [];
+
+ types.forEach((type, i) => {
+ const node = LiteGraph.createNode(type);
+ if (node) {
+ const nodeWidth = node.size?.[0] || 200;
+ const nodeHeight = node.size?.[1] || 100;
+
+ const position = findNonOverlappingPosition(
+ startX + (i % 5) * spacingX,
+ startY + Math.floor(i / 5) * spacingY,
+ nodeWidth,
+ nodeHeight,
+ existingPositions,
+ spacingX,
+ spacingY
+ );
+
+ node.pos = [position.x, position.y];
+ app.graph.add(node);
+ spawnedNodes.push(type);
+
+ existingPositions.push({
+ x: position.x,
+ y: position.y,
+ width: nodeWidth,
+ height: nodeHeight
+ });
+ } else {
+ console.warn(`Could not create node: ${type}`);
+ }
+ });
+
+ updateRecentlyUsedNodes(spawnedNodes);
+ app.graph.setDirtyCanvas(true, true);
+ }
-function addDialogBoxParts() {
- const styleId = 'endless-node-loader-styles';
- if (document.getElementById(styleId)) return;
+ function updateRecentlyUsedNodes(newNodes) {
+ newNodes.forEach(nodeType => {
+ const index = recentlyUsedNodes.indexOf(nodeType);
+ if (index > -1) recentlyUsedNodes.splice(index, 1);
+ recentlyUsedNodes.unshift(nodeType);
+ });
+
+ recentlyUsedNodes = recentlyUsedNodes.slice(0, MAX_RECENT);
+ localStorage.setItem('endlessNodeLoader_recentlyUsed', JSON.stringify(recentlyUsedNodes));
+
+ if (currentDialog) updateRecentChips();
+ }
- const style = document.createElement('style');
- style.id = styleId;
- style.textContent = `
- .dialog-container {
- display: flex;
- flex-direction: column;
- height: 60vh;
- width: 35vw;
- background-color: var(--comfy-menu-bg);
- color: var(--input-text);
- padding: 10px;
- border: 2px solid var(--border-color);
+ function updateRecentChips() {
+ const recentSection = currentDialog.querySelector('.recent-section');
+ if (!recentSection) return;
+
+ recentSection.innerHTML = '';
+
+ recentlyUsedNodes.forEach(nodeType => {
+ const chip = document.createElement('button');
+ chip.className = 'recent-chip';
+
+ const nodeClass = LiteGraph.registered_node_types[nodeType];
+ const displayName = nodeClass?.title || nodeClass?.name || nodeType.split("/").pop();
+ chip.textContent = displayName;
+ chip.title = nodeType;
+
+ chip.onclick = () => {
+ const checkbox = Array.from(currentDialog.querySelectorAll('.node-checkbox')).find(cb => {
+ return cb.closest('li').dataset.nodeType === nodeType;
+ });
+ if (checkbox) {
+ checkbox.checked = true;
+ updateSelectedCounter();
+ }
+ };
+
+ recentSection.appendChild(chip);
+ });
+ }
+
+ function addToSearchHistory(searchTerm) {
+ if (!searchTerm.trim() || searchHistory.includes(searchTerm)) return;
+
+ searchHistory.unshift(searchTerm);
+ searchHistory = searchHistory.slice(0, MAX_HISTORY);
+ localStorage.setItem('endlessNodeLoader_searchHistory', JSON.stringify(searchHistory));
+ }
+
+ function showSearchHistory(inputElement) {
+ const dropdown = inputElement.parentElement.querySelector('.search-dropdown');
+ if (!dropdown || searchHistory.length === 0) {
+ if (dropdown) dropdown.style.display = 'none';
+ return;
+ }
+
+ dropdown.innerHTML = '';
+ searchHistory.forEach(term => {
+ const item = document.createElement('div');
+ item.className = 'search-item';
+ item.textContent = term;
+ item.onclick = () => {
+ inputElement.value = term;
+ applyFilter(term, true);
+ hideSearchHistory(dropdown);
+ };
+ dropdown.appendChild(item);
+ });
+
+ dropdown.style.display = 'block';
+
+ setTimeout(() => {
+ if (dropdown.style.display === 'block') {
+ hideSearchHistory(dropdown);
+ }
+ }, 10000);
+ }
+
+ function hideSearchHistory(dropdown) {
+ dropdown.style.display = 'none';
+ }
+
+ function applyFilter(filterText, saveToHistory = true) {
+ currentFilter = filterText.toLowerCase();
+ const nodeList = currentDialog.querySelector('.node-list');
+
+ if (!currentFilter) {
+ nodeList.querySelectorAll('.category, .category li').forEach(el => {
+ el.classList.remove('hidden');
+ });
+ updateTotalCounter();
+ return;
+ }
+
+ nodeList.querySelectorAll('.category').forEach(details => {
+ const categoryName = details.querySelector('summary span').textContent.toLowerCase();
+ const categoryMatches = categoryName.includes(currentFilter);
+
+ let hasMatchingNodes = false;
+ const nodeItems = details.querySelectorAll('li');
+
+ nodeItems.forEach(li => {
+ const nodeText = li.textContent.toLowerCase();
+ const nodeType = li.dataset.nodeType?.toLowerCase() || '';
+ const matches = nodeText.includes(currentFilter) || nodeType.includes(currentFilter);
+
+ if (matches) {
+ li.classList.remove('hidden');
+ hasMatchingNodes = true;
+ } else {
+ li.classList.add('hidden');
+ }
+ });
+
+ if (categoryMatches || hasMatchingNodes) {
+ details.classList.remove('hidden');
+ if (hasMatchingNodes && !categoryMatches) {
+ details.open = true;
+ }
+ } else {
+ details.classList.add('hidden');
+ }
+ });
+
+ updateTotalCounter();
+ }
+
+ function updateSelectedCounter() {
+ const counter = currentDialog.querySelector('.counter-selected');
+ if (!counter) return;
+
+ const checkedBoxes = currentDialog.querySelectorAll('.node-checkbox:checked');
+ counter.textContent = `Selected: ${checkedBoxes.length}`;
+ }
+
+ function updateTotalCounter() {
+ const counter = currentDialog.querySelector('.counter-total');
+ if (!counter) return;
+
+ const visibleNodes = currentDialog.querySelectorAll('.category li:not(.hidden)');
+ counter.textContent = `Total: ${visibleNodes.length}/${allNodesData.length}`;
+ }
+
+ function toggleAllCategories(expand) {
+ const details = currentDialog.querySelectorAll('.category:not(.hidden)');
+ details.forEach(detail => {
+ detail.open = expand;
+ });
+ }
+
+ function buildHierarchy(nodes) {
+ const root = {};
+ nodes.forEach(n => {
+ let current = root;
+ n.pathParts.forEach((part, idx) => {
+ if (!current[part]) {
+ current[part] = { _nodes: [], _subcategories: {} };
+ }
+ if (idx === n.pathParts.length - 1) {
+ current[part]._nodes.push(n);
+ } else {
+ current = current[part]._subcategories;
+ }
+ });
+ });
+ return root;
+ }
+
+ function countNodesInCategory(categoryObj) {
+ let count = categoryObj._nodes?.length || 0;
+ if (categoryObj._subcategories) {
+ Object.values(categoryObj._subcategories).forEach(sub => {
+ count += countNodesInCategory(sub);
+ });
+ }
+ return count;
+ }
+
+ function selectAllInCategory(categoryDetails, select = true) {
+ const checkboxes = categoryDetails.querySelectorAll("input[type='checkbox']");
+ checkboxes.forEach(checkbox => {
+ checkbox.checked = select;
+ });
+ updateSelectedCounter();
+ }
+
+ function renderCategory(categoryObj, depth = 0) {
+ return Object.entries(categoryObj)
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([cat, obj]) => {
+ const totalNodes = countNodesInCategory(obj);
+
+ const details = document.createElement("details");
+ details.className = "category";
+ details.style.paddingLeft = `${depth * 1.2}em`;
+
+ const summary = document.createElement("summary");
+
+ const categoryName = document.createElement("span");
+ categoryName.textContent = `${cat} (${totalNodes})`;
+
+ const selectAllBtn = document.createElement("button");
+ selectAllBtn.textContent = "All";
+ selectAllBtn.className = "cat-btn select";
+ selectAllBtn.onclick = (e) => {
+ e.stopPropagation();
+ selectAllInCategory(details, true);
+ };
+
+ const selectNoneBtn = document.createElement("button");
+ selectNoneBtn.textContent = "None";
+ selectNoneBtn.className = "cat-btn deselect";
+ selectNoneBtn.onclick = (e) => {
+ e.stopPropagation();
+ selectAllInCategory(details, false);
+ };
+
+ summary.appendChild(categoryName);
+ summary.appendChild(selectAllBtn);
+ summary.appendChild(selectNoneBtn);
+ details.appendChild(summary);
+
+ const list = document.createElement("ul");
+ (obj._nodes || []).forEach(node => {
+ const li = document.createElement("li");
+ li.dataset.nodeType = node.type;
+
+ const checkbox = document.createElement("input");
+ checkbox.type = "checkbox";
+ checkbox.className = "node-checkbox";
+ checkbox.onchange = updateSelectedCounter;
+
+ const label = document.createElement("label");
+ label.textContent = node.displayName;
+
+ li.appendChild(checkbox);
+ li.appendChild(label);
+ list.appendChild(li);
+ });
+
+ details.appendChild(list);
+
+ const subCategories = renderCategory(obj._subcategories || {}, depth + 1);
+ subCategories.forEach(sub => details.appendChild(sub));
+
+ return details;
+ });
+ }
+
+ function getSelectedNodeTypes() {
+ const selected = [];
+ currentDialog.querySelectorAll('.node-checkbox:checked').forEach(checkbox => {
+ const nodeType = checkbox.closest('li').dataset.nodeType;
+ if (nodeType) selected.push(nodeType);
+ });
+ return selected;
+ }
+
+ function clearSelectedNodes() {
+ currentDialog.querySelectorAll('.node-checkbox:checked').forEach(checkbox => {
+ checkbox.checked = false;
+ });
+ updateSelectedCounter();
+ }
+
+ function setupEventHandlers() {
+ const filterInput = currentDialog.querySelector('.filter-input');
+ const searchDropdown = currentDialog.querySelector('.search-dropdown');
+ const expandBtn = currentDialog.querySelector('.expand-btn');
+
+ let isExpanded = false;
+
+ // Filter input handlers
+ filterInput.oninput = (e) => applyFilter(e.target.value, false);
+
+ filterInput.onkeydown = (e) => {
+ if (e.key === 'Enter' && e.target.value.trim()) {
+ addToSearchHistory(e.target.value.trim());
+ hideSearchHistory(searchDropdown);
+ } else if (e.key === 'ArrowDown' && searchHistory.length > 0) {
+ showSearchHistory(filterInput);
+ e.preventDefault();
+ }
+ };
+
+ filterInput.onfocus = (e) => {
+ if (!e.target.value.trim()) {
+ showSearchHistory(filterInput);
+ }
+ };
+
+ filterInput.onblur = (e) => {
+ if (e.target.value.trim()) {
+ addToSearchHistory(e.target.value.trim());
+ }
+ if (hoverTimeout) {
+ clearTimeout(hoverTimeout);
+ hoverTimeout = null;
+ }
+ setTimeout(() => hideSearchHistory(searchDropdown), 150);
+ };
+
+ filterInput.onmouseenter = () => {
+ if (searchTimeout) {
+ clearTimeout(searchTimeout);
+ searchTimeout = null;
+ }
+
+ hoverTimeout = setTimeout(() => {
+ if (searchHistory.length > 0) {
+ showSearchHistory(filterInput);
+ }
+ }, 1000);
+ };
+
+ filterInput.onmouseleave = () => {
+ if (hoverTimeout) {
+ clearTimeout(hoverTimeout);
+ hoverTimeout = null;
+ }
+
+ searchTimeout = setTimeout(() => {
+ hideSearchHistory(searchDropdown);
+ }, 10000);
+ };
+
+ // Expand button
+ expandBtn.onclick = () => {
+ isExpanded = !isExpanded;
+ toggleAllCategories(isExpanded);
+ expandBtn.textContent = isExpanded ? "Collapse All" : "Expand All";
+ };
+
+ // Button handlers
+ currentDialog.querySelector('#spawn-btn').onclick = () => {
+ const selectedTypes = getSelectedNodeTypes();
+ if (selectedTypes.length === 0) {
+ alert("Please select at least one node to spawn.");
+ return;
+ }
+ spawnNodes(selectedTypes);
+ closeDialog();
+ };
+
+ currentDialog.querySelector('#clear-btn').onclick = clearSelectedNodes;
+ currentDialog.querySelector('#cancel-btn').onclick = closeDialog;
+ currentDialog.querySelector('#clear-history-btn').onclick = () => {
+ searchHistory = [];
+ localStorage.setItem('endlessNodeLoader_searchHistory', JSON.stringify(searchHistory));
+ const dropdown = currentDialog.querySelector('.search-dropdown');
+ if (dropdown) dropdown.style.display = 'none';
+ };
+ currentDialog.querySelector('#clear-recent-btn').onclick = () => {
+ recentlyUsedNodes = [];
+ localStorage.setItem('endlessNodeLoader_recentlyUsed', JSON.stringify(recentlyUsedNodes));
+ updateRecentChips(); // This will clear the chips
+ };
+
+ // ESC key handler
+ const escHandler = (e) => e.key === 'Escape' && closeDialog();
+ document.addEventListener('keydown', escHandler);
+
+ return escHandler;
+ }
+
+ function createNodeLoaderDialog() {
+ if (currentDialog) return;
+
+ const colors = getComfyUIColors();
+
+ // Clean up existing style
+ document.getElementById('node-loader-style')?.remove();
+
+ // Create style tag
+ const style = document.createElement('style');
+ style.id = 'node-loader-style';
+ style.textContent = createStyleCSS(colors);
+ document.head.appendChild(style);
+
+ // Create container
+ const container = document.createElement("div");
+ // Calculate max size based on viewport
+ const maxWidth = Math.min(window.innerWidth * 0.4, 1536); // 40% of window width, max 1536px
+ const maxHeight = Math.min(window.innerHeight * 0.6, 1296); // 60% of window height, max 1296px
+
+ container.className = "dialog-container";
+ container.style.cssText = `
position: fixed;
top: 10%;
left: 50%;
transform: translateX(-50%);
- z-index: 9999;
- border-radius: 8px;
- box-shadow: var(--bar-shadow, 0 4px 20px rgba(0,0,0,0.5));
- pointer-events: auto;
- overflow: hidden;
- box-sizing: border-box;
- }
- .dialog-title {
- margin: 0 0 15px 0;
- cursor: move;
- user-select: none;
- padding: 4px;
- background-color: var(--comfy-menu-secondary-bg);
- color: var(--input-text);
- border-radius: 4px;
- font-size: 14px;
- border-bottom: 1px solid var(--border-color);
- padding-bottom: 6px;
- margin-bottom: 6px;
- }
- .dialog-filter-section {
- flex: 0 0 auto;
- display: flex;
- flex-direction: column;
- gap: 8px;
- margin-bottom: 8px;
- padding-bottom: 8px;
- border-bottom: 1px solid var(--border-color);
- }
- .filter-input-container {
- position: relative;
- display: flex;
- gap: 8px;
- align-items: center;
- }
- .filter-input {
- flex: 1;
- background-color: var(--comfy-input-bg);
- color: var(--input-text);
- border: 1px solid var(--border-color);
- border-radius: 4px;
- padding: 6px 8px;
- font-size: 12px;
- }
- .filter-input:focus {
- outline: none;
- border-color: var(--comfy-accent);
- }
- .filter-expand-btn {
- background-color: var(--comfy-input-bg);
- color: var(--input-text);
- border: 1px solid var(--border-color);
- border-radius: 4px;
- padding: 6px 12px;
- font-size: 12px;
- cursor: pointer;
- white-space: nowrap;
- }
- .filter-expand-btn:hover {
- background-color: var(--content-hover-bg);
- }
- .search-history-dropdown {
- position: absolute;
- top: 100%;
- left: 0;
- right: 0;
- background-color: var(--comfy-menu-bg);
- border: 1px solid var(--border-color);
- border-radius: 4px;
- max-height: 1050px;
- overflow-y: auto;
- z-index: 10000;
- display: none;
- }
- .search-history-item {
- padding: 6px 8px;
- cursor: pointer;
- font-size: 12px;
- border-bottom: 1px solid var(--border-color);
- }
- .search-history-item:last-child {
- border-bottom: none;
- }
- .search-history-item:hover {
- background-color: var(--comfy-input-bg);
- }
- .node-counters {
- display: flex;
- justify-content: space-between;
- font-size: 11px;
- color: var(--descrip-text);
- }
- .counter-selected {
- color: var(--comfy-accent);
- font-weight: bold;
- }
- .dialog-recent {
- flex: 0 0 auto;
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- min-height: 30px;
- max-height: 15%;
- overflow-y: auto;
- box-sizing: border-box;
- border-bottom: 1px solid var(--border-color);
- padding-bottom: 6px;
- margin-bottom: 6px;
+ max-width: ${maxWidth}px;
+ max-height: ${maxHeight}px;
+ `;
+
+ container.innerHTML = `
+
Endless πβ¨ Node Spawner Drag Bar
- }
- .recent-chip {
- display: inline-block;
- background-color: var(--comfy-menu-secondary-bg);
- color: var(--input-text);
- padding: 4px 8px;
- border-radius: 4px;
- font-size: 11px;
- cursor: pointer;
- white-space: nowrap;
- flex: 0 0 auto;
- border: 1px solid transparent;
- background-image: linear-gradient(rgba(128, 128, 255, 0.08), rgba(128, 128, 255, 0.08));
- }
- .recent-chip:hover {
- border-color: var(--comfy-accent);
- background-color: var(--comfy-input-bg);
- background-image: linear-gradient(rgba(128, 128, 255, 0.12), rgba(128, 128, 255, 0.12));
- transition: border-color 0.2s ease;
- }
- .dialog-list {
- flex: 1 1 auto;
- min-height: 40%;
- overflow-y: auto;
- box-sizing: border-box;
- border-bottom: 1px solid var(--border-color);
- padding-bottom: 6px;
- margin-bottom: 6px;
- }
- .node-details {
- margin-bottom: 4px;
- }
+
+
+
+ Selected: 0
+ Total: 0
+
+
- .node-details > summary {
- cursor: pointer;
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 4px 0;
- list-style: none;
- position: relative;
- color: var(--input-text);
- }
+
+
- .node-details > summary::-webkit-details-marker {
- display: none;
- }
+
+ `;
- .node-details > summary::before {
- content: "βΆ";
- display: inline-block;
- width: 12px;
- text-align: center;
- color: var(--descrip-text);
- font-size: 10px;
- transition: transform 0.2s ease;
- margin-right: 4px;
- }
+ document.body.appendChild(container);
+ currentDialog = container;
- .node-details[open] > summary::before {
- transform: rotate(90deg);
- color: var(--input-text);
- }
+ // Setup dragging
+ makeDraggable(container, container.querySelector('.dialog-title'));
- .node-details > summary:hover::before {
- color: var(--input-text);
- }
+ // Build node data
+ const nodes = Object.entries(LiteGraph.registered_node_types)
+ .filter(([key, value]) => key && value)
+ .map(([type, nodeClass]) => {
+ const category = nodeClass.category || "Other";
+ const displayName = nodeClass.title || nodeClass.name || type.split("/").pop();
+ return {
+ type,
+ category,
+ pathParts: category.split("/"),
+ displayName,
+ description: nodeClass.desc || nodeClass.description || "",
+ fullPath: category + "/" + displayName
+ };
+ })
+ .sort((a, b) => a.category.localeCompare(b.category) || a.displayName.localeCompare(b.displayName));
- .node-details > summary:hover {
- background-color: var(--comfy-input-bg);
- border-radius: 4px;
- }
-
- .node-details ul {
- margin: 4px 0;
- padding-left: 1em;
- }
-
- .node-details li:hover {
- background-color: var(--comfy-menu-secondary-bg);
- border-radius: 4px;
- }
-
- .node-details input[type="checkbox"] {
- accent-color: var(--comfy-accent);
- }
-
- .node-details button {
- background-color: var(--comfy-input-bg);
- color: var(--input-text);
- border: 1px solid var(--border-color);
- font-size: 10px;
- padding: 2px 6px;
- border-radius: 3px;
- cursor: pointer;
- }
-
- .node-details button:hover {
- background-color: var(--content-hover-bg);
- }
-
- .node-details .select-all-btn {
- background-color: var(--comfy-input-bg);
- color: var(--input-text);
- border: 1px solid var(--border-color);
- font-size: 10px;
- padding: 2px 6px;
- border-radius: 3px;
- cursor: pointer;
- background-image: linear-gradient(rgba(128, 255, 128, 0.08), rgba(128, 255, 128, 0.08));
- }
-
- .node-details .select-all-btn:hover {
- background-color: var(--content-hover-bg);
- border-color: var(--comfy-accent);
- background-image: linear-gradient(rgba(128, 255, 128, 0.12), rgba(128, 255, 128, 0.12));
- transition: border-color 0.2s ease;
- }
-
- .node-details .select-none-btn {
- background-color: var(--comfy-input-bg);
- color: var(--input-text);
- border: 1px solid var(--border-color);
- font-size: 10px;
- padding: 2px 6px;
- border-radius: 3px;
- cursor: pointer;
- background-image: linear-gradient(rgba(255, 128, 128, 0.08), rgba(255, 128, 128, 0.08));
- }
+ allNodesData = nodes;
- .node-details .select-none-btn:hover {
- background-color: var(--content-hover-bg);
- border-color: var(--comfy-accent);
- background-image: linear-gradient(rgba(255, 128, 128, 0.12), rgba(255, 128, 128, 0.12));
- transition: border-color 0.2s ease;
- }
+ // Render hierarchy
+ const hierarchy = buildHierarchy(nodes);
+ const tree = renderCategory(hierarchy);
+ const nodeList = container.querySelector('.node-list');
+ tree.forEach(section => nodeList.appendChild(section));
- .dialog-footer {
- display: flex;
- justify-content: space-between;
- padding: 8px;
- gap: 8px;
- }
- .dialog-btn {
- background-color: var(--comfy-input-bg);
- color: var(--input-text);
- border: 1px solid var(--border-color);
- border-radius: 4px;
- padding: 6px 12px;
- font-size: 12px;
- cursor: pointer;
- }
- .dialog-btn:hover {
- background-color: var(--content-hover-bg);
- border-color: var(--comfy-accent);
- transition: border-color 0.2s ease;
- }
- .dialog-btn.primary {
- background-color: var(--comfy-input-bg);
- color: var(--input-text);
- background-image: linear-gradient(rgba(128, 255, 128, 0.08), rgba(128, 255, 128, 0.08));
- border: 1px solid rgba(128, 255, 128, 0.12);
- }
- .dialog-btn.primary:hover {
- opacity: 0.9;
- background-color: var(--content-hover-bg);
- border-color: var(--comfy-accent);
- transition: border-color 0.2s ease;
- background-image: linear-gradient(rgba(128, 255, 128, 0.12), rgba(128, 255, 128, 0.12));
- }
+ // Add hover effects
+ addButtonHoverEffects(container);
- .dialog-btn.sec {
- background-color: var(--comfy-input-bg);
- color: var(--input-text);
- background-image: linear-gradient(rgba(255, 128, 128, 0.08), rgba(255, 128, 128, 0.08));
- border: 1px solid rgba(255, 128, 128, 0.12);
- }
- .dialog-btn.sec:hover {
- opacity: 0.9;
- background-color: var(--content-hover-bg);
- border-color: var(--comfy-accent);
- transition: border-color 0.2s ease;
- background-image: linear-gradient(rgba(255, 128, 128, 0.12), rgba(255, 128, 128, 0.12));
- }
+ // Setup event handlers
+ const escHandler = setupEventHandlers();
- .hidden {
- display: none !important;
- }
- `;
- document.head.appendChild(style);
-}
+ // Setup theme updates
+ unregisterThemeCallback = onThemeChange(updateTheme);
-// Node spawning functions
-function spawnNodes(types, spacingX = 300, spacingY = 150) {
- if (!window.app?.graph?.add) {
- alert("ComfyUI graph not available.");
- return;
- }
- const startX = -app.canvas.ds.offset[0] + 50;
- const startY = -app.canvas.ds.offset[1] + 50;
- const existingPositions = getExistingNodePositions();
-
- const spawnedNodes = [];
- types.forEach((type, i) => {
- const node = LiteGraph.createNode(type);
- if (node) {
- const nodeWidth = node.size ? node.size[0] : 200;
- const nodeHeight = node.size ? node.size[1] : 100;
-
- // Use collision detection
- const position = findNonOverlappingPosition(
- startX + (i % 5) * spacingX,
- startY + Math.floor(i / 5) * spacingY,
- nodeWidth,
- nodeHeight,
- existingPositions,
- spacingX,
- spacingY
- );
-
- node.pos = [position.x, position.y];
- app.graph.add(node);
- spawnedNodes.push(type);
-
- // Add this node's position to existing positions for next iteration
- existingPositions.push({
- x: position.x,
- y: position.y,
- width: nodeWidth,
- height: nodeHeight
- });
- } else {
- console.warn(`Could not create node: ${type}`);
- }
- });
-
- // Update recently used nodes
- updateRecentlyUsedNodes(spawnedNodes);
-
- app.graph.setDirtyCanvas(true, true);
-}
-
-function getExistingNodePositions() {
- const positions = [];
- if (window.app?.graph?.nodes) {
- window.app.graph.nodes.forEach(node => {
- if (node.pos) {
- positions.push({
- x: node.pos[0],
- y: node.pos[1],
- width: node.size ? node.size[0] : 200,
- height: node.size ? node.size[1] : 100
- });
- }
- });
- }
- return positions;
-}
-
-function findNonOverlappingPosition(startX, startY, width, height, existingPositions, spacingX, spacingY) {
- const padding = 20;
- let x = startX;
- let y = startY;
-
- while (true) {
- let overlaps = false;
-
- for (const pos of existingPositions) {
- if (!(x + width + padding < pos.x ||
- x - padding > pos.x + pos.width ||
- y + height + padding < pos.y ||
- y - padding > pos.y + pos.height)) {
- overlaps = true;
- break;
- }
- }
-
- if (!overlaps) {
- return { x, y };
- }
-
- // Move to next position
- x += spacingX;
- if (x > startX + spacingX * 5) { // After 5 columns, move to next row
- x = startX;
- y += spacingY;
- }
- }
-}
-
-// Recently used nodes management
-function updateRecentlyUsedNodes(newNodes) {
- newNodes.forEach(nodeType => {
- // Remove if already exists
- const index = recentlyUsedNodes.indexOf(nodeType);
- if (index > -1) {
- recentlyUsedNodes.splice(index, 1);
- }
- // Add to beginning
- recentlyUsedNodes.unshift(nodeType);
- });
-
- // Keep only last 15
- recentlyUsedNodes = recentlyUsedNodes.slice(0, 15);
- localStorage.setItem('endlessNodeLoader_recentlyUsed', JSON.stringify(recentlyUsedNodes));
-
- // Update chips if dialog is open
- if (dialogInstance) {
+ // Initialize
updateRecentChips();
- }
-}
-
-function updateRecentChips() {
- const recentSection = dialogInstance.querySelector('.dialog-recent');
- if (!recentSection) return;
-
- recentSection.innerHTML = '';
-
- recentlyUsedNodes.forEach(nodeType => {
- const chip = document.createElement('button');
- chip.className = 'recent-chip';
-
- // Get display name
- const nodeClass = LiteGraph.registered_node_types[nodeType];
- const displayName = nodeClass?.title || nodeClass?.name || nodeType.split("/").pop();
- chip.textContent = displayName;
- chip.title = nodeType; // Full type as tooltip
-
- chip.onclick = () => {
- // Check this node type in the list
- const checkbox = Array.from(dialogInstance.querySelectorAll('.node-checkbox')).find(cb => {
- return cb.closest('li').dataset.nodeType === nodeType;
- });
- if (checkbox) {
- checkbox.checked = true;
- updateSelectedCounter();
- }
- };
-
- recentSection.appendChild(chip);
- });
-}
-
-// Search history management
-function addToSearchHistory(searchTerm) {
- if (!searchTerm.trim() || searchHistory.includes(searchTerm)) return;
-
- searchHistory.unshift(searchTerm);
- searchHistory = searchHistory.slice(0, 15);
- localStorage.setItem('endlessNodeLoader_searchHistory', JSON.stringify(searchHistory));
-}
-
-function showSearchHistory(inputElement) {
- const dropdown = inputElement.parentElement.querySelector('.search-history-dropdown');
- if (!dropdown || searchHistory.length === 0) {
- if (dropdown) dropdown.style.display = 'none';
- return;
- }
-
- dropdown.innerHTML = '';
- searchHistory.forEach(term => {
- const item = document.createElement('div');
- item.className = 'search-history-item';
- item.textContent = term;
- item.onclick = () => {
- inputElement.value = term;
- applyFilter(term, true); // Save this selection to history
- hideSearchHistory(dropdown);
- };
- dropdown.appendChild(item);
- });
-
- dropdown.style.display = 'block';
-
- // Auto-hide after 10 seconds if no interaction
- setTimeout(() => {
- if (dropdown.style.display === 'block') {
- hideSearchHistory(dropdown);
- }
- }, 10000);
-}
-
-function hideSearchHistory(dropdown) {
- dropdown.style.display = 'none';
-}
-
-// Filter functionality
-function applyFilter(filterText, saveToHistory = true) {
- currentFilter = filterText.toLowerCase();
- const nodeListSection = dialogInstance.querySelector('.dialog-list');
-
- if (!currentFilter) {
- // Show all
- nodeListSection.querySelectorAll('.node-details, .node-details li').forEach(el => {
- el.classList.remove('hidden');
- });
+ updateSelectedCounter();
updateTotalCounter();
- return;
- }
-
- // Filter logic
- nodeListSection.querySelectorAll('.node-details').forEach(details => {
- const categoryName = details.querySelector('summary span').textContent.toLowerCase();
- const categoryMatches = categoryName.includes(currentFilter);
- let hasMatchingNodes = false;
- const nodeItems = details.querySelectorAll('li');
-
- nodeItems.forEach(li => {
- const nodeText = li.textContent.toLowerCase();
- const nodeType = li.dataset.nodeType?.toLowerCase() || '';
- const matches = nodeText.includes(currentFilter) || nodeType.includes(currentFilter);
-
- if (matches) {
- li.classList.remove('hidden');
- hasMatchingNodes = true;
- } else {
- li.classList.add('hidden');
- }
- });
-
- // Show category if it matches or has matching nodes
- if (categoryMatches || hasMatchingNodes) {
- details.classList.remove('hidden');
- if (hasMatchingNodes && !categoryMatches) {
- details.open = true; // Auto-expand if contains matches
- }
- } else {
- details.classList.add('hidden');
- }
- });
-
- updateTotalCounter();
-}
+ // Focus filter input
+ container.querySelector('.filter-input').focus();
-// Counter management
-function updateSelectedCounter() {
- const counter = dialogInstance.querySelector('.counter-selected');
- if (!counter) return;
-
- const checkedBoxes = dialogInstance.querySelectorAll('.node-checkbox:checked');
- counter.textContent = `Selected: ${checkedBoxes.length}`;
-}
-
-function updateTotalCounter() {
- const counter = dialogInstance.querySelector('.counter-total');
- if (!counter) return;
-
- const visibleNodes = dialogInstance.querySelectorAll('.node-details li:not(.hidden)');
- const totalNodes = allNodesData.length;
- counter.textContent = `Total: ${visibleNodes.length}/${totalNodes}`;
-}
-
-// Expand/collapse functionality
-function toggleAllCategories(expand) {
- const details = dialogInstance.querySelectorAll('.node-details:not(.hidden)');
- details.forEach(detail => {
- detail.open = expand;
- });
-}
-
-// Build node list and hierarchy //
-
-function buildHierarchy(nodes) {
- const root = {};
- nodes.forEach(n => {
- let current = root;
- n.pathParts.forEach((part, idx) => {
- if (!current[part]) {
- current[part] = { _nodes: [], _subcategories: {} };
- }
- if (idx === n.pathParts.length - 1) {
- current[part]._nodes.push(n);
- } else {
- current = current[part]._subcategories;
- }
- });
- });
- return root;
-}
-
-function countNodesInCategory(categoryObj) {
- let count = 0;
- count += categoryObj._nodes ? categoryObj._nodes.length : 0;
- if (categoryObj._subcategories) {
- Object.values(categoryObj._subcategories).forEach(sub => {
- count += countNodesInCategory(sub);
- });
- }
- return count;
-}
-
-function selectAllInCategory(categoryDetails, select = true) {
- const items = categoryDetails.querySelectorAll("input[type='checkbox']");
- items.forEach(checkbox => {
- checkbox.checked = select;
- });
- updateSelectedCounter();
-}
-
-function renderCategory(categoryObj, depth = 0) {
- return Object.entries(categoryObj)
- .sort(([a], [b]) => a.localeCompare(b))
- .map(([cat, obj]) => {
- const totalNodes = countNodesInCategory(obj);
-
- const details = document.createElement("details");
- details.open = false;
- details.className = "node-details";
- details.style.paddingLeft = `${depth * 1.2}em`;
-
- const summary = document.createElement("summary");
-
- const categoryName = document.createElement("span");
- categoryName.textContent = `${cat} (${totalNodes})`;
-
- const selectAllBtn = document.createElement("button");
- selectAllBtn.textContent = "All";
- selectAllBtn.className = "select-all-btn";
- selectAllBtn.onclick = (e) => {
- e.stopPropagation();
- selectAllInCategory(details, true);
- };
-
- const selectNoneBtn = document.createElement("button");
- selectNoneBtn.textContent = "None";
- selectNoneBtn.className = "select-none-btn";
- selectNoneBtn.onclick = (e) => {
- e.stopPropagation();
- selectAllInCategory(details, false);
- };
-
- summary.appendChild(categoryName);
- summary.appendChild(selectAllBtn);
- summary.appendChild(selectNoneBtn);
- details.appendChild(summary);
-
- const list = document.createElement("ul");
- list.className = "node-list";
-
- (obj._nodes || []).forEach(node => {
- const li = document.createElement("li");
- li.dataset.nodeType = node.type; // Store node type for filtering
-
- const checkbox = document.createElement("input");
- checkbox.type = "checkbox";
- checkbox.className = "node-checkbox";
- checkbox.onchange = updateSelectedCounter;
-
- const label = document.createElement("label");
- label.textContent = node.displayName;
-
- li.appendChild(checkbox);
- li.appendChild(label);
- list.appendChild(li);
- });
-
- details.appendChild(list);
-
- const subCategories = renderCategory(obj._subcategories || {}, depth + 1);
- subCategories.forEach(sub => details.appendChild(sub));
-
- return details;
- });
-}
-
-function getSelectedNodeTypes() {
- const selected = [];
- dialogInstance.querySelectorAll('.node-checkbox:checked').forEach(checkbox => {
- const nodeType = checkbox.closest('li').dataset.nodeType;
- if (nodeType) selected.push(nodeType);
- });
- return selected;
-}
-
-function clearSelectedNodes() {
- dialogInstance.querySelectorAll('.node-checkbox:checked').forEach(checkbox => {
- checkbox.checked = false;
- });
- updateSelectedCounter();
-}
-
-function showNodeLoaderDialog() {
- //Overall box //
- if (dialogInstance) return;
- addDialogBoxParts();
-
- const container = document.createElement("div");
- container.className = "dialog-container";
- const theme = getComfyUIThemeColors();
-
-// ESC key to close
- const handleKeyDown = (e) => {
- if (e.key === 'Escape') {
- closeDialog();
- }
- };
-
- const closeDialog = () => {
- document.removeEventListener('keydown', handleKeyDown);
- document.body.removeChild(container);
- dialogInstance = null;
- };
-
-document.addEventListener('keydown', handleKeyDown);
-
- //Title Section //
- const title = document.createElement("h3");
- title.textContent = "Endless πβ¨ Node Loader";
- title.className = "dialog-title";
- container.appendChild(title);
-
- title.addEventListener("mousedown", (e) => {
- isDragging = true;
- container.style.transform = "none"; // π¨ Disable center alignment during drag
- const rect = container.getBoundingClientRect();
- dragOffset.x = e.clientX - rect.left;
- dragOffset.y = e.clientY - rect.top;
- document.addEventListener("mousemove", handleDrag);
- document.body.style.userSelect = "none";
- document.addEventListener("mouseup", handleDragEnd);
- e.preventDefault();
- });
-
- // Filter Section
- const filterSection = document.createElement("div");
- filterSection.className = "dialog-filter-section";
-
- const filterInputContainer = document.createElement("div");
- filterInputContainer.className = "filter-input-container";
-
- const filterInput = document.createElement("input");
- filterInput.type = "text";
- filterInput.className = "filter-input";
- filterInput.placeholder = "Filter nodes...";
-
- const searchHistoryDropdown = document.createElement("div");
- searchHistoryDropdown.className = "search-history-dropdown";
-
- filterInput.oninput = (e) => applyFilter(e.target.value, false); // Don't save to history on input
-
- filterInput.onkeydown = (e) => {
- if (e.key === 'Enter' && e.target.value.trim()) {
- addToSearchHistory(e.target.value.trim());
- hideSearchHistory(searchHistoryDropdown);
- } else if (e.key === 'ArrowDown' && searchHistory.length > 0) {
- // Show history when user presses down arrow
- showSearchHistory(filterInput);
- e.preventDefault();
- }
- };
-
- filterInput.onfocus = (e) => {
- // Only show history if input is empty
- if (!e.target.value.trim()) {
- showSearchHistory(filterInput);
- }
- };
-
- filterInput.onblur = (e) => {
- // Save to history when leaving the field (if it has content)
- if (e.target.value.trim()) {
- addToSearchHistory(e.target.value.trim());
- }
- // Clear any pending hover timeout
- if (hoverTimeout) {
- clearTimeout(hoverTimeout);
- hoverTimeout = null;
- }
- // Delay hiding to allow clicks on dropdown items
- setTimeout(() => hideSearchHistory(searchHistoryDropdown), 150);
- };
-
- filterInput.onmouseenter = () => {
- // Clear auto-hide timer when mouse enters
- if (searchTimeout) {
- clearTimeout(searchTimeout);
+ // Setup cleanup
+ const originalRemove = container.remove.bind(container);
+ container.remove = function() {
+ document.removeEventListener('keydown', escHandler);
+ unregisterThemeCallback?.();
+ document.getElementById('node-loader-style')?.remove();
+ if (searchTimeout) clearTimeout(searchTimeout);
+ if (hoverTimeout) clearTimeout(hoverTimeout);
+ currentDialog = null;
+ unregisterThemeCallback = null;
searchTimeout = null;
- }
-
- // Start hover timer - show history after 1 second of hovering
- hoverTimeout = setTimeout(() => {
- if (searchHistory.length > 0) {
- showSearchHistory(filterInput);
- }
- }, 1000);
- };
-
- filterInput.onmouseleave = (e) => {
- // Clear hover timeout when mouse leaves
- if (hoverTimeout) {
- clearTimeout(hoverTimeout);
hoverTimeout = null;
- }
-
- // Start auto-hide timer for the dropdown
- searchTimeout = setTimeout(() => {
- hideSearchHistory(searchHistoryDropdown);
- }, 10000);
- };
-
- const expandBtn = document.createElement("button");
- expandBtn.className = "filter-expand-btn";
- expandBtn.textContent = "Expand All";
- let isExpanded = false;
- expandBtn.onclick = () => {
- isExpanded = !isExpanded;
- toggleAllCategories(isExpanded);
- expandBtn.textContent = isExpanded ? "Collapse All" : "Expand All";
- };
-
- filterInputContainer.appendChild(filterInput);
- filterInputContainer.appendChild(searchHistoryDropdown);
- filterInputContainer.appendChild(expandBtn);
-
- const nodeCounters = document.createElement("div");
- nodeCounters.className = "node-counters";
- nodeCounters.innerHTML = `
-
Selected: 0
-
Total: 0
- `;
-
- filterSection.appendChild(filterInputContainer);
- filterSection.appendChild(nodeCounters);
- container.appendChild(filterSection);
-
- // Recent Section //
- const recentSection = document.createElement("div");
- recentSection.className = "dialog-recent";
- container.appendChild(recentSection);
-
- // Node Section //
- const nodeListSection = document.createElement("div");
- nodeListSection.className = "dialog-list";
-
- const nodes = Object.entries(LiteGraph.registered_node_types)
- .filter(([key, value]) => key && value)
- .map(([type, nodeClass]) => {
- const category = nodeClass.category || "Other";
- const displayName = nodeClass.title || nodeClass.name || type.split("/").pop();
- return {
- type,
- category,
- pathParts: category.split("/"),
- displayName,
- description: nodeClass.desc || nodeClass.description || "",
- fullPath: category + "/" + displayName
- };
- })
- .sort((a, b) => a.category.localeCompare(b.category) || a.displayName.localeCompare(b.displayName));
-
- allNodesData = nodes; // Store for filtering
-
- const hierarchy = buildHierarchy(nodes);
- const tree = renderCategory(hierarchy);
- tree.forEach(section => {
- nodeListSection.appendChild(section);
- });
- container.appendChild(nodeListSection);
-
-// Footer Section //
-const footerSection = document.createElement("div");
-footerSection.className = "dialog-footer";
-
-const leftButtons = document.createElement("div");
-leftButtons.style.display = "flex";
-leftButtons.style.gap = "8px";
-
-const spawnBtn = document.createElement("button");
-spawnBtn.textContent = "Spawn Nodes";
-spawnBtn.className = "dialog-btn primary";
-spawnBtn.onclick = () => {
- const selectedTypes = getSelectedNodeTypes();
- if (selectedTypes.length === 0) {
- alert("Please select at least one node to spawn.");
- return;
+ originalRemove();
+ };
}
- spawnNodes(selectedTypes);
- closeDialog(); // Use the shared closeDialog function
-};
-const clearBtn = document.createElement("button");
-clearBtn.textContent = "Clear Selected";
-clearBtn.className = "dialog-btn";
-clearBtn.onclick = clearSelectedNodes;
+ function closeDialog() {
+ currentDialog?.remove();
+ }
-const cancelBtn = document.createElement("button");
-cancelBtn.textContent = "Cancel";
-cancelBtn.className = "dialog-btn sec";
-cancelBtn.onclick = closeDialog; // Use the shared closeDialog function
-
-leftButtons.appendChild(spawnBtn);
-leftButtons.appendChild(clearBtn);
-
-const rightButtons = document.createElement("div");
-rightButtons.style.display = "flex";
-rightButtons.style.gap = "8px";
-
-const clearHistoryBtn = document.createElement("button");
-clearHistoryBtn.textContent = "Clear History";
-clearHistoryBtn.className = "dialog-btn";
-clearHistoryBtn.onclick = () => {
- searchHistory = [];
- localStorage.setItem('endlessNodeLoader_searchHistory', JSON.stringify(searchHistory));
- // Hide dropdown if it's currently showing
- const dropdown = dialogInstance.querySelector('.search-history-dropdown');
- if (dropdown) dropdown.style.display = 'none';
-};
-
-rightButtons.appendChild(clearHistoryBtn);
-rightButtons.appendChild(cancelBtn);
-
-footerSection.appendChild(leftButtons);
-footerSection.appendChild(rightButtons);
-container.appendChild(footerSection);
-
- document.body.appendChild(container);
- dialogInstance = container;
-
- // Initialize
- updateRecentChips();
- updateSelectedCounter();
- updateTotalCounter();
-
- // Focus filter input
- filterInput.focus();
-}
-
-registerEndlessTool("Node Loader", showNodeLoaderDialog);
\ No newline at end of file
+ // Register tool
+ registerEndlessTool("Node Spawner", createNodeLoaderDialog);
+})();
\ No newline at end of file
diff --git a/web/endless_ui_helpers.js b/web/endless_ui_helpers.js
index 11a83a1..e7a78ba 100644
--- a/web/endless_ui_helpers.js
+++ b/web/endless_ui_helpers.js
@@ -1,4 +1,5 @@
// === Endless πβ¨ Tools UI Helper ===
+
const endlessToolsRegistry = [];
export function registerEndlessTool(name, callback) {
@@ -29,23 +30,23 @@ export function showEndlessToolMenu() {
position: fixed;
top: 20px;
right: 20px;
- background: ${colors.background};
- color: ${colors.text};
+ background: ${colors.menu};
+ color: ${colors.inputText};
padding: 12px;
border: 1px solid ${colors.accent};
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
z-index: 99999;
- transition: opacity 0.2s ease;
opacity: 1;
- min-width: 180px;
+ width: fit-content;
+ transition: opacity 0.2s ease;
`;
const dragBar = document.createElement("div");
- dragBar.textContent = "β Drag Me With This β";
+ dragBar.textContent = "Endless πβ¨ Tools Drag Bar";
dragBar.style.cssText = `
padding: 4px;
- background: rgba(255, 255, 255, 0.05);
+ background: ${toRGBA(colors.inputText, 0.05)};
cursor: move;
font-size: 12px;
text-align: center;
@@ -54,7 +55,7 @@ export function showEndlessToolMenu() {
`;
menu.appendChild(dragBar);
- endlessToolsRegistry.forEach(tool => {
+ endlessToolsRegistry.sort((a, b) => a.name.localeCompare(b.name)).forEach(tool => {
const btn = document.createElement("div");
btn.textContent = `πβ¨ ${tool.name}`;
btn.style.cssText = `
@@ -63,7 +64,7 @@ export function showEndlessToolMenu() {
border-radius: 4px;
transition: background 0.2s ease;
`;
- btn.onmouseover = () => btn.style.background = "rgba(255, 255, 255, 0.1)";
+ btn.onmouseover = () => btn.style.background = toRGBA(colors.inputText, 0.1);
btn.onmouseout = () => btn.style.background = "transparent";
btn.onclick = () => {
tool.callback();
@@ -73,9 +74,27 @@ export function showEndlessToolMenu() {
});
makeDraggable(menu, dragBar);
+
+ // Live theme updater
+ function updateMenuTheme(newColors = getComfyUIColors()) {
+ menu.style.background = newColors.menu;
+ menu.style.color = newColors.inputText;
+ menu.style.borderColor = newColors.accent;
+ menu.style.boxShadow = newColors.shadow;
+ dragBar.style.background = toRGBA(newColors.inputText, 0.05);
+ dragBar.style.borderBottomColor = newColors.border;
+ }
+
+ const unregister = onThemeChange(updateMenuTheme);
+ menu.remove = ((orig => function () {
+ unregister();
+ orig.call(this);
+ })(menu.remove));
+
document.body.appendChild(menu);
}
+// === Hotkeys ===
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.altKey && e.key.toLowerCase() === 'e') {
showEndlessToolMenu();
@@ -104,49 +123,130 @@ function waitForToolbarAndInject() {
waitForToolbarAndInject();
function findToolbar() {
- let toolbar = document.querySelector('.comfyui-menu, .comfy-menu, [class*="menu"], [class*="toolbar"]');
- if (!toolbar) {
- const buttonGroups = document.querySelectorAll('[class*="button-group"], [class*="btn-group"], .comfyui-button-group');
- toolbar = Array.from(buttonGroups).find(group => group.querySelectorAll('button').length > 0);
- }
- if (!toolbar) {
- const allElements = document.querySelectorAll('*');
- toolbar = Array.from(allElements).find(el => {
- const buttons = el.querySelectorAll('button');
- return buttons.length >= 2 && buttons.length <= 10;
- });
- }
- if (!toolbar) {
- toolbar = Array.from(document.querySelectorAll(".comfyui-button-group")).find(div =>
- Array.from(div.querySelectorAll("button")).some(btn => btn.title === "Share")
- );
- }
- return toolbar;
+ return (
+ document.querySelector('.comfyui-menu, .comfy-menu, [class*="menu"], [class*="toolbar"]') ||
+ Array.from(document.querySelectorAll('[class*="button-group"], [class*="btn-group"], .comfyui-button-group'))
+ .find(g => g.querySelectorAll('button').length > 0) ||
+ Array.from(document.querySelectorAll('*'))
+ .find(el => {
+ const buttons = el.querySelectorAll('button');
+ return buttons.length >= 2 && buttons.length <= 10;
+ }) ||
+ Array.from(document.querySelectorAll(".comfyui-button-group"))
+ .find(div => Array.from(div.querySelectorAll("button")).some(btn => btn.title === "Share"))
+ );
}
-function getComfyUIColors() {
- const computedStyle = getComputedStyle(document.documentElement);
- return {
- background: computedStyle.getPropertyValue('--comfy-menu-bg') || '#353535',
- backgroundSecondary: computedStyle.getPropertyValue('--comfy-input-bg') || '#222',
- border: computedStyle.getPropertyValue('--border-color') || '#999',
- text: computedStyle.getPropertyValue('--input-text') || '#ddd',
- accent: computedStyle.getPropertyValue('--comfy-accent-color') || '#5fc0e9' // fallback soft blue
+// === Live Theme Monitoring ===
+let themeObserver = null;
+const themeCallbacks = new Set();
+
+export function onThemeChange(callback) {
+ themeCallbacks.add(callback);
+ if (themeCallbacks.size === 1) startThemeObserver();
+ return () => {
+ themeCallbacks.delete(callback);
+ if (themeCallbacks.size === 0) stopThemeObserver();
};
}
-function makeDraggable(element, handle = element) {
- let offsetX = 0, offsetY = 0, isDown = false;
+function startThemeObserver() {
+ if (themeObserver) return;
+ themeObserver = new MutationObserver(() => {
+ clearTimeout(window.themeChangeTimeout);
+ window.themeChangeTimeout = setTimeout(() => {
+ const newColors = getComfyUIColors();
+ themeCallbacks.forEach(cb => cb(newColors));
+ }, 100);
+ });
+ themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] });
+ themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] });
+}
+function stopThemeObserver() {
+ if (themeObserver) {
+ themeObserver.disconnect();
+ themeObserver = null;
+ }
+}
+
+export function getComfyUIColors() {
+ const computed = getComputedStyle(document.documentElement);
+ const getVar = name => computed.getPropertyValue(name).trim() || null;
+ return {
+ fg: getVar("--fg-color") || "#ddd",
+ bg: getVar("--bg-color") || "#353535",
+ menu: getVar("--comfy-menu-bg") || "#353535",
+ menuSecondary: getVar("--comfy-menu-secondary-bg") || "#222",
+ inputBg: getVar("--comfy-input-bg") || "#222",
+ inputText: getVar("--input-text") || "#ddd",
+ descriptionText: getVar("--descrip-text") || "#999",
+ dragText: getVar("--drag-text") || "#ddd",
+ errorText: getVar("--error-text") || "#f44336",
+ border: getVar("--border-color") || "#999",
+ accent: getVar("--comfy-accent") || getVar("--comfy-accent-color") || "#4a90e2",
+ hoverBg: getVar("--content-hover-bg") || "rgba(255,255,255,0.1)",
+ hoverFg: getVar("--content-hover-fg") || "#fff",
+ shadow: getVar("--bar-shadow") || "0 2px 10px rgba(0,0,0,0.3)",
+ dialogBg: getVar("--comfy-menu-bg") || getVar("--bg-color") || "#353535",
+ buttonHoverBg: getVar("--content-hover-bg") || "rgba(255,255,255,0.1)"
+ };
+}
+
+export function toRGBA(color, alpha = 0.2) {
+ if (!color) return `rgba(128,128,128,${alpha})`;
+ color = color.trim();
+ if (color.startsWith('#')) {
+ const hex = color.slice(1);
+ const fullHex = hex.length === 3 ? hex.split('').map(c => c + c).join('') : hex;
+ const bigint = parseInt(fullHex, 16);
+ const r = (bigint >> 16) & 255, g = (bigint >> 8) & 255, b = bigint & 255;
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
+ }
+ if (color.startsWith('rgb')) {
+ const rgb = color.match(/\d+/g);
+ if (rgb?.length >= 3) return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})`;
+ }
+ return `rgba(128,128,128,${alpha})`;
+}
+
+export function blendColors(color1, color2, ratio) {
+ const c1 = toRGBA(color1, 1).match(/\d+/g);
+ const c2 = toRGBA(color2, 1).match(/\d+/g);
+ if (!c1 || !c2) return color1;
+ const r = Math.round(c1[0] * (1 - ratio) + c2[0] * ratio);
+ const g = Math.round(c1[1] * (1 - ratio) + c2[1] * ratio);
+ const b = Math.round(c1[2] * (1 - ratio) + c2[2] * ratio);
+ return `rgb(${r}, ${g}, ${b})`;
+}
+
+export function addButtonHoverEffects(container) {
+ container?.querySelectorAll('button').forEach(button => {
+ button.addEventListener('mouseenter', () => {
+ button.style.boxShadow = '0 0 0 1px currentColor';
+ button.style.filter = 'brightness(1.1)';
+ button.style.transform = 'translateY(-1px)';
+ });
+ button.addEventListener('mouseleave', () => {
+ button.style.boxShadow = 'none';
+ button.style.filter = 'brightness(1)';
+ button.style.transform = 'translateY(0px)';
+ });
+ });
+}
+
+export function makeDraggable(element, handle = element) {
+ let offsetX = 0, offsetY = 0, isDown = false;
handle.onmousedown = (e) => {
isDown = true;
+ if (element.style.position !== 'fixed') {
+ element.style.position = 'fixed';
+ element.style.right = 'auto';
+ }
const rect = element.getBoundingClientRect();
offsetX = e.clientX - rect.left;
offsetY = e.clientY - rect.top;
element.style.cursor = 'move';
- element.style.position = 'fixed';
- element.style.right = 'auto';
-
document.onmousemove = (e) => {
if (!isDown) return;
element.style.left = `${e.clientX - offsetX}px`;
@@ -162,3 +262,15 @@ function makeDraggable(element, handle = element) {
};
}
+// === Global exposure for F12 ===
+window.EndlessHelpers = {
+ registerEndlessTool,
+ injectEndlessToolsButton,
+ showEndlessToolMenu,
+ onThemeChange,
+ getComfyUIColors,
+ toRGBA,
+ blendColors,
+ addButtonHoverEffects,
+ makeDraggable
+};
diff --git a/web/fixed_comfyui_saver.py b/web/fixed_comfyui_saver.py
new file mode 100644
index 0000000..9c54f91
--- /dev/null
+++ b/web/fixed_comfyui_saver.py
@@ -0,0 +1,620 @@
+import os
+import json
+import re
+from datetime import datetime
+from PIL import Image, PngImagePlugin
+import numpy as np
+import torch
+import folder_paths
+from PIL.PngImagePlugin import PngInfo
+import platform
+
+class EndlessNode_Imagesaver:
+ """
+ Enhanced batch image saver with comprehensive metadata support
+ Saves batched images with individual prompt names in filenames
+ Automatically handles multiple images from batch processing
+ Enhanced with workflow embedding, JSON export, and robust filename handling
+ """
+ def __init__(self):
+ self.output_dir = folder_paths.get_output_directory()
+ self.type = "output"
+ self.compress_level = 4
+ # OS-specific filename length limits
+ self.max_filename_length = self._get_max_filename_length()
+
+ def _get_max_filename_length(self):
+ """Get maximum filename length based on OS"""
+ system = platform.system().lower()
+ if system == 'windows':
+ return 255 # NTFS limit
+ elif system in ['linux', 'darwin']: # Linux and macOS
+ return 255 # ext4/APFS limit
+ else:
+ return 200 # Conservative fallback
+
+ @classmethod
+ def INPUT_TYPES(s):
+ return {"required":
+ {"images": ("IMAGE", ),
+ "prompt_list": ("STRING", {"forceInput": True}),
+ "include_timestamp": ("BOOLEAN", {"default": True}),
+ "timestamp_format": ("STRING", {"default": "%Y-%m-%d_%H-%M-%S", "description": "Use Python strftime format.\nExample: %Y-%m-%d %H-%M-%S\nSee: strftime.org for full options."}),
+ "image_format": (["PNG", "JPEG", "WEBP"], {"default": "PNG"}),
+ "jpeg_quality": ("INT", {"default": 95, "min": 1, "max": 100, "step": 1}),
+ "delimiter": ("STRING", {"default": "_"}),
+ "prompt_words_limit": ("INT", {"default": 8, "min": 1, "max": 16, "step": 1}),
+ "embed_workflow": ("BOOLEAN", {"default": True}),
+ "save_json_metadata": ("BOOLEAN", {"default": False}),
+ # ITEM #2: Enable/disable number padding
+ "enable_filename_numbering": ("BOOLEAN", {"default": True}),
+ # ITEM #1: Filename Number Padding Control
+ "filename_number_padding": ("INT", {"default": 2, "min": 1, "max": 9, "step": 1}),
+ "filename_number_start": ("BOOLEAN", {"default": False}),
+ # ITEM #3: Conditional PNG Metadata Embedding
+ "embed_png_metadata": ("BOOLEAN", {"default": True}),
+ },
+ "optional":
+ {"output_path": ("STRING", {"default": ""}),
+ "filename_prefix": ("STRING", {"default": "Batch"}),
+ "negative_prompt_list": ("STRING", {"default": ""}),
+ "json_folder": ("STRING", {"default": ""}),
+ },
+ "hidden": {
+ "prompt": "PROMPT",
+ "extra_pnginfo": "EXTRA_PNGINFO"
+ }
+ }
+
+ RETURN_TYPES = ("STRING",)
+ RETURN_NAMES = ("saved_paths",)
+ FUNCTION = "save_batch_images"
+ OUTPUT_NODE = True
+ CATEGORY = "Endless πβ¨/IO"
+
+ def encode_emoji(self, obj):
+ """Properly encode emojis and special characters"""
+ if isinstance(obj, str):
+ return obj.encode('utf-8', 'surrogatepass').decode('utf-8')
+ return obj
+
+ def clean_filename(self, text, max_words=8, delimiter="_"):
+ """Clean text for use in filenames with word limit and emoji support"""
+ # Limit to specified number of words
+ words = text.split()[:max_words]
+ text = ' '.join(words)
+
+ # Handle emojis by encoding them properly
+ text = self.encode_emoji(text)
+
+ # Replace illegal characters with delimiter, then clean up spaces
+ illegal_chars = r'[<>:"/\\|?*]'
+ clean_text = re.sub(illegal_chars, delimiter, text)
+ clean_text = re.sub(r'\s+', delimiter, clean_text) # Replace spaces with delimiter
+ clean_text = re.sub(r'[^\w\-_.{}]'.format(re.escape(delimiter)), '', clean_text) # Keep only safe chars
+
+ return clean_text
+
+ def format_timestamp(self, dt, format_string, delimiter='_'):
+ try:
+ formatted = dt.strftime(format_string)
+ # Replace colons first
+ formatted = formatted.replace(':', '-')
+ # Then replace all whitespace with the user's delimiter
+ if delimiter:
+ formatted = re.sub(r'\s+', delimiter, formatted)
+ return formatted
+ except Exception as e:
+ print(f"Invalid timestamp format: {e}")
+ return dt.strftime("%Y-%m-%d_%H-%M-%S")
+
+ def validate_and_process_path(self, path, delimiter="_"):
+ if not path or path.strip() == "":
+ return path
+
+ now = datetime.now()
+
+ # Normalize path separators
+ path = path.replace("/", os.sep).replace("\\", os.sep)
+
+ # Handle UNC or drive prefix
+ unc_prefix = ""
+ parts = path.split(os.sep)
+
+ if path.startswith("\\\\"): # UNC path
+ if len(parts) >= 4:
+ unc_prefix = os.sep.join(parts[:4]) # \\server\share
+ parts = parts[4:]
+ else:
+ raise ValueError(f"Invalid UNC path: {path}")
+ elif re.match(r"^[A-Za-z]:$", parts[0]): # Drive letter
+ unc_prefix = parts[0]
+ parts = parts[1:]
+
+ # Process the remaining subfolders
+ processed_parts = []
+ for part in parts:
+ if not part:
+ continue
+ if "%" in part:
+ # Format date placeholders
+ formatted = self.format_timestamp(now, part, delimiter)
+ else:
+ # Sanitize folder names
+ formatted = re.sub(r'[<>:"/\\|?*]', delimiter, part)
+ processed_parts.append(formatted)
+
+ # Reconstruct full path
+ full_path = os.path.join(unc_prefix, *processed_parts)
+ return full_path
+
+ def ensure_filename_length(self, full_path, base_name, extension):
+ """Ensure the full filename doesn't exceed OS limits"""
+ directory = os.path.dirname(full_path)
+
+ # Calculate available space for filename
+ dir_length = len(directory) + 1 # +1 for path separator
+ available_length = self.max_filename_length - len(extension)
+ max_base_length = available_length - dir_length
+
+ if len(base_name) > max_base_length:
+ # Truncate base name to fit
+ base_name = base_name[:max_base_length-3] + "..." # -3 for ellipsis
+
+ return os.path.join(directory, base_name + extension)
+
+ def get_unique_filename(self, file_path):
+ """Generate unique filename by adding incremental numbers if file exists"""
+ if not os.path.exists(file_path):
+ return file_path
+
+ directory = os.path.dirname(file_path)
+ filename = os.path.basename(file_path)
+ name, ext = os.path.splitext(filename)
+
+ counter = 1
+ while True:
+ new_name = f"{name}_{counter:03d}{ext}"
+ new_path = os.path.join(directory, new_name)
+
+ # Check length constraints
+ if len(new_name) > self.max_filename_length:
+ # Truncate original name to make room for counter
+ available = self.max_filename_length - len(f"_{counter:03d}{ext}")
+ truncated_name = name[:available-3] + "..."
+ new_name = f"{truncated_name}_{counter:03d}{ext}"
+ new_path = os.path.join(directory, new_name)
+
+ if not os.path.exists(new_path):
+ return new_path
+ counter += 1
+
+ def save_json_metadata(self, json_path, prompt_text, negative_text,
+ batch_index, creation_time, prompt=None, extra_pnginfo=None):
+ """Save JSON metadata file with ComfyUI-compatible format"""
+
+ # Create the metadata structure that ComfyUI expects
+ metadata = {
+ "prompt": prompt_text,
+ "negative_prompt": negative_text,
+ "batch_index": batch_index,
+ "creation_time": creation_time,
+ }
+
+ # BUG FIX #1: Save workflow and extra_pnginfo in the same format as PNG metadata
+ # ComfyUI expects these to be at the root level, not nested under other keys
+ if prompt is not None:
+ # The 'prompt' parameter contains the workflow data
+ metadata["workflow"] = prompt
+
+ if extra_pnginfo is not None:
+ # extra_pnginfo contains additional workflow metadata
+ # Add each key-value pair from extra_pnginfo directly to metadata
+ for key, value in extra_pnginfo.items():
+ metadata[key] = value
+
+ # BUG FIX #2: Use the same JSON serialization as PNG metadata
+ # Don't double-encode the workflow data
+ try:
+ with open(json_path, 'w', encoding='utf-8', newline='\n') as f:
+ json.dump(metadata, f, indent=2, ensure_ascii=False)
+ return True
+ except Exception as e:
+ print(f"Failed to save JSON metadata: {e}")
+ return False
+
+ def generate_numbered_filename(self, filename_prefix, delimiter, counter,
+ filename_number_padding, filename_number_start,
+ enable_filename_numbering, date_str, clean_prompt, ext):
+ """Generate filename with configurable number positioning and padding"""
+ # ITEM #3: Build filename parts in the correct order based on settings
+ filename_parts = []
+
+ # Always add timestamp first if provided
+ if date_str:
+ filename_parts.append(date_str)
+
+ # Add number after timestamp if number_start is True AND numbering is enabled
+ if enable_filename_numbering and filename_number_start:
+ counter_str = f"{counter:0{filename_number_padding}}"
+ filename_parts.append(counter_str)
+
+ # Add filename prefix if provided
+ if filename_prefix:
+ filename_parts.append(filename_prefix)
+
+ # Add cleaned prompt
+ filename_parts.append(clean_prompt)
+
+ # Add number at the end if number_start is False AND numbering is enabled
+ if enable_filename_numbering and not filename_number_start:
+ counter_str = f"{counter:0{filename_number_padding}}"
+ filename_parts.append(counter_str)
+
+ # Join all parts with delimiter
+ filename = delimiter.join(filename_parts) + ext
+
+ return filename
+
+ def save_batch_images(self, images, prompt_list, include_timestamp=True,
+ timestamp_format="%Y-%m-%d_%H-%M-%S", image_format="PNG",
+ jpeg_quality=95, delimiter="_",
+ prompt_words_limit=8, embed_workflow=True, save_json_metadata=False,
+ enable_filename_numbering=True, filename_number_padding=2,
+ filename_number_start=False, embed_png_metadata=True,
+ output_path="", filename_prefix="batch",
+ negative_prompt_list="", json_folder="", prompt=None, extra_pnginfo=None):
+
+ # Debug: Print tensor information
+ print(f"DEBUG: Images tensor shape: {images.shape}")
+ print(f"DEBUG: Images tensor type: {type(images)}")
+
+ # BUG FIX #3: Debug workflow data structure
+ if save_json_metadata and prompt is not None:
+ print(f"DEBUG: Prompt type: {type(prompt)}")
+ print(f"DEBUG: Prompt keys: {list(prompt.keys()) if isinstance(prompt, dict) else 'Not a dict'}")
+
+ if save_json_metadata and extra_pnginfo is not None:
+ print(f"DEBUG: Extra PNG info type: {type(extra_pnginfo)}")
+ print(f"DEBUG: Extra PNG info keys: {list(extra_pnginfo.keys()) if isinstance(extra_pnginfo, dict) else 'Not a dict'}")
+
+ # Process output path with date/time validation (always process regardless of timestamp toggle)
+ processed_output_path = self.validate_and_process_path(output_path, delimiter)
+
+ # Set output directory
+ if processed_output_path.strip() != "":
+ if not os.path.isabs(processed_output_path):
+ output_dir = os.path.join(self.output_dir, processed_output_path)
+ else:
+ output_dir = processed_output_path
+ else:
+ output_dir = self.output_dir
+
+ # Create directory if it doesn't exist
+ try:
+ os.makedirs(output_dir, exist_ok=True)
+ except Exception as e:
+ raise ValueError(f"Could not create output directory {output_dir}: {e}")
+
+ # Set up JSON directory
+ if save_json_metadata:
+ if json_folder.strip():
+ processed_json_folder = self.validate_and_process_path(json_folder, delimiter)
+ if not os.path.isabs(processed_json_folder):
+ json_dir = os.path.join(self.output_dir, processed_json_folder)
+ else:
+ json_dir = processed_json_folder
+ else:
+ json_dir = output_dir
+
+ try:
+ os.makedirs(json_dir, exist_ok=True)
+ except Exception as e:
+ print(f"Warning: Could not create JSON directory {json_dir}: {e}")
+ json_dir = output_dir
+
+ # Generate datetime string if timestamp is enabled
+ now = datetime.now()
+ if include_timestamp:
+ date_str = self.format_timestamp(now, timestamp_format, delimiter)
+ else:
+ date_str = None
+
+ # Parse individual prompts from the prompt list
+ individual_prompts = prompt_list.split('|')
+ individual_negatives = negative_prompt_list.split('|') if negative_prompt_list else []
+
+ # Set file extension
+ if image_format == "PNG":
+ ext = ".png"
+ elif image_format == "JPEG":
+ ext = ".jpg"
+ elif image_format == "WEBP":
+ ext = ".webp"
+ else:
+ ext = ".png"
+
+ saved_paths = []
+
+ # Handle different tensor formats
+ if isinstance(images, torch.Tensor):
+ # Convert to numpy for easier handling
+ images_np = images.cpu().numpy()
+ print(f"DEBUG: Converted to numpy shape: {images_np.shape}")
+
+ # Check if we have a batch dimension
+ if len(images_np.shape) == 4: # Batch format: [B, H, W, C] or [B, C, H, W]
+ batch_size = images_np.shape[0]
+ print(f"DEBUG: Found batch of {batch_size} images")
+
+ for i in range(batch_size):
+ try:
+ # Extract single image from batch
+ img_array = images_np[i]
+
+ # Validate and process the image array
+ if len(img_array.shape) != 3:
+ raise ValueError(f"Expected 3D tensor for image {i+1}, got shape {img_array.shape}")
+
+ # Convert to 0-255 range if needed
+ if img_array.max() <= 1.0:
+ img_array = img_array * 255.0
+
+ img_array = np.clip(img_array, 0, 255).astype(np.uint8)
+
+ # Handle different channel orders (HWC vs CHW)
+ if img_array.shape[0] == 3 or img_array.shape[0] == 4: # CHW format
+ img_array = np.transpose(img_array, (1, 2, 0)) # Convert to HWC
+
+ img = Image.fromarray(img_array)
+
+ # Get the corresponding prompt for this image
+ if i < len(individual_prompts):
+ prompt_text = individual_prompts[i].strip()
+ else:
+ # Cycle through prompts if we have more images than prompts
+ prompt_text = individual_prompts[i % len(individual_prompts)].strip()
+ print(f"Note: Cycling prompt for image {i+1} (using prompt {(i % len(individual_prompts)) + 1})")
+
+ # Get corresponding negative prompt
+ negative_text = ""
+ if individual_negatives:
+ if i < len(individual_negatives):
+ negative_text = individual_negatives[i].strip()
+ else:
+ negative_text = individual_negatives[i % len(individual_negatives)].strip()
+
+ # Clean the prompt for filename use
+ clean_prompt = self.clean_filename(prompt_text, prompt_words_limit, delimiter)
+
+ # Generate filename using the new method
+ filename = self.generate_numbered_filename(
+ filename_prefix, delimiter, i+1,
+ filename_number_padding, filename_number_start,
+ enable_filename_numbering, date_str, clean_prompt, ext
+ )
+
+ # Create full file path and ensure length constraints
+ base_filename = os.path.splitext(filename)[0]
+ temp_path = os.path.join(output_dir, filename)
+ file_path = self.ensure_filename_length(temp_path, base_filename, ext)
+
+ # Ensure unique filename
+ file_path = self.get_unique_filename(file_path)
+
+ # Create JSON path if needed
+ if save_json_metadata:
+ json_base = os.path.splitext(os.path.basename(file_path))[0]
+ json_path = os.path.join(json_dir, json_base + ".json")
+ json_path = self.get_unique_filename(json_path)
+
+ # Save image based on format
+ if image_format == "PNG":
+ # ITEM #3: Conditional PNG metadata embedding
+ if embed_png_metadata:
+ # Prepare PNG metadata
+ metadata = PngImagePlugin.PngInfo()
+ metadata.add_text("prompt", prompt_text)
+ metadata.add_text("negative_prompt", negative_text)
+ metadata.add_text("batch_index", str(i+1))
+ metadata.add_text("creation_time", now.isoformat())
+
+ # Add workflow data if requested
+ if embed_workflow:
+ # BUG FIX #4: Save workflow data in the same format as expected by ComfyUI
+ if prompt is not None:
+ # Convert prompt to JSON string for PNG metadata
+ metadata.add_text("workflow", json.dumps(prompt, ensure_ascii=False))
+ if extra_pnginfo is not None:
+ for key, value in extra_pnginfo.items():
+ # Convert each value to JSON string for PNG metadata
+ metadata.add_text(key, json.dumps(value, ensure_ascii=False))
+
+ img.save(file_path, format="PNG", optimize=True,
+ compress_level=self.compress_level, pnginfo=metadata)
+ else:
+ # ITEM #3: Save clean PNG without metadata
+ img.save(file_path, format="PNG", optimize=True,
+ compress_level=self.compress_level)
+
+ elif image_format == "JPEG":
+ # Convert RGBA to RGB for JPEG
+ if img.mode == 'RGBA':
+ background = Image.new('RGB', img.size, (255, 255, 255))
+ background.paste(img, mask=img.split()[-1])
+ img = background
+ img.save(file_path, format="JPEG", quality=jpeg_quality, optimize=True)
+
+ elif image_format == "WEBP":
+ img.save(file_path, format="WEBP", quality=jpeg_quality, method=6)
+
+ # Save JSON metadata if requested
+ if save_json_metadata:
+ self.save_json_metadata(json_path, prompt_text, negative_text,
+ i+1, now.isoformat(), prompt, extra_pnginfo)
+
+ saved_paths.append(file_path)
+ print(f"Saved: {os.path.basename(file_path)}")
+ print(f" Prompt: {prompt_text}")
+ if negative_text:
+ print(f" Negative: {negative_text}")
+ if save_json_metadata:
+ print(f" JSON: {os.path.basename(json_path)}")
+
+ except Exception as e:
+ error_msg = f"Failed to save image {i+1}: {e}"
+ print(error_msg)
+ # Continue with other images rather than failing completely
+ saved_paths.append(f"ERROR: {error_msg}")
+
+ elif len(images_np.shape) == 3: # Single image format: [H, W, C]
+ print("DEBUG: Single image detected, processing as batch of 1")
+ # Process as single image
+ img_array = images_np
+
+ # Convert to 0-255 range if needed
+ if img_array.max() <= 1.0:
+ img_array = img_array * 255.0
+
+ img_array = np.clip(img_array, 0, 255).astype(np.uint8)
+ img = Image.fromarray(img_array)
+
+ prompt_text = individual_prompts[0].strip() if individual_prompts else "no_prompt"
+ negative_text = individual_negatives[0].strip() if individual_negatives else ""
+ clean_prompt = self.clean_filename(prompt_text, prompt_words_limit, delimiter)
+
+ # Generate filename using the new method
+ filename = self.generate_numbered_filename(
+ filename_prefix, delimiter, 1,
+ filename_number_padding, filename_number_start,
+ enable_filename_numbering, date_str, clean_prompt, ext
+ )
+
+ base_filename = os.path.splitext(filename)[0]
+ temp_path = os.path.join(output_dir, filename)
+ file_path = self.ensure_filename_length(temp_path, base_filename, ext)
+ file_path = self.get_unique_filename(file_path)
+
+ if save_json_metadata:
+ json_base = os.path.splitext(os.path.basename(file_path))[0]
+ json_path = os.path.join(json_dir, json_base + ".json")
+ json_path = self.get_unique_filename(json_path)
+
+ if image_format == "PNG":
+ # ITEM #3: Conditional PNG metadata embedding
+ if embed_png_metadata:
+ metadata = PngImagePlugin.PngInfo()
+ metadata.add_text("prompt", prompt_text)
+ metadata.add_text("negative_prompt", negative_text)
+ metadata.add_text("batch_index", "1")
+ metadata.add_text("creation_time", now.isoformat())
+
+ if embed_workflow:
+ if prompt is not None:
+ metadata.add_text("workflow", json.dumps(prompt, ensure_ascii=False))
+ if extra_pnginfo is not None:
+ for key, value in extra_pnginfo.items():
+ metadata.add_text(key, json.dumps(value, ensure_ascii=False))
+
+ img.save(file_path, format="PNG", optimize=True,
+ compress_level=self.compress_level, pnginfo=metadata)
+ else:
+ # ITEM #3: Save clean PNG without metadata
+ img.save(file_path, format="PNG", optimize=True,
+ compress_level=self.compress_level)
+ elif image_format == "JPEG":
+ if img.mode == 'RGBA':
+ background = Image.new('RGB', img.size, (255, 255, 255))
+ background.paste(img, mask=img.split()[-1])
+ img = background
+ img.save(file_path, format="JPEG", quality=jpeg_quality, optimize=True)
+ elif image_format == "WEBP":
+ img.save(file_path, format="WEBP", quality=jpeg_quality, method=6)
+
+ if save_json_metadata:
+ self.save_json_metadata(json_path, prompt_text, negative_text,
+ 1, now.isoformat(), prompt, extra_pnginfo)
+
+ saved_paths.append(file_path)
+ print(f"Saved: {os.path.basename(file_path)}")
+ print(f" Prompt: {prompt_text}")
+
+ else:
+ raise ValueError(f"Unexpected image tensor shape: {images_np.shape}")
+
+ else:
+ # Handle case where images might be a list
+ print(f"DEBUG: Images is not a tensor, type: {type(images)}")
+ for i, image in enumerate(images):
+ try:
+ if isinstance(image, torch.Tensor):
+ img_array = image.cpu().numpy()
+ else:
+ img_array = np.array(image)
+
+ # Process similar to above...
+ if img_array.max() <= 1.0:
+ img_array = img_array * 255.0
+
+ img_array = np.clip(img_array, 0, 255).astype(np.uint8)
+
+ if len(img_array.shape) == 3 and (img_array.shape[0] == 3 or img_array.shape[0] == 4):
+ img_array = np.transpose(img_array, (1, 2, 0))
+
+ img = Image.fromarray(img_array)
+
+ prompt_text = individual_prompts[i % len(individual_prompts)].strip() if individual_prompts else "no_prompt"
+ negative_text = individual_negatives[i % len(individual_negatives)].strip() if individual_negatives else ""
+ clean_prompt = self.clean_filename(prompt_text, prompt_words_limit, delimiter)
+
+ # Generate filename using the new method
+ filename = self.generate_numbered_filename(
+ filename_prefix, delimiter, i+1,
+ filename_number_padding, filename_number_start,
+ enable_filename_numbering, date_str, clean_prompt, ext
+ )
+
+ base_filename = os.path.splitext(filename)[0]
+ temp_path = os.path.join(output_dir, filename)
+ file_path = self.ensure_filename_length(temp_path, base_filename, ext)
+ file_path = self.get_unique_filename(file_path)
+
+ if save_json_metadata:
+ json_base = os.path.splitext(os.path.basename(file_path))[0]
+ json_path = os.path.join(json_dir, json_base + ".json")
+ json_path = self.get_unique_filename(json_path)
+
+ # ITEM #3: Apply conditional PNG metadata for all image formats logic
+ if image_format == "PNG" and embed_png_metadata:
+ metadata = PngImagePlugin.PngInfo()
+ metadata.add_text("prompt", prompt_text)
+ metadata.add_text("negative_prompt", negative_text)
+ metadata.add_text("batch_index", str(i+1))
+ metadata.add_text("creation_time", now.isoformat())
+
+ if embed_workflow:
+ if prompt is not None:
+ metadata.add_text("workflow", json.dumps(prompt, ensure_ascii=False))
+ if extra_pnginfo is not None:
+ for key, value in extra_pnginfo.items():
+ metadata.add_text(key, json.dumps(value, ensure_ascii=False))
+
+ img.save(file_path, format="PNG", optimize=True,
+ compress_level=self.compress_level, pnginfo=metadata)
+ else:
+ img.save(file_path, format=image_format.upper())
+
+ if save_json_metadata:
+ self.save_json_metadata(json_path, prompt_text, negative_text,
+ i+1, now.isoformat(), prompt, extra_pnginfo)
+
+ saved_paths.append(file_path)
+ print(f"Saved: {os.path.basename(file_path)}")
+
+ except Exception as e:
+ error_msg = f"Failed to save image {i+1}: {e}"
+ print(error_msg)
+ saved_paths.append(f"ERROR: {error_msg}")
+
+ # Return all saved paths joined with newlines
+ return ("\n".join(saved_paths),)
\ No newline at end of file