效果
用户可以在中间图片画布上描摹骨刻文,并且可以设置线条宽度、使用橡皮擦除、进行清空、撤回操作等等。点击提交按钮可以将用户复现的骨刻文笔迹上传服务器,并获取评分。
实现
1. 结构
这部分代码主要使用了canvas画布,在一个div中包含了4层结构,如图:
除了第二层,其余层的position都设置为absolute
,即不占据文档流;因为初始的canvas是透明的,所以在其下方(第三层)的img可以透过第二层而被用户看到;第一层的加载图标是当服务器的图片还未传过来时用来占据页面,提示正在加载。
2. 预处理
(1) 设置window的坐标到canvas坐标的映射
因为事件坐标里的原点位于浏览器左上角,而canvas中绘图的坐标是相对于canvas元素的左上角,所以需要中间做一个转换,也就是下面这个函数。
这个函数会返回相对于canvas的坐标,后面绘图操作的时候就调用这个方法,并用这个方法获得的坐标值进行描摹即可
function windowToCanvas(canvas, x, y) {
// getBoundingClientRect()用于获得页面中某个元素的左,上,右和下分别相对浏览器视窗的位置。
const bbox = canvas.getBoundingClientRect();
// Window.getComputedStyle()方法返回一个对象,
// 该对象在应用活动样式表并解析这些值可能包含的任何基本计算后报告元素的所有CSS属性的值。
const style = window.getComputedStyle(canvas);
return {
x:
(x -
bbox.left -
parseInt(style.paddingLeft) -
parseInt(style.borderLeft)) *
(canvas.width / parseInt(style.width)),
y:
(y - bbox.top - parseInt(style.paddingTop) - parseInt(style.borderTop)) *
(canvas.height / parseInt(style.height))
};
}
(2)获取canvas及其上下文
Step1:在两个canvas上设置Ref,以便获取canvas的变化。怎么设置可以查阅React官方文档 如何创建Ref
Step2:在componentDidMount
方法中(也就是组件挂载完成后,因此只有组件挂载好了,Ref才创建好了,才能获得Ref对应的canvas的上下文),代码如下
// 组件完成挂载后
componentDidMount() {
this.ctx1 = this.canvas1.current.getContext('2d');//获取上下文
this.ctx2 = this.canvas2.current.getContext('2d');
}
(3) 请求图片
向后端请求图片,然后设置stateloading
(该状态表示当前图片是否正在加载,以在图片未加载完成时使用load图标占位)为false
,即加载完毕,然后设置stateimageSrc
为接收到的图片地址,即可在页面上呈现出骨刻文图片
3. 描摹操作
Step1:当onMouseDown
事件触发时,设置statedrawing
为true
Step2:当onMouseMove
事件触发且statedrawing
为true
时,执行draw()
方法
// 输入的x,y是获取到的鼠标落点位置,已经使用windowToCanvas方法进行过转换
draw(x, y) {
// beginPath用来清除之前的路径并开启新路径
// this.ctx1是canvas1的上下文,也就是第二层canvas的上下文
this.ctx1.beginPath();
// moveTo( )移动路径的点
this.ctx1.moveTo(x, y);
// lineTo( )生成直线的路径
this.ctx1.lineTo(x, y);
this.ctx1.strokeStyle = this.props.lineColor;
this.ctx1.lineWidth = this.props.lineWidth;
this.ctx1.lineCap = 'round';
// stroke( )用来对路径描边
this.ctx1.stroke();
}
Step3:当onMouseUp
事件触发时,设置statedrawing
为false
,停止描摹
4. 橡皮擦、清除功能的实现
这里需要用到第二个canvas,当用户工具栏选择橡皮擦时,进行如下操作:
Step1:当onMouseDown
事件触发时,设置stateerasing
为true
Step2:当onMouseMove
事件触发且stateerasing
为true
时,执行erase()
方法
erase(x, y) {
this.ctx1.beginPath();
// this.ctx2是第二个canvas(也就是最底层的canvas)的上下文
let pxs = this.ctx2.getImageData(x - this.props.radius / 2, y - this.props.radius / 2, this.props.radius, this.props.radius);
this.ctx1.putImageData(pxs, x - this.props.radius / 2, y - this.props.radius / 2);
}
getImageData()
方法返回 ImageData 对象,该对象拷贝了画布指定矩形的像素数据,其参数列表如下:
来自:HTML canvas getImageData() 方法 | 菜鸟教程
this.props.radius
表示的是设置的橡皮宽度
putImageData()
方法将图像数据(从指定的 ImageData 对象)放回画布上
在这里,也就是把橡皮擦要擦除的部分从canvas2中放到canvas1上,canvas2是最底层的没有被描摹过的画布,所以它上面的像素全部是初始像素,所以拷贝canvas2某一部分的像素到canvas1上,可以实现橡皮擦擦除的效果
Step3:当onMouseUp
事件触发时,设置stateerasing
为false
,停止擦除
清空也是类似的道理
// 清除
wipe() {
// 返回一个ImageData对象,该对象复制画布上指定矩形的像素数据
let pxs = this.ctx2.getImageData(0, 0, this.canvas1.current.width, this.canvas1.current.height);
this.canvasStack.push(pxs)
this.ctx1.putImageData(pxs, 0, 0);
}
也就是清除复制的是整张canvas2画布,也就是全部内容还原回初始内容
除此之外,清空也可以使用clearRect()方法
5. 撤回功能的实现
实际上是使用了栈
Step1:在constructor
中定义一个栈canvasStack
Step2:定义方法saveImage()
saveImage() {
let image = this.ctx1.getImageData(0, 0, this.canvas1.current.width, this.canvas2.current.height);
this.setState({
savedImage: image})
this.canvasStack.push(image);
}
这个方法主要是获取canvas1(第二层canvas)的所有像素,并把它放进栈里
Step3:在onMouseDown
监听事件中调用saveImage()
为什么不是在onMouseUp
时呢?如果那样,一开始pop出来的都是当前的画面。正常应该pop出上一次的画面,因此要保证保存后不会进行撤回操作,即每次再次描摹时才会进行save
Step4:撤回
// 撤回
withdraw() {
if (this.canvasStack.length > 1) {
let pxs = this.canvasStack.pop();
this.ctx1.putImageData(pxs, 0, 0);
} else alert("已为最开始的图")
}
当点击撤回按钮时,会将canvasStack
栈顶的pxs弹出,并调用putImageData
方法图像数据放置到canvas1画布上,就实现了还原上一次操作的效果,即撤回