diff --git a/README.md b/README.md index 1a75a3a..17f0e05 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ Some basic custom nodes for the ComfyUI user interface for Stable Diffusion. Features: + **True batch multi-prompting capability for ComfyUI** ++ **Quality of Life scripts such as multiple node spawning and node minimap** + An image saver for images and JSON files to base folder, custom folders for one, or custom folders for both. Also allows for Python timestamping + Switches for text and numbers + Random prompt selectors @@ -15,6 +16,23 @@ When using the [ComfyUI](https://github.com/comfyanonymous/ComfyUI) interface fo Rightly or wrongly, I was teaching myself a bit of Python back in 2023 to get some nodes up and running to do what I'd like, and I am starting to do that again. Yes, I am using ChatGPT, Copilot, Claude and others, and yes, I am a glutton for punishment. There are no promises that these nodes will work for you or that I will maintain them. Feel free to do with them as you wish, according to the license model. *** +**UPDATE: JUL 25, 2025** + +**New for Version 1.5:** + + ++ Added the Endless 🌊✨ Node Spawner, which accesses your node library and allows you to select nodes to places + + Categorizes your nodes based on the ComfyUI menu system + + Places nodes with intelligent collision avoidance so it will not overlap existing ones + + Real time search and filtering off nodes to adda ++ Added the Endless 🌊✨ Minimap, which shows the current workflow landscape in a floating map + + Colour coded nodes by category + + Click on any node in the map to jump to it + + Respects window aspect ratio + + Places nodes with intelligent collision avoidance so it will not overlap existing ones + + Real time search and filtering off nodes to add ++ Updated the Endless 🌊✨ Fontifier to allow for persistent state saving. + **UPDATE: JUL 18, 2025** **Version 1.3 introduces the Endless 🌊✨ Fontifier, a little button on your taskbar that allows you to dynamically change fonts and sizes.** @@ -73,31 +91,115 @@ I am not a programmer, nor do I care to be. I have a fulltime job that eats up If you have issues, ask me **nicely** for help. Your tone matters; I'm too old and tired to pay attention to people who think I blew up their machines, and if how I react to you if you are difficult bothers you, some self-reflection is in order on your part. You are not "forthright" or "honest" or "direct", you're merely an ass if you think badgering people is justifiable to get what you want. The world has too many assholes, don't make me think you're another one. *** + ## Button List + A helper script places a button on your task bar called "Endless 🌊✨ Tools": + +![endlesstools](./img/endlesstools.png) + +**IF YOU DO NOT SEE IT, PRESS CTRL-ALT-E TO BRING UP A FLOATING TOOLBAR THAT CAN BE MOVED AROUND** This is the central toolbar that contains all the scripts I have made for this collection. Get rid of it by pressing the ESC key. + + +### Endless 🌊✨ Node Spawner + +I find that sometimes I need to create a few nodes for a workflow and creating them one at a time is painful for me. So, I made the Endless 🌊✨ Node Spawner. The spawner has a searchable, categorized interface that supports batch operations and maintains usage history for improved efficiency. Click the Endless 🌊✨ Tools button to bring up the floating toolbar and you should see a choice for "🌊✨ Node Spawner". Clicking it shows the dialog box below: + +![spawnerdialog](./img/spawnerdialog.png) + + +The node spawner has the following features: + ++ Hierarchical categorization of all available nodes ++ Real-time search and filtering capabilities ++ Search history with dropdown suggestions ++ Batch node selection and spawning ++ Intelligent collision detection for node placement ++ Category-level selection controls ++ Persistent usage tracking and search history + + +Here's a quick overview of how to use the spawner: + ++ Open the Node Loader from the Endless Tools menu ++ Browse categories or use the search filter to find specific nodes ++ Select nodes individually or use category selection buttons ++ Review selections in the counter display ++ Click Spawn Nodes to add selected nodes to your workflow ++ Recently used nodes appear as clickable chips for quick access + +Once you have made your selections and applied them, all the nodes you created will appear. How fast is it? My system can create 950 nodes in less than two seconds. + +![spawnerresult](./img/spawnerresult.png) + + +### Endless 🌊✨ Minimap + +When you have large workflows, it can be hard to keep tack of everything on the screen. The ComfyUI web interface does have a button to resize the nodes to your screen, but I thought a minimap would be of use to some people. The minimap displays a scaled overview of all nodes with visual indicators for the current viewport and support for direct navigation. Click the Endless 🌊✨ Tools button to bring up the floating toolbar and you should see a choice for "🌊✨ Minimap". Clicking it shows the dialog box below: + + +![minimapbox](./img/minimapbox.png) + +The minimap has the following features: + ++ Dynamic aspect ratio adjustment based on canvas dimensions ++ Real-time viewport highlighting with theme-aware colors ++ Interactive click-to-navigate functionality ++ Zoom and pan controls for detailed exploration ++ Color-coded node types with optional legend display ++ Responsive resizing based on window dimensions ++ Drag-and-drop repositioning of the minimap window + + +Drag the box around by clicking and holding the title. To cancel, you can simply click outside the dialog box or press the escape key. With this dialog box, you can do the following: + ++ Use the minimap to understand your workflow's overall structure ++ Click anywhere on the minimap to jump to that location ++ Click a node to jump to the node ++ Use zoom controls (+/-) or mouse wheel for detailed viewing ++ Toggle the legend (🎨) to identify node types by color + + +The drag bar has the following icons: + + +![minimapicons](./img/minimapicons.png) + +The icons correspond to: + +🎨 Toggle legend +▫ Zoom out +🏠 Reset zoom and pan +⬜ Zoom in +❌ Close minimap + +When you zoom in to the minimap, you can see the labels of the nodes as well, like shown below. The minimap should also change shape to respect the browser window shape, and works with ComfyUI themes. + +![minimaplabel](./img/minimaplabel.png) + + ### Endless 🌊✨ Fontifier -I always found it odd that in the early days of ComfyUI, you could not change the font size for various node elements. Sure you could manually go into the CSS styling in a user file, but that is not user friendly. Later versions have allowed you to change the widget text size, but that's it. Yes, you can zoom in, but... now you've lost your larger view of the workflow. If you have a 4K monitor and old eyes, too bad so sad for you. This javacsript places a button on your task bar called "Endless 🌊✨ Fontifier". Clicking it shows the dialog box below: +I always found it odd that in the early days of ComfyUI, you could not change the font size for various node elements. Sure you could manually go into the CSS styling in a user file, but that is not user friendly. Later versions have allowed you to change the widget text size, but that's it. Yes, you can zoom in, but... now you've lost your larger view of the workflow. If you have a 4K monitor and old eyes, too bad so sad for you. Click the Endless 🌊✨ Tools button to bring up the floating toolbar and you should see a choice for "🌊✨ Fontifier". Clicking it shows the dialog box below: ![fontifierbox](./img/fontifierbox.png) -Drag the box aronud by clicking and holding the title. To cancel, you can simply click outside the dialog box or press the escape key. With this dialog box, you can do the following: +Drag the box around by clicking and holding the title. To cancel, you can simply click outside the dialog box or press the escape key. With this dialog box, you can do the following: + Globally change the font size for all text elements + Change the fonts themselves + Instead of a global change, select various elements to resize -+ Adjust the higher of the title bar or connectors and other input areas ++ Adjust the height of the title bar or connectors and other input areas ++ Save the current state for persistent use, or revert to ComfyUI defaults -Once you make your changes, you can preview them and then choose to apply or cancel. Changed your mind? Load the box again and press the reset key. +Once you make your changes, you can preview them and then choose to apply or cancel. Changed your mind? Load the box again and press the reset key. You can also save your settings for persistent use. ![fontifiernode](./img/fontifiernode.png) -**NOTE: There is a few bugs still where the changes don't respect the preview or reset buttons, instead they are applied immediately. I'll fix these issues soon.** - ## Node List ### Batch Multiprompt Node for SD, SDXL, and FLUX diff --git a/changlelog.md b/changlelog.md index 2005045..20da06c 100644 --- a/changlelog.md +++ b/changlelog.md @@ -1,4 +1,6 @@ -July 19/26, V1.3: INtroducing the Endless Fontifier, a javascript file that adds allows the user to change font sizes and fonts for various text elements on the ComfyUI interface. +July 25/25, V1.5: Adds the Node Spawner to create multiple nodes at once,and a node minimap + +July 19/25, V1.3: Introducing the Endless Fontifier, a javascript file that adds allows the user to change font sizes and fonts for various text elements on the ComfyUI interface. July 8/25, V1.2.5: Fixed bug in Image Saver that forced a connection for prompts. That is now optional diff --git a/img/endlesstools.png b/img/endlesstools.png new file mode 100644 index 0000000..02ef68d Binary files /dev/null and b/img/endlesstools.png differ diff --git a/img/fontifierbox.png b/img/fontifierbox.png index 91b91b8..98de597 100644 Binary files a/img/fontifierbox.png and b/img/fontifierbox.png differ diff --git a/img/minimapbox.png b/img/minimapbox.png new file mode 100644 index 0000000..669c30a Binary files /dev/null and b/img/minimapbox.png differ diff --git a/img/minimapicons.png b/img/minimapicons.png new file mode 100644 index 0000000..7590b63 Binary files /dev/null and b/img/minimapicons.png differ diff --git a/img/minimaplabel.png b/img/minimaplabel.png new file mode 100644 index 0000000..c1eedb3 Binary files /dev/null and b/img/minimaplabel.png differ diff --git a/img/spawnerdialog.png b/img/spawnerdialog.png new file mode 100644 index 0000000..c0cb4ea Binary files /dev/null and b/img/spawnerdialog.png differ diff --git a/img/spawnerresult.png b/img/spawnerresult.png new file mode 100644 index 0000000..10bc601 Binary files /dev/null and b/img/spawnerresult.png differ diff --git a/pyproject.toml b/pyproject.toml index a12bfc9..8cf2d9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "endless-nodes" description = "A small set of nodes I created for myself. Features multiple simultaneous prompts in batches, an image saver with ability to have JSON saved to separate folder, image analysis nodes, switches for text and numbers, and more." -version = "1.3.2" +version = "1.5.0" license = { file = "LICENSE" } dependencies = "" diff --git a/web/endless_fontifier.js b/web/endless_fontifier.js index 19073d8..3b5ab6b 100644 --- a/web/endless_fontifier.js +++ b/web/endless_fontifier.js @@ -1,531 +1,469 @@ -// ComfyUI Endless 🌊✨ Fontifier - Improved Version +// ComfyUI Endless 🌊✨ Fontifier - Fully Fixed Version -(function() { - 'use strict'; - - // Store original values for reset functionality +(function waitForHelpers() { + if (typeof window.EndlessHelpers === 'undefined') { + console.warn("⏳ Waiting for EndlessHelpers to be ready..."); + setTimeout(waitForHelpers, 100); // Retry every 100ms + return; + } + + // Load helpers from window + const { + registerEndlessTool, + injectEndlessToolsButton, + showEndlessToolMenu, + onThemeChange, + getComfyUIColors, + toRGBA, + blendColors, + addButtonHoverEffects, + makeDraggable + } = window.EndlessHelpers; + + // === ORIGINAL COMFYUI DEFAULTS === const originalValues = { - NODE_TEXT_SIZE: 14, - NODE_SUBTEXT_SIZE: 12, - NODE_TITLE_HEIGHT: 30, - DEFAULT_GROUP_FONT: 24, - NODE_FONT: 'Arial', - NODE_SLOT_HEIGHT: 20, - NODE_WIDGET_HEIGHT: 20 + NODE_TEXT_SIZE: LiteGraph.NODE_TEXT_SIZE || 14, + NODE_SUBTEXT_SIZE: LiteGraph.NODE_SUBTEXT_SIZE || 12, + NODE_TITLE_HEIGHT: LiteGraph.NODE_TITLE_HEIGHT || 30, + DEFAULT_GROUP_FONT: LiteGraph.DEFAULT_GROUP_FONT || 24, + NODE_FONT: LiteGraph.NODE_FONT || 'Arial', + NODE_SLOT_HEIGHT: LiteGraph.NODE_SLOT_HEIGHT || 20, + NODE_WIDGET_HEIGHT: LiteGraph.NODE_WIDGET_HEIGHT || 20, + WIDGET_TEXT_SIZE: 12, + GLOBAL_SCALE: 1 }; - - // Current values (will be updated as user changes them) - let currentValues = { ...originalValues }; - - // Get ComfyUI theme colors - 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', - textSecondary: computedStyle.getPropertyValue('--descrip-text') || '#999', - accent: computedStyle.getPropertyValue('--comfy-menu-bg') || '#0f0f0f' - }; - } - function makeDraggable(dialog) { - const header = dialog.querySelector('h2'); - if (!header) return; + const saved = localStorage.getItem("endless_fontifier_defaults"); + let currentValues = saved ? JSON.parse(saved) : { ...originalValues }; + let dialogOpenValues = null; + let currentDialog = null; + let handlersSetup = false; + let escHandler = null; + let unregisterThemeCallback = null; + let isPreviewMode = false; - let offsetX = 0, offsetY = 0, isDown = false; - - header.style.cursor = 'move'; - header.style.userSelect = 'none'; - - header.onmousedown = (e) => { - e.preventDefault(); - isDown = true; - - // Get the actual position of the dialog - const rect = dialog.getBoundingClientRect(); - offsetX = e.clientX - rect.left; - offsetY = e.clientY - rect.top; - - const onMouseMove = (e) => { - if (!isDown) return; - e.preventDefault(); - dialog.style.left = `${e.clientX - offsetX}px`; - dialog.style.top = `${e.clientY - offsetY}px`; - dialog.style.transform = 'none'; // Remove the centering transform - }; - - const onMouseUp = () => { - isDown = false; - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - }; - - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - }; - } - function createFontifierDialog() { - // Remove existing dialog if present - const existingDialog = document.getElementById('fontifier-dialog'); - if (existingDialog) { - existingDialog.remove(); - } - + if (currentDialog) return; + const colors = getComfyUIColors(); - - // Create dialog container - const dialog = document.createElement('div'); - dialog.id = 'fontifier-dialog'; - dialog.className = 'comfyui-dialog'; + + const dialog = document.createElement("div"); + dialog.id = "fontifier-dialog"; dialog.style.cssText = ` - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - background: ${colors.background}; - border: 1px solid ${colors.border}; - border-radius: 8px; - padding: 20px; - z-index: 10000; - width: 520px; - max-height: 80vh; - overflow-y: auto; - font-family: Arial, sans-serif; - box-shadow: 0 4px 12px rgba(0,0,0,0.3); - color: ${colors.text}; - `; - - // Create backdrop - const backdrop = document.createElement('div'); - backdrop.className = 'comfyui-backdrop'; - backdrop.style.cssText = ` - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0,0,0,0.5); + position: absolute; z-index: 9999; + top: 100px; + left: 100px; + width: 320px; + background: ${colors.dialogBg || colors.menu || 'rgba(20, 20, 20, 0.95)'}; + color: ${colors.inputText || '#fff'}; + font-family: sans-serif; + border: 1px solid ${colors.border}; + border-radius: 10px; + box-shadow: ${colors.shadow || '0 0 20px rgba(0,0,0,0.5)'}; + padding: 10px; `; - backdrop.onclick = () => { - backdrop.remove(); - dialog.remove(); - }; - + + // Clean up any existing style tags + const existingStyle = document.getElementById('fontifier-dialog-style'); + if (existingStyle) existingStyle.remove(); + + // Themed style block + const style = document.createElement("style"); + style.id = "fontifier-dialog-style"; + style.textContent = createStyleCSS(colors); + document.head.appendChild(style); + dialog.innerHTML = ` -
-

🌊✨ Endless Fontifier

- -
- -
-

Global Scale

-
- - - +
Endless 🌊✨ Drag Bar
+

Fontifier Settings

+ +
+ +
+ +
- -
-

Font Family

- + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
- -
-

Text Element Sizes

- -
- -
The main title text at the top of each node (e.g., "KSampler", "VAE Decode")
-
- - -
-
- -
- -
Text inside nodes: parameter names and values (e.g., "steps: 20", "cfg: 8.0")
-
- - -
-
-
- -
Font size for text inside input boxes, dropdowns, and textareas in nodes.
-
- - -
-
- -
- -
Height of the colored title bar area at the top of nodes
-
- - -
-
- -
- -
Height of input/output connection points on node sides
-
- - -
-
- -
- -
Text size for node group labels (when nodes are grouped together)
-
- - -
-
+ - -
- - - - + +
+ + + + +
`; + + document.body.appendChild(dialog); + makeDraggable(dialog, dialog.querySelector('#drag-bar')); + setupDialogHandlers(dialog); + currentDialog = dialog; - document.body.appendChild(backdrop); - document.body.appendChild(dialog); + // Store the values when dialog opens for cancel functionality + dialogOpenValues = { ...currentValues }; + + // Set current values in the dialog + updateDialogValues(dialog); - // ESC key handler - document.addEventListener('keydown', function escHandler(e) { - if (e.key === 'Escape') { - backdrop.remove(); - dialog.remove(); - document.removeEventListener('keydown', escHandler); - } - }); - - // Set up event handlers - setupDialogHandlers(dialog, backdrop); + // Live theme updating without closing dialog + unregisterThemeCallback = onThemeChange(() => { + if (currentDialog) { + updateDialogTheme(); + } + }); } - - function setupDialogHandlers(dialog, backdrop) { -// call drag function + 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; + } + `; + } - makeDraggable(dialog); + 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 setupDialogHandlers(dialog) { + if (handlersSetup) return; + handlersSetup = true; + + addButtonHoverEffects(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; - // Update global scale number input properly - 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 - const globalScale = dialog.querySelector('#global-scale'); - const globalScaleNum = dialog.querySelector('#global-scale-num'); - - function updateGlobalScale() { - const scale = parseFloat(globalScale.value); - globalScaleNum.value = scale; // Fix: Update the number input - - // Update all individual controls - 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] - ]; - - 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 = () => { - 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('#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 = () => { - 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'; // Lock the width - 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), NODE_SLOT_HEIGHT: parseInt(dialog.querySelector('#slot-height').value), DEFAULT_GROUP_FONT: parseInt(dialog.querySelector('#group-font-size').value), - FONT_FAMILY: dialog.querySelector('#font-family').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), + GLOBAL_SCALE: globalScale }; - if (typeof LiteGraph !== 'undefined') { - LiteGraph.NODE_TEXT_SIZE = newValues.NODE_TEXT_SIZE; - LiteGraph.NODE_SUBTEXT_SIZE = newValues.NODE_SUBTEXT_SIZE; - LiteGraph.NODE_TITLE_HEIGHT = newValues.NODE_TITLE_HEIGHT; - LiteGraph.NODE_SLOT_HEIGHT = newValues.NODE_SLOT_HEIGHT; - LiteGraph.NODE_WIDGET_HEIGHT = newValues.NODE_SLOT_HEIGHT; - LiteGraph.DEFAULT_GROUP_FONT = newValues.DEFAULT_GROUP_FONT; - LiteGraph.DEFAULT_GROUP_FONT_SIZE = newValues.DEFAULT_GROUP_FONT; - LiteGraph.NODE_FONT = newValues.FONT_FAMILY; - LiteGraph.DEFAULT_FONT = newValues.FONT_FAMILY; - LiteGraph.GROUP_FONT = newValues.FONT_FAMILY; + // 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) + }; - console.log('🌊✨ Fontifier applied:', newValues); + applySettingsToComfyUI(scaledValues); + if (permanent) { + currentValues = { ...baseValues }; // Store unscaled values + isPreviewMode = false; + } + } - if (typeof app !== 'undefined' && app.canvas) { - app.canvas.setDirty(true, true); - if (app.canvas.draw) { - setTimeout(() => app.canvas.draw(true, true), 100); - } - } + 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; - 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; - } - }); + if (window.app?.canvas) { + window.app.canvas.setDirty(true, true); + setTimeout(() => window.app.canvas.draw(true, true), 100); } - // Apply widget font size to CSS, this is DOM-only - const widgetTextSize = parseInt(dialog.querySelector('#widget-text-size').value); - let styleTag = document.getElementById('fontifier-widget-text-style'); + const styleId = "fontifier-widget-text-style"; + let styleTag = document.getElementById(styleId); if (!styleTag) { styleTag = document.createElement('style'); - styleTag.id = 'fontifier-widget-text-style'; + styleTag.id = styleId; 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: ${widgetTextSize}px !important; - font-family: ${newValues.FONT_FAMILY} !important; + font-size: ${settings.WIDGET_TEXT_SIZE}px !important; + font-family: ${settings.NODE_FONT} !important; } - - /* Exclude the fontifier dialog itself */ #fontifier-dialog input, #fontifier-dialog select, #fontifier-dialog textarea { font-size: 14px !important; font-family: Arial !important; } `; - - if (permanent) { - currentValues = { ...newValues }; - console.log('🌊✨ Fontifier changes applied permanently (until page refresh)'); - } } - - function findToolbar() { - // Method 1: Look for ComfyUI specific toolbar classes - let toolbar = document.querySelector('.comfyui-menu, .comfy-menu, [class*="menu"], [class*="toolbar"]'); - - // Method 2: Look for button groups - 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 - ); + 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; } - - // Method 3: Look for any container with multiple buttons - if (!toolbar) { - const allElements = document.querySelectorAll('*'); - toolbar = Array.from(allElements).find(el => { - const buttons = el.querySelectorAll('button'); - return buttons.length >= 2 && buttons.length <= 10; // Reasonable toolbar size - }); - } - - // Method 4: Fallback to the original Share button method - if (!toolbar) { - toolbar = Array.from(document.querySelectorAll(".comfyui-button-group")).find(div => - Array.from(div.querySelectorAll("button")).some(btn => btn.title === "Share") - ); - } - - return toolbar; + setTimeout(waitForApp, 100); } - function injectFontifierButton() { - const toolbar = findToolbar(); - - if (toolbar && !document.getElementById("endless-fontifier-button")) { - const colors = getComfyUIColors(); - - const btn = document.createElement("button"); - btn.id = "endless-fontifier-button"; - btn.textContent = "🌊✨ Fontifier"; - btn.className = "comfyui-button"; + waitForApp(); - // Function to update button colors - function updateButtonColors() { - const currentColors = getComfyUIColors(); - btn.style.cssText = ` - margin-left: 8px; - background: ${currentColors.backgroundSecondary}; - border: 1px solid ${currentColors.border}; - color: ${currentColors.text}; - padding: 6px 12px; - border-radius: 4px; - cursor: pointer; - font-size: 14px; - transition: all 0.2s ease; - `; - - btn.onmouseover = () => { - const hoverColors = getComfyUIColors(); - btn.style.background = hoverColors.background; - btn.style.borderColor = hoverColors.text; - }; - - btn.onmouseout = () => { - const outColors = getComfyUIColors(); - btn.style.background = outColors.backgroundSecondary; - btn.style.borderColor = outColors.border; - }; - } - - // Initial colors - updateButtonColors(); - - // Watch for theme changes - const observer = new MutationObserver(updateButtonColors); - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ['class', 'style'] - }); - - btn.onclick = createFontifierDialog; - toolbar.appendChild(btn); - - console.log("✅ 🌊✨ Endless Fontifier button injected successfully!"); - return true; - } - return false; - } - - // Try to inject immediately - if (!injectFontifierButton()) { - // If immediate injection fails, use observer - const observer = new MutationObserver(() => { - if (injectFontifierButton()) { - observer.disconnect(); - } - }); - - observer.observe(document.body, { childList: true, subtree: true }); - - // Timeout after 30 seconds to avoid infinite observation - setTimeout(() => { - observer.disconnect(); - if (!document.getElementById("endless-fontifier-button")) { - console.warn("⚠️ Could not find suitable toolbar for Fontifier button"); - } - }, 30000); - } + // 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 `
+
+ ${type} (${count}) +
`; + }).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 new file mode 100644 index 0000000..24eba94 --- /dev/null +++ b/web/endless_node_loader.js @@ -0,0 +1,870 @@ +// ComfyUI Endless 🌊✨ Node Spawner - 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, + addButtonHoverEffects + } = window.EndlessHelpers; + + console.log("✅ Endless Node Spawner loaded."); + + // State management + let currentDialog = null; + let unregisterThemeCallback = null; + let allNodesData = []; + let currentFilter = ''; + let searchTimeout = null; + let hoverTimeout = null; + + // Persistent data + let recentlyUsedNodes = JSON.parse(localStorage.getItem('endlessNodeLoader_recentlyUsed') || '[]'); + let searchHistory = JSON.parse(localStorage.getItem('endlessNodeLoader_searchHistory') || '[]'); + + // Constants + const MAX_RECENT = 15; + const MAX_HISTORY = 15; + const DEFAULT_SPACING = { x: 300, y: 150 }; + const NODE_PADDING = 20; + + 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 updateTheme() { + if (!currentDialog) return; + + const colors = getComfyUIColors(); + const styleTag = document.getElementById('node-loader-style'); + if (styleTag) { + styleTag.textContent = createStyleCSS(colors); + } + } + + function getApp() { + return window.app || window.comfyApp; + } + + 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 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; + } + } + } + + function spawnNodes(types, spacingX = DEFAULT_SPACING.x, spacingY = DEFAULT_SPACING.y) { + const app = getApp(); + if (!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?.[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 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(); + } + + 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%); + max-width: ${maxWidth}px; + max-height: ${maxHeight}px; + `; + + container.innerHTML = ` +

Endless 🌊✨ Node Spawner Drag Bar

+ +
+
+ +
+ +
+
+ Selected: 0 + Total: 0 +
+
+ +
+
+ + + `; + + document.body.appendChild(container); + currentDialog = container; + + // Setup dragging + makeDraggable(container, container.querySelector('.dialog-title')); + + // 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)); + + allNodesData = nodes; + + // Render hierarchy + const hierarchy = buildHierarchy(nodes); + const tree = renderCategory(hierarchy); + const nodeList = container.querySelector('.node-list'); + tree.forEach(section => nodeList.appendChild(section)); + + // Add hover effects + addButtonHoverEffects(container); + + // Setup event handlers + const escHandler = setupEventHandlers(); + + // Setup theme updates + unregisterThemeCallback = onThemeChange(updateTheme); + + // Initialize + updateRecentChips(); + updateSelectedCounter(); + updateTotalCounter(); + + // Focus filter input + container.querySelector('.filter-input').focus(); + + // 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; + hoverTimeout = null; + originalRemove(); + }; + } + + function closeDialog() { + currentDialog?.remove(); + } + + // 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 new file mode 100644 index 0000000..e7a78ba --- /dev/null +++ b/web/endless_ui_helpers.js @@ -0,0 +1,276 @@ +// === Endless 🌊✨ Tools UI Helper === + +const endlessToolsRegistry = []; + +export function registerEndlessTool(name, callback) { + endlessToolsRegistry.push({ name, callback }); +} + +export function injectEndlessToolsButton() { + const toolbar = findToolbar(); + if (!toolbar || document.getElementById("endless-tools-button")) return; + + const btn = document.createElement("button"); + btn.id = "endless-tools-button"; + btn.textContent = "Endless 🌊✨ Tools"; + btn.className = "comfyui-button"; + btn.style.marginLeft = "8px"; + btn.onclick = showEndlessToolMenu; + toolbar.appendChild(btn); +} + +export function showEndlessToolMenu() { + document.getElementById("endless-tools-float")?.remove(); + + const colors = getComfyUIColors(); + + const menu = document.createElement("div"); + menu.id = "endless-tools-float"; + menu.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + 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; + opacity: 1; + width: fit-content; + transition: opacity 0.2s ease; + `; + + const dragBar = document.createElement("div"); + dragBar.textContent = "Endless 🌊✨ Tools Drag Bar"; + dragBar.style.cssText = ` + padding: 4px; + background: ${toRGBA(colors.inputText, 0.05)}; + cursor: move; + font-size: 12px; + text-align: center; + user-select: none; + border-bottom: 1px solid ${colors.border}; + `; + menu.appendChild(dragBar); + + endlessToolsRegistry.sort((a, b) => a.name.localeCompare(b.name)).forEach(tool => { + const btn = document.createElement("div"); + btn.textContent = `🌊✨ ${tool.name}`; + btn.style.cssText = ` + padding: 6px 10px; + cursor: pointer; + border-radius: 4px; + transition: background 0.2s ease; + `; + btn.onmouseover = () => btn.style.background = toRGBA(colors.inputText, 0.1); + btn.onmouseout = () => btn.style.background = "transparent"; + btn.onclick = () => { + tool.callback(); + menu.remove(); + }; + menu.appendChild(btn); + }); + + 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(); + e.preventDefault(); + } + if (e.key === "Escape") { + document.getElementById("endless-tools-float")?.remove(); + } +}); + +console.log("Endless 🌊✨ Tools menu: press Ctrl+Alt+E if toolbar button is missing."); + +function waitForToolbarAndInject() { + if (document.querySelector('.comfyui-menu')) { + injectEndlessToolsButton(); + return; + } + const observer = new MutationObserver(() => { + if (document.querySelector('.comfyui-menu')) { + injectEndlessToolsButton(); + observer.disconnect(); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); +} +waitForToolbarAndInject(); + +function findToolbar() { + 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")) + ); +} + +// === 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 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'; + document.onmousemove = (e) => { + if (!isDown) return; + element.style.left = `${e.clientX - offsetX}px`; + element.style.top = `${e.clientY - offsetY}px`; + element.style.transform = 'none'; + }; + document.onmouseup = () => { + isDown = false; + element.style.cursor = 'default'; + document.onmousemove = null; + document.onmouseup = null; + }; + }; +} + +// === Global exposure for F12 === +window.EndlessHelpers = { + registerEndlessTool, + injectEndlessToolsButton, + showEndlessToolMenu, + onThemeChange, + getComfyUIColors, + toRGBA, + blendColors, + addButtonHoverEffects, + makeDraggable +};