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 <noreply@anthropic.com>
This commit is contained in:
Miles
2026-03-04 09:45:20 -08:00
co-authored by Claude Opus 4.6
commit fff96df2ca
5 changed files with 514 additions and 0 deletions
+42
View File
@@ -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
+46
View File
@@ -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
+7
View File
@@ -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 = {}
+115
View File
@@ -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);
}
+304
View File
@@ -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 = `
<div class="nc-header" data-nc-drag>
<div class="nc-title">Node Color</div>
<button class="nc-close" title="Close">✕</button>
</div>
<div class="nc-body">
<div class="nc-row">
<label>R</label>
<input data-nc="r" type="range" min="0" max="255" value="255" />
<input data-nc="rnum" type="number" min="0" max="255" value="255" />
</div>
<div class="nc-row">
<label>G</label>
<input data-nc="g" type="range" min="0" max="255" value="0" />
<input data-nc="gnum" type="number" min="0" max="255" value="0" />
</div>
<div class="nc-row">
<label>B</label>
<input data-nc="b" type="range" min="0" max="255" value="0" />
<input data-nc="bnum" type="number" min="0" max="255" value="0" />
</div>
<div class="nc-row">
<label>A</label>
<input data-nc="a" type="range" min="0" max="1" step="0.01" value="1" />
<input data-nc="anum" type="number" min="0" max="1" step="0.01" value="1" />
</div>
<div class="nc-row" style="grid-template-columns: 56px 1fr 50px;">
<label>Pick</label>
<input data-nc="hex" type="color" value="#ff0000" style="height: 34px; width: 100%; border: none; background: transparent;" />
<div class="nc-preview" data-nc="preview"></div>
</div>
<div class="nc-actions">
<button class="nc-btn" data-nc="useSelected">From Selected</button>
<button class="nc-btn primary" data-nc="apply">Apply</button>
</div>
</div>
`;
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 <input type="color">)
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),
});
},
});