Files
MilesandClaude Opus 4.6 fff96df2ca 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>
2026-03-04 09:45:20 -08:00

305 lines
8.4 KiB
JavaScript

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),
});
},
});