一、背景
之前在公司开发了很多工具类的项目,其中开发了一个流程图的基础组件,做着感觉还挺有意思的,当时方案是基于canvas的(为啥不用插件呢,因为功能还有UI给的样式都比较特别,有时间就自己做了)。
因为公司内网不对外,所以代码没办法拿出来。正好趁着入职新公司之前做点之前一直想做的小功能,顺便适应一下react。
PS:之前用的vue,react的hook写起来没问题,用起来总是出点问题,然后删删改改,现在看着自己的代码感觉好乱emm。
二、功能介绍
整体界面:
上面是工具栏,下面是操作栏。 工具栏从左到右的按钮功能是:画笔、矩形、椭圆、橡皮擦、移动、线条颜色、线宽、前进、后退、缩放,以及最右的选择文件。
下方的按钮从左到右功能是:清空(还原)画布、下载图片到本地。
三、开发环境
框架:React 17.0.1
UI框架: ant design 4.16.9 (用了一些图标和按钮)
四、一些功能实现方案的选择
1.数据存储
前进后退是对数据的保存和提取,要把画布状态保存起来,用数据标识当前处于第几个状态,在此基础上对数据进行管理。存储的话用数组存储,然后用下标表示当前的状态,利用下标的移动实现前进/后退功能。
重点是保存什么数据。
(1)方案
1.保存画布的结果,及当前画布的像素。这可以用CanvasRenderingContext2D.getImageData()方法获取,然后用CanvasRenderingContext2D.putImageData()重新渲染;
2.保存每次操作的状态和轨迹。比如每次画线的颜色、线宽、移动路径等等,每次渲染时取出路径和对应的状态重新渲染;
(2)对比
imageData:优点:逻辑简单,操作方便;缺点:每次保存像素数据的话,内存消耗太大了,尤其是当图片复杂的时候;
path:优点:只记录每次操作的状态,相对更轻量;缺点:操作次数多时,渲染流程会比较长(因为每一次操作都需要单独渲染);状态维护更繁琐一点。
imageData的一个比较致命的问题是,当缩放之后内容超出画布时,它无法记录超出区域的像素,这样就会造成内容的丢失。
所以,这里采用第二种方案。
那么,如何记录操作路径呢?记录每一个坐标点吗?答案是,用path2d对象记录路径,而不是具体的每一个坐标。
path2D对象记录运动路径,但是不记录画布状态(线宽、颜色等),可以用rect、arc、lineTo等方式添加路径,具体使用可以参考MDN
2.缩放/平移
缩放/平移是对画布的整体操作,并且是可以记录的操作。在缩放和平移之后往往会影响交互的数据,因为鼠标的交互数据是不变的,所有交互数据都需要考虑平移/变换的影响。
(1)方案
1.根据缩放和平移修改对应的坐标数据;
2.使用transform对应的canvas元素;
3.对canvas的坐标系进行转换;
(2)对比
1.直接pass。缺点太过明显,如果要使用这种方案,那么操作数据就要保存每一次的真实坐标,并且每次修改缩放比例或者平移时,都要全量修改所有数据,太繁琐了,性能开销也大;
2.可以接受。优点是操作简单,易实现,可通过对数据的代数转换实现等效的交互效果;缺点是,放大之后渲染性能是个问题(canvas越大,单次渲染开销越大);
3.可以接受。优点:处理不算麻烦(相对第二种方案多了几步步骤),且不会影响性能。缺点:需要考虑代数转换。
最终采纳的是第三种方案,对canvas的坐标系进行伸缩变换,同时所有的交互数据均根据缩放和平移数据进行等效的代数替换,并且不需要修改之前记录的path对象。
五、功能实现
先暂定画布大小为1000*750
1.数据初始化
const [mode, setMode] = useState("line");//当前的绘制类型
const [isStart, setIsStart] = useState(false);//是否开始绘制
const [initPosition, setInitPosition] = useState({ x: 0, y: 0 });//当前绘制的起点
const [lineWidth, setLineWidth] = useState(1);//线宽
const [recordIndex, setRecordIndex] = useState(1);//当前状态下标
const [lineColor, setLineColor] = useState("#000000");//颜色
const [canvasTranslate, setTranslate] = useState([500, 375]);//画布坐标系默认平移距离
const [scale, setScale] = useState(100);//缩放大小
const [canvasSize, setCanvasSize] = useState([defaultWidth, defaultHeight]);//画布大小
const [canvasState, setCanvasState] = useState([getInitState()]); //画布数据
2.画笔
画笔是最基础的功能,在画布上按下鼠标,跟随光标轨迹画出一系列线段。和三个事件相关:mousedown、mousemove、mouseup
可以在mousedown事件中记录初始的坐标,然后在mouse事件中实时获取当前的坐标并和上一次的坐标连线,然后在mouseup事件中推出编辑状态。(这个坐标一定要是基于canvas左上角的,所以这里用offsetX和offsetY,不能用pageX,pageY,因为后续有缩放功能,用pageX和pageY的话计算起来很麻烦)
绑定事件:
useEffect(() => {
const canvas = canvasEle.current;
canvas.addEventListener("mousedown", mousedownEvent);
canvas.addEventListener("mousemove", mousemoveEvent);
window.addEventListener("mouseup", mouseupEvent);
return () => {
canvas.removeEventListener("mousedown", mousedownEvent);
canvas.removeEventListener("mousemove", mousemoveEvent);
window.removeEventListener("mouseup", mouseupEvent);
};
});
我们需要一个状态用于记录鼠标是否按下,然后三个事件分别为:
/**
* 鼠标移动事件
* @param {*} e
* @returns
*/
function mousemoveEvent(e) {
if (!isStart) return;
const [x, y] = [e.offsetX, e.offsetY];
switch (mode) {
case "line":
lineMove(x, y);
break;
case "circle":
circleMove(x, y);
break;
case "rect":
rectMove(x, y);
break;
case "move":
canvasMove(x, y);
break;
default:
abraseMove(x, y);
}
}
/**
* 鼠标按下事件
* @param {*} e 事件对象
* @returns
*/
function mousedownEvent(e) {
setIsStart(true);
ctx.beginPath();
ctx.moveTo(e.offsetX, e.offsetY);
const [x, y] = [e.offsetX, e.offsetY];
setInitLayout({ x, y });
}
/**
* 鼠标松开事件
* @param {*} e
* @returns
*/
function mouseupEvent(e) {
if (!isStart) return;
setIsStart(false);
}
其中,lineMove的逻辑:
const lineMove = (newX, newY) => {
const [x, y] = initPosition;
ctx.lineTo(newX, newY);
ctx.stroke();
setInitPosition({ x: newX, y: newY });
};
效果:
3.前进/后退
前进/后退为什么要提前说,是因为这里涉及到数据存储的格式问题,和所有操作都有关。
实现方案在前面详细介绍过了,所以,每一次操作对应的数据的格式为:
const getCurState = (path, fill) => {
return {
type: "path",
scale,
path,
lineWidth: lineWidth,
lineColor: lineColor,
fill,
};
};
path就是当前的路径对象,其他就是画布本身的状态数据。
因为每次操作只记录状态,具体的渲染交给hook来做,所以把渲染过程改一下:
useEffect(() => {
if (!canvasState.length) return;
const ctx = canvasEle.current.getContext("2d");
ctx.clearRect(0, 0, defaultWidth, defaultHeight);
const contentState = canvasState.slice(0, recordIndex);
for (const item of contentState) {
ctx.beginPath();
ctx.lineWidth = item.lineWidth;
ctx.strokeStyle = item.lineColor;
if (item.fill) {
ctx.fillStyle = "#fff";
ctx.fill(item.path);
} else {
ctx.stroke(item.path);
}
}
}, [canvasState, recordIndex]);
那么,之前画线的逻辑对应修改。考虑到前进后退,在mousedown事件里添加一个新的记录,然后在move事件里不断的替换最后一个记录(这样是为了每次操作生成一个记录,而不是每次事件的触发都生成一个记录)。
const lineMove = (x, y) => {
const pre = canvasState[canvasState.length - 1].path;
const path = new Path2D();
path.addPath(pre);
path.lineTo(x, y);
replaceState(getCurState(path));
};
//添加状态记录。
const replaceState = (newState) => {
const state = canvasState.slice(0, canvasState.length - 1);
setCanvasState(state.concat(newState));
};
function mousedownEvent(e) {
setIsStart(true);
const [x, y] = [e.offsetX, e.offsetY];
const newState = canvasState.slice(0, recordIndex);
const newPath = new Path2D();
newPath.moveTo(x, y);
setCanvasState(newState.concat(getCurState(newPath)));
setRecordIndex(recordIndex + 1);
setInitPosition({ x, y });
}
最后,前进后退的逻辑就很简单了,注意一下边界情况就行。
const frontOrBack = (v) => {
setRecordIndex(Math.max(1, Math.min(canvasState.length, recordIndex + v)));
};
这里最小值是1,是因为初始化的时候要将画布的初始状态记录(画布大小、线宽、缩放等)进去,这个记录是一定要有的,不然会丢失最初的画布状态信息。
效果:
4. 矩形/椭圆
这两种放在一起说,是因为这两个很像。
和线段的区别是,线段的路径是连续的,每次mousemove时,要在上一个path的基础上添加新的path。
矩形和椭圆只需要mousedown的坐标,然后根据当前坐标计算对应的宽高/半径及左上角/中心点坐标即可,当前的path和上一次的path没有关系。
处理逻辑:
const circleMove = (x, y) => {
const path = new Path2D();
const { x: initX, y: initY } = initPosition;
path.ellipse(
(initX + x) / 2,
(initY + y) / 2,
Math.abs((initX - x) / 2),
Math.abs((initY - y) / 2),
0,
0,
2 * Math.PI
);
replaceState(getCurState(path));
};
const rectMove = (x, y) => {
const path = new Path2D();
const { x: initX, y: initY } = initPosition;
path.rect(
Math.min(initX, x),
Math.min(initY, y),
Math.abs(x - initX),
Math.abs(y - initY)
);
replaceState(getCurState(path));
};
效果:
5.橡皮擦
橡皮擦的话,目前的思路是类似画线,区别就是每一次用连续的矩形填充白色,覆盖path上的图案。这里不用圆形是因为实践过程发现圆fill时有问题,和路径起点会有联系,用rect就不会。
为了防止移动过快,矩形不连续,可以根据前后坐标构造一组连续的矩形path
const getLinearRect = (x, y, x2, y2, step = 5) => {
const path = new Path2D();
const disx = x2 - x;
const disy = y2 - y;
let c = Math.abs(disx / step);
const ypercent = disy / c;
let flag = x2 >= x ? 1 : -1;
for (let i = 0; i <= c; i++) {
path.rect(x2 - i * 5 * flag - 8, y2 - i * ypercent - 8, 16, 16);
}
return path;
};
const abraseMove = (x, y) => {
const pre = canvasState[canvasState.length - 1].path;
const path = getLinearRect(initPosition.x, initPosition.y, x, y);
path.addPath(pre);
setInitPosition({ x, y });
replaceState(getCurState(path, true));
};
效果:
6.线宽/线条颜色
这两个功能相对简单,因为不会对原有图形产生影响,只需要维护状态就行了。直接绑定组件即可。
const [lineWidth, setLineWidth] = useState(1);
const [lineColor, setLineColor] = useState("#000000");
效果:
7.缩放/平移
平移的逻辑和画矩形/椭圆类似,记录mousedown的坐标,在mousemove时不断更新当前的相对偏移。
方案在前面介绍过了,对canvas的坐标系进行转换,可以用CanvasRenderingContext2D.setTransform(),每次更新当前的变换,也可以CanvasRenderingContext2D.transform(),叠加之前的变换。只应用某种变换,比如缩放,可以用CanvasRenderingContext2D.scale()。
应用之后,所有的交互数据均需要经过对应的变换处理,举个简单的例子:
执行CanvasRenderingContext2D.scale(2,2)之后,canvas的坐标系放大两倍,所以所有的交互数据要除以2,也就是缩小两倍。
平移是类似,坐标系平移x和y,数据就要减去x和y。
为了区分图形绘制和坐标系变换的操作,在操作记录的数据里添加一个type属性用于区分,并用transform属性记录当前的坐标系状态。
以mousemove事件的处理逻辑为例:
//移动模式时,鼠标按下事件
const moveMouseDown = (x, y) => {
if (!canvasState.length) return;
setInitPosition({ x, y });
const preState = canvasState.slice(0, recordIndex);
setCanvasState(
preState.concat({
type: "transform",
transform: [
[scale, scale],
[canvasTranslate[0], canvasTranslate[1]],
],
})
);
setRecordIndex(recordIndex + 1);
};
/**
* 画布移动事件
* @param {*} x
* @param {*} y
*/
const canvasMove = (x, y) => {
const [preX, preY] = canvasTranslate;
const { x: initX, y: initY } = initPosition;
setTranslate([preX + x - initX, preY + y - initY]);
replaceState({
type: "transform",
transform: [
[scale, scale],
[preX + x - initX, preY + y - initY],
],
});
};
数据转换过程:
/**
* 鼠标移动事件
* @param {*} e
* @returns
*/
function mousemoveEvent(e) {
if (!isStart) return;
ctx.lineJoin = "round";
const [translateX, translateY] = canvasTranslate;
const [x, y] = [
((e.offsetX - translateX) * 100) / scale,
((e.offsetY - translateY) * 100) / scale,
];
....
}
修改渲染的处理逻辑,canvas应用伸缩变换
useEffect(() => {
const ctx = canvasEle.current.getContext("2d");
ctx.resetTransform();
ctx.clearRect(0, 0, defaultWidth, defaultHeight);
const contentState = canvasState
.slice(0, recordIndex)
.filter((item) => item.type !== "transform");
const transformState = canvasState
.slice(0, recordIndex)
.filter((item) => item.type === "transform")
.pop()?.transform;
if (!transformState) {
} else {
const [[x, y], [x2, y2]] = transformState;
ctx.translate(x2, y2);
ctx.scale(x / 100, y / 100);
setScale(x);
setTranslate([x2, y2]);
}
for (const item of contentState) {
ctx.beginPath();
ctx.lineWidth = item.lineWidth;
ctx.strokeStyle = item.lineColor;
if (item.fill) {
ctx.fillStyle = "#fff";
ctx.fill(item.path);
} else {
ctx.stroke(item.path);
}
}
}, [canvasState, recordIndex]);
效果:
8.加载图片
思路是选择文件,然后生成一个本地链接,然后用canvas去绘制。
有个问题就是,选择的图片比例不一定是默认尺寸的比例,所以要先获取一下选择图片的原始宽高,用contain的方式计算出最大缩放比例,计算出此时的宽高,然后应用到canvas上。
因为修改了尺寸,所以加载图片时选择清空画布并还原伸缩变换,根据当前图片尺寸计算初始的translate的值。
为了区分图片的渲染和其他渲染,用type='img'表示,并用img属性记录img对象,size记录尺寸信息。
const selectFile = (e) => {
const url = window.URL.createObjectURL(e.file);
const img = new Image();
img.src = url;
img.onload = () => {
const n = Math.min(defaultWidth / img.width, defaultHeight / img.height);
const size = [img.width * n, img.height * n];
initCanvas(...size, [
{
type: "img",
img,
size,
},
]);
};
return false;
};
修改渲染逻辑:
for (const item of contentState) {
const { img, size } = item;
if (item.type === "img") {
ctx.drawImage(
img,
0,
0,
img.width,
img.height,
(-1 * size[0]) / 2,
(-1 * size[1]) / 2,
size[0],
size[1]
);
continue;
}
ctx.beginPath();
ctx.lineWidth = item.lineWidth;
ctx.strokeStyle = item.lineColor;
if (item.fill) {
ctx.fillStyle = "#fff";
ctx.fill(item.path);
} else {
ctx.stroke(item.path);
}
}
效果:
9.清空画布
添加一个初始化状态的方法。下面的state参数,是加载图片时的数据,清空画布时不传即可。注意要清除之前加载图片生成的url
const initCanvas = (w = defaultWidth, h = defaultHeight, state = []) => {
for (const item of canvasState) {
if (item.type === "img") {
window.URL.revokeObjectURL(item.img.src);
}
}
setCanvasSize([w, h]);
setScale(100);
setTranslate([w / 2, h / 2]);
setCanvasState([getInitState(w / 2, h / 2), ...state]);
setRecordIndex(state.length + 1);
};
10.下载图片
用canvas.toDataURL()生成URI,并用a标签下载
const downLoadImg = () => {
const aEle = document.createElement("a");
document.body.appendChild(aEle);
aEle.href = canvasEle.current.toDataURL();
aEle.download = `${Date.now()}.jpg`;
aEle.click();
document.body.removeChild(aEle);
};
效果:
11.完整代码
function CanvasTool() {
const defaultWidth = 1000;
const defaultHeight = 750;
const canvasEle = useRef();
const ctx = canvasEle.current?.getContext("2d");
const list = [
{
value: "line",
title: "线条",
},
{
value: "rect",
title: "矩形",
},
{
value: "circle",
title: "圆",
},
// {
// value: "abrase",
// title: "马赛克",
// },
];
const lineWidthList = [1, 2, 4, 6];
const [mode, setMode] = useState("line");
const [isStart, setIsStart] = useState(false);
const [initPosition, setInitPosition] = useState({ x: 0, y: 0 });
const [recordIndex, setRecordIndex] = useState(1);
const [lineWidth, setLineWidth] = useState(1);
const [lineColor, setLineColor] = useState("#000000");
const [canvasTranslate, setTranslate] = useState([500, 375]);
const [scale, setScale] = useState(100);
const [canvasSize, setCanvasSize] = useState([defaultWidth, defaultHeight]);
/**
* 获取默认的数据记录
* @param {*} x
* @param {*} y
* @returns
*/
const getInitState = (x = 500, y = 375) => {
return {
type: "transform",
transform: [
[100, 100],
[x, y],
],
};
};
const [canvasState, setCanvasState] = useState([getInitState()]);
/**
* 修改缩放比例
* @param {*} v
*/
const setScale2 = (v) => {
setScale(v);
const preState = canvasState.slice(0, recordIndex);
setCanvasState(
preState.concat({
type: "transform",
transform: [
[v, v],
[canvasTranslate[0], canvasTranslate[1]],
],
})
);
setRecordIndex(recordIndex + 1);
};
/**
* 修改线条颜色
* @param {*} e
*/
const setLineColor2 = (e) => {
setLineColor(e.target.value);
};
/**
* 替换最后一个操作记录
* @param {*} newState
*/
const replaceState = (newState) => {
const state = canvasState.slice(0, canvasState.length - 1);
setCanvasState(state.concat(newState));
};
/**
* 画笔移动时的处理
* @param {*} x
* @param {*} y
*/
const lineMove = (x, y) => {
const pre = canvasState[canvasState.length - 1].path;
const path = new Path2D();
path.addPath(pre);
path.lineTo(x, y);
replaceState(getCurState(path));
};
/**
* 画圆时的移动处理
* @param {*} x
* @param {*} y
*/
const circleMove = (x, y) => {
const path = new Path2D();
const { x: initX, y: initY } = initPosition;
path.ellipse(
(initX + x) / 2,
(initY + y) / 2,
Math.abs((initX - x) / 2),
Math.abs((initY - y) / 2),
0,
0,
2 * Math.PI
);
replaceState(getCurState(path));
};
/**
* 画矩形时的移动处理
* @param {*} x
* @param {*} y
*/
const rectMove = (x, y) => {
const path = new Path2D();
const { x: initX, y: initY } = initPosition;
path.rect(
Math.min(initX, x),
Math.min(initY, y),
Math.abs(x - initX),
Math.abs(y - initY)
);
replaceState(getCurState(path));
};
/**
* 根据起点和重点生成连续的矩形路径
* @param {*} x
* @param {*} y
* @param {*} x2
* @param {*} y2
* @param {*} step
* @returns
*/
const getLinearRect = (x, y, x2, y2, step = 5) => {
const path = new Path2D();
const disx = x2 - x;
const disy = y2 - y;
let c = Math.abs((disx * 100) / (step * scale));
const ypercent = disy / c;
let flag = x2 >= x ? 1 : -1;
for (let i = 0; i <= c; i++) {
path.rect(
x2 - i * 5 * flag - 8,
y2 - i * ypercent - 8,
(16 * 100) / scale,
(16 * 100) / scale
);
}
return path;
};
/**
* 橡皮擦时的移动处理
* @param {*} x
* @param {*} y
*/
const abraseMove = (x, y) => {
const pre = canvasState[canvasState.length - 1].path;
const path = getLinearRect(initPosition.x, initPosition.y, x, y);
path.addPath(pre);
setInitPosition({ x, y });
replaceState(getCurState(path, true));
};
/**
* 移动模式时,鼠标按下事件
* @param {*} x
* @param {*} y
* @returns
*/
const moveMouseDown = (x, y) => {
if (!canvasState.length) return;
setInitPosition({ x, y });
const preState = canvasState.slice(0, recordIndex);
setCanvasState(
preState.concat({
type: "transform",
transform: [
[scale, scale],
[canvasTranslate[0], canvasTranslate[1]],
],
})
);
setRecordIndex(recordIndex + 1);
};
/**
* 鼠标移动事件
* @param {*} e
* @returns
*/
function mousemoveEvent(e) {
if (!isStart) return;
ctx.lineJoin = "round";
const [translateX, translateY] = canvasTranslate;
const [x, y] = [
((e.offsetX - translateX) * 100) / scale,
((e.offsetY - translateY) * 100) / scale,
];
switch (mode) {
case "line":
lineMove(x, y);
break;
case "circle":
circleMove(x, y);
break;
case "rect":
rectMove(x, y);
break;
case "move":
canvasMove(x, y);
break;
default:
abraseMove(x, y);
}
}
/**
* 鼠标按下事件
* @param {*} e 事件对象
* @returns
*/
function mousedownEvent(e) {
setIsStart(true);
const [translateX, translateY] = canvasTranslate;
const [x, y] = [
((e.offsetX - translateX) * 100) / scale,
((e.offsetY - translateY) * 100) / scale,
];
if (mode === "move") {
moveMouseDown(x, y);
return;
}
const newState = canvasState.slice(0, recordIndex);
const newPath = new Path2D();
newPath.moveTo(x, y);
setCanvasState(newState.concat(getCurState(newPath)));
setRecordIndex(recordIndex + 1);
setInitPosition({ x, y });
}
/**
* 鼠标松开事件
* @param {*} e
* @returns
*/
function mouseupEvent(e) {
if (!isStart) return;
// recordState();
setIsStart(false);
// setCurPath(null);
}
/**
* 画布移动事件
* @param {*} x
* @param {*} y
*/
const canvasMove = (x, y) => {
const [preX, preY] = canvasTranslate;
const { x: initX, y: initY } = initPosition;
setTranslate([preX + x - initX, preY + y - initY]);
replaceState({
type: "transform",
transform: [
[scale, scale],
[preX + x - initX, preY + y - initY],
],
});
};
//事件绑定
useEffect(() => {
const canvas = canvasEle.current;
canvas.addEventListener("mousedown", mousedownEvent);
canvas.addEventListener("mousemove", mousemoveEvent);
window.addEventListener("mouseup", mouseupEvent);
return () => {
canvas.removeEventListener("mousedown", mousedownEvent);
canvas.removeEventListener("mousemove", mousemoveEvent);
window.removeEventListener("mouseup", mouseupEvent);
};
});
//画布移动模式下,修改画布的cursor
useEffect(() => {
if (mode === "move") {
canvasEle.current?.classList.add("move-canvas");
} else {
canvasEle.current?.classList.remove("move-canvas");
}
}, [mode]);
/**
* 前进/后退事件
* @param {*} v
*/
const frontOrBack = (v) => {
setRecordIndex(Math.max(1, Math.min(canvasState.length, recordIndex + v)));
};
/**
* 生成当前的操作记录
* @param {*} path
* @param {*} fill
* @returns
*/
const getCurState = (path, fill) => {
return {
type: "path",
scale,
path,
lineWidth: lineWidth,
lineColor: lineColor,
fill,
};
};
//画布渲染
useEffect(() => {
if (!canvasState.length) return;
const ctx = canvasEle.current.getContext("2d");
ctx.resetTransform();
ctx.clearRect(0, 0, defaultWidth, defaultHeight);
const contentState = canvasState
.slice(0, recordIndex)
.filter((item) => item.type !== "transform");
const transformState = canvasState
.slice(0, recordIndex)
.filter((item) => item.type === "transform")
.pop()?.transform;
const [[x, y], [x2, y2]] = transformState;
ctx.translate(x2, y2);
ctx.scale(x / 100, y / 100);
setScale(x);
setTranslate([x2, y2]);
for (const item of contentState) {
const { img, size } = item;
if (item.type === "img") {
ctx.drawImage(
img,
0,
0,
img.width,
img.height,
(-1 * size[0]) / 2,
(-1 * size[1]) / 2,
size[0],
size[1]
);
continue;
}
ctx.beginPath();
ctx.lineWidth = item.lineWidth;
ctx.strokeStyle = item.lineColor;
if (item.fill) {
ctx.fillStyle = "#fff";
ctx.fill(item.path);
} else {
ctx.stroke(item.path);
}
}
}, [canvasState, recordIndex]);
/**
* 初始化画布状态
* @param {*} w
* @param {*} h
* @param {*} state
*/
const initCanvas = (w = defaultWidth, h = defaultHeight, state = []) => {
for (const item of canvasState) {
if (item.type === "img") {
window.URL.revokeObjectURL(item.img.src);
}
}
setCanvasSize([w, h]);
setScale(100);
setTranslate([w / 2, h / 2]);
setCanvasState([getInitState(w / 2, h / 2), ...state]);
setRecordIndex(state.length + 1);
};
/**
* 选择图片
* @param {*} e
* @returns
*/
const selectFile = (e) => {
const url = window.URL.createObjectURL(e.file);
const img = new Image();
img.src = url;
img.onload = () => {
const n = Math.min(defaultWidth / img.width, defaultHeight / img.height);
const size = [img.width * n, img.height * n];
initCanvas(...size, [
{
type: "img",
img,
size,
},
]);
};
return false;
};
/**
* 下载图片
*/
const downLoadImg = () => {
const aEle = document.createElement("a");
document.body.appendChild(aEle);
aEle.href = canvasEle.current.toDataURL();
aEle.download = `${Date.now()}.jpg`;
aEle.click();
document.body.removeChild(aEle);
};
export default CanvasTool;
PS:页面部分就不放了
六、思考
1.待优化的功能
(1)橡皮擦
橡皮擦是用连续的白色填充矩形覆盖实现的,不过会覆盖任何像素,我想实现的是可以只覆盖此次编辑添加的图形,不覆盖加载的图片,大概的思路是区分图片和此次的绘制,然后用CanvasRenderingContext2D.globalCompositeOperation这个属性来实现。
并且感觉橡皮擦的坐标计算比较奇怪,很容易有不连续的点。这块后续还要再研究一下。
难度:☆☆☆☆☆
(2)渲染过程
因为是根据每一次的操作记录渲染的,如果记录次数过多的话(成千上万次),可能会造成渲染卡顿的问题。
优化思路,感觉可以在操作次数大于一定长度时,合并前面若干次记录并合成图片作为初始的记录,同时限制前进/后退的次数。
或者可以转换成buffer调用webgl去渲染,这样处理速度会快很多。
难度:不好说,看实现方式。感觉☆☆☆☆以上。
2.待添加的功能
(1)伸缩变换
目前变换只做了缩放和平移,考虑后面可以加个旋转和变形。这个要做的话,数据处理就更复杂了,可以考虑专门拆分一个模块作为中间层,在绘制和交互之间,负责处理数据的转换。
难度:☆☆☆☆☆
(2)文字
添加文字的功能,比图形麻烦点,重点是我拿捏不准交互方式。按照一般的,是圈定一个矩形作为Input区域,失焦时渲染文字(也就是添加文字的记录),这个透明的input区域,我老感觉有坑,也可能是我想多了,后面再看吧。
难度:☆☆☆
(3)直线/箭头
这个也挺常见,实现起来也还好,后续会试试。
难度:☆☆
(4)选中某个path并移动/高亮
这个单纯的选中,也还行,CanvasRenderingContext2D.isPointInStroke()可以实现(PS:这个交互区域太小了,如果要实现模糊选中最好处理linestyle,添加渐变,之前做公司的流程图的时候,因为这个模糊选中,就没用这个api,记录了坐标并使用数学计算的方式判断是否选中)。
选中之后的平移或者更改线宽/颜色等样式,平移可能要做一次额外的伸缩变换处理(找到这次path时的伸缩变换属性,再将当前的操作造成的变换叠加,然后添加一个新的记录),这样做也有可能生成更多的记录,后面实现的时候要再衡量一下。
难度:☆☆☆☆