diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue index 0d87aaf5b1..f71d2ad002 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue @@ -29,7 +29,7 @@ import { computed } from 'vue' import { useWidgetValue } from '@/composables/graph/useWidgetValue' import type { SimplifiedWidget } from '@/types/simplifiedWidget' -import { hsbToRgb, parseToRgb, rgbToHex } from '@/utils/colorUtil' +import { normalizeColorToHex } from '@/utils/colorUtil' import { cn } from '@/utils/tailwindUtil' import { PANEL_EXCLUDED_PROPS, @@ -49,59 +49,13 @@ const emit = defineEmits<{ 'update:modelValue': [value: string] }>() -// Use the composable for consistent widget value handling -function normalizeToHexWithHash(value: unknown): string { - if (typeof value === 'string') { - const raw = value.trim() - // Bare hex without '#' - if (/^[0-9a-fA-F]{3}$/.test(raw) || /^[0-9a-fA-F]{6}$/.test(raw)) { - return `#${raw.toLowerCase()}` - } - // If starts with '#', ensure lower-case and valid length - if (raw.startsWith('#')) { - const hex = raw.toLowerCase() - if (hex.length === 4 || hex.length === 7) return hex - // Fallback: attempt parse via RGB and re-encode - } - // rgb(), rgba(), hsl(), hsla() - if (/^(rgb|rgba|hsl|hsla)\(/i.test(raw)) { - const rgb = parseToRgb(raw) - return rgbToHex(rgb).toLowerCase() - } - // hsb(h,s,b) - if (/^hsb\(/i.test(raw)) { - const nums = raw.match(/\d+(?:\.\d+)?/g)?.map(Number) || [] - if (nums.length >= 3) { - const rgb = hsbToRgb({ h: nums[0], s: nums[1], b: nums[2] }) - return rgbToHex(rgb).toLowerCase() - } - } - } - // HSB object from PrimeVue - if ( - value && - typeof value === 'object' && - 'h' in (value as any) && - 's' in (value as any) && - ('b' in (value as any) || 'v' in (value as any)) - ) { - const h = Number((value as any).h) - const s = Number((value as any).s) - const b = Number((value as any).b ?? (value as any).v) - const rgb = hsbToRgb({ h, s, b }) - return rgbToHex(rgb).toLowerCase() - } - // Fallback to default black - return '#000000' -} - const { localValue, onChange } = useWidgetValue({ widget: props.widget, // Normalize initial model value to ensure leading '#' - modelValue: normalizeToHexWithHash(props.modelValue), + modelValue: normalizeColorToHex(props.modelValue), defaultValue: '#000000', emit, - transform: (val: unknown) => normalizeToHexWithHash(val) + transform: (val: unknown) => normalizeColorToHex(val) }) // ColorPicker specific excluded props include panel/overlay classes diff --git a/src/utils/colorUtil.ts b/src/utils/colorUtil.ts index 2acbccc366..7033b16982 100644 --- a/src/utils/colorUtil.ts +++ b/src/utils/colorUtil.ts @@ -115,6 +115,73 @@ export function hsbToRgb({ h, s, b }: HSB): RGB { } } +/** + * Normalize various color inputs (hex, rgb/rgba, hsl/hsla, hsb string/object) + * into lowercase #rrggbb. Falls back to #000000 on invalid inputs. + */ +export function normalizeColorToHex(value: unknown): string { + // String inputs + if (typeof value === 'string') { + const raw = value.trim() + if (!raw) return '#000000' + + // Bare hex without '#' + if (/^[0-9a-fA-F]{3}$/.test(raw) || /^[0-9a-fA-F]{6}$/.test(raw)) { + return `#${raw.toLowerCase()}` + } + + // Starts with '#': only accept #rgb or #rrggbb + if (raw.startsWith('#')) { + const hex = raw.toLowerCase() + if (hex.length === 4 || hex.length === 7) return hex + return '#000000' + } + + // rgb(), rgba(), hsl(), hsla() + if (/^(rgb|rgba|hsl|hsla)\(/i.test(raw)) { + const rgb = parseToRgb(raw) + return rgbToHex(rgb).toLowerCase() + } + + // hsb(h,s,b) + if (/^hsb\(/i.test(raw)) { + const nums = raw.match(/\d+(?:\.\d+)?/g)?.map(Number) || [] + if (nums.length >= 3) { + const [h, s, b] = nums + if ([h, s, b].every((n) => Number.isFinite(n))) { + const rgb = hsbToRgb({ h, s, b }) + return rgbToHex(rgb).toLowerCase() + } + } + return '#000000' + } + + // Unknown string format -> default + return '#000000' + } + + // HSB object (PrimeVue) + if ( + value && + typeof value === 'object' && + 'h' in (value as any) && + 's' in (value as any) && + ('b' in (value as any) || 'v' in (value as any)) + ) { + const h = Number((value as any).h) + const s = Number((value as any).s) + const b = Number((value as any).b ?? (value as any).v) + if ([h, s, b].every((n) => Number.isFinite(n))) { + const rgb = hsbToRgb({ h, s, b }) + return rgbToHex(rgb).toLowerCase() + } + return '#000000' + } + + // Fallback + return '#000000' +} + export function parseToRgb(color: string): RGB { const format = identifyColorFormat(color) if (!format) return { r: 0, g: 0, b: 0 } diff --git a/tests-ui/utils/colorUtil.test.ts b/tests-ui/utils/colorUtil.test.ts index 5922deb47e..abc1927701 100644 --- a/tests-ui/utils/colorUtil.test.ts +++ b/tests-ui/utils/colorUtil.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest' -import { hexToRgb, hsbToRgb, parseToRgb, rgbToHex } from '@/utils/colorUtil' +import { + hexToRgb, + hsbToRgb, + normalizeColorToHex, + parseToRgb, + rgbToHex +} from '@/utils/colorUtil' describe('colorUtil conversions', () => { describe('hexToRgb / rgbToHex', () => { @@ -69,4 +75,31 @@ describe('colorUtil conversions', () => { expect(rgbToHex(rgb)).toBe('#7f0000') }) }) + + describe('normalizeColorToHex (guard rails)', () => { + it('returns #hex for common inputs and falls back to black', () => { + expect(normalizeColorToHex('#FFaa00')).toBe('#ffaa00') + expect(normalizeColorToHex('ffaa00')).toBe('#ffaa00') + expect(normalizeColorToHex('rgb(300, -5, 16)')).toBe('#ff0010') + expect(normalizeColorToHex('hsl(0, 100%, 50%)')).toBe('#ff0000') + expect(normalizeColorToHex('hsb(120, 100, 100)')).toBe('#00ff00') + + // invalid strings + expect(normalizeColorToHex('')).toBe('#000000') + expect(normalizeColorToHex(' ')).toBe('#000000') + expect(normalizeColorToHex('#12')).toBe('#000000') + expect(normalizeColorToHex('#zzzzzz')).toBe('#000000') + expect(normalizeColorToHex('not-a-color')).toBe('#000000') + + // HSB object inputs + expect(normalizeColorToHex({ h: 240, s: 100, b: 100 } as any)).toBe( + '#0000ff' + ) + expect(normalizeColorToHex({ h: NaN, s: 100, b: 100 } as any)).toBe( + '#000000' + ) + expect(normalizeColorToHex(null)).toBe('#000000') + expect(normalizeColorToHex(undefined)).toBe('#000000') + }) + }) })