h5 canvas仿 Photoshop 繪製調色盤
本文采取的是最原始方式進行繪製,實現類似漸變的效果等都是最原始的。我進行了大量的迴圈繪製,而 js 的效率本來就不高。建議採用系統的漸變 api 進行繪製,靠底層的能力,效率應該會高出不少。但漸變的繪製也需要注意,畫布寬高太大,繪製太多也會產生效能障礙,具體沒有進行對比,這不知了。
以上須知。
漸變的繪製方法請移步,參考別人如何實現:
同時,漸變由於是底層控制的,色板的變化不一定是準確的。顏色可能不是均勻遞增的變化,也可能取不到某些值。
調色盤
不管是Photoshop還是其他繪圖軟體,通常都帶有調色的面板,方便取色。
Photoshop的調色盤:
PicPick的調色盤:
window的畫圖的調色盤:
PicPick和Photoshop的調色盤是上下顛倒的。這次要實現的是Photoshop的調色盤。先看window的畫圖調色盤,可以看到,顏色是呈現一級一級的變化,這就是繪製的原理了:按照一塊一塊顏色進行繪製,當色塊足夠小時,眼睛就不能分辨,就自然沒有那麼明顯的級別。
先看mdn上的一個示例:
程式碼:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
<title>Title</title>
</head>
<body>
<canvas id="canvas"></canvas>
<script>
(function draw() {
var ctx = document.getElementById('canvas').getContext('2d');
for (var i = 0; i < 6; i++) {
for (var j = 0; j < 6; j++) {
ctx.fillStyle = 'rgb(' + Math.floor(255 - 42.5 * i) + ',' +
Math.floor(255 - 42.5 * j) + ',0)';
ctx.fillRect(j * 25, i * 25, 25, 25);
}
}
})();
</script>
</body>
</html>
最後是通過填充小色塊完成的:
ctx.fillRect(j * 25, i * 25, 25, 25);
當色塊足夠小時,就是漸變了。
同時,文章也指出:“通過增加漸變的頻率,你還可以繪製出類似 Photoshop 裡面的那樣的調色盤。”
接下來,我要乾的就是這件事。
繪製調色盤
首先我們要明確:
- 色板的左上角始終是純白色
- 色板的最下部分始終是純黑色
- 色板始終只存在一個主色,且此主色在右上角達到最豔麗
- 橫軸方向,由灰白色均勻遞增至最豔麗的主色
- 縱軸方向,由最上方橫軸顏色,逐漸變暗至純黑色。
- 橫軸方向,主色所在通道不變,另兩色漸變
確認主色
顏色可分為rgb三原色,其中最大值即是主色。整體顏色便偏向它。當相等時,便是灰色。
橫軸方向,主色所在通道不變,另兩色漸變:
繪製和取色過程中,最上方的顏色,rgb三通道,主色所在通道始終不變,其他通道色,逐級遞增至當前彩虹色條所選顏色,即最右上方顏色。
計算每個格子佔領的畫素值和座標
由於每個座標每個座標的繪製,相當於點陣圖操作,寬高一大,耗時嚴重。所以,開頭就採取了分塊的方式繪製。這樣一來,給畫布x和y軸方向都均分為指定分數即可。
上方程式碼將寬高均分為128份,假如x=0,y=0 為白色(255,255,255),最右上方,顏色為(255,0,0),那麼每份就是2個顏色值。當x = 4;時,顏色值就是
r=255(主色),g = 4*2,b = 4*2
此時,一個畫素對應一個格子。座標自然也就得知。
當一個格子對應多個畫素時,計算好一個格子在x、y軸上對應多少個畫素,按比例即可求出第(x,y)個格子對應的畫布座標。
this.xScale = Math.ceil(this.canvas.width / 128);//向上取整方便繪製矩形
this.yScale = Math.ceil(this.canvas.height / 128);//向上取整方便繪製矩形
this.scaleWidth = this.canvas.width / this.xScale;
this.scaleHeight = this.canvas.height / this.yScale;
……
//算出座標,並填充
this.ctx.fillRect(x * xScale, y * yScale, xScale, yScale);
計算橫縱一個格子代表的顏色遞增值
將格子均分後,還要計算出一個格子的顏色值。存在三個通道,都計算出來。
var oneMaxXV = (255 - max) / w;
var oneMidXV = (255 - mid) / w;
var oneMinXV = (255 - min) / w;
繪製調色盤的基本流程就是如上了。
繪製彩虹漸變取色器
觀察Photoshop彩虹取色器,可以分為3個大段:
- 紅到藍
- 藍到綠
- 綠到紅
細分為:
- 紅到洋紅,洋紅到藍。rgb(255,0,0)到rgb(255,0,255) ,r不變,遞增直到255,。洋紅rgb(255,0,255)到藍,r遞減直到0,b不變。
- 藍到青,青到綠。rgb(0,0,255) 到 rgb(0,255,255) 到 rgb(0,255,0)。
- 綠到黃,黃到紅。rgb(0,255,0) 到 rgb(255,255,0) 到 rgb(255,0,0)。
這個的繪製就簡單了,由於x軸上的顏色都相等,即使是逐行1畫素的繪製,頂天了也就繪製1000次。可以不需要進行比例換算,逐個方塊進行繪製。
轉行繪製1px線條時,取線條顏色:
function getColor(y) {
var h = this.rainHeight;
//每個畫素顏色級別
var oneYV = 255 / (h / 6);
var r = 255;
var g = 0;
var b = 0;
if (y <= h / 3) {
//紅-洋紅
if (y <= h / 6) {
b = Math.floor(oneYV * y);
if (b > 255) {
b = 255;
}
r = 255;
}
//洋紅-藍
else {
r = 255 - Math.floor(oneYV * (y - h / 6));
if (r < 0) {
r = 0;
}
b = 255;
}
g = 0;
}
else if (y <= 2 * h / 3) {
if (y < 3 * h / 6) {
g = Math.floor(oneYV * (y - 2 * h / 6));
if (g > 255) {
g = 255;
}
b = 255;
} else {
b = 255 - Math.floor(oneYV * (y - 3 * h / 6));
if (b < 0) {
b = 0;
}
g = 255;
}
r = 0;
}
else {
if (y < 5 * h / 6) {
r = Math.floor(oneYV * (y - 4 * h / 6));
if (r > 255) {
r = 255;
}
g = 255;
} else {
g = 255 - Math.floor(oneYV * (y - 5 * h / 6));
if (g < 0) {
g = 0;
}
r = 255;
}
b = 0;
}
return {r: r, g: g, b: b};
}
取得顏色,從上往下逐行繪製即可:
for (var y = 0; y <= h; y++) {
var col = this.getColor(y);
var color = 'rgb(' + col.r + ',' + col.g + ',' + col.b + ")";
ctx.strokeStyle = color;
// console.log(color);
ctx.beginPath();
ctx.moveTo(this.pickLineWidth, y + offY);
ctx.lineTo(this.pickLineWidth + w, y + offY);
ctx.stroke();
}
調色盤的吸管(小球)和彩虹漸變的吸管(標尺)
結合滑鼠和手勢事件
需要注意:
1、調色盤的吸管(小球),是可以隨著滑鼠或手指的移動而移動的,為了避免頻繁重繪調色盤這個大頭,吸管不應該和色板共用一個畫布來繪製。可以另開一個畫布或html元素,當作上方圖層,限制在色板範圍內移動即可。
2、彩虹漸變的吸管(標尺)就可以隨意一些了,由於繪製內容小,重繪的地方也不多,放在同一畫布也可以。但是吸管如果覆蓋到彩虹漸變條,那還是建議另開一個畫布或html元素。重繪畢竟沒有那麼快。
3、移動端為了相容pc的頁面,手指觸控式螢幕幕時,會觸發滑鼠的事件onmousedown 和 ontouchstart。故不應當同時監聽 touch 和 mouse 事件,否則可能因為響應到兩次,而產生一些問題。如通過判斷touch事件是否支援來判別裝置是PC段還是移動端,然後分別監聽。弊端是除錯時,pc切換手機模擬需要重新整理。
結果和預覽
預覽圖:
pc動態:
mobile動態:
顏色變化多,動圖錄制相當糟糕。忽略就好。
全部程式碼:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>色板</title>
<!--<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"/>-->
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<div style="margin-top:50px;text-align: center">
<div>
<div style="display: inline-block">
<canvas id="platter" width="300px" height="300px"></canvas>
<canvas id="global" style="position: absolute;visibility: hidden" width="10px" height="10px"></canvas>
</div>
<canvas id="colorBar" width="40px" height="300px"></canvas>
</div>
<div>
<div>
<span>色板:</span><span id="platterTextId"></span>
</div>
<div>
<span>彩虹:</span><span id="colorBarTextId"></span>
</div>
</div>
</div>
<script>
var barTextEl = document.getElementById('colorBarTextId');
var platterTextEl = document.getElementById('platterTextId');
var Platter = (function () {
function Platter() {
this.sR = 0;
this.sG = 255;
this.sB = 255;
this.canvas = document.getElementById('platter');
this.ctx = this.canvas.getContext('2d');
this.xScale = Math.ceil(this.canvas.width / 128);//向上取整方便繪製矩形
this.yScale = Math.ceil(this.canvas.height / 128);//向上取整方便繪製矩形
this.scaleWidth = this.canvas.width / this.xScale;
this.scaleHeight = this.canvas.height / this.yScale;
//xScale 和 yScale 即小色塊的值,如果不取整,繪製時導致重疊變深。畫素誤差導致。
// this.scaleWidth = 128;
// this.scaleHeight = 128;
// this.xScale = this.canvas.width / this.scaleWidth;
// this.yScale = this.canvas.height / this.scaleHeight;
var gCanvas = document.getElementById('global');
var gCtx = gCanvas.getContext('2d');
var gRadius = gCanvas.width > gCanvas.height ? gCanvas.height / 2 : gCanvas.width / 2;
this.gCanvas = gCanvas;
this.gCtx = gCtx;
this.gRadius = gRadius;
this.updateGlobal("#000000");
var minX = this.canvas.offsetLeft - gCanvas.width / 2;
var maxX = minX + this.canvas.offsetWidth;
var minY = this.canvas.offsetTop - gCanvas.height / 2;
var maxY = minY + this.canvas.offsetHeight;
var pLeft = this.canvas.offsetLeft;
var pTop = this.canvas.offsetTop;
var that = this;
var isApp = 'ontouchstart' in window;
if (isApp) {
// 判斷後,除錯時切換移動預覽,需要重新整理才生效
// 如果全部繫結,移動端下,會先響應ontouchstart,再響應onmousedown
this.canvas.ontouchstart = handleTouchEvent;
gCanvas.ontouchstart = handleTouchEvent;
} else {
this.canvas.onmousedown = handleMouseEvent;
gCanvas.onmousedown = handleMouseEvent;
}
function handleMouseEvent(e) {
dragGlobal(e);
// console.log(e);
document.onmousemove = function (e) {
dragGlobal(e);
e.preventDefault ? e.preventDefault() : (e.returnValue = false)
}
document.onmouseup = function (e) {
document.onmousemove = null;
document.onmouseup = null;
}
}
function handleTouchEvent(e) {
dragGlobal(e.touches[0]);
// console.log(e);
document.ontouchmove = function (e) {
dragGlobal(e.changedTouches[0]);
// console.log(e);
}
document.ontouchend = function (e) {
document.ontouchmove = null;
document.ontouchend = null;
}
}
function dragGlobal(e) {
if ('hidden' === gCanvas.style.visibility) {
gCanvas.style.visibility = 'visible';
}
var x = e.clientX - gCanvas.width / 2;
var y = e.clientY - gCanvas.height / 2;
if (x < minX) {
x = minX;
}
else if (x > maxX) {
x = maxX;
}
if (y < minY) {
y = minY;
}
else if (y > maxY) {
y = maxY;
}
gCanvas.style.left = x + "px";
gCanvas.style.top = y + "px";
var cx = x - pLeft + gCanvas.width / 2;
var cy = y - pTop + gCanvas.height / 2;
var col = that.getColor(cx, cy);
that.pickPoint = {x: cx, y: cy};
platterTextEl.innerText = 'x:' + cx + " y:" + cy + " rgb(" + col.r + "," + col.g + "," + col.b + ")";
var maxCol = col.r > col.g ? col.r : (col.g > col.b ? col.g : col.b) + 1;
if (maxCol > 128) {
if ("#000000" !== gCtx.strokeStyle) {
that.updateGlobal("#000000");
}
} else {
if ("#dddddd" !== gCtx.strokeStyle) {
that.updateGlobal("#dddddd");
}
}
}
}
Platter.prototype.updateGlobal = function (color) {
var gCanvas = this.gCanvas;
var gCtx = this.gCtx;
var gRadius = this.gRadius;
gCtx.clearRect(0, 0, gCanvas.width, gCanvas.height);
gCtx.strokeStyle = color;
gCtx.beginPath();
gCtx.arc(gRadius, gRadius, gRadius, 0, Math.PI * 2);
gCtx.stroke();
}
Platter.prototype.draw = function () {
var cw = this.canvas.width;
var ch = this.canvas.height;
//每個畫素每個畫素迴圈填充,計算太多,耗時嚴重。採取縮放,分塊填充顏色的方式。當前約為128顏色級別。256以上會卡
var xScale = this.xScale;
var yScale = this.yScale;
var w = this.scaleWidth;
var h = this.scaleHeight;
for (var x = 0; x < w; x++) {
for (var y = 0; y < h; y++) {
var col = this.getScaleColor(x, y);
var color = 'rgb(' + col.r + ',' + col.g + ',' + col.b + ')';
this.ctx.fillStyle = color;
this.ctx.fillRect(x * xScale, y * yScale, xScale, yScale);
}
}
}
Platter.prototype.getScaleColor = function (scaleX, scaleY) {
var w = this.scaleWidth;
var h = this.scaleHeight;
var r = 0;
var g = 0;
var b = 0;
var sR = this.sR;
var sG = this.sG;
var sB = this.sB;
var mid = sR > sG ? sR : sG;
var max = mid > sB ? mid : sB;
mid = mid > sB ? sB : mid;
var min = sR + sG + sB - mid - max;
var oneMaxXV = (255 - max) / w;
// var oneMaxYV = (255 - max) / h;
var oneMidXV = (255 - mid) / w;
// var oneMidYV = (255 - mid) / h;
var oneMinXV = (255 - min) / w;
// var oneMinYV = (255 - min) / h;
var midColor = 255 - scaleX * oneMidXV;
var minColor = 255 - scaleX * oneMinXV;
var maxColor = 255 - scaleX * oneMaxXV;
var oneYTemp = midColor / h;
var midC = Math.floor(midColor - scaleY * oneYTemp);
oneYTemp = minColor / h;
var minC = Math.floor(minColor - scaleY * oneYTemp);
oneYTemp = maxColor / h;
var maxC = Math.floor(maxColor - scaleY * oneYTemp);
sR === max ? (r = maxC) : (sR === mid ? (r = midC) : (r = minC));
sG === max ? (g = maxC) : (sG === mid ? (g = midC) : (g = minC));
sB === max ? (b = maxC) : (sB === mid ? (b = midC) : (b = minC));
return {r: r, g: g, b: b}
}
Platter.prototype.getColor = function (x, y) {
return this.getScaleColor(x / this.xScale, y / this.yScale);
}
Platter.prototype.update = function (rgb) {
this.sR = rgb.r;
this.sG = rgb.g;
this.sB = rgb.b;
this.draw();
}
Platter.prototype.getPickColor = function () {
if (this.pickPoint) {//選中的座標
return this.getColor(this.pickPoint.x, this.pickPoint.y);
}
return undefined;
}
return Platter;
}());
var BarHelper = (function () {
function BarHelper() {
this.canvas = document.getElementById('colorBar');
this.ctx = this.canvas.getContext('2d');
this.pickLineWidth = 10;
this.rainWidth = this.canvas.width - 2 * this.pickLineWidth;
this.rainHeight = this.canvas.height - this.pickLineWidth;
var top = this.canvas.offsetTop;
var that = this;
var isApp = 'ontouchstart' in window;
if (isApp) {
// 判斷後,除錯時切換移動預覽,需要重新整理才生效
// 如果全部繫結,移動端下,會先響應ontouchstart,再響應onmousedown(有延遲)
this.canvas.ontouchstart = handleTouchEvent;
} else {
this.canvas.onmousedown = handleMouseEvent;
}
function handleMouseEvent(e) {
dragLine(e);
document.onmousemove = function (e) {
dragLine(e);
e.preventDefault ? e.preventDefault() : (e.returnValue = false)
}
document.onmouseup = function (event) {
document.onmousemove = null;
document.onmouseup = null;
}
}
function handleTouchEvent(e) {
dragLine(e.touches[0]);
document.ontouchmove = function (e) {
dragLine(e.changedTouches[0]);
}
document.ontouchend = function (e) {
document.ontouchmove = null;
document.ontouchend = null;
}
}
function dragLine(e) {
var y = e.clientY - top;
that.updatePickLine(y);
var col = that.getPickColor();
var color = 'rgb(' + col.r + ',' + col.g + ',' + col.b + ")";
barTextEl.innerText = 'y:' + y + ' ' + color;
platter.update(col);
var col = platter.getPickColor();
if (col) {
platterTextEl.innerText = "rgb(" + col.r + "," + col.g + "," + col.b + ")";
}
}
}
BarHelper.prototype.draw = function () {
this.drawRain();
// this.drawPickLine(0);
}
BarHelper.prototype.drawRain = function () {
var w = this.rainWidth;
var h = this.rainHeight;
var ctx = this.ctx;
var offY = this.pickLineWidth / 2;
for (var y = 0; y <= h; y++) {
var col = this.getColor(y);
var color = 'rgb(' + col.r + ',' + col.g + ',' + col.b + ")";
ctx.strokeStyle = color;
// console.log(color);
ctx.beginPath();
ctx.moveTo(this.pickLineWidth, y + offY);
ctx.lineTo(this.pickLineWidth + w, y + offY);
ctx.stroke();
}
}
BarHelper.prototype.getColor = function (y) {
var h = this.rainHeight;
//每個畫素顏色級別
var oneYV = 255 / (h / 6);
var r = 255;
var g = 0;
var b = 0;
if (y <= h / 3) {
//紅-洋紅
if (y <= h / 6) {
b = Math.floor(oneYV * y);
if (b > 255) {
b = 255;
}
r = 255;
}
//洋紅-藍
else {
r = 255 - Math.floor(oneYV * (y - h / 6));
if (r < 0) {
r = 0;
}
b = 255;
}
g = 0;
}
else if (y <= 2 * h / 3) {
if (y < 3 * h / 6) {
g = Math.floor(oneYV * (y - 2 * h / 6));
if (g > 255) {
g = 255;
}
b = 255;
} else {
b = 255 - Math.floor(oneYV * (y - 3 * h / 6));
if (b < 0) {
b = 0;
}
g = 255;
}
r = 0;
}
else {
if (y < 5 * h / 6) {
r = Math.floor(oneYV * (y - 4 * h / 6));
if (r > 255) {
r = 255;
}
g = 255;
} else {
g = 255 - Math.floor(oneYV * (y - 5 * h / 6));
if (g < 0) {
g = 0;
}
r = 255;
}
b = 0;
}
return {r: r, g: g, b: b};
}
BarHelper.prototype.drawPickLine = function (y) {
this.pickLineY = y;
var w = this.pickLineWidth;
var h = this.canvas.height - w;
var ch = this.canvas.height;
var cw = this.canvas.width;
var ctx = this.ctx;
ctx.clearRect(0, 0, w, ch);
ctx.clearRect(cw - w, 0, w, ch);
ctx.fillStyle = '#ffaaaa';
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y + w / 2);
ctx.lineTo(0, y + w);
ctx.fill();
ctx.beginPath();
ctx.moveTo(cw, y);
ctx.lineTo(cw - w, y + w / 2);
ctx.lineTo(cw, y + w);
ctx.fill();
}
BarHelper.prototype.getPickColor = function () {
return this.getColor(this.pickLineY);
}
BarHelper.prototype.updatePickLine = function (y) {
var w = this.pickLineWidth;
var h = this.canvas.height;
var maxY = h - w;
var minY = 0;
if (y < minY) {
y = minY;
}
else if (y > maxY) {
y = maxY;
}
this.drawPickLine(y);
}
return BarHelper;
}());
var platter = new Platter();
platter.draw();
var barHelper = new BarHelper();
barHelper.draw();
</script>
</body>
</html>