持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第2天,点击查看活动详情
作业批改
这个功能好久2年前就写好了, 当时是找遍了全网都没有符合自己需求的开源项目, 最后还是自己用原生API一点一点的设计和实现的, 第一篇来讲下作业批改功能的设计方案和实现基本的功能。
设计方案
我把所需要的功能大致列了下, 有以下几个
- 基本绘制
- 形状: 圆、对勾、方框、叉、箭头
- 文字、橡皮
- 缩放、旋转
- 撤销、清空
- 保存
这里我是采用三层canvas实现的:
- 一层是放原图, 什么编辑操作都不做;
- 一层是用来绘制编辑的, 画笔绘制出的图形都在这一层
- 一层是用来绘制文字的
这样设计, 清空的话就只要清空其余两层画布, 合成的时候三个放在一起合成一张图片即可。
这样设计的原因是, 当时被 offsetX, clientX 等这些位置坐标, 还有布局的偏移, 还有甲方一直在提新需求, 总是打破之前的方案, 最终改了多次版本就这种方式最稳妥。
但我实现的作业批改,有一个明显的缺点就是绘制好后不能调整大小了, emmm, 这个我的想法是用svg 来做, 因为要调整大小, 就是要获取这个图形对象, 但canvas 本来就获取不到里面的图形, 也可以通过加多个canvas实现, 但还不如就用svg 实现。
这里等canvas的更新完成后, 再研究svg怎么写, 整个系列预期有3篇文章左右, 预计7天左右更新完。
基本绘制功能实现。
这里带大家先实现基本的框架和绘制功能, 接下来我会把所有代码都做一个详细的解释的。
布局
布局我是分了工具栏和画布部分, 画布的宽度根据情况自己定, 这里就没有定死了, 画布的高度即为图片等比例缩放的高度; 这里高度设定的是100vh, 超出宽度就滚动, 也可以跟宽度一样成自定义
<template>
<div id="canvas-container">
<!-- 工具栏 -->
<div class="toolbox"> </div>
<!-- 画布 -->
<div class="canvas-box" :style="{ width: canvasWidth + 'px' }"></div>
</div>
</template>
<style>
#canvas-container {
display: flex;
height: 100vh;
position: absolute;
}
.canvas-box {
position: relative;
height: 100%;
overflow-x: hidden;
overflow-y: scroll;
}
</style>
工具栏的实现
图片是在阿里巴巴矢量图标库里找的, 旁边的字母是快捷键的意思, 这里先只实现它的布局和样式, 功能到第二篇再实现。
数据结构
- className: 图标和每个图标的样式, 其实可以用css选择器, 但这里不想改了。
- action: 这里就是点击图标后的触发事件了, 包括类型和事件, 比如点击画笔, 就只是改变下画布的配置; 点击旋转, 就是触发旋转事件。(具体怎么用的第二篇再讲)
- title: 图标的名称
- key: 快捷键
<!-- 工具栏 -->
<div class="toolbox">
<span
v-for="shape in shapes"
id="tools"
:key="shape.id"
:title="shape.title"
:class="[
'canvas-report',
shape.className,
{ active: config.shape === shape.action },
]"
@click="setShape(shape.action, shape.title)"
>
<span style="font-size: 14px; color: orange">{{ shape.key }}</span>
</span>
</div>
<script>
export default {
data() {
shapes: [
{
className: "icon-pencil tool",
action: "pencil",
title: "画笔",
key: "A",
},
{
className: "icon-eraser tool",
action: "eraser",
title: "橡皮擦",
key: "B",
},
{
className: "icon-arrow tool",
action: "arrow",
title: "箭头",
key: "C",
},
{
className: "icon-rotate-left tool",
action: "rotateLeft",
title: "向左旋转",
key: "D",
},
{
className: "icon-rotate-right tool",
action: "rotateRight",
title: "向右旋转",
key: "E",
},
{
className: "icon-check tool",
action: "check",
title: "对勾",
key: "F",
},
{
className: "icon-cha tool",
action: "close",
title: "错误",
key: "G",
},
{
className: "icon-search-plus tool",
action: "plus",
title: "放大",
key: "H",
},
{
className: "icon-search-minus tool",
action: "minus",
title: "缩小",
key: "I",
},
{
className: "icon-move tool",
action: "move",
title: "移动",
key: "J",
},
{
className: "icon-circle-thin tool",
action: "circle",
title: "圆形",
key: "K",
},
{
className: "icon-square tool",
action: "square",
title: "方形",
key: "L",
},
{
className: "icon-font tool",
action: "text",
title: "文字",
key: "M",
},
{
className: "icon-refresh tool",
action: "refresh",
title: "重置或清空",
},
], // 画布- 控件
}
}
</script>
<style>
// 工具箱的样式
.toolbox {
width: 50px;
display: flex;
background-color: #f2f4f7;
padding-bottom: 20px;
border-radius: 20px;
flex-direction: column;
text-align: center;
height: 664px;
min-width: 50px;
padding-top: 15px;
}
.tool {
margin: 5px auto 0 auto;
font-size: 24px;
color: #afc3dd;
padding: 5px 0;
width: 40px;
height: 40px;
display: flex;
justify-content: center;
align-items: center;
}
.tool.active {
color: white;
background-color: #94c0f9;
width: 40px;
height: 35px;
border-radius: 5px;
}
.tool:hover {
cursor: pointer;
background-color: #94c0f9;
color: white;
border-radius: 5px;
}
</style>
第一步: 加载图片
首先第一步, 我们用一个画布把图片加载上去, 布局和样式如下:
<div class="canvas-box" :style="{ width: canvasWidth + 'px' }">
<canvas id="canvas-report" class="imgCanvas"></canvas>
</div>
<style>
.imgCanvas {
float: right;
position: absolute;
width: 100%;
}
</style>
主要涉及到两个方法
首先在creat中, 我们进行数据的初始化, 在这, 我们可以进行图片数据的获取和处理
data() {
return {
canvasImg: new Image(), // 画布-图片
canvasWidth: "750", // 画布-宽度
};
},
created() {
// 初始化时是对数据进行处理
this.initData();
},
methods: {
/**初始化的部分**/
// 初始化图片数据, 若有数组啥的, 可以直接在这里进行数组的获取和整理
initData() {
this.canvasImg.src =
"https://wlsy-hbut.oss-cn-beijing.aliyuncs.com/%E5%AE%9E%E9%AA%8C%E8%B5%84%E6%96%99/empty2.png";
this.canvasImg.setAttribute("crossOrigin", "Anonymous");
},
}
其次, 在 mount 中, 我们获取画布节点, 设置画布的宽高, 并把图片添加到画布上去.
export default {
data() {
return {
// 与画布相关的节点或控件
canvasReport: null, // 原图片的画布容器
ctxReport: null, // 原图片的2d对象
};
},
mounted() {
this.initMounted();
},
methods:{
// 挂载后初始画布
initMounted() {
// 获取原图的画布节点和2d内容
this.canvasReport = document.querySelector("#canvas-report");
this.ctxReport = this.canvasReport.getContext("2d");
// 画布加载
this.canvasImg.onload = () => {
// 获取比例, 以宽度为准, 调整高度
let scale = this.canvasWidth / this.canvasImg.width;
// 初始化宽高,这个是由.canvas宽决定的
let canvasWidth = this.canvasWidth;
let canvasHeight = this.canvasImg.height * scale;
// 给各个画图图层设置宽高
this.canvasReport.width = canvasWidth;
this.canvasReport.height = canvasHeight;
// 图片加载: 注意: 这里只有一个是放原图的
this.ctxReport.clearRect(0, 0, canvasWidth, canvasHeight);
this.ctxReport.drawImage(
this.canvasImg,
0,
0,
canvasWidth,
canvasHeight
);
};
},
}
}
到这里, 你就可以看到了页面上已经出现了图片
第二步: 实现绘制
这里最坑的地方, 就是获取鼠标在画布中的位置, 这里就涉及到clientX offsetX screenX pageX 这几个的区别了。
当时就是花了很久去设计公式, 可最后发现, 只用 offset 即可, 这里先讲下这几个值的区别。
- clientX:当事件被触发时鼠标指针相对于窗口左边界的水平坐标,
参照点为浏览器内容区域的左上角,该参照点会随之滚动条的移动而移动。 - offsetX:当事件被触发时鼠标指针相对于
所触发的标签元素的左内边框的水平坐标。 - screenX:鼠标位置相对于用户屏幕水平偏移量,而screenY也就是垂直方向的,
此时的参照点也就是原点是屏幕的左上角 - pageX:
参照点是页面本身的body原点,而不是浏览器内容区域左上角,它计算的值不会随着滚动条而变动,它在计算时其实是以body左上角原点(即页面本身的左上角,而不是浏览器可见区域的左上角)为参考点计算的,这个相当于已经把滚动条滚过的高或宽计算在内了,所以无论滚动条是否滚动,他都是一样的距离值。
然后你再去看 canvas 中的 moveTo, lineTo 这些API, 它传入的坐标值是相当于画布的左上角的, 这就符合 offsetX 的介绍, 即我们只要获取当前点击的 offsetXY 的坐标, 即可实现绘制, 而不会出现偏移的情况, 也不用考虑滚动等其他因素。
<!-- 画布 -->
<div class="canvas-box" :style="{ width: canvasWidth + 'px' }">
<canvas id="canvas-report" class="imgCanvas"></canvas>
<canvas
id="canvas-correct"
class="imgCanvas"
@mousedown="canvasDown($event)"
@touchstart="canvasDown($event)"
@mousemove="canvasMove($event)"
@touchmove="canvasMove($event)"
@mouseup="canvasUp($event)"
@touchend="canvasUp($event)"
>
</canvas>
</div>
<script>
export default {
methods:{
/**
* 鼠标按下的触发事件, 主要做以下几件事
*
* @param e 事件对象
*/
canvasDown(e) {
this.getCanvasXY(e);
// 开始绘制, 把点移动到当前位置
this.canvasMouseUse = true;
this.ctxCorrect.beginPath();
this.ctxCorrect.moveTo(this.canvasX, this.canvasY);
},
/**
* 移动过程中, 不端获取当前在画布上的位置,并绘制
*/
canvasMove(e) {
if (!this.canvasMouseUse) return;
this.getCanvasXY(e);
this.ctxCorrect.lineTo(this.canvasX, this.canvasY);
this.ctxCorrect.stroke();
},
/**
* 鼠标抬起时, 结束绘制
*/
canvasUp() {
this.canvasMouseUse = false;
this.ctxCorrect.beginPath();
},
/**
* 获取当前的offset坐标
* @param e
*/
getCanvasXY(e) {
this.canvasX = e.offsetX;
this.canvasY = e.offsetY;
},
}
}
</script>
总结
好了, 第一篇文章就先写到这里, 下一篇将会实现编辑器的剩余部分。