mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-16 16:58:33 +00:00
Compare commits
1 Commits
main
...
feat/color
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caf2c03c6a |
141
src/components/ui/color-picker/ColorPickerPanel.test.ts
Normal file
141
src/components/ui/color-picker/ColorPickerPanel.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ColorPickerPanel from '@/components/ui/color-picker/ColorPickerPanel.vue'
|
||||
import type { HSVA } from '@/utils/colorUtil'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
color: {
|
||||
hex: 'Hex',
|
||||
rgba: 'RGBA',
|
||||
hue: 'Hue',
|
||||
alpha: 'Alpha',
|
||||
red: 'Red',
|
||||
green: 'Green',
|
||||
blue: 'Blue',
|
||||
saturationBrightness: 'Saturation and brightness'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderPanel(initial: HSVA, mode: 'hex' | 'rgba' = 'hex') {
|
||||
const hsva = ref<HSVA>(initial)
|
||||
const displayMode = ref<'hex' | 'rgba'>(mode)
|
||||
|
||||
const Host = defineComponent(
|
||||
() => () =>
|
||||
h(ColorPickerPanel, {
|
||||
hsva: hsva.value,
|
||||
'onUpdate:hsva': (value: HSVA) => {
|
||||
hsva.value = value
|
||||
},
|
||||
displayMode: displayMode.value,
|
||||
'onUpdate:displayMode': (value: 'hex' | 'rgba') => {
|
||||
displayMode.value = value
|
||||
},
|
||||
alpha: true
|
||||
})
|
||||
)
|
||||
|
||||
render(Host, { global: { plugins: [i18n] } })
|
||||
|
||||
return { hsva, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
const black: HSVA = { h: 0, s: 0, v: 0, a: 100 }
|
||||
|
||||
describe('ColorPickerPanel hex input', () => {
|
||||
it('updates the hsva model when a valid 6-digit hex is typed', async () => {
|
||||
const { hsva, user } = renderPanel({ ...black })
|
||||
|
||||
const input = screen.getByRole('textbox', { name: 'Hex' })
|
||||
await user.clear(input)
|
||||
await user.type(input, 'ff0000')
|
||||
|
||||
expect(hsva.value).toMatchObject({ h: 0, s: 100, v: 100 })
|
||||
})
|
||||
|
||||
it('accepts 3-digit shorthand hex', async () => {
|
||||
const { hsva, user } = renderPanel({ ...black })
|
||||
|
||||
const input = screen.getByRole('textbox', { name: 'Hex' })
|
||||
await user.clear(input)
|
||||
await user.type(input, '0f0')
|
||||
|
||||
expect(hsva.value).toMatchObject({ h: 120, s: 100, v: 100 })
|
||||
})
|
||||
|
||||
it('preserves the existing alpha when editing hex', async () => {
|
||||
const { hsva, user } = renderPanel({ h: 0, s: 0, v: 0, a: 40 })
|
||||
|
||||
const input = screen.getByRole('textbox', { name: 'Hex' })
|
||||
await user.clear(input)
|
||||
await user.type(input, 'ffffff')
|
||||
|
||||
expect(hsva.value.a).toBe(40)
|
||||
})
|
||||
|
||||
it('ignores invalid input and leaves the model unchanged', async () => {
|
||||
const { hsva, user } = renderPanel({ ...black })
|
||||
|
||||
const input = screen.getByRole('textbox', { name: 'Hex' })
|
||||
await user.clear(input)
|
||||
await user.type(input, 'nothex')
|
||||
|
||||
expect(hsva.value).toEqual(black)
|
||||
})
|
||||
|
||||
it('reformats the draft to canonical hex on blur', async () => {
|
||||
const { user } = renderPanel({ ...black })
|
||||
|
||||
const input = screen.getByRole<HTMLInputElement>('textbox', { name: 'Hex' })
|
||||
await user.clear(input)
|
||||
await user.type(input, '0f0')
|
||||
await user.tab()
|
||||
|
||||
expect(input.value).toBe('#00ff00')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ColorPickerPanel rgba inputs', () => {
|
||||
it('updates the hsva model when an RGB channel is edited', async () => {
|
||||
const { hsva, user } = renderPanel({ ...black }, 'rgba')
|
||||
|
||||
const red = screen.getByRole('spinbutton', { name: 'Red' })
|
||||
await user.clear(red)
|
||||
await user.type(red, '255')
|
||||
|
||||
expect(hsva.value).toMatchObject({ h: 0, s: 100, v: 100 })
|
||||
})
|
||||
|
||||
it('clamps an out-of-range channel to 255 on blur', async () => {
|
||||
const { user } = renderPanel({ ...black }, 'rgba')
|
||||
|
||||
const red = screen.getByRole<HTMLInputElement>('spinbutton', {
|
||||
name: 'Red'
|
||||
})
|
||||
await user.clear(red)
|
||||
await user.type(red, '300')
|
||||
await user.tab()
|
||||
|
||||
expect(red.value).toBe('255')
|
||||
})
|
||||
|
||||
it('edits alpha as a percentage', async () => {
|
||||
const { hsva, user } = renderPanel({ h: 0, s: 0, v: 0, a: 100 })
|
||||
|
||||
const alpha = screen.getByRole('spinbutton', { name: 'Alpha' })
|
||||
await user.clear(alpha)
|
||||
await user.type(alpha, '50')
|
||||
|
||||
expect(hsva.value.a).toBe(50)
|
||||
})
|
||||
})
|
||||
@@ -1,33 +1,3 @@
|
||||
<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 { alpha = true } = defineProps<{ alpha?: boolean }>()
|
||||
|
||||
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"
|
||||
@@ -66,19 +36,79 @@ const { t } = useI18n()
|
||||
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>
|
||||
<input
|
||||
v-model="hex.draft"
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
:aria-label="t('color.hex')"
|
||||
class="min-w-0 flex-1 appearance-none border-none bg-transparent p-0 text-center outline-none"
|
||||
@focus="hex.beginEdit"
|
||||
@input="hex.commit"
|
||||
@keydown.enter="hex.commit"
|
||||
@blur="hex.reset"
|
||||
/>
|
||||
</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>
|
||||
<input
|
||||
v-for="channel in rgbChannels"
|
||||
:key="channel.key"
|
||||
v-model.number="rgb.draft[channel.key]"
|
||||
type="number"
|
||||
:min="0"
|
||||
:max="255"
|
||||
:aria-label="t(channel.label)"
|
||||
class="min-w-0 flex-1 appearance-none border-none bg-transparent p-0 text-center outline-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
@focus="rgb.beginEdit"
|
||||
@input="rgb.commit"
|
||||
@keydown.enter="rgb.commit"
|
||||
@blur="rgb.reset"
|
||||
/>
|
||||
</template>
|
||||
<span v-if="alpha" class="shrink-0 border-l border-border-subtle pl-1"
|
||||
>{{ hsva.a }}%</span
|
||||
<div
|
||||
v-if="alpha"
|
||||
class="flex shrink-0 items-center border-l border-border-subtle pl-1"
|
||||
>
|
||||
<input
|
||||
v-model.number="alphaField.draft"
|
||||
type="number"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:aria-label="t('color.alpha')"
|
||||
class="w-6 min-w-0 appearance-none border-none bg-transparent p-0 text-right outline-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
@focus="alphaField.beginEdit"
|
||||
@input="alphaField.commit"
|
||||
@keydown.enter="alphaField.commit"
|
||||
@blur="alphaField.reset"
|
||||
/>
|
||||
<span>%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 ColorPickerSaturationValue from './ColorPickerSaturationValue.vue'
|
||||
import ColorPickerSlider from './ColorPickerSlider.vue'
|
||||
import { rgbChannels, useColorPicker } from './useColorPicker'
|
||||
|
||||
const { alpha = true } = defineProps<{ alpha?: boolean }>()
|
||||
|
||||
const hsva = defineModel<HSVA>('hsva', { required: true })
|
||||
const displayMode = defineModel<'hex' | 'rgba'>('displayMode', {
|
||||
required: true
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { hex, rgb, alpha: alphaField } = useColorPicker(hsva)
|
||||
</script>
|
||||
|
||||
78
src/components/ui/color-picker/useColorPicker.test.ts
Normal file
78
src/components/ui/color-picker/useColorPicker.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
|
||||
import type { HSVA } from '@/utils/colorUtil'
|
||||
|
||||
import { useColorPicker } from './useColorPicker'
|
||||
|
||||
const black: HSVA = { h: 0, s: 0, v: 0, a: 100 }
|
||||
|
||||
function setup(initial: HSVA) {
|
||||
const hsva = ref<HSVA>(initial)
|
||||
const scope = effectScope()
|
||||
const api = scope.run(() => useColorPicker(hsva))!
|
||||
return { hsva, api, stop: () => scope.stop() }
|
||||
}
|
||||
|
||||
describe('useColorPicker', () => {
|
||||
it('commits a valid hex and ignores malformed input', () => {
|
||||
const { hsva, api } = setup({ ...black })
|
||||
|
||||
api.hex.draft = '#00ff00'
|
||||
api.hex.commit()
|
||||
expect(hsva.value).toMatchObject({ h: 120, s: 100, v: 100 })
|
||||
|
||||
const unchanged = hsva.value
|
||||
api.hex.draft = 'zzz'
|
||||
api.hex.commit()
|
||||
expect(hsva.value).toBe(unchanged)
|
||||
})
|
||||
|
||||
it('clamps rgb channels before converting to hsv', () => {
|
||||
const { hsva, api } = setup({ ...black })
|
||||
|
||||
api.rgb.draft.r = 300
|
||||
api.rgb.commit()
|
||||
|
||||
expect(hsva.value).toMatchObject({ h: 0, s: 100, v: 100 })
|
||||
})
|
||||
|
||||
it('clamps alpha to the 0..100 range', () => {
|
||||
const { hsva, api } = setup({ h: 0, s: 0, v: 0, a: 100 })
|
||||
|
||||
api.alpha.draft = 150
|
||||
api.alpha.commit()
|
||||
|
||||
expect(hsva.value.a).toBe(100)
|
||||
})
|
||||
|
||||
it('syncs the draft from the model when not editing', async () => {
|
||||
const { hsva, api } = setup({ ...black })
|
||||
|
||||
hsva.value = { ...hsva.value, h: 0, s: 100, v: 100 }
|
||||
await nextTick()
|
||||
|
||||
expect(api.hex.draft).toBe('#ff0000')
|
||||
})
|
||||
|
||||
it('freezes the draft against external model changes while editing', async () => {
|
||||
const { hsva, api } = setup({ ...black })
|
||||
|
||||
api.hex.beginEdit()
|
||||
api.hex.draft = '#123456'
|
||||
hsva.value = { h: 0, s: 100, v: 100, a: 100 }
|
||||
await nextTick()
|
||||
|
||||
expect(api.hex.draft).toBe('#123456')
|
||||
})
|
||||
|
||||
it('resyncs the draft to the model on reset', () => {
|
||||
const { api } = setup({ ...black })
|
||||
|
||||
api.hex.beginEdit()
|
||||
api.hex.draft = '#abcdef'
|
||||
api.hex.reset()
|
||||
|
||||
expect(api.hex.draft).toBe('#000000')
|
||||
})
|
||||
})
|
||||
79
src/components/ui/color-picker/useColorPicker.ts
Normal file
79
src/components/ui/color-picker/useColorPicker.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { clamp } from 'es-toolkit'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
import type { HSVA } from '@/utils/colorUtil'
|
||||
import {
|
||||
hexToHsva,
|
||||
hsbToRgb,
|
||||
normalizeHex,
|
||||
rgbToHex,
|
||||
rgbToHsv
|
||||
} from '@/utils/colorUtil'
|
||||
|
||||
export const rgbChannels = [
|
||||
{ key: 'r', label: 'color.red' },
|
||||
{ key: 'g', label: 'color.green' },
|
||||
{ key: 'b', label: 'color.blue' }
|
||||
] as const
|
||||
|
||||
function useDraftField<T>(source: () => T, apply: (draft: T) => void) {
|
||||
const draft = ref(source()) as Ref<T>
|
||||
const isEditing = ref(false)
|
||||
|
||||
watch(source, (value) => {
|
||||
if (!isEditing.value) draft.value = value
|
||||
})
|
||||
|
||||
return reactive({
|
||||
draft,
|
||||
beginEdit: () => {
|
||||
isEditing.value = true
|
||||
},
|
||||
commit: () => apply(draft.value),
|
||||
reset: () => {
|
||||
isEditing.value = false
|
||||
draft.value = source()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useColorPicker(hsva: Ref<HSVA>) {
|
||||
const rgb = computed(() =>
|
||||
hsbToRgb({ h: hsva.value.h, s: hsva.value.s, b: hsva.value.v })
|
||||
)
|
||||
const hexString = computed(() => rgbToHex(rgb.value).toLowerCase())
|
||||
|
||||
const hex = useDraftField(
|
||||
() => hexString.value,
|
||||
(draft) => {
|
||||
const normalized = normalizeHex(draft)
|
||||
if (!normalized) return
|
||||
const next = hexToHsva(normalized)
|
||||
hsva.value = { ...hsva.value, h: next.h, s: next.s, v: next.v }
|
||||
}
|
||||
)
|
||||
|
||||
const rgbField = useDraftField(
|
||||
() => ({ ...rgb.value }),
|
||||
({ r, g, b }) => {
|
||||
if (![r, g, b].every(Number.isFinite)) return
|
||||
const hsv = rgbToHsv({
|
||||
r: clamp(Math.round(r), 0, 255),
|
||||
g: clamp(Math.round(g), 0, 255),
|
||||
b: clamp(Math.round(b), 0, 255)
|
||||
})
|
||||
hsva.value = { ...hsva.value, h: hsv.h, s: hsv.s, v: hsv.v }
|
||||
}
|
||||
)
|
||||
|
||||
const alpha = useDraftField(
|
||||
() => hsva.value.a,
|
||||
(draft) => {
|
||||
if (!Number.isFinite(draft)) return
|
||||
hsva.value = { ...hsva.value, a: clamp(Math.round(draft), 0, 100) }
|
||||
}
|
||||
)
|
||||
|
||||
return { hex, rgb: rgbField, alpha }
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
hsvaToHex,
|
||||
isTransparent,
|
||||
luminance,
|
||||
normalizeHex,
|
||||
parseToRgb,
|
||||
readableTextColor,
|
||||
rgbToHex,
|
||||
@@ -101,6 +102,22 @@ describe('colorUtil conversions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeHex', () => {
|
||||
it('canonicalizes 3- and 6-digit hex with or without a leading #', () => {
|
||||
expect(normalizeHex('#ff0000')).toBe('#ff0000')
|
||||
expect(normalizeHex('ff0000')).toBe('#ff0000')
|
||||
expect(normalizeHex('#0f0')).toBe('#0f0')
|
||||
expect(normalizeHex(' 0f0 ')).toBe('#0f0')
|
||||
})
|
||||
|
||||
it('rejects malformed input', () => {
|
||||
expect(normalizeHex('nothex')).toBeNull()
|
||||
expect(normalizeHex('#ff00')).toBeNull()
|
||||
expect(normalizeHex('#ff00000')).toBeNull()
|
||||
expect(normalizeHex('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('hexToInt', () => {
|
||||
it('converts 6-digit hex to packed integer', () => {
|
||||
expect(hexToInt('#ff0000')).toBe(0xff0000)
|
||||
|
||||
@@ -366,7 +366,7 @@ function parseToHSLA(color: string, format: ColorFormatInternal): HSLA | null {
|
||||
}
|
||||
}
|
||||
|
||||
function rgbToHsv({ r, g, b }: RGB): {
|
||||
export function rgbToHsv({ r, g, b }: RGB): {
|
||||
h: number
|
||||
s: number
|
||||
v: number
|
||||
@@ -397,6 +397,11 @@ function rgbToHsv({ r, g, b }: RGB): {
|
||||
return { h, s, v }
|
||||
}
|
||||
|
||||
export function normalizeHex(value: string): string | null {
|
||||
const digits = value.trim().replace(/^#/, '')
|
||||
return /^([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(digits) ? `#${digits}` : null
|
||||
}
|
||||
|
||||
export function hexToHsva(hex: string): HSVA {
|
||||
const normalized = hex.startsWith('#') ? hex : `#${hex}`
|
||||
let a = 100
|
||||
|
||||
Reference in New Issue
Block a user