1. 程式人生 > >h5 canvas仿 Photoshop 繪製調色盤

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>