快速入门canvas
准备画布
html
/* 注意:canvas画布的width和height属性与canvas的style属性的width和height不是同一个东西 */
<canvas class="canvas" width="640" height="480" style="outline:1px solid gray;"></canvas>
js
/* 1.获取画板元素,画板没有图形绘制的能力 2.获取context对象所有的绘制操作都是通过context对象来完成*/
let canvas = document.querySelector('.canvas')
let ctx = canvas.getContext('2d')
绘制直线
js
ctx.moveTo(200,10);//指定点(200,10)
ctx.lineTo(240,10);//创建一条从(200,10)到(240,10)的线段,此时画布上什么也没有
ctx.moveTo(200,15);//指定点(200,15)
ctx.lineTo(240,15);//创建一条从(200,15)到(240,15)的线段,此时画布上依然什么也没有
ctx.moveTo(200,20);//指定点(200,20)
ctx.lineTo(240,20);//创建一条从(200,50)到(240,20)的线段,此时画布上依然什么也没有
ctx.stroke();//绘制路径,此时画布上绘制了三条线段
moveTo和lineTo都是将路径移动到新的指定点,区别在于moveTo不会创建新的线段,而lineTo会生成从上一个指定点到当前指定点的线段。路径生成后并不会自动绘制,需要调用stroke方法绘制路径。
绘制圆弧
js
ctx.beginPath();//新建一条路径
ctx.arc(220, 100, 50, 0, 2 * Math.PI);//创建圆弧,圆心为(220,100),半径为50,由0弧度到2*Math.PI的圆弧,即完整的圆
ctx.stroke();//绘制路径
ctx.beginPath();//新建一条路径
ctx.arc(220, 100, 50, Math.PI, 0);//创建圆弧,圆心为(220,100),半径为50,由0弧度到2*Math.PI的圆弧,即完整的圆
ctx.arc(245, 100, 25, 0, Math.PI);//创建圆弧,圆心为(245, 100),半径为25,由0弧度到Math.PI的圆弧
ctx.arc(195, 100, 25, 0, Math.PI,true);//创建圆弧,圆心为(195, 100),半径为25,由0弧度到Math.PI的圆弧,最后一个参数控制逆时针绘制
ctx.fill();//填充路径
arc绘制圆弧的注意事项:
- 0弧度和2*Math.PI都是在圆心的右边,默认顺时针绘制。
- 圆弧的起点和终点都会记录在绘制路径中,如果没有新建绘制路径,则路径终点会指定为圆弧终点。
- 可以通过第六个参数设置绘制的方向,是一个布尔值,为
true
时逆时针绘制。同样绘制从0弧度到Math.PI的圆弧,顺时针绘制只能画出下半圆,而逆时针绘制只能画出上半圆。 - 使用fill填充路径,会自动闭合路径,连接路径中的第一点与最后一点。
绘制矩形
矩形的绘制比较特殊,存在多种实现方式
- 方法一:通过生成多条线段闭合成矩形,再绘制
js
ctx.beginPath();//新建一条路径
ctx.moveTo(10,10);//指定点(10,10)
ctx.lineTo(50,10);//创建一条从(10,10)到(50,10)的线段
ctx.lineTo(50,100);//创建一条从(50,10)到(50,100)的线段
ctx.lineTo(10,100);//创建一条从(50,100)到(10,100)的线段
ctx.closePath();//闭合一条路径,会自动将最后一点与起点连接
ctx.stroke();
ctx.fill();//填充路径
- 方法二:通过直接生成矩形,再进行绘制
js
ctx.beginPath();//新建一条路径
ctx.rect(10,20,50,50);//创建矩形,矩形左上角坐标为(10,20),长宽皆为50
ctx.stroke();
ctx.fillStyle = "red";//填充样式
ctx.fill();//填充路径
- 方法三:直接绘制矩形
js
// 不用单独调用描边或填充方法,且不会影响当前路径的绘制
ctx.strokeRect(10, 200, 50, 50);
ctx.fillRect(10, 200, 50, 50);
- 方法四:清除矩形区域中的内容,清除区域完全透明
js
ctx.clearRect(0,0,100,400);//canvas中清除画布内容只能通过这一个方法
绘制曲线
绘制曲线用到了贝塞尔曲线,相当复杂,就不讲了。这里只给出绘制曲线的方法。
- quadraticCurveTo(cp1x, cp1y, x, y)。绘制二次贝塞尔曲线,
cp1x,cp1y
为一个控制点,x,y
为结束点。 - bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y)。绘制三次贝塞尔曲线,
cp1x,cp1y
为控制点一,cp2x,cp2y
为控制点二,x,y
为结束点。
站内有大佬介绍贝塞尔曲线的原理,感兴趣的可以去看看:深入理解贝塞尔曲线
绘制椭圆弧
js
ctx.fillStyle = "black";
ctx.beginPath();
ctx.ellipse(195,100,10,10,0,0,2*Math.PI);//参数依次为圆心x轴坐标、y轴坐标、长轴半径、短轴半径、旋转弧度、起始弧度、结束弧度
ctx.fill();
绘制椭圆弧的方法与绘制圆弧的方法类似,只是半径由一个变为两个且多了一个旋转弧度。而如果你用上面的几行代码进行绘制,你会发现绘制出来依然是一个圆形。这是因为圆是特殊的椭圆,当椭圆的长轴半径与短轴半径相同时,这个椭圆就是一个圆。所以,通过绘制椭圆弧的方法,也能绘制出圆弧。绘制椭圆弧的方法同样默认顺时针绘制,如果希望逆时针绘制,可在最后添加一个布尔值true
。
绘制文本
js
ctx.fillStyle = "blue";
ctx.strokeStyle = "green";
ctx.font='48px serif';//设置字体大小,必须同时设置字体
ctx.strokeText('天道酬勤',50,400)
ctx.fillText('人定胜天',350,400)
需要注意的是,canvas中的文本是通过路径绘制出来的图像,并不是真的文字,无法像普通文字一样选中它们。
裁切
js
ctx.beginPath();
ctx.rect(0, 350, 600, 200);
ctx.clip();//沿路径裁切
ctx.beginPath();
ctx.arc(100,370,50,0,2*Math.PI);
ctx.fill();
ctx.clearRect(0,0,300,480)
裁切操作后,描边和填充以及清除等操作,都只会在裁切区域内生效,超出裁切区域的操作不会生效。裁切不会影响画布上原来的内容。
转换
js
ctx.translate(100,100);//平移
ctx.rotate(Math.PI/4);//旋转
ctx.scale(2,1)//缩放,值为-1时代表翻转
需要注意的是,转换操作针对的是坐标轴,而非元素,且不会影响画板上原来的内容。
样式
细心的你应该发现了,前面的教学中,我对一些属性进行了赋值,比如fillStyle、strokeStyle和font
属性,这些绘图上下文中的属性,全部都是对绘图样式进行设置。常用的属性还有lineWidth
、lineCap
、textBaseline
等。
lineWidth
设置线宽,默认为1.0,不带单位。 lineCap
设置线条端点样式,默认值为buzz
,支持的值有square
和round
。 textBaseline
设置文本基准线位置,默认值为alphabetic
,支持的值还有top
、hangying
、middle
、ideographic
和bottom
。
保存和恢复
save
和restore
两个方法只看方法名可能会造成误解。有些人会以为save会保存整个画板的内容,restore会将保存的内容恢复,比如说我自己。其实canvas画板中的内容可以看成两个部分:绘制效果和绘图状态。
- stroke()、fill()、strokeRect()、fillRect()和clearRect()等方法,将路径描边或填充到画板上,路径和画板上绘制的图像就是绘制效果,路径只能通过beginPath()进行重置。
- 绘图状态包括裁切路径、转换和其他绘制样式。
save()方法会将绘图状态压入栈内,可以保存多个绘图状态。restore()方法将绘图状态从栈中弹出,如果没有保存的绘图状态,该方法不会有任何效果。save()和restore()方法都是对绘图状态进行操作,对绘图效果没有影响。也就是说,在保存后,新绘制的内容在恢复时不会消失,清除的内容在恢复时也不会复原。
你想保存和恢复绘制的图像?通过getImageData
和putImageData
两个方法即可实现。
- getImageData(x,y,w,h)返回一个
ImageData
对象,包含画布从(x,y)
开始,宽w
,高h
的矩形区域的像素点信息,是一个数组,每四个数据为一组,对应一个像素点的rgba值。 - putImageData(imageData,x,y)传入ImageData对象,在画布的
(x,y)
开始,绘制ImageData对象保存的像素信息。
小练习
结合上述知识,你已经可以用canvas绘制出任意多边形、曲线和文字,现在试着自己绘制一个好看的图形,比如一个八卦图:
实现白板功能
功能分析
我们需要画笔、檫除、图形、文本工具、撤销等功能。本文只做简单实现,不做太复杂的功能。比如图形只实现矩形和椭圆形,不做五角星、三角形、五边形等多边形的实现。
总结一下,本文实现的在线白板功能有:
- 画笔工具
- 檫除工具
- 矩形工具
- 椭圆工具
- 文本工具
- 撤销
画笔工具
画笔工具核心功能:根据鼠标按下后移动的轨迹,绘制图形,直到鼠标松开。
- 按下鼠标。重置路径,将鼠标点击的位置添加到路径,监听鼠标移动事件。
- 鼠标移动。将鼠标移动到的位置添加到路径,并与路径的上一点相连(lineTo),绘制并重置路径。
- 松开鼠标。清除鼠标移动监听。
js
// 新建空数组,用于保存一次按下鼠标后到鼠标松开记录的路径。
let points = [];
let snapshot = null;
// 鼠标移动回调
function drawLine(ev){
ctx.putImageData(snapshot,0,0)
points.push({ x: ev.offsetX, y: ev.offsetY });
ctx.beginPath()
ctx.moveTo(points[0].x,points[0].y)
for(let i = 1;i<points.length;i++){
ctx.lineTo(points[i].x,points[i].y)
}
ctx.stroke()
};
canvas.addEventListener("mousedown",(ev)=>{
// 重置路径
points = [];
points.push({ x: ev.offsetX, y: ev.offsetY });
// 保存当前画布内容
snapshot = ctx.getImageData(0,0,canvas.width,canvas.height)
canvas.addEventListener("mousemove",drawLine);
})
canvas.addEventListener("mouseup",(ev)=>{
canvas.removeEventListener("mousemove",drawLine);
})
矩形工具
绘制矩形步骤:鼠标按下,移动鼠标,绘制矩形并随鼠标移动调整矩形的尺寸与位置,松开鼠标。
- 按下鼠标。重置路径,将鼠标点击的位置添加到路径,监听鼠标移动事件。
- 鼠标移动。将鼠标按下位置记为矩形的顶点,将鼠标的当前位置作为对角的顶点,绘制矩形路径。
- 松开鼠标。清除鼠标移动监听。
js
// 新建一个数组,用于保存一次按下鼠标后到鼠标松开记录的路径。
let points = [];
let snapshot = null;
// 鼠标移动回调
function drawRect(ev){
ctx.putImageData(snapshot,0,0)
points[1]=({ x: ev.offsetX, y: ev.offsetY });
let width = ev.offsetX - points[0].x
let height = ev.offsetY - points[0].y
// 矩形的宽高可以是负数
ctx.strokeRect(points[0].x,points[0].y,width,height)
};
canvas.addEventListener("mousedown",(ev)=>{
// 重置路径
points = [];
points.push({ x: ev.offsetX, y: ev.offsetY });
// 保存当前画布内容
snapshot = ctx.getImageData(0,0,canvas.width,canvas.height)
canvas.addEventListener("mousemove",drawRect);
})
canvas.addEventListener("mouseup",(ev)=>{
canvas.removeEventListener("mousemove",drawRect);
})
椭圆工具
绘制椭圆:鼠标按下,移动鼠标,绘制椭圆并随鼠标移动调整椭圆的尺寸与位置,松开鼠标。
- 按下鼠标。重置路径,将鼠标点击的位置添加到路径,监听鼠标移动事件。
- 鼠标移动。将鼠标按下位置与鼠标的当前位置作为参考,绘制椭圆路径。
- 松开鼠标。清除鼠标移动监听。
先来看看第二点,如何计算椭圆的路径?
如图,点a是鼠标按下的位置,点b是鼠标移动到的位置,则点c是我们要绘制的椭圆圆心的位置。
js
// 新建一个数组,用于保存一次按下鼠标后到鼠标松开记录的路径。
let points = [];
let snapshot = null;
// 鼠标移动回调
function drawEllipse(ev){
ctx.putImageData(snapshot,0,0)
points[1] = ({ x: ev.offsetX, y: ev.offsetY });
let x = (ev.offsetX + points[0].x) / 2
let y = (ev.offsetY + points[0].y) / 2
//椭圆的半径不可以是负数
let xRadius = Math.abs(ev.offsetX - points[0].x) / 2
let yRadius = Math.abs(ev.offsetY - points[0].y) / 2
ctx.beginPath()
ctx.ellipse(x, y, xRadius, yRadius, 0, 0, 2 * Math.PI)
ctx.stroke()
};
canvas.addEventListener("mousedown",(ev)=>{
// 重置路径
points = [];
points.push({ x: ev.offsetX, y: ev.offsetY });
// 保存当前画布内容
snapshot = ctx.getImageData(0,0,canvas.width,canvas.height)
canvas.addEventListener("mousemove",drawEllipse);
})
canvas.addEventListener("mouseup",(ev)=>{
canvas.removeEventListener("mousemove",drawEllipse);
})
檫除工具
檫除:鼠标按下,移动鼠标,将鼠标移动过的区域内容清空变为透明,松开鼠标。
- 按下鼠标。重置路径,将鼠标点击的位置添加到路径,监听鼠标移动事件。
- 鼠标移动。将鼠标按下位置与鼠标的当前位置作为参考,清空内容。
- 松开鼠标。清除鼠标移动监听。
来看看第二点,如何计算应该清空的区域?
如图,假设a、b是鼠标移动过程中经过的连续的两点,则需要清除的范围就是图中的矩形加上一个圆形(也可以不加上圆形)。
js
// 新建一个数组,用于保存一次按下鼠标后檫除的路径。
let points = [];
let snapshot = null;
// 鼠标移动回调
function drawEraser(ev){
ctx.putImageData(snapshot, 0, 0)
points.push({ x: ev.offsetX, y: ev.offsetY })
for (let i = 1; i < points.length; i++) {
let sx = ctx.lineWidth * Math.sin(Math.atan((points[i - 1].y - points[i].y) / (points[i - 1].x - points[i].x)))
let sy = ctx.lineWidth * Math.cos(Math.atan((points[i - 1].y - points[i].y) / (points[i - 1].x - points[i].x)))
ctx.save()
ctx.beginPath()
ctx.moveTo(points[i - 1].x + sx, points[i - 1].y - sy)
ctx.lineTo(points[i - 1].x - sx, points[i - 1].y + sy)
ctx.lineTo(points[i].x + sx, points[i].y - sy)
ctx.lineTo(points[i].x - sx, points[i].y + sy)
ctx.closePath()
ctx.clip()
ctx.clearRect(0,0,canvas.width,canvas.height)
ctx.restore()
ctx.save()
ctx.arc(points[i].x,points[i].y,ctx.lineWidth,0,2*Math.PI)
ctx.clip()
ctx.clearRect(0,0,canvas.width,canvas.height)
ctx.restore()
}
};
canvas.addEventListener("mousedown",(ev)=>{
// 重置路径
points = [];
points.push({ x: ev.offsetX, y: ev.offsetY });
// 保存当前画布内容
snapshot = ctx.getImageData(0,0,canvas.width,canvas.height)
canvas.addEventListener("mousemove",drawEllipse);
})
canvas.addEventListener("mouseup",(ev)=>{
canvas.removeEventListener("mousemove",drawEllipse);
})
工具切换
仔细观察画笔工具、矩形工具、椭圆工具和檫除工具的使用方式竟如此相似!也因此,实现的代码也大致相同,鼠标按下与抬起事件的代码都几乎一样,不同的点只在于绑定的处理函数不同。因为鼠标移动事件是在鼠标按下时才绑定的,所以只要在绑定前修改回调函数,就能使用相同的代码实现上述的功能:
js
let callback = null;
canvas.addEventListener("mousedown",(ev)=>{
// 重置路径
points = [];
points.push({ x: ev.offsetX, y: ev.offsetY });
// 保存当前画布内容
snapshot = ctx.getImageData(0,0,canvas.width,canvas.height)
canvas.addEventListener("mousemove",callback);
})
canvas.addEventListener("mouseup",(ev)=>{
canvas.removeEventListener("mousemove",callback);
})
我们显示一些按钮,用来切换对应的功能:
html
<button onclick="brush(1)">画笔工具</button>
<button onclick="brush(2)">矩形工具</button>
<button onclick="brush(3)">椭圆工具</button>
<button onclick="brush(4)">檫除工具</button>
根据点击的按钮,设置不同的回调函数。
js
function brush(choice){
switch (choice) {
case 1:
callback = drawLine
break;
case 2:
callback = drawRect
break;
case 3:
callback = drawEllipse
break;
case 4:
callback = drawEraser
break;
default:
break;
}
}
文本工具
文本工具与上述工具不太一样,不需要监听鼠标按下、移动和松开事件,而是需要监听鼠标点击和输入内容。
使用步骤:鼠标点击,记录点击位置,输入文本内容,点击其他区域结束输入。
功能分析:监听鼠标点击事件,获取点击位置,在点击位置放一个输入框,聚焦于输入框,输入文本,当输入框失去焦点时隐藏输入框,将输入内容绘制到画板。
html
<button onclick="brush(5)">文本工具</button>
<textarea class="input"></textarea>
css
.input{
display: none;
position: absolute;
left: 0;
top: 0;
resize: none;
overflow: hidden;
font-size: 24px;
line-height: 24px;
border: none;
}
.input:focus {
outline: solid 1px #ff0000;
}
js
function brush(choice) {
switch (choice) {
case 1:
callback = drawLine
break;
case 2:
callback = drawRect
break;
case 3:
callback = drawEllipse
break;
case 4:
callback = drawEraser
break;
case 5:
callback = null
text()
break;
default:
break;
}
}
function text() {
canvas.style.cursor = 'text'
canvas.onclick = function (ev) {
canvas.onclick = null
canvas.style.cursor = 'default'
// console.log(ev);
let x = ev.offsetX
let y = ev.offsetY
let lineHeight = 24
let whitespace = 20
input.value = ''
input.rows = 1
input.style.display = 'block'
input.style.left = ev.clientX + 'px'
input.style.top = ev.clientY - lineHeight / 2 + 'px'
input.style.width = whitespace + 'px'
snapshot = ctx.getImageData(0, 0, canvas.width, canvas.height)
ctx.textBaseline = "top";
ctx.font = "24px 仿宋"
ctx.fillStyle = '#000000ff'
// 输入时根据文本域内容修改文本域尺寸
input.oninput = (ev) => {
let text = input.value.split(/\n/g);
let width = 0
input.rows = text.length
text.forEach((el, index) => {
width = Math.max(ctx.measureText(el).width, width)
});
input.style.width = width + whitespace + 'px'
}
// 当文本域失去焦点,隐藏文本域,将文字绘制在画板上
input.onblur = () => {
input.style.display = 'none'
let text = input.value.split(/\n/g);
text.forEach((el, index) => {
ctx.fillText(el, x, y - lineHeight / 2 + index * lineHeight)
});
}
input.focus()
}
}
撤销
撤销功能,简单来说就是将画布还原到上一步的状态。这里提供两种思路:
- 记录每一步的操作,将操作压入栈中,每次撤销都弹出一个操作,然后清空画布,按栈中的操作再绘制一次。
- 记录每一次操作的画布状态,不关心之前进行过什么操作,每次撤销都直接读取画布的前一个状态。
我们现在用思路二来完成撤销功能。首先还是提供一个撤销按钮:
html
<button onclick="brush(6)">撤销</button>
前面我们声明了一个变量snapshot
,用于保存当前的画布状态,及生成了一次快照,现在我们再声明一个变量用来保存这些快照。
js
let shapshots = [];
然后是保存快照的时机:每次完成一个操作。
js
canvas.addEventListener("mouseup", (ev) => {
if(points[1]){
snapshots.push(snapshot)
}
canvas.removeEventListener("mousemove", callback);
})
js
/* 文本工具 */
input.onblur = () => {
input.style.display = 'none'
let text = input.value.split(/\n/g);
text.forEach((el, index) => {
ctx.fillText(el, x, y - lineHeight / 2 + index * lineHeight)
});
if(input.value){
snapshots.push(snapshot)
}
}
实现撤销功能
js
function brush(choice) {
switch (choice) {
case 1:
callback = drawLine
break;
case 2:
callback = drawRect
break;
case 3:
callback = drawEllipse
break;
case 4:
callback = drawEraser
break;
case 5:
callback = null
text()
break;
case 6:
if(snapshots.length>0){
ctx.putImageData(snapshots.pop(),0,0)
}
break;
default:
break;
}
}