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:
Dante
2026-03-13 12:45:10 +09:00
committed by GitHub
parent bfabf128ce
commit c318cc4c14
11 changed files with 780 additions and 254 deletions

View File

@@ -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)

View File

@@ -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