diff --git a/src/components/ui/color-picker/ColorPicker.stories.ts b/src/components/ui/color-picker/ColorPicker.stories.ts new file mode 100644 index 0000000000..ceb0ab2606 --- /dev/null +++ b/src/components/ui/color-picker/ColorPicker.stories.ts @@ -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> = { + title: 'Components/ColorPicker', + component: ColorPicker, + tags: ['autodocs'], + parameters: { layout: 'padded' }, + decorators: [ + (story) => ({ + components: { story }, + template: '
' + }) + ] +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ({ + components: { ColorPicker }, + setup() { + const color = ref('#e06cbd') + return { color } + }, + template: '' + }) +} + +export const Red: Story = { + render: () => ({ + components: { ColorPicker }, + setup() { + const color = ref('#ff0000') + return { color } + }, + template: '' + }) +} + +export const Black: Story = { + render: () => ({ + components: { ColorPicker }, + setup() { + const color = ref('#000000') + return { color } + }, + template: '' + }) +} + +export const White: Story = { + render: () => ({ + components: { ColorPicker }, + setup() { + const color = ref('#ffffff') + return { color } + }, + template: '' + }) +} diff --git a/src/components/ui/color-picker/ColorPicker.vue b/src/components/ui/color-picker/ColorPicker.vue new file mode 100644 index 0000000000..1324724119 --- /dev/null +++ b/src/components/ui/color-picker/ColorPicker.vue @@ -0,0 +1,125 @@ + + + diff --git a/src/components/ui/color-picker/ColorPickerPanel.vue b/src/components/ui/color-picker/ColorPickerPanel.vue new file mode 100644 index 0000000000..d7f28678a1 --- /dev/null +++ b/src/components/ui/color-picker/ColorPickerPanel.vue @@ -0,0 +1,81 @@ + + + diff --git a/src/components/ui/color-picker/ColorPickerSaturationValue.vue b/src/components/ui/color-picker/ColorPickerSaturationValue.vue new file mode 100644 index 0000000000..d79d99f7bc --- /dev/null +++ b/src/components/ui/color-picker/ColorPickerSaturationValue.vue @@ -0,0 +1,66 @@ + + + diff --git a/src/components/ui/color-picker/ColorPickerSlider.vue b/src/components/ui/color-picker/ColorPickerSlider.vue new file mode 100644 index 0000000000..d1f30dc03c --- /dev/null +++ b/src/components/ui/color-picker/ColorPickerSlider.vue @@ -0,0 +1,91 @@ + + + diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 7d57bfba4e..0447fc85ea 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -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", diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.stories.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.stories.ts new file mode 100644 index 0000000000..2cb4628c74 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.stories.ts @@ -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 { + format: ColorFormat +} + +const meta: Meta = { + 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: + '
' + }) + ] +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: (args) => ({ + components: { WidgetColorPicker }, + setup() { + const { format } = toRefs(args) + const value = ref('#E06CBD') + const widget = computed>(() => ({ + name: 'color', + type: 'STRING', + value: '', + options: { format: format.value } + })) + return { value, widget } + }, + template: '' + }) +} + +export const RGBFormat: Story = { + args: { format: 'rgb' }, + render: (args) => ({ + components: { WidgetColorPicker }, + setup() { + const { format } = toRefs(args) + const value = ref('#3498DB') + const widget = computed>(() => ({ + name: 'color', + type: 'STRING', + value: '', + options: { format: format.value } + })) + return { value, widget } + }, + template: '' + }) +} + +export const HSBFormat: Story = { + args: { format: 'hsb' }, + render: (args) => ({ + components: { WidgetColorPicker }, + setup() { + const { format } = toRefs(args) + const value = ref('#2ECC71') + const widget = computed>(() => ({ + name: 'color', + type: 'STRING', + value: '', + options: { format: format.value } + })) + return { value, widget } + }, + template: '' + }) +} + +export const CustomColor: Story = { + render: (args) => ({ + components: { WidgetColorPicker }, + setup() { + const { format } = toRefs(args) + const value = ref('#FF5733') + const widget = computed>(() => ({ + name: 'accent_color', + type: 'STRING', + value: '', + options: { format: format.value } + })) + return { value, widget } + }, + template: '' + }) +} + +export const WithLabel: Story = { + render: () => ({ + components: { WidgetColorPicker }, + setup() { + const value = ref('#9B59B6') + const widget: SimplifiedWidget = { + name: 'background', + type: 'STRING', + value: '', + label: 'Background Color', + options: { format: 'hex' } + } + return { value, widget } + }, + template: '' + }) +} diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts index 1d20580ef8..c07083a3b1 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts @@ -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 = {}, + options: Record = {}, callback?: (value: string) => void ) => createMockWidget({ @@ -26,12 +25,10 @@ describe('WidgetColorPicker Value Binding', () => { const mountComponent = ( widget: SimplifiedWidget, - 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, - 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) }) }) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue index 41982d1f6b..d71ae69929 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue @@ -1,90 +1,42 @@ - diff --git a/src/utils/colorUtil.test.ts b/src/utils/colorUtil.test.ts index 36d8458970..e217c97462 100644 --- a/src/utils/colorUtil.test.ts +++ b/src/utils/colorUtil.test.ts @@ -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) diff --git a/src/utils/colorUtil.ts b/src/utils/colorUtil.ts index b9f2a0a3c1..0976c7aee5 100644 --- a/src/utils/colorUtil.ts +++ b/src/utils/colorUtil.ts @@ -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