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

@@ -0,0 +1,68 @@
import type {
ComponentPropsAndSlots,
Meta,
StoryObj
} from '@storybook/vue3-vite'
import { ref } from 'vue'
import ColorPicker from './ColorPicker.vue'
const meta: Meta<ComponentPropsAndSlots<typeof ColorPicker>> = {
title: 'Components/ColorPicker',
component: ColorPicker,
tags: ['autodocs'],
parameters: { layout: 'padded' },
decorators: [
(story) => ({
components: { story },
template: '<div class="w-60"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { ColorPicker },
setup() {
const color = ref('#e06cbd')
return { color }
},
template: '<ColorPicker v-model="color" />'
})
}
export const Red: Story = {
render: () => ({
components: { ColorPicker },
setup() {
const color = ref('#ff0000')
return { color }
},
template: '<ColorPicker v-model="color" />'
})
}
export const Black: Story = {
render: () => ({
components: { ColorPicker },
setup() {
const color = ref('#000000')
return { color }
},
template: '<ColorPicker v-model="color" />'
})
}
export const White: Story = {
render: () => ({
components: { ColorPicker },
setup() {
const color = ref('#ffffff')
return { color }
},
template: '<ColorPicker v-model="color" />'
})
}

View File

@@ -0,0 +1,125 @@
<script setup lang="ts">
import {
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger
} from 'reka-ui'
import { computed, ref, watch } from 'vue'
import type { HSVA } from '@/utils/colorUtil'
import { hexToHsva, hsbToRgb, hsvaToHex, rgbToHex } from '@/utils/colorUtil'
import { cn } from '@/utils/tailwindUtil'
import ColorPickerPanel from './ColorPickerPanel.vue'
defineProps<{
class?: string
}>()
const modelValue = defineModel<string>({ default: '#000000' })
const hsva = ref<HSVA>(hexToHsva(modelValue.value || '#000000'))
const displayMode = ref<'hex' | 'rgba'>('hex')
watch(modelValue, (newVal) => {
const current = hsvaToHex(hsva.value)
if (newVal !== current) {
hsva.value = hexToHsva(newVal || '#000000')
}
})
watch(
hsva,
(newHsva) => {
const hex = hsvaToHex(newHsva)
if (hex !== modelValue.value) {
modelValue.value = hex
}
},
{ deep: true }
)
const baseRgb = computed(() =>
hsbToRgb({ h: hsva.value.h, s: hsva.value.s, b: hsva.value.v })
)
const previewColor = computed(() => {
const hex = rgbToHex(baseRgb.value)
const a = hsva.value.a / 100
if (a < 1) {
const alphaHex = Math.round(a * 255)
.toString(16)
.padStart(2, '0')
return `${hex}${alphaHex}`
}
return hex
})
const displayHex = computed(() => rgbToHex(baseRgb.value).toLowerCase())
const isOpen = ref(false)
</script>
<template>
<PopoverRoot v-model:open="isOpen">
<PopoverTrigger as-child>
<button
type="button"
:class="
cn(
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-node-component-surface pr-2 outline-none hover:bg-component-node-widget-background-hovered',
isOpen && 'border-node-stroke',
$props.class
)
"
>
<div class="flex size-8 shrink-0 items-center justify-center">
<div class="relative size-4 overflow-hidden rounded-sm">
<div
class="absolute inset-0"
:style="{
backgroundImage:
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
backgroundSize: '4px 4px'
}"
/>
<div
class="absolute inset-0"
:style="{ backgroundColor: previewColor }"
/>
</div>
</div>
<div
class="flex flex-1 items-center justify-between pl-1 text-xs text-node-component-slot-text"
>
<template v-if="displayMode === 'hex'">
<span>{{ displayHex }}</span>
</template>
<template v-else>
<div class="flex gap-2">
<span>{{ baseRgb.r }}</span>
<span>{{ baseRgb.g }}</span>
<span>{{ baseRgb.b }}</span>
</div>
</template>
<span>{{ hsva.a }}%</span>
</div>
</button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
side="bottom"
align="start"
:side-offset="7"
:collision-padding="10"
class="z-1700"
>
<ColorPickerPanel
v-model:hsva="hsva"
v-model:display-mode="displayMode"
/>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import type { HSVA } from '@/utils/colorUtil'
import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
import ColorPickerSaturationValue from './ColorPickerSaturationValue.vue'
import ColorPickerSlider from './ColorPickerSlider.vue'
const hsva = defineModel<HSVA>('hsva', { required: true })
const displayMode = defineModel<'hex' | 'rgba'>('displayMode', {
required: true
})
const rgb = computed(() =>
hsbToRgb({ h: hsva.value.h, s: hsva.value.s, b: hsva.value.v })
)
const hexString = computed(() => rgbToHex(rgb.value).toLowerCase())
const { t } = useI18n()
</script>
<template>
<div
class="flex w-[211px] flex-col gap-2 rounded-lg border border-border-subtle bg-base-background p-2 shadow-md"
>
<ColorPickerSaturationValue
v-model:saturation="hsva.s"
v-model:value="hsva.v"
:hue="hsva.h"
/>
<ColorPickerSlider v-model="hsva.h" type="hue" />
<ColorPickerSlider
v-model="hsva.a"
type="alpha"
:hue="hsva.h"
:saturation="hsva.s"
:brightness="hsva.v"
/>
<div class="flex items-center gap-1">
<Select v-model="displayMode">
<SelectTrigger
class="h-6 w-[58px] shrink-0 gap-0.5 overflow-clip rounded-sm border-0 px-1.5 py-0 text-xs [&>span]:overflow-visible"
>
<SelectValue />
</SelectTrigger>
<SelectContent class="min-w-16 p-1">
<SelectItem value="hex" class="px-2 py-1 text-xs">
{{ t('color.hex') }}
</SelectItem>
<SelectItem value="rgba" class="px-2 py-1 text-xs">
{{ t('color.rgba') }}
</SelectItem>
</SelectContent>
</Select>
<div
class="flex h-6 min-w-0 flex-1 items-center gap-1 rounded-sm bg-secondary-background px-1 text-xs text-node-component-slot-text"
>
<template v-if="displayMode === 'hex'">
<span class="min-w-0 flex-1 truncate text-center">{{
hexString
}}</span>
</template>
<template v-else>
<span class="w-6 shrink-0 text-center">{{ rgb.r }}</span>
<span class="w-6 shrink-0 text-center">{{ rgb.g }}</span>
<span class="w-6 shrink-0 text-center">{{ rgb.b }}</span>
</template>
<span class="shrink-0 border-l border-border-subtle pl-1"
>{{ hsva.a }}%</span
>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { hue } = defineProps<{
hue: number
}>()
const saturation = defineModel<number>('saturation', { required: true })
const value = defineModel<number>('value', { required: true })
const containerRef = ref<HTMLElement | null>(null)
const hueBackground = computed(() => `hsl(${hue}, 100%, 50%)`)
const handleStyle = computed(() => ({
left: `${saturation.value}%`,
top: `${100 - value.value}%`
}))
function updateFromPointer(e: PointerEvent) {
const el = containerRef.value
if (!el) return
const rect = el.getBoundingClientRect()
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
saturation.value = Math.round(x * 100)
value.value = Math.round((1 - y) * 100)
}
function handlePointerDown(e: PointerEvent) {
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
updateFromPointer(e)
}
function handlePointerMove(e: PointerEvent) {
if (!(e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) return
updateFromPointer(e)
}
</script>
<template>
<div
ref="containerRef"
role="slider"
:aria-label="t('color.saturationBrightness')"
:aria-valuetext="`${saturation}%, ${value}%`"
class="relative aspect-square w-full cursor-crosshair rounded-sm"
:style="{ backgroundColor: hueBackground, touchAction: 'none' }"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
>
<div
class="absolute inset-0 rounded-sm bg-linear-to-r from-white to-transparent"
/>
<div
class="absolute inset-0 rounded-sm bg-linear-to-b from-transparent to-black"
/>
<div
class="pointer-events-none absolute size-3.5 -translate-1/2 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)]"
:style="handleStyle"
/>
</div>
</template>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
const { t } = useI18n()
const {
type,
hue = 0,
saturation = 100,
brightness = 100
} = defineProps<{
type: 'hue' | 'alpha'
hue?: number
saturation?: number
brightness?: number
}>()
const modelValue = defineModel<number>({ required: true })
const max = computed(() => (type === 'hue' ? 360 : 100))
const fraction = computed(() => modelValue.value / max.value)
const trackBackground = computed(() => {
if (type === 'hue') {
return 'linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)'
}
const rgb = hsbToRgb({ h: hue, s: saturation, b: brightness })
const hex = rgbToHex(rgb)
return `linear-gradient(to right, transparent, ${hex})`
})
const containerStyle = computed(() => {
if (type === 'alpha') {
return {
backgroundImage:
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
backgroundSize: '8px 8px',
touchAction: 'none'
}
}
return {
background: trackBackground.value,
touchAction: 'none'
}
})
function updateFromPointer(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
modelValue.value = Math.round(x * max.value)
}
function handlePointerDown(e: PointerEvent) {
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
updateFromPointer(e)
}
function handlePointerMove(e: PointerEvent) {
if (!(e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) return
updateFromPointer(e)
}
</script>
<template>
<div
role="slider"
:aria-label="type === 'hue' ? t('color.hue') : t('color.alpha')"
:aria-valuemin="0"
:aria-valuemax="max"
:aria-valuenow="modelValue"
class="relative flex h-4 cursor-pointer items-center rounded-full p-px"
:style="containerStyle"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
>
<div
v-if="type === 'alpha'"
class="absolute inset-0 rounded-full"
:style="{ background: trackBackground }"
/>
<div
class="pointer-events-none absolute aspect-square h-full -translate-x-1/2 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)]"
:style="{ left: `${fraction * 100}%` }"
/>
</div>
</template>

View File

@@ -496,7 +496,12 @@
"cyan": "Cyan",
"purple": "Purple",
"black": "Black",
"custom": "Custom"
"custom": "Custom",
"hex": "Hex",
"rgba": "RGBA",
"saturationBrightness": "Color saturation and brightness",
"hue": "Hue",
"alpha": "Alpha"
},
"contextMenu": {
"Inputs": "Inputs",

View File

@@ -0,0 +1,137 @@
import type {
ComponentPropsAndSlots,
Meta,
StoryObj
} from '@storybook/vue3-vite'
import { computed, ref, toRefs } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { ColorFormat } from '@/utils/colorUtil'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import WidgetColorPicker from './WidgetColorPicker.vue'
type WidgetOptions = IWidgetOptions & { format?: ColorFormat }
interface StoryArgs extends ComponentPropsAndSlots<typeof WidgetColorPicker> {
format: ColorFormat
}
const meta: Meta<StoryArgs> = {
title: 'Widgets/WidgetColorPicker',
component: WidgetColorPicker,
tags: ['autodocs'],
parameters: { layout: 'centered' },
argTypes: {
format: {
control: 'select',
options: ['hex', 'rgb', 'hsb']
}
},
args: {
format: 'hex'
},
decorators: [
(story) => ({
components: { story },
template:
'<div class="grid grid-cols-[auto_1fr] gap-1 w-80"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { WidgetColorPicker },
setup() {
const { format } = toRefs(args)
const value = ref('#E06CBD')
const widget = computed<SimplifiedWidget<string, WidgetOptions>>(() => ({
name: 'color',
type: 'STRING',
value: '',
options: { format: format.value }
}))
return { value, widget }
},
template: '<WidgetColorPicker :widget="widget" v-model="value" />'
})
}
export const RGBFormat: Story = {
args: { format: 'rgb' },
render: (args) => ({
components: { WidgetColorPicker },
setup() {
const { format } = toRefs(args)
const value = ref('#3498DB')
const widget = computed<SimplifiedWidget<string, WidgetOptions>>(() => ({
name: 'color',
type: 'STRING',
value: '',
options: { format: format.value }
}))
return { value, widget }
},
template: '<WidgetColorPicker :widget="widget" v-model="value" />'
})
}
export const HSBFormat: Story = {
args: { format: 'hsb' },
render: (args) => ({
components: { WidgetColorPicker },
setup() {
const { format } = toRefs(args)
const value = ref('#2ECC71')
const widget = computed<SimplifiedWidget<string, WidgetOptions>>(() => ({
name: 'color',
type: 'STRING',
value: '',
options: { format: format.value }
}))
return { value, widget }
},
template: '<WidgetColorPicker :widget="widget" v-model="value" />'
})
}
export const CustomColor: Story = {
render: (args) => ({
components: { WidgetColorPicker },
setup() {
const { format } = toRefs(args)
const value = ref('#FF5733')
const widget = computed<SimplifiedWidget<string, WidgetOptions>>(() => ({
name: 'accent_color',
type: 'STRING',
value: '',
options: { format: format.value }
}))
return { value, widget }
},
template: '<WidgetColorPicker :widget="widget" v-model="value" />'
})
}
export const WithLabel: Story = {
render: () => ({
components: { WidgetColorPicker },
setup() {
const value = ref('#9B59B6')
const widget: SimplifiedWidget<string, WidgetOptions> = {
name: 'background',
type: 'STRING',
value: '',
label: 'Background Color',
options: { format: 'hex' }
}
return { value, widget }
},
template: '<WidgetColorPicker :widget="widget" v-model="value" />'
})
}

View File

@@ -1,11 +1,10 @@
import { mount } from '@vue/test-utils'
import ColorPicker from 'primevue/colorpicker'
import type { ColorPickerProps } from 'primevue/colorpicker'
import PrimeVue from 'primevue/config'
import { describe, expect, it } from 'vitest'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
import WidgetColorPicker from './WidgetColorPicker.vue'
import { createMockWidget } from './widgetTestUtils'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
@@ -13,7 +12,7 @@ import WidgetLayoutField from './layout/WidgetLayoutField.vue'
describe('WidgetColorPicker Value Binding', () => {
const createColorWidget = (
value: string = '#000000',
options: Partial<ColorPickerProps> = {},
options: Record<string, unknown> = {},
callback?: (value: string) => void
) =>
createMockWidget<string>({
@@ -26,12 +25,10 @@ describe('WidgetColorPicker Value Binding', () => {
const mountComponent = (
widget: SimplifiedWidget<string>,
modelValue: string,
readonly = false
modelValue: string
) => {
return mount(WidgetColorPicker, {
global: {
plugins: [PrimeVue],
components: {
ColorPicker,
WidgetLayoutField
@@ -39,93 +36,35 @@ describe('WidgetColorPicker Value Binding', () => {
},
props: {
widget,
modelValue,
readonly
modelValue
}
})
}
const setColorPickerValue = async (
wrapper: ReturnType<typeof mount>,
value: unknown
) => {
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
await colorPicker.setValue(value)
return wrapper.emitted('update:modelValue')
}
describe('Vue Event Emission', () => {
it('emits Vue event when color changes', async () => {
const widget = createColorWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
const emitted = await setColorPickerValue(wrapper, '#00ff00')
const colorPicker = wrapper.findComponent(ColorPicker)
await colorPicker.vm.$emit('update:modelValue', '#00ff00')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#00ff00')
})
it('handles different color formats', async () => {
const widget = createColorWidget('#ffffff')
const wrapper = mountComponent(widget, '#ffffff')
const emitted = await setColorPickerValue(wrapper, '#123abc')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#123abc')
})
it('handles missing callback gracefully', async () => {
const widget = createColorWidget('#000000', {}, undefined)
const wrapper = mountComponent(widget, '#000000')
const emitted = await setColorPickerValue(wrapper, '#ff00ff')
const colorPicker = wrapper.findComponent(ColorPicker)
await colorPicker.vm.$emit('update:modelValue', '#ff00ff')
// Should still emit Vue event
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#ff00ff')
})
it('normalizes bare hex without # to #hex on emit', async () => {
const widget = createColorWidget('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 (context) => {
context.skip('needs diagnosis')
const widget = createColorWidget('#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 = createColorWidget('#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 = createColorWidget('#000000', { format: 'hsb' })
const wrapper = mountComponent(widget, '#000000')
const emitted = await setColorPickerValue(wrapper, {
h: 240,
s: 100,
b: 100
})
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#0000ff')
})
})
describe('Component Rendering', () => {
@@ -133,110 +72,37 @@ describe('WidgetColorPicker Value Binding', () => {
const widget = createColorWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
const colorPicker = wrapper.findComponent(ColorPicker)
expect(colorPicker.exists()).toBe(true)
})
it('normalizes display to a single leading #', () => {
// Case 1: model value already includes '#'
let widget = createColorWidget('#ff0000')
let wrapper = mountComponent(widget, '#ff0000')
let colorText = wrapper.find('[data-testid="widget-color-text"]')
expect.soft(colorText.text()).toBe('#ff0000')
// Case 2: model value missing '#'
widget = createColorWidget('ff0000')
wrapper = mountComponent(widget, 'ff0000')
colorText = wrapper.find('[data-testid="widget-color-text"]')
expect.soft(colorText.text()).toBe('#ff0000')
})
it('renders layout field wrapper', () => {
const widget = createColorWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
const layoutField = wrapper.findComponent({
name: 'WidgetLayoutField'
})
expect(layoutField.exists()).toBe(true)
})
it('displays current color value as text', () => {
const widget = createColorWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
const colorText = wrapper.find('[data-testid="widget-color-text"]')
expect(colorText.text()).toBe('#ff0000')
})
it('updates color text when value changes', async () => {
const widget = createColorWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
await setColorPickerValue(wrapper, '#00ff00')
// Need to check the local state update
const colorText = wrapper.find('[data-testid="widget-color-text"]')
// Be specific about the displayed value including the leading '#'
expect.soft(colorText.text()).toBe('#00ff00')
})
it('uses default color when no value provided', () => {
const widget = createColorWidget('')
const wrapper = mountComponent(widget, '')
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
// Should use the default value from the composable
const colorPicker = wrapper.findComponent(ColorPicker)
expect(colorPicker.exists()).toBe(true)
})
})
describe('Color Formats', () => {
it('handles valid hex colors', async () => {
const validHexColors = [
'#000000',
'#ffffff',
'#ff0000',
'#00ff00',
'#0000ff',
'#123abc'
]
for (const color of validHexColors) {
const widget = createColorWidget(color)
const wrapper = mountComponent(widget, color)
const colorText = wrapper.find('[data-testid="widget-color-text"]')
expect.soft(colorText.text()).toBe(color)
}
})
it('handles short hex colors', () => {
const widget = createColorWidget('#fff')
const wrapper = mountComponent(widget, '#fff')
const colorText = wrapper.find('[data-testid="widget-color-text"]')
expect(colorText.text()).toBe('#fff')
})
it('passes widget options to color picker', () => {
const colorOptions = {
format: 'hex' as const,
inline: true
}
const widget = createColorWidget('#ff0000', colorOptions)
const wrapper = mountComponent(widget, '#ff0000')
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
expect(colorPicker.props('format')).toBe('hex')
expect(colorPicker.props('inline')).toBe(true)
})
})
describe('Widget Layout Integration', () => {
it('passes widget to layout field', () => {
const widget = createColorWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
const layoutField = wrapper.findComponent({
name: 'WidgetLayoutField'
})
expect(layoutField.props('widget')).toEqual(widget)
})
@@ -244,16 +110,13 @@ describe('WidgetColorPicker Value Binding', () => {
const widget = createColorWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
// Should have layout field containing label with color picker and text
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
const label = wrapper.find('label')
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
const colorText = wrapper.find('span')
const layoutField = wrapper.findComponent({
name: 'WidgetLayoutField'
})
const colorPicker = wrapper.findComponent(ColorPicker)
expect(layoutField.exists()).toBe(true)
expect(label.exists()).toBe(true)
expect(colorPicker.exists()).toBe(true)
expect(colorText.exists()).toBe(true)
})
})
@@ -262,27 +125,15 @@ describe('WidgetColorPicker Value Binding', () => {
const widget = createColorWidget('')
const wrapper = mountComponent(widget, '')
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
const colorPicker = wrapper.findComponent(ColorPicker)
expect(colorPicker.exists()).toBe(true)
})
it('handles invalid color formats gracefully', async () => {
const widget = createColorWidget('invalid-color')
const wrapper = mountComponent(widget, 'invalid-color')
const colorText = wrapper.find('[data-testid="widget-color-text"]')
expect(colorText.text()).toBe('#000000')
const emitted = await setColorPickerValue(wrapper, 'invalid-color')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#000000')
})
it('handles widget with no options', () => {
const widget = createColorWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
const colorPicker = wrapper.findComponent(ColorPicker)
expect(colorPicker.exists()).toBe(true)
})
})

View File

@@ -1,90 +1,42 @@
<!-- Needs custom color picker for alpha support -->
<template>
<WidgetLayoutField :widget="widget">
<label
:class="
cn(WidgetInputBaseClass, 'flex w-full items-center gap-2 px-4 py-2')
"
>
<ColorPicker
v-model="localValue"
v-bind="filteredProps"
class="h-4 w-8 overflow-hidden rounded-full! border-none"
:aria-label="widget.name"
:pt="{
preview: '!w-full !h-full !border-none'
}"
@update:model-value="onPickerUpdate"
/>
<span
class="min-w-[4ch] truncate text-xs"
data-testid="widget-color-text"
>{{ toHexFromFormat(localValue, format) }}</span
>
</label>
<ColorPicker v-model="localValue" @update:model-value="onUpdate" />
</WidgetLayoutField>
</template>
<script setup lang="ts">
import ColorPicker from 'primevue/colorpicker'
import { computed, ref, watch } from 'vue'
import { ref, watch } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { isColorFormat, toHexFromFormat } from '@/utils/colorUtil'
import type { ColorFormat, HSB } from '@/utils/colorUtil'
import { cn } from '@/utils/tailwindUtil'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import type { ColorFormat } from '@/utils/colorUtil'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import { WidgetInputBaseClass } from './layout'
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
type WidgetOptions = IWidgetOptions & { format?: ColorFormat }
const props = defineProps<{
const { widget } = defineProps<{
widget: SimplifiedWidget<string, WidgetOptions>
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const modelValue = defineModel<string>({ required: true })
const format = computed<ColorFormat>(() => {
const optionFormat = props.widget.options?.format
return isColorFormat(optionFormat) ? optionFormat : 'hex'
const format = isColorFormat(widget.options?.format)
? widget.options.format
: 'hex'
const localValue = ref(toHexFromFormat(modelValue.value || '#000000', format))
watch(modelValue, (newVal) => {
localValue.value = toHexFromFormat(newVal || '#000000', format)
})
type PickerValue = string | HSB
const localValue = ref<PickerValue>(
toHexFromFormat(
props.modelValue || '#000000',
isColorFormat(props.widget.options?.format)
? props.widget.options.format
: 'hex'
)
)
watch(
() => props.modelValue,
(newVal) => {
localValue.value = toHexFromFormat(newVal || '#000000', format.value)
}
)
function onPickerUpdate(val: unknown) {
localValue.value = val as PickerValue
emit('update:modelValue', toHexFromFormat(val, format.value))
function onUpdate(val: string) {
localValue.value = val
modelValue.value = val
}
// ColorPicker specific excluded props include panel/overlay classes
const COLOR_PICKER_EXCLUDED_PROPS = [...PANEL_EXCLUDED_PROPS] as const
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, COLOR_PICKER_EXCLUDED_PROPS)
)
</script>

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