mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-27 09:45:13 +00:00
Add Vue Color Picker widget (#4114)
This commit is contained in:
551
src/components/graph/widgets/ColorPickerWidget.vue
Normal file
551
src/components/graph/widgets/ColorPickerWidget.vue
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
<template>
|
||||||
|
<div class="color-picker-widget">
|
||||||
|
<div
|
||||||
|
:style="{ width: widgetWidth }"
|
||||||
|
class="flex items-center gap-2 p-2 rounded-lg border border-surface-300 bg-surface-0 w-full"
|
||||||
|
>
|
||||||
|
<!-- Color picker preview and popup trigger -->
|
||||||
|
<div class="relative">
|
||||||
|
<div
|
||||||
|
:style="{ backgroundColor: parsedColor.hex }"
|
||||||
|
class="w-4 h-4 rounded border-2 border-surface-400 cursor-pointer hover:border-surface-500 transition-colors"
|
||||||
|
title="Click to edit color"
|
||||||
|
@click="toggleColorPicker"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Color picker popover -->
|
||||||
|
<Popover ref="colorPickerPopover" class="!p-0">
|
||||||
|
<ColorPicker
|
||||||
|
v-model="colorValue"
|
||||||
|
format="hex"
|
||||||
|
class="border-none"
|
||||||
|
@update:model-value="updateColorFromPicker"
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Color component inputs -->
|
||||||
|
<div class="flex gap-5">
|
||||||
|
<InputNumber
|
||||||
|
v-for="component in colorComponents"
|
||||||
|
:key="component.name"
|
||||||
|
v-model="component.value"
|
||||||
|
:min="component.min"
|
||||||
|
:max="component.max"
|
||||||
|
:step="component.step"
|
||||||
|
:placeholder="component.name"
|
||||||
|
class="flex-1 text-xs max-w-8"
|
||||||
|
:pt="{
|
||||||
|
pcInputText: {
|
||||||
|
root: {
|
||||||
|
class: 'max-w-12'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
:show-buttons="false"
|
||||||
|
size="small"
|
||||||
|
@update:model-value="updateColorFromComponents"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Format dropdown -->
|
||||||
|
<Select
|
||||||
|
v-model="currentFormat"
|
||||||
|
:options="colorFormats"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
class="w-24 ml-3"
|
||||||
|
size="small"
|
||||||
|
:pt="{
|
||||||
|
pcInputText: {
|
||||||
|
root: {
|
||||||
|
class: 'max-w-12'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
@update:model-value="handleFormatChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ColorPicker from 'primevue/colorpicker'
|
||||||
|
import InputNumber from 'primevue/inputnumber'
|
||||||
|
import Popover from 'primevue/popover'
|
||||||
|
import Select from 'primevue/select'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||||
|
|
||||||
|
interface ColorComponent {
|
||||||
|
name: string
|
||||||
|
value: number
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
step: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedColor {
|
||||||
|
hex: string
|
||||||
|
rgb: { r: number; g: number; b: number; a: number }
|
||||||
|
hsl: { h: number; s: number; l: number; a: number }
|
||||||
|
hsv: { h: number; s: number; v: number; a: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
type ColorFormat = 'rgba' | 'hsla' | 'hsva' | 'hex'
|
||||||
|
|
||||||
|
const modelValue = defineModel<string>({ required: true })
|
||||||
|
|
||||||
|
const { widget } = defineProps<{
|
||||||
|
widget: ComponentWidget<string>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Color format options
|
||||||
|
const colorFormats = [
|
||||||
|
{ label: 'RGBA', value: 'rgba' },
|
||||||
|
{ label: 'HSLA', value: 'hsla' },
|
||||||
|
{ label: 'HSVA', value: 'hsva' },
|
||||||
|
{ label: 'HEX', value: 'hex' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Current format state
|
||||||
|
const currentFormat = ref<ColorFormat>('rgba')
|
||||||
|
|
||||||
|
// Color picker popover reference
|
||||||
|
const colorPickerPopover = ref()
|
||||||
|
|
||||||
|
// Internal color value for the PrimeVue ColorPicker
|
||||||
|
const colorValue = ref<string>('#ff0000')
|
||||||
|
|
||||||
|
// Calculate widget width based on node size with padding
|
||||||
|
const widgetWidth = computed(() => {
|
||||||
|
if (!widget?.node?.size) return 'auto'
|
||||||
|
|
||||||
|
const nodeWidth = widget.node.size[0]
|
||||||
|
const WIDGET_PADDING = 16 // Account for padding around the widget
|
||||||
|
const maxWidth = Math.max(200, nodeWidth - WIDGET_PADDING) // Minimum 200px, but scale with node
|
||||||
|
|
||||||
|
return `${maxWidth}px`
|
||||||
|
})
|
||||||
|
|
||||||
|
// Parse color string to various formats
|
||||||
|
const parsedColor = computed<ParsedColor>(() => {
|
||||||
|
const value = modelValue.value || '#ff0000'
|
||||||
|
|
||||||
|
// Handle different input formats
|
||||||
|
if (value.startsWith('#')) {
|
||||||
|
return parseHexColor(value)
|
||||||
|
} else if (value.startsWith('rgb')) {
|
||||||
|
return parseRgbaColor(value)
|
||||||
|
} else if (value.startsWith('hsl')) {
|
||||||
|
return parseHslaColor(value)
|
||||||
|
} else if (value.startsWith('hsv')) {
|
||||||
|
return parseHsvaColor(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseHexColor('#ff0000') // Default fallback
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get color components based on current format
|
||||||
|
const colorComponents = computed<ColorComponent[]>(() => {
|
||||||
|
const { rgb, hsl, hsv } = parsedColor.value
|
||||||
|
|
||||||
|
switch (currentFormat.value) {
|
||||||
|
case 'rgba':
|
||||||
|
return [
|
||||||
|
{ name: 'R', value: rgb.r, min: 0, max: 255, step: 1 },
|
||||||
|
{ name: 'G', value: rgb.g, min: 0, max: 255, step: 1 },
|
||||||
|
{ name: 'B', value: rgb.b, min: 0, max: 255, step: 1 },
|
||||||
|
{ name: 'A', value: rgb.a, min: 0, max: 1, step: 0.01 }
|
||||||
|
]
|
||||||
|
case 'hsla':
|
||||||
|
return [
|
||||||
|
{ name: 'H', value: hsl.h, min: 0, max: 360, step: 1 },
|
||||||
|
{ name: 'S', value: hsl.s, min: 0, max: 100, step: 1 },
|
||||||
|
{ name: 'L', value: hsl.l, min: 0, max: 100, step: 1 },
|
||||||
|
{ name: 'A', value: hsl.a, min: 0, max: 1, step: 0.01 }
|
||||||
|
]
|
||||||
|
case 'hsva':
|
||||||
|
return [
|
||||||
|
{ name: 'H', value: hsv.h, min: 0, max: 360, step: 1 },
|
||||||
|
{ name: 'S', value: hsv.s, min: 0, max: 100, step: 1 },
|
||||||
|
{ name: 'V', value: hsv.v, min: 0, max: 100, step: 1 },
|
||||||
|
{ name: 'A', value: hsv.a, min: 0, max: 1, step: 0.01 }
|
||||||
|
]
|
||||||
|
case 'hex':
|
||||||
|
return [] // No components for hex format
|
||||||
|
default:
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch for changes in modelValue to update colorValue
|
||||||
|
watch(
|
||||||
|
() => modelValue.value,
|
||||||
|
(newValue) => {
|
||||||
|
if (newValue && newValue !== colorValue.value) {
|
||||||
|
colorValue.value = parsedColor.value.hex
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Toggle color picker popover
|
||||||
|
function toggleColorPicker(event: Event) {
|
||||||
|
colorPickerPopover.value.toggle(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update color from picker
|
||||||
|
function updateColorFromPicker(value: string) {
|
||||||
|
colorValue.value = value
|
||||||
|
updateModelValue(parseHexColor(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update color from component inputs
|
||||||
|
function updateColorFromComponents() {
|
||||||
|
const components = colorComponents.value
|
||||||
|
if (components.length === 0) return
|
||||||
|
|
||||||
|
let newColor: ParsedColor
|
||||||
|
const rgbFromHsl = hslToRgb(
|
||||||
|
components[0].value,
|
||||||
|
components[1].value,
|
||||||
|
components[2].value,
|
||||||
|
components[3].value
|
||||||
|
)
|
||||||
|
const rgbFromHsv = hsvToRgb(
|
||||||
|
components[0].value,
|
||||||
|
components[1].value,
|
||||||
|
components[2].value,
|
||||||
|
components[3].value
|
||||||
|
)
|
||||||
|
|
||||||
|
switch (currentFormat.value) {
|
||||||
|
case 'rgba':
|
||||||
|
newColor = {
|
||||||
|
hex: rgbToHex(
|
||||||
|
components[0].value,
|
||||||
|
components[1].value,
|
||||||
|
components[2].value
|
||||||
|
),
|
||||||
|
rgb: {
|
||||||
|
r: components[0].value,
|
||||||
|
g: components[1].value,
|
||||||
|
b: components[2].value,
|
||||||
|
a: components[3].value
|
||||||
|
},
|
||||||
|
hsl: rgbToHsl(
|
||||||
|
components[0].value,
|
||||||
|
components[1].value,
|
||||||
|
components[2].value,
|
||||||
|
components[3].value
|
||||||
|
),
|
||||||
|
hsv: rgbToHsv(
|
||||||
|
components[0].value,
|
||||||
|
components[1].value,
|
||||||
|
components[2].value,
|
||||||
|
components[3].value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'hsla':
|
||||||
|
newColor = {
|
||||||
|
hex: rgbToHex(rgbFromHsl.r, rgbFromHsl.g, rgbFromHsl.b),
|
||||||
|
rgb: rgbFromHsl,
|
||||||
|
hsl: {
|
||||||
|
h: components[0].value,
|
||||||
|
s: components[1].value,
|
||||||
|
l: components[2].value,
|
||||||
|
a: components[3].value
|
||||||
|
},
|
||||||
|
hsv: rgbToHsv(rgbFromHsl.r, rgbFromHsl.g, rgbFromHsl.b, rgbFromHsl.a)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'hsva':
|
||||||
|
newColor = {
|
||||||
|
hex: rgbToHex(rgbFromHsv.r, rgbFromHsv.g, rgbFromHsv.b),
|
||||||
|
rgb: rgbFromHsv,
|
||||||
|
hsl: rgbToHsl(rgbFromHsv.r, rgbFromHsv.g, rgbFromHsv.b, rgbFromHsv.a),
|
||||||
|
hsv: {
|
||||||
|
h: components[0].value,
|
||||||
|
s: components[1].value,
|
||||||
|
v: components[2].value,
|
||||||
|
a: components[3].value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateModelValue(newColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle format change
|
||||||
|
function handleFormatChange() {
|
||||||
|
updateModelValue(parsedColor.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the model value based on current format
|
||||||
|
function updateModelValue(color: ParsedColor) {
|
||||||
|
switch (currentFormat.value) {
|
||||||
|
case 'rgba':
|
||||||
|
modelValue.value = `rgba(${color.rgb.r}, ${color.rgb.g}, ${color.rgb.b}, ${color.rgb.a})`
|
||||||
|
break
|
||||||
|
case 'hsla':
|
||||||
|
modelValue.value = `hsla(${color.hsl.h}, ${color.hsl.s}%, ${color.hsl.l}%, ${color.hsl.a})`
|
||||||
|
break
|
||||||
|
case 'hsva':
|
||||||
|
modelValue.value = `hsva(${color.hsv.h}, ${color.hsv.s}%, ${color.hsv.v}%, ${color.hsv.a})`
|
||||||
|
break
|
||||||
|
case 'hex':
|
||||||
|
modelValue.value = color.hex
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
colorValue.value = color.hex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color parsing functions
|
||||||
|
function parseHexColor(hex: string): ParsedColor {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16)
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16)
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16)
|
||||||
|
const a = hex.length === 9 ? parseInt(hex.slice(7, 9), 16) / 255 : 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
hex,
|
||||||
|
rgb: { r, g, b, a },
|
||||||
|
hsl: rgbToHsl(r, g, b, a),
|
||||||
|
hsv: rgbToHsv(r, g, b, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRgbaColor(rgba: string): ParsedColor {
|
||||||
|
const match = rgba.match(/rgba?\(([^)]+)\)/)
|
||||||
|
if (!match) return parseHexColor('#ff0000')
|
||||||
|
|
||||||
|
const [r, g, b, a = 1] = match[1].split(',').map((v) => parseFloat(v.trim()))
|
||||||
|
|
||||||
|
return {
|
||||||
|
hex: rgbToHex(r, g, b),
|
||||||
|
rgb: { r, g, b, a },
|
||||||
|
hsl: rgbToHsl(r, g, b, a),
|
||||||
|
hsv: rgbToHsv(r, g, b, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHslaColor(hsla: string): ParsedColor {
|
||||||
|
const match = hsla.match(/hsla?\(([^)]+)\)/)
|
||||||
|
if (!match) return parseHexColor('#ff0000')
|
||||||
|
|
||||||
|
const [h, s, l, a = 1] = match[1]
|
||||||
|
.split(',')
|
||||||
|
.map((v) => parseFloat(v.trim().replace('%', '')))
|
||||||
|
const rgb = hslToRgb(h, s, l, a)
|
||||||
|
|
||||||
|
return {
|
||||||
|
hex: rgbToHex(rgb.r, rgb.g, rgb.b),
|
||||||
|
rgb,
|
||||||
|
hsl: { h, s, l, a },
|
||||||
|
hsv: rgbToHsv(rgb.r, rgb.g, rgb.b, rgb.a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHsvaColor(hsva: string): ParsedColor {
|
||||||
|
const match = hsva.match(/hsva?\(([^)]+)\)/)
|
||||||
|
if (!match) return parseHexColor('#ff0000')
|
||||||
|
|
||||||
|
const [h, s, v, a = 1] = match[1]
|
||||||
|
.split(',')
|
||||||
|
.map((val) => parseFloat(val.trim().replace('%', '')))
|
||||||
|
const rgb = hsvToRgb(h, s, v, a)
|
||||||
|
|
||||||
|
return {
|
||||||
|
hex: rgbToHex(rgb.r, rgb.g, rgb.b),
|
||||||
|
rgb,
|
||||||
|
hsl: rgbToHsl(rgb.r, rgb.g, rgb.b, rgb.a),
|
||||||
|
hsv: { h, s, v, a }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color conversion utility functions
|
||||||
|
function rgbToHex(r: number, g: number, b: number): string {
|
||||||
|
return (
|
||||||
|
'#' +
|
||||||
|
[r, g, b].map((x) => Math.round(x).toString(16).padStart(2, '0')).join('')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToHsl(r: number, g: number, b: number, a: number) {
|
||||||
|
r /= 255
|
||||||
|
g /= 255
|
||||||
|
b /= 255
|
||||||
|
|
||||||
|
const max = Math.max(r, g, b)
|
||||||
|
const min = Math.min(r, g, b)
|
||||||
|
let h: number, s: number
|
||||||
|
const l = (max + min) / 2
|
||||||
|
|
||||||
|
if (max === min) {
|
||||||
|
h = s = 0
|
||||||
|
} else {
|
||||||
|
const d = max - min
|
||||||
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||||
|
|
||||||
|
switch (max) {
|
||||||
|
case r:
|
||||||
|
h = (g - b) / d + (g < b ? 6 : 0)
|
||||||
|
break
|
||||||
|
case g:
|
||||||
|
h = (b - r) / d + 2
|
||||||
|
break
|
||||||
|
case b:
|
||||||
|
h = (r - g) / d + 4
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
h = 0
|
||||||
|
}
|
||||||
|
h /= 6
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
h: Math.round(h * 360),
|
||||||
|
s: Math.round(s * 100),
|
||||||
|
l: Math.round(l * 100),
|
||||||
|
a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToHsv(r: number, g: number, b: number, a: number) {
|
||||||
|
r /= 255
|
||||||
|
g /= 255
|
||||||
|
b /= 255
|
||||||
|
|
||||||
|
const max = Math.max(r, g, b)
|
||||||
|
const min = Math.min(r, g, b)
|
||||||
|
let h: number
|
||||||
|
const v = max
|
||||||
|
const s = max === 0 ? 0 : (max - min) / max
|
||||||
|
|
||||||
|
if (max === min) {
|
||||||
|
h = 0
|
||||||
|
} else {
|
||||||
|
const d = max - min
|
||||||
|
switch (max) {
|
||||||
|
case r:
|
||||||
|
h = (g - b) / d + (g < b ? 6 : 0)
|
||||||
|
break
|
||||||
|
case g:
|
||||||
|
h = (b - r) / d + 2
|
||||||
|
break
|
||||||
|
case b:
|
||||||
|
h = (r - g) / d + 4
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
h = 0
|
||||||
|
}
|
||||||
|
h /= 6
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
h: Math.round(h * 360),
|
||||||
|
s: Math.round(s * 100),
|
||||||
|
v: Math.round(v * 100),
|
||||||
|
a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hslToRgb(h: number, s: number, l: number, a: number) {
|
||||||
|
h /= 360
|
||||||
|
s /= 100
|
||||||
|
l /= 100
|
||||||
|
|
||||||
|
const hue2rgb = (p: number, q: number, t: number) => {
|
||||||
|
if (t < 0) t += 1
|
||||||
|
if (t > 1) t -= 1
|
||||||
|
if (t < 1 / 6) return p + (q - p) * 6 * t
|
||||||
|
if (t < 1 / 2) return q
|
||||||
|
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
let r: number, g: number, b: number
|
||||||
|
|
||||||
|
if (s === 0) {
|
||||||
|
r = g = b = l
|
||||||
|
} else {
|
||||||
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
||||||
|
const p = 2 * l - q
|
||||||
|
r = hue2rgb(p, q, h + 1 / 3)
|
||||||
|
g = hue2rgb(p, q, h)
|
||||||
|
b = hue2rgb(p, q, h - 1 / 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
r: Math.round(r * 255),
|
||||||
|
g: Math.round(g * 255),
|
||||||
|
b: Math.round(b * 255),
|
||||||
|
a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hsvToRgb(h: number, s: number, v: number, a: number) {
|
||||||
|
h /= 360
|
||||||
|
s /= 100
|
||||||
|
v /= 100
|
||||||
|
|
||||||
|
const c = v * s
|
||||||
|
const x = c * (1 - Math.abs(((h * 6) % 2) - 1))
|
||||||
|
const m = v - c
|
||||||
|
|
||||||
|
let r: number, g: number, b: number
|
||||||
|
|
||||||
|
if (h < 1 / 6) {
|
||||||
|
;[r, g, b] = [c, x, 0]
|
||||||
|
} else if (h < 2 / 6) {
|
||||||
|
;[r, g, b] = [x, c, 0]
|
||||||
|
} else if (h < 3 / 6) {
|
||||||
|
;[r, g, b] = [0, c, x]
|
||||||
|
} else if (h < 4 / 6) {
|
||||||
|
;[r, g, b] = [0, x, c]
|
||||||
|
} else if (h < 5 / 6) {
|
||||||
|
;[r, g, b] = [x, 0, c]
|
||||||
|
} else {
|
||||||
|
;[r, g, b] = [c, 0, x]
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
r: Math.round((r + m) * 255),
|
||||||
|
g: Math.round((g + m) * 255),
|
||||||
|
b: Math.round((b + m) * 255),
|
||||||
|
a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.color-picker-widget {
|
||||||
|
min-height: 40px;
|
||||||
|
overflow: hidden; /* Prevent overflow outside node bounds */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure proper styling for small inputs */
|
||||||
|
:deep(.p-inputnumber-input) {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.p-select) {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.p-select .p-select-label) {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.p-colorpicker) {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
207
src/composables/widgets/useColorPickerWidget.ts
Normal file
207
src/composables/widgets/useColorPickerWidget.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import ColorPickerWidget from '@/components/graph/widgets/ColorPickerWidget.vue'
|
||||||
|
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||||
|
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||||
|
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||||
|
|
||||||
|
const PADDING = 8
|
||||||
|
|
||||||
|
interface ColorPickerWidgetOptions {
|
||||||
|
defaultValue?: string
|
||||||
|
defaultFormat?: 'rgba' | 'hsla' | 'hsva' | 'hex'
|
||||||
|
minHeight?: number
|
||||||
|
serialize?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useColorPickerWidget = (
|
||||||
|
options: ColorPickerWidgetOptions = {}
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
defaultValue = 'rgba(255, 0, 0, 1)',
|
||||||
|
minHeight = 48,
|
||||||
|
serialize = true
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||||
|
node: LGraphNode,
|
||||||
|
inputSpec: InputSpec
|
||||||
|
) => {
|
||||||
|
// Initialize widget value as string
|
||||||
|
const widgetValue = ref<string>(defaultValue)
|
||||||
|
|
||||||
|
// Create the main widget instance
|
||||||
|
const widget = new ComponentWidgetImpl<string>({
|
||||||
|
node,
|
||||||
|
name: inputSpec.name,
|
||||||
|
component: ColorPickerWidget,
|
||||||
|
inputSpec,
|
||||||
|
options: {
|
||||||
|
// Required: getter for widget value
|
||||||
|
getValue: () => widgetValue.value,
|
||||||
|
|
||||||
|
// Required: setter for widget value
|
||||||
|
setValue: (value: string | any) => {
|
||||||
|
// Handle different input types
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
// Validate and normalize color string
|
||||||
|
const normalizedValue = normalizeColorString(value)
|
||||||
|
if (normalizedValue) {
|
||||||
|
widgetValue.value = normalizedValue
|
||||||
|
}
|
||||||
|
} else if (typeof value === 'object' && value !== null) {
|
||||||
|
// Handle object input (e.g., from PrimeVue ColorPicker)
|
||||||
|
if (value.hex) {
|
||||||
|
widgetValue.value = value.hex
|
||||||
|
} else {
|
||||||
|
// Try to convert object to string
|
||||||
|
widgetValue.value = String(value)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback to string conversion
|
||||||
|
widgetValue.value = String(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Optional: minimum height for the widget
|
||||||
|
getMinHeight: () => minHeight + PADDING,
|
||||||
|
|
||||||
|
// Optional: whether to serialize this widget's value
|
||||||
|
serialize
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Register the widget with the node
|
||||||
|
addWidget(node, widget as any)
|
||||||
|
|
||||||
|
return widget
|
||||||
|
}
|
||||||
|
|
||||||
|
return widgetConstructor
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes color string inputs to ensure consistent format
|
||||||
|
* @param colorString - The input color string
|
||||||
|
* @returns Normalized color string or null if invalid
|
||||||
|
*/
|
||||||
|
function normalizeColorString(colorString: string): string | null {
|
||||||
|
if (!colorString || typeof colorString !== 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = colorString.trim()
|
||||||
|
|
||||||
|
// Handle hex colors
|
||||||
|
if (trimmed.startsWith('#')) {
|
||||||
|
if (/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(trimmed)) {
|
||||||
|
// Convert 3-digit hex to 6-digit
|
||||||
|
if (trimmed.length === 4) {
|
||||||
|
return (
|
||||||
|
'#' +
|
||||||
|
trimmed[1] +
|
||||||
|
trimmed[1] +
|
||||||
|
trimmed[2] +
|
||||||
|
trimmed[2] +
|
||||||
|
trimmed[3] +
|
||||||
|
trimmed[3]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return trimmed.toLowerCase()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle rgb/rgba colors
|
||||||
|
if (trimmed.startsWith('rgb')) {
|
||||||
|
const rgbaMatch = trimmed.match(
|
||||||
|
/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)/
|
||||||
|
)
|
||||||
|
if (rgbaMatch) {
|
||||||
|
const [, r, g, b, a] = rgbaMatch
|
||||||
|
const red = Math.max(0, Math.min(255, parseInt(r)))
|
||||||
|
const green = Math.max(0, Math.min(255, parseInt(g)))
|
||||||
|
const blue = Math.max(0, Math.min(255, parseInt(b)))
|
||||||
|
const alpha = a ? Math.max(0, Math.min(1, parseFloat(a))) : 1
|
||||||
|
|
||||||
|
if (alpha === 1) {
|
||||||
|
return `rgb(${red}, ${green}, ${blue})`
|
||||||
|
} else {
|
||||||
|
return `rgba(${red}, ${green}, ${blue}, ${alpha})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle hsl/hsla colors
|
||||||
|
if (trimmed.startsWith('hsl')) {
|
||||||
|
const hslaMatch = trimmed.match(
|
||||||
|
/hsla?\(\s*(\d+)\s*,\s*(\d+)%?\s*,\s*(\d+)%?\s*(?:,\s*([\d.]+))?\s*\)/
|
||||||
|
)
|
||||||
|
if (hslaMatch) {
|
||||||
|
const [, h, s, l, a] = hslaMatch
|
||||||
|
const hue = Math.max(0, Math.min(360, parseInt(h)))
|
||||||
|
const saturation = Math.max(0, Math.min(100, parseInt(s)))
|
||||||
|
const lightness = Math.max(0, Math.min(100, parseInt(l)))
|
||||||
|
const alpha = a ? Math.max(0, Math.min(1, parseFloat(a))) : 1
|
||||||
|
|
||||||
|
if (alpha === 1) {
|
||||||
|
return `hsl(${hue}, ${saturation}%, ${lightness}%)`
|
||||||
|
} else {
|
||||||
|
return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle hsv/hsva colors (custom format)
|
||||||
|
if (trimmed.startsWith('hsv')) {
|
||||||
|
const hsvaMatch = trimmed.match(
|
||||||
|
/hsva?\(\s*(\d+)\s*,\s*(\d+)%?\s*,\s*(\d+)%?\s*(?:,\s*([\d.]+))?\s*\)/
|
||||||
|
)
|
||||||
|
if (hsvaMatch) {
|
||||||
|
const [, h, s, v, a] = hsvaMatch
|
||||||
|
const hue = Math.max(0, Math.min(360, parseInt(h)))
|
||||||
|
const saturation = Math.max(0, Math.min(100, parseInt(s)))
|
||||||
|
const value = Math.max(0, Math.min(100, parseInt(v)))
|
||||||
|
const alpha = a ? Math.max(0, Math.min(1, parseFloat(a))) : 1
|
||||||
|
|
||||||
|
if (alpha === 1) {
|
||||||
|
return `hsv(${hue}, ${saturation}%, ${value}%)`
|
||||||
|
} else {
|
||||||
|
return `hsva(${hue}, ${saturation}%, ${value}%, ${alpha})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle named colors by converting to hex (basic set)
|
||||||
|
const namedColors: Record<string, string> = {
|
||||||
|
red: '#ff0000',
|
||||||
|
green: '#008000',
|
||||||
|
blue: '#0000ff',
|
||||||
|
white: '#ffffff',
|
||||||
|
black: '#000000',
|
||||||
|
yellow: '#ffff00',
|
||||||
|
cyan: '#00ffff',
|
||||||
|
magenta: '#ff00ff',
|
||||||
|
orange: '#ffa500',
|
||||||
|
purple: '#800080',
|
||||||
|
pink: '#ffc0cb',
|
||||||
|
brown: '#a52a2a',
|
||||||
|
gray: '#808080',
|
||||||
|
grey: '#808080'
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerTrimmed = trimmed.toLowerCase()
|
||||||
|
if (namedColors[lowerTrimmed]) {
|
||||||
|
return namedColors[lowerTrimmed]
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we can't parse it, return null
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export types for use in other modules
|
||||||
|
export type { ColorPickerWidgetOptions }
|
||||||
@@ -230,6 +230,16 @@
|
|||||||
"black": "Black",
|
"black": "Black",
|
||||||
"custom": "Custom"
|
"custom": "Custom"
|
||||||
},
|
},
|
||||||
|
"widgets": {
|
||||||
|
"colorPicker": {
|
||||||
|
"clickToEdit": "Click to edit color",
|
||||||
|
"selectColor": "Select a color",
|
||||||
|
"formatRGBA": "RGBA",
|
||||||
|
"formatHSLA": "HSLA",
|
||||||
|
"formatHSVA": "HSVA",
|
||||||
|
"formatHEX": "HEX"
|
||||||
|
}
|
||||||
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"Inputs": "Inputs",
|
"Inputs": "Inputs",
|
||||||
"Outputs": "Outputs",
|
"Outputs": "Outputs",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
|
|
||||||
import { useBadgedNumberInput } from '@/composables/widgets/useBadgedNumberInput'
|
import { useBadgedNumberInput } from '@/composables/widgets/useBadgedNumberInput'
|
||||||
import { useBooleanWidget } from '@/composables/widgets/useBooleanWidget'
|
import { useBooleanWidget } from '@/composables/widgets/useBooleanWidget'
|
||||||
|
import { useColorPickerWidget } from '@/composables/widgets/useColorPickerWidget'
|
||||||
import { useComboWidget } from '@/composables/widgets/useComboWidget'
|
import { useComboWidget } from '@/composables/widgets/useComboWidget'
|
||||||
import { useImageUploadWidget } from '@/composables/widgets/useImageUploadWidget'
|
import { useImageUploadWidget } from '@/composables/widgets/useImageUploadWidget'
|
||||||
import { useMarkdownWidget } from '@/composables/widgets/useMarkdownWidget'
|
import { useMarkdownWidget } from '@/composables/widgets/useMarkdownWidget'
|
||||||
@@ -288,5 +289,6 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
|
|||||||
MARKDOWN: transformWidgetConstructorV2ToV1(useMarkdownWidget()),
|
MARKDOWN: transformWidgetConstructorV2ToV1(useMarkdownWidget()),
|
||||||
COMBO: transformWidgetConstructorV2ToV1(useComboWidget()),
|
COMBO: transformWidgetConstructorV2ToV1(useComboWidget()),
|
||||||
IMAGEUPLOAD: useImageUploadWidget(),
|
IMAGEUPLOAD: useImageUploadWidget(),
|
||||||
BADGED_NUMBER: transformWidgetConstructorV2ToV1(useBadgedNumberInput())
|
BADGED_NUMBER: transformWidgetConstructorV2ToV1(useBadgedNumberInput()),
|
||||||
|
COLOR: transformWidgetConstructorV2ToV1(useColorPickerWidget())
|
||||||
}
|
}
|
||||||
|
|||||||
80
tests-ui/tests/composables/useColorPickerWidget.test.ts
Normal file
80
tests-ui/tests/composables/useColorPickerWidget.test.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { useColorPickerWidget } from '@/composables/widgets/useColorPickerWidget'
|
||||||
|
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('@/scripts/domWidget', () => ({
|
||||||
|
ComponentWidgetImpl: vi.fn().mockImplementation((config) => ({
|
||||||
|
...config,
|
||||||
|
name: config.name,
|
||||||
|
options: {
|
||||||
|
...config.options,
|
||||||
|
getValue: config.options.getValue,
|
||||||
|
setValue: config.options.setValue,
|
||||||
|
getMinHeight: config.options.getMinHeight
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
addWidget: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useColorPickerWidget', () => {
|
||||||
|
const createMockNode = (): LGraphNode =>
|
||||||
|
({
|
||||||
|
id: 1,
|
||||||
|
title: 'Test Node',
|
||||||
|
widgets: [],
|
||||||
|
addWidget: vi.fn()
|
||||||
|
}) as any
|
||||||
|
|
||||||
|
const createInputSpec = (overrides: Partial<InputSpec> = {}): InputSpec => ({
|
||||||
|
name: 'color',
|
||||||
|
type: 'COLOR',
|
||||||
|
...overrides
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates widget constructor with default options', () => {
|
||||||
|
const constructor = useColorPickerWidget()
|
||||||
|
expect(constructor).toBeDefined()
|
||||||
|
expect(typeof constructor).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates widget with default options', () => {
|
||||||
|
const constructor = useColorPickerWidget()
|
||||||
|
const node = createMockNode()
|
||||||
|
const inputSpec = createInputSpec()
|
||||||
|
|
||||||
|
const widget = constructor(node, inputSpec)
|
||||||
|
|
||||||
|
expect(widget).toBeDefined()
|
||||||
|
expect(widget.name).toBe(inputSpec.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates widget with custom default value', () => {
|
||||||
|
const constructor = useColorPickerWidget({
|
||||||
|
defaultValue: '#00ff00'
|
||||||
|
})
|
||||||
|
const node = createMockNode()
|
||||||
|
const inputSpec = createInputSpec()
|
||||||
|
|
||||||
|
const widget = constructor(node, inputSpec)
|
||||||
|
|
||||||
|
expect(widget).toBeDefined()
|
||||||
|
expect(widget.name).toBe(inputSpec.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates widget with custom options', () => {
|
||||||
|
const constructor = useColorPickerWidget({
|
||||||
|
minHeight: 60,
|
||||||
|
serialize: false
|
||||||
|
})
|
||||||
|
const node = createMockNode()
|
||||||
|
const inputSpec = createInputSpec()
|
||||||
|
|
||||||
|
const widget = constructor(node, inputSpec)
|
||||||
|
|
||||||
|
expect(widget).toBeDefined()
|
||||||
|
expect(widget.name).toBe(inputSpec.name)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user