diff --git a/browser_tests/colorPalette.spec.ts b/browser_tests/colorPalette.spec.ts index 2885d7289..8caca6600 100644 --- a/browser_tests/colorPalette.spec.ts +++ b/browser_tests/colorPalette.spec.ts @@ -150,4 +150,20 @@ test.describe('Color Palette', () => { await comfyPage.nextFrame() await expect(comfyPage.canvas).toHaveScreenshot('default-color-palette.png') }) + + test('Can change node opacity setting', async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.Node.Opacity', 0.5) + await comfyPage.page.waitForTimeout(128) + + // Drag mouse to force canvas to redraw + await comfyPage.page.mouse.move(0, 0) + + await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-0.5.png') + + await comfyPage.setSetting('Comfy.Node.Opacity', 1.0) + await comfyPage.page.waitForTimeout(128) + + await comfyPage.page.mouse.move(8, 8) + await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-1.png') + }) }) diff --git a/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-5-chromium-2x-linux.png b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-5-chromium-2x-linux.png new file mode 100644 index 000000000..22d252522 Binary files /dev/null and b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-5-chromium-2x-linux.png differ diff --git a/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-5-chromium-linux.png b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-5-chromium-linux.png new file mode 100644 index 000000000..03bd2fba0 Binary files /dev/null and b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-0-5-chromium-linux.png differ diff --git a/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-1-chromium-2x-linux.png b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-1-chromium-2x-linux.png new file mode 100644 index 000000000..242929e52 Binary files /dev/null and b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-1-chromium-2x-linux.png differ diff --git a/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-1-chromium-linux.png b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-1-chromium-linux.png new file mode 100644 index 000000000..f6d7b3540 Binary files /dev/null and b/browser_tests/colorPalette.spec.ts-snapshots/node-opacity-1-chromium-linux.png differ diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index 6cacc803f..cac9d9342 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -42,6 +42,9 @@ import { import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes' import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore' import { useCanvasStore } from '@/stores/graphStore' +import { applyOpacity } from '@/utils/colorUtil' +import { getColorPalette } from '@/extensions/core/colorPalette' +import { debounce } from 'lodash' const emit = defineEmits(['ready']) const canvasRef = ref(null) @@ -90,6 +93,24 @@ watchEffect(() => { }) }) +const updateNodeOpacity = (nodeOpacity: number) => { + const colorPalette = getColorPalette() + + if (!canvasStore.canvas) return + + const nodeBgColor = colorPalette?.colors?.litegraph_base?.NODE_DEFAULT_BGCOLOR + if (nodeBgColor) { + LiteGraph.NODE_DEFAULT_BGCOLOR = applyOpacity(nodeBgColor, nodeOpacity) + } +} + +const debouncedUpdateNodeOpacity = debounce(updateNodeOpacity, 128) + +watchEffect(() => { + const nodeOpacity = settingStore.get('Comfy.Node.Opacity') + debouncedUpdateNodeOpacity(nodeOpacity) +}) + let dropTargetCleanup = () => {} onMounted(async () => { diff --git a/src/extensions/core/colorPalette.ts b/src/extensions/core/colorPalette.ts index fa67fca87..52bdc90a0 100644 --- a/src/extensions/core/colorPalette.ts +++ b/src/extensions/core/colorPalette.ts @@ -2,6 +2,8 @@ import { app } from '../../scripts/app' import { $el } from '../../scripts/ui' import type { ColorPalettes, Palette } from '@/types/colorPalette' import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph' +import { applyOpacity } from '@/utils/colorUtil' +import { useSettingStore } from '@/stores/settingStore' // Manage color palettes @@ -682,7 +684,13 @@ app.registerExtension({ colorPalette.colors.litegraph_base.hasOwnProperty(key) && LiteGraph.hasOwnProperty(key) ) { - LiteGraph[key] = colorPalette.colors.litegraph_base[key] + LiteGraph[key] = + key === 'NODE_DEFAULT_BGCOLOR' + ? applyOpacity( + colorPalette.colors.litegraph_base[key], + useSettingStore().get('Comfy.Node.Opacity') + ) + : colorPalette.colors.litegraph_base[key] } } } diff --git a/src/stores/coreSettings.ts b/src/stores/coreSettings.ts index f9bf4e613..0c7c2b41a 100644 --- a/src/stores/coreSettings.ts +++ b/src/stores/coreSettings.ts @@ -139,6 +139,17 @@ export const CORE_SETTINGS: SettingParams[] = [ type: 'boolean', defaultValue: true }, + { + id: 'Comfy.Node.Opacity', + name: 'Node opacity', + type: 'slider', + defaultValue: 1, + attrs: { + min: 0.01, + max: 1, + step: 0.01 + } + }, { id: 'Comfy.Workflow.ShowMissingNodesWarning', name: 'Show missing nodes warning', diff --git a/src/utils/colorUtil.ts b/src/utils/colorUtil.ts index 6f377ff68..12d0b188f 100644 --- a/src/utils/colorUtil.ts +++ b/src/utils/colorUtil.ts @@ -1,5 +1,6 @@ type RGB = { r: number; g: number; b: number } type HSL = { h: number; s: number; l: number } +type ColorFormat = 'hex' | 'rgb' | 'rgba' | 'hsl' | 'hsla' function rgbToHsl({ r, g, b }: RGB): HSL { r /= 255 @@ -92,6 +93,16 @@ function rgbToHex({ r, g, b }: RGB): string { ) } +function identifyColorFormat(color: string): ColorFormat | null { + if (!color) return null + if (color.startsWith('#')) return 'hex' + if (/^rgba?\(\d+,\s*\d+,\s*\d+/.test(color)) + return color.includes('rgba') ? 'rgba' : 'rgb' + if (/^hsla?\(\d+(\.\d+)?,\s*\d+(\.\d+)?%,\s*\d+(\.\d+)?%/.test(color)) + return color.includes('hsla') ? 'hsla' : 'hsl' + return null +} + export function lightenColor(hex: string, amount: number): string { let rgb = hexToRgb(hex) const hsl = rgbToHsl(rgb) @@ -99,3 +110,46 @@ export function lightenColor(hex: string, amount: number): string { rgb = hslToRgb(hsl) return rgbToHex(rgb) } + +export function applyOpacity(color: string, opacity: number): string { + const colorFormat = identifyColorFormat(color) + + if (!colorFormat) { + console.warn( + `Unsupported color format in user color palette for color: ${color}` + ) + return color + } + + const clampedOpacity = Math.max(0, Math.min(1, opacity)) + + switch (colorFormat) { + case 'hex': { + const { r, g, b } = hexToRgb(color) + if (isNaN(r) || isNaN(g) || isNaN(b)) { + return color + } + return `rgba(${r}, ${g}, ${b}, ${clampedOpacity})` + } + case 'rgb': { + return color.replace('rgb', 'rgba').replace(')', `, ${clampedOpacity})`) + } + case 'rgba': { + return color.replace( + /rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,[^)]+\)/, + `rgba($1, $2, $3, ${clampedOpacity})` + ) + } + case 'hsl': { + return color.replace('hsl', 'hsla').replace(')', `, ${clampedOpacity})`) + } + case 'hsla': { + return color.replace( + /hsla\(\s*(\d+)\s*,\s*(\d+(?:\.\d+)?)%\s*,\s*(\d+(?:\.\d+)?)%\s*,[^)]+\)/, + `hsla($1, $2%, $3%, ${clampedOpacity})` + ) + } + default: + return color + } +} diff --git a/tests-ui/tests/colorUtil.test.ts b/tests-ui/tests/colorUtil.test.ts new file mode 100644 index 000000000..5c8fd5522 --- /dev/null +++ b/tests-ui/tests/colorUtil.test.ts @@ -0,0 +1,50 @@ +import { applyOpacity } from '@/utils/colorUtil' + +describe('colorUtil - applyOpacity', () => { + // Same color in various formats + const solarized = { + hex: '#073642', + rgb: 'rgb(7, 54, 66)', + rgba: 'rgba(7, 54, 66, 1)', + hsl: 'hsl(192, 80.80%, 14.30%)', + hsla: 'hsla(192, 80.80%, 14.30%, 1)' + } + + const opacity = 0.5 + + it('applies opacity consistently to hex, rgb, and rgba formats', () => { + const hexResult = applyOpacity(solarized.hex, opacity) + const rgbResult = applyOpacity(solarized.rgb, opacity) + const rgbaResult = applyOpacity(solarized.rgba, opacity) + + expect(hexResult).toBe(rgbResult) + expect(rgbResult).toBe(rgbaResult) + }) + + it('applies opacity consistently to hsl and hsla formats', () => { + const hslResult = applyOpacity(solarized.hsl, opacity) + const hslaResult = applyOpacity(solarized.hsla, opacity) + + expect(hslResult).toBe(hslaResult) + }) + + it('returns the original value for invalid color formats', () => { + const invalidColors = [ + '#GGGGGG', // Invalid hex code (non-hex characters) + 'rgb(300, -10, 256)', // Invalid RGB values (out of range) + 'xyz(255, 255, 255)', // Unsupported format + 'rgba(255, 255, 255)', // Missing alpha in RGBA + 'hsl(100, 50, 50%)' // Missing percentage sign for saturation + ] + + invalidColors.forEach((color) => { + const result = applyOpacity(color, opacity) + expect(result).toBe(color) + }) + }) + + it('returns the original value for null or undefined inputs', () => { + expect(applyOpacity(null, opacity)).toBe(null) + expect(applyOpacity(undefined, opacity)).toBe(undefined) + }) +})