mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-13 09:00:16 +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:
68
src/components/ui/color-picker/ColorPicker.stories.ts
Normal file
68
src/components/ui/color-picker/ColorPicker.stories.ts
Normal 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" />'
|
||||
})
|
||||
}
|
||||
125
src/components/ui/color-picker/ColorPicker.vue
Normal file
125
src/components/ui/color-picker/ColorPicker.vue
Normal 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>
|
||||
81
src/components/ui/color-picker/ColorPickerPanel.vue
Normal file
81
src/components/ui/color-picker/ColorPickerPanel.vue
Normal 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>
|
||||
@@ -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>
|
||||
91
src/components/ui/color-picker/ColorPickerSlider.vue
Normal file
91
src/components/ui/color-picker/ColorPickerSlider.vue
Normal 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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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" />'
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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