mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 01:48:06 +00:00
feat: replace PrimeVue ColorPicker with custom component (#9647)
## Summary - Replace PrimeVue `ColorPicker` with a custom component built on Reka UI Popover - New `ColorPicker` supports HSV saturation-value picking, hue/alpha sliders, hex/rgba display toggle - Simplify `WidgetColorPicker` by removing PrimeVue-specific normalization logic - Add Storybook stories for both `ColorPicker` and `WidgetColorPicker` ## Test plan - [x] Unit tests pass (9 widget tests, 47 colorUtil tests) - [x] Typecheck passes - [x] Lint passes - [ ] Verify color picker visually in Storybook - [ ] Test color picking in node widgets with hex/rgb/hsb formats ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9647-feat-replace-PrimeVue-ColorPicker-with-custom-component-31e6d73d36508114bc54d958ff8d0448) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Alexander Brown <drjkl@comfy.org>
This commit is contained in:
@@ -3,8 +3,10 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ColorAdjustOptions } from '@/utils/colorUtil'
|
||||
import {
|
||||
adjustColor,
|
||||
hexToHsva,
|
||||
hexToRgb,
|
||||
hsbToRgb,
|
||||
hsvaToHex,
|
||||
parseToRgb,
|
||||
rgbToHex
|
||||
} from '@/utils/colorUtil'
|
||||
@@ -132,6 +134,65 @@ describe('colorUtil conversions', () => {
|
||||
expect(rgbToHex(rgb)).toBe('#7f0000')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hexToHsva / hsvaToHex', () => {
|
||||
it('round-trips hex -> hsva -> hex for primary colors', () => {
|
||||
expect(hsvaToHex(hexToHsva('#ff0000'))).toBe('#ff0000')
|
||||
expect(hsvaToHex(hexToHsva('#00ff00'))).toBe('#00ff00')
|
||||
expect(hsvaToHex(hexToHsva('#0000ff'))).toBe('#0000ff')
|
||||
})
|
||||
|
||||
it('handles black (v=0)', () => {
|
||||
const hsva = hexToHsva('#000000')
|
||||
expect(hsva.v).toBe(0)
|
||||
expect(hsvaToHex(hsva)).toBe('#000000')
|
||||
})
|
||||
|
||||
it('handles white (s=0, v=100)', () => {
|
||||
const hsva = hexToHsva('#ffffff')
|
||||
expect(hsva.s).toBe(0)
|
||||
expect(hsva.v).toBe(100)
|
||||
expect(hsvaToHex(hsva)).toBe('#ffffff')
|
||||
})
|
||||
|
||||
it('handles pure hues', () => {
|
||||
const red = hexToHsva('#ff0000')
|
||||
expect(red.h).toBeCloseTo(0)
|
||||
expect(red.s).toBeCloseTo(100)
|
||||
expect(red.v).toBeCloseTo(100)
|
||||
|
||||
const green = hexToHsva('#00ff00')
|
||||
expect(green.h).toBeCloseTo(120)
|
||||
|
||||
const blue = hexToHsva('#0000ff')
|
||||
expect(blue.h).toBeCloseTo(240)
|
||||
})
|
||||
|
||||
it('preserves alpha=100 (no alpha suffix in hex)', () => {
|
||||
const hsva = hexToHsva('#ff0000')
|
||||
expect(hsva.a).toBe(100)
|
||||
expect(hsvaToHex(hsva)).toBe('#ff0000')
|
||||
})
|
||||
|
||||
it('preserves alpha=0', () => {
|
||||
const hsva = hexToHsva('#ff000000')
|
||||
expect(hsva.a).toBe(0)
|
||||
expect(hsvaToHex(hsva)).toBe('#ff000000')
|
||||
})
|
||||
|
||||
it('round-trips hex with alpha', () => {
|
||||
const hex = '#ff000080'
|
||||
const hsva = hexToHsva(hex)
|
||||
expect(hsva.a).toBe(50)
|
||||
expect(hsvaToHex(hsva)).toBe(hex)
|
||||
})
|
||||
|
||||
it('handles 5-char hex with alpha', () => {
|
||||
const hsva = hexToHsva('#f008')
|
||||
expect(hsva.a).toBe(53)
|
||||
expect(hsvaToHex(hsva)).toMatch(/^#ff0000/)
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('colorUtil - adjustColor', () => {
|
||||
const runAdjustColorTests = (
|
||||
@@ -170,8 +231,7 @@ describe('colorUtil - adjustColor', () => {
|
||||
'xyz(255, 255, 255)',
|
||||
'hsl(100, 50, 50%)',
|
||||
'hsl(100, 50%, 50)',
|
||||
'#GGGGGG',
|
||||
'#3333'
|
||||
'#GGGGGG'
|
||||
]
|
||||
|
||||
invalidColors.forEach((color) => {
|
||||
@@ -183,6 +243,15 @@ describe('colorUtil - adjustColor', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('treats 5-char hex as valid color with alpha', () => {
|
||||
const result = adjustColor('#f008', {
|
||||
lightness: targetLightness,
|
||||
opacity: targetOpacity
|
||||
})
|
||||
expect(result).not.toBe('#f008')
|
||||
expect(result).toMatch(/^hsla\(/)
|
||||
})
|
||||
|
||||
it('returns the original value for null or undefined inputs', () => {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
expect(adjustColor(null, { opacity: targetOpacity })).toBe(null)
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { memoize } from 'es-toolkit/compat'
|
||||
|
||||
type RGB = { r: number; g: number; b: number }
|
||||
export interface HSB {
|
||||
interface HSB {
|
||||
h: number
|
||||
s: number
|
||||
b: number
|
||||
}
|
||||
export interface HSVA {
|
||||
h: number
|
||||
s: number
|
||||
v: number
|
||||
a: number
|
||||
}
|
||||
type HSL = { h: number; s: number; l: number }
|
||||
type HSLA = { h: number; s: number; l: number; a: number }
|
||||
type ColorFormatInternal = 'hex' | 'rgb' | 'rgba' | 'hsl' | 'hsla'
|
||||
@@ -64,14 +70,11 @@ export function hexToRgb(hex: string): RGB {
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0
|
||||
// 3 digits
|
||||
if (hex.length == 4) {
|
||||
if (hex.length === 4 || hex.length === 5) {
|
||||
r = parseInt(hex[1] + hex[1], 16)
|
||||
g = parseInt(hex[2] + hex[2], 16)
|
||||
b = parseInt(hex[3] + hex[3], 16)
|
||||
}
|
||||
// 6 digits
|
||||
else if (hex.length == 7) {
|
||||
} else if (hex.length === 7 || hex.length === 9) {
|
||||
r = parseInt(hex.slice(1, 3), 16)
|
||||
g = parseInt(hex.slice(3, 5), 16)
|
||||
b = parseInt(hex.slice(5, 7), 16)
|
||||
@@ -193,7 +196,13 @@ export function parseToRgb(color: string): RGB {
|
||||
|
||||
const identifyColorFormat = (color: string): ColorFormatInternal | null => {
|
||||
if (!color) return null
|
||||
if (color.startsWith('#') && (color.length === 4 || color.length === 7))
|
||||
if (
|
||||
color.startsWith('#') &&
|
||||
(color.length === 4 ||
|
||||
color.length === 5 ||
|
||||
color.length === 7 ||
|
||||
color.length === 9)
|
||||
)
|
||||
return 'hex'
|
||||
if (/rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*/.test(color))
|
||||
return color.includes('rgba') ? 'rgba' : 'rgb'
|
||||
@@ -246,10 +255,12 @@ export function toHexFromFormat(val: unknown, format: ColorFormat): string {
|
||||
if (format === 'hex' && typeof val === 'string') {
|
||||
const raw = val.trim().toLowerCase()
|
||||
if (!raw) return '#000000'
|
||||
if (/^[0-9a-f]{3}$/.test(raw)) return `#${raw}`
|
||||
if (/^#[0-9a-f]{3}$/.test(raw)) return raw
|
||||
if (/^[0-9a-f]{3,4}$/.test(raw)) return `#${raw}`
|
||||
if (/^#[0-9a-f]{3,4}$/.test(raw)) return raw
|
||||
if (/^[0-9a-f]{6}$/.test(raw)) return `#${raw}`
|
||||
if (/^#[0-9a-f]{6}$/.test(raw)) return raw
|
||||
if (/^[0-9a-f]{8}$/.test(raw)) return `#${raw}`
|
||||
if (/^#[0-9a-f]{8}$/.test(raw)) return raw
|
||||
return '#000000'
|
||||
}
|
||||
|
||||
@@ -283,12 +294,22 @@ function parseToHSLA(color: string, format: ColorFormatInternal): HSLA | null {
|
||||
|
||||
switch (format) {
|
||||
case 'hex': {
|
||||
const hsl = rgbToHsl(hexToRgb(color))
|
||||
let a = 1
|
||||
let hexColor = color
|
||||
if (color.length === 9) {
|
||||
a = parseInt(color.slice(7, 9), 16) / 255
|
||||
hexColor = color.slice(0, 7)
|
||||
} else if (color.length === 5) {
|
||||
const aChar = color[4]
|
||||
a = parseInt(aChar + aChar, 16) / 255
|
||||
hexColor = color.slice(0, 4)
|
||||
}
|
||||
const hsl = rgbToHsl(hexToRgb(hexColor))
|
||||
return {
|
||||
h: Math.round(hsl.h * 360),
|
||||
s: +(hsl.s * 100).toFixed(1),
|
||||
l: +(hsl.l * 100).toFixed(1),
|
||||
a: 1
|
||||
a
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,6 +343,66 @@ function parseToHSLA(color: string, format: ColorFormatInternal): HSLA | null {
|
||||
}
|
||||
}
|
||||
|
||||
function rgbToHsv({ r, g, b }: RGB): {
|
||||
h: number
|
||||
s: number
|
||||
v: number
|
||||
} {
|
||||
r /= 255
|
||||
g /= 255
|
||||
b /= 255
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
const d = max - min
|
||||
let h = 0
|
||||
const s = max === 0 ? 0 : (d / max) * 100
|
||||
const v = max * 100
|
||||
|
||||
if (d !== 0) {
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) * 60
|
||||
break
|
||||
case g:
|
||||
h = ((b - r) / d + 2) * 60
|
||||
break
|
||||
case b:
|
||||
h = ((r - g) / d + 4) * 60
|
||||
break
|
||||
}
|
||||
}
|
||||
return { h, s, v }
|
||||
}
|
||||
|
||||
export function hexToHsva(hex: string): HSVA {
|
||||
const normalized = hex.startsWith('#') ? hex : `#${hex}`
|
||||
let a = 100
|
||||
let hexColor = normalized
|
||||
|
||||
if (normalized.length === 9) {
|
||||
a = Math.round((parseInt(normalized.slice(7, 9), 16) / 255) * 100)
|
||||
hexColor = normalized.slice(0, 7)
|
||||
} else if (normalized.length === 5) {
|
||||
const aChar = normalized[4]
|
||||
a = Math.round((parseInt(aChar + aChar, 16) / 255) * 100)
|
||||
hexColor = normalized.slice(0, 4)
|
||||
}
|
||||
|
||||
const rgb = hexToRgb(hexColor)
|
||||
const hsv = rgbToHsv(rgb)
|
||||
return { ...hsv, a }
|
||||
}
|
||||
|
||||
export function hsvaToHex(hsva: HSVA): string {
|
||||
const rgb = hsbToRgb({ h: hsva.h, s: hsva.s, b: hsva.v })
|
||||
const hex = rgbToHex(rgb)
|
||||
if (hsva.a >= 100) return hex.toLowerCase()
|
||||
const alphaHex = Math.round((hsva.a / 100) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
return `${hex}${alphaHex}`.toLowerCase()
|
||||
}
|
||||
|
||||
const applyColorAdjustments = (
|
||||
color: string,
|
||||
options: ColorAdjustOptions
|
||||
|
||||
Reference in New Issue
Block a user