From f1e74cef9294167ba9be36da2e580b5c2202fbe8 Mon Sep 17 00:00:00 2001 From: Ewald <129623145+MajoorWaldi@users.noreply.github.com> Date: Tue, 26 May 2026 07:52:30 +0200 Subject: [PATCH] feat: Nodes Ui Refacto --- js/preview/host.js | 201 +++++++++++-- js/preview/interactions/comp.js | 22 +- js/preview/interactions/constant.js | 22 -- js/preview/interactions/draw.js | 111 +++---- js/preview/nodes/comp.js | 73 +++-- js/preview/nodes/constant.js | 67 +++-- js/preview/nodes/draw.js | 371 ++++++++++++----------- js/preview/nodes/grain.js | 176 ++--------- js/preview/nodes/keyer.js | 191 ++++++------ js/preview/nodes/pad-out.js | 128 +------- js/preview/nodes/ramp.js | 213 +------------- js/preview/nodes/text.js | 241 +-------------- js/preview/ops.js | 4 +- js/preview/shared/dom-styles.js | 194 +++--------- js/preview/shared/navigation.js | 6 +- js/preview/shared/preview-widget.js | 235 ++------------- js/preview/shared/widgets.js | 7 +- nodes/comp.py | 23 +- nodes/constant.py | 8 +- nodes/draw.py | 8 +- nodes/padout.py | 8 +- src/preview/host.ts | 198 +++++++++++-- src/preview/interactions/comp.ts | 19 +- src/preview/interactions/constant.ts | 25 +- src/preview/interactions/draw.ts | 124 ++++---- src/preview/nodes/comp.ts | 77 +++-- src/preview/nodes/constant.ts | 66 +++-- src/preview/nodes/draw.ts | 423 ++++++++++++++------------- src/preview/nodes/grain.ts | 182 ++---------- src/preview/nodes/keyer.ts | 235 ++++++++------- src/preview/nodes/pad-out.ts | 145 +-------- src/preview/nodes/ramp.ts | 232 +-------------- src/preview/nodes/text.ts | 254 +--------------- src/preview/ops.ts | 18 +- src/preview/shared/dom-styles.ts | 218 +++----------- src/preview/shared/navigation.ts | 9 +- src/preview/shared/preview-widget.ts | 257 +++------------- src/preview/shared/widgets.ts | 14 +- 38 files changed, 1589 insertions(+), 3216 deletions(-) diff --git a/js/preview/host.js b/js/preview/host.js index 3e8917e..5b5d719 100644 --- a/js/preview/host.js +++ b/js/preview/host.js @@ -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(() => { diff --git a/js/preview/interactions/comp.js b/js/preview/interactions/comp.js index 945f2a1..2cd055c 100644 --- a/js/preview/interactions/comp.js +++ b/js/preview/interactions/comp.js @@ -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"; diff --git a/js/preview/interactions/constant.js b/js/preview/interactions/constant.js index 73b4ad7..8d04275 100644 --- a/js/preview/interactions/constant.js +++ b/js/preview/interactions/constant.js @@ -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 { diff --git a/js/preview/interactions/draw.js b/js/preview/interactions/draw.js index 7eacb57..82e165a 100644 --- a/js/preview/interactions/draw.js +++ b/js/preview/interactions/draw.js @@ -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); diff --git a/js/preview/nodes/comp.js b/js/preview/nodes/comp.js index 0e7a7d3..8df2c26 100644 --- a/js/preview/nodes/comp.js +++ b/js/preview/nodes/comp.js @@ -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, diff --git a/js/preview/nodes/constant.js b/js/preview/nodes/constant.js index dfe265f..3540041 100644 --- a/js/preview/nodes/constant.js +++ b/js/preview/nodes/constant.js @@ -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]"); diff --git a/js/preview/nodes/draw.js b/js/preview/nodes/draw.js index d9b814f..045e24a 100644 --- a/js/preview/nodes/draw.js +++ b/js/preview/nodes/draw.js @@ -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 diff --git a/js/preview/nodes/grain.js b/js/preview/nodes/grain.js index 0dc5b87..0b78bc7 100644 --- a/js/preview/nodes/grain.js +++ b/js/preview/nodes/grain.js @@ -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, diff --git a/js/preview/nodes/keyer.js b/js/preview/nodes/keyer.js index 73db328..906b648 100644 --- a/js/preview/nodes/keyer.js +++ b/js/preview/nodes/keyer.js @@ -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) { diff --git a/js/preview/nodes/pad-out.js b/js/preview/nodes/pad-out.js index 5167b36..77a7b41 100644 --- a/js/preview/nodes/pad-out.js +++ b/js/preview/nodes/pad-out.js @@ -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; diff --git a/js/preview/nodes/ramp.js b/js/preview/nodes/ramp.js index d3af0e5..c31ba1f 100644 --- a/js/preview/nodes/ramp.js +++ b/js/preview/nodes/ramp.js @@ -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, diff --git a/js/preview/nodes/text.js b/js/preview/nodes/text.js index 2206df0..a79f7b0 100644 --- a/js/preview/nodes/text.js +++ b/js/preview/nodes/text.js @@ -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, diff --git a/js/preview/ops.js b/js/preview/ops.js index 0f6a1cc..a504248 100644 --- a/js/preview/ops.js +++ b/js/preview/ops.js @@ -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) { diff --git a/js/preview/shared/dom-styles.js b/js/preview/shared/dom-styles.js index 07c8e63..e787101 100644 --- a/js/preview/shared/dom-styles.js +++ b/js/preview/shared/dom-styles.js @@ -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"; diff --git a/js/preview/shared/navigation.js b/js/preview/shared/navigation.js index 82eeadc..d5d104e 100644 --- a/js/preview/shared/navigation.js +++ b/js/preview/shared/navigation.js @@ -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(); diff --git a/js/preview/shared/preview-widget.js b/js/preview/shared/preview-widget.js index 6951f89..3b60fed 100644 --- a/js/preview/shared/preview-widget.js +++ b/js/preview/shared/preview-widget.js @@ -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); } diff --git a/js/preview/shared/widgets.js b/js/preview/shared/widgets.js index a947abf..0f7a5f3 100644 --- a/js/preview/shared/widgets.js +++ b/js/preview/shared/widgets.js @@ -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; diff --git a/nodes/comp.py b/nodes/comp.py index 93de59d..1b051c8 100644 --- a/nodes/comp.py +++ b/nodes/comp.py @@ -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, diff --git a/nodes/constant.py b/nodes/constant.py index 4fc0775..be384db 100644 --- a/nodes/constant.py +++ b/nodes/constant.py @@ -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))) diff --git a/nodes/draw.py b/nodes/draw.py index af4603e..e11c6cb 100644 --- a/nodes/draw.py +++ b/nodes/draw.py @@ -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, diff --git a/nodes/padout.py b/nodes/padout.py index 46e0d04..d94ad45 100644 --- a/nodes/padout.py +++ b/nodes/padout.py @@ -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, diff --git a/src/preview/host.ts b/src/preview/host.ts index 5d887c2..3edb597 100644 --- a/src/preview/host.ts +++ b/src/preview/host.ts @@ -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. diff --git a/src/preview/interactions/comp.ts b/src/preview/interactions/comp.ts index 50fc5c8..e3b2e24 100644 --- a/src/preview/interactions/comp.ts +++ b/src/preview/interactions/comp.ts @@ -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) => { diff --git a/src/preview/interactions/constant.ts b/src/preview/interactions/constant.ts index f45b92a..7688b36 100644 --- a/src/preview/interactions/constant.ts +++ b/src/preview/interactions/constant.ts @@ -28,24 +28,7 @@ export function attachInteractions(node: ComfyNode, ctx: NodeInteractionContext) ctx.refreshNode(node); }; - const ratioSelect = root.querySelector('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('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); } diff --git a/src/preview/interactions/draw.ts b/src/preview/interactions/draw.ts index 8af21ab..32376e8 100644 --- a/src/preview/interactions/draw.ts +++ b/src/preview/interactions/draw.ts @@ -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); diff --git a/src/preview/nodes/comp.ts b/src/preview/nodes/comp.ts index ee62e16..9ed22fe 100644 --- a/src/preview/nodes/comp.ts +++ b/src/preview/nodes/comp.ts @@ -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" diff --git a/src/preview/nodes/constant.ts b/src/preview/nodes/constant.ts index de534fb..e1e44b4 100644 --- a/src/preview/nodes/constant.ts +++ b/src/preview/nodes/constant.ts @@ -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('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('input[data-constant-field="alpha"]'); if (alphaInput) alphaInput.value = String(Math.round(alpha * 100)); diff --git a/src/preview/nodes/draw.ts b/src/preview/nodes/draw.ts index 65fb66b..55a7b39 100644 --- a/src/preview/nodes/draw.ts +++ b/src/preview/nodes/draw.ts @@ -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); diff --git a/src/preview/nodes/grain.ts b/src/preview/nodes/grain.ts index 6071f88..063a634 100644 --- a/src/preview/nodes/grain.ts +++ b/src/preview/nodes/grain.ts @@ -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('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); } diff --git a/src/preview/nodes/keyer.ts b/src/preview/nodes/keyer.ts index f8dc254..cfff016 100644 --- a/src/preview/nodes/keyer.ts +++ b/src/preview/nodes/keyer.ts @@ -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) { diff --git a/src/preview/nodes/pad-out.ts b/src/preview/nodes/pad-out.ts index 7f7c1ce..61862cf 100644 --- a/src/preview/nodes/pad-out.ts +++ b/src/preview/nodes/pad-out.ts @@ -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(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); } @@ -377,28 +286,6 @@ export function attachPadOutControls(node: ComfyNode, ctx: NodeInteractionContex ctx.refreshNode(node); }; - control(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(node, "anchor_select")?.addEventListener("change", (event) => { - applyPadOutAnchor(node, (event.currentTarget as HTMLSelectElement).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 as any; st.previewZoom = 1; diff --git a/src/preview/nodes/ramp.ts b/src/preview/nodes/ramp.ts index 69bd98e..f0b1697 100644 --- a/src/preview/nodes/ramp.ts +++ b/src/preview/nodes/ramp.ts @@ -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(`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); } diff --git a/src/preview/nodes/text.ts b/src/preview/nodes/text.ts index a932217..47e2c95 100644 --- a/src/preview/nodes/text.ts +++ b/src/preview/nodes/text.ts @@ -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(`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); } diff --git a/src/preview/ops.ts b/src/preview/ops.ts index e98426a..059ea96 100644 --- a/src/preview/ops.ts +++ b/src/preview/ops.ts @@ -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 diff --git a/src/preview/shared/dom-styles.ts b/src/preview/shared/dom-styles.ts index fbd520c..7a972f8 100644 --- a/src/preview/shared/dom-styles.ts +++ b/src/preview/shared/dom-styles.ts @@ -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"; diff --git a/src/preview/shared/navigation.ts b/src/preview/shared/navigation.ts index 05cfc02..59d9b34 100644 --- a/src/preview/shared/navigation.ts +++ b/src/preview/shared/navigation.ts @@ -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(); }); } + diff --git a/src/preview/shared/preview-widget.ts b/src/preview/shared/preview-widget.ts index 5d3ecd1..cd5399f 100644 --- a/src/preview/shared/preview-widget.ts +++ b/src/preview/shared/preview-widget.ts @@ -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(); - - 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
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); } diff --git a/src/preview/shared/widgets.ts b/src/preview/shared/widgets.ts index 808a88b..6308f12 100644 --- a/src/preview/shared/widgets.ts +++ b/src/preview/shared/widgets.ts @@ -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(value: T): T {