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:
@@ -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
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user