feat: Nodes Ui Refacto
This commit is contained in:
+172
-29
@@ -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(() => {
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user