几个月以前,有人问了我一个canvass怎么实现缩放和涂鸦的问题,我基于当时的想法写了一篇博客,但是后来发现当时做的不完善,所以实现上其实还是有一些其他问题的。但是因为前段时间太忙了,也就一直没有机会去改进它。现在总算是有时间了,正好抽空把这个问题解决一下。因为不太熟悉 JS,感觉代码写多了(200行以上),复杂性上来之后,我的能力就无法来维护这个代码了,所以这次换一个面向对象的写法,感觉是好了一点。
左边是展示的 Canvas,右边是缓存 Canvas(这个通常是不显示的),这里进行显示是为了让你更好的理解我的思路。移动图片是很好理解的,主要是缩放和绘制,绘制时,需要计算当前点在图片上面的位置,然后计算对应的在缓存 Canvas图片上的位置,然后在对应的位置进行绘制。今天的状态不好,不想写那么详细了,可以去看上一篇博客了解怎么做的,这里主要是代码上面的改进,总体的思路是不变的:
canvas实现图片缩放+涂鸦
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image-Editortitle>
<style type="text/css">
body, div {
margin: 0;
padding: 0;
}
#cs {
float: left;
}
canvas {
border: red 1px solid;
}
style>
head>
<body>
<div class="cas">
<canvas id="cs" width="800" height="600">canvas>
div>
<div class="cas">
<canvas id="cache_cs" width="800" height="600">canvas>
div>
<button id="draw_move" onclick="editor.moveOrDraw()">移动button>
<script type="text/javascript">
let ZOOM = [0.5, 0.6, 0.7, 1.0, 1.1, 1.2, 1.5, 2.0] // 缩放数组
let editor = {
isUserMove: true, // 用户是否在移动,否则就是绘制
isMouseDown: false, // 用户鼠标是否按下,按下才处理移动和绘制
isInImage: false,
zoomIndex: 3, // 缩放下标
lineWidth: 10, // 默认线宽
unit: 400, // 宽高的最大值
width: 0, // 图像的宽
height: 0, // 图像的高
canvas: null,
ctx: null,
cacheCanvas: null,
cacheCtx: null,
vertexPos: {x:0, y:0}, // 左上顶点的位置
mousePos: {x:0, y:0}, // 鼠标当前位置
init: function() {
this.canvas = document.getElementById("cs")
this.ctx = this.canvas.getContext("2d")
this.ctx.lineWidth = this.lineWidth * ZOOM[this.zoomIndex]
this.ctx.strokeStyle = "red"
this.cacheCanvas = document.getElementById("cache_cs")
this.cacheCtx = this.cacheCanvas.getContext("2d")
this.cacheCtx.lineWidth = this.lineWidth * ZOOM[this.zoomIndex]
this.cacheCtx.strokeStyle = "black"
},
loadImage: function() {
let img = new Image()
img.src = "./husky.png"
img.onload = () => {
// 缩放图片
if (img.width > img.height) {
this.width = 400
this.height = this.width * img.height / img.width
} else {
this.height = 400
this.width = this.height * img.width / img.height
}
// 计算左上顶点的位置
this.vertexPos = {
x: (this.canvas.width-this.width)/2,
y: (this.canvas.height-this.height)/2
}
let zoom = ZOOM[this.zoomIndex]
this.ctx.drawImage(img, this.vertexPos.x, this.vertexPos.y, this.width*zoom, this.height*zoom)
this.cacheCtx.drawImage(img, 0, 0, this.width, this.height)
}
},
addMouseEvent: function() {
// 鼠标按下
this.canvas.onmousedown = e => {
let x = e.clientX - this.canvas.offsetLeft
let y = e.clientY - this.canvas.offsetTop
this.isMouseDown = true
// 每次按下鼠标时更新顶点的位置
let zoom = ZOOM[this.zoomIndex]
console.log("vertex: ", this.vertexPos)
this.mousePos = {x: x, y: y}
console.log("On (%d, %d)", x, y)
// 判断是否点击在图像上, 否则不做处理
if (this.isMouseInImage(x, y)) {
console.log("In image")
this.isInImage = true
// 这里加一个选中提示框
if (this.isUserMove) {
this.drawChooseRect()
} else {
// 把画笔移动到鼠标点击处
this.ctx.beginPath()
this.ctx.moveTo(x, y)
console.log("move: ", x, y)
// 计算相对位置
let cachePos = this.computeRelevantPos(x, y)
this.cacheCtx.beginPath()
this.cacheCtx.moveTo(cachePos.x, cachePos.y)
}
} else {
console.log("Out image")
}
}
// 鼠标移动
this.canvas.onmousemove = e => {
// 鼠标按下才处理
if (!this.isMouseDown) {
return
}
let x = e.clientX - this.canvas.offsetLeft
let y = e.clientY - this.canvas.offsetTop
// 鼠标在图像外部不处理
if (!this.isMouseInImage(x, y)) {
return
}
let dx = x-this.mousePos.x
let dy = y-this.mousePos.y
// 更新鼠标的位置
this.mousePos.x = x
this.mousePos.y = y
if (this.isUserMove) {
// 移动操作
// 更新顶点位置
this.vertexPos.x = this.vertexPos.x + dx
this.vertexPos.y = this.vertexPos.y + dy
// 重新绘制
this.redraw()
this.drawChooseRect()
} else {
// 绘制操作
this.draw(x, y)
}
}
// 鼠标滚轮
this.canvas.onmousewheel = e => {
// 禁止移动和缩放一起操作
if (this.isMouseDown) {
return
}
let x = e.clientX - this.canvas.offsetLeft;
let y = e.clientY - this.canvas.offsetTop;
delta = e.wheelDelta;
if (delta > 0) {
if (this.zoomIndex + 1 < ZOOM.length) {
this.zoomIndex += 1;
} else {
this.zoomIndex = ZOOM.length - 1;
}
} else {
if (this.zoomIndex - 1 >= 0) {
this.zoomIndex -= 1;
} else {
this.zoomIndex = 0;
}
}
// 图像缩放
this.redraw()
}
let mouseUpAndOut =e => {
this.isMouseDown = false
this.isInImage = false
// 如果是在移动操作中,则清空canvas,重新绘制
if (this.isUserMove) {
this.redraw()
}
}
// 鼠标松开和鼠标离开
this.canvas.onmouseup = mouseUpAndOut
this.canvas.onmouseout = mouseUpAndOut
},
moveOrDraw: function() {
this.isUserMove = !this.isUserMove
if (this.isUserMove) {
document.getElementById("draw_move").innerText = "移动";
} else {
document.getElementById("draw_move").innerText = "绘制";
}
},
redraw: function() {
let zoom = ZOOM[this.zoomIndex]
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
// 如果从顶点处进行缩放,我感觉不是很好,所以考虑从图像的中心处开始缩放,
// 所以这里的顶点的计算要多思考一下
this.ctx.save()
this.ctx.drawImage(this.cacheCanvas, 0, 0,
this.width, this.height,
this.vertexPos.x + (1-zoom)*this.width/2,
this.vertexPos.y + (1-zoom)*this.height/2,
this.width*zoom, this.height*zoom)
this.ctx.restore()
console.log("zoom vertex: ", this.vertexPos)
},
drawChooseRect: function() {
let zoom = ZOOM[this.zoomIndex]
this.ctx.save()
this.ctx.lineWidth = 1
this.ctx.strokeStyle = "red"
this.ctx.strokeRect(
this.vertexPos.x + (1-zoom)*this.width/2,
this.vertexPos.y + (1-zoom)*this.height/2,
this.width*zoom,
this.height*zoom
)
this.ctx.restore()
},
draw: function(x, y) {
// 在显示canvas中绘制图像,在缓存canvas中绘制
let zoom = ZOOM[this.zoomIndex]
this.ctx.save()
this.ctx.lineWidth = this.ctx.lineWidth * zoom
this.ctx.lineTo(x, y)
this.ctx.stroke()
this.ctx.restore()
// 计算在缓存canvas中的相对位置,并进行绘制
let cachePos = this.computeRelevantPos(x, y)
this.cacheCtx.save()
// 缩小对应放大的尺寸
this.cacheCtx.lineWidth = this.ctx.lineWidth / zoom
this.cacheCtx.lineTo(cachePos.x, cachePos.y)
this.cacheCtx.stroke()
this.cacheCtx.restore()
},
isMouseInImage: function(x, y) {
let zoom = ZOOM[this.zoomIndex]
let vx = this.vertexPos.x+(1-zoom)*this.width/2
let vy = this.vertexPos.y+(1-zoom)*this.height/2
let xInImage = vx <= x && x <= vx+this.width*zoom
let yInImage = vy <= y && y <= vy+this.height*zoom
if (xInImage && yInImage) {
return true
}
return false
},
computeRelevantPos: function(x, y) {
// 对应的缓存画布坐标需要做一个转换
// 计算相对位置,这里是这个程序最复杂的一部分了
// 这里需要考虑到显示canvas中图像的顶点位置,缩放尺寸,
// 然后来计算在对应的缓存canvas上的相对位置
let zoom = ZOOM[this.zoomIndex]
let vx = this.vertexPos.x+(1-zoom)*this.width/2
let vy = this.vertexPos.y+(1-zoom)*this.height/2
return {
x: (x-vx) / zoom,
y: (y-vy) / zoom
}
},
run: function() {
this.init()
this.loadImage()
this.addMouseEvent()
}
}
editor.run()
script>
body>
html>
这里补充一些实现上的数学细节和效果展示, 便于更好的理解这个程序的功能和实现。
这点我认为是最难的地方, 因为其他的坐标变化都是很直观的, 容易推导得出. 但是这里, 我当时也是饶了一个弯子, 感觉不是很好理解.
图片当前的顶点为:
( x 0 , y 0 ) (x_0, y_0) (x0,y0)
那么, 在缩放尺寸为 zoom 的情况下呢:
{ x 0 ′ = x 0 − ( z o o m − 1 ) ∗ w i d t h / 2 y 0 ′ = y 0 − ( z o o m − 1 ) ∗ h e i g h t / 2 \begin{cases} x_0' = x_0 - (zoom-1) * width / 2 \\ y_0' = y_0 - (zoom-1) * height / 2 \\ \end{cases} {x0′=x0−(zoom−1)∗width/2y0′=y0−(zoom−1)∗height/2
但是, 这里的意思是当前的顶点坐标依赖于缩放尺寸, 它是一个计算的值. 当前的顶点坐标还是不变的, 除非通过鼠标移动图片. 如果缩放以后使用这个计算的值更新了坐标, 后面再缩放就会造成坐标漂移了. 所以注意看第一个推导中的实际代码, 在比较的时候计算在缩放下的顶点坐标。这里可以举一个例子: 原地进行缩放, 它只是在当前顶点坐标的基础上进行变化, 而不是每次变化之后更新顶点坐标.
首先是图片的移动, 当鼠标点击在图片中时, 可以触发图片移动操作, 否则不做处理. 所以需要首先判断鼠标是否点击在图片中, 注意这里我加了一个逻辑, 如果选中了会绘制一个红色的边框, 判断逻辑是: 在 canvas 中按下鼠标后, 获取鼠标当前的坐标(需要做和屏幕坐标做一个偏移转换)和图片左上顶点, 图片的宽高, 图片的缩放尺寸做一个比较:
图片左上顶点坐标:
( x 0 , y 0 ) (x_0, y_0) (x0,y0)
鼠标按下坐标:
( x 1 , y 1 ) (x_1, y_1) (x1,y1)
图片宽高以及缩放尺寸: width, height, zoom
{ x 0 < = x 1 < = x 0 + w i d t h ∗ z o o m y 0 < = y 1 < = y 0 + h e i g h t ∗ z o o m \begin{cases} x_0 <= x_1 <= x_0 + width * zoom \\ y_0 <= y_1 <= y_0 + height * zoom \\ \end{cases} {x0<=x1<=x0+width∗zoomy0<=y1<=y0+height∗zoom
isMouseInImage: function(x, y) {
let zoom = ZOOM[this.zoomIndex]
let vx = this.vertexPos.x+(1-zoom)*this.width/2
let vy = this.vertexPos.y+(1-zoom)*this.height/2
let xInImage = vx <= x && x <= vx+this.width*zoom
let yInImage = vy <= y && y <= vy+this.height*zoom
if (xInImage && yInImage) {
return true
}
return false
}
这里为什么需要两个 canvas, 图片是绘制在 canvas 中的像素, 放到和缩小会影响到显示的质量, 从一个小的像素放大, 是把当前的像素放大, 这样多次操作之后就会很模糊了. 所以采用这种方式, 在缓存canvas上绘制的是不会进行放大缩小的, 所以图片的显示质量是可以保证和刚开始一样.
在显示 canvas 中, 不论是放大还是缩小或者移动到别处, 我们在绘制时, 和图片左上顶点的相对位置是不变的. 即, 当前的鼠标按下的点(显然得在图片中)和其到图片左上顶点的位置的差值再除以当前的缩放尺寸, 即是在缓存canvas上对应的坐标了.
所以, 这里可以很容易写成这个坐标转换公式:
{ x = ( x 1 − x 0 ) / z o o m y = ( y 1 − y 0 ) / z o o m \begin{cases} x = (x_1 - x_0) / zoom \\ y = (y_1 - y_0) / zoom \\ \end{cases} {x=(x1−x0)/zoomy=(y1−y0)/zoom
不过这里需要注意, 虽然坐标的转换相对容易, 但是也不是绝对精确的, 因为浮点数的问题, 不过这里并不是太大的问题. 还有就是线条粗细的转换, 这也是一个不能精确计算的.
computeRelevantPos: function(x, y) {
// 对应的缓存画布坐标需要做一个转换
// 计算相对位置,这里是这个程序最复杂的一部分了
// 这里需要考虑到显示canvas中图像的顶点位置,缩放尺寸,
// 然后来计算在对应的缓存canvas上的相对位置
let zoom = ZOOM[this.zoomIndex]
let vx = this.vertexPos.x+(1-zoom)*this.width/2
let vy = this.vertexPos.y+(1-zoom)*this.height/2
return {
x: (x-vx) / zoom,
y: (y-vy) / zoom
}
}