前言 我又双叒来参加活动了,新年新气象,作为新年的第一篇文章,必须福气满满,给自己一个好兆头(读书笔记暂时鸽一下,哈哈哈哈)。不知道jym还记不记得去年支付宝的一个写福字的活动,我当时还写了一个自认为很好看的福呢。
前几天突发奇想,身为一个前端人为什么不能自己做一个呢?说干就干!
怎么实现 美其名曰写福,其实就是在浏览器绘制,我们很容易就能想到用canvas实现。我们只需要用canvas捕捉鼠标移动轨迹并将其绘制出来就能实现这样的效果(实现的时候发现是我想的太简单了……)。这就需要我们对canvas的api有足够的了解,这里就不介绍了,想了解的百度一下你就知道~
V1.0实现 首先我们肯定需要一个canvas,为了方便观察,给它加上一个背景色:
1 <canvas id ="canvas" style ="background:#ffffcc" width ="400" height ="500" ></canvas>
接下来我们需要定义几个变量并开始实现效果:
moveFlag<Boolean>:开始绘制的标志
offset<Object>:鼠标的当前位置
posList<Array>:鼠标运动的位置集合
绘制出来的线实质上是一个个点的集合,所以我们需要对鼠标运动过的位置加以记录。
首先我们需要定义画笔的颜色并对canvas绑定上鼠标相关事件(移动端可调整为touch相关事件,这里不做编写)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 var moveFlag = falsevar offset = {}, posList = [] var canvas = document.getElementById ('canvas' )var ctx = canvas .getContext ('2d' ) ctx.fillStyle = 'rgba(0,0,0,0.3)' canvas .onmousedown = (e) => { downEvent (e) }canvas .onmousemove = (e) => { moveEvent (e) }canvas .onmouseup = (e) => { upEvent (e) }canvas .onmouseout = (e) => { upEvent (e) }
鼠标按下事件的逻辑就是修改标志为true,清空位置集合并保存当前鼠标位置,抬起和移出事件无非就是修改标志为false,实现的重点在移动事件,稍后重点讲解。
1 2 3 4 5 6 7 8 function downEvent(e ) { moveFlag = true posList = [] offset = getPos(e ) }function upEvent(e ) { moveFlag = false }
鼠标按下时使用了一个工具函数获取位置:
1 2 3 4 5 6 function getPos (e) { return { x: e.clientX - canvas .offsetLeft , y: e.clientY - canvas .offsetTop } }
鼠标移动时我们都要干什么,获取当前鼠标位置,将两次鼠标位置移动的距离置入集合,并根据移动距离绘制无数个点以构成线,然后将当前点变成鼠标移动终点位置。
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 function moveEvent(e) { if (!moveFlag) return var currentOffset = getPos(e) var prevOffset = offset var radius = 1 posList.unshift({ distance: getDistance(prevOffset, currentOffset), time : new Date().getTime() }) var dis = 0 , time = 0 for (var i = 0 , l = posList.length - 1 ; i < l; i++) { dis += posList[i].distance time += posList[i].time - posList[i + 1 ].time } offset = currentOffset for (var i = 0 , l = Math.round (posList[0 ].distance / 1 ); i < l + 1 ; i++) { var x = prevOffset.x + (currentOffset.x - prevOffset.x) / l * i var y = prevOffset.y + (currentOffset.y - prevOffset.y) / l * i ctx.beginPath() ctx.arc(x, y, radius , 0 , 2 * Math.PI, true ) ctx.fill() } }
上述代码中,第一个for循环内我们计算了鼠标移动距离,第二个for循环内则是将距离以1为单位分成若干份,每份都画一个圆,形成直线。代码中用到了一个工具函数计算距离:
1 2 3 function getDistance (a , b) { return Math.sqrt (Math.pow ((b .x - a .x), 2 ) + Math.pow ((b .y - a .y), 2 )) }
至此我们的v1.0版本就已经完成了,现在我们可以在canvas中比比划划了。
虽然实现了但是它也太丑了,没有笔锋也没有粗细变化,怎么能写出好看的福字呢,接下来我们进行2.0版本改造。
V2.0实现 相比于1.0版本,我们需要给画笔添加更加真实的效果,比如触摸的压力,笔的最大宽度和最小宽度,以及笔的平滑程度。尽最大程度还原真实的使用感觉。
新增如下参数
lineMax<Number>:线宽最大值
lineMin<Number>:线宽最小值
smoothness<Number>:笔触平滑程度
linePressure<Number>:笔触压力
如何实现笔触的平滑,我们在计算距离的时候判断当前距离和笔触的平滑程度大小,若距离大于平滑程度则跳出此次循环。
1 2 3 4 5 for (var i = 0, l = posList.length - 1; i < l; i++) { dis += posList[i].distance time += posList[i].time - posList[i + 1].time if (dis > smoothness) break; // 新增,保持平滑 }
那我们如何实现笔触的压力效果呢,在使用中,无非就是停留时间长、使劲会让线条更加浑厚粗犷,在代码中,我们可以通过两点之间time
的间隔和距离模拟这一使用场景,动态生成圆的半径用来绘制。
1 var offsetRadius = Math.min ((time / dis) * linePressure + lineMin, lineMax) / 2
这里可以看到,我们利用最小线宽、压力、点的时间和距离模拟了动态圆的半径,并限制住圆半径范围不超过最大线宽。接着在第二个for循环绘制圆中我们就可以使用动态的圆半径来绘制大小不一的圆,模拟粗细有致的平滑笔触。
1 2 3 4 5 6 7 8 for (var i = 0 , l = Math.round (posList[0 ].distance / 1 ); i < l + 1 ; i++) { var x = prevOffset.x + (currentOffset.x - prevOffset.x) / l * i var y = prevOffset.y + (currentOffset.y - prevOffset.y) / l * i var r = currentRadius + (offsetRadius - currentRadius) / l * i ctx.beginPath() ctx.arc(x, y, r, 0 , 2 * Math.PI , true ) ctx.fill() }
2.0的完整代码不摆在这了,下面还有3.0完整版,我最后会附上3.0的完整版代码
看效果:
到这其实基本上对绘制以及笔触的模拟都已完成,但毕竟是过年,需要有点过年的气氛,而且关于线宽这些我们也可以将其交给用户,让其自己配置,接下来就是3.0终极版。
V3.0实现 V3.0主要添加了撤销的功能。撤销的实现说白了就是在鼠标移动的时候维护一个笔划的历史数组,点击撤销后去除历史数组中的最末一个对整幅canvas进行重新绘制。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 back () { history.pop (); ctx.clearRect(0 , 0 , canvas.width, canvas.height); for (var i = 0 ; i < history.length ; i++) { var h = history[i]; for (var j = 0 ; j < h.length ; j += 3 ) { ctx.beginPath(); canvas .getContext("2d" ) .arc(h[j], h[j + 1 ], h[j + 2 ], 0 , 2 * Math.PI , true ); ctx.fill(); } } },
效果如下
在上边的图片中也看到了,我将线宽范围、笔触压力和平滑程度可视化出来方便用户自己配置调节想要的效果,这里简单写了一个原生的双向绑定支持随改随生效。
最后为了烘托一下过年的气氛,我们找一张喜庆的背景图作为canvas的背景绘制上去。
有人会问为什么不直接在css里添加background,其实最开始我也是这么加的,但这种方式在canvas转图片时是不会将背景也作为canvas的一部分的,所以直接将图片绘到canvas上了。
1 2 3 4 5 6 var img = new Image() ; img.setAttribute('crossOrigin ', 'anonymous ') ; img.onload = function () { ctx.drawImage(img , 0, 0) ; } img.src = './drawBG.jpg';
最后就是添加一个保存逻辑,这个就不列出来了。
大功告成,我们最终形成的就是这样的一个页面,重点在功能实现哈,页面实在是懒得去美化了,至少这个背景看着就很有过年的气氛嘛哈哈哈。美中不足(之一)就是对于笔锋的模拟还是不够到位,但这是我目前能够想到最好的方案了,欢迎小伙伴评论区讨论哈,我一定虚心倾听。v3.0版本的最终代码放在最后了。
最后 就把我写的这不争气的福发出来祝大家新年心想事成福气满满!
源码 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 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" /> <meta http-equiv ="X-UA-Compatible" content ="IE=edge,chrome=1" /> <style type ="text/css" > </style > </head > <body > <canvas id ="canvas" style ="background:#ffffcc" width ="400" height ="700" > </canvas > <br /> <div style ="position: absolute;top: 10px;left: 420px;" > 线宽范围:<input style ="display:inline" type ="text" model ='lineMin' id ="lineMin" /> - <input style ="display:inline" type ="text" model ='lineMax' id ="lineMax" /> <br /> 笔触压力:<input type ="text" model ='linePressure' id ="linePressure" /> <br /> 平滑程度:<input type ="text" model ='smoothness' id ="smoothness" /> <br /> <input type ="button" id ='back' value ="撤销" onclick ="back()" /> <input type ="button" id ='clear' value ="清空" onclick ="clear()" /> <input type ="button" id ='save' value ="保存" onclick ="save()" /> </div > <script type ="text/javascript" > var moveFlag = false var offset = {}, posList = [] var drawHistory = [], startOffset = null var lineMax = 30 , lineMin = 2 , linePressure = 3 , smoothness = 80 var radius = 0 var canvas = document .getElementById ('canvas' ) var ctx = canvas.getContext ('2d' ) var img = new Image (); img.setAttribute ('crossOrigin' , 'anonymous' ); img.onload = function ( ) { ctx.drawImage (img, 0 , 0 ); } img.src = './drawBG.jpg' ; ctx.fillStyle = 'rgba(0,0,0,0.3)' canvas.onmousedown = (e ) => { downEvent (e) } canvas.onmousemove = (e ) => { moveEvent (e) } canvas.onmouseup = (e ) => { upEvent (e) } canvas.onmouseout = (e ) => { upEvent (e) } function downEvent (e ) { moveFlag = true posList = [] drawHistory.push ([]) console .log (drawHistory); startOffset = offset = getPos (e) } function moveEvent (e ) { if (!moveFlag) return var currentOffset = getPos (e) var prevOffset = offset var currentRadius = radius posList.unshift ({ distance : getDistance (prevOffset, currentOffset), time : new Date ().getTime () }) var dis = 0 , time = 0 for (var i = 0 , l = posList.length - 1 ; i < l; i++) { dis += posList[i].distance time += posList[i].time - posList[i + 1 ].time if (dis > smoothness) break ; } var offsetRadius = Math .min ((time / dis) * linePressure + lineMin, lineMax) / 2 radius = offsetRadius offset = currentOffset if (dis < 7 ) return ; if (startOffset) { prevOffset = startOffset currentRadius = offsetRadius startOffset = null } for (var i = 0 , l = Math .round (posList[0 ].distance / 1 ); i < l + 1 ; i++) { var x = prevOffset.x + (currentOffset.x - prevOffset.x ) / l * i var y = prevOffset.y + (currentOffset.y - prevOffset.y ) / l * i var r = currentRadius + (offsetRadius - currentRadius) / l * i ctx.beginPath () ctx.arc (x, y, r, 0 , 2 * Math .PI , true ) ctx.fill () drawHistory[drawHistory.length - 1 ].push (x, y, r) } } function upEvent (e ) { moveFlag = false } function getPos (e ) { return { x : e.clientX - canvas.offsetLeft , y : e.clientY - canvas.offsetTop } } function getDistance (a, b ) { return Math .sqrt (Math .pow ((b.x - a.x ), 2 ) + Math .pow ((b.y - a.y ), 2 )) } function clear ( ) { drawHistory = [] ctx.clearRect (0 , 0 , canvas.width , canvas.height ); } function back ( ) { drawHistory.pop (); ctx.clearRect (0 , 0 , canvas.width , canvas.height ); ctx.drawImage (img, 0 , 0 ); for (var i = 0 ; i < drawHistory.length ; i++) { var h = drawHistory[i]; for (var j = 0 ; j < h.length ; j += 3 ) { ctx.beginPath (); canvas .getContext ("2d" ) .arc (h[j], h[j + 1 ], h[j + 2 ], 0 , 2 * Math .PI , true ); ctx.fill (); } } } function save ( ) { var url = canvas.toDataURL ("image/png" ); var oA = document .createElement ("a" ); oA.download = '' ; oA.href = url; document .body .appendChild (oA); oA.click (); oA.remove (); } const ngmodel = { lineMin, lineMax, linePressure, smoothness }; const inputs = document .querySelectorAll ('input[model]' ); for (let i = 0 ; i < inputs.length ; i++) { inputs[i].value = ngmodel[inputs[i].getAttribute ('model' )] inputs[i].addEventListener ('keyup' , change) }; function change (e ) { const attr = e.target .getAttribute ('model' ); window [attr] = ngmodel[attr] = e.target .value } </script > </body > </html >