refactor(components): [color-picker] Use tinycolor to calculate color (#20847)

* refactor(components): [color-picker] Use tinycolor to calculate color

* refactor(components): update

* refactor(components): update

* refactor(components): update

* fix(components): fix

closed #13239
This commit is contained in:
kooriookami
2025-06-03 23:05:00 +08:00
committed by GitHub
parent d720e5c31c
commit cacd8ea53a
4 changed files with 71 additions and 363 deletions

View File

@@ -35,7 +35,7 @@ describe('Color-picker', () => {
wrapper.unmount()
})
it('should show alpha slider when show-alpha=true', async () => {
const color = ref('#20A0FF')
const color = ref('#20a0ff')
const wrapper = mount(() => (
<ColorPicker v-model={color.value} show-alpha={true} />
))
@@ -46,7 +46,7 @@ describe('Color-picker', () => {
wrapper.unmount()
})
it('should show correct rgb value', async () => {
const color = ref('#20A0FF')
const color = ref('#20a0ff')
const wrapper = mount(() => <ColorPicker v-model={color.value} />)
await wrapper.find('.el-color-picker__trigger').trigger('click')
@@ -54,11 +54,11 @@ describe('Color-picker', () => {
const input = document.querySelector<HTMLInputElement>(
'.el-color-dropdown__value input'
)
expect(input!.value.trim().toUpperCase()).toEqual('#20A0FF')
expect(input!.value.trim()).toEqual('#20a0ff')
wrapper.unmount()
})
it('should show correct hex value contains alpha', async () => {
const color = ref('#20A0FFEE')
const color = ref('#20a0ffee')
const wrapper = mount(() => (
<ColorPicker v-model={color.value} color-format="hex" show-alpha />
))
@@ -68,7 +68,7 @@ describe('Color-picker', () => {
const input = document.querySelector<HTMLInputElement>(
'.el-color-dropdown__value input'
)
expect(input!.value.trim().toUpperCase()).toEqual('#20A0FFEE')
expect(input!.value.trim()).toEqual('#20a0ffee')
wrapper.unmount()
})
it('control alpha changes through keyboard', async () => {
@@ -106,7 +106,7 @@ describe('Color-picker', () => {
await wrapper.find('.el-color-picker__trigger').trigger('click')
document.querySelector<HTMLElement>('.el-color-dropdown__btn')?.click()
await nextTick()
expect(color.value).toEqual('#FF0000')
expect(color.value).toEqual('') // should be empty #13239
wrapper.unmount()
})
it('should pick a color contains alpha when confirm button click', async () => {
@@ -118,11 +118,11 @@ describe('Color-picker', () => {
await wrapper.find('.el-color-picker__trigger').trigger('click')
document.querySelector<HTMLElement>('.el-color-dropdown__btn')?.click()
await nextTick()
expect(color.value).toEqual('#FF0000FF')
expect(color.value).toEqual('') // should be empty #13239
wrapper.unmount()
})
it('should init the right color when open', async () => {
const color = ref('#0F0')
const color = ref('#0f0')
const wrapper = mount(() => <ColorPicker v-model={color.value} />)
const colorPickerWrapper = wrapper.findComponent(ColorPicker)
@@ -148,7 +148,7 @@ describe('Color-picker', () => {
wrapper.unmount()
})
it('should show color picker when click trigger', async () => {
const color = ref('#0F0')
const color = ref('#0f0')
const wrapper = mount(() => <ColorPicker v-model={color.value} />)
await wrapper.find('.el-color-picker__trigger').trigger('click')
@@ -157,7 +157,7 @@ describe('Color-picker', () => {
wrapper.unmount()
})
it('should clear a color when clear button click', async () => {
const color = ref('#0F0')
const color = ref('#0f0')
const wrapper = mount(() => <ColorPicker v-model={color.value} />)
await wrapper.find('.el-color-picker__trigger').trigger('click')
@@ -169,7 +169,7 @@ describe('Color-picker', () => {
wrapper.unmount()
})
it('should change hue when clicking the hue bar', async () => {
const color = ref('#F00')
const color = ref('#f00')
const wrapper = mount(() => <ColorPicker v-model={color.value} />)
await wrapper.find('.el-color-picker__trigger').trigger('click')
@@ -204,7 +204,7 @@ describe('Color-picker', () => {
wrapper.unmount()
})
it('should change hue when saturation is zero', async () => {
const color = ref('20A0FF')
const color = ref('20a0ff')
const wrapper = mount(() => <ColorPicker v-model={color.value} />)
await wrapper.find('.el-color-picker__trigger').trigger('click')
@@ -244,7 +244,7 @@ describe('Color-picker', () => {
wrapper.unmount()
})
it('should change alpha when clicking the alpha bar', async () => {
const color = ref('#F00')
const color = ref('#f00')
const wrapper = mount(() => (
<ColorPicker v-model={color.value} show-alpha />
))
@@ -423,7 +423,7 @@ describe('Color-picker', () => {
})
it('should not execute activeChange event', async () => {
const onActiveChange = vi.fn()
const color = ref('#20A0FF')
const color = ref('#20a0ff')
const wrapper = mount(() => (
<ColorPicker modelValue={color.value} onActiveChange={onActiveChange} />
))
@@ -434,7 +434,7 @@ describe('Color-picker', () => {
})
it('should update the colorFormat and selected color when the colorFormat prop changes', async () => {
const color = ref('#00FF00')
const color = ref('#00ff00')
const colorFormat = ref('hex')
const wrapper = mount(() => (
<ColorPicker v-model={color.value} color-format={colorFormat.value} />
@@ -454,7 +454,7 @@ describe('Color-picker', () => {
wrapper.unmount()
})
it('should update the selected color when the showAlpha prop changes', async () => {
const color = ref('#00FF00AA')
const color = ref('#00ff00aa')
const showAlpha = ref(true)
const wrapper = mount(() => (
<ColorPicker
@@ -471,10 +471,10 @@ describe('Color-picker', () => {
ref: 'inputRef',
})
expect(colorPickerWrapper.vm.color.enableAlpha).toBe(false)
expect(color.value).toBe('#00FF00')
expect(color.value).toBe('#00ff00')
expect(
customInput.find<HTMLInputElement>('.el-input__inner').element.value
).toBe('#00FF00')
).toBe('#00ff00')
wrapper.unmount()
})

View File

@@ -39,8 +39,7 @@
v-model="customInput"
:validate-event="false"
size="small"
@keyup.enter="handleConfirm"
@blur="handleConfirm"
@change="handleConfirm"
/>
</span>
<el-button
@@ -236,14 +235,8 @@ const btnKls = computed(() => {
})
function displayedRgb(color: Color, showAlpha: boolean) {
if (!(color instanceof Color)) {
throw new TypeError('color should be instance of _color Class')
}
const { r, g, b } = color.toRgb()
return showAlpha
? `rgba(${r}, ${g}, ${b}, ${color.get('alpha') / 100})`
: `rgb(${r}, ${g}, ${b})`
const { r, g, b, a } = color.toRgb()
return showAlpha ? `rgba(${r}, ${g}, ${b}, ${a})` : `rgb(${r}, ${g}, ${b})`
}
function setShowPicker(value: boolean) {

View File

@@ -6,7 +6,7 @@
:key="colors[index]"
:class="[
ns.e('color-selector'),
ns.is('alpha', item._alpha < 100),
ns.is('alpha', item.get('alpha') < 100),
{ selected: item.selected },
]"
@click="handleSelect(index)"
@@ -51,8 +51,9 @@ export default defineComponent({
watch(
() => currentColor.value,
(val) => {
const color = new Color()
color.fromString(val)
const color = new Color({
value: val,
})
rgbaColors.value.forEach((item) => {
item.selected = color.compare(item)
@@ -70,11 +71,10 @@ export default defineComponent({
function parseColors(colors: string[], color: Color) {
return colors.map((value) => {
const c = new Color()
c.enableAlpha = props.enableAlpha
c.format = 'rgba'
c.fromString(value)
c.selected = c.value === color.value
const c = new Color({
value,
})
c.selected = c.compare(color)
return c
})
}

View File

@@ -1,172 +1,6 @@
import { hasOwn, isString } from '@element-plus/utils'
const hsv2hsl = function (hue: number, sat: number, val: number) {
return [
hue,
(sat * val) / ((hue = (2 - sat) * val) < 1 ? hue : 2 - hue) || 0,
hue / 2,
]
}
// Need to handle 1.0 as 100%, since once it is a number, there is no difference between it and 1
// <http://stackoverflow.com/questions/7422072/javascript-how-to-detect-number-as-a-decimal-including-1-0>
const isOnePointZero = function (n: unknown) {
return isString(n) && n.includes('.') && Number.parseFloat(n) === 1
}
const isPercentage = function (n: unknown) {
return isString(n) && n.includes('%')
}
// Take input from [0, n] and return it as [0, 1]
const bound01 = function (value: number | string, max: number | string) {
if (isOnePointZero(value)) value = '100%'
const processPercent = isPercentage(value)
value = Math.min(max as number, Math.max(0, Number.parseFloat(`${value}`)))
// Automatically convert percentage into number
if (processPercent) {
value = Number.parseInt(`${value * (max as number)}`, 10) / 100
}
// Handle floating point rounding errors
if (Math.abs(value - (max as number)) < 0.000001) {
return 1
}
// Convert into [0, 1] range if it isn't already
return (value % (max as number)) / Number.parseFloat(max as string)
}
const INT_HEX_MAP: Record<string, string> = {
10: 'A',
11: 'B',
12: 'C',
13: 'D',
14: 'E',
15: 'F',
}
const hexOne = (value: number) => {
value = Math.min(Math.round(value), 255)
const high = Math.floor(value / 16)
const low = value % 16
return `${INT_HEX_MAP[high] || high}${INT_HEX_MAP[low] || low}`
}
const toHex = function ({ r, g, b }: { r: number; g: number; b: number }) {
if (Number.isNaN(+r) || Number.isNaN(+g) || Number.isNaN(+b)) return ''
return `#${hexOne(r)}${hexOne(g)}${hexOne(b)}`
}
const HEX_INT_MAP: Record<string, number> = {
A: 10,
B: 11,
C: 12,
D: 13,
E: 14,
F: 15,
}
const parseHexChannel = function (hex: string) {
if (hex.length === 2) {
return (
(HEX_INT_MAP[hex[0].toUpperCase()] || +hex[0]) * 16 +
(HEX_INT_MAP[hex[1].toUpperCase()] || +hex[1])
)
}
return HEX_INT_MAP[hex[1].toUpperCase()] || +hex[1]
}
const hsl2hsv = function (hue: number, sat: number, light: number) {
sat = sat / 100
light = light / 100
let smin = sat
const lmin = Math.max(light, 0.01)
// let sv
// let v
light *= 2
sat *= light <= 1 ? light : 2 - light
smin *= lmin <= 1 ? lmin : 2 - lmin
const v = (light + sat) / 2
const sv =
light === 0 ? (2 * smin) / (lmin + smin) : (2 * sat) / (light + sat)
return {
h: hue,
s: sv * 100,
v: v * 100,
}
}
// `rgbToHsv`
// Converts an RGB color value to HSV
// *Assumes:* r, g, and b are contained in the set [0, 255] or [0, 1]
// *Returns:* { h, s, v } in [0,1]
const rgb2hsv = (r: number, g: number, b: number) => {
r = bound01(r, 255)
g = bound01(g, 255)
b = bound01(b, 255)
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
let h: number
const v = max
const d = max - min
const s = max === 0 ? 0 : d / max
if (max === min) {
h = 0 // achromatic
} else {
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
}
}
h! /= 6
}
return { h: h! * 360, s: s * 100, v: v * 100 }
}
// `hsvToRgb`
// Converts an HSV color value to RGB.
// *Assumes:* h is contained in [0, 1] or [0, 360] and s and v are contained in [0, 1] or [0, 100]
// *Returns:* { r, g, b } in the set [0, 255]
const hsv2rgb = function (h: number, s: number, v: number) {
h = bound01(h, 360) * 6
s = bound01(s, 100)
v = bound01(v, 100)
const i = Math.floor(h)
const f = h - i
const p = v * (1 - s)
const q = v * (1 - f * s)
const t = v * (1 - (1 - f) * s)
const mod = i % 6
const r = [v, q, p, p, t, v][mod]
const g = [t, v, v, q, p, p][mod]
const b = [p, p, t, v, v, q][mod]
return {
r: Math.round(r * 255),
g: Math.round(g * 255),
b: Math.round(b * 255),
}
}
import { TinyColor } from '@ctrl/tinycolor'
import { hasOwn } from '@element-plus/utils'
import type { ColorFormats } from '@ctrl/tinycolor'
interface ColorOptions {
enableAlpha: boolean
@@ -178,9 +12,11 @@ export default class Color {
private _hue = 0
private _saturation = 100
private _value = 100
_alpha = 100
private _alpha = 100
private _tiny = new TinyColor()
private _isValid = false
public enableAlpha = false
public format = 'hex'
public format = ''
public value = ''
public selected?: boolean
@@ -207,185 +43,64 @@ export default class Color {
return
}
;(this as any)[`_${prop}`] = value
this._isValid = true
this.doOnChange()
}
get(prop: string) {
if (prop === 'alpha') {
return Math.floor(this[`_${prop}`])
if (['hue', 'saturation', 'value', 'alpha'].includes(prop)) {
return Math.round((this as any)[`_${prop}`])
}
return (this as any)[`_${prop}`]
}
toRgb() {
return hsv2rgb(this._hue, this._saturation, this._value)
return this._isValid ? this._tiny.toRgb() : { r: 255, g: 255, b: 255, a: 0 }
}
fromString(value: string) {
if (!value) {
const color = new TinyColor(value)
this._isValid = color.isValid
if (color.isValid) {
const { h, s, v, a } = color.toHsv()
this._hue = h
this._saturation = s * 100
this._value = v * 100
this._alpha = a * 100
} else {
this._hue = 0
this._saturation = 100
this._value = 100
this.doOnChange()
return
}
const fromHSV = (h: number, s: number, v: number) => {
this._hue = Math.max(0, Math.min(360, h))
this._saturation = Math.max(0, Math.min(100, s))
this._value = Math.max(0, Math.min(100, v))
this.doOnChange()
}
if (value.includes('hsl')) {
const parts = value
.replace(/hsla|hsl|\(|\)/gm, '')
.split(/\s|,/g)
.filter((val) => val !== '')
.map((val, index) =>
index > 2 ? Number.parseFloat(val) : Number.parseInt(val, 10)
)
if (parts.length === 4) {
// @ts-expect-error
this._alpha = Number.parseFloat(parts[3]) * 100
} else if (parts.length === 3) {
this._alpha = 100
}
if (parts.length >= 3) {
const { h, s, v } = hsl2hsv(parts[0], parts[1], parts[2])
fromHSV(h, s, v)
}
} else if (value.includes('hsv')) {
const parts = value
.replace(/hsva|hsv|\(|\)/gm, '')
.split(/\s|,/g)
.filter((val) => val !== '')
.map((val, index) =>
index > 2 ? Number.parseFloat(val) : Number.parseInt(val, 10)
)
if (parts.length === 4) {
// @ts-expect-error
this._alpha = Number.parseFloat(parts[3]) * 100
} else if (parts.length === 3) {
this._alpha = 100
}
if (parts.length >= 3) {
fromHSV(parts[0], parts[1], parts[2])
}
} else if (value.includes('rgb')) {
const parts = value
.replace(/rgba|rgb|\(|\)/gm, '')
.split(/\s|,/g)
.filter((val) => val !== '')
.map((val, index) =>
index > 2 ? Number.parseFloat(val) : Number.parseInt(val, 10)
)
if (parts.length === 4) {
// @ts-expect-error
this._alpha = Number.parseFloat(parts[3]) * 100
} else if (parts.length === 3) {
this._alpha = 100
}
if (parts.length >= 3) {
const { h, s, v } = rgb2hsv(parts[0], parts[1], parts[2])
fromHSV(h, s, v)
}
} else if (value.includes('#')) {
const hex = value.replace('#', '').trim()
if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$|^[0-9a-fA-F]{8}$/.test(hex))
return
let r: number, g: number, b: number
if (hex.length === 3) {
r = parseHexChannel(hex[0] + hex[0])
g = parseHexChannel(hex[1] + hex[1])
b = parseHexChannel(hex[2] + hex[2])
} else if (hex.length === 6 || hex.length === 8) {
r = parseHexChannel(hex.slice(0, 2))
g = parseHexChannel(hex.slice(2, 4))
b = parseHexChannel(hex.slice(4, 6))
}
if (hex.length === 8) {
this._alpha = (parseHexChannel(hex.slice(6)) / 255) * 100
} else if (hex.length === 3 || hex.length === 6) {
this._alpha = 100
}
const { h, s, v } = rgb2hsv(r!, g!, b!)
fromHSV(h, s, v)
this._alpha = 100
}
this.doOnChange()
}
compare(color: this) {
return (
Math.abs(color._hue - this._hue) < 2 &&
Math.abs(color._saturation - this._saturation) < 1 &&
Math.abs(color._value - this._value) < 1 &&
Math.abs(color._alpha - this._alpha) < 1
)
const compareColor = new TinyColor({
h: color._hue,
s: color._saturation / 100,
v: color._value / 100,
a: color._alpha / 100,
})
return this._tiny.equals(compareColor)
}
doOnChange() {
const { _hue, _saturation, _value, _alpha, format } = this
if (this.enableAlpha) {
switch (format) {
case 'hsl': {
const hsl = hsv2hsl(_hue, _saturation / 100, _value / 100)
this.value = `hsla(${_hue}, ${Math.round(
hsl[1] * 100
)}%, ${Math.round(hsl[2] * 100)}%, ${this.get('alpha') / 100})`
break
}
case 'hsv': {
this.value = `hsva(${_hue}, ${Math.round(_saturation)}%, ${Math.round(
_value
)}%, ${this.get('alpha') / 100})`
break
}
case 'hex': {
this.value = `${toHex(hsv2rgb(_hue, _saturation, _value))}${hexOne(
(_alpha * 255) / 100
)}`
break
}
default: {
const { r, g, b } = hsv2rgb(_hue, _saturation, _value)
this.value = `rgba(${r}, ${g}, ${b}, ${this.get('alpha') / 100})`
}
}
} else {
switch (format) {
case 'hsl': {
const hsl = hsv2hsl(_hue, _saturation / 100, _value / 100)
this.value = `hsl(${_hue}, ${Math.round(hsl[1] * 100)}%, ${Math.round(
hsl[2] * 100
)}%)`
break
}
case 'hsv': {
this.value = `hsv(${_hue}, ${Math.round(_saturation)}%, ${Math.round(
_value
)}%)`
break
}
case 'rgb': {
const { r, g, b } = hsv2rgb(_hue, _saturation, _value)
this.value = `rgb(${r}, ${g}, ${b})`
break
}
default: {
this.value = toHex(hsv2rgb(_hue, _saturation, _value))
}
}
const { _hue, _saturation, _value, _alpha, format, enableAlpha } = this
let _format = format || (enableAlpha ? 'rgb' : 'hex')
if (format === 'hex' && enableAlpha) {
_format = 'hex8'
}
this._tiny = new TinyColor({
h: _hue,
s: _saturation / 100,
v: _value / 100,
a: _alpha / 100,
})
this.value = this._isValid
? this._tiny.toString(_format as ColorFormats)
: ''
}
}