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>
305 lines
8.4 KiB
JavaScript
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),
|
|
});
|
|
},
|
|
});
|
|
|