feat: Nodes Ui Refacto

This commit is contained in:
Ewald
2026-05-26 07:52:30 +02:00
parent 917509078e
commit f1e74cef92
38 changed files with 1589 additions and 3216 deletions
+172 -29
View File
@@ -10,10 +10,10 @@ import { getPreviewConfig } from "./config.js";
import { initOpsConstants } from "./constants.js";
import { getCompSlots } from "./comp.js";
import { renderCompPreview } from "./ops.js";
import { findWidget, hideCompactUiWidgets, resetNodeWidgetsToDefaults, setWidgetStringValue, widgetNumber, widgetString } from "./shared/widgets.js";
import { findWidget, resetNodeWidgetsToDefaults, setWidgetStringValue, widgetNumber, widgetString } from "./shared/widgets.js";
import { getProceduralFrameCount, hasProceduralAnimation, getProceduralPlaybackFps } from "./shared/animation.js";
import { getInputIndexByName, getNativePreviewImage } from "./shared/media.js";
import { isImageOpsClass, isImageOpsNativeUiClass } from "./shared/classes.js";
import { isImageOpsClass } from "./shared/classes.js";
import { isNode as isPreviewNode, hidePreviewWidgets, syncPreviewWidgets } from "./nodes/preview.js";
import { isNode as isConstantNode, getConstantInfoText, hideConstantWidgets, syncConstantWidgets } from "./nodes/constant.js";
import { isNode as isGrainNode, getGrainInfoText, hideGrainWidgets, syncGrainWidgets } from "./nodes/grain.js";
@@ -35,7 +35,8 @@ import {
writeCompLayerCorners,
syncDarkColorInputUI,
setDarkColorInputState,
getCompInfoText
getCompInfoText,
syncCompWidgets
} from "./nodes/comp.js";
import { isNode as isCornerPinNode, getCornerPinInfoText } from "./nodes/corner-pin.js";
import { applyPadOutTargetFormat, attachPadOutControls, getPadOutInfoText, hidePadOutWidgets, hydratePadOutTargetFormat, isNode as isPadOutNode, syncPadOutControls } from "./nodes/pad-out.js";
@@ -43,7 +44,7 @@ import { isNode as isJoinNode, ensureJoinInputs, hideJoinWidgets, getJoinPreview
import { ensureState, setInfo, schedule, stopRAF, markPreviewInteraction, getRenderCanvasSize, buildPreviewRenderKey } from "./shared/state.js";
import { markCanvasDirty } from "./shared/canvas.js";
import { noteFrame } from "./shared/fps-monitor.js";
import { ensurePreviewWidget, getNodePreviewMinHeight, getNodePreviewTargetSize, syncCompactNativeWidgetControls } from "./shared/preview-widget.js";
import { ensurePreviewWidget, getNodePreviewMinHeight, getNodePreviewTargetSize } from "./shared/preview-widget.js";
import { blit, tryRenderNativePreview } from "./shared/bounds.js";
import { attachPreviewNavigation } from "./shared/navigation.js";
import {
@@ -93,6 +94,168 @@ function hydrateKeyerDefaults(node, nodeData) {
}
}
function registerImageOpsLivePreview() {
if (typeof document !== "undefined" && !document.getElementById("comfyui-imageops-styles")) {
const style = document.createElement("style");
style.id = "comfyui-imageops-styles";
style.textContent = `
/* Scrollbars in custom UI panel */
.comfy-menu::-webkit-scrollbar,
.comfy-panel::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.comfy-menu::-webkit-scrollbar-thumb,
.comfy-panel::-webkit-scrollbar-thumb {
background: #444;
border-radius: 3px;
}
.comfy-menu::-webkit-scrollbar-track,
.comfy-panel::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
}
/* Fields & Inputs */
.comfy-input {
font-family: var(--comfy-font-sans, Inter, sans-serif);
font-size: 11px !important;
border-radius: 4px !important;
border: 1px solid #3f3f3f !important;
background: #1c1c1c !important;
color: #fff !important;
padding: 4px 6px !important;
box-sizing: border-box !important;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.comfy-input:focus {
border-color: #555 !important;
outline: none !important;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.05);
}
/* Buttons */
.comfy-btn {
font-family: var(--comfy-font-sans, Inter, sans-serif);
font-size: 11px !important;
border-radius: 4px !important;
border: 1px solid #3f3f3f !important;
background: #2b2b2b !important;
color: #ccc !important;
padding: 4px 8px !important;
cursor: pointer !important;
line-height: 1.2 !important;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
user-select: none;
}
.comfy-btn:hover {
background: #3e3e3e !important;
color: #fff !important;
border-color: #555 !important;
}
.comfy-btn:active {
background: #1c1c1c !important;
color: #aaa !important;
}
.comfy-btn.active {
background: #444 !important;
color: #fff !important;
border-color: #666 !important;
font-weight: 500;
}
/* Range/Slider Input */
.comfy-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
background: #151515 !important;
border: 1px solid #2e2e2e !important;
border-radius: 3px !important;
outline: none !important;
margin: 6px 0 !important;
cursor: ew-resize !important;
transition: border-color 0.15s ease;
}
.comfy-slider:hover {
border-color: #3f3f3f !important;
}
/* WebKit Slider Thumb (Chrome, Safari, Edge) */
.comfy-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #ccc;
border: 1px solid #444;
cursor: ew-resize;
transition: background-color 0.15s ease, transform 0.1s ease;
}
.comfy-slider::-webkit-slider-thumb:hover {
background: #fff;
transform: scale(1.15);
}
.comfy-slider::-webkit-slider-thumb:active {
background: #aaa;
}
/* Firefox Slider Thumb */
.comfy-slider::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: #ccc;
border: 1px solid #444;
cursor: ew-resize;
transition: background-color 0.15s ease, transform 0.1s ease;
}
.comfy-slider::-moz-range-thumb:hover {
background: #fff;
transform: scale(1.15);
}
/* Details Panel (Collapsible Panel) */
.comfy-details {
margin-top: 8px !important;
background: rgba(255, 255, 255, 0.02) !important;
border: 1px solid #333333 !important;
border-radius: 4px !important;
padding: 4px 8px 8px !important;
font-family: var(--comfy-font-sans, Inter, sans-serif);
}
.comfy-details summary {
cursor: pointer;
font-size: 11px;
font-weight: 600;
opacity: 0.85;
padding: 6px 0;
user-select: none;
color: #fff;
}
.comfy-details summary:hover {
opacity: 1;
}
/* Custom Dropdown Context Menu */
.comfy-menu {
background: #1c1c1f !important;
border: 1px solid #3e3e3e !important;
border-radius: 4px !important;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6) !important;
box-sizing: border-box;
}
.comfy-menu button {
border-radius: 3px !important;
font-family: var(--comfy-font-sans, Inter, sans-serif);
transition: background-color 0.1s ease;
}
.comfy-menu button:hover {
background: #3e3e42 !important;
}
`;
document.head.appendChild(style);
}
initOpsConstants();
const cfg = getPreviewConfig();
const canvasSize = cfg.canvasSize;
@@ -170,12 +333,6 @@ function registerImageOpsLivePreview() {
st.drawOverlayKey = null;
syncDrawWidgets(node);
}
if (isImageOpsNativeUiClass(node.comfyClass)) {
syncCompactNativeWidgetControls(node);
}
if (isCompNode(node)) {
syncCompactNativeWidgetControls(node);
}
if (isCornerPinNode(node) || isPadOutNode(node) || isRampNode(node)) {
if (st.canvas) st.canvas.style.cursor = "default";
}
@@ -914,10 +1071,6 @@ function registerImageOpsLivePreview() {
if (isRampNode(node)) syncRampWidgets(node);
if (isDrawNode(node)) syncDrawWidgets(node);
if (isKeyerNode(node)) syncKeyerWidgets(node);
if (isImageOpsNativeUiClass(node.comfyClass) || isCompNode(node)) {
hideCompactUiWidgets(node);
syncCompactNativeWidgetControls(node);
}
} catch (e) {
console.warn("[ImageOps] late widget sync failed for", node?.comfyClass, e);
}
@@ -927,11 +1080,6 @@ function registerImageOpsLivePreview() {
if (isImageOpsClass(node.comfyClass)) {
node.previewMediaType = "image";
ensurePreviewWidget(node, progress, canvasSize, () => nodeCtx.refreshNode(node));
if (isImageOpsNativeUiClass(node.comfyClass) || isCompNode(node)) {
hideCompactUiWidgets(node);
syncCompactNativeWidgetControls(node);
}
node;
attachPreviewNavigation(node, canvasSize);
if (isPreviewNode(node)) {
attachPreviewInteractionsExt(node, nodeCtx);
@@ -996,13 +1144,14 @@ function registerImageOpsLivePreview() {
}
if (isCompNode(node)) {
ensureCompState(node);
syncCompWidgets(node, w.name);
updateCompControls(node);
}
if (isPreviewNode(node)) {
syncPreviewWidgets(node);
}
if (isConstantNode(node)) {
syncConstantWidgets(node);
syncConstantWidgets(node, w.name);
}
if (isGrainNode(node)) {
syncGrainWidgets(node);
@@ -1020,18 +1169,15 @@ function registerImageOpsLivePreview() {
syncKeyerWidgets(node);
}
if (isPadOutNode(node)) {
if (w.name === "target_format") {
if (w.name === "aspect_ratio") {
st.padOutRatioHydrated = false;
hydratePadOutTargetFormat(node);
} else if (w.name === "snap_to_multiple") {
const targetFormat = widgetString(node, "target_format", "custom");
const targetFormat = widgetString(node, "aspect_ratio", "custom");
if (targetFormat !== "custom") applyPadOutTargetFormat(node, targetFormat);
}
syncPadOutControls(node);
}
if (isImageOpsNativeUiClass(node.comfyClass) || isCompNode(node)) {
syncCompactNativeWidgetControls(node);
}
const loopRunning = !!st.rafId;
schedule(node, () => {
if (isImageOpsClass(node.comfyClass)) startLoopIfVideo(node);
@@ -1133,6 +1279,7 @@ function registerImageOpsLivePreview() {
if (isCompNode(node) && prop === "onConfigure") {
hideCompWidgets(node);
ensureCompState(node);
syncCompWidgets(node);
st.compInteractiveHooked = false;
st.compKeyboardHooked = false;
attachCompInteractionsExt(node, compCtx);
@@ -1168,10 +1315,6 @@ function registerImageOpsLivePreview() {
hideFrameSelectorWidgets(node);
syncFrameSelectorWidgets(node);
}
if ((isImageOpsNativeUiClass(node.comfyClass) || isCompNode(node)) && prop === "onConfigure") {
hideCompactUiWidgets(node);
syncCompactNativeWidgetControls(node);
}
if (prop === "onConfigure" && isImageOpsClass(node.comfyClass)) {
const minH = getNodePreviewMinHeight(node);
setTimeout(() => {
+5 -17
View File
@@ -1,5 +1,5 @@
import { clampCompScale, clampCompRotation, getCompSlots, getCompLayerOutputCorners } from "../comp.js";
import { findWidget, resetNodeWidgetsToDefaults, setWidgetStringValue, setWidgetValue, widgetBoolean, widgetNumber } from "../shared/widgets.js";
import { findWidget, resetNodeWidgetsToDefaults, setWidgetValue, widgetBoolean, widgetNumber } from "../shared/widgets.js";
import {
cloneCompCorners,
compDragHandleToCorner,
@@ -26,10 +26,13 @@ function rotatePoint(x, y, angleRad) {
return { x: x * cos - y * sin, y: x * sin + y * cos };
}
function aspectRatioValue(value) {
switch (String(value || "free").trim().toLowerCase()) {
switch (String(value || "custom").trim().toLowerCase()) {
case "1/1":
case "1:1":
return 1;
case "3/4":
case "3:4":
return 3 / 4;
case "4/3":
case "4:3":
return 4 / 3;
@@ -116,21 +119,6 @@ function attachInteractions(node, ctx) {
ctx.markPreviewInteraction(node);
ctx.markCanvasDirty();
});
if (st.compAspectRatioSelect && !st.compAspectRatioSelect.dataset.compAspectHooked) {
st.compAspectRatioSelect.dataset.compAspectHooked = "1";
st.compAspectRatioSelect.addEventListener("change", () => {
const value = st.compAspectRatioSelect?.value ?? "free";
setWidgetStringValue(findWidget(node, "aspect_ratio"), value);
applyCompAspectRatio(node, value);
ctx.updateCompControls(node);
ctx.markPreviewInteraction(node);
ctx.markCanvasDirty();
ctx.schedule(node, () => {
ctx.startLoopIfVideo(node);
ctx.refreshDependents(node);
}, 0);
});
}
st.compModeSelect?.addEventListener("change", () => {
ctx.updateSelectedCompLayer(node, (layer) => {
layer.mode = st.compModeSelect?.value ?? "over";
-22
View File
@@ -22,21 +22,6 @@ function attachInteractions(node, ctx) {
syncConstantWidgets(node);
ctx.refreshNode(node);
};
const ratioSelect = root.querySelector('select[data-constant-ratio="1"]');
const applyRatioPreset = (changedField) => {
const preset = String(ratioSelect?.value ?? "custom");
const ratio = CONSTANT_RATIO_PRESETS[preset];
if (!ratio) return;
const widthWidget = findWidget(node, "width");
const heightWidget = findWidget(node, "height");
const currentWidth = clampInt(Number(widthWidget?.value ?? 1024), 1024, 1, 8192);
const currentHeight = clampInt(Number(heightWidget?.value ?? 1024), 1024, 1, 8192);
if (changedField === "width") {
setWidgetValue(heightWidget, clampInt(currentWidth / ratio, currentHeight, 1, 8192));
} else {
setWidgetValue(widthWidget, clampInt(currentHeight * ratio, currentWidth, 1, 8192));
}
};
for (const button of Array.from(root.querySelectorAll("button[data-constant-mode]"))) {
button.addEventListener("click", (event) => {
event.preventDefault();
@@ -65,13 +50,6 @@ function attachInteractions(node, ctx) {
refresh();
});
}
ratioSelect?.addEventListener("change", () => {
const preset = String(ratioSelect.value ?? "custom");
if (preset !== "custom") {
applyRatioPreset("width");
}
refresh();
});
syncConstantWidgets(node);
}
export {
+62 -49
View File
@@ -26,9 +26,58 @@ function attachInteractions(node, ctx) {
const w = worldPointer(x, y);
return canvasToDrawSourcePoint(st.drawGeometry, w.x, w.y);
};
let ctrlPanDrag = null;
let panRafPending = false;
let hoverRafPending = false;
let panRafPending = false;
const startPreviewPan = (event) => {
const rect = canvas.getBoundingClientRect();
if (rect.width < 1 || rect.height < 1) return false;
const sx = canvas.width / rect.width;
const sy = canvas.height / rect.height;
st.previewPanDrag = {
pointerId: event.pointerId,
startCanvasX: (event.clientX - rect.left) * sx,
startCanvasY: (event.clientY - rect.top) * sy,
startPanX: st.previewPanX ?? 0,
startPanY: st.previewPanY ?? 0
};
try {
canvas.setPointerCapture?.(event.pointerId);
} catch {
}
canvas.style.cursor = "grabbing";
return true;
};
const updatePreviewPan = (event) => {
const drag = st.previewPanDrag;
if (!drag || drag.pointerId !== event.pointerId) return false;
const rect = canvas.getBoundingClientRect();
if (rect.width < 1 || rect.height < 1) return true;
const sx = canvas.width / rect.width;
const sy = canvas.height / rect.height;
const cx = (event.clientX - rect.left) * sx;
const cy = (event.clientY - rect.top) * sy;
st.previewPanX = drag.startPanX + (cx - drag.startCanvasX);
st.previewPanY = drag.startPanY + (cy - drag.startCanvasY);
ctx.markPreviewInteraction(node);
if (!panRafPending) {
panRafPending = true;
requestAnimationFrame(() => {
panRafPending = false;
void ctx.renderDrawNode(node, 0);
});
}
return true;
};
const releasePreviewPan = (event) => {
if (!st.previewPanDrag || st.previewPanDrag.pointerId !== event.pointerId) return false;
st.previewPanDrag = null;
canvas.style.cursor = event.ctrlKey || event.metaKey ? "grab" : "";
try {
canvas.releasePointerCapture?.(event.pointerId);
} catch {
}
return true;
};
if (!st.drawGeometry || !st.drawCanvas) {
void ctx.renderDrawNode(node, 0);
}
@@ -204,23 +253,9 @@ function attachInteractions(node, ctx) {
canvas.addEventListener("pointerdown", async (event) => {
if ((event.ctrlKey || event.metaKey) && event.button === 0) {
event.preventDefault();
event.stopPropagation();
canvas.focus();
try {
canvas.setPointerCapture?.(event.pointerId);
} catch {
}
const rect = canvas.getBoundingClientRect();
if (rect.width < 1 || rect.height < 1) return;
const sx = canvas.width / rect.width;
const sy = canvas.height / rect.height;
ctrlPanDrag = {
pointerId: event.pointerId,
startCanvasX: (event.clientX - rect.left) * sx,
startCanvasY: (event.clientY - rect.top) * sy,
startPanX: st.previewPanX ?? 0,
startPanY: st.previewPanY ?? 0
};
canvas.style.cursor = "grabbing";
startPreviewPan(event);
return;
}
if (st.drawGeometry) {
@@ -258,22 +293,9 @@ function attachInteractions(node, ctx) {
void ctx.renderDrawNode(node, 0);
});
canvas.addEventListener("pointermove", (event) => {
if (ctrlPanDrag && ctrlPanDrag.pointerId === event.pointerId) {
const rect = canvas.getBoundingClientRect();
if (rect.width < 1 || rect.height < 1) return;
const sx = canvas.width / rect.width;
const sy = canvas.height / rect.height;
const cx = (event.clientX - rect.left) * sx;
const cy = (event.clientY - rect.top) * sy;
st.previewPanX = ctrlPanDrag.startPanX + (cx - ctrlPanDrag.startCanvasX);
st.previewPanY = ctrlPanDrag.startPanY + (cy - ctrlPanDrag.startCanvasY);
if (!panRafPending) {
panRafPending = true;
requestAnimationFrame(() => {
panRafPending = false;
void ctx.renderDrawNode(node, 0);
});
}
if (updatePreviewPan(event)) {
event.preventDefault();
event.stopPropagation();
return;
}
canvas.style.cursor = event.ctrlKey || event.metaKey ? "grab" : "";
@@ -311,15 +333,6 @@ function attachInteractions(node, ctx) {
void ctx.renderDrawNode(node, 0);
});
const releaseStroke = (event) => {
if (ctrlPanDrag && ctrlPanDrag.pointerId === event.pointerId) {
ctrlPanDrag = null;
canvas.style.cursor = "";
try {
canvas.releasePointerCapture?.(event.pointerId);
} catch {
}
return;
}
if (!st.drawStroke || st.drawStroke.pointerId !== event.pointerId) return;
st.drawStroke = null;
try {
@@ -329,16 +342,16 @@ function attachInteractions(node, ctx) {
ctx.updateDrawOverlayWidget(node);
ctx.refreshNode(node);
};
canvas.addEventListener("pointerup", releaseStroke);
canvas.addEventListener("pointerup", (event) => {
if (releasePreviewPan(event)) return;
releaseStroke(event);
});
canvas.addEventListener("pointercancel", (event) => {
if (ctrlPanDrag && ctrlPanDrag.pointerId === event.pointerId) {
ctrlPanDrag = null;
canvas.style.cursor = "";
}
if (releasePreviewPan(event)) return;
releaseStroke(event);
});
canvas.addEventListener("pointerleave", () => {
if (!st.drawStroke && !ctrlPanDrag) {
if (!st.drawStroke && !st.previewPanDrag) {
st.drawHover = null;
canvas.style.cursor = "";
void ctx.renderDrawNode(node, 0);
+54 -19
View File
@@ -1,7 +1,8 @@
import { COMP_BLEND_MODES, getCompSlots, serializeCompLayers, syncCompLayers, clampCompCenter } from "../comp.js";
import { getFitPlacement } from "../shared/geometry.js";
import { findWidget, hideWidgetForGood, setWidgetStringValue, widgetBoolean, widgetString } from "../shared/widgets.js";
import { findWidget, hideWidgetForGood, setWidgetStringValue, widgetBoolean, widgetString, widgetNumber, setWidgetValue } from "../shared/widgets.js";
import { ensureState } from "../shared/state.js";
import { markCanvasDirty } from "../shared/canvas.js";
import {
createContextMenuSelect,
setControlDisabled,
@@ -18,7 +19,7 @@ function createCompControlsUi() {
const controls = document.createElement("div");
controls.style.marginTop = "8px";
controls.style.display = "grid";
controls.style.gridTemplateColumns = "auto auto auto auto auto minmax(0, 1fr) auto";
controls.style.gridTemplateColumns = "auto auto auto auto auto minmax(0, 1fr)";
controls.style.gap = "6px";
controls.style.alignItems = "center";
controls.style.minWidth = "0";
@@ -46,17 +47,6 @@ function createCompControlsUi() {
cornerPinButton.type = "button";
cornerPinButton.textContent = "Pin";
styleSoftButton(cornerPinButton, false);
const aspectRatioSelect = document.createElement("select");
aspectRatioSelect.title = "Custom Format aspect ratio";
aspectRatioSelect.style.width = "100%";
aspectRatioSelect.style.minWidth = "0";
styleSoftField(aspectRatioSelect);
for (const [value, label] of [["free", "Free"], ["1/1", "1/1"], ["4/3", "4/3"], ["16/9", "16/9"], ["9/16", "9/16"]]) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
aspectRatioSelect.appendChild(option);
}
const layerLabel = document.createElement("div");
layerLabel.style.fontSize = "11px";
layerLabel.style.opacity = "0.85";
@@ -97,7 +87,6 @@ function createCompControlsUi() {
controls.appendChild(resetButton);
controls.appendChild(resizeButton);
controls.appendChild(cornerPinButton);
controls.appendChild(createContextMenuSelect(aspectRatioSelect));
controls.appendChild(layerLabel);
bottomRow.appendChild(createContextMenuSelect(modeSelect));
bottomRow.appendChild(opacityLabel);
@@ -110,7 +99,7 @@ function createCompControlsUi() {
resetButton,
resizeButton,
cornerPinButton,
aspectRatioSelect,
aspectRatioSelect: null,
modeSelect,
opacityInput,
opacityLabel,
@@ -119,7 +108,52 @@ function createCompControlsUi() {
}
function hideCompWidgets(node) {
hideWidgetForGood(node, findWidget(node, "layers_json"));
hideWidgetForGood(node, findWidget(node, "aspect_ratio"));
}
function aspectRatioValue(value) {
switch (String(value || "custom").trim().toLowerCase()) {
case "1/1":
case "1:1":
return 1;
case "3/4":
case "3:4":
return 3 / 4;
case "4/3":
case "4:3":
return 4 / 3;
case "16/9":
case "16:9":
return 16 / 9;
case "9/16":
case "9:16":
return 9 / 16;
default:
return null;
}
}
function syncCompWidgets(node, changedName, notify = true) {
if (!isNode(node)) return;
if (widgetBoolean(node, "use_first_layer_size", true) || widgetBoolean(node, "auto_layering", false)) {
return;
}
const widthWidget = findWidget(node, "width");
const heightWidget = findWidget(node, "height");
if (!widthWidget || !heightWidget) return;
const preset = widgetString(node, "aspect_ratio", "custom");
if (preset === "custom") {
return;
}
const ratio = aspectRatioValue(preset);
if (!ratio) return;
let width = Math.max(1, Math.round(widgetNumber(node, "width", 1024)));
let height = Math.max(1, Math.round(widgetNumber(node, "height", 1024)));
if (changedName === "height") {
width = Math.max(1, Math.round(height * ratio));
setWidgetValue(widthWidget, width, { notify });
} else {
height = Math.max(1, Math.round(width / ratio));
setWidgetValue(heightWidget, height, { notify });
}
markCanvasDirty();
}
function ensureCompInputs(node, minLayers = 1) {
if (!isNode(node) || !node.addInput) return;
@@ -181,8 +215,8 @@ function getCompInfoText(_node, connectedLayers, totalLayers, width, height) {
return `Comp preview (${connectedLayers}/${totalLayers} layers, ${width}x${height})`;
}
function normalizeCompAspectRatioValue(value) {
const normalized = String(value || "free").trim().toLowerCase().replace(":", "/");
return ["1/1", "4/3", "16/9", "9/16"].includes(normalized) ? normalized : "free";
const normalized = String(value || "custom").trim().toLowerCase().replace(":", "/");
return ["1/1", "3/4", "4/3", "16/9", "9/16"].includes(normalized) ? normalized : "custom";
}
function getCompCursor(mode) {
switch (mode) {
@@ -258,7 +292,7 @@ function updateCompControls(node) {
const selected = layers.find((layer) => layer.slot === st.compSelectedSlot) ?? layers[layers.length - 1] ?? null;
const customFormat = !widgetBoolean(node, "use_first_layer_size", true) && !widgetBoolean(node, "auto_layering", false);
if (st.compAspectRatioSelect) {
st.compAspectRatioSelect.value = normalizeCompAspectRatioValue(widgetString(node, "aspect_ratio", "free"));
st.compAspectRatioSelect.value = normalizeCompAspectRatioValue(widgetString(node, "aspect_ratio", "custom"));
setControlDisabled(st.compAspectRatioSelect, !customFormat);
st.compAspectRatioSelect.title = customFormat ? "Custom Format aspect ratio" : "Aspect ratio is only used when Custom Format is active";
}
@@ -409,6 +443,7 @@ export {
readCompLayers,
removeSelectedCompLayer,
setDarkColorInputState2 as setDarkColorInputState,
syncCompWidgets,
syncDarkColorInputUI2 as syncDarkColorInputUI,
updateCompControls,
updateSelectedCompLayer,
+42 -25
View File
@@ -1,12 +1,10 @@
import { ensureState } from "../shared/state.js";
import { resolveImageOpsClassName } from "../shared/classes.js";
import { hideWidgetsByName, setWidgetStringValuesByName, widgetNumber, widgetString } from "../shared/widgets.js";
import { findWidget, hideWidgetsByName, setWidgetStringValuesByName, widgetNumber, widgetString, setWidgetValue } from "../shared/widgets.js";
import {
createColorSwatch,
createContextMenuSelect,
setDarkColorInputState,
styleSoftButton,
styleSoftField,
styleSoftRange,
syncDarkColorInputUI
} from "../shared/dom-styles.js";
@@ -60,23 +58,6 @@ function createConstantControlsUi() {
modeRow.appendChild(button);
}
controls.appendChild(modeRow);
const sizeRow = makeRow();
sizeRow.style.gridTemplateColumns = "auto minmax(70px,96px)";
sizeRow.appendChild(makeLabel("Ratio"));
const ratioSelect = document.createElement("select");
ratioSelect.dataset.constantRatio = "1";
ratioSelect.title = "Standard ratio or Free";
ratioSelect.style.width = "100%";
ratioSelect.style.minWidth = "0";
styleSoftField(ratioSelect);
for (const [value, label] of [["custom", "Free"], ["1:1", "1:1"], ["16:9", "16:9"], ["9:16", "9:16"], ["4:3", "4:3"], ["3:4", "3:4"]]) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
ratioSelect.appendChild(option);
}
sizeRow.appendChild(createContextMenuSelect(ratioSelect));
controls.appendChild(sizeRow);
const alphaRow = makeRow();
alphaRow.style.gridTemplateColumns = "auto minmax(0,1fr) auto";
alphaRow.appendChild(makeLabel("Alpha"));
@@ -131,7 +112,28 @@ function getConstantInfoText(node) {
const frameCount = Math.max(1, Math.round(widgetNumber(node, "frame_count", widgetNumber(node, "frame_length", widgetNumber(node, "batch_size", 1)))));
return `${mode} preview (${width}x${height}, frames ${frameCount})`;
}
function syncConstantWidgets(node) {
function aspectRatioValue(value) {
switch (String(value || "custom").trim().toLowerCase()) {
case "1/1":
case "1:1":
return 1;
case "3/4":
case "3:4":
return 3 / 4;
case "4/3":
case "4:3":
return 4 / 3;
case "16/9":
case "16:9":
return 16 / 9;
case "9/16":
case "9:16":
return 9 / 16;
default:
return null;
}
}
function syncConstantWidgets(node, changedName, notify = true) {
if (!isNode(node)) return;
hideConstantWidgets(node);
const colorA = widgetString(node, "color", "#ffffff");
@@ -141,12 +143,27 @@ function syncConstantWidgets(node) {
const st = ensureState(node);
const root = st.previewRoot;
if (!root) return;
const widthWidget = findWidget(node, "width");
const heightWidget = findWidget(node, "height");
if (widthWidget && heightWidget) {
const preset = widgetString(node, "aspect_ratio", "custom");
if (preset !== "custom") {
const ratio = aspectRatioValue(preset);
if (ratio) {
let width = Math.max(1, Math.round(widgetNumber(node, "width", 1024)));
let height = Math.max(1, Math.round(widgetNumber(node, "height", 1024)));
if (changedName === "height") {
width = Math.max(1, Math.round(height * ratio));
setWidgetValue(widthWidget, width, { notify });
} else {
height = Math.max(1, Math.round(width / ratio));
setWidgetValue(heightWidget, height, { notify });
}
}
}
}
const mode = widgetString(node, "mode", "constant").toLowerCase() === "checkerboard" ? "checkerboard" : "constant";
const width = Math.max(1, Math.round(widgetNumber(node, "width", 1024)));
const height = Math.max(1, Math.round(widgetNumber(node, "height", 1024)));
const alpha = Math.max(0, Math.min(1, widgetNumber(node, "alpha", 1)));
const ratioSelect = root.querySelector('select[data-constant-ratio="1"]');
if (ratioSelect) ratioSelect.value = resolveConstantRatioPreset(width, height);
const alphaInput = root.querySelector('input[data-constant-field="alpha"]');
if (alphaInput) alphaInput.value = String(Math.round(alpha * 100));
const alphaLabel = root.querySelector("[data-constant-alpha-label]");
+185 -186
View File
@@ -1,13 +1,8 @@
import { findWidget, widgetNumber, widgetString, widgetBoolean, hideWidgetsByName, setWidgetValue, setWidgetStringValue, setWidgetStringValuesByName, setWidgetBooleanValue } from "../shared/widgets.js";
import { ensureState } from "../shared/state.js";
import {
createColorSwatch,
createContextMenuSelect,
setDarkColorInputState,
styleInlineAction,
styleSoftButton,
styleSoftField,
styleSoftRange,
syncDarkColorInputUI
} from "../shared/dom-styles.js";
import {
@@ -25,209 +20,198 @@ const NODE_CLASS = "ImageOpsDraw";
function isNode(node) {
return String(node?.comfyClass ?? "") === NODE_CLASS;
}
function styleSegmentControl(container) {
container.style.display = "flex";
container.style.background = "#181818";
container.style.borderRadius = "6px";
container.style.padding = "2px";
container.style.border = "1px solid #2e2e2e";
container.style.width = "100%";
container.style.boxSizing = "border-box";
}
function styleSegmentButton(button, active) {
button.style.flex = "1";
button.style.border = "none";
button.style.outline = "none";
button.style.borderRadius = "4px";
button.style.padding = "4px 8px";
button.style.fontSize = "10.5px";
button.style.fontFamily = "var(--comfy-font-sans, Inter, sans-serif)";
button.style.cursor = "pointer";
button.style.transition = "background 0.15s, color 0.15s";
button.style.lineHeight = "1.2";
button.style.textAlign = "center";
if (active) {
button.classList.add("active");
button.style.background = "#525252";
button.style.color = "#ffffff";
button.style.fontWeight = "600";
} else {
button.classList.remove("active");
button.style.background = "transparent";
button.style.color = "#aaaaaa";
button.style.fontWeight = "normal";
}
}
function setupSegmentHover(button) {
button.addEventListener("mouseenter", () => {
if (!button.classList.contains("active")) {
button.style.background = "rgba(255,255,255,0.06)";
button.style.color = "#ffffff";
}
});
button.addEventListener("mouseleave", () => {
if (!button.classList.contains("active")) {
button.style.background = "transparent";
button.style.color = "#aaaaaa";
}
});
}
function createDrawControlsUi() {
const controls = document.createElement("div");
controls.style.marginTop = "8px";
controls.style.display = "grid";
controls.style.gap = "8px";
const topRow = document.createElement("div");
topRow.style.display = "flex";
topRow.style.alignItems = "center";
topRow.style.justifyContent = "space-between";
topRow.style.gap = "8px";
const toolRow = document.createElement("div");
toolRow.style.display = "flex";
toolRow.style.alignItems = "center";
toolRow.style.gap = "6px";
controls.style.gridTemplateColumns = "72px 1fr";
controls.style.rowGap = "8px";
controls.style.columnGap = "12px";
controls.style.alignItems = "center";
controls.style.width = "100%";
controls.style.boxSizing = "border-box";
controls.style.padding = "0 4px";
const styleLabel = (el) => {
el.style.fontSize = "11px";
el.style.opacity = "0.78";
el.style.fontFamily = "var(--comfy-font-sans, Inter, sans-serif)";
el.style.whiteSpace = "nowrap";
};
const toolLabel = document.createElement("div");
toolLabel.textContent = "Tool";
styleLabel(toolLabel);
const brushButton = document.createElement("button");
brushButton.type = "button";
brushButton.textContent = "Brush";
styleSoftButton(brushButton, true);
styleSegmentButton(brushButton, true);
setupSegmentHover(brushButton);
const eraserButton = document.createElement("button");
eraserButton.type = "button";
eraserButton.textContent = "Eraser";
styleSoftButton(eraserButton, false);
const clearButton = document.createElement("button");
clearButton.type = "button";
clearButton.textContent = "Clear";
styleInlineAction(clearButton);
const overlayFormatSelect = document.createElement("select");
overlayFormatSelect.title = "Overlay format";
overlayFormatSelect.style.width = "72px";
styleSoftField(overlayFormatSelect);
for (const format of ["png", "webp"]) {
const option = document.createElement("option");
option.value = format;
option.textContent = format.toUpperCase();
overlayFormatSelect.appendChild(option);
}
const rightTools = document.createElement("div");
rightTools.style.display = "flex";
rightTools.style.alignItems = "center";
rightTools.style.gap = "6px";
rightTools.appendChild(createContextMenuSelect(overlayFormatSelect));
rightTools.appendChild(clearButton);
toolRow.appendChild(brushButton);
toolRow.appendChild(eraserButton);
topRow.appendChild(toolRow);
topRow.appendChild(rightTools);
const strokeRow = document.createElement("div");
strokeRow.style.display = "grid";
strokeRow.style.gridTemplateColumns = "auto minmax(0,1fr) auto auto minmax(0,1fr)";
strokeRow.style.alignItems = "center";
strokeRow.style.gap = "6px";
const colorLabel = document.createElement("div");
colorLabel.textContent = "Color";
colorLabel.style.fontSize = "11px";
colorLabel.style.opacity = "0.78";
const colorSwatchResult = createColorSwatch("#FFFFFF");
const colorInput = colorSwatchResult.input;
styleSegmentButton(eraserButton, false);
setupSegmentHover(eraserButton);
const toolGroup = document.createElement("div");
styleSegmentControl(toolGroup);
toolGroup.appendChild(brushButton);
toolGroup.appendChild(eraserButton);
controls.appendChild(toolLabel);
controls.appendChild(toolGroup);
const edgeLabel = document.createElement("div");
edgeLabel.textContent = "Edge";
styleLabel(edgeLabel);
const edgeSelect = document.createElement("select");
styleSoftField(edgeSelect);
edgeSelect.title = "Brush edge";
edgeSelect.style.display = "none";
for (const edge of ["hard", "soft"]) {
const option = document.createElement("option");
option.value = edge;
option.textContent = edge === "hard" ? "Hard edge" : "Soft edge";
option.textContent = edge === "hard" ? "Hard" : "Soft";
edgeSelect.appendChild(option);
}
const opacityLabel = document.createElement("div");
opacityLabel.textContent = "100%";
opacityLabel.style.fontSize = "11px";
opacityLabel.style.opacity = "0.82";
opacityLabel.style.justifySelf = "end";
const opacityInput = document.createElement("input");
opacityInput.type = "range";
opacityInput.min = "0";
opacityInput.max = "100";
opacityInput.step = "1";
opacityInput.value = "100";
opacityInput.title = "Brush opacity";
styleSoftRange(opacityInput);
strokeRow.appendChild(colorLabel);
strokeRow.appendChild(colorSwatchResult.host);
strokeRow.appendChild(createContextMenuSelect(edgeSelect));
strokeRow.appendChild(opacityLabel);
strokeRow.appendChild(opacityInput);
const softnessRow = document.createElement("div");
softnessRow.style.display = "grid";
softnessRow.style.gridTemplateColumns = "auto minmax(0,1fr) auto";
softnessRow.style.alignItems = "center";
softnessRow.style.gap = "6px";
const softnessTextLabel = document.createElement("div");
softnessTextLabel.textContent = "Softness";
softnessTextLabel.style.fontSize = "11px";
softnessTextLabel.style.opacity = "0.78";
const softnessInput = document.createElement("input");
softnessInput.type = "range";
softnessInput.min = "0";
softnessInput.max = "100";
softnessInput.step = "1";
softnessInput.value = "50";
softnessInput.title = "Soft brush feather (only when edge=Soft)";
styleSoftRange(softnessInput);
const softnessLabel = document.createElement("div");
softnessLabel.textContent = "50%";
softnessLabel.style.fontSize = "11px";
softnessLabel.style.opacity = "0.82";
softnessLabel.style.justifySelf = "end";
softnessRow.appendChild(softnessTextLabel);
softnessRow.appendChild(softnessInput);
softnessRow.appendChild(softnessLabel);
const sizeRow = document.createElement("div");
sizeRow.style.display = "grid";
sizeRow.style.gridTemplateColumns = "auto auto";
sizeRow.style.alignItems = "center";
sizeRow.style.gap = "6px";
sizeRow.style.minWidth = "0";
const linkButton = document.createElement("button");
linkButton.type = "button";
linkButton.textContent = "Linked";
styleSoftButton(linkButton, true);
const bgColorSwatchResult = createColorSwatch("#000000", { compact: true, title: "Background color" });
const bgColorInput = bgColorSwatchResult.input;
sizeRow.appendChild(linkButton);
sizeRow.appendChild(bgColorSwatchResult.host);
const dynamicsRow = document.createElement("div");
dynamicsRow.style.display = "flex";
dynamicsRow.style.alignItems = "center";
dynamicsRow.style.gap = "10px";
dynamicsRow.style.flexWrap = "wrap";
dynamicsRow.style.fontSize = "11px";
dynamicsRow.style.opacity = "0.86";
const dynamicsLabel = document.createElement("span");
dynamicsLabel.textContent = "Dynamics";
dynamicsLabel.style.opacity = "0.78";
const makeDynamicsToggle = (text, title) => {
const label = document.createElement("label");
label.style.display = "inline-flex";
label.style.alignItems = "center";
label.style.gap = "4px";
label.title = title;
const input = document.createElement("input");
input.type = "checkbox";
input.style.margin = "0";
label.appendChild(input);
label.appendChild(document.createTextNode(text));
return [label, input];
const hardButton = document.createElement("button");
hardButton.type = "button";
hardButton.textContent = "Hard";
styleSegmentButton(hardButton, true);
setupSegmentHover(hardButton);
const softButton = document.createElement("button");
softButton.type = "button";
softButton.textContent = "Soft";
styleSegmentButton(softButton, false);
setupSegmentHover(softButton);
edgeSelect.__hardButton = hardButton;
edgeSelect.__softButton = softButton;
const updateEdgeUi = (edge) => {
styleSegmentButton(hardButton, edge === "hard");
styleSegmentButton(softButton, edge === "soft");
};
const [pressureSizeLabel, pressureSizeInput] = makeDynamicsToggle("Size pressure", "Pen pressure changes brush diameter");
const [pressureOpacityLabel, pressureOpacityInput] = makeDynamicsToggle("Opacity pressure", "Pen pressure changes brush opacity");
const [tiltSizeLabel, tiltSizeInput] = makeDynamicsToggle("Tilt size", "Pen tilt widens the brush footprint");
dynamicsRow.appendChild(dynamicsLabel);
dynamicsRow.appendChild(pressureSizeLabel);
dynamicsRow.appendChild(pressureOpacityLabel);
dynamicsRow.appendChild(tiltSizeLabel);
controls.appendChild(topRow);
controls.appendChild(strokeRow);
controls.appendChild(softnessRow);
controls.appendChild(sizeRow);
controls.appendChild(dynamicsRow);
hardButton.addEventListener("click", (e) => {
e.preventDefault();
edgeSelect.value = "hard";
edgeSelect.dispatchEvent(new Event("change"));
updateEdgeUi("hard");
});
softButton.addEventListener("click", (e) => {
e.preventDefault();
edgeSelect.value = "soft";
edgeSelect.dispatchEvent(new Event("change"));
updateEdgeUi("soft");
});
const edgeGroup = document.createElement("div");
styleSegmentControl(edgeGroup);
edgeGroup.appendChild(hardButton);
edgeGroup.appendChild(softButton);
edgeGroup.appendChild(edgeSelect);
controls.appendChild(edgeLabel);
controls.appendChild(edgeGroup);
const clearButton = document.createElement("button");
clearButton.type = "button";
clearButton.textContent = "\u21BA Clear";
clearButton.style.border = "1px solid #3f3f3f";
clearButton.style.background = "#2a2a2a";
clearButton.style.color = "#ccc";
clearButton.style.borderRadius = "4px";
clearButton.style.padding = "4px 16px";
clearButton.style.cursor = "pointer";
clearButton.style.fontSize = "11px";
clearButton.style.lineHeight = "1.2";
clearButton.style.transition = "background 0.15s ease, color 0.15s ease, border-color 0.15s ease";
clearButton.addEventListener("mouseenter", () => {
clearButton.style.background = "#3e3e3e";
clearButton.style.color = "#fff";
clearButton.style.borderColor = "#555";
});
clearButton.addEventListener("mouseleave", () => {
clearButton.style.background = "#2a2a2a";
clearButton.style.color = "#ccc";
clearButton.style.borderColor = "#3f3f3f";
});
clearButton.addEventListener("mousedown", () => {
clearButton.style.background = "#1c1c1c";
clearButton.style.color = "#aaa";
});
clearButton.addEventListener("mouseup", () => {
clearButton.style.background = "#3e3e3e";
clearButton.style.color = "#fff";
});
return {
controls,
brushButton,
eraserButton,
clearButton,
colorInput,
colorInput: null,
edgeSelect,
softnessInput,
softnessLabel,
opacityInput,
opacityLabel,
softnessInput: null,
softnessLabel: null,
opacityInput: null,
opacityLabel: null,
sizeInput: null,
sizeLabel: null,
widthInput: null,
heightInput: null,
linkButton,
bgColorInput,
overlayFormatSelect,
pressureSizeInput,
pressureOpacityInput,
tiltSizeInput
linkButton: null,
bgColorInput: null,
overlayFormatSelect: null,
pressureSizeInput: null,
pressureOpacityInput: null,
tiltSizeInput: null
};
}
function hideDrawWidgets(node) {
const names = [
"bypass",
"invert_mask",
"sync_dimensions",
"width",
"height",
"bg_color",
"tool",
"brush_color",
"brush_size",
"brush_edge",
"brush_softness",
"brush_opacity",
"brush_pressure_size",
"brush_pressure_opacity",
"brush_tilt_size",
"overlay_format",
const namesToHide = [
"overlay_data",
"overlay_layers"
"overlay_layers",
"tool",
"brush_edge"
];
for (const name of names) hideWidgetsByName(node, name);
for (const name of namesToHide) {
hideWidgetsByName(node, name);
}
}
function canvasToDrawSourcePoint(geometry, x, y) {
if (!geometry) return { x: 0, y: 0, inside: false };
@@ -247,8 +231,8 @@ function getDrawInfoText(node, width, height) {
function updateDrawToolButtons(node) {
const st = ensureState(node);
const tool = normalizeDrawTool(widgetString(node, "tool", "brush"));
if (st.drawBrushButton) styleSoftButton(st.drawBrushButton, tool === "brush");
if (st.drawEraserButton) styleSoftButton(st.drawEraserButton, tool === "eraser");
if (st.drawBrushButton) styleSegmentButton(st.drawBrushButton, tool === "brush");
if (st.drawEraserButton) styleSegmentButton(st.drawEraserButton, tool === "eraser");
}
function updateDrawOverlayWidget(node) {
const st = ensureState(node);
@@ -275,6 +259,7 @@ function syncDrawWidgets(node, changedName) {
const pressureOpacityWidget = findWidget(node, "brush_pressure_opacity");
const tiltSizeWidget = findWidget(node, "brush_tilt_size");
const overlayFormatWidget = findWidget(node, "overlay_format");
const softnessWidget = findWidget(node, "brush_softness");
if (!widthWidget || !heightWidget) return;
let width = clampDrawDimension(widgetNumber(node, "width", 1024));
let height = clampDrawDimension(widgetNumber(node, "height", 1024));
@@ -320,15 +305,17 @@ function syncDrawWidgets(node, changedName) {
if (st.drawEdgeSelect) {
const edgeMode = normalizeDrawEdge(widgetString(node, "brush_edge", "hard"));
st.drawEdgeSelect.value = edgeMode;
const hb = st.drawEdgeSelect.__hardButton;
const sb = st.drawEdgeSelect.__softButton;
if (hb) styleSegmentButton(hb, edgeMode === "hard");
if (sb) styleSegmentButton(sb, edgeMode === "soft");
if (st.drawSoftnessInput) {
const softness = Math.round(clampDrawSoftness(widgetNumber(node, "brush_softness", 0.5), 0.5) * 100);
st.drawSoftnessInput.value = String(softness);
st.drawSoftnessInput.disabled = edgeMode === "hard";
st.drawSoftnessInput.style.opacity = edgeMode === "hard" ? "0.45" : "1";
st.drawSoftnessInput.title = `Soft brush feather ${softness}%`;
const softnessPercent = Math.round(clampDrawSoftness(widgetNumber(node, "brush_softness", 0.5), 0.5) * 100);
const hardness = edgeMode === "hard" ? 100 : 100 - softnessPercent;
st.drawSoftnessInput.value = String(hardness);
st.drawSoftnessInput.title = `Brush edge hardness ${hardness}%`;
if (st.drawSoftnessLabel) {
st.drawSoftnessLabel.textContent = `${softness}%`;
st.drawSoftnessLabel.style.opacity = edgeMode === "hard" ? "0.45" : "0.82";
st.drawSoftnessLabel.textContent = `${hardness}%`;
}
}
}
@@ -359,9 +346,20 @@ function syncDrawWidgets(node, changedName) {
setWidgetBooleanValue(linkWidget, linked);
if (st.drawBgColorInput && !inputConnected) setWidgetStringValue(bgWidget, st.drawBgColorInput.value);
if (st.drawColorInput) setWidgetStringValue(colorWidget, st.drawColorInput.value);
if (st.drawEdgeSelect) setWidgetStringValue(edgeWidget, st.drawEdgeSelect.value);
if (st.drawSoftnessInput) {
const hardness = Number(st.drawSoftnessInput.value);
if (hardness === 100) {
setWidgetStringValue(edgeWidget, "hard");
setWidgetValue(softnessWidget, 0);
} else {
setWidgetStringValue(edgeWidget, "soft");
setWidgetValue(softnessWidget, (100 - hardness) / 100);
}
}
if (st.drawOpacityInput) setWidgetValue(opacityWidget, Number(st.drawOpacityInput.value) / 100);
if (st.drawSizeInput) setWidgetValue(sizeWidget, Number(st.drawSizeInput.value));
if (st.drawWidthInput && !inputConnected) setWidgetValue(widthWidget, Number(st.drawWidthInput.value));
if (st.drawHeightInput && !inputConnected) setWidgetValue(heightWidget, Number(st.drawHeightInput.value));
if (st.drawPressureSizeInput) setWidgetBooleanValue(pressureSizeWidget, st.drawPressureSizeInput.checked);
if (st.drawPressureOpacityInput) setWidgetBooleanValue(pressureOpacityWidget, st.drawPressureOpacityInput.checked);
if (st.drawTiltSizeInput) setWidgetBooleanValue(tiltSizeWidget, st.drawTiltSizeInput.checked);
@@ -375,6 +373,7 @@ export {
getDrawInfoText,
hideDrawWidgets,
isNode,
styleSegmentButton,
syncDrawWidgets,
updateDrawOverlayWidget,
updateDrawToolButtons
+26 -150
View File
@@ -1,99 +1,23 @@
import { createContextMenuSelect, styleSoftButton, styleSoftField, styleSoftRange } from "../shared/dom-styles.js";
import { ensureState } from "../shared/state.js";
import { findWidget, hideWidgetForGood, widgetBoolean, widgetNumber, widgetString } from "../shared/widgets.js";
import { findWidget, setWidgetValue, setWidgetStringValue, setWidgetBooleanValue, widgetBoolean, widgetNumber, widgetString } from "../shared/widgets.js";
const NODE_CLASS = "ImageOpsGrain";
function isNode(node) {
return String(node?.comfyClass ?? "") === NODE_CLASS;
}
function createGrainControlsUi() {
function createGrainControlsUi(node) {
const controls = document.createElement("div");
controls.style.marginTop = "8px";
controls.style.display = "grid";
controls.style.gap = "8px";
const makeRow = () => {
const row = document.createElement("div");
row.style.display = "grid";
row.style.gap = "6px";
row.style.alignItems = "center";
row.style.minWidth = "0";
return row;
};
const makeLabel = (text) => {
const label = document.createElement("div");
label.textContent = text;
label.style.fontSize = "11px";
label.style.opacity = "0.78";
return label;
};
const topRow = makeRow();
topRow.style.gridTemplateColumns = "auto minmax(0,1fr)";
topRow.appendChild(makeLabel("Blend"));
const blendSelect = document.createElement("select");
blendSelect.dataset.grainSelect = "blend_mode";
blendSelect.title = "Grain blend mode";
blendSelect.style.width = "100%";
blendSelect.style.minWidth = "0";
styleSoftField(blendSelect);
for (const [value, label] of [["add", "Add"], ["overlay", "Overlay"], ["soft_light", "Soft Light"]]) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
blendSelect.appendChild(option);
}
topRow.appendChild(createContextMenuSelect(blendSelect));
controls.appendChild(topRow);
const amountRow = makeRow();
amountRow.style.gridTemplateColumns = "auto minmax(0,1fr) auto";
amountRow.appendChild(makeLabel("Amount"));
const amountInput = document.createElement("input");
amountInput.type = "range";
amountInput.dataset.grainField = "amount";
amountInput.min = "0";
amountInput.max = "100";
amountInput.step = "1";
amountInput.value = "8";
amountInput.title = "Grain amount";
styleSoftRange(amountInput);
amountRow.appendChild(amountInput);
const amountLabel = document.createElement("div");
amountLabel.dataset.grainAmountLabel = "1";
amountLabel.textContent = "8%";
amountLabel.style.fontSize = "11px";
amountLabel.style.opacity = "0.84";
amountLabel.style.textAlign = "right";
amountRow.appendChild(amountLabel);
controls.appendChild(amountRow);
const actionsRow = makeRow();
actionsRow.style.display = "flex";
actionsRow.style.flexWrap = "wrap";
actionsRow.style.gap = "6px";
const monoButton = document.createElement("button");
monoButton.type = "button";
monoButton.dataset.grainToggle = "monochrome";
monoButton.textContent = "Mono on";
styleSoftButton(monoButton, true);
actionsRow.appendChild(monoButton);
const animatedButton = document.createElement("button");
animatedButton.type = "button";
animatedButton.dataset.grainToggle = "animated";
animatedButton.textContent = "Animated on";
styleSoftButton(animatedButton, true);
actionsRow.appendChild(animatedButton);
const invertButton = document.createElement("button");
invertButton.type = "button";
invertButton.dataset.grainToggle = "invert_mask";
invertButton.textContent = "Invert mask off";
styleSoftButton(invertButton, false);
actionsRow.appendChild(invertButton);
const bypassButton = document.createElement("button");
bypassButton.type = "button";
bypassButton.dataset.grainToggle = "bypass";
bypassButton.textContent = "Bypass off";
styleSoftButton(bypassButton, false);
actionsRow.appendChild(bypassButton);
controls.appendChild(actionsRow);
controls.style.display = "flex";
controls.style.flexDirection = "column";
controls.style.gap = "6px";
controls.style.width = "100%";
const label = document.createElement("div");
label.textContent = "Presets";
label.style.fontSize = "11px";
label.style.opacity = "0.78";
label.style.fontFamily = "var(--comfy-font-sans, Inter, sans-serif)";
controls.appendChild(label);
const presetsRow = document.createElement("div");
presetsRow.style.cssText = "display:flex;gap:4px;flex-wrap:wrap";
presetsRow.style.cssText = "display:flex;gap:4px;flex-wrap:wrap;width:100%";
const presets = [
{ label: "Clean", amount: 0.02, blend: "add", mono: false },
{ label: "Subtle", amount: 0.06, blend: "overlay", mono: false },
@@ -106,31 +30,20 @@ function createGrainControlsUi() {
btn.type = "button";
btn.textContent = preset.label;
btn.title = `Preset: amount ${Math.round(preset.amount * 100)}%, ${preset.blend}, ${preset.mono ? "mono" : "rgb"}`;
btn.style.cssText = "background:#2a2a2a;color:#ddd;border:1px solid #444;border-radius:4px;padding:3px 8px;cursor:pointer;font-size:11px";
btn.style.cssText = "background:#2a2a2a;color:#ddd;border:1px solid #444;border-radius:4px;padding:3px 8px;cursor:pointer;font-size:11px;transition:background 0.15s";
btn.addEventListener("mouseenter", () => {
btn.style.background = "#3a3a3a";
});
btn.addEventListener("mouseleave", () => {
btn.style.background = "#2a2a2a";
});
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
amountInput.value = String(Math.round(preset.amount * 100));
try {
amountInput.dispatchEvent(new Event("input", { bubbles: true }));
} catch {
}
try {
amountInput.dispatchEvent(new Event("change", { bubbles: true }));
} catch {
}
blendSelect.value = preset.blend;
try {
blendSelect.dispatchEvent(new Event("change", { bubbles: true }));
} catch {
}
const monoOn = monoButton.textContent?.trim().toLowerCase() === "mono on";
if (monoOn !== preset.mono) {
try {
monoButton.click();
} catch {
}
}
setWidgetValue(findWidget(node, "amount"), preset.amount);
setWidgetStringValue(findWidget(node, "blend_mode"), preset.blend);
setWidgetBooleanValue(findWidget(node, "monochrome"), preset.mono);
node.graph?.setDirtyCanvas?.(true, true);
});
presetsRow.appendChild(btn);
}
@@ -139,9 +52,7 @@ function createGrainControlsUi() {
}
const GRAIN_BLEND_MODES = /* @__PURE__ */ new Set(["add", "overlay", "soft_light"]);
function hideGrainWidgets(node) {
for (const name of ["bypass", "amount", "blend_mode", "monochrome", "animated", "invert_mask"]) {
hideWidgetForGood(node, findWidget(node, name));
}
if (!isNode(node)) return;
}
function getGrainInfoText(node) {
const amount = Math.round(Math.max(0, Math.min(1, widgetNumber(node, "amount", 0.08))) * 100);
@@ -154,42 +65,7 @@ function getGrainInfoText(node) {
}
function syncGrainWidgets(node) {
if (!isNode(node)) return;
const st = ensureState(node);
const root = st.previewRoot;
if (!root) return;
const amount = Math.max(0, Math.min(1, widgetNumber(node, "amount", 0.08)));
const blendModeRaw = widgetString(node, "blend_mode", "add");
const blendMode = GRAIN_BLEND_MODES.has(blendModeRaw) ? blendModeRaw : "add";
const monochrome = widgetBoolean(node, "monochrome", true);
const animated = widgetBoolean(node, "animated", true);
const invertMask = widgetBoolean(node, "invert_mask", false);
const bypass = widgetBoolean(node, "bypass", false);
const amountInput = root.querySelector('input[data-grain-field="amount"]');
if (amountInput) amountInput.value = String(Math.round(amount * 100));
const amountLabel = root.querySelector("[data-grain-amount-label]");
if (amountLabel) amountLabel.textContent = `${Math.round(amount * 100)}%`;
const blendSelect = root.querySelector('select[data-grain-select="blend_mode"]');
if (blendSelect) blendSelect.value = blendMode;
const monoButton = root.querySelector('button[data-grain-toggle="monochrome"]');
if (monoButton) {
monoButton.textContent = monochrome ? "Mono on" : "Mono off";
styleSoftButton(monoButton, monochrome);
}
const animatedButton = root.querySelector('button[data-grain-toggle="animated"]');
if (animatedButton) {
animatedButton.textContent = animated ? "Animated on" : "Animated off";
styleSoftButton(animatedButton, animated);
}
const invertButton = root.querySelector('button[data-grain-toggle="invert_mask"]');
if (invertButton) {
invertButton.textContent = invertMask ? "Invert mask on" : "Invert mask off";
styleSoftButton(invertButton, invertMask);
}
const bypassButton = root.querySelector('button[data-grain-toggle="bypass"]');
if (bypassButton) {
bypassButton.textContent = bypass ? "Bypass on" : "Bypass off";
styleSoftButton(bypassButton, bypass);
}
hideGrainWidgets(node);
}
export {
NODE_CLASS,
+104 -87
View File
@@ -1,113 +1,120 @@
import { createColorSwatch, styleSoftButton, styleSoftRange, syncDarkColorInputUI } from "../shared/dom-styles.js";
import { styleSoftButton, syncDarkColorInputUI } from "../shared/dom-styles.js";
import { getCanvasPointer } from "../shared/geometry.js";
import { findWidget, hideWidgetForGood, hideWidgetsByName, setWidgetBooleanValue, setWidgetStringValue, setWidgetStringValuesByName, setWidgetValue, widgetBoolean, widgetNumber, widgetString } from "../shared/widgets.js";
const NODE_CLASS = "ImageOpsKeyer";
function isNode(node) {
return String(node?.comfyClass ?? "") === NODE_CLASS;
}
function styleSegmentControl(container) {
container.style.display = "flex";
container.style.background = "#181818";
container.style.borderRadius = "6px";
container.style.padding = "2px";
container.style.border = "1px solid #2e2e2e";
container.style.width = "100%";
container.style.boxSizing = "border-box";
}
function styleSegmentButton(button, active) {
button.style.flex = "1";
button.style.border = "none";
button.style.outline = "none";
button.style.borderRadius = "4px";
button.style.padding = "4px 8px";
button.style.fontSize = "10.5px";
button.style.fontFamily = "var(--comfy-font-sans, Inter, sans-serif)";
button.style.cursor = "pointer";
button.style.transition = "background 0.15s, color 0.15s";
button.style.lineHeight = "1.2";
button.style.textAlign = "center";
if (active) {
button.classList.add("active");
button.style.background = "#525252";
button.style.color = "#ffffff";
button.style.fontWeight = "600";
} else {
button.classList.remove("active");
button.style.background = "transparent";
button.style.color = "#aaaaaa";
button.style.fontWeight = "normal";
}
}
function setupSegmentHover(button) {
button.addEventListener("mouseenter", () => {
if (!button.classList.contains("active")) {
button.style.background = "rgba(255,255,255,0.06)";
button.style.color = "#ffffff";
}
});
button.addEventListener("mouseleave", () => {
if (!button.classList.contains("active")) {
button.style.background = "transparent";
button.style.color = "#aaaaaa";
}
});
}
function createKeyerControlsUi() {
const controls = document.createElement("div");
controls.style.marginTop = "8px";
controls.style.display = "grid";
controls.style.gap = "8px";
controls.style.gridTemplateColumns = "72px 1fr";
controls.style.rowGap = "8px";
controls.style.columnGap = "12px";
controls.style.alignItems = "center";
controls.style.width = "100%";
controls.style.boxSizing = "border-box";
controls.style.padding = "0 4px";
const styleLabel = (el) => {
el.style.fontSize = "11px";
el.style.opacity = "0.78";
el.style.fontFamily = "var(--comfy-font-sans, Inter, sans-serif)";
el.style.whiteSpace = "nowrap";
};
const modeButtons = [];
const topRow = document.createElement("div");
topRow.style.display = "grid";
topRow.style.gridTemplateColumns = "repeat(2, minmax(0,1fr))";
topRow.style.gap = "4px";
const modeLabel = document.createElement("div");
modeLabel.textContent = "Mode";
styleLabel(modeLabel);
const modeGroup = document.createElement("div");
styleSegmentControl(modeGroup);
for (const [value, label] of [["color", "Color"], ["luma", "Luma"]]) {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.dataset.keyerMode = value;
styleSoftButton(button, value === "color");
topRow.appendChild(button);
styleSegmentButton(button, value === "color");
setupSegmentHover(button);
modeGroup.appendChild(button);
modeButtons.push(button);
}
controls.appendChild(topRow);
const actionRow = document.createElement("div");
actionRow.style.display = "grid";
actionRow.style.gridTemplateColumns = "minmax(0,1fr) auto auto auto auto";
actionRow.style.gap = "6px";
const keyerColorSwatch = createColorSwatch("#00ff00", { title: "Key color" });
const colorInput = keyerColorSwatch.input;
const invertButton = document.createElement("button");
invertButton.type = "button";
invertButton.textContent = "Invert off";
styleSoftButton(invertButton, false);
const invertMaskButton = document.createElement("button");
invertMaskButton.type = "button";
invertMaskButton.textContent = "Inv mask off";
styleSoftButton(invertMaskButton, false);
controls.appendChild(modeLabel);
controls.appendChild(modeGroup);
const pickLabel = document.createElement("div");
pickLabel.textContent = "Sampling";
styleLabel(pickLabel);
pickLabel.dataset.keyerPickerLabel = "1";
const pickButton = document.createElement("button");
pickButton.type = "button";
pickButton.textContent = "Pick";
styleSoftButton(pickButton, false);
const bypassButton = document.createElement("button");
bypassButton.type = "button";
bypassButton.textContent = "Bypass off";
styleSoftButton(bypassButton, false);
actionRow.appendChild(keyerColorSwatch.host);
actionRow.appendChild(pickButton);
actionRow.appendChild(invertButton);
actionRow.appendChild(invertMaskButton);
actionRow.appendChild(bypassButton);
controls.appendChild(actionRow);
const makeRange = (labelText, min, max, step, value, accent = "") => {
const row2 = document.createElement("div");
row2.style.display = "grid";
row2.style.gridTemplateColumns = "54px minmax(0,1fr) 38px";
row2.style.gap = "7px";
row2.style.alignItems = "center";
const label = document.createElement("div");
label.textContent = labelText;
label.style.fontSize = "11px";
label.style.opacity = "0.78";
const input = document.createElement("input");
input.type = "range";
input.min = String(min);
input.max = String(max);
input.step = String(step);
input.value = String(value);
input.title = labelText;
styleSoftRange(input);
if (accent) input.style.accentColor = accent;
const valueLabel = document.createElement("div");
valueLabel.dataset.keyerValue = "1";
valueLabel.textContent = String(value);
valueLabel.style.fontSize = "11px";
valueLabel.style.opacity = "0.84";
valueLabel.style.textAlign = "right";
row2.appendChild(label);
row2.appendChild(input);
row2.appendChild(valueLabel);
return [row2, input];
};
let row;
let toleranceInput;
let softnessInput;
let gainInput;
let blurInput;
[row, toleranceInput] = makeRange("Tol", 0, 1, 0.01, 0.25, "");
controls.appendChild(row);
[row, softnessInput] = makeRange("Soft", 0, 1, 0.01, 0.1, "");
controls.appendChild(row);
[row, gainInput] = makeRange("Gain", 0, 4, 0.01, 1, "");
controls.appendChild(row);
[row, blurInput] = makeRange("Blur", 0, 64, 0.1, 0, "");
controls.appendChild(row);
pickButton.style.width = "100%";
const pickWrapper = document.createElement("div");
pickWrapper.style.display = "flex";
pickWrapper.style.width = "100%";
pickWrapper.appendChild(pickButton);
pickWrapper.dataset.keyerPickerWrapper = "1";
controls.appendChild(pickLabel);
controls.appendChild(pickWrapper);
return {
controls,
modeButtons,
invertButton,
invertMaskButton,
bypassButton,
invertButton: null,
invertMaskButton: null,
bypassButton: null,
pickButton,
colorInput,
toleranceInput,
softnessInput,
gainInput,
blurInput
colorInput: null,
toleranceInput: null,
softnessInput: null,
gainInput: null,
blurInput: null
};
}
const KEYER_WIDGETS = [
@@ -189,7 +196,10 @@ function hexToRgb(color) {
function hideKeyerWidgets(node) {
if (!isNode(node)) return;
ensureKeyColorsWidget(node);
for (const name of KEYER_WIDGETS) hideWidgetsByName(node, name);
const namesToHide = ["key_colors", "mode"];
for (const name of namesToHide) {
hideWidgetsByName(node, name);
}
}
function setRange(input, value) {
if (!input) return;
@@ -212,7 +222,14 @@ function syncKeyerWidgets(node) {
const selectionCount = Math.max(1, parseKeyColorSelection(node).length || (keyColor ? 1 : 0));
setWidgetStringValuesByName(node, "key_color", keyColor, { notify: false, dirty: false });
for (const button of st.keyerModeButtons ?? []) {
styleSoftButton(button, button.dataset.keyerMode === mode);
styleSegmentButton(button, button.dataset.keyerMode === mode);
}
const pickLabel = st.previewRoot?.querySelector("[data-keyer-picker-label]");
const pickWrapper = st.previewRoot?.querySelector("[data-keyer-picker-wrapper]");
if (pickLabel && pickWrapper) {
const show = mode === "color";
pickLabel.style.display = show ? "" : "none";
pickWrapper.style.display = show ? "" : "none";
}
if (st.keyerColorInput) syncDarkColorInputUI(st.keyerColorInput, keyColor);
setRange(st.keyerToleranceInput, widgetNumber(node, "tolerance", 0.25));
@@ -224,7 +241,7 @@ function syncKeyerWidgets(node) {
styleSoftButton(st.keyerInvertButton, invert);
}
if (st.keyerInvertMaskButton) {
st.keyerInvertMaskButton.textContent = invertMask ? "Inv mask on" : "Inv mask off";
st.keyerInvertMaskButton.textContent = invertMask ? "Invert mask on" : "Invert mask off";
styleSoftButton(st.keyerInvertMaskButton, invertMask);
}
if (st.keyerBypassButton) {
+14 -114
View File
@@ -1,9 +1,9 @@
import { createContextMenuSelect, styleSoftButton, styleSoftField } from "../shared/dom-styles.js";
import { findWidget, hideWidgetForGood, setWidgetBooleanValue, setWidgetStringValue, setWidgetValue, widgetBoolean, widgetNumber, widgetString } from "../shared/widgets.js";
import { styleSoftButton } from "../shared/dom-styles.js";
import { findWidget, setWidgetStringValue, setWidgetValue, widgetNumber, widgetString } from "../shared/widgets.js";
const NODE_CLASS = "ImageOpsPadOut";
const PADOUT_WIDGETS = [
"bypass",
"target_format",
"aspect_ratio",
"fill_mode",
"fill_preset",
"fill_color",
@@ -26,77 +26,17 @@ function createPadOutControlsUi() {
const controls = document.createElement("div");
controls.dataset.padoutUi = "1";
controls.style.marginTop = "8px";
controls.style.display = "grid";
controls.style.gap = "8px";
const makeRow = () => {
const row = document.createElement("div");
row.style.display = "flex";
row.style.alignItems = "center";
row.style.gap = "6px";
row.style.flexWrap = "wrap";
return row;
};
const makeLabel = (text) => {
const label = document.createElement("span");
label.textContent = text;
label.style.fontSize = "11px";
label.style.opacity = "0.76";
return label;
};
const ratioRow = makeRow();
ratioRow.appendChild(makeLabel("Ratio"));
const ratioSelect = document.createElement("select");
ratioSelect.dataset.padout = "ratio_select";
styleSoftField(ratioSelect);
ratioSelect.style.width = "120px";
for (const [value, label] of [["custom", "Free"], ["1:1", "1:1"], ["16:9", "16:9"], ["9:16", "9:16"], ["4:3", "4:3"], ["3:4", "3:4"]]) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
ratioSelect.appendChild(option);
}
ratioRow.appendChild(createContextMenuSelect(ratioSelect));
controls.appendChild(ratioRow);
const anchorRow = makeRow();
anchorRow.appendChild(makeLabel("Anchor"));
const anchorSelect = document.createElement("select");
anchorSelect.dataset.padout = "anchor_select";
styleSoftField(anchorSelect);
anchorSelect.style.width = "120px";
for (const [value, label] of [["center", "Center"], ["top", "Top"], ["bottom", "Bottom"], ["left", "Left"], ["right", "Right"], ["fit_w", "Fit W"], ["fit_h", "Fit H"]]) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
anchorSelect.appendChild(option);
}
anchorRow.appendChild(createContextMenuSelect(anchorSelect));
controls.appendChild(anchorRow);
const actionsRow = makeRow();
controls.style.display = "flex";
controls.style.justifyContent = "center";
controls.style.width = "100%";
const fitButton = document.createElement("button");
fitButton.type = "button";
fitButton.textContent = "Fit All";
fitButton.dataset.padout = "fit_all";
styleSoftButton(fitButton, false);
actionsRow.appendChild(fitButton);
const invertMaskButton = document.createElement("button");
invertMaskButton.type = "button";
invertMaskButton.textContent = "Inv mask off";
invertMaskButton.dataset.padout = "invert_mask";
styleSoftButton(invertMaskButton, false);
actionsRow.appendChild(invertMaskButton);
const bypassButton = document.createElement("button");
bypassButton.type = "button";
bypassButton.textContent = "Bypass off";
bypassButton.dataset.padout = "bypass";
styleSoftButton(bypassButton, false);
actionsRow.appendChild(bypassButton);
const hint = document.createElement("div");
hint.textContent = "Wheel: zoom. Middle drag: pan. Arrow keys: nudge. Shift: x4.";
hint.style.fontSize = "11px";
hint.style.opacity = "0.7";
hint.style.marginLeft = "auto";
actionsRow.appendChild(hint);
controls.appendChild(actionsRow);
fitButton.style.padding = "4px 16px";
fitButton.style.fontSize = "11px";
controls.appendChild(fitButton);
return { controls };
}
function normalizeTargetFormat(value) {
@@ -122,12 +62,6 @@ function getPadOutInfoText(_node, width, height) {
}
function hidePadOutWidgets(node) {
if (!isNode(node)) return;
const names = new Set(PADOUT_WIDGETS);
for (const widget of node.widgets ?? []) {
if (widget && names.has(String(widget.name ?? ""))) {
hideWidgetForGood(node, widget);
}
}
}
function getPadOutSnap(node) {
return Math.max(1, Math.round(widgetNumber(node, "snap_to_multiple", 1)));
@@ -136,7 +70,7 @@ function snapPadValue(value, snap) {
return snap <= 1 ? Math.max(0, Math.round(value)) : Math.max(0, Math.round(Math.round(value) / snap) * snap);
}
function getPadOutTargetRatio(node) {
return TARGET_RATIO_MAP[normalizeTargetFormat(widgetString(node, "target_format", "custom"))] ?? null;
return TARGET_RATIO_MAP[normalizeTargetFormat(widgetString(node, "aspect_ratio", "custom"))] ?? null;
}
function getPadOutSourceSize(node, geometry = null) {
if (geometry?.sourceWidth && geometry?.sourceHeight) {
@@ -199,13 +133,13 @@ function setPadOutOutputRect(node, sourceWidth, sourceHeight, x1, y1, outWidth,
setPadOutPadding(node, left, top, right, bottom, notify);
}
function setPadOutTargetFormatCustomIfNeeded(node) {
const currentFormat = widgetString(node, "target_format", "custom");
const currentFormat = widgetString(node, "aspect_ratio", "custom");
const ratio = TARGET_RATIO_MAP[normalizeTargetFormat(currentFormat)] ?? null;
if (ratio == null) return;
const frame = getPadOutFrame(node);
const actualRatio = frame.outWidth / Math.max(1, frame.outHeight);
if (Math.abs(actualRatio - ratio) > 0.015) {
setWidgetStringValue(findWidget(node, "target_format"), "custom");
setWidgetStringValue(findWidget(node, "aspect_ratio"), "custom");
}
}
function applyPadOutTargetFormat(node, targetFormat) {
@@ -226,7 +160,7 @@ function hydratePadOutTargetFormat(node) {
const st = node.__imageops_state;
if (st?.padOutRatioHydrated) return;
if (st) st.padOutRatioHydrated = true;
const targetFormat = widgetString(node, "target_format", "custom");
const targetFormat = widgetString(node, "aspect_ratio", "custom");
const ratio = TARGET_RATIO_MAP[normalizeTargetFormat(targetFormat)] ?? null;
if (ratio == null) return;
const frame = getPadOutFrame(node);
@@ -280,25 +214,9 @@ function applyPadOutAnchor(node, anchor) {
}
function syncPadOutControls(node) {
if (!isNode(node)) return;
hidePadOutWidgets(node);
const root = controlRoot(node);
if (!root) return;
const targetFormat = widgetString(node, "target_format", "custom");
const bypass = widgetBoolean(node, "bypass", false);
const invertMask = widgetBoolean(node, "invert_mask", false);
const ratioSelect = control(node, "ratio_select");
if (ratioSelect && ratioSelect.value !== targetFormat) ratioSelect.value = targetFormat;
const anchorSelect = control(node, "anchor_select");
if (anchorSelect && !anchorSelect.value) anchorSelect.value = "center";
const invertButton = control(node, "invert_mask");
if (invertButton) {
invertButton.textContent = invertMask ? "Inv mask on" : "Inv mask off";
styleSoftButton(invertButton, invertMask);
}
const bypassButton = control(node, "bypass");
if (bypassButton) {
bypassButton.textContent = bypass ? "Bypass on" : "Bypass off";
styleSoftButton(bypassButton, bypass);
}
const fitButton = control(node, "fit_all");
if (fitButton) styleSoftButton(fitButton, false);
}
@@ -311,24 +229,6 @@ function attachPadOutControls(node, ctx) {
syncPadOutControls(node);
ctx.refreshNode(node);
};
control(node, "ratio_select")?.addEventListener("change", (event) => {
const targetFormat = event.currentTarget.value || "custom";
setWidgetStringValue(findWidget(node, "target_format"), targetFormat);
if (targetFormat !== "custom") applyPadOutTargetFormat(node, targetFormat);
rerender();
});
control(node, "anchor_select")?.addEventListener("change", (event) => {
applyPadOutAnchor(node, event.currentTarget.value || "center");
rerender();
});
control(node, "invert_mask")?.addEventListener("click", () => {
setWidgetBooleanValue(findWidget(node, "invert_mask"), !widgetBoolean(node, "invert_mask", false));
rerender();
});
control(node, "bypass")?.addEventListener("click", () => {
setWidgetBooleanValue(findWidget(node, "bypass"), !widgetBoolean(node, "bypass", false));
rerender();
});
control(node, "fit_all")?.addEventListener("click", () => {
const st = node.__imageops_state;
st.previewZoom = 1;
+3 -210
View File
@@ -1,15 +1,5 @@
import {
createColorSwatch,
createContextMenuSelect,
setDarkColorInputState,
styleSoftButton,
styleSoftField,
styleSoftRange,
syncDarkColorInputUI
} from "../shared/dom-styles.js";
import { resolveImageOpsClassName } from "../shared/classes.js";
import { ensureState } from "../shared/state.js";
import { findWidget, hideWidgetsByName, setWidgetStringValuesByName, setWidgetValue, widgetBoolean, widgetNumber, widgetString } from "../shared/widgets.js";
import { findWidget, setWidgetStringValuesByName, setWidgetValue, widgetNumber, widgetString } from "../shared/widgets.js";
const RAMP_RATIO_PRESETS = {
"1:1": 1,
"3:4": 3 / 4,
@@ -29,153 +19,10 @@ function isNode(node) {
return resolveImageOpsClassName(node?.comfyClass) === NODE_CLASS;
}
function createRampControlsUi() {
const controls = document.createElement("div");
controls.style.marginTop = "8px";
controls.style.display = "grid";
controls.style.gap = "8px";
const makeRow = () => {
const row = document.createElement("div");
row.style.display = "grid";
row.style.gap = "6px";
row.style.alignItems = "center";
return row;
};
const makeLabel = (text) => {
const label = document.createElement("div");
label.textContent = text;
label.style.fontSize = "11px";
label.style.opacity = "0.78";
return label;
};
const makeNumberInput = (field, options) => {
const input = document.createElement("input");
input.type = "number";
input.dataset.rampField = field;
input.min = String(options.min);
input.max = String(options.max);
input.step = String(options.step);
input.title = options.title;
input.style.width = options.width ?? "100%";
input.style.minWidth = "0";
input.style.fontFamily = "ui-monospace, SFMono-Regular, Consolas, monospace";
styleSoftField(input);
return input;
};
const topRow = makeRow();
topRow.style.gridTemplateColumns = "auto minmax(0,1fr) minmax(0,1fr) auto";
topRow.appendChild(makeLabel("Style"));
const shapeSelect = document.createElement("select");
shapeSelect.dataset.rampSelect = "shape";
shapeSelect.title = "Ramp shape";
shapeSelect.style.width = "100%";
shapeSelect.style.minWidth = "0";
styleSoftField(shapeSelect);
for (const [value, label] of [["linear", "Linear"], ["radial", "Radial"]]) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
shapeSelect.appendChild(option);
}
topRow.appendChild(createContextMenuSelect(shapeSelect));
const modeSelect = document.createElement("select");
modeSelect.dataset.rampSelect = "mode";
modeSelect.title = "Ramp curve";
modeSelect.style.width = "100%";
modeSelect.style.minWidth = "0";
styleSoftField(modeSelect);
for (const [value, label] of [["linear", "Linear"], ["ease_in", "Ease In"], ["ease_out", "Ease Out"], ["smoothstep", "Smooth"]]) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
modeSelect.appendChild(option);
}
topRow.appendChild(createContextMenuSelect(modeSelect));
const invertButton = document.createElement("button");
invertButton.type = "button";
invertButton.dataset.rampToggle = "invert";
invertButton.textContent = "Invert off";
styleSoftButton(invertButton, false);
topRow.appendChild(invertButton);
controls.appendChild(topRow);
const sizeRow = makeRow();
sizeRow.style.gridTemplateColumns = "auto minmax(70px,96px)";
sizeRow.appendChild(makeLabel("Ratio"));
const ratioSelect = document.createElement("select");
ratioSelect.dataset.rampRatio = "1";
ratioSelect.title = "Standard ratio or Free";
ratioSelect.style.width = "100%";
ratioSelect.style.minWidth = "0";
styleSoftField(ratioSelect);
for (const [value, label] of [["custom", "Free"], ["1:1", "1:1"], ["16:9", "16:9"], ["9:16", "9:16"], ["4:3", "4:3"], ["3:4", "3:4"]]) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
ratioSelect.appendChild(option);
}
sizeRow.appendChild(createContextMenuSelect(ratioSelect));
controls.appendChild(sizeRow);
const colorsRow = makeRow();
colorsRow.style.gridTemplateColumns = "auto minmax(0,1fr) auto minmax(0,1fr)";
colorsRow.appendChild(makeLabel("Color A"));
const colorA = createColorSwatch("#ffffff");
colorA.input.dataset.rampColor = "a";
colorsRow.appendChild(colorA.host);
colorsRow.appendChild(makeLabel("Color B"));
const colorB = createColorSwatch("#000000");
colorB.input.dataset.rampColor = "b";
colorsRow.appendChild(colorB.host);
controls.appendChild(colorsRow);
const alphaRow = makeRow();
alphaRow.style.gridTemplateColumns = "auto minmax(0,1fr) auto";
alphaRow.appendChild(makeLabel("Alpha"));
const alphaInput = document.createElement("input");
alphaInput.type = "range";
alphaInput.dataset.rampField = "alpha";
alphaInput.min = "0";
alphaInput.max = "100";
alphaInput.step = "1";
alphaInput.value = "100";
alphaInput.title = "Opacity";
styleSoftRange(alphaInput);
alphaRow.appendChild(alphaInput);
const alphaValue = document.createElement("div");
alphaValue.dataset.rampAlphaLabel = "1";
alphaValue.textContent = "100%";
alphaValue.style.fontSize = "11px";
alphaValue.style.opacity = "0.84";
alphaValue.style.textAlign = "right";
alphaRow.appendChild(alphaValue);
controls.appendChild(alphaRow);
const pointsRow = makeRow();
pointsRow.style.gridTemplateColumns = "auto minmax(0,1fr) auto minmax(0,1fr)";
pointsRow.appendChild(makeLabel("Start X"));
pointsRow.appendChild(makeNumberInput("start_x", { min: -2, max: 3, step: 1e-3, title: "Start X" }));
pointsRow.appendChild(makeLabel("Start Y"));
pointsRow.appendChild(makeNumberInput("start_y", { min: -2, max: 3, step: 1e-3, title: "Start Y" }));
pointsRow.appendChild(makeLabel("End X"));
pointsRow.appendChild(makeNumberInput("end_x", { min: -2, max: 3, step: 1e-3, title: "End X" }));
pointsRow.appendChild(makeLabel("End Y"));
pointsRow.appendChild(makeNumberInput("end_y", { min: -2, max: 3, step: 1e-3, title: "End Y" }));
controls.appendChild(pointsRow);
return { controls };
return { controls: null };
}
function hideRampWidgets(node) {
for (const name of [
"color_a",
"color_b",
"alpha",
"frame_length",
"batch_size",
"start_x",
"start_y",
"end_x",
"end_y",
"ramp_shape",
"ramp_mode",
"invert"
]) {
hideWidgetsByName(node, name);
}
if (!isNode(node)) return;
}
function getRampInfoText(node) {
const width = Math.max(1, Math.round(widgetNumber(node, "width", 1024)));
@@ -230,60 +77,6 @@ function syncRampWidgets(node) {
const colorB = widgetString(node, "color_b", "#000000");
setWidgetStringValuesByName(node, "color_a", colorA, { notify: false, dirty: false });
setWidgetStringValuesByName(node, "color_b", colorB, { notify: false, dirty: false });
const st = ensureState(node);
const root = st.previewRoot;
if (!root) return;
const width = Math.max(1, Math.round(widgetNumber(node, "width", 1024)));
const height = Math.max(1, Math.round(widgetNumber(node, "height", 1024)));
const frameCount = Math.max(1, Math.round(widgetNumber(node, "frame_count", widgetNumber(node, "frame_length", widgetNumber(node, "batch_size", 1)))));
const alpha = Math.max(0, Math.min(1, widgetNumber(node, "alpha", 1)));
const startX = widgetNumber(node, "start_x", 0);
const startY = widgetNumber(node, "start_y", 0.5);
const endX = widgetNumber(node, "end_x", 1);
const endY = widgetNumber(node, "end_y", 0.5);
const shape = widgetString(node, "ramp_shape", "linear");
const mode = widgetString(node, "ramp_mode", "linear");
const invert = widgetBoolean(node, "invert", false);
const setNumberField = (name, value) => {
const input = root.querySelector(`input[data-ramp-field="${name}"]`);
if (input) input.value = String(value);
};
setNumberField("width", width);
setNumberField("height", height);
setNumberField("frame_count", frameCount);
setNumberField("frame_length", frameCount);
setNumberField("batch_size", frameCount);
setNumberField("start_x", startX);
setNumberField("start_y", startY);
setNumberField("end_x", endX);
setNumberField("end_y", endY);
const ratioSelect = root.querySelector('select[data-ramp-ratio="1"]');
if (ratioSelect) ratioSelect.value = resolveRampRatioPreset(width, height);
const alphaInput = root.querySelector('input[data-ramp-field="alpha"]');
if (alphaInput) alphaInput.value = String(Math.round(alpha * 100));
const alphaLabel = root.querySelector("[data-ramp-alpha-label]");
if (alphaLabel) alphaLabel.textContent = `${Math.round(alpha * 100)}%`;
const colorAInput = root.querySelector('input[data-ramp-color="a"]');
if (colorAInput) {
colorAInput.value = colorA;
syncDarkColorInputUI(colorAInput, colorA);
}
const colorBInput = root.querySelector('input[data-ramp-color="b"]');
if (colorBInput) {
colorBInput.value = colorB;
syncDarkColorInputUI(colorBInput, colorB);
}
const shapeSelect = root.querySelector('select[data-ramp-select="shape"]');
if (shapeSelect) shapeSelect.value = shape;
const modeSelect = root.querySelector('select[data-ramp-select="mode"]');
if (modeSelect) modeSelect.value = mode;
const invertButton = root.querySelector('button[data-ramp-toggle="invert"]');
if (invertButton) {
invertButton.textContent = invert ? "Invert on" : "Invert off";
styleSoftButton(invertButton, invert);
}
setDarkColorInputState(colorAInput, false, false);
setDarkColorInputState(colorBInput, false, false);
}
export {
NODE_CLASS,
+3 -238
View File
@@ -1,196 +1,14 @@
import {
createColorSwatch,
createContextMenuSelect,
setDarkColorInputState,
styleSoftButton,
styleSoftField,
styleSoftRange,
syncDarkColorInputUI
} from "../shared/dom-styles.js";
import { ensureState } from "../shared/state.js";
import { hideWidgetsByName, setWidgetStringValuesByName, widgetBoolean, widgetNumber, widgetString } from "../shared/widgets.js";
import { startEyedropper } from "../shared/eyedropper.js";
import { setWidgetStringValuesByName, widgetBoolean, widgetNumber, widgetString } from "../shared/widgets.js";
const NODE_CLASS = "ImageOpsText";
function isNode(node) {
return String(node?.comfyClass ?? "") === NODE_CLASS;
}
function createTextControlsUi() {
const controls = document.createElement("div");
controls.style.marginTop = "8px";
controls.style.display = "grid";
controls.style.gap = "8px";
const makeEyedropperButton = (getInput) => {
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = "\u{1F4A7}";
btn.title = "Pick color from a preview";
btn.style.cssText = "background:#2a2a2a;color:#ddd;border:1px solid #444;border-radius:4px;padding:2px 6px;cursor:pointer;font-size:12px;line-height:1";
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const input = getInput();
startEyedropper({
onPick: (hex) => {
input.value = hex;
try {
input.dispatchEvent(new Event("input", { bubbles: true }));
} catch {
}
try {
input.dispatchEvent(new Event("change", { bubbles: true }));
} catch {
}
}
});
});
return btn;
};
const makeRow = () => {
const row = document.createElement("div");
row.style.display = "grid";
row.style.gap = "6px";
row.style.alignItems = "center";
return row;
};
const makeLabel = (text) => {
const label = document.createElement("div");
label.textContent = text;
label.style.fontSize = "11px";
label.style.opacity = "0.78";
return label;
};
const makeNumberInput = (field, options) => {
const input = document.createElement("input");
input.type = "number";
input.dataset.textField = field;
input.min = String(options.min);
if (typeof options.max === "number") input.max = String(options.max);
input.step = String(options.step);
input.title = options.title;
input.style.width = options.width ?? "100%";
input.style.minWidth = "0";
input.style.fontFamily = "ui-monospace, SFMono-Regular, Consolas, monospace";
styleSoftField(input);
return input;
};
const topRow = makeRow();
topRow.style.gridTemplateColumns = "auto minmax(0,1fr) auto minmax(0,1fr)";
topRow.appendChild(makeLabel("Align"));
const alignSelect = document.createElement("select");
alignSelect.dataset.textSelect = "align";
alignSelect.title = "Text alignment";
alignSelect.style.width = "100%";
alignSelect.style.minWidth = "0";
styleSoftField(alignSelect);
for (const [value, label] of [["left", "Left"], ["center", "Center"], ["right", "Right"]]) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
alignSelect.appendChild(option);
}
topRow.appendChild(createContextMenuSelect(alignSelect));
controls.appendChild(topRow);
const posRow = makeRow();
posRow.style.gridTemplateColumns = "auto minmax(0,1fr)";
posRow.appendChild(makeLabel("X/Y"));
const posWrap = document.createElement("div");
posWrap.style.display = "flex";
posWrap.style.gap = "6px";
posWrap.style.alignItems = "center";
posWrap.style.minWidth = "0";
posWrap.appendChild(makeNumberInput("x", { min: -2, max: 3, step: 1e-3, title: "Normalized X" }));
posWrap.appendChild(makeNumberInput("y", { min: -2, max: 3, step: 1e-3, title: "Normalized Y" }));
posRow.appendChild(posWrap);
controls.appendChild(posRow);
const styleRow = makeRow();
styleRow.style.gridTemplateColumns = "auto minmax(0,1fr) auto";
styleRow.appendChild(makeLabel("Opacity"));
const opacityInput = document.createElement("input");
opacityInput.type = "range";
opacityInput.dataset.textField = "opacity";
opacityInput.min = "0";
opacityInput.max = "100";
opacityInput.step = "1";
opacityInput.value = "100";
opacityInput.title = "Text opacity";
styleSoftRange(opacityInput);
styleRow.appendChild(opacityInput);
const opacityLabel = document.createElement("div");
opacityLabel.dataset.textOpacityLabel = "1";
opacityLabel.textContent = "100%";
opacityLabel.style.fontSize = "11px";
opacityLabel.style.opacity = "0.84";
opacityLabel.style.textAlign = "right";
styleRow.appendChild(opacityLabel);
controls.appendChild(styleRow);
const colorsRow = makeRow();
colorsRow.style.gridTemplateColumns = "auto minmax(0,1fr) auto minmax(0,1fr)";
colorsRow.appendChild(makeLabel("Fill"));
const fillColor = createColorSwatch("#ffffff");
fillColor.input.dataset.textColor = "fill";
const fillWrap = document.createElement("div");
fillWrap.style.cssText = "display:flex;gap:4px;align-items:center;min-width:0";
fillWrap.appendChild(fillColor.host);
const fillEye = makeEyedropperButton(() => fillColor.input);
fillWrap.appendChild(fillEye);
colorsRow.appendChild(fillWrap);
colorsRow.appendChild(makeLabel("Stroke"));
const strokeColor = createColorSwatch("#000000");
strokeColor.input.dataset.textColor = "stroke";
const strokeWrap = document.createElement("div");
strokeWrap.style.cssText = "display:flex;gap:4px;align-items:center;min-width:0";
strokeWrap.appendChild(strokeColor.host);
const strokeEye = makeEyedropperButton(() => strokeColor.input);
strokeWrap.appendChild(strokeEye);
colorsRow.appendChild(strokeWrap);
controls.appendChild(colorsRow);
const bottomRow = makeRow();
bottomRow.style.gridTemplateColumns = "auto minmax(0,1fr) auto auto auto";
bottomRow.appendChild(makeLabel("Font"));
const fontPathInput = document.createElement("input");
fontPathInput.type = "text";
fontPathInput.dataset.textField = "font_path";
fontPathInput.placeholder = "Optional font path";
fontPathInput.title = "Optional font path";
fontPathInput.style.width = "100%";
fontPathInput.style.fontFamily = "ui-monospace, SFMono-Regular, Consolas, monospace";
styleSoftField(fontPathInput);
bottomRow.appendChild(fontPathInput);
const invertButton = document.createElement("button");
invertButton.type = "button";
invertButton.dataset.textToggle = "invert_mask";
invertButton.textContent = "Invert mask off";
styleSoftButton(invertButton, false);
bottomRow.appendChild(invertButton);
const bypassButton = document.createElement("button");
bypassButton.type = "button";
bypassButton.dataset.textToggle = "bypass";
bypassButton.textContent = "Bypass off";
styleSoftButton(bypassButton, false);
bottomRow.appendChild(bypassButton);
const hint = document.createElement("div");
hint.textContent = "X/Y are normalized from -2 to 3.";
hint.style.fontSize = "11px";
hint.style.opacity = "0.64";
bottomRow.appendChild(hint);
controls.appendChild(bottomRow);
return { controls };
return { controls: null };
}
const TEXT_ALIGN_OPTIONS = /* @__PURE__ */ new Set(["left", "center", "right"]);
function hideTextWidgets(node) {
for (const name of [
"bypass",
"x",
"y",
"color",
"opacity",
"align",
"stroke_color",
"invert_mask",
"font_path"
]) {
hideWidgetsByName(node, name);
}
if (!isNode(node)) return;
}
function getTextInfoText(node) {
const text = String(widgetString(node, "text", "ImageOps Text") || "");
@@ -208,59 +26,6 @@ function syncTextWidgets(node) {
const strokeColor = widgetString(node, "stroke_color", "#000000");
setWidgetStringValuesByName(node, "color", color, { notify: false, dirty: false });
setWidgetStringValuesByName(node, "stroke_color", strokeColor, { notify: false, dirty: false });
const st = ensureState(node);
const root = st.previewRoot;
if (!root) return;
const text = widgetString(node, "text", "ImageOps Text");
const x = widgetNumber(node, "x", 0.5);
const y = widgetNumber(node, "y", 0.5);
const fontSize = Math.max(1, Math.round(widgetNumber(node, "font_size", 64)));
const opacity = Math.max(0, Math.min(1, widgetNumber(node, "opacity", 1)));
const alignRaw = widgetString(node, "align", "center").toLowerCase();
const align = TEXT_ALIGN_OPTIONS.has(alignRaw) ? alignRaw : "center";
const lineSpacing = Math.max(0, Math.round(widgetNumber(node, "line_spacing", 4)));
const strokeWidth = Math.max(0, Math.round(widgetNumber(node, "stroke_width", 0)));
const invertMask = widgetBoolean(node, "invert_mask", false);
const bypass = widgetBoolean(node, "bypass", false);
const fontPath = widgetString(node, "font_path", "");
const setNumberField = (name, value) => {
const input = root.querySelector(`input[data-text-field="${name}"]`);
if (input) input.value = String(value);
};
setNumberField("x", x);
setNumberField("y", y);
setNumberField("font_size", fontSize);
setNumberField("line_spacing", lineSpacing);
setNumberField("stroke_width", strokeWidth);
const fontPathInput = root.querySelector('input[data-text-field="font_path"]');
if (fontPathInput && fontPathInput.value !== fontPath) fontPathInput.value = fontPath;
const opacityInput = root.querySelector('input[data-text-field="opacity"]');
if (opacityInput) opacityInput.value = String(Math.round(opacity * 100));
const opacityLabel = root.querySelector("[data-text-opacity-label]");
if (opacityLabel) opacityLabel.textContent = `${Math.round(opacity * 100)}%`;
const alignSelect = root.querySelector('select[data-text-select="align"]');
if (alignSelect) alignSelect.value = align;
const colorInput = root.querySelector('input[data-text-color="fill"]');
if (colorInput) {
colorInput.value = color;
syncDarkColorInputUI(colorInput, color);
}
const strokeColorInput = root.querySelector('input[data-text-color="stroke"]');
if (strokeColorInput) {
strokeColorInput.value = strokeColor;
syncDarkColorInputUI(strokeColorInput, strokeColor);
}
const invertButton = root.querySelector('button[data-text-toggle="invert_mask"]');
if (invertButton) {
invertButton.textContent = invertMask ? "Invert mask on" : "Invert mask off";
styleSoftButton(invertButton, invertMask);
}
const bypassButton = root.querySelector('button[data-text-toggle="bypass"]');
if (bypassButton) {
bypassButton.textContent = bypass ? "Bypass on" : "Bypass off";
styleSoftButton(bypassButton, bypass);
}
setDarkColorInputState(strokeColorInput, false, strokeWidth <= 0);
}
export {
NODE_CLASS,
+2 -2
View File
@@ -2137,8 +2137,8 @@ function renderCompPreview(node, inputLayers) {
const useFirst = bool(node, "use_first_layer_size", true);
const largestWidth = inputLayers.reduce((value, entry) => Math.max(value, entry.sourceWidth || entry.image.width || 1), 1);
const largestHeight = inputLayers.reduce((value, entry) => Math.max(value, entry.sourceHeight || entry.image.height || 1), 1);
const customAspect = str(node, "aspect_ratio", "free").trim().toLowerCase();
const customRatio = customAspect === "1:1" || customAspect === "1/1" ? 1 : customAspect === "4:3" || customAspect === "4/3" ? 4 / 3 : customAspect === "16:9" || customAspect === "16/9" ? 16 / 9 : customAspect === "9:16" || customAspect === "9/16" ? 9 / 16 : null;
const customAspect = str(node, "aspect_ratio", "custom").trim().toLowerCase();
const customRatio = customAspect === "1:1" || customAspect === "1/1" ? 1 : customAspect === "3:4" || customAspect === "3/4" ? 3 / 4 : customAspect === "4:3" || customAspect === "4/3" ? 4 / 3 : customAspect === "16:9" || customAspect === "16/9" ? 16 / 9 : customAspect === "9:16" || customAspect === "9/16" ? 9 / 16 : null;
const outputWidth = useAutoLayering ? largestWidth : useFirst && firstInput ? Math.max(1, firstInput.sourceWidth || firstInput.image.width || 1) : Math.max(1, Math.round(num(node, "width", firstInput?.sourceWidth ?? firstInput?.image.width ?? 1024)));
const outputHeight = useAutoLayering ? largestHeight : useFirst && firstInput ? Math.max(1, firstInput.sourceHeight || firstInput.image.height || 1) : customRatio ? Math.max(1, Math.round(outputWidth / customRatio)) : Math.max(1, Math.round(num(node, "height", firstInput?.sourceHeight ?? firstInput?.image.height ?? 1024)));
if (useAutoLayering || useFirst && firstInput || !useFirst && !useAutoLayering && customRatio) {
+33 -161
View File
@@ -1,18 +1,25 @@
function styleSoftButton(button, active = false) {
button.style.border = "1px solid rgba(255,255,255,0.12)";
button.style.background = active ? "rgba(255,255,255,0.16)" : "rgba(255,255,255,0.04)";
button.style.color = "rgba(255,255,255,0.94)";
button.style.borderRadius = "6px";
button.classList.add("comfy-btn");
if (active) {
button.classList.add("active");
} else {
button.classList.remove("active");
}
button.style.border = "1px solid #3f3f3f";
button.style.background = active ? "#444444" : "#2b2b2b";
button.style.color = active ? "#ffffff" : "#cccccc";
button.style.borderRadius = "4px";
button.style.padding = "4px 8px";
button.style.cursor = "pointer";
button.style.fontSize = "11px";
button.style.lineHeight = "1.2";
}
function styleSoftField(field) {
field.style.borderRadius = "6px";
field.style.border = "1px solid rgba(255,255,255,0.12)";
field.style.background = "rgba(0,0,0,0.28)";
field.style.color = "rgba(255,255,255,0.95)";
field.classList.add("comfy-input");
field.style.borderRadius = "4px";
field.style.border = "1px solid #3f3f3f";
field.style.background = "#1c1c1c";
field.style.color = "#ffffff";
field.style.padding = "4px 6px";
field.style.boxSizing = "border-box";
field.style.fontSize = "11px";
@@ -45,160 +52,24 @@ function createContextMenuSelect(select) {
wrapper.style.width = select.style.width || "auto";
wrapper.style.flex = select.style.flex || "0 0 auto";
wrapper.style.flexShrink = select.style.flexShrink || "1";
select.style.display = "none";
const trigger = document.createElement("button");
trigger.type = "button";
styleSoftField(trigger);
trigger.style.width = "100%";
trigger.style.display = "flex";
trigger.style.alignItems = "center";
trigger.style.justifyContent = "space-between";
trigger.style.gap = "8px";
trigger.style.cursor = "pointer";
trigger.style.textAlign = "left";
const label = document.createElement("span");
label.dataset.contextMenuLabel = "1";
label.style.flex = "1 1 auto";
label.style.minWidth = "0";
label.style.overflow = "hidden";
label.style.textOverflow = "ellipsis";
label.style.whiteSpace = "nowrap";
const caret = document.createElement("span");
caret.textContent = "\u25BC";
caret.style.fontSize = "9px";
caret.style.opacity = "0.7";
caret.style.flexShrink = "0";
trigger.append(label, caret);
wrapper.append(select, trigger);
patched.__imageopsContextMenuTrigger = trigger;
patched.__imageopsContextMenuSync = () => syncContextMenuSelect(patched);
if (!patched.__imageopsContextMenuPatched) {
patched.__imageopsContextMenuPatched = true;
const valueDescriptor = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value");
if (valueDescriptor?.get && valueDescriptor?.set) {
Object.defineProperty(select, "value", {
configurable: true,
enumerable: true,
get() {
return valueDescriptor.get?.call(select);
},
set(value) {
valueDescriptor.set?.call(select, value);
syncContextMenuSelect(patched);
}
});
}
const disabledDescriptor = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "disabled");
if (disabledDescriptor?.get && disabledDescriptor?.set) {
Object.defineProperty(select, "disabled", {
configurable: true,
enumerable: true,
get() {
return !!disabledDescriptor.get?.call(select);
},
set(value) {
disabledDescriptor.set?.call(select, value);
syncContextMenuSelect(patched);
}
});
}
}
const openMenu = () => {
if (select.disabled) return;
closeActiveContextMenu();
const menu = document.createElement("div");
menu.style.position = "fixed";
menu.style.zIndex = "999999";
menu.style.minWidth = `${Math.max(120, Math.round(trigger.getBoundingClientRect().width))}px`;
menu.style.maxWidth = "280px";
menu.style.maxHeight = "320px";
menu.style.overflowY = "auto";
menu.style.padding = "4px";
menu.style.border = "1px solid rgba(255,255,255,0.14)";
menu.style.borderRadius = "8px";
menu.style.background = "rgba(18,18,20,0.98)";
menu.style.boxShadow = "0 10px 30px rgba(0,0,0,0.45)";
menu.style.backdropFilter = "blur(10px)";
const triggerRect = trigger.getBoundingClientRect();
const placeMenu = () => {
const estimatedHeight = Math.min(320, Math.max(36, select.options.length * 28 + 8));
const belowTop = triggerRect.bottom + 6;
const aboveTop = Math.max(8, triggerRect.top - estimatedHeight - 6);
menu.style.left = `${Math.max(8, triggerRect.left)}px`;
menu.style.top = `${belowTop + estimatedHeight <= window.innerHeight ? belowTop : aboveTop}px`;
};
placeMenu();
const choose = (value) => {
const nativeValueDescriptor = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value");
nativeValueDescriptor?.set?.call(select, value);
syncContextMenuSelect(patched);
select.dispatchEvent(new Event("change", { bubbles: true }));
closeActiveContextMenu();
};
for (const option of Array.from(select.options)) {
const item = document.createElement("button");
item.type = "button";
item.textContent = option.textContent ?? option.value;
item.disabled = option.disabled;
item.style.display = "block";
item.style.width = "100%";
item.style.border = "none";
item.style.borderRadius = "6px";
item.style.padding = "6px 8px";
item.style.margin = "0";
item.style.textAlign = "left";
item.style.fontSize = "11px";
item.style.color = "rgba(255,255,255,0.95)";
item.style.background = option.value === select.value ? "rgba(255,255,255,0.14)" : "transparent";
item.style.cursor = option.disabled ? "default" : "pointer";
item.style.opacity = option.disabled ? "0.45" : "1";
item.addEventListener("click", (event) => {
event.preventDefault();
if (option.disabled) return;
choose(option.value);
});
menu.appendChild(item);
}
const closeMenu = () => {
document.removeEventListener("pointerdown", onPointerDown, true);
document.removeEventListener("keydown", onKeyDown, true);
menu.remove();
trigger.setAttribute("aria-expanded", "false");
if (activeContextMenuCleanup === closeMenu) activeContextMenuCleanup = null;
};
const onPointerDown = (event) => {
const target = event.target;
if (target && (menu.contains(target) || trigger.contains(target))) return;
closeMenu();
};
const onKeyDown = (event) => {
if (event.key !== "Escape") return;
event.preventDefault();
closeMenu();
};
activeContextMenuCleanup = closeMenu;
trigger.setAttribute("aria-expanded", "true");
document.body.appendChild(menu);
requestAnimationFrame(() => {
document.addEventListener("pointerdown", onPointerDown, true);
document.addEventListener("keydown", onKeyDown, true);
});
styleSoftField(select);
select.style.display = "block";
select.style.width = "100%";
select.style.minWidth = "0";
select.style.height = "26px";
select.style.cursor = select.disabled ? "default" : "pointer";
patched.__imageopsContextMenuSync = () => {
select.style.opacity = select.disabled ? "0.55" : "1";
select.style.cursor = select.disabled ? "default" : "pointer";
};
trigger.addEventListener("click", (event) => {
event.preventDefault();
openMenu();
});
trigger.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " " && event.key !== "ArrowDown") return;
event.preventDefault();
openMenu();
});
select.addEventListener("change", patched.__imageopsContextMenuSync);
select.addEventListener("input", patched.__imageopsContextMenuSync);
syncContextMenuSelect(patched);
patched.__imageopsContextMenuSync();
wrapper.append(select);
return wrapper;
}
function styleSoftRange(field) {
field.classList.add("comfy-slider");
field.style.width = "100%";
field.style.margin = "0";
field.style.boxSizing = "border-box";
@@ -230,10 +101,11 @@ function createColorSwatch(initialColor, options = {}) {
const compact = options.compact ?? false;
const hex = normalizeHex6(initialColor);
const host = document.createElement("div");
host.classList.add("comfy-swatch");
host.style.display = "inline-flex";
host.style.alignItems = "stretch";
host.style.borderRadius = "6px";
host.style.border = "1px solid rgba(255,255,255,0.12)";
host.style.borderRadius = "4px";
host.style.border = "1px solid #3f3f3f";
host.style.overflow = "hidden";
host.style.cursor = "pointer";
host.style.height = "28px";
@@ -243,7 +115,7 @@ function createColorSwatch(initialColor, options = {}) {
const colorBlock = document.createElement("div");
colorBlock.style.flex = compact ? "1" : "0 0 22px";
colorBlock.style.background = hex;
if (!compact) colorBlock.style.borderRight = "1px solid rgba(255,255,255,0.12)";
if (!compact) colorBlock.style.borderRight = "1px solid #3f3f3f";
host.appendChild(colorBlock);
let hexLabel = null;
if (!compact) {
@@ -253,8 +125,8 @@ function createColorSwatch(initialColor, options = {}) {
hexLabel.style.alignItems = "center";
hexLabel.style.justifyContent = "center";
hexLabel.style.fontSize = "10px";
hexLabel.style.color = "rgba(255,255,255,0.65)";
hexLabel.style.background = "rgba(0,0,0,0.28)";
hexLabel.style.color = "#aaaaaa";
hexLabel.style.background = "#1c1c1c";
hexLabel.style.letterSpacing = "0.5px";
hexLabel.style.fontFamily = "monospace";
hexLabel.style.userSelect = "none";
+5 -1
View File
@@ -22,7 +22,7 @@ function attachPreviewNavigation(node, canvasSize) {
return { rect, sx: canvas.width / rect.width, sy: canvas.height / rect.height };
};
canvas.addEventListener("pointerdown", (event) => {
if (event.button !== 1) return;
if (event.button !== 0 || isInteractiveNode(node)) return;
event.preventDefault();
event.stopPropagation();
try {
@@ -38,6 +38,7 @@ function attachPreviewNavigation(node, canvasSize) {
startPanX: st.previewPanX,
startPanY: st.previewPanY
};
canvas.style.cursor = "grabbing";
});
let panRafPending = false;
canvas.addEventListener("pointermove", (event) => {
@@ -60,6 +61,7 @@ function attachPreviewNavigation(node, canvasSize) {
canvas.addEventListener("pointerup", (event) => {
if (st.previewPanDrag?.pointerId === event.pointerId) {
st.previewPanDrag = null;
canvas.style.cursor = "";
try {
canvas.releasePointerCapture(event.pointerId);
} catch {
@@ -69,12 +71,14 @@ function attachPreviewNavigation(node, canvasSize) {
canvas.addEventListener("pointercancel", (event) => {
if (st.previewPanDrag?.pointerId === event.pointerId) {
st.previewPanDrag = null;
canvas.style.cursor = "";
}
});
const handleWheel = (event) => {
const target = event.target;
if (target !== canvas && !canvas.contains(target)) return;
if (isInteractiveNode(node)) return;
if (!(event.ctrlKey || event.metaKey)) return;
event.stopImmediatePropagation();
event.preventDefault();
const r = safeRect();
+32 -203
View File
@@ -1,5 +1,5 @@
import { ensureState } from "./state.js";
import { isImageOpsClass, isImageOpsNativeUiClass } from "./classes.js";
import { isImageOpsClass } from "./classes.js";
import { createPreviewControlsUi, isNode as isPreviewNode } from "../nodes/preview.js";
import { createConstantControlsUi, isNode as isConstantNode } from "../nodes/constant.js";
import { createGrainControlsUi, isNode as isGrainNode } from "../nodes/grain.js";
@@ -13,10 +13,7 @@ import { createJoinControlsUi, isNode as isAppendNode } from "../nodes/append.js
import { createFrameSelectorControlsUi, isNode as isFrameRangeNode } from "../nodes/frame-range.js";
import { createKeyerControlsUi, isNode as isKeyerNode } from "../nodes/keyer.js";
import { createPadOutControlsUi, isNode as isPadOutNode } from "../nodes/pad-out.js";
import { styleSoftButton, styleSoftField, styleInlineAction, createColorSwatch, createContextMenuSelect, syncDarkColorInputUI } from "./dom-styles.js";
import { getWidgetInputSpec, listCompactUiWidgets, setWidgetMixedValue } from "./widgets.js";
import { bindCollapsibleToUiState } from "./ui-persist.js";
import { attachDblClickReset } from "./dbl-click-reset.js";
import { styleInlineAction } from "./dom-styles.js";
function getNodePreviewMinHeight(node) {
if (isDrawNode(node)) return 220;
if (isConstantNode(node)) return 390;
@@ -76,205 +73,9 @@ function getCompactWidgetValue(widget, fallback) {
return String(raw ?? "");
}
function buildCompactNativeWidgetControls(node, onWidgetChange) {
if (!isImageOpsNativeUiClass(node.comfyClass) && !isCompNode(node)) return null;
const widgets = listCompactUiWidgets(node);
if (!widgets.length) return null;
const st = ensureState(node);
const bindings = [];
const panel = document.createElement("div");
panel.style.marginTop = "8px";
panel.style.display = "grid";
panel.style.gap = "8px";
const seenNames = /* @__PURE__ */ new Set();
for (const widget of widgets) {
if (seenNames.has(widget.name)) continue;
seenNames.add(widget.name);
const spec = getWidgetInputSpec(node, widget.name);
if (!spec) continue;
const row = document.createElement("div");
row.style.display = "grid";
row.style.gridTemplateColumns = "minmax(84px, auto) minmax(0,1fr)";
row.style.gap = "8px";
row.style.alignItems = "center";
const label = document.createElement("div");
label.textContent = String(spec.options?.display_name ?? prettifyWidgetLabel(widget.name));
label.style.fontSize = "11px";
label.style.opacity = "0.78";
label.style.lineHeight = "1.2";
row.appendChild(label);
const commitValue = (value) => {
setWidgetMixedValue(widget, value);
syncCompactNativeWidgetControls(node);
onWidgetChange?.();
};
const comboOptions = Array.isArray(spec.typeSpec) ? spec.typeSpec : String(spec.typeSpec ?? "").trim().toUpperCase() === "COMBO" && Array.isArray(spec.options?.options) ? spec.options.options : null;
if (comboOptions !== null) {
const select = document.createElement("select");
styleSoftField(select);
select.style.width = "100%";
for (const entry of comboOptions) {
const option = document.createElement("option");
option.value = String(entry);
option.textContent = prettifyWidgetLabel(String(entry));
select.appendChild(option);
}
if (spec.options?.tooltip) select.title = String(spec.options.tooltip);
select.addEventListener("change", () => commitValue(select.value));
row.appendChild(createContextMenuSelect(select));
bindings.push({ widget, kind: "select", control: select, integer: false });
panel.appendChild(row);
continue;
}
const kind = String(spec.typeSpec ?? "").trim().toUpperCase();
if (kind === "BOOLEAN") {
const button = document.createElement("button");
button.type = "button";
if (spec.options?.tooltip) button.title = String(spec.options.tooltip);
button.addEventListener("click", () => {
const active = String(widget.value ?? "false").toLowerCase().match(/^(true|1)$/) != null;
commitValue(!active);
});
row.appendChild(button);
bindings.push({ widget, kind: "boolean", control: button, integer: false });
panel.appendChild(row);
continue;
}
if (kind === "COLOR") {
const initialColor = String(widget.value ?? spec.options?.default ?? "#000000");
const { input: colorInput, host: colorHost } = createColorSwatch(initialColor, {
title: spec.options?.tooltip ? String(spec.options.tooltip) : void 0
});
colorInput.addEventListener("input", () => commitValue(colorInput.value));
const defaultColor = String(spec.options?.default ?? "#000000");
attachDblClickReset(colorHost, { defaultValue: defaultColor, onReset: (v) => commitValue(String(v)) });
attachDblClickReset(colorInput, { defaultValue: defaultColor, onReset: (v) => commitValue(String(v)) });
row.appendChild(colorHost);
bindings.push({ widget, kind: "color", control: colorInput, integer: false });
panel.appendChild(row);
continue;
}
const input = document.createElement("input");
input.type = kind === "STRING" ? "text" : "number";
input.style.width = "100%";
if (kind !== "STRING") {
if (spec.options?.min != null) input.min = String(spec.options.min);
if (spec.options?.max != null) input.max = String(spec.options.max);
if (spec.options?.step != null) input.step = String(spec.options.step);
input.style.fontFamily = "ui-monospace, SFMono-Regular, Consolas, monospace";
input.addEventListener("input", () => {
const next = Number(input.value);
if (!Number.isFinite(next)) return;
commitValue(kind === "INT" ? Math.round(next) : next);
});
const numericDefault = Number(spec.options?.default ?? 0);
if (Number.isFinite(numericDefault)) {
attachDblClickReset(input, {
defaultValue: kind === "INT" ? Math.round(numericDefault) : numericDefault,
onReset: (v) => commitValue(v)
});
}
} else {
input.addEventListener("change", () => commitValue(input.value));
const stringDefault = String(spec.options?.default ?? "");
attachDblClickReset(input, { defaultValue: stringDefault, onReset: (v) => commitValue(String(v)) });
}
if (spec.options?.tooltip) input.title = String(spec.options.tooltip);
styleSoftField(input);
if (kind === "INT" && widget.name === "seed") {
const wrap = document.createElement("div");
wrap.style.cssText = "display:flex;gap:4px;align-items:center;min-width:0";
wrap.appendChild(input);
const rand = document.createElement("button");
rand.type = "button";
rand.textContent = "\u{1F3B2}";
rand.title = "Randomize seed";
rand.style.cssText = "background:#2a2a2a;color:#ddd;border:1px solid #444;border-radius:4px;padding:2px 6px;cursor:pointer;font-size:12px;line-height:1;flex:0 0 auto";
rand.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const max = Number.isFinite(Number(spec.options?.max)) ? Number(spec.options?.max) : 2147483647;
const min = Number.isFinite(Number(spec.options?.min)) ? Number(spec.options?.min) : 0;
const span = Math.max(1, Math.min(max, 2147483647) - Math.max(min, 0));
const next = Math.floor(Math.random() * span) + Math.max(min, 0);
input.value = String(next);
commitValue(next);
});
wrap.appendChild(rand);
row.appendChild(wrap);
} else {
row.appendChild(input);
}
bindings.push({ widget, kind: kind === "STRING" ? "text" : "number", control: input, integer: kind === "INT" });
panel.appendChild(row);
}
if (!bindings.length) return null;
st.compactWidgetBindings = bindings;
syncCompactNativeWidgetControls(node);
const COLLAPSE_THRESHOLD = 4;
if (bindings.length > COLLAPSE_THRESHOLD) {
const details = document.createElement("details");
details.style.marginTop = "8px";
details.style.background = "rgba(255,255,255,0.03)";
details.style.border = "1px solid rgba(255,255,255,0.08)";
details.style.borderRadius = "6px";
details.style.padding = "4px 8px 8px";
const summary = document.createElement("summary");
summary.textContent = `Controls (${bindings.length})`;
summary.style.cursor = "pointer";
summary.style.fontSize = "11px";
summary.style.opacity = "0.85";
summary.style.padding = "4px 0";
summary.style.userSelect = "none";
details.appendChild(summary);
panel.style.marginTop = "6px";
details.appendChild(panel);
bindCollapsibleToUiState(details, String(node.comfyClass ?? "unknown"), "compactPanelOpen", false);
details.addEventListener("toggle", () => {
try {
node.graph?.setDirtyCanvas?.(true, true);
} catch {
}
try {
const root = ensureState(node).previewRoot;
const target = getNodePreviewTargetSize(node, root ?? null, Math.max(360, Math.round(node.size?.[0] ?? 360)));
node.setSize?.(target);
} catch {
}
});
return details;
}
return panel;
return null;
}
function syncCompactNativeWidgetControls(node) {
const bindings = ensureState(node).compactWidgetBindings ?? [];
for (const binding of bindings) {
const spec = getWidgetInputSpec(node, binding.widget.name);
const fallback = spec?.options?.default;
if (binding.kind === "boolean") {
const button = binding.control;
const active = String(binding.widget.value ?? fallback ?? "false").toLowerCase().match(/^(true|1)$/) != null;
const labelOn = spec?.options?.label_on ? String(spec.options.label_on) : "On";
const labelOff = spec?.options?.label_off ? String(spec.options.label_off) : "Off";
button.textContent = active ? labelOn : labelOff;
styleSoftButton(button, active);
continue;
}
if (binding.kind === "select") {
binding.control.value = getCompactWidgetValue(binding.widget, fallback);
continue;
}
if (binding.kind === "number") {
const input = binding.control;
const raw = Number(binding.widget.value ?? fallback ?? "");
input.value = Number.isFinite(raw) ? String(binding.integer ? Math.round(raw) : raw) : "";
continue;
}
if (binding.kind === "color") {
syncDarkColorInputUI(binding.control, String(binding.widget.value ?? fallback ?? "#000000"));
continue;
}
binding.control.value = getCompactWidgetValue(binding.widget, fallback);
}
}
function getNodePreviewTargetSize(node, root, fallbackWidth = 360) {
const minWidth = 360;
@@ -427,7 +228,7 @@ function ensurePreviewWidget(node, progress, canvasSize, onNativeWidgetChange) {
} else if (constantNode) {
previewControls = createConstantControlsUi().controls;
} else if (grainNode) {
previewControls = createGrainControlsUi().controls;
previewControls = createGrainControlsUi(node).controls;
} else if (textNode) {
previewControls = createTextControlsUi().controls;
} else if (rampNode) {
@@ -698,10 +499,38 @@ function ensurePreviewWidget(node, progress, canvasSize, onNativeWidgetChange) {
obs.observe(root, { attributes: true, attributeFilter: ["style"] });
state._displayObserverCleanup = () => obs.disconnect();
});
if (drawNode && drawClearButton) {
const clearWrap = document.createElement("div");
clearWrap.style.display = "flex";
clearWrap.style.justifyContent = "center";
clearWrap.style.marginTop = "8px";
clearWrap.style.marginBottom = "8px";
clearWrap.style.width = "100%";
clearWrap.appendChild(drawClearButton);
node.addDOMWidget("clear_action", "ImageOpsClear", clearWrap, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => 32
});
const clearWidget = (node.widgets ?? []).find((widget) => widget?.name === "clear_action");
if (clearWidget?.hidden !== false) {
clearWidget.hidden = false;
}
}
} else {
const domEl = node.domElement ?? node.element;
if (domEl instanceof HTMLElement) {
domEl.appendChild(root);
if (drawNode && drawClearButton) {
const clearWrap = document.createElement("div");
clearWrap.style.display = "flex";
clearWrap.style.justifyContent = "center";
clearWrap.style.marginTop = "8px";
clearWrap.style.marginBottom = "8px";
clearWrap.style.width = "100%";
clearWrap.appendChild(drawClearButton);
domEl.appendChild(clearWrap);
}
} else {
console.warn("[ImageOps] addDOMWidget unavailable and no DOM container found on node", node.id);
}
+3 -4
View File
@@ -77,7 +77,9 @@ function hideWidgetForGood(node, widget, suffix = "") {
function hideWidgetsByName(node, name) {
if (!node?.widgets) return;
for (const widget of node.widgets) {
if (widget?.name === name) hideWidgetForGood(node, widget);
if (widget?.name && widget.name.toLowerCase() === name.toLowerCase()) {
hideWidgetForGood(node, widget);
}
}
}
function setWidgetValue(widget, value, options = {}) {
@@ -168,9 +170,6 @@ function listCompactUiWidgets(node) {
return (node?.widgets ?? []).filter((widget) => supportsCompactUi(node, widget));
}
function hideCompactUiWidgets(node) {
for (const widget of listCompactUiWidgets(node)) {
hideWidgetForGood(node, widget);
}
}
function cloneDefaultValue(value) {
if (value == null || typeof value !== "object") return value;
+8 -15
View File
@@ -7,6 +7,7 @@ import torch
from comfy_api.latest import io
from ._helpers import (
ASPECT_RATIO_PRESETS,
COMP_BLEND_MODES,
_alpha_mask_from_image,
_coerce_media_to_tensor,
@@ -41,24 +42,16 @@ def _io_combo(name: str, options: list[str], **kwargs):
return _io_field("String", "Input", name, **kwargs)
_COMP_ASPECT_RATIOS = ["free", "1/1", "4/3", "16/9", "9/16"]
_COMP_RATIO_VALUES = {
"1/1": (1, 1),
"1:1": (1, 1),
"4/3": (4, 3),
"4:3": (4, 3),
"16/9": (16, 9),
"16:9": (16, 9),
"9/16": (9, 16),
"9:16": (9, 16),
}
_COMP_ASPECT_RATIOS = list(ASPECT_RATIO_PRESETS.keys())
def _resolve_custom_comp_size(width: int, height: int, aspect_ratio: str) -> tuple[int, int]:
out_w = max(1, _scalar(width, int))
out_h = max(1, _scalar(height, int))
normalized = str(aspect_ratio or "free").strip().lower().replace(" ", "")
ratio = _COMP_RATIO_VALUES.get(normalized)
normalized = str(aspect_ratio or "custom").strip().lower().replace(" ", "")
if normalized in ("free", "custom"):
return out_w, out_h
ratio = ASPECT_RATIO_PRESETS.get(aspect_ratio)
if ratio is None:
return out_w, out_h
ratio_w, ratio_h = ratio
@@ -209,7 +202,7 @@ class ImageOpsComp(io.ComfyNode):
),
_io_field("Int", "Input", "width", default=1024, min=1, max=8192, step=1),
_io_field("Int", "Input", "height", default=1024, min=1, max=8192, step=1),
_io_combo("aspect_ratio", _COMP_ASPECT_RATIOS, default="free", tooltip="Only used in Custom Format. Free keeps Width/Height independent; presets derive Height from Width."),
_io_combo("aspect_ratio", _COMP_ASPECT_RATIOS, default="custom", tooltip="Only used in Custom Format. Custom keeps Width/Height independent; presets derive Height from Width."),
_io_field("Color", "Input", "background_color", default="#000000"),
_io_field("String", "Input", "layers_json", default='{"version":1,"layers":[]}', socketless=True),
_io_field("Boolean", "Input", "invert_mask", default=False),
@@ -232,7 +225,7 @@ class ImageOpsComp(io.ComfyNode):
auto_layering: bool = False,
width: int = 1024,
height: int = 1024,
aspect_ratio: str = "free",
aspect_ratio: str = "custom",
background_color: str = "#000000",
layers_json: str = '{"version":1,"layers":[]}',
invert_mask: bool = False,
+5 -3
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import torch
from ._helpers import _hex_to_rgb01, _scalar
from ._helpers import _hex_to_rgb01, _scalar, ASPECT_RATIO_PRESETS
from ._preview import build_node_preview_result
from ._progress import start_progress
@@ -52,6 +52,7 @@ class ImageOpsConstant:
"mode": (_MODES, {"default": "constant"}),
"width": ("INT", {"default": 1024, "min": 1, "max": 8192, "step": 1}),
"height": ("INT", {"default": 1024, "min": 1, "max": 8192, "step": 1}),
"aspect_ratio": (list(ASPECT_RATIO_PRESETS.keys()), {"default": "custom"}),
"frame_count": ("INT", {"default": 1, "min": 1, "max": 4096, "step": 1}),
"color": ("COLOR", {"default": "#ffffff"}),
"color_b": ("COLOR", {"default": "#000000"}),
@@ -70,6 +71,7 @@ class ImageOpsConstant:
mode="constant",
width=1024,
height=1024,
aspect_ratio="custom",
frame_count=None,
frame_length=None,
batch_size=None,
@@ -82,8 +84,8 @@ class ImageOpsConstant:
unique_id=None,
):
progress = start_progress(unique_id=unique_id)
out_w = max(1, _scalar(width, int))
out_h = max(1, _scalar(height, int))
from .comp import _resolve_custom_comp_size
out_w, out_h = _resolve_custom_comp_size(width, height, aspect_ratio)
frame_count_source = frame_count if frame_count is not None else (frame_length if frame_length is not None else (batch_size if batch_size is not None else 1))
batch = max(1, _scalar(frame_count_source, int))
opacity = max(0.0, min(1.0, _scalar(alpha)))
+4 -4
View File
@@ -210,6 +210,8 @@ class ImageOpsDraw:
return {
"required": {
"bypass": ("BOOLEAN", {"default": False}),
"brush_color": ("COLOR", {"default": "#FFFFFF"}),
"brush_size": ("INT", {"default": 10, "min": 1, "max": 256, "step": 1, "display": "slider"}),
"width": ("INT", {"default": 1024, "min": 64, "max": 4096, "step": 64}),
"height": ("INT", {"default": 1024, "min": 64, "max": 4096, "step": 64}),
"sync_dimensions": ("BOOLEAN", {"default": True, "label_on": "Linked", "label_off": "Free"}),
@@ -217,9 +219,7 @@ class ImageOpsDraw:
"tool": (["brush", "eraser"], {"default": "brush"}),
"brush_edge": (["hard", "soft"], {"default": "hard"}),
"brush_softness": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "display": "slider", "round": 0.001, "tooltip": "How feathered the soft brush is. 0 = nearly hard, 1 = max falloff. Ignored when brush_edge=hard."}),
"brush_color": ("COLOR", {"default": "#FFFFFF"}),
"brush_opacity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "display": "slider", "round": 0.001}),
"brush_size": ("INT", {"default": 10, "min": 1, "max": 256, "step": 1, "display": "slider"}),
"brush_pressure_size": ("BOOLEAN", {"default": True}),
"brush_pressure_opacity": ("BOOLEAN", {"default": True}),
"brush_tilt_size": ("BOOLEAN", {"default": False}),
@@ -240,6 +240,8 @@ class ImageOpsDraw:
self,
image=None,
bypass=False,
brush_color="#FFFFFF",
brush_size=10,
width=1024,
height=1024,
sync_dimensions=True,
@@ -247,9 +249,7 @@ class ImageOpsDraw:
tool="brush",
brush_edge="hard",
brush_softness=0.5,
brush_color="#FFFFFF",
brush_opacity=1.0,
brush_size=10,
brush_pressure_size=True,
brush_pressure_opacity=True,
brush_tilt_size=False,
+4 -4
View File
@@ -1,10 +1,10 @@
import torch
from ._helpers import MEDIA_INPUT_TYPE, _scalar, _select_media_tensor
from ._helpers import ASPECT_RATIO_PRESETS, MEDIA_INPUT_TYPE, _scalar, _select_media_tensor
from ._preview import build_node_preview_result
from ._progress import start_progress
_PADOUT_TARGET_FORMATS = ["custom", "1:1", "16:9", "9:16", "4:3", "3:4"]
_PADOUT_TARGET_FORMATS = list(ASPECT_RATIO_PRESETS.keys())
_TARGET_RATIOS = {
"1:1": (1, 1),
"square": (1, 1),
@@ -61,7 +61,7 @@ class ImageOpsPadOut:
"pad_top": ("INT", {"default": 128, "min": 0, "max": 4096, "step": 1}),
"pad_right": ("INT", {"default": 128, "min": 0, "max": 4096, "step": 1}),
"pad_bottom": ("INT", {"default": 128, "min": 0, "max": 4096, "step": 1}),
"target_format": (_PADOUT_TARGET_FORMATS, {"default": "custom", "tooltip": "Interactive preview ratio preset. The UI turns this into explicit padding; backend output uses the pad values as-is."}),
"aspect_ratio": (_PADOUT_TARGET_FORMATS, {"default": "custom", "tooltip": "Interactive preview ratio preset. The UI turns this into explicit padding; backend output uses the pad values as-is."}),
"snap_to_multiple": ("INT", {"default": 1, "min": 1, "max": 256, "step": 1, "tooltip": "Quantize padding to this multiple. Use 16 for Wan/VACE-friendly output sizes."}),
"invert_mask": ("BOOLEAN", {"default": False, "tooltip": "By default the output mask marks the *padded* area as 1 and the original source as 0. Toggle to invert (source=1, padding=0)."}),
},
@@ -81,7 +81,7 @@ class ImageOpsPadOut:
pad_top=128,
pad_right=128,
pad_bottom=128,
target_format="custom",
aspect_ratio="custom",
snap_to_multiple=1,
invert_mask=False,
video=None,
+174 -24
View File
@@ -47,6 +47,7 @@ import {
syncDarkColorInputUI,
setDarkColorInputState,
getCompInfoText,
syncCompWidgets,
} from "./nodes/comp.js";
import { isNode as isCornerPinNode, getCornerPinInfoText } from "./nodes/corner-pin.js";
import { applyPadOutTargetFormat, attachPadOutControls, getPadOutInfoText, hidePadOutWidgets, hydratePadOutTargetFormat, isNode as isPadOutNode, syncPadOutControls } from "./nodes/pad-out.js";
@@ -116,6 +117,169 @@ function hydrateKeyerDefaults(node: ComfyNode, nodeData: any): void {
}
export function registerImageOpsLivePreview(): void {
if (typeof document !== "undefined" && !document.getElementById("comfyui-imageops-styles")) {
const style = document.createElement("style");
style.id = "comfyui-imageops-styles";
style.textContent = `
/* Scrollbars in custom UI panel */
.comfy-menu::-webkit-scrollbar,
.comfy-panel::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.comfy-menu::-webkit-scrollbar-thumb,
.comfy-panel::-webkit-scrollbar-thumb {
background: #444;
border-radius: 3px;
}
.comfy-menu::-webkit-scrollbar-track,
.comfy-panel::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
}
/* Fields & Inputs */
.comfy-input {
font-family: var(--comfy-font-sans, Inter, sans-serif);
font-size: 11px !important;
border-radius: 4px !important;
border: 1px solid #3f3f3f !important;
background: #1c1c1c !important;
color: #fff !important;
padding: 4px 6px !important;
box-sizing: border-box !important;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.comfy-input:focus {
border-color: #555 !important;
outline: none !important;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.05);
}
/* Buttons */
.comfy-btn {
font-family: var(--comfy-font-sans, Inter, sans-serif);
font-size: 11px !important;
border-radius: 4px !important;
border: 1px solid #3f3f3f !important;
background: #2b2b2b !important;
color: #ccc !important;
padding: 4px 8px !important;
cursor: pointer !important;
line-height: 1.2 !important;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
user-select: none;
}
.comfy-btn:hover {
background: #3e3e3e !important;
color: #fff !important;
border-color: #555 !important;
}
.comfy-btn:active {
background: #1c1c1c !important;
color: #aaa !important;
}
.comfy-btn.active {
background: #444 !important;
color: #fff !important;
border-color: #666 !important;
font-weight: 500;
}
/* Range/Slider Input */
.comfy-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
background: #151515 !important;
border: 1px solid #2e2e2e !important;
border-radius: 3px !important;
outline: none !important;
margin: 6px 0 !important;
cursor: ew-resize !important;
transition: border-color 0.15s ease;
}
.comfy-slider:hover {
border-color: #3f3f3f !important;
}
/* WebKit Slider Thumb (Chrome, Safari, Edge) */
.comfy-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #ccc;
border: 1px solid #444;
cursor: ew-resize;
transition: background-color 0.15s ease, transform 0.1s ease;
}
.comfy-slider::-webkit-slider-thumb:hover {
background: #fff;
transform: scale(1.15);
}
.comfy-slider::-webkit-slider-thumb:active {
background: #aaa;
}
/* Firefox Slider Thumb */
.comfy-slider::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: #ccc;
border: 1px solid #444;
cursor: ew-resize;
transition: background-color 0.15s ease, transform 0.1s ease;
}
.comfy-slider::-moz-range-thumb:hover {
background: #fff;
transform: scale(1.15);
}
/* Details Panel (Collapsible Panel) */
.comfy-details {
margin-top: 8px !important;
background: rgba(255, 255, 255, 0.02) !important;
border: 1px solid #333333 !important;
border-radius: 4px !important;
padding: 4px 8px 8px !important;
font-family: var(--comfy-font-sans, Inter, sans-serif);
}
.comfy-details summary {
cursor: pointer;
font-size: 11px;
font-weight: 600;
opacity: 0.85;
padding: 6px 0;
user-select: none;
color: #fff;
}
.comfy-details summary:hover {
opacity: 1;
}
/* Custom Dropdown Context Menu */
.comfy-menu {
background: #1c1c1f !important;
border: 1px solid #3e3e3e !important;
border-radius: 4px !important;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6) !important;
box-sizing: border-box;
}
.comfy-menu button {
border-radius: 3px !important;
font-family: var(--comfy-font-sans, Inter, sans-serif);
transition: background-color 0.1s ease;
}
.comfy-menu button:hover {
background: #3e3e42 !important;
}
`;
document.head.appendChild(style);
}
initOpsConstants();
const cfg = getPreviewConfig();
const canvasSize = cfg.canvasSize;
@@ -194,12 +358,7 @@ export function registerImageOpsLivePreview(): void {
st.drawOverlayKey = null;
syncDrawWidgets(node);
}
if (isImageOpsNativeUiClass(node.comfyClass)) {
syncCompactNativeWidgetControls(node);
}
if (isCompNode(node)) {
syncCompactNativeWidgetControls(node);
}
// Removed syncCompactNativeWidgetControls calls to support native ComfyUI widgets
if (isCornerPinNode(node) || isPadOutNode(node) || isRampNode(node)) {
if (st.canvas) st.canvas.style.cursor = "default";
}
@@ -1001,10 +1160,7 @@ export function registerImageOpsLivePreview(): void {
if (isRampNode(node)) syncRampWidgets(node);
if (isDrawNode(node)) syncDrawWidgets(node);
if (isKeyerNode(node)) syncKeyerWidgets(node);
if (isImageOpsNativeUiClass(node.comfyClass) || isCompNode(node)) {
hideCompactUiWidgets(node);
syncCompactNativeWidgetControls(node);
}
// Removed hideCompactUiWidgets & syncCompactNativeWidgetControls in favor of native ComfyUI widgets
} catch (e) {
console.warn("[ImageOps] late widget sync failed for", node?.comfyClass, e);
}
@@ -1015,10 +1171,7 @@ export function registerImageOpsLivePreview(): void {
if (isImageOpsClass(node.comfyClass)) {
node.previewMediaType = "image";
ensurePreviewWidget(node, progress, canvasSize, () => nodeCtx.refreshNode(node));
if (isImageOpsNativeUiClass(node.comfyClass) || isCompNode(node)) {
hideCompactUiWidgets(node);
syncCompactNativeWidgetControls(node);
}(node);
// Removed hideCompactUiWidgets & syncCompactNativeWidgetControls in favor of native ComfyUI widgets
attachPreviewNavigation(node, canvasSize);
if (isPreviewNode(node)) {
attachPreviewInteractionsExt(node, nodeCtx);
@@ -1084,13 +1237,14 @@ export function registerImageOpsLivePreview(): void {
}
if (isCompNode(node)) {
ensureCompState(node);
syncCompWidgets(node, w.name);
updateCompControls(node);
}
if (isPreviewNode(node)) {
syncPreviewWidgets(node);
}
if (isConstantNode(node)) {
syncConstantWidgets(node);
syncConstantWidgets(node, w.name);
}
if (isGrainNode(node)) {
syncGrainWidgets(node);
@@ -1108,18 +1262,16 @@ export function registerImageOpsLivePreview(): void {
syncKeyerWidgets(node);
}
if (isPadOutNode(node)) {
if (w.name === "target_format") {
if (w.name === "aspect_ratio") {
(st as any).padOutRatioHydrated = false;
hydratePadOutTargetFormat(node);
} else if (w.name === "snap_to_multiple") {
const targetFormat = widgetString(node, "target_format", "custom");
const targetFormat = widgetString(node, "aspect_ratio", "custom");
if (targetFormat !== "custom") applyPadOutTargetFormat(node, targetFormat);
}
syncPadOutControls(node);
}
if (isImageOpsNativeUiClass(node.comfyClass) || isCompNode(node)) {
syncCompactNativeWidgetControls(node);
}
// Removed syncCompactNativeWidgetControls calls to support native ComfyUI widgets
// Use 0ms delay when RAF loop is stopped (frozen FrameRange, static nodes) for
// instant visual feedback. Use full debounce when the loop is already running —
// it will pick up widget changes on the next tick automatically.
@@ -1225,6 +1377,7 @@ export function registerImageOpsLivePreview(): void {
if (isCompNode(node) && prop === "onConfigure") {
hideCompWidgets(node);
ensureCompState(node);
syncCompWidgets(node);
st.compInteractiveHooked = false;
(st as any).compKeyboardHooked = false;
attachCompInteractionsExt(node, compCtx);
@@ -1262,10 +1415,7 @@ export function registerImageOpsLivePreview(): void {
hideFrameSelectorWidgets(node);
syncFrameSelectorWidgets(node);
}
if ((isImageOpsNativeUiClass(node.comfyClass) || isCompNode(node)) && prop === "onConfigure") {
hideCompactUiWidgets(node);
syncCompactNativeWidgetControls(node);
}
// Removed hideCompactUiWidgets & syncCompactNativeWidgetControls calls to support native ComfyUI widgets
if (prop === "onConfigure" && isImageOpsClass(node.comfyClass)) {
const minH = getNodePreviewMinHeight(node);
// Defer so any post-configure size restoration by ComfyUI happens first.
+3 -16
View File
@@ -24,9 +24,11 @@ function rotatePoint(x: number, y: number, angleRad: number): { x: number; y: nu
}
function aspectRatioValue(value: string): number | null {
switch (String(value || "free").trim().toLowerCase()) {
switch (String(value || "custom").trim().toLowerCase()) {
case "1/1":
case "1:1": return 1;
case "3/4":
case "3:4": return 3 / 4;
case "4/3":
case "4:3": return 4 / 3;
case "16/9":
@@ -119,21 +121,6 @@ export function attachInteractions(node: ComfyNode, ctx: CompInteractionContext)
ctx.markCanvasDirty();
});
if (st.compAspectRatioSelect && !st.compAspectRatioSelect.dataset.compAspectHooked) {
st.compAspectRatioSelect.dataset.compAspectHooked = "1";
st.compAspectRatioSelect.addEventListener("change", () => {
const value = st.compAspectRatioSelect?.value ?? "free";
setWidgetStringValue(findWidget(node, "aspect_ratio"), value);
applyCompAspectRatio(node, value);
ctx.updateCompControls(node);
ctx.markPreviewInteraction(node);
ctx.markCanvasDirty();
ctx.schedule(node, () => {
ctx.startLoopIfVideo(node);
ctx.refreshDependents(node);
}, 0);
});
}
st.compModeSelect?.addEventListener("change", () => {
ctx.updateSelectedCompLayer(node, (layer: any) => {
+1 -24
View File
@@ -28,24 +28,7 @@ export function attachInteractions(node: ComfyNode, ctx: NodeInteractionContext)
ctx.refreshNode(node);
};
const ratioSelect = root.querySelector<HTMLSelectElement>('select[data-constant-ratio="1"]');
const applyRatioPreset = (changedField: "width" | "height"): void => {
const preset = String(ratioSelect?.value ?? "custom");
const ratio = CONSTANT_RATIO_PRESETS[preset];
if (!ratio) return;
const widthWidget = findWidget(node, "width");
const heightWidget = findWidget(node, "height");
const currentWidth = clampInt(Number(widthWidget?.value ?? 1024), 1024, 1, 8192);
const currentHeight = clampInt(Number(heightWidget?.value ?? 1024), 1024, 1, 8192);
if (changedField === "width") {
setWidgetValue(heightWidget, clampInt(currentWidth / ratio, currentHeight, 1, 8192));
} else {
setWidgetValue(widthWidget, clampInt(currentHeight * ratio, currentWidth, 1, 8192));
}
};
for (const button of Array.from(root.querySelectorAll<HTMLButtonElement>('button[data-constant-mode]'))) {
button.addEventListener("click", (event) => {
@@ -78,13 +61,7 @@ export function attachInteractions(node: ComfyNode, ctx: NodeInteractionContext)
});
}
ratioSelect?.addEventListener("change", () => {
const preset = String(ratioSelect.value ?? "custom");
if (preset !== "custom") {
applyRatioPreset("width");
}
refresh();
});
syncConstantWidgets(node);
}
+67 -57
View File
@@ -36,21 +36,64 @@ export function attachInteractions(node: ComfyNode, ctx: DrawInteractionContext)
return canvasToDrawSourcePoint(st.drawGeometry, w.x, w.y);
};
// State for Ctrl+left-drag pan.
let ctrlPanDrag: {
pointerId: number;
startCanvasX: number;
startCanvasY: number;
startPanX: number;
startPanY: number;
} | null = null;
// RAF handle to coalesce pan renders.
// RAF handle to coalesce hover renders.
let hoverRafPending = false;
let panRafPending = false;
// Coalesces hover-only renders to one per animation frame so repeated
// pointermove events don't queue unlimited async render calls.
let hoverRafPending = false;
const startPreviewPan = (event: PointerEvent): boolean => {
const rect = canvas.getBoundingClientRect();
if (rect.width < 1 || rect.height < 1) return false;
const sx = canvas.width / rect.width;
const sy = canvas.height / rect.height;
st.previewPanDrag = {
pointerId: event.pointerId,
startCanvasX: (event.clientX - rect.left) * sx,
startCanvasY: (event.clientY - rect.top) * sy,
startPanX: (st.previewPanX as number) ?? 0,
startPanY: (st.previewPanY as number) ?? 0,
};
try {
canvas.setPointerCapture?.(event.pointerId);
} catch {
// Some environments may reject pointer capture for synthetic pointers.
}
canvas.style.cursor = "grabbing";
return true;
};
const updatePreviewPan = (event: PointerEvent): boolean => {
const drag = st.previewPanDrag;
if (!drag || drag.pointerId !== event.pointerId) return false;
const rect = canvas.getBoundingClientRect();
if (rect.width < 1 || rect.height < 1) return true;
const sx = canvas.width / rect.width;
const sy = canvas.height / rect.height;
const cx = (event.clientX - rect.left) * sx;
const cy = (event.clientY - rect.top) * sy;
st.previewPanX = drag.startPanX + (cx - drag.startCanvasX);
st.previewPanY = drag.startPanY + (cy - drag.startCanvasY);
ctx.markPreviewInteraction(node);
if (!panRafPending) {
panRafPending = true;
requestAnimationFrame(() => {
panRafPending = false;
void ctx.renderDrawNode(node, 0);
});
}
return true;
};
const releasePreviewPan = (event: PointerEvent): boolean => {
if (!st.previewPanDrag || st.previewPanDrag.pointerId !== event.pointerId) return false;
st.previewPanDrag = null;
canvas.style.cursor = event.ctrlKey || event.metaKey ? "grab" : "";
try {
canvas.releasePointerCapture?.(event.pointerId);
} catch {
// Ignore missing capture state.
}
return true;
};
if (!st.drawGeometry || !st.drawCanvas) {
void ctx.renderDrawNode(node, 0);
@@ -253,23 +296,11 @@ export function attachInteractions(node: ComfyNode, ctx: DrawInteractionContext)
});
canvas.addEventListener("pointerdown", async (event: PointerEvent) => {
// ── Ctrl+left-drag: pan the viewport ────────────────────────────────────
if ((event.ctrlKey || event.metaKey) && event.button === 0) {
event.preventDefault();
event.stopPropagation();
canvas.focus();
try { canvas.setPointerCapture?.(event.pointerId); } catch {}
const rect = canvas.getBoundingClientRect();
if (rect.width < 1 || rect.height < 1) return;
const sx = canvas.width / rect.width;
const sy = canvas.height / rect.height;
ctrlPanDrag = {
pointerId: event.pointerId,
startCanvasX: (event.clientX - rect.left) * sx,
startCanvasY: (event.clientY - rect.top) * sy,
startPanX: (st.previewPanX as number) ?? 0,
startPanY: (st.previewPanY as number) ?? 0,
};
canvas.style.cursor = "grabbing";
startPreviewPan(event);
return;
}
@@ -313,23 +344,9 @@ export function attachInteractions(node: ComfyNode, ctx: DrawInteractionContext)
});
canvas.addEventListener("pointermove", (event: PointerEvent) => {
// ── Ctrl+drag pan in progress ────────────────────────────────────────────
if (ctrlPanDrag && ctrlPanDrag.pointerId === event.pointerId) {
const rect = canvas.getBoundingClientRect();
if (rect.width < 1 || rect.height < 1) return;
const sx = canvas.width / rect.width;
const sy = canvas.height / rect.height;
const cx = (event.clientX - rect.left) * sx;
const cy = (event.clientY - rect.top) * sy;
st.previewPanX = ctrlPanDrag.startPanX + (cx - ctrlPanDrag.startCanvasX);
st.previewPanY = ctrlPanDrag.startPanY + (cy - ctrlPanDrag.startCanvasY);
if (!panRafPending) {
panRafPending = true;
requestAnimationFrame(() => {
panRafPending = false;
void ctx.renderDrawNode(node, 0);
});
}
if (updatePreviewPan(event)) {
event.preventDefault();
event.stopPropagation();
return;
}
@@ -372,13 +389,6 @@ export function attachInteractions(node: ComfyNode, ctx: DrawInteractionContext)
});
const releaseStroke = (event: PointerEvent) => {
// ── Release Ctrl+pan drag ────────────────────────────────────────────────
if (ctrlPanDrag && ctrlPanDrag.pointerId === event.pointerId) {
ctrlPanDrag = null;
canvas.style.cursor = "";
try { canvas.releasePointerCapture?.(event.pointerId); } catch {}
return;
}
if (!st.drawStroke || st.drawStroke.pointerId !== event.pointerId) return;
st.drawStroke = null;
try {
@@ -390,16 +400,16 @@ export function attachInteractions(node: ComfyNode, ctx: DrawInteractionContext)
ctx.refreshNode(node);
};
canvas.addEventListener("pointerup", releaseStroke);
canvas.addEventListener("pointerup", (event: PointerEvent) => {
if (releasePreviewPan(event)) return;
releaseStroke(event);
});
canvas.addEventListener("pointercancel", (event: PointerEvent) => {
if (ctrlPanDrag && ctrlPanDrag.pointerId === event.pointerId) {
ctrlPanDrag = null;
canvas.style.cursor = "";
}
if (releasePreviewPan(event)) return;
releaseStroke(event);
});
canvas.addEventListener("pointerleave", () => {
if (!st.drawStroke && !ctrlPanDrag) {
if (!st.drawStroke && !st.previewPanDrag) {
st.drawHover = null;
canvas.style.cursor = "";
void ctx.renderDrawNode(node, 0);
+56 -21
View File
@@ -1,8 +1,9 @@
import type { ComfyNode, CompDragMode, CompLayerPreviewGeometry, CornerPinHandle } from "../../types.js";
import { COMP_BLEND_MODES, getCompSlots, serializeCompLayers, syncCompLayers, clampCompCenter } from "../comp.js";
import { type FitPlacement, getFitPlacement } from "../shared/geometry.js";
import { findWidget, hideWidgetForGood, setWidgetStringValue, widgetBoolean, widgetString } from "../shared/widgets.js";
import { findWidget, hideWidgetForGood, setWidgetStringValue, widgetBoolean, widgetString, widgetNumber, setWidgetValue } from "../shared/widgets.js";
import { ensureState } from "../shared/state.js";
import { markCanvasDirty } from "../shared/canvas.js";
import {
createContextMenuSelect,
setControlDisabled,
@@ -27,7 +28,7 @@ export type CompControlsUi = {
resetButton: HTMLButtonElement;
resizeButton: HTMLButtonElement;
cornerPinButton: HTMLButtonElement;
aspectRatioSelect: HTMLSelectElement;
aspectRatioSelect: HTMLSelectElement | null;
modeSelect: HTMLSelectElement;
opacityInput: HTMLInputElement;
opacityLabel: HTMLDivElement;
@@ -38,7 +39,7 @@ export function createCompControlsUi(): CompControlsUi {
const controls = document.createElement("div");
controls.style.marginTop = "8px";
controls.style.display = "grid";
controls.style.gridTemplateColumns = "auto auto auto auto auto minmax(0, 1fr) auto";
controls.style.gridTemplateColumns = "auto auto auto auto auto minmax(0, 1fr)";
controls.style.gap = "6px";
controls.style.alignItems = "center";
controls.style.minWidth = "0";
@@ -72,18 +73,6 @@ export function createCompControlsUi(): CompControlsUi {
cornerPinButton.textContent = "Pin";
styleSoftButton(cornerPinButton, false);
const aspectRatioSelect = document.createElement("select");
aspectRatioSelect.title = "Custom Format aspect ratio";
aspectRatioSelect.style.width = "100%";
aspectRatioSelect.style.minWidth = "0";
styleSoftField(aspectRatioSelect);
for (const [value, label] of [["free", "Free"], ["1/1", "1/1"], ["4/3", "4/3"], ["16/9", "16/9"], ["9/16", "9/16"]] as const) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
aspectRatioSelect.appendChild(option);
}
const layerLabel = document.createElement("div");
layerLabel.style.fontSize = "11px";
layerLabel.style.opacity = "0.85";
@@ -129,7 +118,6 @@ export function createCompControlsUi(): CompControlsUi {
controls.appendChild(resetButton);
controls.appendChild(resizeButton);
controls.appendChild(cornerPinButton);
controls.appendChild(createContextMenuSelect(aspectRatioSelect));
controls.appendChild(layerLabel);
bottomRow.appendChild(createContextMenuSelect(modeSelect));
bottomRow.appendChild(opacityLabel);
@@ -143,7 +131,7 @@ export function createCompControlsUi(): CompControlsUi {
resetButton,
resizeButton,
cornerPinButton,
aspectRatioSelect,
aspectRatioSelect: null,
modeSelect,
opacityInput,
opacityLabel,
@@ -153,7 +141,54 @@ export function createCompControlsUi(): CompControlsUi {
export function hideCompWidgets(node: ComfyNode): void {
hideWidgetForGood(node, findWidget(node, "layers_json"));
hideWidgetForGood(node, findWidget(node, "aspect_ratio"));
}
function aspectRatioValue(value: string): number | null {
switch (String(value || "custom").trim().toLowerCase()) {
case "1/1":
case "1:1": return 1;
case "3/4":
case "3:4": return 3 / 4;
case "4/3":
case "4:3": return 4 / 3;
case "16/9":
case "16:9": return 16 / 9;
case "9/16":
case "9:16": return 9 / 16;
default: return null;
}
}
export function syncCompWidgets(node: ComfyNode, changedName?: string, notify: boolean = true): void {
if (!isNode(node)) return;
if (widgetBoolean(node, "use_first_layer_size", true) || widgetBoolean(node, "auto_layering", false)) {
return;
}
const widthWidget = findWidget(node, "width");
const heightWidget = findWidget(node, "height");
if (!widthWidget || !heightWidget) return;
const preset = widgetString(node, "aspect_ratio", "custom");
if (preset === "custom") {
return;
}
const ratio = aspectRatioValue(preset);
if (!ratio) return;
let width = Math.max(1, Math.round(widgetNumber(node, "width", 1024)));
let height = Math.max(1, Math.round(widgetNumber(node, "height", 1024)));
if (changedName === "height") {
width = Math.max(1, Math.round(height * ratio));
setWidgetValue(widthWidget, width, { notify });
} else {
height = Math.max(1, Math.round(width / ratio));
setWidgetValue(heightWidget, height, { notify });
}
markCanvasDirty();
}
export function ensureCompInputs(node: ComfyNode, minLayers: number = 1): void {
@@ -227,8 +262,8 @@ export function getCompInfoText(_node: ComfyNode, connectedLayers: number, total
}
function normalizeCompAspectRatioValue(value: string): string {
const normalized = String(value || "free").trim().toLowerCase().replace(":", "/");
return ["1/1", "4/3", "16/9", "9/16"].includes(normalized) ? normalized : "free";
const normalized = String(value || "custom").trim().toLowerCase().replace(":", "/");
return ["1/1", "3/4", "4/3", "16/9", "9/16"].includes(normalized) ? normalized : "custom";
}
export function getCompCursor(mode: CompDragMode | "move" | null): string {
@@ -311,7 +346,7 @@ export function updateCompControls(node: ComfyNode): void {
const selected = layers.find((layer) => layer.slot === st.compSelectedSlot) ?? layers[layers.length - 1] ?? null;
const customFormat = !widgetBoolean(node, "use_first_layer_size", true) && !widgetBoolean(node, "auto_layering", false);
if (st.compAspectRatioSelect) {
st.compAspectRatioSelect.value = normalizeCompAspectRatioValue(widgetString(node, "aspect_ratio", "free"));
st.compAspectRatioSelect.value = normalizeCompAspectRatioValue(widgetString(node, "aspect_ratio", "custom"));
setControlDisabled(st.compAspectRatioSelect, !customFormat);
st.compAspectRatioSelect.title = customFormat
? "Custom Format aspect ratio"
+41 -25
View File
@@ -1,7 +1,7 @@
import type { ComfyNode } from "../../types.js";
import { ensureState } from "../shared/state.js";
import { resolveImageOpsClassName } from "../shared/classes.js";
import { findWidget, hideWidgetForGood, hideWidgetsByName, setWidgetStringValuesByName, widgetNumber, widgetString } from "../shared/widgets.js";
import { findWidget, hideWidgetForGood, hideWidgetsByName, setWidgetStringValuesByName, widgetNumber, widgetString, setWidgetValue } from "../shared/widgets.js";
import {
createColorSwatch,
createContextMenuSelect,
@@ -74,23 +74,7 @@ export function createConstantControlsUi(): ConstantControlsUi {
}
controls.appendChild(modeRow);
const sizeRow = makeRow();
sizeRow.style.gridTemplateColumns = "auto minmax(70px,96px)";
sizeRow.appendChild(makeLabel("Ratio"));
const ratioSelect = document.createElement("select");
ratioSelect.dataset.constantRatio = "1";
ratioSelect.title = "Standard ratio or Free";
ratioSelect.style.width = "100%";
ratioSelect.style.minWidth = "0";
styleSoftField(ratioSelect);
for (const [value, label] of [["custom", "Free"], ["1:1", "1:1"], ["16:9", "16:9"], ["9:16", "9:16"], ["4:3", "4:3"], ["3:4", "3:4"]] as const) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
ratioSelect.appendChild(option);
}
sizeRow.appendChild(createContextMenuSelect(ratioSelect));
controls.appendChild(sizeRow);
const alphaRow = makeRow();
alphaRow.style.gridTemplateColumns = "auto minmax(0,1fr) auto";
@@ -153,7 +137,23 @@ export function getConstantInfoText(node: ComfyNode): string {
return `${mode} preview (${width}x${height}, frames ${frameCount})`;
}
export function syncConstantWidgets(node: ComfyNode): void {
function aspectRatioValue(value: string): number | null {
switch (String(value || "custom").trim().toLowerCase()) {
case "1/1":
case "1:1": return 1;
case "3/4":
case "3:4": return 3 / 4;
case "4/3":
case "4:3": return 4 / 3;
case "16/9":
case "16:9": return 16 / 9;
case "9/16":
case "9:16": return 9 / 16;
default: return null;
}
}
export function syncConstantWidgets(node: ComfyNode, changedName?: string, notify: boolean = true): void {
if (!isNode(node)) return;
hideConstantWidgets(node);
@@ -167,13 +167,29 @@ export function syncConstantWidgets(node: ComfyNode): void {
const root = st.previewRoot;
if (!root) return;
const mode = widgetString(node, "mode", "constant").toLowerCase() === "checkerboard" ? "checkerboard" : "constant";
const width = Math.max(1, Math.round(widgetNumber(node, "width", 1024)));
const height = Math.max(1, Math.round(widgetNumber(node, "height", 1024)));
const alpha = Math.max(0, Math.min(1, widgetNumber(node, "alpha", 1)));
// Bidirectional aspect ratio constraints
const widthWidget = findWidget(node, "width");
const heightWidget = findWidget(node, "height");
if (widthWidget && heightWidget) {
const preset = widgetString(node, "aspect_ratio", "custom");
if (preset !== "custom") {
const ratio = aspectRatioValue(preset);
if (ratio) {
let width = Math.max(1, Math.round(widgetNumber(node, "width", 1024)));
let height = Math.max(1, Math.round(widgetNumber(node, "height", 1024)));
if (changedName === "height") {
width = Math.max(1, Math.round(height * ratio));
setWidgetValue(widthWidget, width, { notify });
} else {
height = Math.max(1, Math.round(width / ratio));
setWidgetValue(heightWidget, height, { notify });
}
}
}
}
const ratioSelect = root.querySelector<HTMLSelectElement>('select[data-constant-ratio="1"]');
if (ratioSelect) ratioSelect.value = resolveConstantRatioPreset(width, height);
const mode = widgetString(node, "mode", "constant").toLowerCase() === "checkerboard" ? "checkerboard" : "constant";
const alpha = Math.max(0, Math.min(1, widgetNumber(node, "alpha", 1)));
const alphaInput = root.querySelector<HTMLInputElement>('input[data-constant-field="alpha"]');
if (alphaInput) alphaInput.value = String(Math.round(alpha * 100));
+212 -211
View File
@@ -22,265 +22,251 @@ export function isNode(node: ComfyNode): boolean {
return String(node?.comfyClass ?? "") === NODE_CLASS;
}
function styleSegmentControl(container: HTMLElement): void {
container.style.display = "flex";
container.style.background = "#181818";
container.style.borderRadius = "6px";
container.style.padding = "2px";
container.style.border = "1px solid #2e2e2e";
container.style.width = "100%";
container.style.boxSizing = "border-box";
}
export function styleSegmentButton(button: HTMLButtonElement, active: boolean): void {
button.style.flex = "1";
button.style.border = "none";
button.style.outline = "none";
button.style.borderRadius = "4px";
button.style.padding = "4px 8px";
button.style.fontSize = "10.5px";
button.style.fontFamily = "var(--comfy-font-sans, Inter, sans-serif)";
button.style.cursor = "pointer";
button.style.transition = "background 0.15s, color 0.15s";
button.style.lineHeight = "1.2";
button.style.textAlign = "center";
if (active) {
button.classList.add("active");
button.style.background = "#525252";
button.style.color = "#ffffff";
button.style.fontWeight = "600";
} else {
button.classList.remove("active");
button.style.background = "transparent";
button.style.color = "#aaaaaa";
button.style.fontWeight = "normal";
}
}
function setupSegmentHover(button: HTMLButtonElement): void {
button.addEventListener("mouseenter", () => {
if (!button.classList.contains("active")) {
button.style.background = "rgba(255,255,255,0.06)";
button.style.color = "#ffffff";
}
});
button.addEventListener("mouseleave", () => {
if (!button.classList.contains("active")) {
button.style.background = "transparent";
button.style.color = "#aaaaaa";
}
});
}
export type DrawControlsUi = {
controls: HTMLDivElement;
brushButton: HTMLButtonElement;
eraserButton: HTMLButtonElement;
brushButton: HTMLButtonElement | null;
eraserButton: HTMLButtonElement | null;
clearButton: HTMLButtonElement;
colorInput: HTMLInputElement;
edgeSelect: HTMLSelectElement;
softnessInput: HTMLInputElement;
softnessLabel: HTMLDivElement;
opacityInput: HTMLInputElement;
opacityLabel: HTMLDivElement;
colorInput: HTMLInputElement | null;
edgeSelect: HTMLSelectElement | null;
softnessInput: HTMLInputElement | null;
softnessLabel: HTMLDivElement | null;
opacityInput: HTMLInputElement | null;
opacityLabel: HTMLDivElement | null;
sizeInput: HTMLInputElement | null;
sizeLabel: HTMLDivElement | null;
widthInput: HTMLInputElement | null;
heightInput: HTMLInputElement | null;
linkButton: HTMLButtonElement;
bgColorInput: HTMLInputElement;
overlayFormatSelect: HTMLSelectElement;
pressureSizeInput: HTMLInputElement;
pressureOpacityInput: HTMLInputElement;
tiltSizeInput: HTMLInputElement;
linkButton: HTMLButtonElement | null;
bgColorInput: HTMLInputElement | null;
overlayFormatSelect: HTMLSelectElement | null;
pressureSizeInput: HTMLInputElement | null;
pressureOpacityInput: HTMLInputElement | null;
tiltSizeInput: HTMLInputElement | null;
};
export function createDrawControlsUi(): DrawControlsUi {
const controls = document.createElement("div");
controls.style.marginTop = "8px";
controls.style.display = "grid";
controls.style.gap = "8px";
controls.style.gridTemplateColumns = "72px 1fr";
controls.style.rowGap = "8px";
controls.style.columnGap = "12px";
controls.style.alignItems = "center";
controls.style.width = "100%";
controls.style.boxSizing = "border-box";
controls.style.padding = "0 4px";
const topRow = document.createElement("div");
topRow.style.display = "flex";
topRow.style.alignItems = "center";
topRow.style.justifyContent = "space-between";
topRow.style.gap = "8px";
const styleLabel = (el: HTMLElement) => {
el.style.fontSize = "11px";
el.style.opacity = "0.78";
el.style.fontFamily = "var(--comfy-font-sans, Inter, sans-serif)";
el.style.whiteSpace = "nowrap";
};
const toolRow = document.createElement("div");
toolRow.style.display = "flex";
toolRow.style.alignItems = "center";
toolRow.style.gap = "6px";
// 1. Tool (Brush/Eraser buttons)
const toolLabel = document.createElement("div");
toolLabel.textContent = "Tool";
styleLabel(toolLabel);
const brushButton = document.createElement("button");
brushButton.type = "button";
brushButton.textContent = "Brush";
styleSoftButton(brushButton, true);
styleSegmentButton(brushButton, true);
setupSegmentHover(brushButton);
const eraserButton = document.createElement("button");
eraserButton.type = "button";
eraserButton.textContent = "Eraser";
styleSoftButton(eraserButton, false);
styleSegmentButton(eraserButton, false);
setupSegmentHover(eraserButton);
const clearButton = document.createElement("button");
clearButton.type = "button";
clearButton.textContent = "Clear";
styleInlineAction(clearButton);
const toolGroup = document.createElement("div");
styleSegmentControl(toolGroup);
toolGroup.appendChild(brushButton);
toolGroup.appendChild(eraserButton);
const overlayFormatSelect = document.createElement("select");
overlayFormatSelect.title = "Overlay format";
overlayFormatSelect.style.width = "72px";
styleSoftField(overlayFormatSelect);
for (const format of ["png", "webp"]) {
const option = document.createElement("option");
option.value = format;
option.textContent = format.toUpperCase();
overlayFormatSelect.appendChild(option);
}
controls.appendChild(toolLabel);
controls.appendChild(toolGroup);
const rightTools = document.createElement("div");
rightTools.style.display = "flex";
rightTools.style.alignItems = "center";
rightTools.style.gap = "6px";
rightTools.appendChild(createContextMenuSelect(overlayFormatSelect));
rightTools.appendChild(clearButton);
toolRow.appendChild(brushButton);
toolRow.appendChild(eraserButton);
topRow.appendChild(toolRow);
topRow.appendChild(rightTools);
const strokeRow = document.createElement("div");
strokeRow.style.display = "grid";
strokeRow.style.gridTemplateColumns = "auto minmax(0,1fr) auto auto minmax(0,1fr)";
strokeRow.style.alignItems = "center";
strokeRow.style.gap = "6px";
const colorLabel = document.createElement("div");
colorLabel.textContent = "Color";
colorLabel.style.fontSize = "11px";
colorLabel.style.opacity = "0.78";
const colorSwatchResult = createColorSwatch("#FFFFFF");
const colorInput = colorSwatchResult.input;
// 2. Edge (Hard/Soft buttons backed by a hidden select)
const edgeLabel = document.createElement("div");
edgeLabel.textContent = "Edge";
styleLabel(edgeLabel);
const edgeSelect = document.createElement("select");
styleSoftField(edgeSelect);
edgeSelect.title = "Brush edge";
edgeSelect.style.display = "none";
for (const edge of ["hard", "soft"]) {
const option = document.createElement("option");
option.value = edge;
option.textContent = edge === "hard" ? "Hard edge" : "Soft edge";
option.textContent = edge === "hard" ? "Hard" : "Soft";
edgeSelect.appendChild(option);
}
const opacityLabel = document.createElement("div");
opacityLabel.textContent = "100%";
opacityLabel.style.fontSize = "11px";
opacityLabel.style.opacity = "0.82";
opacityLabel.style.justifySelf = "end";
const hardButton = document.createElement("button");
hardButton.type = "button";
hardButton.textContent = "Hard";
styleSegmentButton(hardButton, true);
setupSegmentHover(hardButton);
const opacityInput = document.createElement("input");
opacityInput.type = "range";
opacityInput.min = "0";
opacityInput.max = "100";
opacityInput.step = "1";
opacityInput.value = "100";
opacityInput.title = "Brush opacity";
styleSoftRange(opacityInput);
const softButton = document.createElement("button");
softButton.type = "button";
softButton.textContent = "Soft";
styleSegmentButton(softButton, false);
setupSegmentHover(softButton);
strokeRow.appendChild(colorLabel);
strokeRow.appendChild(colorSwatchResult.host);
strokeRow.appendChild(createContextMenuSelect(edgeSelect));
strokeRow.appendChild(opacityLabel);
strokeRow.appendChild(opacityInput);
(edgeSelect as any).__hardButton = hardButton;
(edgeSelect as any).__softButton = softButton;
const softnessRow = document.createElement("div");
softnessRow.style.display = "grid";
softnessRow.style.gridTemplateColumns = "auto minmax(0,1fr) auto";
softnessRow.style.alignItems = "center";
softnessRow.style.gap = "6px";
const softnessTextLabel = document.createElement("div");
softnessTextLabel.textContent = "Softness";
softnessTextLabel.style.fontSize = "11px";
softnessTextLabel.style.opacity = "0.78";
const softnessInput = document.createElement("input");
softnessInput.type = "range";
softnessInput.min = "0";
softnessInput.max = "100";
softnessInput.step = "1";
softnessInput.value = "50";
softnessInput.title = "Soft brush feather (only when edge=Soft)";
styleSoftRange(softnessInput);
const softnessLabel = document.createElement("div");
softnessLabel.textContent = "50%";
softnessLabel.style.fontSize = "11px";
softnessLabel.style.opacity = "0.82";
softnessLabel.style.justifySelf = "end";
softnessRow.appendChild(softnessTextLabel);
softnessRow.appendChild(softnessInput);
softnessRow.appendChild(softnessLabel);
const sizeRow = document.createElement("div");
sizeRow.style.display = "grid";
sizeRow.style.gridTemplateColumns = "auto auto";
sizeRow.style.alignItems = "center";
sizeRow.style.gap = "6px";
sizeRow.style.minWidth = "0";
const linkButton = document.createElement("button");
linkButton.type = "button";
linkButton.textContent = "Linked";
styleSoftButton(linkButton, true);
const bgColorSwatchResult = createColorSwatch("#000000", { compact: true, title: "Background color" });
const bgColorInput = bgColorSwatchResult.input;
sizeRow.appendChild(linkButton);
sizeRow.appendChild(bgColorSwatchResult.host);
const dynamicsRow = document.createElement("div");
dynamicsRow.style.display = "flex";
dynamicsRow.style.alignItems = "center";
dynamicsRow.style.gap = "10px";
dynamicsRow.style.flexWrap = "wrap";
dynamicsRow.style.fontSize = "11px";
dynamicsRow.style.opacity = "0.86";
const dynamicsLabel = document.createElement("span");
dynamicsLabel.textContent = "Dynamics";
dynamicsLabel.style.opacity = "0.78";
const makeDynamicsToggle = (text: string, title: string): [HTMLLabelElement, HTMLInputElement] => {
const label = document.createElement("label");
label.style.display = "inline-flex";
label.style.alignItems = "center";
label.style.gap = "4px";
label.title = title;
const input = document.createElement("input");
input.type = "checkbox";
input.style.margin = "0";
label.appendChild(input);
label.appendChild(document.createTextNode(text));
return [label, input];
const updateEdgeUi = (edge: "hard" | "soft") => {
styleSegmentButton(hardButton, edge === "hard");
styleSegmentButton(softButton, edge === "soft");
};
const [pressureSizeLabel, pressureSizeInput] = makeDynamicsToggle("Size pressure", "Pen pressure changes brush diameter");
const [pressureOpacityLabel, pressureOpacityInput] = makeDynamicsToggle("Opacity pressure", "Pen pressure changes brush opacity");
const [tiltSizeLabel, tiltSizeInput] = makeDynamicsToggle("Tilt size", "Pen tilt widens the brush footprint");
dynamicsRow.appendChild(dynamicsLabel);
dynamicsRow.appendChild(pressureSizeLabel);
dynamicsRow.appendChild(pressureOpacityLabel);
dynamicsRow.appendChild(tiltSizeLabel);
hardButton.addEventListener("click", (e) => {
e.preventDefault();
edgeSelect.value = "hard";
edgeSelect.dispatchEvent(new Event("change"));
updateEdgeUi("hard");
});
controls.appendChild(topRow);
controls.appendChild(strokeRow);
controls.appendChild(softnessRow);
controls.appendChild(sizeRow);
controls.appendChild(dynamicsRow);
softButton.addEventListener("click", (e) => {
e.preventDefault();
edgeSelect.value = "soft";
edgeSelect.dispatchEvent(new Event("change"));
updateEdgeUi("soft");
});
const edgeGroup = document.createElement("div");
styleSegmentControl(edgeGroup);
edgeGroup.appendChild(hardButton);
edgeGroup.appendChild(softButton);
edgeGroup.appendChild(edgeSelect);
controls.appendChild(edgeLabel);
controls.appendChild(edgeGroup);
// 3. Clear Button (Created and returned, but NOT appended to controls)
const clearButton = document.createElement("button");
clearButton.type = "button";
clearButton.textContent = "\u21BA Clear"; // ↺ Clear
clearButton.style.border = "1px solid #3f3f3f";
clearButton.style.background = "#2a2a2a";
clearButton.style.color = "#ccc";
clearButton.style.borderRadius = "4px";
clearButton.style.padding = "4px 16px";
clearButton.style.cursor = "pointer";
clearButton.style.fontSize = "11px";
clearButton.style.lineHeight = "1.2";
clearButton.style.transition = "background 0.15s ease, color 0.15s ease, border-color 0.15s ease";
clearButton.addEventListener("mouseenter", () => {
clearButton.style.background = "#3e3e3e";
clearButton.style.color = "#fff";
clearButton.style.borderColor = "#555";
});
clearButton.addEventListener("mouseleave", () => {
clearButton.style.background = "#2a2a2a";
clearButton.style.color = "#ccc";
clearButton.style.borderColor = "#3f3f3f";
});
clearButton.addEventListener("mousedown", () => {
clearButton.style.background = "#1c1c1c";
clearButton.style.color = "#aaa";
});
clearButton.addEventListener("mouseup", () => {
clearButton.style.background = "#3e3e3e";
clearButton.style.color = "#fff";
});
return {
controls,
brushButton,
eraserButton,
clearButton,
colorInput,
colorInput: null,
edgeSelect,
softnessInput,
softnessLabel,
opacityInput,
opacityLabel,
softnessInput: null,
softnessLabel: null,
opacityInput: null,
opacityLabel: null,
sizeInput: null,
sizeLabel: null,
widthInput: null,
heightInput: null,
linkButton,
bgColorInput,
overlayFormatSelect,
pressureSizeInput,
pressureOpacityInput,
tiltSizeInput,
linkButton: null,
bgColorInput: null,
overlayFormatSelect: null,
pressureSizeInput: null,
pressureOpacityInput: null,
tiltSizeInput: null,
};
}
export function hideDrawWidgets(node: ComfyNode): void {
// Use hideWidgetsByName so duplicate widgets (e.g. ComfyUI auto-adds an
// uppercase COLOR / BOOLEAN copy alongside the original) are all hidden.
const names = [
"bypass",
"invert_mask",
"sync_dimensions",
"width",
"height",
"bg_color",
"tool",
"brush_color",
"brush_size",
"brush_edge",
"brush_softness",
"brush_opacity",
"brush_pressure_size",
"brush_pressure_opacity",
"brush_tilt_size",
"overlay_format",
// 1. Hide internal base64 payload widgets and custom HTML-driven selectors (keep them in array for serialization)
const namesToHide = [
"overlay_data",
"overlay_layers",
"tool",
"brush_edge",
];
for (const name of names) hideWidgetsByName(node, name);
for (const name of namesToHide) {
hideWidgetsByName(node, name);
}
}
export function canvasToDrawSourcePoint(geometry: DrawPreviewGeometry | null, x: number, y: number): { x: number; y: number; inside: boolean } {
@@ -307,8 +293,8 @@ export function getDrawInfoText(node: ComfyNode, width: number, height: number):
export function updateDrawToolButtons(node: ComfyNode): void {
const st = ensureState(node);
const tool = normalizeDrawTool(widgetString(node, "tool", "brush"));
if (st.drawBrushButton) styleSoftButton(st.drawBrushButton, tool === "brush");
if (st.drawEraserButton) styleSoftButton(st.drawEraserButton, tool === "eraser");
if (st.drawBrushButton) styleSegmentButton(st.drawBrushButton, tool === "brush");
if (st.drawEraserButton) styleSegmentButton(st.drawEraserButton, tool === "eraser");
}
export function updateDrawOverlayWidget(node: ComfyNode): void {
@@ -338,6 +324,7 @@ export function syncDrawWidgets(node: ComfyNode, changedName?: string): void {
const pressureOpacityWidget = findWidget(node, "brush_pressure_opacity");
const tiltSizeWidget = findWidget(node, "brush_tilt_size");
const overlayFormatWidget = findWidget(node, "overlay_format");
const softnessWidget = findWidget(node, "brush_softness");
if (!widthWidget || !heightWidget) return;
let width = clampDrawDimension(widgetNumber(node, "width", 1024));
@@ -388,15 +375,18 @@ export function syncDrawWidgets(node: ComfyNode, changedName?: string): void {
if (st.drawEdgeSelect) {
const edgeMode = normalizeDrawEdge(widgetString(node, "brush_edge", "hard"));
st.drawEdgeSelect.value = edgeMode;
const hb = (st.drawEdgeSelect as any).__hardButton;
const sb = (st.drawEdgeSelect as any).__softButton;
if (hb) styleSegmentButton(hb, edgeMode === "hard");
if (sb) styleSegmentButton(sb, edgeMode === "soft");
if (st.drawSoftnessInput) {
const softness = Math.round(clampDrawSoftness(widgetNumber(node, "brush_softness", 0.5), 0.5) * 100);
st.drawSoftnessInput.value = String(softness);
st.drawSoftnessInput.disabled = edgeMode === "hard";
st.drawSoftnessInput.style.opacity = edgeMode === "hard" ? "0.45" : "1";
st.drawSoftnessInput.title = `Soft brush feather ${softness}%`;
// Invert softness to display as Hardness: Hardness = 100 - Softness
const softnessPercent = Math.round(clampDrawSoftness(widgetNumber(node, "brush_softness", 0.5), 0.5) * 100);
const hardness = edgeMode === "hard" ? 100 : (100 - softnessPercent);
st.drawSoftnessInput.value = String(hardness);
st.drawSoftnessInput.title = `Brush edge hardness ${hardness}%`;
if (st.drawSoftnessLabel) {
st.drawSoftnessLabel.textContent = `${softness}%`;
st.drawSoftnessLabel.style.opacity = edgeMode === "hard" ? "0.45" : "0.82";
st.drawSoftnessLabel.textContent = `${hardness}%`;
}
}
}
@@ -427,9 +417,20 @@ export function syncDrawWidgets(node: ComfyNode, changedName?: string): void {
setWidgetBooleanValue(linkWidget, linked);
if (st.drawBgColorInput && !inputConnected) setWidgetStringValue(bgWidget, st.drawBgColorInput.value);
if (st.drawColorInput) setWidgetStringValue(colorWidget, st.drawColorInput.value);
if (st.drawEdgeSelect) setWidgetStringValue(edgeWidget, st.drawEdgeSelect.value);
if (st.drawSoftnessInput) {
const hardness = Number(st.drawSoftnessInput.value);
if (hardness === 100) {
setWidgetStringValue(edgeWidget, "hard");
setWidgetValue(softnessWidget, 0);
} else {
setWidgetStringValue(edgeWidget, "soft");
setWidgetValue(softnessWidget, (100 - hardness) / 100);
}
}
if (st.drawOpacityInput) setWidgetValue(opacityWidget, Number(st.drawOpacityInput.value) / 100);
if (st.drawSizeInput) setWidgetValue(sizeWidget, Number(st.drawSizeInput.value));
if (st.drawWidthInput && !inputConnected) setWidgetValue(widthWidget, Number(st.drawWidthInput.value));
if (st.drawHeightInput && !inputConnected) setWidgetValue(heightWidget, Number(st.drawHeightInput.value));
if (st.drawPressureSizeInput) setWidgetBooleanValue(pressureSizeWidget, st.drawPressureSizeInput.checked);
if (st.drawPressureOpacityInput) setWidgetBooleanValue(pressureOpacityWidget, st.drawPressureOpacityInput.checked);
if (st.drawTiltSizeInput) setWidgetBooleanValue(tiltSizeWidget, st.drawTiltSizeInput.checked);
+27 -155
View File
@@ -1,7 +1,7 @@
import type { ComfyNode } from "../../types.js";
import { createContextMenuSelect, styleSoftButton, styleSoftField, styleSoftRange } from "../shared/dom-styles.js";
import { styleSoftButton } from "../shared/dom-styles.js";
import { ensureState } from "../shared/state.js";
import { findWidget, hideWidgetForGood, widgetBoolean, widgetNumber, widgetString } from "../shared/widgets.js";
import { findWidget, hideWidgetForGood, setWidgetValue, setWidgetStringValue, setWidgetBooleanValue, widgetBoolean, widgetNumber, widgetString } from "../shared/widgets.js";
export const NODE_CLASS = "ImageOpsGrain";
@@ -13,106 +13,24 @@ export type GrainControlsUi = {
controls: HTMLDivElement;
};
export function createGrainControlsUi(): GrainControlsUi {
export function createGrainControlsUi(node: ComfyNode): GrainControlsUi {
const controls = document.createElement("div");
controls.style.marginTop = "8px";
controls.style.display = "grid";
controls.style.gap = "8px";
controls.style.display = "flex";
controls.style.flexDirection = "column";
controls.style.gap = "6px";
controls.style.width = "100%";
const makeRow = (): HTMLDivElement => {
const row = document.createElement("div");
row.style.display = "grid";
row.style.gap = "6px";
row.style.alignItems = "center";
row.style.minWidth = "0";
return row;
};
const label = document.createElement("div");
label.textContent = "Presets";
label.style.fontSize = "11px";
label.style.opacity = "0.78";
label.style.fontFamily = "var(--comfy-font-sans, Inter, sans-serif)";
controls.appendChild(label);
const makeLabel = (text: string): HTMLDivElement => {
const label = document.createElement("div");
label.textContent = text;
label.style.fontSize = "11px";
label.style.opacity = "0.78";
return label;
};
const topRow = makeRow();
topRow.style.gridTemplateColumns = "auto minmax(0,1fr)";
topRow.appendChild(makeLabel("Blend"));
const blendSelect = document.createElement("select");
blendSelect.dataset.grainSelect = "blend_mode";
blendSelect.title = "Grain blend mode";
blendSelect.style.width = "100%";
blendSelect.style.minWidth = "0";
styleSoftField(blendSelect);
for (const [value, label] of [["add", "Add"], ["overlay", "Overlay"], ["soft_light", "Soft Light"]] as const) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
blendSelect.appendChild(option);
}
topRow.appendChild(createContextMenuSelect(blendSelect));
controls.appendChild(topRow);
const amountRow = makeRow();
amountRow.style.gridTemplateColumns = "auto minmax(0,1fr) auto";
amountRow.appendChild(makeLabel("Amount"));
const amountInput = document.createElement("input");
amountInput.type = "range";
amountInput.dataset.grainField = "amount";
amountInput.min = "0";
amountInput.max = "100";
amountInput.step = "1";
amountInput.value = "8";
amountInput.title = "Grain amount";
styleSoftRange(amountInput);
amountRow.appendChild(amountInput);
const amountLabel = document.createElement("div");
amountLabel.dataset.grainAmountLabel = "1";
amountLabel.textContent = "8%";
amountLabel.style.fontSize = "11px";
amountLabel.style.opacity = "0.84";
amountLabel.style.textAlign = "right";
amountRow.appendChild(amountLabel);
controls.appendChild(amountRow);
const actionsRow = makeRow();
actionsRow.style.display = "flex";
actionsRow.style.flexWrap = "wrap";
actionsRow.style.gap = "6px";
const monoButton = document.createElement("button");
monoButton.type = "button";
monoButton.dataset.grainToggle = "monochrome";
monoButton.textContent = "Mono on";
styleSoftButton(monoButton, true);
actionsRow.appendChild(monoButton);
const animatedButton = document.createElement("button");
animatedButton.type = "button";
animatedButton.dataset.grainToggle = "animated";
animatedButton.textContent = "Animated on";
styleSoftButton(animatedButton, true);
actionsRow.appendChild(animatedButton);
const invertButton = document.createElement("button");
invertButton.type = "button";
invertButton.dataset.grainToggle = "invert_mask";
invertButton.textContent = "Invert mask off";
styleSoftButton(invertButton, false);
actionsRow.appendChild(invertButton);
const bypassButton = document.createElement("button");
bypassButton.type = "button";
bypassButton.dataset.grainToggle = "bypass";
bypassButton.textContent = "Bypass off";
styleSoftButton(bypassButton, false);
actionsRow.appendChild(bypassButton);
controls.appendChild(actionsRow);
// Film stock presets — set amount/blend/mono via the existing inputs.
const presetsRow = document.createElement("div");
presetsRow.style.cssText = "display:flex;gap:4px;flex-wrap:wrap";
presetsRow.style.cssText = "display:flex;gap:4px;flex-wrap:wrap;width:100%";
const presets: Array<{ label: string; amount: number; blend: string; mono: boolean }> = [
{ label: "Clean", amount: 0.02, blend: "add", mono: false },
{ label: "Subtle", amount: 0.06, blend: "overlay", mono: false },
@@ -120,38 +38,35 @@ export function createGrainControlsUi(): GrainControlsUi {
{ label: "B&W", amount: 0.18, blend: "overlay", mono: true },
{ label: "Heavy", amount: 0.35, blend: "soft_light", mono: false },
];
for (const preset of presets) {
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = preset.label;
btn.title = `Preset: amount ${Math.round(preset.amount * 100)}%, ${preset.blend}, ${preset.mono ? "mono" : "rgb"}`;
btn.style.cssText = "background:#2a2a2a;color:#ddd;border:1px solid #444;border-radius:4px;padding:3px 8px;cursor:pointer;font-size:11px";
btn.style.cssText = "background:#2a2a2a;color:#ddd;border:1px solid #444;border-radius:4px;padding:3px 8px;cursor:pointer;font-size:11px;transition:background 0.15s";
btn.addEventListener("mouseenter", () => { btn.style.background = "#3a3a3a"; });
btn.addEventListener("mouseleave", () => { btn.style.background = "#2a2a2a"; });
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
amountInput.value = String(Math.round(preset.amount * 100));
try { amountInput.dispatchEvent(new Event("input", { bubbles: true })); } catch {}
try { amountInput.dispatchEvent(new Event("change", { bubbles: true })); } catch {}
blendSelect.value = preset.blend;
try { blendSelect.dispatchEvent(new Event("change", { bubbles: true })); } catch {}
const monoOn = monoButton.textContent?.trim().toLowerCase() === "mono on";
if (monoOn !== preset.mono) {
try { monoButton.click(); } catch {}
}
setWidgetValue(findWidget(node, "amount"), preset.amount);
setWidgetStringValue(findWidget(node, "blend_mode"), preset.blend);
setWidgetBooleanValue(findWidget(node, "monochrome"), preset.mono);
(node as any).graph?.setDirtyCanvas?.(true, true);
});
presetsRow.appendChild(btn);
}
controls.appendChild(presetsRow);
return { controls };
}
const GRAIN_BLEND_MODES = new Set(["add", "overlay", "soft_light"]);
export function hideGrainWidgets(node: ComfyNode): void {
for (const name of ["bypass", "amount", "blend_mode", "monochrome", "animated", "invert_mask"]) {
hideWidgetForGood(node, findWidget(node, name));
}
if (!isNode(node)) return;
}
export function getGrainInfoText(node: ComfyNode): string {
@@ -166,48 +81,5 @@ export function getGrainInfoText(node: ComfyNode): string {
export function syncGrainWidgets(node: ComfyNode): void {
if (!isNode(node)) return;
const st = ensureState(node);
const root = st.previewRoot;
if (!root) return;
const amount = Math.max(0, Math.min(1, widgetNumber(node, "amount", 0.08)));
const blendModeRaw = widgetString(node, "blend_mode", "add");
const blendMode = GRAIN_BLEND_MODES.has(blendModeRaw) ? blendModeRaw : "add";
const monochrome = widgetBoolean(node, "monochrome", true);
const animated = widgetBoolean(node, "animated", true);
const invertMask = widgetBoolean(node, "invert_mask", false);
const bypass = widgetBoolean(node, "bypass", false);
const amountInput = root.querySelector<HTMLInputElement>('input[data-grain-field="amount"]');
if (amountInput) amountInput.value = String(Math.round(amount * 100));
const amountLabel = root.querySelector<HTMLElement>('[data-grain-amount-label]');
if (amountLabel) amountLabel.textContent = `${Math.round(amount * 100)}%`;
const blendSelect = root.querySelector<HTMLSelectElement>('select[data-grain-select="blend_mode"]');
if (blendSelect) blendSelect.value = blendMode;
const monoButton = root.querySelector<HTMLButtonElement>('button[data-grain-toggle="monochrome"]');
if (monoButton) {
monoButton.textContent = monochrome ? "Mono on" : "Mono off";
styleSoftButton(monoButton, monochrome);
}
const animatedButton = root.querySelector<HTMLButtonElement>('button[data-grain-toggle="animated"]');
if (animatedButton) {
animatedButton.textContent = animated ? "Animated on" : "Animated off";
styleSoftButton(animatedButton, animated);
}
const invertButton = root.querySelector<HTMLButtonElement>('button[data-grain-toggle="invert_mask"]');
if (invertButton) {
invertButton.textContent = invertMask ? "Invert mask on" : "Invert mask off";
styleSoftButton(invertButton, invertMask);
}
const bypassButton = root.querySelector<HTMLButtonElement>('button[data-grain-toggle="bypass"]');
if (bypassButton) {
bypassButton.textContent = bypass ? "Bypass on" : "Bypass off";
styleSoftButton(bypassButton, bypass);
}
hideGrainWidgets(node);
}
+127 -108
View File
@@ -9,145 +9,147 @@ export function isNode(node: ComfyNode): boolean {
return String(node?.comfyClass ?? "") === NODE_CLASS;
}
function styleSegmentControl(container: HTMLElement): void {
container.style.display = "flex";
container.style.background = "#181818";
container.style.borderRadius = "6px";
container.style.padding = "2px";
container.style.border = "1px solid #2e2e2e";
container.style.width = "100%";
container.style.boxSizing = "border-box";
}
function styleSegmentButton(button: HTMLButtonElement, active: boolean): void {
button.style.flex = "1";
button.style.border = "none";
button.style.outline = "none";
button.style.borderRadius = "4px";
button.style.padding = "4px 8px";
button.style.fontSize = "10.5px";
button.style.fontFamily = "var(--comfy-font-sans, Inter, sans-serif)";
button.style.cursor = "pointer";
button.style.transition = "background 0.15s, color 0.15s";
button.style.lineHeight = "1.2";
button.style.textAlign = "center";
if (active) {
button.classList.add("active");
button.style.background = "#525252";
button.style.color = "#ffffff";
button.style.fontWeight = "600";
} else {
button.classList.remove("active");
button.style.background = "transparent";
button.style.color = "#aaaaaa";
button.style.fontWeight = "normal";
}
}
function setupSegmentHover(button: HTMLButtonElement): void {
button.addEventListener("mouseenter", () => {
if (!button.classList.contains("active")) {
button.style.background = "rgba(255,255,255,0.06)";
button.style.color = "#ffffff";
}
});
button.addEventListener("mouseleave", () => {
if (!button.classList.contains("active")) {
button.style.background = "transparent";
button.style.color = "#aaaaaa";
}
});
}
export type KeyerControlsUi = {
controls: HTMLDivElement;
modeButtons: HTMLButtonElement[];
invertButton: HTMLButtonElement;
invertMaskButton: HTMLButtonElement;
bypassButton: HTMLButtonElement;
invertButton: HTMLButtonElement | null;
invertMaskButton: HTMLButtonElement | null;
bypassButton: HTMLButtonElement | null;
pickButton: HTMLButtonElement;
colorInput: HTMLInputElement;
toleranceInput: HTMLInputElement;
softnessInput: HTMLInputElement;
gainInput: HTMLInputElement;
blurInput: HTMLInputElement;
colorInput: HTMLInputElement | null;
toleranceInput: HTMLInputElement | null;
softnessInput: HTMLInputElement | null;
gainInput: HTMLInputElement | null;
blurInput: HTMLInputElement | null;
};
export function createKeyerControlsUi(): KeyerControlsUi {
const controls = document.createElement("div");
controls.style.marginTop = "8px";
controls.style.display = "grid";
controls.style.gap = "8px";
controls.style.gridTemplateColumns = "72px 1fr";
controls.style.rowGap = "8px";
controls.style.columnGap = "12px";
controls.style.alignItems = "center";
controls.style.width = "100%";
controls.style.boxSizing = "border-box";
controls.style.padding = "0 4px";
const styleLabel = (el: HTMLElement) => {
el.style.fontSize = "11px";
el.style.opacity = "0.78";
el.style.fontFamily = "var(--comfy-font-sans, Inter, sans-serif)";
el.style.whiteSpace = "nowrap";
};
const modeButtons: HTMLButtonElement[] = [];
const topRow = document.createElement("div");
topRow.style.display = "grid";
topRow.style.gridTemplateColumns = "repeat(2, minmax(0,1fr))";
topRow.style.gap = "4px";
// Row 1: Mode Segment Control
const modeLabel = document.createElement("div");
modeLabel.textContent = "Mode";
styleLabel(modeLabel);
const modeGroup = document.createElement("div");
styleSegmentControl(modeGroup);
for (const [value, label] of [["color", "Color"], ["luma", "Luma"]] as const) {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.dataset.keyerMode = value;
styleSoftButton(button, value === "color");
topRow.appendChild(button);
styleSegmentButton(button, value === "color");
setupSegmentHover(button);
modeGroup.appendChild(button);
modeButtons.push(button);
}
controls.appendChild(topRow);
const actionRow = document.createElement("div");
actionRow.style.display = "grid";
actionRow.style.gridTemplateColumns = "minmax(0,1fr) auto auto auto auto";
actionRow.style.gap = "6px";
const keyerColorSwatch = createColorSwatch("#00ff00", { title: "Key color" });
const colorInput = keyerColorSwatch.input;
controls.appendChild(modeLabel);
controls.appendChild(modeGroup);
const invertButton = document.createElement("button");
invertButton.type = "button";
invertButton.textContent = "Invert off";
styleSoftButton(invertButton, false);
const invertMaskButton = document.createElement("button");
invertMaskButton.type = "button";
invertMaskButton.textContent = "Inv mask off";
styleSoftButton(invertMaskButton, false);
// Row 2: Sampling Control (Pick button only)
const pickLabel = document.createElement("div");
pickLabel.textContent = "Sampling";
styleLabel(pickLabel);
pickLabel.dataset.keyerPickerLabel = "1";
const pickButton = document.createElement("button");
pickButton.type = "button";
pickButton.textContent = "Pick";
styleSoftButton(pickButton, false);
pickButton.style.width = "100%";
const bypassButton = document.createElement("button");
bypassButton.type = "button";
bypassButton.textContent = "Bypass off";
styleSoftButton(bypassButton, false);
const pickWrapper = document.createElement("div");
pickWrapper.style.display = "flex";
pickWrapper.style.width = "100%";
pickWrapper.appendChild(pickButton);
pickWrapper.dataset.keyerPickerWrapper = "1";
actionRow.appendChild(keyerColorSwatch.host);
actionRow.appendChild(pickButton);
actionRow.appendChild(invertButton);
actionRow.appendChild(invertMaskButton);
actionRow.appendChild(bypassButton);
controls.appendChild(actionRow);
const makeRange = (
labelText: string,
min: number,
max: number,
step: number,
value: number,
accent = "",
): [HTMLDivElement, HTMLInputElement] => {
const row = document.createElement("div");
row.style.display = "grid";
row.style.gridTemplateColumns = "54px minmax(0,1fr) 38px";
row.style.gap = "7px";
row.style.alignItems = "center";
const label = document.createElement("div");
label.textContent = labelText;
label.style.fontSize = "11px";
label.style.opacity = "0.78";
const input = document.createElement("input");
input.type = "range";
input.min = String(min);
input.max = String(max);
input.step = String(step);
input.value = String(value);
input.title = labelText;
styleSoftRange(input);
if (accent) input.style.accentColor = accent;
const valueLabel = document.createElement("div");
valueLabel.dataset.keyerValue = "1";
valueLabel.textContent = String(value);
valueLabel.style.fontSize = "11px";
valueLabel.style.opacity = "0.84";
valueLabel.style.textAlign = "right";
row.appendChild(label);
row.appendChild(input);
row.appendChild(valueLabel);
return [row, input];
};
let row: HTMLDivElement;
let toleranceInput: HTMLInputElement;
let softnessInput: HTMLInputElement;
let gainInput: HTMLInputElement;
let blurInput: HTMLInputElement;
[row, toleranceInput] = makeRange("Tol", 0, 1, 0.01, 0.25, "");
controls.appendChild(row);
[row, softnessInput] = makeRange("Soft", 0, 1, 0.01, 0.1, "");
controls.appendChild(row);
[row, gainInput] = makeRange("Gain", 0, 4, 0.01, 1, "");
controls.appendChild(row);
[row, blurInput] = makeRange("Blur", 0, 64, 0.1, 0, "");
controls.appendChild(row);
controls.appendChild(pickLabel);
controls.appendChild(pickWrapper);
return {
controls,
modeButtons,
invertButton,
invertMaskButton,
bypassButton,
invertButton: null,
invertMaskButton: null,
bypassButton: null,
pickButton,
colorInput,
toleranceInput,
softnessInput,
gainInput,
blurInput,
colorInput: null,
toleranceInput: null,
softnessInput: null,
gainInput: null,
blurInput: null,
};
}
@@ -239,7 +241,14 @@ function hexToRgb(color: string): [number, number, number] {
export function hideKeyerWidgets(node: ComfyNode): void {
if (!isNode(node)) return;
ensureKeyColorsWidget(node);
for (const name of KEYER_WIDGETS) hideWidgetsByName(node, name);
// Hide internal storage and widgets rendered as custom HTML DOM elements
const namesToHide = ["key_colors", "mode"];
for (const name of namesToHide) {
hideWidgetsByName(node, name);
}
}
function setRange(input: HTMLInputElement | null, value: number): void {
@@ -267,8 +276,18 @@ export function syncKeyerWidgets(node: ComfyNode): void {
setWidgetStringValuesByName(node, "key_color", keyColor, { notify: false, dirty: false });
for (const button of st.keyerModeButtons ?? []) {
styleSoftButton(button, button.dataset.keyerMode === mode);
styleSegmentButton(button, button.dataset.keyerMode === mode);
}
// Picker row visibility
const pickLabel = st.previewRoot?.querySelector('[data-keyer-picker-label]') as HTMLElement | null;
const pickWrapper = st.previewRoot?.querySelector('[data-keyer-picker-wrapper]') as HTMLElement | null;
if (pickLabel && pickWrapper) {
const show = mode === "color";
pickLabel.style.display = show ? "" : "none";
pickWrapper.style.display = show ? "" : "none";
}
if (st.keyerColorInput) syncDarkColorInputUI(st.keyerColorInput, keyColor);
setRange(st.keyerToleranceInput, widgetNumber(node, "tolerance", 0.25));
setRange(st.keyerSoftnessInput, widgetNumber(node, "softness", 0.1));
@@ -280,7 +299,7 @@ export function syncKeyerWidgets(node: ComfyNode): void {
styleSoftButton(st.keyerInvertButton, invert);
}
if (st.keyerInvertMaskButton) {
st.keyerInvertMaskButton.textContent = invertMask ? "Inv mask on" : "Inv mask off";
st.keyerInvertMaskButton.textContent = invertMask ? "Invert mask on" : "Invert mask off";
styleSoftButton(st.keyerInvertMaskButton, invertMask);
}
if (st.keyerBypassButton) {
+16 -129
View File
@@ -10,7 +10,7 @@ export type PadOutControlsUi = {
const PADOUT_WIDGETS = [
"bypass",
"target_format",
"aspect_ratio",
"fill_mode",
"fill_preset",
"fill_color",
@@ -53,86 +53,19 @@ export function createPadOutControlsUi(): PadOutControlsUi {
const controls = document.createElement("div");
controls.dataset.padoutUi = "1";
controls.style.marginTop = "8px";
controls.style.display = "grid";
controls.style.gap = "8px";
controls.style.display = "flex";
controls.style.justifyContent = "center";
controls.style.width = "100%";
const makeRow = (): HTMLDivElement => {
const row = document.createElement("div");
row.style.display = "flex";
row.style.alignItems = "center";
row.style.gap = "6px";
row.style.flexWrap = "wrap";
return row;
};
const makeLabel = (text: string): HTMLSpanElement => {
const label = document.createElement("span");
label.textContent = text;
label.style.fontSize = "11px";
label.style.opacity = "0.76";
return label;
};
const ratioRow = makeRow();
ratioRow.appendChild(makeLabel("Ratio"));
const ratioSelect = document.createElement("select");
ratioSelect.dataset.padout = "ratio_select";
styleSoftField(ratioSelect);
ratioSelect.style.width = "120px";
for (const [value, label] of [["custom", "Free"], ["1:1", "1:1"], ["16:9", "16:9"], ["9:16", "9:16"], ["4:3", "4:3"], ["3:4", "3:4"]] as const) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
ratioSelect.appendChild(option);
}
ratioRow.appendChild(createContextMenuSelect(ratioSelect));
controls.appendChild(ratioRow);
const anchorRow = makeRow();
anchorRow.appendChild(makeLabel("Anchor"));
const anchorSelect = document.createElement("select");
anchorSelect.dataset.padout = "anchor_select";
styleSoftField(anchorSelect);
anchorSelect.style.width = "120px";
for (const [value, label] of [["center", "Center"], ["top", "Top"], ["bottom", "Bottom"], ["left", "Left"], ["right", "Right"], ["fit_w", "Fit W"], ["fit_h", "Fit H"]] as const) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
anchorSelect.appendChild(option);
}
anchorRow.appendChild(createContextMenuSelect(anchorSelect));
controls.appendChild(anchorRow);
const actionsRow = makeRow();
const fitButton = document.createElement("button");
fitButton.type = "button";
fitButton.textContent = "Fit All";
fitButton.dataset.padout = "fit_all";
styleSoftButton(fitButton, false);
actionsRow.appendChild(fitButton);
const invertMaskButton = document.createElement("button");
invertMaskButton.type = "button";
invertMaskButton.textContent = "Inv mask off";
invertMaskButton.dataset.padout = "invert_mask";
styleSoftButton(invertMaskButton, false);
actionsRow.appendChild(invertMaskButton);
const bypassButton = document.createElement("button");
bypassButton.type = "button";
bypassButton.textContent = "Bypass off";
bypassButton.dataset.padout = "bypass";
styleSoftButton(bypassButton, false);
actionsRow.appendChild(bypassButton);
const hint = document.createElement("div");
hint.textContent = "Wheel: zoom. Middle drag: pan. Arrow keys: nudge. Shift: x4.";
hint.style.fontSize = "11px";
hint.style.opacity = "0.7";
hint.style.marginLeft = "auto";
actionsRow.appendChild(hint);
controls.appendChild(actionsRow);
fitButton.style.padding = "4px 16px";
fitButton.style.fontSize = "11px";
controls.appendChild(fitButton);
return { controls };
}
@@ -149,6 +82,8 @@ function projectedWidth(deltaX: number, deltaY: number, targetRatio: number): nu
return (deltaX + deltaY / ratio) / (1 + 1 / (ratio * ratio));
}
type ControlState = any;
function controlRoot(node: ComfyNode): HTMLDivElement | null {
const st = node.__imageops_state as any;
const root = st?.previewControls ?? null;
@@ -165,12 +100,6 @@ export function getPadOutInfoText(_node: ComfyNode, width: number, height: numbe
export function hidePadOutWidgets(node: ComfyNode): void {
if (!isNode(node)) return;
const names = new Set(PADOUT_WIDGETS);
for (const widget of node.widgets ?? []) {
if (widget && names.has(String(widget.name ?? ""))) {
hideWidgetForGood(node, widget as any);
}
}
}
export function getPadOutSnap(node: ComfyNode): number {
@@ -182,7 +111,7 @@ export function snapPadValue(value: number, snap: number): number {
}
export function getPadOutTargetRatio(node: ComfyNode): number | null {
return TARGET_RATIO_MAP[normalizeTargetFormat(widgetString(node, "target_format", "custom"))] ?? null;
return TARGET_RATIO_MAP[normalizeTargetFormat(widgetString(node, "aspect_ratio", "custom"))] ?? null;
}
function getPadOutSourceSize(node: ComfyNode, geometry: PadOutPreviewGeometry | null = null): { sourceWidth: number; sourceHeight: number } {
@@ -250,13 +179,13 @@ export function setPadOutOutputRect(node: ComfyNode, sourceWidth: number, source
}
function setPadOutTargetFormatCustomIfNeeded(node: ComfyNode): void {
const currentFormat = widgetString(node, "target_format", "custom");
const currentFormat = widgetString(node, "aspect_ratio", "custom");
const ratio = TARGET_RATIO_MAP[normalizeTargetFormat(currentFormat)] ?? null;
if (ratio == null) return;
const frame = getPadOutFrame(node);
const actualRatio = frame.outWidth / Math.max(1, frame.outHeight);
if (Math.abs(actualRatio - ratio) > 0.015) {
setWidgetStringValue(findWidget(node, "target_format"), "custom");
setWidgetStringValue(findWidget(node, "aspect_ratio"), "custom");
}
}
@@ -279,7 +208,7 @@ export function hydratePadOutTargetFormat(node: ComfyNode): void {
const st = node.__imageops_state as any;
if (st?.padOutRatioHydrated) return;
if (st) st.padOutRatioHydrated = true;
const targetFormat = widgetString(node, "target_format", "custom");
const targetFormat = widgetString(node, "aspect_ratio", "custom");
const ratio = TARGET_RATIO_MAP[normalizeTargetFormat(targetFormat)] ?? null;
if (ratio == null) return;
const frame = getPadOutFrame(node);
@@ -337,31 +266,11 @@ export function applyPadOutAnchor(node: ComfyNode, anchor: string): void {
export function syncPadOutControls(node: ComfyNode): void {
if (!isNode(node)) return;
hidePadOutWidgets(node);
const root = controlRoot(node);
if (!root) return;
const targetFormat = widgetString(node, "target_format", "custom");
const bypass = widgetBoolean(node, "bypass", false);
const invertMask = widgetBoolean(node, "invert_mask", false);
const ratioSelect = control<HTMLSelectElement>(node, "ratio_select");
if (ratioSelect && ratioSelect.value !== targetFormat) ratioSelect.value = targetFormat;
const anchorSelect = control<HTMLSelectElement>(node, "anchor_select");
if (anchorSelect && !anchorSelect.value) anchorSelect.value = "center";
const invertButton = control<HTMLButtonElement>(node, "invert_mask");
if (invertButton) {
invertButton.textContent = invertMask ? "Inv mask on" : "Inv mask off";
styleSoftButton(invertButton, invertMask);
}
const bypassButton = control<HTMLButtonElement>(node, "bypass");
if (bypassButton) {
bypassButton.textContent = bypass ? "Bypass on" : "Bypass off";
styleSoftButton(bypassButton, bypass);
}
const fitButton = control<HTMLButtonElement>(node, "fit_all");
if (fitButton) styleSoftButton(fitButton, false);
}
@@ -377,28 +286,6 @@ export function attachPadOutControls(node: ComfyNode, ctx: NodeInteractionContex
ctx.refreshNode(node);
};
control<HTMLSelectElement>(node, "ratio_select")?.addEventListener("change", (event) => {
const targetFormat = (event.currentTarget as HTMLSelectElement).value || "custom";
setWidgetStringValue(findWidget(node, "target_format"), targetFormat);
if (targetFormat !== "custom") applyPadOutTargetFormat(node, targetFormat);
rerender();
});
control<HTMLSelectElement>(node, "anchor_select")?.addEventListener("change", (event) => {
applyPadOutAnchor(node, (event.currentTarget as HTMLSelectElement).value || "center");
rerender();
});
control<HTMLButtonElement>(node, "invert_mask")?.addEventListener("click", () => {
setWidgetBooleanValue(findWidget(node, "invert_mask"), !widgetBoolean(node, "invert_mask", false));
rerender();
});
control<HTMLButtonElement>(node, "bypass")?.addEventListener("click", () => {
setWidgetBooleanValue(findWidget(node, "bypass"), !widgetBoolean(node, "bypass", false));
rerender();
});
control<HTMLButtonElement>(node, "fit_all")?.addEventListener("click", () => {
const st = node.__imageops_state as any;
st.previewZoom = 1;
+3 -229
View File
@@ -35,173 +35,15 @@ export function isNode(node: ComfyNode): boolean {
}
export type RampControlsUi = {
controls: HTMLDivElement;
controls: HTMLDivElement | null;
};
export function createRampControlsUi(): RampControlsUi {
const controls = document.createElement("div");
controls.style.marginTop = "8px";
controls.style.display = "grid";
controls.style.gap = "8px";
const makeRow = (): HTMLDivElement => {
const row = document.createElement("div");
row.style.display = "grid";
row.style.gap = "6px";
row.style.alignItems = "center";
return row;
};
const makeLabel = (text: string): HTMLDivElement => {
const label = document.createElement("div");
label.textContent = text;
label.style.fontSize = "11px";
label.style.opacity = "0.78";
return label;
};
const makeNumberInput = (
field: string,
options: { min: number; max: number; step: number; width?: string; title: string },
): HTMLInputElement => {
const input = document.createElement("input");
input.type = "number";
input.dataset.rampField = field;
input.min = String(options.min);
input.max = String(options.max);
input.step = String(options.step);
input.title = options.title;
input.style.width = options.width ?? "100%";
input.style.minWidth = "0";
input.style.fontFamily = "ui-monospace, SFMono-Regular, Consolas, monospace";
styleSoftField(input);
return input;
};
const topRow = makeRow();
topRow.style.gridTemplateColumns = "auto minmax(0,1fr) minmax(0,1fr) auto";
topRow.appendChild(makeLabel("Style"));
const shapeSelect = document.createElement("select");
shapeSelect.dataset.rampSelect = "shape";
shapeSelect.title = "Ramp shape";
shapeSelect.style.width = "100%";
shapeSelect.style.minWidth = "0";
styleSoftField(shapeSelect);
for (const [value, label] of [["linear", "Linear"], ["radial", "Radial"]] as const) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
shapeSelect.appendChild(option);
}
topRow.appendChild(createContextMenuSelect(shapeSelect));
const modeSelect = document.createElement("select");
modeSelect.dataset.rampSelect = "mode";
modeSelect.title = "Ramp curve";
modeSelect.style.width = "100%";
modeSelect.style.minWidth = "0";
styleSoftField(modeSelect);
for (const [value, label] of [["linear", "Linear"], ["ease_in", "Ease In"], ["ease_out", "Ease Out"], ["smoothstep", "Smooth"]] as const) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
modeSelect.appendChild(option);
}
topRow.appendChild(createContextMenuSelect(modeSelect));
const invertButton = document.createElement("button");
invertButton.type = "button";
invertButton.dataset.rampToggle = "invert";
invertButton.textContent = "Invert off";
styleSoftButton(invertButton, false);
topRow.appendChild(invertButton);
controls.appendChild(topRow);
const sizeRow = makeRow();
sizeRow.style.gridTemplateColumns = "auto minmax(70px,96px)";
sizeRow.appendChild(makeLabel("Ratio"));
const ratioSelect = document.createElement("select");
ratioSelect.dataset.rampRatio = "1";
ratioSelect.title = "Standard ratio or Free";
ratioSelect.style.width = "100%";
ratioSelect.style.minWidth = "0";
styleSoftField(ratioSelect);
for (const [value, label] of [["custom", "Free"], ["1:1", "1:1"], ["16:9", "16:9"], ["9:16", "9:16"], ["4:3", "4:3"], ["3:4", "3:4"]] as const) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
ratioSelect.appendChild(option);
}
sizeRow.appendChild(createContextMenuSelect(ratioSelect));
controls.appendChild(sizeRow);
const colorsRow = makeRow();
colorsRow.style.gridTemplateColumns = "auto minmax(0,1fr) auto minmax(0,1fr)";
colorsRow.appendChild(makeLabel("Color A"));
const colorA = createColorSwatch("#ffffff");
colorA.input.dataset.rampColor = "a";
colorsRow.appendChild(colorA.host);
colorsRow.appendChild(makeLabel("Color B"));
const colorB = createColorSwatch("#000000");
colorB.input.dataset.rampColor = "b";
colorsRow.appendChild(colorB.host);
controls.appendChild(colorsRow);
const alphaRow = makeRow();
alphaRow.style.gridTemplateColumns = "auto minmax(0,1fr) auto";
alphaRow.appendChild(makeLabel("Alpha"));
const alphaInput = document.createElement("input");
alphaInput.type = "range";
alphaInput.dataset.rampField = "alpha";
alphaInput.min = "0";
alphaInput.max = "100";
alphaInput.step = "1";
alphaInput.value = "100";
alphaInput.title = "Opacity";
styleSoftRange(alphaInput);
alphaRow.appendChild(alphaInput);
const alphaValue = document.createElement("div");
alphaValue.dataset.rampAlphaLabel = "1";
alphaValue.textContent = "100%";
alphaValue.style.fontSize = "11px";
alphaValue.style.opacity = "0.84";
alphaValue.style.textAlign = "right";
alphaRow.appendChild(alphaValue);
controls.appendChild(alphaRow);
const pointsRow = makeRow();
pointsRow.style.gridTemplateColumns = "auto minmax(0,1fr) auto minmax(0,1fr)";
pointsRow.appendChild(makeLabel("Start X"));
pointsRow.appendChild(makeNumberInput("start_x", { min: -2, max: 3, step: 0.001, title: "Start X" }));
pointsRow.appendChild(makeLabel("Start Y"));
pointsRow.appendChild(makeNumberInput("start_y", { min: -2, max: 3, step: 0.001, title: "Start Y" }));
pointsRow.appendChild(makeLabel("End X"));
pointsRow.appendChild(makeNumberInput("end_x", { min: -2, max: 3, step: 0.001, title: "End X" }));
pointsRow.appendChild(makeLabel("End Y"));
pointsRow.appendChild(makeNumberInput("end_y", { min: -2, max: 3, step: 0.001, title: "End Y" }));
controls.appendChild(pointsRow);
return { controls };
return { controls: null };
}
export function hideRampWidgets(node: ComfyNode): void {
for (const name of [
"color_a",
"color_b",
"alpha",
"frame_length",
"batch_size",
"start_x",
"start_y",
"end_x",
"end_y",
"ramp_shape",
"ramp_mode",
"invert",
]) {
hideWidgetsByName(node, name);
}
if (!isNode(node)) return;
}
export function getRampInfoText(node: ComfyNode): string {
@@ -258,78 +100,10 @@ export function setRampHandle(node: ComfyNode, handle: RampHandle, xNorm: number
export function syncRampWidgets(node: ComfyNode): void {
if (!isNode(node)) return;
hideRampWidgets(node);
const colorA = widgetString(node, "color_a", "#ffffff");
const colorB = widgetString(node, "color_b", "#000000");
setWidgetStringValuesByName(node, "color_a", colorA, { notify: false, dirty: false });
setWidgetStringValuesByName(node, "color_b", colorB, { notify: false, dirty: false });
const st = ensureState(node);
const root = st.previewRoot;
if (!root) return;
const width = Math.max(1, Math.round(widgetNumber(node, "width", 1024)));
const height = Math.max(1, Math.round(widgetNumber(node, "height", 1024)));
const frameCount = Math.max(1, Math.round(widgetNumber(node, "frame_count", widgetNumber(node, "frame_length", widgetNumber(node, "batch_size", 1)))));
const alpha = Math.max(0, Math.min(1, widgetNumber(node, "alpha", 1)));
const startX = widgetNumber(node, "start_x", 0);
const startY = widgetNumber(node, "start_y", 0.5);
const endX = widgetNumber(node, "end_x", 1);
const endY = widgetNumber(node, "end_y", 0.5);
const shape = widgetString(node, "ramp_shape", "linear");
const mode = widgetString(node, "ramp_mode", "linear");
const invert = widgetBoolean(node, "invert", false);
const setNumberField = (name: string, value: number): void => {
const input = root.querySelector<HTMLInputElement>(`input[data-ramp-field="${name}"]`);
if (input) input.value = String(value);
};
setNumberField("width", width);
setNumberField("height", height);
setNumberField("frame_count", frameCount);
setNumberField("frame_length", frameCount);
setNumberField("batch_size", frameCount);
setNumberField("start_x", startX);
setNumberField("start_y", startY);
setNumberField("end_x", endX);
setNumberField("end_y", endY);
const ratioSelect = root.querySelector<HTMLSelectElement>('select[data-ramp-ratio="1"]');
if (ratioSelect) ratioSelect.value = resolveRampRatioPreset(width, height);
const alphaInput = root.querySelector<HTMLInputElement>('input[data-ramp-field="alpha"]');
if (alphaInput) alphaInput.value = String(Math.round(alpha * 100));
const alphaLabel = root.querySelector<HTMLElement>('[data-ramp-alpha-label]');
if (alphaLabel) alphaLabel.textContent = `${Math.round(alpha * 100)}%`;
const colorAInput = root.querySelector<HTMLInputElement>('input[data-ramp-color="a"]');
if (colorAInput) {
colorAInput.value = colorA;
syncDarkColorInputUI(colorAInput, colorA);
}
const colorBInput = root.querySelector<HTMLInputElement>('input[data-ramp-color="b"]');
if (colorBInput) {
colorBInput.value = colorB;
syncDarkColorInputUI(colorBInput, colorB);
}
const shapeSelect = root.querySelector<HTMLSelectElement>('select[data-ramp-select="shape"]');
if (shapeSelect) shapeSelect.value = shape;
const modeSelect = root.querySelector<HTMLSelectElement>('select[data-ramp-select="mode"]');
if (modeSelect) modeSelect.value = mode;
const invertButton = root.querySelector<HTMLButtonElement>('button[data-ramp-toggle="invert"]');
if (invertButton) {
invertButton.textContent = invert ? "Invert on" : "Invert off";
styleSoftButton(invertButton, invert);
}
setDarkColorInputState(colorAInput, false, false);
setDarkColorInputState(colorBInput, false, false);
}
+3 -251
View File
@@ -19,198 +19,17 @@ export function isNode(node: ComfyNode): boolean {
}
export type TextControlsUi = {
controls: HTMLDivElement;
controls: HTMLDivElement | null;
};
export function createTextControlsUi(): TextControlsUi {
const controls = document.createElement("div");
controls.style.marginTop = "8px";
controls.style.display = "grid";
controls.style.gap = "8px";
const makeEyedropperButton = (getInput: () => HTMLInputElement): HTMLButtonElement => {
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = "\uD83D\uDCA7";
btn.title = "Pick color from a preview";
btn.style.cssText = "background:#2a2a2a;color:#ddd;border:1px solid #444;border-radius:4px;padding:2px 6px;cursor:pointer;font-size:12px;line-height:1";
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const input = getInput();
startEyedropper({
onPick: (hex) => {
input.value = hex;
try { input.dispatchEvent(new Event("input", { bubbles: true })); } catch {}
try { input.dispatchEvent(new Event("change", { bubbles: true })); } catch {}
},
});
});
return btn;
};
const makeRow = (): HTMLDivElement => {
const row = document.createElement("div");
row.style.display = "grid";
row.style.gap = "6px";
row.style.alignItems = "center";
return row;
};
const makeLabel = (text: string): HTMLDivElement => {
const label = document.createElement("div");
label.textContent = text;
label.style.fontSize = "11px";
label.style.opacity = "0.78";
return label;
};
const makeNumberInput = (
field: string,
options: { min: number; max?: number; step: number; width?: string; title: string },
): HTMLInputElement => {
const input = document.createElement("input");
input.type = "number";
input.dataset.textField = field;
input.min = String(options.min);
if (typeof options.max === "number") input.max = String(options.max);
input.step = String(options.step);
input.title = options.title;
input.style.width = options.width ?? "100%";
input.style.minWidth = "0";
input.style.fontFamily = "ui-monospace, SFMono-Regular, Consolas, monospace";
styleSoftField(input);
return input;
};
const topRow = makeRow();
topRow.style.gridTemplateColumns = "auto minmax(0,1fr) auto minmax(0,1fr)";
topRow.appendChild(makeLabel("Align"));
const alignSelect = document.createElement("select");
alignSelect.dataset.textSelect = "align";
alignSelect.title = "Text alignment";
alignSelect.style.width = "100%";
alignSelect.style.minWidth = "0";
styleSoftField(alignSelect);
for (const [value, label] of [["left", "Left"], ["center", "Center"], ["right", "Right"]] as const) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
alignSelect.appendChild(option);
}
topRow.appendChild(createContextMenuSelect(alignSelect));
controls.appendChild(topRow);
const posRow = makeRow();
posRow.style.gridTemplateColumns = "auto minmax(0,1fr)";
posRow.appendChild(makeLabel("X/Y"));
const posWrap = document.createElement("div");
posWrap.style.display = "flex";
posWrap.style.gap = "6px";
posWrap.style.alignItems = "center";
posWrap.style.minWidth = "0";
posWrap.appendChild(makeNumberInput("x", { min: -2, max: 3, step: 0.001, title: "Normalized X" }));
posWrap.appendChild(makeNumberInput("y", { min: -2, max: 3, step: 0.001, title: "Normalized Y" }));
posRow.appendChild(posWrap);
controls.appendChild(posRow);
const styleRow = makeRow();
styleRow.style.gridTemplateColumns = "auto minmax(0,1fr) auto";
styleRow.appendChild(makeLabel("Opacity"));
const opacityInput = document.createElement("input");
opacityInput.type = "range";
opacityInput.dataset.textField = "opacity";
opacityInput.min = "0";
opacityInput.max = "100";
opacityInput.step = "1";
opacityInput.value = "100";
opacityInput.title = "Text opacity";
styleSoftRange(opacityInput);
styleRow.appendChild(opacityInput);
const opacityLabel = document.createElement("div");
opacityLabel.dataset.textOpacityLabel = "1";
opacityLabel.textContent = "100%";
opacityLabel.style.fontSize = "11px";
opacityLabel.style.opacity = "0.84";
opacityLabel.style.textAlign = "right";
styleRow.appendChild(opacityLabel);
controls.appendChild(styleRow);
const colorsRow = makeRow();
colorsRow.style.gridTemplateColumns = "auto minmax(0,1fr) auto minmax(0,1fr)";
colorsRow.appendChild(makeLabel("Fill"));
const fillColor = createColorSwatch("#ffffff");
fillColor.input.dataset.textColor = "fill";
const fillWrap = document.createElement("div");
fillWrap.style.cssText = "display:flex;gap:4px;align-items:center;min-width:0";
fillWrap.appendChild(fillColor.host);
const fillEye = makeEyedropperButton(() => fillColor.input);
fillWrap.appendChild(fillEye);
colorsRow.appendChild(fillWrap);
colorsRow.appendChild(makeLabel("Stroke"));
const strokeColor = createColorSwatch("#000000");
strokeColor.input.dataset.textColor = "stroke";
const strokeWrap = document.createElement("div");
strokeWrap.style.cssText = "display:flex;gap:4px;align-items:center;min-width:0";
strokeWrap.appendChild(strokeColor.host);
const strokeEye = makeEyedropperButton(() => strokeColor.input);
strokeWrap.appendChild(strokeEye);
colorsRow.appendChild(strokeWrap);
controls.appendChild(colorsRow);
const bottomRow = makeRow();
bottomRow.style.gridTemplateColumns = "auto minmax(0,1fr) auto auto auto";
bottomRow.appendChild(makeLabel("Font"));
const fontPathInput = document.createElement("input");
fontPathInput.type = "text";
fontPathInput.dataset.textField = "font_path";
fontPathInput.placeholder = "Optional font path";
fontPathInput.title = "Optional font path";
fontPathInput.style.width = "100%";
fontPathInput.style.fontFamily = "ui-monospace, SFMono-Regular, Consolas, monospace";
styleSoftField(fontPathInput);
bottomRow.appendChild(fontPathInput);
const invertButton = document.createElement("button");
invertButton.type = "button";
invertButton.dataset.textToggle = "invert_mask";
invertButton.textContent = "Invert mask off";
styleSoftButton(invertButton, false);
bottomRow.appendChild(invertButton);
const bypassButton = document.createElement("button");
bypassButton.type = "button";
bypassButton.dataset.textToggle = "bypass";
bypassButton.textContent = "Bypass off";
styleSoftButton(bypassButton, false);
bottomRow.appendChild(bypassButton);
const hint = document.createElement("div");
hint.textContent = "X/Y are normalized from -2 to 3.";
hint.style.fontSize = "11px";
hint.style.opacity = "0.64";
bottomRow.appendChild(hint);
controls.appendChild(bottomRow);
return { controls };
return { controls: null };
}
const TEXT_ALIGN_OPTIONS = new Set(["left", "center", "right"]);
export function hideTextWidgets(node: ComfyNode): void {
for (const name of [
"bypass",
"x",
"y",
"color",
"opacity",
"align",
"stroke_color",
"invert_mask",
"font_path",
]) {
hideWidgetsByName(node, name);
}
if (!isNode(node)) return;
}
export function getTextInfoText(node: ComfyNode): string {
@@ -225,78 +44,11 @@ export function getTextInfoText(node: ComfyNode): string {
export function syncTextWidgets(node: ComfyNode): void {
if (!isNode(node)) return;
hideTextWidgets(node);
const color = widgetString(node, "color", "#ffffff");
const strokeColor = widgetString(node, "stroke_color", "#000000");
setWidgetStringValuesByName(node, "color", color, { notify: false, dirty: false });
setWidgetStringValuesByName(node, "stroke_color", strokeColor, { notify: false, dirty: false });
const st = ensureState(node);
const root = st.previewRoot;
if (!root) return;
const text = widgetString(node, "text", "ImageOps Text");
const x = widgetNumber(node, "x", 0.5);
const y = widgetNumber(node, "y", 0.5);
const fontSize = Math.max(1, Math.round(widgetNumber(node, "font_size", 64)));
const opacity = Math.max(0, Math.min(1, widgetNumber(node, "opacity", 1)));
const alignRaw = widgetString(node, "align", "center").toLowerCase();
const align = TEXT_ALIGN_OPTIONS.has(alignRaw) ? alignRaw : "center";
const lineSpacing = Math.max(0, Math.round(widgetNumber(node, "line_spacing", 4)));
const strokeWidth = Math.max(0, Math.round(widgetNumber(node, "stroke_width", 0)));
const invertMask = widgetBoolean(node, "invert_mask", false);
const bypass = widgetBoolean(node, "bypass", false);
const fontPath = widgetString(node, "font_path", "");
const setNumberField = (name: string, value: number): void => {
const input = root.querySelector<HTMLInputElement>(`input[data-text-field="${name}"]`);
if (input) input.value = String(value);
};
setNumberField("x", x);
setNumberField("y", y);
setNumberField("font_size", fontSize);
setNumberField("line_spacing", lineSpacing);
setNumberField("stroke_width", strokeWidth);
const fontPathInput = root.querySelector<HTMLInputElement>('input[data-text-field="font_path"]');
if (fontPathInput && fontPathInput.value !== fontPath) fontPathInput.value = fontPath;
const opacityInput = root.querySelector<HTMLInputElement>('input[data-text-field="opacity"]');
if (opacityInput) opacityInput.value = String(Math.round(opacity * 100));
const opacityLabel = root.querySelector<HTMLElement>('[data-text-opacity-label]');
if (opacityLabel) opacityLabel.textContent = `${Math.round(opacity * 100)}%`;
const alignSelect = root.querySelector<HTMLSelectElement>('select[data-text-select="align"]');
if (alignSelect) alignSelect.value = align;
const colorInput = root.querySelector<HTMLInputElement>('input[data-text-color="fill"]');
if (colorInput) {
colorInput.value = color;
syncDarkColorInputUI(colorInput, color);
}
const strokeColorInput = root.querySelector<HTMLInputElement>('input[data-text-color="stroke"]');
if (strokeColorInput) {
strokeColorInput.value = strokeColor;
syncDarkColorInputUI(strokeColorInput, strokeColor);
}
const invertButton = root.querySelector<HTMLButtonElement>('button[data-text-toggle="invert_mask"]');
if (invertButton) {
invertButton.textContent = invertMask ? "Invert mask on" : "Invert mask off";
styleSoftButton(invertButton, invertMask);
}
const bypassButton = root.querySelector<HTMLButtonElement>('button[data-text-toggle="bypass"]');
if (bypassButton) {
bypassButton.textContent = bypass ? "Bypass on" : "Bypass off";
styleSoftButton(bypassButton, bypass);
}
setDarkColorInputState(strokeColorInput, false, strokeWidth <= 0);
}
+10 -8
View File
@@ -2555,16 +2555,18 @@ export function renderCompPreview(
const useFirst = bool(node, "use_first_layer_size", true);
const largestWidth = inputLayers.reduce((value, entry) => Math.max(value, entry.sourceWidth || entry.image.width || 1), 1);
const largestHeight = inputLayers.reduce((value, entry) => Math.max(value, entry.sourceHeight || entry.image.height || 1), 1);
const customAspect = str(node, "aspect_ratio", "free").trim().toLowerCase();
const customAspect = str(node, "aspect_ratio", "custom").trim().toLowerCase();
const customRatio = customAspect === "1:1" || customAspect === "1/1"
? 1
: customAspect === "4:3" || customAspect === "4/3"
? 4 / 3
: customAspect === "16:9" || customAspect === "16/9"
? 16 / 9
: customAspect === "9:16" || customAspect === "9/16"
? 9 / 16
: null;
: customAspect === "3:4" || customAspect === "3/4"
? 3 / 4
: customAspect === "4:3" || customAspect === "4/3"
? 4 / 3
: customAspect === "16:9" || customAspect === "16/9"
? 16 / 9
: customAspect === "9:16" || customAspect === "9/16"
? 9 / 16
: null;
const outputWidth = useAutoLayering
? largestWidth
: useFirst && firstInput
+37 -181
View File
@@ -1,8 +1,14 @@
export function styleSoftButton(button: HTMLButtonElement, active: boolean = false): void {
button.style.border = "1px solid rgba(255,255,255,0.12)";
button.style.background = active ? "rgba(255,255,255,0.16)" : "rgba(255,255,255,0.04)";
button.style.color = "rgba(255,255,255,0.94)";
button.style.borderRadius = "6px";
button.classList.add("comfy-btn");
if (active) {
button.classList.add("active");
} else {
button.classList.remove("active");
}
button.style.border = "1px solid #3f3f3f";
button.style.background = active ? "#444444" : "#2b2b2b";
button.style.color = active ? "#ffffff" : "#cccccc";
button.style.borderRadius = "4px";
button.style.padding = "4px 8px";
button.style.cursor = "pointer";
button.style.fontSize = "11px";
@@ -10,10 +16,11 @@ export function styleSoftButton(button: HTMLButtonElement, active: boolean = fal
}
export function styleSoftField(field: HTMLElement): void {
field.style.borderRadius = "6px";
field.style.border = "1px solid rgba(255,255,255,0.12)";
field.style.background = "rgba(0,0,0,0.28)";
field.style.color = "rgba(255,255,255,0.95)";
field.classList.add("comfy-input");
field.style.borderRadius = "4px";
field.style.border = "1px solid #3f3f3f";
field.style.background = "#1c1c1c";
field.style.color = "#ffffff";
field.style.padding = "4px 6px";
field.style.boxSizing = "border-box";
field.style.fontSize = "11px";
@@ -57,181 +64,29 @@ export function createContextMenuSelect(select: HTMLSelectElement): HTMLDivEleme
wrapper.style.flex = select.style.flex || "0 0 auto";
wrapper.style.flexShrink = select.style.flexShrink || "1";
select.style.display = "none";
const trigger = document.createElement("button");
trigger.type = "button";
styleSoftField(trigger);
trigger.style.width = "100%";
trigger.style.display = "flex";
trigger.style.alignItems = "center";
trigger.style.justifyContent = "space-between";
trigger.style.gap = "8px";
trigger.style.cursor = "pointer";
trigger.style.textAlign = "left";
const label = document.createElement("span");
label.dataset.contextMenuLabel = "1";
label.style.flex = "1 1 auto";
label.style.minWidth = "0";
label.style.overflow = "hidden";
label.style.textOverflow = "ellipsis";
label.style.whiteSpace = "nowrap";
const caret = document.createElement("span");
caret.textContent = "▼";
caret.style.fontSize = "9px";
caret.style.opacity = "0.7";
caret.style.flexShrink = "0";
trigger.append(label, caret);
wrapper.append(select, trigger);
patched.__imageopsContextMenuTrigger = trigger;
patched.__imageopsContextMenuSync = () => syncContextMenuSelect(patched);
if (!patched.__imageopsContextMenuPatched) {
patched.__imageopsContextMenuPatched = true;
const valueDescriptor = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value");
if (valueDescriptor?.get && valueDescriptor?.set) {
Object.defineProperty(select, "value", {
configurable: true,
enumerable: true,
get() {
return valueDescriptor.get?.call(select);
},
set(value: string) {
valueDescriptor.set?.call(select, value);
syncContextMenuSelect(patched);
},
});
}
const disabledDescriptor = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "disabled");
if (disabledDescriptor?.get && disabledDescriptor?.set) {
Object.defineProperty(select, "disabled", {
configurable: true,
enumerable: true,
get() {
return !!disabledDescriptor.get?.call(select);
},
set(value: boolean) {
disabledDescriptor.set?.call(select, value);
syncContextMenuSelect(patched);
},
});
}
}
const openMenu = (): void => {
if (select.disabled) return;
closeActiveContextMenu();
const menu = document.createElement("div");
menu.style.position = "fixed";
menu.style.zIndex = "999999";
menu.style.minWidth = `${Math.max(120, Math.round(trigger.getBoundingClientRect().width))}px`;
menu.style.maxWidth = "280px";
menu.style.maxHeight = "320px";
menu.style.overflowY = "auto";
menu.style.padding = "4px";
menu.style.border = "1px solid rgba(255,255,255,0.14)";
menu.style.borderRadius = "8px";
menu.style.background = "rgba(18,18,20,0.98)";
menu.style.boxShadow = "0 10px 30px rgba(0,0,0,0.45)";
menu.style.backdropFilter = "blur(10px)";
const triggerRect = trigger.getBoundingClientRect();
const placeMenu = (): void => {
const estimatedHeight = Math.min(320, Math.max(36, select.options.length * 28 + 8));
const belowTop = triggerRect.bottom + 6;
const aboveTop = Math.max(8, triggerRect.top - estimatedHeight - 6);
menu.style.left = `${Math.max(8, triggerRect.left)}px`;
menu.style.top = `${belowTop + estimatedHeight <= window.innerHeight ? belowTop : aboveTop}px`;
};
placeMenu();
const choose = (value: string): void => {
const nativeValueDescriptor = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value");
nativeValueDescriptor?.set?.call(select, value);
syncContextMenuSelect(patched);
select.dispatchEvent(new Event("change", { bubbles: true }));
closeActiveContextMenu();
};
for (const option of Array.from(select.options)) {
const item = document.createElement("button");
item.type = "button";
item.textContent = option.textContent ?? option.value;
item.disabled = option.disabled;
item.style.display = "block";
item.style.width = "100%";
item.style.border = "none";
item.style.borderRadius = "6px";
item.style.padding = "6px 8px";
item.style.margin = "0";
item.style.textAlign = "left";
item.style.fontSize = "11px";
item.style.color = "rgba(255,255,255,0.95)";
item.style.background = option.value === select.value ? "rgba(255,255,255,0.14)" : "transparent";
item.style.cursor = option.disabled ? "default" : "pointer";
item.style.opacity = option.disabled ? "0.45" : "1";
item.addEventListener("click", (event) => {
event.preventDefault();
if (option.disabled) return;
choose(option.value);
});
menu.appendChild(item);
}
const closeMenu = (): void => {
document.removeEventListener("pointerdown", onPointerDown, true);
document.removeEventListener("keydown", onKeyDown, true);
menu.remove();
trigger.setAttribute("aria-expanded", "false");
if (activeContextMenuCleanup === closeMenu) activeContextMenuCleanup = null;
};
const onPointerDown = (event: Event): void => {
const target = event.target as Node | null;
if (target && (menu.contains(target) || trigger.contains(target))) return;
closeMenu();
};
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key !== "Escape") return;
event.preventDefault();
closeMenu();
};
activeContextMenuCleanup = closeMenu;
trigger.setAttribute("aria-expanded", "true");
document.body.appendChild(menu);
requestAnimationFrame(() => {
document.addEventListener("pointerdown", onPointerDown, true);
document.addEventListener("keydown", onKeyDown, true);
});
// Keep ImageOps selects inside the node layout. The previous custom
// fixed-position context menu could cover ComfyUI input sockets, especially
// widget-backed INT/FLOAT inputs exposed by the core frontend.
styleSoftField(select);
select.style.display = "block";
select.style.width = "100%";
select.style.minWidth = "0";
select.style.height = "26px";
select.style.cursor = select.disabled ? "default" : "pointer";
patched.__imageopsContextMenuSync = () => {
select.style.opacity = select.disabled ? "0.55" : "1";
select.style.cursor = select.disabled ? "default" : "pointer";
};
trigger.addEventListener("click", (event) => {
event.preventDefault();
openMenu();
});
trigger.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " " && event.key !== "ArrowDown") return;
event.preventDefault();
openMenu();
});
select.addEventListener("change", patched.__imageopsContextMenuSync);
select.addEventListener("input", patched.__imageopsContextMenuSync);
syncContextMenuSelect(patched);
patched.__imageopsContextMenuSync();
wrapper.append(select);
return wrapper;
}
export function styleSoftRange(field: HTMLInputElement): void {
field.classList.add("comfy-slider");
field.style.width = "100%";
field.style.margin = "0";
field.style.boxSizing = "border-box";
@@ -284,10 +139,11 @@ export function createColorSwatch(
const hex = normalizeHex6(initialColor);
const host = document.createElement("div");
host.classList.add("comfy-swatch");
host.style.display = "inline-flex";
host.style.alignItems = "stretch";
host.style.borderRadius = "6px";
host.style.border = "1px solid rgba(255,255,255,0.12)";
host.style.borderRadius = "4px";
host.style.border = "1px solid #3f3f3f";
host.style.overflow = "hidden";
host.style.cursor = "pointer";
host.style.height = "28px";
@@ -298,7 +154,7 @@ export function createColorSwatch(
const colorBlock = document.createElement("div");
colorBlock.style.flex = compact ? "1" : "0 0 22px";
colorBlock.style.background = hex;
if (!compact) colorBlock.style.borderRight = "1px solid rgba(255,255,255,0.12)";
if (!compact) colorBlock.style.borderRight = "1px solid #3f3f3f";
host.appendChild(colorBlock);
let hexLabel: HTMLDivElement | null = null;
@@ -309,8 +165,8 @@ export function createColorSwatch(
hexLabel.style.alignItems = "center";
hexLabel.style.justifyContent = "center";
hexLabel.style.fontSize = "10px";
hexLabel.style.color = "rgba(255,255,255,0.65)";
hexLabel.style.background = "rgba(0,0,0,0.28)";
hexLabel.style.color = "#aaaaaa";
hexLabel.style.background = "#1c1c1c";
hexLabel.style.letterSpacing = "0.5px";
hexLabel.style.fontFamily = "monospace";
hexLabel.style.userSelect = "none";
+7 -2
View File
@@ -29,9 +29,9 @@ export function attachPreviewNavigation(node: ComfyNode, canvasSize: number): vo
return { rect, sx: canvas.width / rect.width, sy: canvas.height / rect.height };
};
// ── Middle-button pan ──
// ── Left-drag pan ──
canvas.addEventListener("pointerdown", (event: PointerEvent) => {
if (event.button !== 1) return;
if (event.button !== 0 || isInteractiveNode(node)) return;
event.preventDefault();
event.stopPropagation();
try { canvas.setPointerCapture(event.pointerId); } catch {}
@@ -44,6 +44,7 @@ export function attachPreviewNavigation(node: ComfyNode, canvasSize: number): vo
startPanX: st.previewPanX,
startPanY: st.previewPanY,
};
canvas.style.cursor = "grabbing";
});
let panRafPending = false;
@@ -65,6 +66,7 @@ export function attachPreviewNavigation(node: ComfyNode, canvasSize: number): vo
canvas.addEventListener("pointerup", (event: PointerEvent) => {
if (st.previewPanDrag?.pointerId === event.pointerId) {
st.previewPanDrag = null;
canvas.style.cursor = "";
try { canvas.releasePointerCapture(event.pointerId); } catch {}
}
});
@@ -72,6 +74,7 @@ export function attachPreviewNavigation(node: ComfyNode, canvasSize: number): vo
canvas.addEventListener("pointercancel", (event: PointerEvent) => {
if (st.previewPanDrag?.pointerId === event.pointerId) {
st.previewPanDrag = null;
canvas.style.cursor = "";
}
});
@@ -84,6 +87,7 @@ export function attachPreviewNavigation(node: ComfyNode, canvasSize: number): vo
if (target !== canvas && !canvas.contains(target)) return;
// Let interactive nodes (Draw) handle wheel themselves — don't block their handlers.
if (isInteractiveNode(node)) return;
if (!(event.ctrlKey || event.metaKey)) return;
// Prevent the graph from zooming when the cursor is over our preview canvas.
event.stopImmediatePropagation();
event.preventDefault();
@@ -118,3 +122,4 @@ export function attachPreviewNavigation(node: ComfyNode, canvasSize: number): vo
reblitNow();
});
}
+34 -223
View File
@@ -95,231 +95,11 @@ function getCompactWidgetValue(widget: ComfyWidget, fallback: unknown): string {
}
function buildCompactNativeWidgetControls(node: ComfyNode, onWidgetChange?: () => void): HTMLElement | null {
if (!isImageOpsNativeUiClass(node.comfyClass) && !isCompNode(node)) return null;
const widgets = listCompactUiWidgets(node);
if (!widgets.length) return null;
const st = ensureState(node) as any;
const bindings: CompactWidgetBinding[] = [];
const panel = document.createElement("div");
panel.style.marginTop = "8px";
panel.style.display = "grid";
panel.style.gap = "8px";
const seenNames = new Set<string>();
for (const widget of widgets) {
// Skip duplicate widget names (e.g. background_color has both a native canvas
// widget and a ComfyUI-auto-added DOM color picker — only build one row).
if (seenNames.has(widget.name)) continue;
seenNames.add(widget.name);
const spec = getWidgetInputSpec(node, widget.name);
if (!spec) continue;
const row = document.createElement("div");
row.style.display = "grid";
row.style.gridTemplateColumns = "minmax(84px, auto) minmax(0,1fr)";
row.style.gap = "8px";
row.style.alignItems = "center";
const label = document.createElement("div");
label.textContent = String(spec.options?.display_name ?? prettifyWidgetLabel(widget.name));
label.style.fontSize = "11px";
label.style.opacity = "0.78";
label.style.lineHeight = "1.2";
row.appendChild(label);
const commitValue = (value: string | number | boolean): void => {
setWidgetMixedValue(widget, value);
syncCompactNativeWidgetControls(node);
onWidgetChange?.();
};
// Old COMBO format: typeSpec is an array of option values.
// New COMBO format: typeSpec is the string "COMBO" and options are in spec.options.options.
const comboOptions = Array.isArray(spec.typeSpec)
? (spec.typeSpec as unknown[])
: String(spec.typeSpec ?? "").trim().toUpperCase() === "COMBO" && Array.isArray(spec.options?.options)
? (spec.options!.options as unknown[])
: null;
if (comboOptions !== null) {
const select = document.createElement("select");
styleSoftField(select);
select.style.width = "100%";
for (const entry of comboOptions) {
const option = document.createElement("option");
option.value = String(entry);
option.textContent = prettifyWidgetLabel(String(entry));
select.appendChild(option);
}
if (spec.options?.tooltip) select.title = String(spec.options.tooltip);
select.addEventListener("change", () => commitValue(select.value));
row.appendChild(createContextMenuSelect(select));
bindings.push({ widget, kind: "select", control: select, integer: false });
panel.appendChild(row);
continue;
}
const kind = String(spec.typeSpec ?? "").trim().toUpperCase();
if (kind === "BOOLEAN") {
const button = document.createElement("button");
button.type = "button";
if (spec.options?.tooltip) button.title = String(spec.options.tooltip);
button.addEventListener("click", () => {
const active = String(widget.value ?? "false").toLowerCase().match(/^(true|1)$/) != null;
commitValue(!active);
});
row.appendChild(button);
bindings.push({ widget, kind: "boolean", control: button, integer: false });
panel.appendChild(row);
continue;
}
if (kind === "COLOR") {
const initialColor = String(widget.value ?? spec.options?.default ?? "#000000");
const { input: colorInput, host: colorHost } = createColorSwatch(initialColor, {
title: spec.options?.tooltip ? String(spec.options.tooltip) : undefined,
});
colorInput.addEventListener("input", () => commitValue(colorInput.value));
const defaultColor = String(spec.options?.default ?? "#000000");
attachDblClickReset(colorHost, { defaultValue: defaultColor, onReset: (v) => commitValue(String(v)) });
attachDblClickReset(colorInput, { defaultValue: defaultColor, onReset: (v) => commitValue(String(v)) });
row.appendChild(colorHost);
bindings.push({ widget, kind: "color", control: colorInput, integer: false });
panel.appendChild(row);
continue;
}
const input = document.createElement("input");
input.type = kind === "STRING" ? "text" : "number";
input.style.width = "100%";
if (kind !== "STRING") {
if (spec.options?.min != null) input.min = String(spec.options.min);
if (spec.options?.max != null) input.max = String(spec.options.max);
if (spec.options?.step != null) input.step = String(spec.options.step);
input.style.fontFamily = "ui-monospace, SFMono-Regular, Consolas, monospace";
input.addEventListener("input", () => {
const next = Number(input.value);
if (!Number.isFinite(next)) return;
commitValue(kind === "INT" ? Math.round(next) : next);
});
const numericDefault = Number(spec.options?.default ?? 0);
if (Number.isFinite(numericDefault)) {
attachDblClickReset(input, {
defaultValue: kind === "INT" ? Math.round(numericDefault) : numericDefault,
onReset: (v) => commitValue(v),
});
}
} else {
input.addEventListener("change", () => commitValue(input.value));
const stringDefault = String(spec.options?.default ?? "");
attachDblClickReset(input, { defaultValue: stringDefault, onReset: (v) => commitValue(String(v)) });
}
if (spec.options?.tooltip) input.title = String(spec.options.tooltip);
styleSoftField(input);
// For INT widgets named "seed", append a randomize 🎲 button.
if (kind === "INT" && widget.name === "seed") {
const wrap = document.createElement("div");
wrap.style.cssText = "display:flex;gap:4px;align-items:center;min-width:0";
wrap.appendChild(input);
const rand = document.createElement("button");
rand.type = "button";
rand.textContent = "\uD83C\uDFB2";
rand.title = "Randomize seed";
rand.style.cssText = "background:#2a2a2a;color:#ddd;border:1px solid #444;border-radius:4px;padding:2px 6px;cursor:pointer;font-size:12px;line-height:1;flex:0 0 auto";
rand.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const max = Number.isFinite(Number(spec.options?.max)) ? Number(spec.options?.max) : 2147483647;
const min = Number.isFinite(Number(spec.options?.min)) ? Number(spec.options?.min) : 0;
const span = Math.max(1, Math.min(max, 2147483647) - Math.max(min, 0));
const next = Math.floor(Math.random() * span) + Math.max(min, 0);
input.value = String(next);
commitValue(next);
});
wrap.appendChild(rand);
row.appendChild(wrap);
} else {
row.appendChild(input);
}
bindings.push({ widget, kind: kind === "STRING" ? "text" : "number", control: input, integer: kind === "INT" });
panel.appendChild(row);
}
if (!bindings.length) return null;
st.compactWidgetBindings = bindings;
syncCompactNativeWidgetControls(node);
// For tall panels (>4 controls), wrap in a collapsible <details> so the node
// doesn't grow vertically out of control. Open/closed state is persisted per
// node-class via localStorage.
const COLLAPSE_THRESHOLD = 4;
if (bindings.length > COLLAPSE_THRESHOLD) {
const details = document.createElement("details");
details.style.marginTop = "8px";
details.style.background = "rgba(255,255,255,0.03)";
details.style.border = "1px solid rgba(255,255,255,0.08)";
details.style.borderRadius = "6px";
details.style.padding = "4px 8px 8px";
const summary = document.createElement("summary");
summary.textContent = `Controls (${bindings.length})`;
summary.style.cursor = "pointer";
summary.style.fontSize = "11px";
summary.style.opacity = "0.85";
summary.style.padding = "4px 0";
summary.style.userSelect = "none";
details.appendChild(summary);
panel.style.marginTop = "6px";
details.appendChild(panel);
bindCollapsibleToUiState(details, String(node.comfyClass ?? "unknown"), "compactPanelOpen", false);
// When toggled, recompute node layout so height shrinks/grows accordingly.
details.addEventListener("toggle", () => {
try { (node as any).graph?.setDirtyCanvas?.(true, true); } catch {}
try {
const root = (ensureState(node) as any).previewRoot as HTMLElement | undefined;
const target = getNodePreviewTargetSize(node, root ?? null, Math.max(360, Math.round((node as any).size?.[0] ?? 360)));
node.setSize?.(target);
} catch {}
});
return details;
}
return panel;
return null; // Disabled in favor of native ComfyUI widgets to prevent masking input connection slots (INT/FLOAT)
}
export function syncCompactNativeWidgetControls(node: ComfyNode): void {
const bindings = ((ensureState(node) as any).compactWidgetBindings ?? []) as CompactWidgetBinding[];
for (const binding of bindings) {
const spec = getWidgetInputSpec(node, binding.widget.name);
const fallback = spec?.options?.default;
if (binding.kind === "boolean") {
const button = binding.control as HTMLButtonElement;
const active = String(binding.widget.value ?? fallback ?? "false").toLowerCase().match(/^(true|1)$/) != null;
const labelOn = spec?.options?.label_on ? String(spec.options.label_on) : "On";
const labelOff = spec?.options?.label_off ? String(spec.options.label_off) : "Off";
button.textContent = active ? labelOn : labelOff;
styleSoftButton(button, active);
continue;
}
if (binding.kind === "select") {
(binding.control as HTMLSelectElement).value = getCompactWidgetValue(binding.widget, fallback);
continue;
}
if (binding.kind === "number") {
const input = binding.control as HTMLInputElement;
const raw = Number(binding.widget.value ?? fallback ?? "");
input.value = Number.isFinite(raw) ? String(binding.integer ? Math.round(raw) : raw) : "";
continue;
}
if (binding.kind === "color") {
syncDarkColorInputUI(binding.control as HTMLInputElement, String(binding.widget.value ?? fallback ?? "#000000"));
continue;
}
(binding.control as HTMLInputElement).value = getCompactWidgetValue(binding.widget, fallback);
}
// No-op: Disabled in favor of native ComfyUI widgets to prevent masking input connection slots (INT/FLOAT)
}
export function getNodePreviewTargetSize(
@@ -499,7 +279,7 @@ export function ensurePreviewWidget(node: ComfyNode, progress: ProgressBus, canv
previewControls = createConstantControlsUi().controls;
} else if (grainNode) {
previewControls = createGrainControlsUi().controls;
previewControls = createGrainControlsUi(node).controls;
} else if (textNode) {
previewControls = createTextControlsUi().controls;
} else if (rampNode) {
@@ -793,11 +573,42 @@ export function ensurePreviewWidget(node: ComfyNode, progress: ProgressBus, canv
obs.observe(root, { attributes: true, attributeFilter: ["style"] });
state._displayObserverCleanup = () => obs.disconnect();
});
if (drawNode && drawClearButton) {
const clearWrap = document.createElement("div");
clearWrap.style.display = "flex";
clearWrap.style.justifyContent = "center";
clearWrap.style.marginTop = "8px";
clearWrap.style.marginBottom = "8px";
clearWrap.style.width = "100%";
clearWrap.appendChild(drawClearButton);
node.addDOMWidget("clear_action", "ImageOpsClear", clearWrap, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => 32,
});
const clearWidget = (node.widgets ?? []).find((widget) => widget?.name === "clear_action") as any;
if (clearWidget?.hidden !== false) {
clearWidget.hidden = false;
}
}
} else {
// Node 2.0 fallback: inject the root element directly into the node's DOM container.
const domEl = (node as any).domElement ?? (node as any).element;
if (domEl instanceof HTMLElement) {
domEl.appendChild(root);
if (drawNode && drawClearButton) {
const clearWrap = document.createElement("div");
clearWrap.style.display = "flex";
clearWrap.style.justifyContent = "center";
clearWrap.style.marginTop = "8px";
clearWrap.style.marginBottom = "8px";
clearWrap.style.width = "100%";
clearWrap.appendChild(drawClearButton);
domEl.appendChild(clearWrap);
}
} else {
console.warn("[ImageOps] addDOMWidget unavailable and no DOM container found on node", node.id);
}
+5 -9
View File
@@ -91,18 +91,16 @@ export function hideWidgetForGood(node: ComfyNode, widget: ComfyWidget | null, s
}
}
/**
* Hide every widget whose name matches. Useful when ComfyUI's Vue frontend
* adds a duplicate widget with the same name but a different (uppercase) type
* (e.g. `color` + `COLOR`, `boolean` + `BOOLEAN`).
*/
export function hideWidgetsByName(node: ComfyNode, name: string): void {
if (!node?.widgets) return;
for (const widget of node.widgets) {
if (widget?.name === name) hideWidgetForGood(node, widget);
if (widget?.name && widget.name.toLowerCase() === name.toLowerCase()) {
hideWidgetForGood(node, widget);
}
}
}
export function setWidgetValue(widget: ComfyWidget | null, value: number, options: WidgetSetOptions = {}): void {
if (!widget) return;
const notify = options.notify !== false;
@@ -209,9 +207,7 @@ export function listCompactUiWidgets(node: ComfyNode): ComfyWidget[] {
}
export function hideCompactUiWidgets(node: ComfyNode): void {
for (const widget of listCompactUiWidgets(node)) {
hideWidgetForGood(node, widget);
}
// No-op: Disabled in favor of native ComfyUI widgets to prevent masking input connection slots (INT/FLOAT)
}
function cloneDefaultValue<T>(value: T): T {