diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts index 3f8dbdf54..9213989cd 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts @@ -83,6 +83,46 @@ describe('WidgetColorPicker Value Binding', () => { expect(emitted).toBeDefined() expect(emitted![0]).toContain('#ff00ff') }) + + it('normalizes bare hex without # to #hex on emit', async () => { + const widget = createMockWidget('ff0000') + const wrapper = mountComponent(widget, 'ff0000') + + const emitted = await setColorPickerValue(wrapper, '00ff00') + expect(emitted).toBeDefined() + expect(emitted![0]).toContain('#00ff00') + }) + + it('normalizes rgb() strings to #hex on emit', async () => { + const widget = createMockWidget('#000000') + const wrapper = mountComponent(widget, '#000000') + + const emitted = await setColorPickerValue(wrapper, 'rgb(255, 0, 0)') + expect(emitted).toBeDefined() + expect(emitted![0]).toContain('#ff0000') + }) + + it('normalizes hsb() strings to #hex on emit', async () => { + const widget = createMockWidget('#000000', { format: 'hsb' }) + const wrapper = mountComponent(widget, '#000000') + + const emitted = await setColorPickerValue(wrapper, 'hsb(120, 100, 100)') + expect(emitted).toBeDefined() + expect(emitted![0]).toContain('#00ff00') + }) + + it('normalizes HSB object values to #hex on emit', async () => { + const widget = createMockWidget('#000000', { format: 'hsb' }) + const wrapper = mountComponent(widget, '#000000') + + const emitted = await setColorPickerValue(wrapper, { + h: 240, + s: 100, + b: 100 + } as any) + expect(emitted).toBeDefined() + expect(emitted![0]).toContain('#0000ff') + }) }) describe('Component Rendering', () => { diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue index 7202bd959..3fc4830fa 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue @@ -29,6 +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 { cn } from '@/utils/tailwindUtil' import { PANEL_EXCLUDED_PROPS, @@ -49,11 +50,58 @@ const emit = defineEmits<{ }>() // 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, - modelValue: props.modelValue, + // Normalize initial model value to ensure leading '#' + modelValue: normalizeToHexWithHash(props.modelValue), defaultValue: '#000000', - emit + emit, + transform: (val: unknown) => normalizeToHexWithHash(val) }) // ColorPicker specific excluded props include panel/overlay classes diff --git a/src/utils/colorUtil.ts b/src/utils/colorUtil.ts index e6df94953..2acbccc36 100644 --- a/src/utils/colorUtil.ts +++ b/src/utils/colorUtil.ts @@ -1,6 +1,7 @@ import { memoize } from 'es-toolkit/compat' -type RGB = { r: number; g: number; b: number } +export type RGB = { r: number; g: number; b: number } +export type HSB = { h: number; s: number; b: number } type HSL = { h: number; s: number; l: number } type HSLA = { h: number; s: number; l: number; a: number } type ColorFormat = 'hex' | 'rgb' | 'rgba' | 'hsl' | 'hsla' @@ -59,6 +60,61 @@ export function hexToRgb(hex: string): RGB { return { r, g, b } } +export function rgbToHex({ r, g, b }: RGB): string { + const toHex = (n: number) => + Math.max(0, Math.min(255, Math.round(n))) + .toString(16) + .padStart(2, '0') + return `#${toHex(r)}${toHex(g)}${toHex(b)}` +} + +export function hsbToRgb({ h, s, b }: HSB): RGB { + // Normalize + const hh = ((h % 360) + 360) % 360 + const ss = Math.max(0, Math.min(100, s)) / 100 + const vv = Math.max(0, Math.min(100, b)) / 100 + + const c = vv * ss + const x = c * (1 - Math.abs(((hh / 60) % 2) - 1)) + const m = vv - c + + let rp = 0, + gp = 0, + bp = 0 + + if (hh < 60) { + rp = c + gp = x + bp = 0 + } else if (hh < 120) { + rp = x + gp = c + bp = 0 + } else if (hh < 180) { + rp = 0 + gp = c + bp = x + } else if (hh < 240) { + rp = 0 + gp = x + bp = c + } else if (hh < 300) { + rp = x + gp = 0 + bp = c + } else { + rp = c + gp = 0 + bp = x + } + + return { + r: Math.round((rp + m) * 255), + g: Math.round((gp + m) * 255), + b: Math.round((bp + m) * 255) + } +} + export function parseToRgb(color: string): RGB { const format = identifyColorFormat(color) if (!format) return { r: 0, g: 0, b: 0 }