颜色选择器用得多了,是时候也要学学怎么自己写一个了!通过canvas的ImageData像素矩阵可以轻易获取RGBA值,就是这么快趣!
1. 颜色选择器功能
- 色相条:修改hue色相角度值
- 颜色深浅板:在该hue色相角度下,修改饱和度和亮度调整颜色
- 透明度条:修改rgba的alpha透明度
- 色系渐变列表:颜色从浅到深渐变
- 取色器:通过EyeDropper滴管获取页面的颜色值
- 输入框:颜色值的文本同步在输入框,并且改变输入框的值对应解析同步到色相条,颜色深浅板,透明度条,色系渐变列表
用canvas画颜色选择器的好处就是不同格式颜色都会存在ImageData里面,转化rgba的像素矩阵,只要计算出对应的坐标就能取到对应的rgba颜色值,非常方便。
2. 用canvas画色相条
360度的色相角度,每60度取一个颜色值,饱和度100%,亮度50%,得到六个颜色,头尾都用色相0deg的红色,通过渐变即可得到一个标准色渐变条
const huelist = [
'hsl(60deg, 100%, 50%)',
'hsl(120deg, 100%, 50%)',
'hsl(180deg, 100%, 50%)',
'hsl(240deg, 100%, 50%)',
'hsl(300deg, 100%, 50%)'
];
const createBar = () => {
const canvas = barRef.value;
if (canvas) {
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
const ctx = canvas.getContext('2d');
if (ctx) {
const grd = ctx.createLinearGradient(0, 0, 0, canvas.height);//创建线性渐变
const len = huelist.length + 1;
grd.addColorStop(0.01, 'hsl(0deg, 100%, 50%)');//红色
huelist.forEach((a, i) => {
grd.addColorStop((i + 1) / len, a);
});
grd.addColorStop(0.99, 'hsl(360deg, 100%, 50%)');
ctx.fillStyle = grd;
ctx.fillRect(0, 0, canvas.width, canvas.height);
//截取宽度为1的像素矩阵
barImgData = ctx.getImageData(0, 0, 1, canvas.height).data;
}
}
};
注意: 开始的红色是0.01的位置,结束的红色是0.99的位置,避免取不到红色。
3. 用canvas画颜色深浅板
颜色深浅板是由色相条选中的标准色作为底色,然后添加一层从左向右的白色到透明的渐变,和一层从下向上的黑色到透明的渐变,这样就形成了不同亮度和饱和度的板面。
const createPanel = () => {
const canvas = panelRef.value;
if (canvas) {
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.fillStyle = state.bgColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
{//从左到右,白色到透明
const grd = ctx.createLinearGradient(0, 0, canvas.width, 0);
grd.addColorStop(0.01, 'white');
grd.addColorStop(0.99, 'rgba(255, 255, 255, 0)');
ctx.fillStyle = grd;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
{//从下到上,黑色到透明
const grd = ctx.createLinearGradient(0, canvas.height, 0, 0);
grd.addColorStop(0.01, 'black');
grd.addColorStop(0.99, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = grd;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
//面板像素矩阵
panelImgData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
//宽度具有的像素矩阵索引数量
panelWidthPx = canvas.width * 4;
}
}
};
注意: 跟上面的色相条一样,两层黑白渐变色头尾分别都是0.01和0.99,避免取不到边缘颜色。
4. 给色相条和颜色深浅板加上动作
在面板上点击,拖拽移动获取对应的坐标
export interface DragMoveConfig {
start?: (e: MouseEvent) => void;
move?: (e: MouseEvent) => void;
end?: (e: MouseEvent) => void;
}
export function onDragMove(config: DragMoveConfig) {
let el: HTMLElement;
const onMouseDown = (ev: MouseEvent) => {
ev.stopImmediatePropagation();
config.start && config.start(ev);
//禁用选择
document.onselectstart = () => false;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
const onMouseMove = (ev: MouseEvent) => {
//只返回el范围内的
ev.target === el && config.move && config.move(ev);
};
const onMouseUp = (ev: MouseEvent) => {
//只返回el范围内的
ev.target === el && config.end && config.end(ev);
//取消禁用选择
document.onselectstart = null;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
return {
init: (dom: HTMLElement) => {
el = dom;
el.addEventListener('mousedown', onMouseDown);
},
destroyed: () => {
el.removeEventListener('mousedown', onMouseDown);
}
};
}
色相条获取颜色
给色相条canvas添加动作,获取y坐标计算颜色值
let isLock=false;
const onBarMove = (ev: MouseEvent) => {
const canvas = barRef.value;
if (canvas) {
let y = ev.offsetY;
//避免超出范围
if (y < 0) {
y = 0;
} else if (y > canvas.height) {
y = canvas.height;
}
state.barY = y;//垂直方向坐标,用于计算色相值hue
const i = y * 4;
//barImgData宽度为1的像素矩阵,即标准色
const res = getImageDataColor(barImgData, i);
if (res) {
const { color } = res;
console.log(`%c color`, 'background:' + color, color);
//标准色
state.bgColor = color;
//节流
if (!isLock) {
isLock = true;
nextTick(() => {
//同步更新颜色深浅板
createPanel();
getPanelColor();
isLock = false;
});
}
}
}
};
//拖拽移动动作
const dragmoveBar = onDragMove({
start: onBarMove,
move: onBarMove,
end: onBarMove
});
onMounted(() => {
//初始化传入DOM
dragmoveBar.init(barRef.value as HTMLElement);
});
onBeforeUnmount(() => {
//销毁动作
dragmoveBar.destroyed();
});
- 色相条先绘制好,并截取到
barImgData宽度为1的像素矩阵,因为canvas已经将hsl转化为rgba,所以(y*4)及对应像素索引,即对应的标准色作为深浅板的底色。 - 色相条选中的底色改变后,深浅板也要重新绘制更新,做到操作同步
获取对应索引的像素矩阵: 像素矩阵以rgba4个元素为单位,避免错误索引,要进行规整化计算i = Math.floor(i / 4) * 4。
interface DataColor {
color: string;
r: number;
g: number;
b: number;
}
const getImageDataColor = (imgData: Uint8ClampedArray, i: number): DataColor | undefined => {
i = Math.floor(i / 4) * 4;
if (i >= 0 && i <= imgData.length - 5) {
const r = imgData[i];
const g = imgData[i + 1];
const b = imgData[i + 2];
const color = `rgb(${r},${g},${b})`;
return {
color,
r,
g,
b
};
}
};
颜色深浅板获取颜色
给颜色深浅板canvas添加动作,获取xy坐标计算颜色值
const onMovePanel = (ev: MouseEvent) => {
const canvas = panelRef.value;
if (canvas) {
let x = ev.offsetX;
//避免x超出范围
if (x < 0) {
x = 0;
} else if (x > canvas.width) {
x = canvas.width;
}
let y = ev.offsetY;
//避免y超出范围
if (y < 0) {
y = 0;
} else if (y > canvas.height) {
y = canvas.height;
}
state.panelX = x;
state.panelY = y;
//更新选中颜色
getPanelColor();
}
};
//拖拽移动动作
const dragmovePanel = onDragMove({
start: onMovePanel,
move: onMovePanel,
end: onMovePanel
});
onMounted(() => {
//初始化传入DOM
dragmovePanel.init(panelRef.value as HTMLElement);
});
onBeforeUnmount(() => {
//销毁动作
dragmovePanel.destroyed();
});
- 更新选中颜色:画板宽度具有的像素矩阵索引数量
panelWidthPx=panelWidth*4,前(y-1)行的数量是panelWidthPx*(y-1),第y行的像素索引数量(x-1)*4,最终的像素矩阵索引坐标是(y <= 1 ? 0 : (y - 1) * panelWidthPx) + (x <= 1 ? 0 : (x - 1) * 4)
const getPanelColor = () => {
const x = state.panelX,
y = state.panelY;
const i = (y <= 1 ? 0 : (y - 1) * panelWidthPx) + (x <= 1 ? 0 : (x - 1) * 4);
const res = getImageDataColor(panelImgData, i);
if (res) {
const { color, r, g, b } = res;
console.log(`%c color`, 'background:' + color, color);
state.colorSet.r = r;
state.colorSet.g = g;
state.colorSet.b = b;
//深浅板选择的颜色
state.color = formatColor(r, g, b, state.colorSet.a, props.format);
//更新色系渐变色
getGradients(r, g, b);
}
};
5. 色系渐变列表
<div class="color-gradient">
<span
:class="[state.activeGrd === i ? 'active' : '']"
v-for="(item, i) in state.colorGradients"
:key="i"
:style="{ backgroundColor: item.rgb }"
@click="onGrdColor(i)"
></span>
</div>
同色系即色相和饱和度相同,亮度不同形成不同的渐变色
const getGradients = (r: number, g: number, b: number) => {
let { h, s } = rgb2hsv(r, g, b);
h = Math.floor(h);
s = Math.floor(s);
const grdList = [];
const len = 11;
let minDist = 255 * 3;
let idx = 0;
for (let i = 0; i <= len; i++) {
const c = hsl2rgb(h, s, Math.floor((i / len) * 100));
grdList.push({ rgb: `rgb(${c.r},${c.g},${c.b})`, ...c });
const d = Math.abs(c.r - r) + Math.abs(c.g - g) + Math.abs(c.b - b);
if (d < minDist) {
minDist = d;
idx = i;
}
}
state.colorGradients = grdList;
state.activeGrd = idx;
};
- 改变颜色深浅板会同步更新,获取色系渐变列表
- 将100%的亮度分成12份,对应不同明暗程度的颜色。可能不能完全跟选中的颜色一致,但色系相同,可以计算rgb的距离最短距离,来确认最近的颜色作为激活状态
通过色系渐变条选中颜色
const onGrdColor = (idx: number) => {
const { r, g, b } = state.colorGradients[idx];
const { s1, v1 } = rgb2hsv(r, g, b);
//同步更新颜色深浅板
const canvas = panelRef.value;
if (canvas) {
state.panelX = Math.floor(s1 * canvas.width);
state.panelY = Math.floor((1 - v1) * canvas.height);
}
//选中渐变列表的索引
state.activeGrd = idx;
state.colorSet.r = r;
state.colorSet.g = g;
state.colorSet.b = b;
state.color = formatColor(r, g, b, state.colorSet.a, props.format);
};
6. 透明度条
<div class="color-alpha">
<div
class="color-alpha-bar"
ref="alphaRef"
:style="{
background: `linear-gradient(to right, ${resultColor.rgb} 0.1%, rgba(${resultColor.num}, 0) 99.9%)`
}"
></div>
<span
class="color-alpha-thumb"
:style="{ left: state.alphaX + 'px', background: resultColor.rgba }"
></span>
</div>
给透明度条添加拖拽移动动作,获取x坐标计算出[0-1]范围的透明度。
//规范显示,避免小数位太多
const getAlpha = (a: number) => {
return a === 1 ? 1 : a === 0 ? 0 : Number(a.toFixed(2));
};
const onAlphaMove = (ev: MouseEvent) => {
if (alphaRef.value) {
const w = alphaRef.value.offsetWidth;
let x = ev.offsetX;
//透明度范围
if (x < 0) {
x = 0;
} else if (x > w) {
x = w;
}
state.alphaX = x;
state.colorSet.a = getAlpha(1 - x / w);
const { r, g, b, a } = state.colorSet;
state.color = formatColor(r, g, b, a, props.format);
}
};
const dragmoveAlpha = onDragMove({
start: onAlphaMove,
move: onAlphaMove,
end: onAlphaMove
});
onMounted(() => {
dragmoveAlpha.init(alphaRef.value as HTMLElement);
});
onBeforeUnmount(() => {
dragmoveAlpha.destroyed();
});
7. 解析输入框修改的颜色
//统一转换成rgba
const parseColor = (newColor?: string) => {
const c = newColor || props.modelValue;
const res = getRgba(c);
if (res) {
const { r, g, b, a, color } = res;
state.color = formatColor(r, g, b, a, props.format);
const hsv = rgb2hsv(r, g, b);
//计算色相条选中位置
if (barRef.value) {
state.barY = Math.floor(hsv.h1 * barRef.value.height);
const barColor = getImageDataColor(barImgData, state.barY * 4);
if (barColor) state.bgColor = barColor.color;
}
//绘制颜色深浅板
createPanel();
const canvas = panelRef.value;
if (canvas) {
//计算颜色深浅板选中的位置
state.panelX = Math.floor(hsv.s1 * canvas.width);
state.panelY = Math.floor((1 - hsv.v1) * canvas.height);
if (alphaRef.value) state.alphaX = Math.floor((1 - a) * alphaRef.value.offsetWidth);
state.colorSet.r = r;
state.colorSet.g = g;
state.colorSet.b = b;
state.colorSet.a = a;
//获取渐变列表
getGradients(r, g, b);
}
}
};
//输入框修改后解析文本
const onInputColor = (ev: Event) => {
const target = ev.target as HTMLInputElement;
if (target) parseColor(target.value);
};
颜色格式转换
解析颜色值
function str2Num(str: string) {
const c = str.match(/[0-9]+/);
if (c) {
return Number(c[0]);
}
return parseFloat(str);
}
export const getRgba = (str: string) => {
if (!str) return;
if (str.indexOf('hsl(') === 0) {
const s = str
.slice(4, str.length - 1)
.replace(/\s/g, '')
.split(',')
.map((a: string) => str2Num(a));
const c = hsl2rgb(s[0], s[1], s[2]);
return { ...c, a: 1, color: `rgba(${c.r},${c.g},${c.b},1)` };
} else if (str.indexOf('rgba(') === 0) {
const s = str
.slice(5, str.length - 1)
.replace(/\s/g, '')
.split(',')
.map((a: string) => Number(a));
return { r: s[0], g: s[1], b: s[2], a: s[3], color: `rgba(${s[0]},${s[1]},${s[2]},${s[3]})` };
} else if (str.indexOf('rgb(') === 0) {
const s = str
.slice(4, str.length - 1)
.replace(/\s/g, '')
.split(',')
.map((a: string) => Number(a));
return { r: s[0], g: s[1], b: s[2], a: 1, color: `rgba(${s[0]},${s[1]},${s[2]},1)` };
} else if (str.indexOf('#') === 0) {
let res;
if (str.length === 7) res = str;
else if (str.length === 4) res = `#${str[1]}${str[1]}${str[2]}${str[2]}${str[3]}${str[3]}`;
else if (str.length === 3) res = `#${str[1]}${str[1]}${str[1]}${str[2]}${str[2]}${str[2]}`;
if (res) {
const c = hex2rgb(res);
return { ...c, a: 1, color: `rgba(${c.r},${c.g},${c.b},1)` };
}
}
};
格式化颜色值
export const formatColor = (
r: number,
g: number,
b: number,
a: number,
type: string = 'rgba'
): string => {
if (type === 'rgba') {
return `rgba(${r},${g},${b},${a})`;
} else if (type === 'rgb') {
return `rgb(${r},${g},${b})`;
} else if (type === 'hex') {
return rgb2hex(r, g, b);
} else if (type === 'hsl') {
const { h, s, l } = rgb2hsl(r, g, b);
return `hsl(${h}deg,${s}%,${l}%)`;
}
return `rgba(${r},${g},${b},${a})`;
};
- 为了方便理解,所以采用了rgb作为中介。
8. 自定义颜色列表
将颜色值传入parseColor即可
9. 添加EyeDropper滴管取色
启用浏览器页面滴管取色工具,可以参考官网:developer.mozilla.org/zh-CN/docs/…
let eyeDropper: unknown;
const onDropper = () => {
if (!eyeDropper) eyeDropper = new EyeDropper();
state.isDropper = true;
eyeDropper
.open()
.then((result: any) => {
state.isDropper = false;
parseColor(result.sRGBHex);
})
.catch((e: Error) => {
console.log(e);
state.isDropper = false;
});
};
将滴管取到的颜色通过parseColor解析即可
10. 最终效果
canvas颜色选择器功能基本完成啦。虽然颜色之间的转换可能有点精度丢失,但无伤大雅。
11. GitHub地址
https://github.com/xiaolidan00/color-picker
参考