【Excalidraw揭秘】canvas无限画布及矩形绘制

前言

本节我们通过简单的矩形绘制学习如何实现无限画布

准备工作

在绘制前,我们需要矫正 canvas 的分辨率,使用 appState 保存 canvas 相关的信息。新建一个 index.jsx 文件,初始化代码如下:

const appState = {offsetLeft: 0,offsetTop: 0,
};
const Canvas = memo(() => {const canvasRef = useRef(null);const canvasContainer = useRef(null);useEffect(() => {const canvas = canvasRef.current;const context = canvas.getContext("2d");const { offsetWidth, offsetHeight, offsetLeft, offsetTop } = canvas;canvas.width = offsetWidth * window.devicePixelRatio;canvas.height = offsetHeight * window.devicePixelRatio;context.scale(window.devicePixelRatio, window.devicePixelRatio);appState.offsetLeft = offsetLeft;appState.offsetTop = offsetTop;}, []);return (<div ref={canvasContainer}><canvas ref={canvasRef} className="canvas">绘制canvas</canvas></div>);
}); 

绘制坐标轴

为方便观察,首先在 canvas 上绘制一个坐标轴。新建一个 renderScene.js 文件,实现 drawAxis 方法:

const drawAxis = (ctx) => {ctx.save();const rectH = 100; // 纵轴刻度间距const rectW = 100; // 横轴刻度间距const tickLength = 8; // 刻度线长度const canvas = ctx.canvas;ctx.translate(0, 0);ctx.strokeStyle = "red";ctx.fillStyle = "red";// 绘制横轴和纵轴ctx.save();ctx.beginPath();ctx.setLineDash([10, 10]);ctx.moveTo(0, 0);ctx.lineTo(0, canvas.height);ctx.moveTo(0, 0);ctx.lineTo(canvas.width, 0);ctx.stroke();ctx.restore();// 绘制横轴和纵轴刻度ctx.beginPath();ctx.lineWidth = 2;ctx.textBaseline = "middle";for (let i = 0; i < canvas.height / rectH; i++) {// 绘制纵轴刻度ctx.moveTo(0, i * rectH);ctx.lineTo(tickLength, i * rectH);ctx.font = "20px Arial";ctx.fillText(i, -25, i * rectH);}for (let i = 1; i < canvas.width / rectW; i++) {// 绘制横轴刻度ctx.moveTo(i * rectW, 0);ctx.lineTo(i * rectW, tickLength);ctx.font = "20px Arial";ctx.fillText(i, i * rectW - 5, -15);}ctx.stroke();ctx.restore();
};

const renderScene = (canvas) => {const context = canvas.getContext("2d");drawAxis(context);
};

export default renderScene; 

然后在 index.jsx 中引入 renderScene

useEffect(() => {//...renderScene(canvas);
}, []); 

效果如下:

infinite-01.jpg

绘制矩形

屏幕坐标系转 canvas 坐标系

在开始绘制矩形之前,我们先来看下屏幕坐标系如何转换成 canvas 坐标系。如下图所示,对于 canvas 上的任意一点,比如下面的 A 点。当我们点击事件位于 A 点时,我们可以获取到 A 点的屏幕坐标 (event.clientX, event.clientY)。那么 A 点的 canvas 坐标计算方式就是

x = event.clientX - canvas.offsetLeft;
y = event.clientY - canvas.offsetTop; 

infinite-02.png

因此我们可以封装一个坐标系转换的工具方法viewportCoordsToSceneCoords

const viewportCoordsToSceneCoords = ( { clientX, clientY },{ offsetLeft, offsetTop } ) => {const x = clientX - offsetLeft;const y = clientY - offsetTop;return { x, y };
}; 

绘制矩形

  • 声明一个 elements 数组存放我们绘制的图形
  • 为 canvas 绑定一个onPointerDown={handleCanvasPointerDown}事件
const handleCanvasPointerDown = (event) => {const origin = viewportCoordsToSceneCoords(event, appState);const pointerDownState = {origin,lastCoords: { ...origin },eventListeners: {onMove: null,onUp: null,},};const element = {x: pointerDownState.origin.x,y: pointerDownState.origin.y,width: 0,height: 0,strokeColor: "#000000",backgroundColor: "transparent",fillStyle: "hachure",strokeWidth: 1,strokeStyle: "solid",};appState.draggingElement = element;elements.push(element);const onPointerMove =onPointerMoveFromCanvasPointerDownHandler(pointerDownState);const onPointerUp = onPointerUpFromCanvasPointerDownHandler(pointerDownState);window.addEventListener("pointermove", onPointerMove);window.addEventListener("pointerup", onPointerUp);pointerDownState.eventListeners.onMove = onPointerMove;pointerDownState.eventListeners.onUp = onPointerUp;
}; 

handleCanvasPointerDown事件主要做了以下几件事:

  • 调用viewportCoordsToSceneCoords方法将点击事件的屏幕坐标转换成 canvas 左标,并保存在 origin 中,这个也是我们绘制矩形的起点(即矩形的左上角的点)
  • 初始化一个 element 对象,这个 element 对象保存绘制矩形所需要的坐标信息以及颜色信息等
  • 将 element 对象添加到 elements 数组中,并保存在 appState.draggingElement 中,方便后续使用
  • 在 window 上注册pointermovepointerup事件,其中pointermove事件用于计算鼠标移动的距离,计算矩形的宽度和高度。pointerup用于注销这两个事件,因为一旦鼠标离开,就说明绘制过程结束。

onPointerUpFromCanvasPointerDownHandler实现如下:

const onPointerUpFromCanvasPointerDownHandler =(pointerDownState) => (event) => {window.removeEventListener("pointermove",pointerDownState.eventListeners.onMove);window.removeEventListener("pointerup",pointerDownState.eventListeners.onUp);}; 

onPointerMoveFromCanvasPointerDownHandler事件逻辑如下:

  • 根据鼠标移动事件,计算当前点的 canvas 坐标
  • 计算矩形的宽高
  • 调用 renderScene 开始绘制
const onPointerMoveFromCanvasPointerDownHandler =(pointerDownState) => (event) => {const pointerCoords = viewportCoordsToSceneCoords(event, appState);pointerDownState.lastCoords.x = pointerCoords.x;pointerDownState.lastCoords.y = pointerCoords.y;appState.draggingElement.width =pointerCoords.x - pointerDownState.origin.x;appState.draggingElement.height =pointerCoords.y - pointerDownState.origin.y;renderScene(canvasRef.current);}; 

renderScene新增 renderElements 方法

const renderElements = (ctx) => {elements.forEach((ele) => {ctx.save();ctx.translate(ele.x, ele.y);ctx.strokeStyle = ele.strokeStyle;ctx.strokeColor = ele.strokeColor;ctx.strokeRect(0, 0, ele.width, ele.height);ctx.restore();});
};
const renderScene = (canvas) => {const context = canvas.getContext("2d");context.clearRect(0, 0, canvas.width, canvas.height);drawAxis(context);renderElements(context);
}; 

最终效果如下:

infinite-03.jpg

现在,我们已经可以在画布上随意绘制矩形了。

无限画布

所谓无限画布,就是我们可以水平或者竖直方向滚动画布,并可以实现绘制。如下图,假设我们在 canvas 水平方向滚动了 scrollX,在竖直方向滚动了 scrollY 距离,那么我们原先的坐标系原点就从(0,0)的位置移动到了下图中的B点。对于滚动后的画布上面的任意一点,比如 A 点,A 点的坐标就变成了

x = event.clientX - canvas.offsetLeft - scrollX;
y = event.clientY - canvas.offsetTop - scrollY; 

infinite-04.png

我们需要给 canvas 添加滚动事件onWheel={handleCanvasWheel},同时记录滚动距离。并重新绘制

const handleCanvasWheel = (event) => {const { deltaX, deltaY } = event;appState.scrollX = appState.scrollX - deltaX;appState.scrollY = appState.scrollY - deltaY;renderScene(canvasRef.current, appState);
}; 

我们将滚动距离保存在appState中,并传入renderScene方法:

const renderScene = (canvas, appState) => {const context = canvas.getContext("2d");context.clearRect(0, 0, canvas.width, canvas.height);drawAxis(context, appState);renderElements(context, appState);
}; 

由于坐标发生了改变,因此我们需要调整下 drawAxis 的逻辑。这里我绘制出了横轴和纵轴的正负刻度。

const drawAxis = (ctx, { scrollX, scrollY }) => {ctx.save();const rectH = 100; // 纵轴刻度间距const rectW = 100; // 横轴刻度间距const tickLength = 8; // 刻度线长度const canvas = ctx.canvas;ctx.translate(scrollX, scrollY);ctx.strokeStyle = "red";ctx.fillStyle = "red";// 绘制横轴和纵轴ctx.save();ctx.beginPath();ctx.setLineDash([10, 10]);ctx.moveTo(0, -scrollY);ctx.lineTo(0, canvas.height - scrollY);ctx.moveTo(-scrollX, 0);ctx.lineTo(canvas.width - scrollX, 0);ctx.stroke();ctx.restore();// 绘制横轴和纵轴刻度ctx.beginPath();ctx.lineWidth = 2;ctx.textBaseline = "middle";for (let i = 0; i < scrollY / rectH; i++) {// 绘制纵轴负数刻度ctx.moveTo(0, -i * rectH);ctx.lineTo(tickLength, -i * rectH);ctx.font = "20px Arial";ctx.fillText(-i, -25, -i * rectH);}for (let i = 0; i < (canvas.height - scrollY) / rectH; i++) {// 绘制纵轴正数刻度ctx.moveTo(0, i * rectH);ctx.lineTo(tickLength, i * rectH);ctx.font = "20px Arial";ctx.fillText(i, -25, i * rectH);}for (let i = 1; i < scrollX / rectW; i++) {// 绘制横轴负数刻度ctx.moveTo(-i * rectW, 0);ctx.lineTo(-i * rectW, tickLength);ctx.font = "20px Arial";ctx.fillText(-i, -i * rectW - 10, -15);}for (let i = 1; i < (canvas.width - scrollX) / rectW; i++) {// 绘制横轴正数刻度ctx.moveTo(i * rectW, 0);ctx.lineTo(i * rectW, tickLength);ctx.font = "20px Arial";ctx.fillText(i, i * rectW - 5, -15);}ctx.stroke();ctx.restore();
}; 

坐标轴效果如下:

infinite-05.jpg

可以看出坐标轴的绘制和滚动距离完全对应的上。我们已经能够实现一个无限画布并且正确绘制坐标轴,但此时如果我们在上面绘制一个矩形就会发现,矩形的宽度和高度是正确的,同时矩形的原点,即 x,y 也是正确的,但是矩形绘制的位置并不对。这是因为我们这里矩形的位置是相对于移动后的坐标系。

infinite-06.jpg

因此我们需要修改我们的 renderElement 方法

const renderElements = (ctx, appState) => {elements.forEach((ele) => {ctx.save();ctx.translate(ele.x + appState.scrollX, ele.y + appState.scrollY);ctx.strokeStyle = ele.strokeStyle;ctx.strokeColor = ele.strokeColor;ctx.strokeRect(0, 0, ele.width, ele.height);ctx.restore();});
}; 

修改后,我们就可以正常绘制矩形了

infinite-07.jpg

至此,我们就可以实现 canvas 无限画布,并能够在上面绘制矩形。

导出

现在,我们希望能够将我们的画布导出成 png 图片。很快,我们就可以实现下面的代码:

<buttononClick={() => {const canvas = canvasRef.current;var a = document.createElement("a");a.href = canvas.toDataURL();a.download = "canvas.png";a.click();}}
>导出PNG
</button> 

但是我们导出的时候会发现只能导出视图内的图形,视图以外的图形(即画面中看不到的图形)无法导出,这显然不符合我们的需求

infinite-08.png

我们需要计算elements中最小的 minX 和 minY,以及最大的 maxX 和 maxY,并重新创建一个画布绘制,然后在这个新的画布上绘制我们的图形,修改导出代码

<buttononClick={() => {let minX = Infinity;let maxX = -Infinity;let minY = Infinity;let maxY = -Infinity;elements.forEach((element) => {const [x1, y1, x2, y2] = [element.x,element.y,element.x + element.width,element.y + element.height,];minX = Math.min(minX, x1);minY = Math.min(minY, y1);maxX = Math.max(maxX, x2);maxY = Math.max(maxY, y2);});const canvas = document.createElement("canvas");canvas.width = (maxX - minX + 20) * window.devicePixelRatio;canvas.height = (maxY - minY + 20) * window.devicePixelRatio;const context = canvas.getContext("2d");context.scale(window.devicePixelRatio, window.devicePixelRatio);renderScene(canvas, {...appState,scrollX: -minX + 10,scrollY: -minY + 10,});console.log("导出", elements);var a = document.createElement("a");a.href = canvas.toDataURL();a.download = "canvas.png";a.click();}}
>导出PNG
</button> 

 -minX + 10,scrollY: -minY + 10,});console.log("导出", elements);var a = document.createElement("a");a.href = canvas.toDataURL();a.download = "canvas.png";a.click();}}
>导出PNG
</button> 

可以看到,现在一切正常

最后

最近找到一个VUE的文档,它将VUE的各个知识点进行了总结,整理成了《Vue 开发必须知道的36个技巧》。内容比较详实,对各个知识点的讲解也十分到位。



有需要的小伙伴,可以点击下方卡片领取,无偿分享

猜你喜欢

转载自blog.csdn.net/web2022050903/article/details/129364448