演示地址
演示地址
PC端的项目啦,需要在电脑上看哦,而且最好用Chrome打开
引言
这是今年三月份帮学长做的一个项目,陪我度过了两个月的春招生活,整个项目做下来也是学到了很多东西,下面就开始我的分享啦,包括一些知识点总结和遇到的坑,dalao莫笑哈。
项目概述
主要功能如上图,左边是图形工具栏,右边是canvas,上面是清除、删除、旋转、切换格子背景、保存并下载图片的操作。
代码是基于vue-cli码的,所以路由、vuex这些都不用讲啦,我们把重点放在canvas上面吧。
知识点总结
拖拽
这里的拖拽是指把左边工具栏里的图形图形拖拽到右边画布里,三步完成:
被拖拽元素设置
draggable="true"
;被拖拽元素还有三个相应的事件
dragstart
drag
dragend
,分别对应拖拽开始、拖拽中和拖拽结束,如果你希望在这些过程加上特效,可以试试,但更多的还是用作响应数据,比如让画布知道具体是哪个元素被拖拽进来了;被放置元素设置
dragover
drop
两个事件,分别表示被拖拽元素在该元素范围内移动、被拖拽元素着陆,这里注意dragover
事件函数内需设置event.preventDefault()
防止弹出新页面,然后我们就可以愉快地在drop
事件函数里画图形到画布上啦。
HEX => RGBA
由于设计图上颜色都没有透明度,所以我们需要手动加一个0.3的alpha,不然画布上图形相互层叠,会覆盖掉层级低的图形和背景图。
function hex2rgba(hex) { // hex格式如#ffffff let colorArr = []; for(let i = 1; i<7; i += 2){ colorArr.push(parseInt("0x" + hex.slice(i,i+2))); // 16进制值转10进制 } return `rgba(${colorArr.join(",")},0.3)`; } 复制代码
另外如果有兴趣了解RGBA转RGB的小伙伴,可以看看这篇博客RGBA转换成RGB
canvas基本用法
下面就是关于canvas的内容了,如果对它的基础用法还不太了解的小伙伴,可以看看JavaScript之Canvas画布
save与restore
save
可以保存当前canvas的状态,包括strokeStyle
、fillStyle
、变换矩阵、剪切区域等,restore
可以恢复到canvas状态栈中的上一个状态,所以我们在这两个函数中间做的canvas状态改变相当于被隔离起来了,不会污染外部的canvas操作。
这样看来,我们最好在每次画图前调用save
,画完后调用restore
,从而保证每次绘制都有一个纯粹的状态。
这里有一篇讲得特别好的文章,如果嫌本直男没讲清楚的话,一定要看哦。Canvas学习:save()和restore()
drawImage
可能有些小伙伴会小看这个API,认为它只能绘制图片,实际上它还能svg、canvas绘制到画布上,我们先来看看如何绘制svg咯。
我们功能界面左侧工具栏里的图标其实都是svg,我一开始是想把他们截图下来切成一个个背景透明的png,然后画到canvas上,后来发现放大看的话会比较模糊,毕竟是像素图嘛,所以新的需求来了。
我自己的代码不好贴出来,那就看看dalao的吧,将 DOM 对象绘制到 canvas 中,他这里是将DOM塞到svg里再往canvas上画的,如果你只需要画现成的svg,则可以不用foreignObject
包裹。
另外,如果你的svg有.svg格式图片,可以直接调用drawImage
去绘制。
椭圆与贝赛尔曲线
canvas已经有画椭圆的API了,但兼容性还不够好,在其他所有模拟绘制椭圆的方式里,贝塞尔曲线可以说是最优雅的一种了,好吧,扫盲文 => 贝塞尔曲线原理(简单阐述)
三维贝塞尔曲线需要一个起始点、两个中间点、一个终止点确定,当然起始点一般默认当前点,所以bezierCurveTo
的参数就是按顺序的后三个点坐标了;当这四个点恰好围成一个矩形时,就有点椭圆的模样啦。
let a = this.width / 2; let b = this.height / 2; let ox = 0.5 * a, oy = 0.6 * b; this.ctx.beginPath(); // 从椭圆纵轴下端开始逆时针方向绘制 this.ctx.moveTo(0, b); // 把椭圆划成四份分开来画 this.ctx.bezierCurveTo(ox, b, a, oy, a, 0); this.ctx.bezierCurveTo(a, -oy, ox, -b, 0, -b); this.ctx.bezierCurveTo(-ox, -b, -a, -oy, -a, 0); this.ctx.bezierCurveTo(-a, oy, -ox, b, 0, b); this.ctx.closePath(); this.ctx.fill(); 复制代码
这里有一篇整理得比较完整的椭圆绘制方法的文章 可以参考 HTML5 Canvas中绘制椭圆的5种方法
线条
带箭头的实线
实线好画,但是箭头怎么来做呢?Emmm,其实就是计算线段与画布x轴的夹角,然后在线段终点画偏移对应角度的三角形嘛
drawArrow(x1, y1, x2, y2) { // (x1, y1)是线段起点 (x2, y2)是线段终点 // 反正切函数计算夹角 let endRadians = Math.atan((y2 - y1) / (x2 - x1)); // 三角形的底边与线段垂直,所以还要再转 π / 2 endRadians += ((x2 >= x1) ? 90 : -90) * Math.PI / 180; this.ctx.save(); this.ctx.beginPath(); // 坐标原点 => (x2, y2) this.ctx.translate(x2, y2); this.ctx.rotate(endRadians); this.ctx.moveTo(0, 0); this.ctx.lineTo(5, 15); this.ctx.lineTo(-5, 15); this.ctx.closePath(); this.ctx.fill(); this.ctx.restore(); } 复制代码
虚线
比较传统的一种做法是修改CanvasRenderingContext2D的原型,手动增加一个dashedLine的方法,原理大概是从起始点先画一段实线,然后跳过一段,moveTo到下一个点继续画实线,这样循环到终止点,就能得到虚线。具体实现见html5 实现画虚线
其实canvas已经支持画虚线了,画线前用
setLineDash
即可指定虚线的样式,详见Canvas学习:绘制虚线和圆点线
但是这个方法用起来有些问题,角度不好或者间隔太小的时候,画出来的虚线看起来就像是实线。
波浪线
一般常见的波浪线都是用正弦曲线来模拟的吧,y = A * sin(ω * x + φ),指定它的A和ω就可以确定波浪线的振幅和频率(或者说每个波浪的高度和宽度)
let len = Math.sqrt(width * width + height * height); this.ctx.save(); this.ctx.moveTo(this.start.x,this.start.y); // 起点 this.ctx.translate(this.start.x,this.start.y); this.ctx.beginPath(); let x = 0; let y = 0; let amplitude = 5; // 振幅 let frequency = 5; // 频率 while (x < len) { y = amplitude * Math.sin(x / frequency); this.ctx.lineTo(x, y); x = x + 1; } this.ctx.stroke(); this.ctx.restore(); 复制代码
参考文章:Draw a Sine Wave in JavaScript
图形栈
保存
简单来说,我们画布上的图形都是一个类的实例,保存在一个数组中,每次有更新时都会清除画布,再全部重新绘制一遍(后面会将优化)。这个图形实例需要保存的属性一般有起始和终点坐标、颜色、偏移角度等,根据自己的需求设置,还至少需要一个方法去动态计算该图形的有效范围,以便鼠标事件找到它。
删除
选中某图形实例后,从图形栈数组中删除即可。
旋转
由于我们每次画图形的时候,都会把坐标原点暂时移到图形的中心,所以只需要rotate
一个角度再画就可以实现旋转啦
拖拽移动
Emmm,每个图形不太一样,有兴趣的话看看项目源码呗
判断一个点是否在某个四边形内
向量法
详见 判断一个点是否在四边形内部,但是这种方法有点局限性,首先,图形边数必须事先确定,而且边数多起来了代码会很长;其次,这种方法只是适用于凸多边形,举个凹多边形的反例想想就能明白了。射线法
详见射线法理论,代码实现如下:
function inRange(x, y, points){ // points表示多边形的顶点集合 let inside = false; for (let i = 0, j = points.length - 1; i < points.length; j = i++) { let xi = points[i][0], yi = points[i][1]; let xj = points[j][0], yj = points[j][1]; let intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi); if (intersect) inside = !inside; } return inside; } 复制代码
一个公式
任意点(x,y),绕一个坐标点(rx0,ry0)逆时针旋转a角度后的新的坐标设为(x0, y0),有公式:
x0= (x - rx0)*cos(a) - (y - ry0)*sin(a) + rx0 ;
y0= (x - rx0)*sin(a) + (y - ry0)*cos(a) + ry0 ;
极坐标的知识啦,不想推就直接套公式呗。
撤销与回退
类似PS的功能嘛,我这个项目没做,但是思路不难,用past、present、future三个数组来保存图形栈,Emm好像讲起来还是有点长,可以参考实现撤销历史的思路。
优先级
图形栈里的实例被依次取出绘制,后画上去的图形会覆盖掉之前的图形,所以这里涉及到一个优先级,重要的东西放在后面画。
我们可以把保存图形的数组再细分类,数组的每个子元素都是一个Array,专门保存某一种图形,优先级越高,对应的索引值越大,这样我们就可以把重要的图形全部放在后面画了。
vuex中的状态实现双向绑定
一般我们用于双向绑定的值都会放在vue实例的data
中,因为它默认提供了getter
和setter
;但vuex的状态一般都需要computed
来读取,但computed
默认是没有setter方法的,需要手动设置,代码如下:
computed:{ text : { get(){ return this.$store.state.text; }, set(value){ this.$store.commit('setText',value); } } } 复制代码
遇到的坑
html2canvas的一个小bug
在实现保存图片功能的时候,我希望能截取一段DOM的内容,而不仅仅是canvas的内容,所以找到了这个插件html2canvas,它可以把dom转换成canvas,然后我们就能canvas.toDataURL()
把它转换成图片了。
转换并保存成图片下载的代码如下:
downImg() { html2canvas( this.$refs.ground, { onrendered: function(canvas) { let url = canvas.toDataURL(); let a = document.createElement('a'); a.href = url; a.download = new Date() + ".png"; document.body.appendChild(a); a.click(); document.body.removeChild(a); } }); } 复制代码
但是出现了一个bug,就是下载下来的图片不清晰,左上角一大片空白。
于是我尝试了网上的很多方法,都行不通,最后只能把项目从零开始慢慢加东西,最后发现是我画虚线的时候改了CanvasRenderingContext2D的原型,我滴妈耶,做梦也没想到会是这里出问题,用插件有风险啊。
上传到gh-pages时的路径错误
如果上传到https://XXX.github.io/(GitHub的个人博客)上,则跟上传到服务器上操作一致,但如果是传到某个仓库的gh-pages,那么一堆问题都来了,解决步骤如下:
把
.gitignore
文件里的/dist
删掉,忽略了的话,还怎么上传打包文件到master分支呢;/config/index.js
里build部分里的assetsPublicPath
由'/'改成'./',相当于说把服务器根目录改成了相对路径,仓库gh-pages的根目录不是'/'而是'/仓库名';相对应的,如果使用了history模式,请改成hash模式,不然github可能会把前端路由识别成后端api;
还有一些
static
里的图片,使用了绝对路径,可能上传后显示不出来;git subtree push --prefix dist origin gh-pages
敲完命令,应该就可以看到上传成功了。
优化
多层次画布
上面提到,我们的画布每次更新时,总是要全部清除,然后重新再画一遍,对于那些背景图片等不变的内容来说,是不是可以优化呢?Emmm,好尬的设问句。
我们用多个同样大小层叠的canvas来完成,层级低的下层canvas用来画背景图片等静态图形,层级高的上层canvas用来画动态变化的图形,这样就可以每次渲染都优化一点啦。
离屏渲染
当我们在画布上拖拽图形时,一般做法是随着鼠标移动mousemove
,重新绘制所有图形,但其实这个过程中,要绘制的可以分为两部分,一个是被拖拽移动的图形,另一个就是其他图形;我们可以分别动态创建两个canvas,把两部分画在两个离屏画布上,mousemove
时只要调用两次drawImage(离屏canvas)即可,这样是不是性能又花了很多呢
代码地址
代码地址
虽然代码质量差,我自己都不忍直视,但还是放出来吧,万一哪里看不懂了还可以翻翻源码嘛
原文地址:https://juejin.im/post/5b529ddaf265da0f504a552c