commit fff96df2ca59df1abb0f0a388629735b95c7e98a Author: Miles Date: Wed Mar 4 09:45:20 2026 -0800 Initial commit: Node Colorizer extension Floating RGBA color picker panel for coloring selected ComfyUI nodes. Integrates with the MCE bottom bar. Co-Authored-By: Claude Opus 4.6 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..60b3227 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,42 @@ +# CLAUDE.md — ComfyUI-NodeColorizer + +## Overview + +Pure frontend ComfyUI extension. No backend nodes — `NODE_CLASS_MAPPINGS` is empty. Adds a floating RGBA color picker panel accessible via the MCE bottom bar. + +## File Structure + +``` +__init__.py # Extension entry point (WEB_DIRECTORY = "web", no nodes) +web/ + node_colorizer.js # Main logic: panel creation, color math, MCE pill registration + node_colorizer.css # Dark-themed panel styles +``` + +## Key Architecture + +- **MCE Integration**: imports `registerMcePill` from `/extensions/ComfyUI-MCE/mce_bottom_bar.js` +- **ComfyUI Extension**: registers via `app.registerExtension()` with name `ComfyUI-NodeColorizer.Panel` +- **LiteGraph Nodes**: reads `app.canvas.selected_nodes`, sets `node.color` and `node.bgcolor` +- **Persistence**: stores color in `node.properties.mce_node_color` +- **CSS Loading**: `loadCssOnce()` injects stylesheet link once, avoids duplicates + +## Color Model + +- RGB channels: 0–255 (clamped via `clamp255`) +- Alpha: 0–1 (clamped via `clamp01`) +- Output format: `rgba(r, g, b, a)` CSS string +- Hex picker syncs with RGB only (HTML5 color input doesn't support alpha) + +## Panel Behavior + +- Lazy-created on first open via `makePanel()` +- Fixed position, z-index 10050 +- Draggable by header; constrains to viewport bounds +- Show/hide toggled by MCE pill button + +## Dependencies + +- ComfyUI core (`/scripts/app.js`) +- ComfyUI-MCE (`/extensions/ComfyUI-MCE/mce_bottom_bar.js`) +- No Python packages diff --git a/README.md b/README.md new file mode 100644 index 0000000..60a88ab --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# ComfyUI-NodeColorizer + +A lightweight ComfyUI extension that adds a floating RGBA color picker panel for coloring selected nodes on the canvas. + +![ComfyUI](https://img.shields.io/badge/ComfyUI-Extension-blue) + +## Features + +- **RGBA Color Picker** — RGB sliders (0–255) + alpha slider (0–1) with linked numeric inputs for precision +- **Hex Color Input** — native HTML5 color picker synced bidirectionally with RGB values +- **Live Preview** — real-time color swatch updates as you adjust values +- **From Selected** — grab the current color from a selected node to use as a starting point +- **Batch Apply** — apply the chosen color to all selected nodes at once +- **Draggable Panel** — reposition the floating panel anywhere on screen + +## Requirements + +- [ComfyUI](https://github.com/comfyanonymous/ComfyUI) +- [ComfyUI-MCE](https://github.com/noesclavo/ComfyUI-MCE) — provides the bottom bar pill system + +## Installation + +Clone into your ComfyUI custom nodes directory: + +```bash +cd ComfyUI/custom_nodes +git clone https://github.com/noesclavo/ComfyUI-NodeColorizer.git +``` + +Restart ComfyUI. No Python dependencies required. + +## Usage + +1. Click **Node Color** in the MCE bottom bar +2. Select one or more nodes on the canvas +3. Adjust RGB/Alpha sliders or use the hex color picker +4. Click **Apply** to color the selected nodes +5. Use **From Selected** to sample a node's existing color + +## How It Works + +This is a pure frontend extension — no backend nodes. It registers a pill button in the MCE bottom bar and creates a floating panel that sets `node.color` and `node.bgcolor` on selected LiteGraph nodes. Colors are also persisted in `node.properties.mce_node_color` for downstream tooling. + +## License + +MIT diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..816c779 --- /dev/null +++ b/__init__.py @@ -0,0 +1,7 @@ +# ComfyUI-NodeColorizer: adds a node color picker panel to the MCE bottom bar +WEB_DIRECTORY = "web" + +# No nodes, just a web extension +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} + diff --git a/web/node_colorizer.css b/web/node_colorizer.css new file mode 100644 index 0000000..40b7c88 --- /dev/null +++ b/web/node_colorizer.css @@ -0,0 +1,115 @@ +/* ComfyUI-NodeColorizer floating panel */ + +#mce-node-colorizer-panel { + position: fixed; + z-index: 10050; + width: 320px; + max-width: calc(100vw - 24px); + + border-radius: 14px; + padding: 10px; + + background: rgba(25, 25, 28, 0.95); + border: 1px solid rgba(255, 255, 255, 0.10); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); + + color: rgba(255, 255, 255, 0.92); + font: 13px/1.35 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; +} + +#mce-node-colorizer-panel .nc-header { + display: flex; + align-items: center; + justify-content: space-between; + + padding: 8px 10px; + border-radius: 12px; + + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); + + cursor: move; + user-select: none; +} + +#mce-node-colorizer-panel .nc-title { + font-weight: 700; + letter-spacing: 0.2px; +} + +#mce-node-colorizer-panel .nc-close { + cursor: pointer; + border: none; + background: rgba(255, 255, 255, 0.10); + color: rgba(255, 255, 255, 0.9); + border-radius: 10px; + padding: 6px 10px; +} +#mce-node-colorizer-panel .nc-close:hover { + background: rgba(255, 255, 255, 0.16); +} + +#mce-node-colorizer-panel .nc-body { + padding: 10px; + display: grid; + gap: 10px; +} + +#mce-node-colorizer-panel .nc-row { + display: grid; + grid-template-columns: 56px 1fr 50px; + gap: 10px; + align-items: center; +} + +#mce-node-colorizer-panel .nc-row label { + opacity: 0.85; +} + +#mce-node-colorizer-panel input[type="range"] { + width: 100%; +} + +#mce-node-colorizer-panel input[type="number"] { + width: 100%; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(0, 0, 0, 0.25); + color: rgba(255, 255, 255, 0.92); + padding: 6px 8px; +} + +#mce-node-colorizer-panel .nc-preview { + height: 34px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.06); +} + +#mce-node-colorizer-panel .nc-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +#mce-node-colorizer-panel button.nc-btn { + border: none; + border-radius: 12px; + padding: 9px 12px; + cursor: pointer; + + background: rgba(255, 255, 255, 0.10); + color: rgba(255, 255, 255, 0.92); +} +#mce-node-colorizer-panel button.nc-btn:hover { + background: rgba(255, 255, 255, 0.16); +} + +#mce-node-colorizer-panel button.nc-btn.primary { + background: rgba(255, 0, 0, 0.24); + border: 1px solid rgba(255, 0, 0, 0.30); +} +#mce-node-colorizer-panel button.nc-btn.primary:hover { + background: rgba(255, 0, 0, 0.30); +} + diff --git a/web/node_colorizer.js b/web/node_colorizer.js new file mode 100644 index 0000000..94b5a02 --- /dev/null +++ b/web/node_colorizer.js @@ -0,0 +1,304 @@ +import { app } from "../../scripts/app.js"; +import { registerMcePill } from "/extensions/ComfyUI-MCE/mce_bottom_bar.js"; + +const EXT_NAME = "ComfyUI-NodeColorizer.Panel"; + +let panelEl = null; +let isOpen = false; + +function loadCssOnce() { + const id = "mce-node-colorizer-css"; + if (document.getElementById(id)) return; + + const link = document.createElement("link"); + link.id = id; + link.rel = "stylesheet"; + link.href = "/extensions/ComfyUI-NodeColorizer/node_colorizer.css"; + document.head.appendChild(link); +} + +function clamp01(x) { + x = Number(x); + if (Number.isNaN(x)) return 0; + return Math.min(1, Math.max(0, x)); +} + +function clamp255(x) { + x = Number(x); + if (Number.isNaN(x)) return 0; + return Math.min(255, Math.max(0, Math.round(x))); +} + +function rgbaToCss(r, g, b, a) { + return `rgba(${clamp255(r)}, ${clamp255(g)}, ${clamp255(b)}, ${clamp01(a)})`; +} + +function rgbaToHex(r, g, b) { + const rr = clamp255(r).toString(16).padStart(2, "0"); + const gg = clamp255(g).toString(16).padStart(2, "0"); + const bb = clamp255(b).toString(16).padStart(2, "0"); + return `#${rr}${gg}${bb}`; +} + +function hexToRgb(hex) { + const h = (hex || "").trim(); + const m = /^#?([0-9a-f]{6})$/i.exec(h); + if (!m) return { r: 255, g: 0, b: 0 }; + const n = parseInt(m[1], 16); + return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; +} + +function getSelectedNodes() { + // LiteGraph selection is usually stored in app.canvas.selected_nodes (map) + const sel = app?.canvas?.selected_nodes; + if (!sel) return []; + + // sel can be an object map { [id]: node } + return Object.values(sel).filter(Boolean); +} + +function markCanvasDirty() { + try { + app.graph?.setDirtyCanvas(true, true); + } catch {} + try { + app.canvas?.draw(true, true); + } catch {} +} + +function applyColorToNodes(cssColor) { + const nodes = getSelectedNodes(); + if (!nodes.length) { + alert("No nodes selected.\nSelect one or more nodes, then click Apply."); + return; + } + + for (const node of nodes) { + // LiteGraph nodes typically respect color/bgcolor + node.color = cssColor; + node.bgcolor = cssColor; + + // Optional: persist a hint in properties (handy for later tooling) + node.properties = node.properties || {}; + node.properties.mce_node_color = cssColor; + } + + markCanvasDirty(); +} + +function makePanel() { + if (panelEl) return panelEl; + + panelEl = document.createElement("div"); + panelEl.id = "mce-node-colorizer-panel"; + panelEl.style.display = "none"; + + // Default position near bottom-right-ish, above the bar + panelEl.style.left = "calc(50% + 220px)"; + panelEl.style.bottom = "70px"; + + panelEl.innerHTML = ` +
+
Node Color
+ +
+ +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+ +
+ + +
+
+ +
+ + +
+
+ `; + + document.body.appendChild(panelEl); + + const q = (sel) => panelEl.querySelector(sel); + + const r = q('[data-nc="r"]'); + const g = q('[data-nc="g"]'); + const b = q('[data-nc="b"]'); + const a = q('[data-nc="a"]'); + + const rnum = q('[data-nc="rnum"]'); + const gnum = q('[data-nc="gnum"]'); + const bnum = q('[data-nc="bnum"]'); + const anum = q('[data-nc="anum"]'); + + const hex = q('[data-nc="hex"]'); + const preview = q('[data-nc="preview"]'); + + function syncPreview() { + const css = rgbaToCss(r.value, g.value, b.value, a.value); + preview.style.background = css; + + // keep the hex picker aligned to RGB (alpha not supported by ) + hex.value = rgbaToHex(r.value, g.value, b.value); + } + + function syncFromHex() { + const { r: rr, g: gg, b: bb } = hexToRgb(hex.value); + r.value = rr; g.value = gg; b.value = bb; + rnum.value = rr; gnum.value = gg; bnum.value = bb; + syncPreview(); + } + + function bindPair(rangeEl, numEl, clampFn) { + rangeEl.addEventListener("input", () => { + numEl.value = rangeEl.value; + syncPreview(); + }); + numEl.addEventListener("input", () => { + const v = clampFn(numEl.value); + rangeEl.value = v; + numEl.value = v; + syncPreview(); + }); + } + + bindPair(r, rnum, clamp255); + bindPair(g, gnum, clamp255); + bindPair(b, bnum, clamp255); + + a.addEventListener("input", () => { + anum.value = a.value; + syncPreview(); + }); + anum.addEventListener("input", () => { + const v = clamp01(anum.value); + a.value = v; + anum.value = v; + syncPreview(); + }); + + hex.addEventListener("input", syncFromHex); + + q(".nc-close").addEventListener("click", () => setOpen(false)); + + q('[data-nc="apply"]').addEventListener("click", () => { + const css = rgbaToCss(r.value, g.value, b.value, a.value); + applyColorToNodes(css); + }); + + // Grab the first selected node's color as the UI color + q('[data-nc="useSelected"]').addEventListener("click", () => { + const nodes = getSelectedNodes(); + if (!nodes.length) { + alert("No nodes selected."); + return; + } + const n = nodes[0]; + const c = (n?.bgcolor || n?.color || "").toString(); + + // Try parse rgba(...) if present; otherwise ignore + const m = /^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)(?:\s*,\s*([0-9.]+))?\s*\)$/i.exec(c); + if (m) { + const rr = clamp255(m[1]), gg = clamp255(m[2]), bb = clamp255(m[3]); + const aa = m[4] == null ? 1 : clamp01(m[4]); + + r.value = rr; g.value = gg; b.value = bb; a.value = aa; + rnum.value = rr; gnum.value = gg; bnum.value = bb; anum.value = aa; + syncPreview(); + } else { + // fallback: keep UI as-is but still sync preview + syncPreview(); + } + }); + + // Dragging + const header = q('[data-nc-drag]'); + let dragging = false; + let startX = 0, startY = 0; + let startLeft = 0, startTop = 0; + + function px(n) { return `${Math.round(n)}px`; } + + header.addEventListener("mousedown", (e) => { + dragging = true; + startX = e.clientX; + startY = e.clientY; + + const rect = panelEl.getBoundingClientRect(); + startLeft = rect.left; + startTop = rect.top; + + // When dragging, switch to top/left positioning for stability + panelEl.style.left = px(startLeft); + panelEl.style.top = px(startTop); + panelEl.style.bottom = "auto"; + + e.preventDefault(); + }); + + window.addEventListener("mousemove", (e) => { + if (!dragging) return; + const dx = e.clientX - startX; + const dy = e.clientY - startY; + + const newLeft = Math.max(8, Math.min(window.innerWidth - panelEl.offsetWidth - 8, startLeft + dx)); + const newTop = Math.max(8, Math.min(window.innerHeight - panelEl.offsetHeight - 8, startTop + dy)); + + panelEl.style.left = px(newLeft); + panelEl.style.top = px(newTop); + }); + + window.addEventListener("mouseup", () => { + dragging = false; + }); + + // initialize preview + syncPreview(); + return panelEl; +} + +function setOpen(next) { + isOpen = !!next; + const el = makePanel(); + el.style.display = isOpen ? "block" : "none"; +} + +app.registerExtension({ + name: EXT_NAME, + + async setup() { + loadCssOnce(); + + registerMcePill({ + id: "node_color", + label: "Node Color", + // Put it wherever you want in the bar ordering + // (Example: WF=10, Files=20, Node Color=30, Restart=40) + order: 30, + getIsOpen: () => isOpen, + onToggle: () => setOpen(!isOpen), + }); + }, +}); +