From cacd8ea53ab374a9b994b6b7eca96cf10df51cd7 Mon Sep 17 00:00:00 2001
From: kooriookami <38392315+kooriookami@users.noreply.github.com>
Date: Tue, 3 Jun 2025 23:05:00 +0800
Subject: [PATCH] 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
---
.../__tests__/color-picker.test.tsx | 36 +-
.../color-picker/src/color-picker.vue | 13 +-
.../color-picker/src/components/predefine.vue | 16 +-
.../color-picker/src/utils/color.ts | 369 ++----------------
4 files changed, 71 insertions(+), 363 deletions(-)
diff --git a/packages/components/color-picker/__tests__/color-picker.test.tsx b/packages/components/color-picker/__tests__/color-picker.test.tsx
index e6f5cbcd15..cd6bb6aca3 100644
--- a/packages/components/color-picker/__tests__/color-picker.test.tsx
+++ b/packages/components/color-picker/__tests__/color-picker.test.tsx
@@ -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(() => (
))
@@ -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(() => )
await wrapper.find('.el-color-picker__trigger').trigger('click')
@@ -54,11 +54,11 @@ describe('Color-picker', () => {
const input = document.querySelector(
'.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(() => (
))
@@ -68,7 +68,7 @@ describe('Color-picker', () => {
const input = document.querySelector(
'.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('.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('.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(() => )
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(() => )
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(() => )
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(() => )
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(() => )
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(() => (
))
@@ -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(() => (
))
@@ -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(() => (
@@ -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(() => (
{
ref: 'inputRef',
})
expect(colorPickerWrapper.vm.color.enableAlpha).toBe(false)
- expect(color.value).toBe('#00FF00')
+ expect(color.value).toBe('#00ff00')
expect(
customInput.find('.el-input__inner').element.value
- ).toBe('#00FF00')
+ ).toBe('#00ff00')
wrapper.unmount()
})
diff --git a/packages/components/color-picker/src/color-picker.vue b/packages/components/color-picker/src/color-picker.vue
index 9b85548acb..3e595384ff 100644
--- a/packages/components/color-picker/src/color-picker.vue
+++ b/packages/components/color-picker/src/color-picker.vue
@@ -39,8 +39,7 @@
v-model="customInput"
:validate-event="false"
size="small"
- @keyup.enter="handleConfirm"
- @blur="handleConfirm"
+ @change="handleConfirm"
/>
{
})
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) {
diff --git a/packages/components/color-picker/src/components/predefine.vue b/packages/components/color-picker/src/components/predefine.vue
index 9d440a573d..105c8ae7f4 100644
--- a/packages/components/color-picker/src/components/predefine.vue
+++ b/packages/components/color-picker/src/components/predefine.vue
@@ -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
})
}
diff --git a/packages/components/color-picker/src/utils/color.ts b/packages/components/color-picker/src/utils/color.ts
index 84bc45805c..03934644fc 100644
--- a/packages/components/color-picker/src/utils/color.ts
+++ b/packages/components/color-picker/src/utils/color.ts
@@ -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
-//
-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 = {
- 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 = {
- 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)
+ : ''
}
}