再整一个简单的canvas画板

lxf2023-03-21 07:34:01

写在前面

刚开始学习前端那段时间吧,在第一次接触canvas的时候做过一个demo,内容就是一张画布,很简陋,只能画线,也没有其他的功能。

想着马上到年底了也许应该例行更新一下简历,偶然看到了这个画布的demo,点开乱涂一通之后,感觉是时候把简历上的“canvas画板”变成真正的画板了(不然到时候人家看预览不得觉得我捞的淌口水)。

使用vue3 + ts

功能确认

首先来列一下所需要的功能吧,一个看起来能正常使用的画板应该有什么样的功能呢

  1. 能画(废话)。
  2. 能擦。
  3. 能撤销。
  4. 都能撤销了那肯定也得能前进。
  5. 笔刷可以设置,需要一个配置区域。
  6. 画是铅笔擦是橡皮,那就需要一个工具箱,可以进行工具的切换。
  7. 能清空画布。
  8. 可以保存为图片。

有这些就差不多了,至于其他工具(比如矩形工具),目前顾不上搞,就先弄这些。

关于布局

开始的想法是左右布局,左边为选择工具和配置工具的区域,右侧为画布。

再整一个简单的canvas画板

但考虑到这样的布局压缩了画板的空间,于是改成了弹出式,并且将工具栏和配置区域分为两部分。

再整一个简单的canvas画板

需要的差不多都有了,那么我直接进行一个工的开。

过程

前期准备

创建画布和布局比较简单,能说的不多,在这里就不详细展开了。

跳过前期工作,得到了这样一个效果: 再整一个简单的canvas画板 收起时: 再整一个简单的canvas画板 再整一个简单的canvas画板

悬浮弹出

工具栏和配置区域的显示状态通过v-model绑定状态变量,在子组件中外层容器中使用鼠标移入移出事件,触发update:modelValue来进行实时更新。

再整一个简单的canvas画板

为了优化操作体验,弹出的组件不会在鼠标移出的同时收起,当鼠标移出时,会添加一个定时器,当定时器结束时再去更新显示状态。

// 鼠标移出500ms后收起组件
setTimeout(() => {
    showState.value = state;
    emit("update:modelValue", showState.value);
}, 500);

这里出现了一个问题,如果鼠标在移出组件到组件收回的期间重新把鼠标移回去,因为设置的计时器没有被清除,导致即便鼠标在组件中,组件还是会在计时器结束后收回,所以还需要一个记录计时器id的变量,如果在计时器结束之前又将鼠标移入,则停止计时,再移出则重新计时。

let timer = 0;
const changeShowState = (state: boolean) => {
  // 鼠标移出时开始计时,500ms后改变显示状态。如果期间鼠标再次移入,则清空计时器,再次离开时重新计时
  if (!state) {
    timer = setTimeout(() => {
      showState.value = state;
      emit("update:modelValue", showState.value);
    }, 500);
  } else {
    clearTimeout(timer);
    showState.value = state;
    emit("update:modelValue", showState.value);
  }
};

看看效果:

再整一个简单的canvas画板

工具切换

因为不同工具需要支持不同的配置方式,通过emitprops,将工具栏选中工具信息传给画板,画板再传给配置组件,就可以实现动态的配置项.

绘制

首先需要获取到canvas的context:

context = canvas.value.getContext("2d");

要实现鼠标绘画,需要给canvas添加鼠标事件,鼠标按下时切换绘制状态并记录点击位置,鼠标移动时执行绘制方法,松开按键时再次切换绘制状态。

铅笔

首先需要一个画线方法:

// 绘制线条
const drawLine = (
  context: CanvasRenderingContext2D,
  x1: number,
  y1: number,
  x2: number,
  y2: number
) => {
  context.beginPath();
  context.moveTo(x1, y1);
  context.lineTo(x2, y2);
  context.stroke();
};

在鼠标移动时执行画线方法,每次绘制时,因为画线实际展示的效果是绘制了一个矩形,所以会发现在拐弯处存在缺口:

再整一个简单的canvas画板

我的解决方法是先绘制一个圆点,再去画线,这样就可以补上拐点处的缺失。

// 绘制圆点
const drawCircle = (
  context: CanvasRenderingContext2D,
  x: number,
  y: number,
  radius: number
) => {
  context.beginPath();
  context.arc(x, y, radius, 0, Math.PI * 2);
  context.fill();
  context.closePath();
};

鼠标移动时先画点再画线,就完成了画笔的基本需求,最起码不会中断了。

再整一个简单的canvas画板

橡皮

再整一个简单的canvas画板

橡皮的做法,首先可以借鉴大佬们提到的使用clip()方法,步骤大致为

  1. 保存当前画布:canvas.save()
  2. 绘制圆形裁切区
  3. 清空绘制的裁切区中的内容
  4. 创建两圆形裁切区之间的矩形裁切区
  5. 再次清空裁切区内容
  6. 取出画布:canvas.restore()

但是也提到了一些问题,清空区域时连带画布的背景色也会被清空(变回透明)。

考虑到这点,我琢磨了一个丐版橡皮的实现方式:

  1. 画板生成后,保存背景颜色
  2. 切换橡皮时保存笔刷颜色
  3. 将fillStyle和strokeStyle切换为保存的背景色,鼠标移动时使用和画笔相同的绘制逻辑
  4. 切换回画笔时再改回为笔刷颜色

对于简单画板来说,实际需要执行的方法更少,而且还不用担心橡皮会用力过猛擦掉背景(- =)。

撤销、前进

撤销的核心逻辑是每次绘制结束时(即松开按键时)保存canvas内容作为历史,并记录步骤。

// 保存历史记录方法
const saveHistory = () => {
  step.value++;
  if (step.value < canvasHistory.length) {
    canvasHistory.length = step.value; 
  }
  // 保存为base64
  canvasHistory.push(canvas.value.toDataURL());
};

撤销时清空画板,后退一步并取出对应步骤的历史记录重新绘制到画布上,前进时同理,改为前进一步即可。

// canvas历史记录
let canvasHistory: string[] = [];
// 当前步骤数
const step = ref(0);
const undo = () => {
  if (step.value > 0) {
    step.value--;
    let canvasPic = new Image();
    canvasPic.src = canvasHistory[step.value];
    canvasPic.onload = () => {
      // 清理画布
      context.value?.clearRect(0, 0, canvas.value.width, canvas.value.height);
      // 将缓存复制到画布上,通过这种方式可以解决画面闪动的问题
      context.value?.drawImage(canvasPic, 0, 0);
    };
  }
};
const redo = () => {
  if (step.value < canvasHistory.length - 1) {
    step.value++;
    let canvasPic = new Image();
    canvasPic.src = canvasHistory[step.value];
    canvasPic.onload = () => {
      context.value?.clearRect(0, 0, canvas.value.width, canvas.value.height);
      context.value?.drawImage(canvasPic, 0, 0);
    };
  }
};

这里又出现了一个问题,撤销时,在清空画布和重绘内容期间有一小段空白,导致撤销时会出现画布闪烁。 我们可以创建另一个canvas作为缓存,在清空画布后,直接将cache的内容搬到画布上来解决闪烁的问题。

再整一个简单的canvas画板

const undo = () => {
  if (step.value > 0) {
    step.value--;
    let canvasPic = new Image();
    canvasPic.src = canvasHistory[step.value];
    canvasPic.onload = () => {
      // 设置缓存
      setCache(canvasPic);
      // 清理画布
      context.value?.clearRect(0, 0, canvas.value.width, canvas.value.height);
      // 将缓存复制到画布上,通过这种方式可以解决画面闪动的问题
      context.value?.drawImage(cache.value, 0, 0);
    };
  }
};
const setCache = (imgSrc: HTMLImageElement) => {
  cache.value.width = canvas.value.width;
  cache.value.height = canvas.value.height;
  cacheContext.value = cache.value.getContext("2d");
  cacheContext.value?.drawImage(imgSrc, 0, 0);
};

清空画布

没啥好说的,就是跑一下清空= =,需要注意的是清空之后还要保存一下历史记录,不然撤销的时候无法返回清空之前的那一步。

保存

这个也没啥好说的,直接上码

const savePicture = () => {
  // 创建一个链接,提供href和下载时的文件名
  let a: HTMLAnchorElement = document.createElement("a");
  a.href = canvas.value.toDataURL("image/png");
  a.download = "picture" + new Date().getTime();
  // 模拟点击链接
  a.click();
};

写在最后

通篇码下来,突出一个表达和总结能力的欠缺,文化水平有限,语言不通顺之处还望多多包含 附上源码链接(使用vite重新创建了一次项目,后续更新都会在该项目中):画板一张