From 102590c2c2d8525bfce98533dbd09a7cf34d6a62 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Mon, 9 Jun 2025 01:35:06 -0700 Subject: [PATCH] Add Vue Color Picker widget (#4114) --- .../graph/widgets/ColorPickerWidget.vue | 551 ++++++++++++++++++ .../widgets/useColorPickerWidget.ts | 207 +++++++ src/locales/en/main.json | 10 + src/scripts/widgets.ts | 4 +- .../composables/useColorPickerWidget.test.ts | 80 +++ 5 files changed, 851 insertions(+), 1 deletion(-) create mode 100644 src/components/graph/widgets/ColorPickerWidget.vue create mode 100644 src/composables/widgets/useColorPickerWidget.ts create mode 100644 tests-ui/tests/composables/useColorPickerWidget.test.ts diff --git a/src/components/graph/widgets/ColorPickerWidget.vue b/src/components/graph/widgets/ColorPickerWidget.vue new file mode 100644 index 000000000..5d3c9bb7c --- /dev/null +++ b/src/components/graph/widgets/ColorPickerWidget.vue @@ -0,0 +1,551 @@ + + + + + diff --git a/src/composables/widgets/useColorPickerWidget.ts b/src/composables/widgets/useColorPickerWidget.ts new file mode 100644 index 000000000..d6f05770f --- /dev/null +++ b/src/composables/widgets/useColorPickerWidget.ts @@ -0,0 +1,207 @@ +import type { LGraphNode } from '@comfyorg/litegraph' +import { ref } from 'vue' + +import ColorPickerWidget from '@/components/graph/widgets/ColorPickerWidget.vue' +import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' +import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget' +import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes' + +const PADDING = 8 + +interface ColorPickerWidgetOptions { + defaultValue?: string + defaultFormat?: 'rgba' | 'hsla' | 'hsva' | 'hex' + minHeight?: number + serialize?: boolean +} + +export const useColorPickerWidget = ( + options: ColorPickerWidgetOptions = {} +) => { + const { + defaultValue = 'rgba(255, 0, 0, 1)', + minHeight = 48, + serialize = true + } = options + + const widgetConstructor: ComfyWidgetConstructorV2 = ( + node: LGraphNode, + inputSpec: InputSpec + ) => { + // Initialize widget value as string + const widgetValue = ref(defaultValue) + + // Create the main widget instance + const widget = new ComponentWidgetImpl({ + node, + name: inputSpec.name, + component: ColorPickerWidget, + inputSpec, + options: { + // Required: getter for widget value + getValue: () => widgetValue.value, + + // Required: setter for widget value + setValue: (value: string | any) => { + // Handle different input types + if (typeof value === 'string') { + // Validate and normalize color string + const normalizedValue = normalizeColorString(value) + if (normalizedValue) { + widgetValue.value = normalizedValue + } + } else if (typeof value === 'object' && value !== null) { + // Handle object input (e.g., from PrimeVue ColorPicker) + if (value.hex) { + widgetValue.value = value.hex + } else { + // Try to convert object to string + widgetValue.value = String(value) + } + } else { + // Fallback to string conversion + widgetValue.value = String(value) + } + }, + + // Optional: minimum height for the widget + getMinHeight: () => minHeight + PADDING, + + // Optional: whether to serialize this widget's value + serialize + } + }) + + // Register the widget with the node + addWidget(node, widget as any) + + return widget + } + + return widgetConstructor +} + +/** + * Normalizes color string inputs to ensure consistent format + * @param colorString - The input color string + * @returns Normalized color string or null if invalid + */ +function normalizeColorString(colorString: string): string | null { + if (!colorString || typeof colorString !== 'string') { + return null + } + + const trimmed = colorString.trim() + + // Handle hex colors + if (trimmed.startsWith('#')) { + if (/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(trimmed)) { + // Convert 3-digit hex to 6-digit + if (trimmed.length === 4) { + return ( + '#' + + trimmed[1] + + trimmed[1] + + trimmed[2] + + trimmed[2] + + trimmed[3] + + trimmed[3] + ) + } + return trimmed.toLowerCase() + } + return null + } + + // Handle rgb/rgba colors + if (trimmed.startsWith('rgb')) { + const rgbaMatch = trimmed.match( + /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)/ + ) + if (rgbaMatch) { + const [, r, g, b, a] = rgbaMatch + const red = Math.max(0, Math.min(255, parseInt(r))) + const green = Math.max(0, Math.min(255, parseInt(g))) + const blue = Math.max(0, Math.min(255, parseInt(b))) + const alpha = a ? Math.max(0, Math.min(1, parseFloat(a))) : 1 + + if (alpha === 1) { + return `rgb(${red}, ${green}, ${blue})` + } else { + return `rgba(${red}, ${green}, ${blue}, ${alpha})` + } + } + return null + } + + // Handle hsl/hsla colors + if (trimmed.startsWith('hsl')) { + const hslaMatch = trimmed.match( + /hsla?\(\s*(\d+)\s*,\s*(\d+)%?\s*,\s*(\d+)%?\s*(?:,\s*([\d.]+))?\s*\)/ + ) + if (hslaMatch) { + const [, h, s, l, a] = hslaMatch + const hue = Math.max(0, Math.min(360, parseInt(h))) + const saturation = Math.max(0, Math.min(100, parseInt(s))) + const lightness = Math.max(0, Math.min(100, parseInt(l))) + const alpha = a ? Math.max(0, Math.min(1, parseFloat(a))) : 1 + + if (alpha === 1) { + return `hsl(${hue}, ${saturation}%, ${lightness}%)` + } else { + return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})` + } + } + return null + } + + // Handle hsv/hsva colors (custom format) + if (trimmed.startsWith('hsv')) { + const hsvaMatch = trimmed.match( + /hsva?\(\s*(\d+)\s*,\s*(\d+)%?\s*,\s*(\d+)%?\s*(?:,\s*([\d.]+))?\s*\)/ + ) + if (hsvaMatch) { + const [, h, s, v, a] = hsvaMatch + const hue = Math.max(0, Math.min(360, parseInt(h))) + const saturation = Math.max(0, Math.min(100, parseInt(s))) + const value = Math.max(0, Math.min(100, parseInt(v))) + const alpha = a ? Math.max(0, Math.min(1, parseFloat(a))) : 1 + + if (alpha === 1) { + return `hsv(${hue}, ${saturation}%, ${value}%)` + } else { + return `hsva(${hue}, ${saturation}%, ${value}%, ${alpha})` + } + } + return null + } + + // Handle named colors by converting to hex (basic set) + const namedColors: Record = { + red: '#ff0000', + green: '#008000', + blue: '#0000ff', + white: '#ffffff', + black: '#000000', + yellow: '#ffff00', + cyan: '#00ffff', + magenta: '#ff00ff', + orange: '#ffa500', + purple: '#800080', + pink: '#ffc0cb', + brown: '#a52a2a', + gray: '#808080', + grey: '#808080' + } + + const lowerTrimmed = trimmed.toLowerCase() + if (namedColors[lowerTrimmed]) { + return namedColors[lowerTrimmed] + } + + // If we can't parse it, return null + return null +} + +// Export types for use in other modules +export type { ColorPickerWidgetOptions } diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 8367583dd..52cb75d5f 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -230,6 +230,16 @@ "black": "Black", "custom": "Custom" }, + "widgets": { + "colorPicker": { + "clickToEdit": "Click to edit color", + "selectColor": "Select a color", + "formatRGBA": "RGBA", + "formatHSLA": "HSLA", + "formatHSVA": "HSVA", + "formatHEX": "HEX" + } + }, "contextMenu": { "Inputs": "Inputs", "Outputs": "Outputs", diff --git a/src/scripts/widgets.ts b/src/scripts/widgets.ts index 41a58c935..296a7dd25 100644 --- a/src/scripts/widgets.ts +++ b/src/scripts/widgets.ts @@ -7,6 +7,7 @@ import type { import { useBadgedNumberInput } from '@/composables/widgets/useBadgedNumberInput' import { useBooleanWidget } from '@/composables/widgets/useBooleanWidget' +import { useColorPickerWidget } from '@/composables/widgets/useColorPickerWidget' import { useComboWidget } from '@/composables/widgets/useComboWidget' import { useImageUploadWidget } from '@/composables/widgets/useImageUploadWidget' import { useMarkdownWidget } from '@/composables/widgets/useMarkdownWidget' @@ -288,5 +289,6 @@ export const ComfyWidgets: Record = { MARKDOWN: transformWidgetConstructorV2ToV1(useMarkdownWidget()), COMBO: transformWidgetConstructorV2ToV1(useComboWidget()), IMAGEUPLOAD: useImageUploadWidget(), - BADGED_NUMBER: transformWidgetConstructorV2ToV1(useBadgedNumberInput()) + BADGED_NUMBER: transformWidgetConstructorV2ToV1(useBadgedNumberInput()), + COLOR: transformWidgetConstructorV2ToV1(useColorPickerWidget()) } diff --git a/tests-ui/tests/composables/useColorPickerWidget.test.ts b/tests-ui/tests/composables/useColorPickerWidget.test.ts new file mode 100644 index 000000000..528e9730d --- /dev/null +++ b/tests-ui/tests/composables/useColorPickerWidget.test.ts @@ -0,0 +1,80 @@ +import type { LGraphNode } from '@comfyorg/litegraph' +import { describe, expect, it, vi } from 'vitest' + +import { useColorPickerWidget } from '@/composables/widgets/useColorPickerWidget' +import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' + +// Mock dependencies +vi.mock('@/scripts/domWidget', () => ({ + ComponentWidgetImpl: vi.fn().mockImplementation((config) => ({ + ...config, + name: config.name, + options: { + ...config.options, + getValue: config.options.getValue, + setValue: config.options.setValue, + getMinHeight: config.options.getMinHeight + } + })), + addWidget: vi.fn() +})) + +describe('useColorPickerWidget', () => { + const createMockNode = (): LGraphNode => + ({ + id: 1, + title: 'Test Node', + widgets: [], + addWidget: vi.fn() + }) as any + + const createInputSpec = (overrides: Partial = {}): InputSpec => ({ + name: 'color', + type: 'COLOR', + ...overrides + }) + + it('creates widget constructor with default options', () => { + const constructor = useColorPickerWidget() + expect(constructor).toBeDefined() + expect(typeof constructor).toBe('function') + }) + + it('creates widget with default options', () => { + const constructor = useColorPickerWidget() + const node = createMockNode() + const inputSpec = createInputSpec() + + const widget = constructor(node, inputSpec) + + expect(widget).toBeDefined() + expect(widget.name).toBe(inputSpec.name) + }) + + it('creates widget with custom default value', () => { + const constructor = useColorPickerWidget({ + defaultValue: '#00ff00' + }) + const node = createMockNode() + const inputSpec = createInputSpec() + + const widget = constructor(node, inputSpec) + + expect(widget).toBeDefined() + expect(widget.name).toBe(inputSpec.name) + }) + + it('creates widget with custom options', () => { + const constructor = useColorPickerWidget({ + minHeight: 60, + serialize: false + }) + const node = createMockNode() + const inputSpec = createInputSpec() + + const widget = constructor(node, inputSpec) + + expect(widget).toBeDefined() + expect(widget.name).toBe(inputSpec.name) + }) +})