制作一个颜色选择器

颜色选择器预览效果

需求:网站主题色,使用自定义的色彩范围

关键字:CSS Variables, HEX & RGB

  1. 从需求拿到了 9 个颜色,UI 做成了渐变的颜色空间,需要用户可以从这个颜色空间内选择颜色。9 个颜色分别是#ff001b,#ff00de,#a400fc,#1600fb,#00fdfe,#00ff1d,#a2ff0a,#f3ff00,#ff5e00。使用 9 个颜色,我们可以很容易构造成一个横向的渐变条, 如上图所示。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.colorBar {
width: 100%;
height: 20px;
border-radius: 10px;
background: linear-gradient(
to right,
#ff001b,
#ff00de,
#a400fc,
#1600fb,
#00fdfe,
#00ff1d,
#a2ff0a,
#f3ff00,
#ff5e00
)
}
  1. 选择器中放置一个滑轨,已便于在色彩范围内滑动选取颜色。这里我使用了 vant 的 slider 组件。(轮子已经很多了,不需要再造了)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<div class="colorBar">
<van-slider v-model="progress" :min="1" :max="999" @input="onDragEnd($event)" />
</div>

.van-slider {
width: calc(100% - 20px);
margin-left: 10px;
background-color: transparent;
height: 20px;
.van-slider__bar {
height: 20px !important;
background-color: transparent;
}
.van-slider__button {
background-color: transparent;
border: solid 3px #fff;
}
}
  1. 滑轨移动,选择颜色,这里为了减少计算量,在滑轨停下时才计算颜色。UI 给出的是 HEX 格式(16 进制 RRGGBB)的颜色,我们可以很容易的把它转换成 RGB 颜色(R: 0 - 255, G: 0 - 255, B: 0 - 255), 便于使用 10 进制的计算,更容易理解。9 个颜色之间,实际上包含 8 个颜色区域,滑轨也是在这 8 个区域内移动,在计算当前的颜色时,我们可以计算当前滑轨在它所在的 8 个颜色区域内的偏移值,在用当前颜色区域的起始值加上颜色的偏移值,就可以得到具体的颜色值。因此,可以写出以下 method 计算当前颜色 rgb 值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
onDragEnd: _.throttle(function(progress) {
// 此处加一个节流优化性能
this.getCurrentColorRgb(progress)
}, 100),
getColorRgb(progress) {
// 例如当前滑轨progress = 80, 80/125 < 1,那么此时它应该处于第1个颜色和第2个颜色组成的色彩空间内。
const quotient = Math.floor(progress / 125) // 计算当前区间 125 是当前滑轨总共可以移动1000个单位,这1000个单位内共有8个颜色空间,每个空间则包含125个单位

const currentIndex = Math.ceil(progress / 125)
const percentage = (progress / 125) % 1
const color1 = this.colorZone[currentIndex - 1]

const color2 = this.colorZone[currentIndex]
if (percentage === 0) {
this.getColorHex(color2)
return color2
}
if (color1) {
let result
const diffObj = {
r:
color1.r >= color2.r
? -parseInt((color1.r - color2.r) * percentage)
: parseInt((color2.r - color1.r) * percentage),
g:
color1.g >= color2.g
? -parseInt((color1.g - color2.g) * percentage)
: parseInt((color2.g - color1.g) * percentage),
b:
color1.b >= color2.b
? -parseInt((color1.b - color2.b) * percentage)
: parseInt((color2.b - color1.b) * percentage)
}

result = {
r: color1.r + diffObj.r,
g: color1.g + diffObj.g,
b: color1.b + diffObj.b
}
this.getColorHex(result)
return result
} else {
const defaultColor = {
r: 35,
g: 142,
b: 250
}
this.getColorHex(defaultColor)
return defaultColor
}
}
  1. 上一个步骤得到的 rgb 可以使用 CSS 的 variables 设置网站主题色。例如我设置--themeColor: rgb(254, 168, 23),对应地,在需要用到颜色变量的地方使用background-color: var(--themeColor)或者color: var(--themeColor)即可。
  2. 完结,撒花。

完整的组件代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
<template>
<div class="site-theme-colorPicker">
<div class="colorBar" :class="{ hideSliderButton: hideSliderButton }">
<van-slider v-model="progress" :min="1" :max="999" @input="onDragEnd($event)" />
</div>
<div class="current-color">
<div class="current-preview" :style="`background-color: #${this.currentColorHex}`"></div>
<van-field class="color-input" clearable type="text" v-model="currentColorHex" @blur="onCurrentColorHexChanged()">
<div slot="left-icon">#</div>
</van-field>
</div>
</div>
</template>

<script>
import _ from 'lodash'
import Vue from 'vue'
import { Slider } from 'vant'

Vue.use(Slider)
export default {
props: {
value: {
type: String
}
},
data() {
return {
hideSliderButton: false,
progress: 1,
currentColorHex: 'FF001B',
colorRange: []
}
},
computed: {
colorZone() {
const arr = [
{
r: 255,
g: 0,
b: 27
},
{
r: 255,
g: 0,
b: 222
},
{
r: 164,
g: 0,
b: 252
},
{
r: 22,
g: 0,
b: 251
},
{
r: 0,
g: 253,
b: 254
},
{
r: 0,
g: 255,
b: 29
},
{
r: 162,
g: 255,
b: 10
},
{
r: 243,
g: 255,
b: 0
},
{
r: 255,
g: 94,
b: 0
}
]
return arr
},
currentIndex() {
return Math.ceil(this.progress / 125)
},
percentage() {
return (this.progress / 125) % 1
},
currentColorRgb() {
return this.getCurrentColorRgb(this.progress)
}
},
watch: {
progress() {
if (this.hideSliderButton) {
this.hideSliderButton = false
}
}
},
mounted() {
this.initColorRange()
this.currentColorHex = this.value.replace(/#/g, '')
this.findColorPosition(this.getRgbColor(this.value))
},
methods: {
getRgbColor(hexColor) {
let hex = hexColor.replace(/#/g, '')
return {
r: parseInt(hex.substring(0, 2), 16),
g: parseInt(hex.substring(2, 4), 16),
b: parseInt(hex.substring(4, 6), 16)
}
},
onDragEnd: _.throttle(function(progress) {
this.getCurrentColorRgb(progress)
this.$emit('input', `#${this.currentColorHex}`)
}, 100),
getCurrentColorRgb(progress) {
const currentIndex = Math.ceil(progress / 125)
const percentage = (progress / 125) % 1
const color1 = this.colorZone[currentIndex - 1]

const color2 = this.colorZone[currentIndex]
if (percentage === 0) {
this.getColorHex(color2)
return color2
}
if (color1) {
let result
const diffObj = {
r:
color1.r >= color2.r
? -parseInt((color1.r - color2.r) * percentage)
: parseInt((color2.r - color1.r) * percentage),
g:
color1.g >= color2.g
? -parseInt((color1.g - color2.g) * percentage)
: parseInt((color2.g - color1.g) * percentage),
b:
color1.b >= color2.b
? -parseInt((color1.b - color2.b) * percentage)
: parseInt((color2.b - color1.b) * percentage)
}

result = {
r: color1.r + diffObj.r,
g: color1.g + diffObj.g,
b: color1.b + diffObj.b
}
this.getColorHex(result)
return result
} else {
const defaultColor = {
r: 35,
g: 142,
b: 250
}
this.getColorHex(defaultColor)
return defaultColor
}
},
getColorHex(rgb) {
const formatStep1 = {
r: rgb.r.toString(16),
g: rgb.g.toString(16),
b: rgb.b.toString(16)
}
const formatStep2 = {
r: formatStep1.r.length < 2 ? `0${formatStep1.r}` : formatStep1.r,
g: formatStep1.g.length < 2 ? `0${formatStep1.g}` : formatStep1.g,
b: formatStep1.b.length < 2 ? `0${formatStep1.b}` : formatStep1.b
}

this.currentColorHex = _.toUpper(
`${formatStep2.r.toString(16)}${formatStep2.g.toString(16)}${formatStep2.b.toString(16)}`
)
},
onCurrentColorHexChanged(hex) {
const colorHex = hex ? hex.replace(/#/g, '') : this.currentColorHex.replace(/#/g, '')
const colorHexReg = /([0-9a-fA-F]{2}){3}/g
const isColor = colorHexReg.test(colorHex)
if (isColor) {
this.$emit('input', hex)
}
},
initColorRange() {
const localColorRange = window.localStorage.getItem('siteTheme-colorRange')
if (localColorRange) {
this.colorRange = JSON.parse(localColorRange)
} else {
_.forEach(this.colorZone, (color, index) => {
if (color && this.colorZone[index + 1]) {
this.calcColorRange(color, this.colorZone[index + 1], index)
}
})
}
},
cartesianProduct(arr) {
return arr.reduce((a, b) => a.map(x => b.map(y => x.concat(y))).reduce((a, b) => a.concat(b), []), [[]])
},
calcColorRange(color1, color2, index) {
const rDiff = color1.r >= color2.r ? color1.r - color2.r : color2.r - color1.r
const gDiff = color1.g >= color2.g ? color1.g - color2.g : color2.g - color1.g
const bDiff = color1.b >= color2.b ? color1.b - color2.b : color2.b - color1.b
let arr = {
r: [],
g: [],
b: []
}
_.forEach(_.range(rDiff), r => {
const num = r
arr.r.push({
...color1,
r: color1.r >= color2.r ? color1.r - num : color1.r + num
})
})
_.forEach(_.range(gDiff), g => {
const num = g
arr.g.push({
...color1,
g: color1.g >= color2.r ? color1.g - num : color1.g + num
})
})
_.forEach(_.range(bDiff), b => {
const num = b
arr.b.push({
...color1,
b: color1.b >= color2.b ? color1.b - num : color1.b + num
})
})
let arr1 = this.cartesianProduct(
_.filter([arr.r, arr.g, arr.b], item => {
return item.length
})
)
this.colorRange.push({
index: index + 1,
start: color1,
end: color2,
data: _.flatMap(arr1)
})
if (index === 7) {
window.localStorage.setItem('siteTheme-colorRange', JSON.stringify(this.colorRange))
}
},
findColorPosition(rgb) {
const rangeIndex = _.findIndex(this.colorRange, item => {
return _.find(item.data, obj => {
return _.isEqual(obj, rgb)
})
})
let accIndex = -1
if (rangeIndex !== -1) {
accIndex = _.findIndex(this.colorRange[rangeIndex].data, item => {
return _.isEqual(item, rgb)
})
}
if (rangeIndex !== -1 && accIndex !== -1) {
this.progress = 125 * (rangeIndex + accIndex / _.get(this.colorRange, `[${rangeIndex}].data.length`, 0))
} else {
this.hideSliderButton = true
}
}
}
}
</script>

<style lang="scss">
.site-theme-colorPicker {
padding: 42px 0 45px 0;
position: relative;
&::after {
content: '';
display: block;
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 1px;
background-color: #e3e3e3;
transform: scaleY(0.5);
}
.colorBar {
width: 100%;
height: 20px;
border-radius: 10px;
background: linear-gradient(
to right,
#ff001b,
#ff00de,
#a400fc,
#1600fb,
#00fdfe,
#00ff1d,
#a2ff0a,
#f3ff00,
#ff5e00
);
.van-slider {
width: calc(100% - 20px);
margin-left: 10px;
background-color: transparent;
height: 20px;
.van-slider__bar {
height: 20px !important;
background-color: transparent;
}
.van-slider__button {
background-color: transparent;
border: solid 3px #fff;
}
}
&.hideSliderButton {
.van-slider {
.van-slider__button {
background-color: transparent;
opacity: 0;
}
}
}
}
.current-color {
margin-top: 25px;
display: flex;
.current-preview {
width: 70px;
height: 30px;
border-radius: 3px;
transition: background-color ease-in-out 0.1s;
}
.color-input {
margin-left: 10px;
width: 120px;
height: 30px;
border: solid 1px #d8d8d8;
border-radius: 3px;
padding: 0 9px;
font-size: 14px;
.van-field__left-icon {
color: #999;
}
}
}
}
</style>