最近,开发了一个用于移动批阅的Web手写板工具。大致业务需求如下:
手写板工具读取指定图片并作为底板图片显示,用户在PC/手机/平板电脑上使用鼠标/手指/触控笔在底板图片上进行批注,最后将笔划和底板图片合成一张图片。
其中还包括一些功能需求,例如:
- 支持笔划的粗细、颜色设定
- 支持橡皮擦功能
- 支持画布的缩放、移动
- 支持撤销上一步操作
- 支持导出合成的图像
- 保存和读取笔划数据
一开始,因为任务时间紧,对开源工具进行了调研,最终选择了相对更接近需求的 react-canvas-draw 这套开源代码。这套代码在2021年11月就停更了,幸运的是,放在React18的项目中可以正常运行,但是它所依赖的其他npm包的版本不能轻易更新。在实际使用中,发现了它的一些不足:
- 没有橡皮擦功能
- 笔划过多(约200+)的时候会卡顿
- 横竖屏切换时,笔划与画面错位,这是因为笔划的粗细和坐标都是固定值,没有参考系进行适配调整
- 无法区分手指和触控笔,导致使用触控笔的时候,手掌等部位会蹭出多余笔划
- 在iPad上缩放和移动非常卡顿(Android设备没问题)
其中,问题1、2、3,在源码基础上经过一番折腾基本上解决了,但问题4、5实在是很难解决了。
于是,在空余时间,经过调研,基于目前主流的Konva.js开源框架进行了React19(兼容React18)和Vue3两个版本的重构,每个版本又分别做了Javascript版和Typescript版,合计4个版本。
这4个版本代码,一方面可以直接二次开发用于其他项目,另一方面也方便不同版本的对照学习(了解Javascript与Typescript的异同、了解React与Vue的异同)。
本文仅分享最为核心canvas前端手写板的开发内容,不涉及整体项目的其余开发部分(业务系统界面、后端开发等)。
注:本文为Vue Typescript版本,React版本请阅读《一个近乎完美的Konva手写板诞生记(React版)》
最终成果
先看下界面。
实现的功能如下:
- 加载远程图片URL或base64编码图片,作为底板图像
- 支持笔划的粗细和颜色设定
- 支持橡皮擦的粗细设定
- 支持对触控方式(鼠标/手指/触控笔)的开启和关闭
- 支持鼠标滚轮缩放及按住空格键的同时(光标变成小手)进行鼠标拖拽移动
- 支持双指缩放和移动(仅可手指,触控笔不能)
- 支持撤销上一步操作,可设置最多可撤销的次数
- 支持保存和读取笔划数据
- 支持导出合成图片或base64编码
- 支持清空所有笔划
- 支持根据底板图片及外层容器实时自动调整合适的画布尺寸
- 支持设置参考系,以参考系为标准,当画布尺寸改变时,能够自动调整笔划的粗细
- 支持实时显示DEBUG信息
在满足业务需求的同时,尽可能把配置项抽离出来,提高兼容性和可扩展性。
章节目录
1 开发及测试环境
2 核心点及解决方案
• 2.1 识别鼠标、手指、触控笔
• 2.2 高强度单点笔划操作时可能误识别为多点触控
• 2.3 双指缩放和移动
• 2.4 自适应外层容器尺寸
• 2.5 双指接触和抬起的瞬间导致误触笔划
• 2.6 笔划过多导致的卡顿
• 2.7 其他性能优化Tips
• 2.8 iPad与HUAWEI MatePad体验差异
• 2.9 Debug信息输出
3 项目源码
推荐阅读
ZIP项目源码解压密码
1 开发及测试环境
本教程的主要依赖包版本:
Node.js 22.14.0
vite 6.1.0
vue 3.5.13
konva 9.3.18
vue-konva 3.2.0
element-plus 2.9.4
测试环境:
iPad Pro(11寸)第3代, iOS 18.3, Safari
HUAWEI MatePad Pro,Harmony OS 4.2.0, 自带浏览器
2 核心点及解决方案
Konva官网提供了一个React版的绘图板的Demo:
https://konvajs.org/docs/react/Free_Drawing.html
当时让我很兴奋,这个Demo才几十行代码,就实现了一个简易的绘图板,有笔和橡皮,能设置笔触粗细和颜色,再加上Konva内置好的缩放、位移导出图像等功能,看上去需求已经基本都满足。
但,实际开发起来发现,需要处理的细节问题非常多,并且有一些挑战性。下面就重点分享一下其中遇到的关键问题及解决方案。
2.1 识别鼠标、手指、触控笔
对于以上三种接触方式,使用了以下事件:
@pointerDown:接触按下
@pointerMove:接触移动
@pointerUp:接触抬起
@pointerCancel:接触取消
@wheel:鼠标滚轮滑动
抛弃了@mouseDown、@touchStart等事件。
Pointer Events API的pointerType中,可以直接识别当前的接触类型:
mouse=鼠标
pen=触控笔
touch=手指
此外,Pointer Events API的pointerId,是给输入设备(鼠标、手指、触控笔)自动分配的唯一标识。具有以下特点:
1. 唯一性
- 在每次交互中,浏览器会为每个活动的指针分配一个唯一的 pointerId。
- 这个 ID 在指针的整个生命周期中保持不变(从按下到释放)。
2. 多点触控支持
- 对于多点触控场景,每个触控点会有一个独立的 pointerId,使开发者能够区分不同的触控点。
3. 设备相关性
- 不同类型的输入设备(鼠标、触控笔、手指等)也会分配不同的 pointerId。
基于pointerId,可以有效解决误触、双指缩放移动等很多细节问题。
例如下面的代码,就是先判断允许的接触类型,再执行后续逻辑:
const {
...(略)
allowedPointerType,
} = defineProps({
...(略)
// 允许的触控方式,mouse=鼠标,touch=手指,pen=触控笔
allowedPointerType: {
type: Object,
default () {
return { mouse: true, touch: true, pen: true }
},
},
})
const handlePointerDown = (e: Konva.KonvaEventObject < PointerEvent > ) => {
...(略)
// 判断指针数量
if (activePointersRef.value.size === 1) {
// --- 单指 => 准备画线 ---
// 若不允许的指针类型就return
if (!allowedPointerType[e.evt.pointerType]) return ...(略)
}
}
2.2 高强度单点笔划操作时可能误识别为多点触控
在进行快速书写时,可能会因为Pointer Events还未处理(或者清理)完上一次的触点,新的触点又加入进来,导致误识别为多点触控。
解决方案就是:将当前触点的pointerId保存起来,当接触设备为触控笔或鼠标时,先清空pointerId,然后再将本次触点保存起来。这是因为,触控笔和鼠标都是单点触控设备,确保当前触点只有一个pointerId,从而避免多点触控误识别。
相关Vue源码如下:
const handlePointerDown = (e: Konva.KonvaEventObject < PointerEvent > ) => {
// 防止触屏页面滑动
e.evt.preventDefault()
...(略)
if (e.evt.pointerType === 'pen' || e.evt.pointerType === 'mouse') {
// 对于 pen 和 mouse,清空其他指针,确保单指操作,解决高强度笔划时可能导致的多指误识别问题
activePointersRef.value.clear()
activePointersRef.value.add(e.evt.pointerId)
} else if (e.evt.pointerType === 'touch') {
// 对于 touch,允许多指操作, 把当前 pointerId 加入集合
activePointersRef.value.add(e.evt.pointerId)
}
...(略)
}
2.3 双指缩放和移动
Konva虽然提供了画布缩放和位移的API,但是手势操作需要自己实现。
幸运的是,Konva官网给出了双指缩放的实现思路和Demo:
《How to enable pan and pinch zoom for canvas stage?》
缩放的大致思路就是记录两指的坐标,并计算出两指坐标连线的中心坐标,以这个中心坐标作为缩放的中心点,然后根据两个手指的初始距离和当前距离计算出缩放比例。
双指移动也是根据双指连线的中心坐标的位置进行画布移动。
当然,只看Konva官网给的思路还不够,还需要考虑移动画布时防止溢出的问题,也就是避免出现留白。
相关Vue源码如下:
...(略)
// 多指缩放相关状态
const layerScale = ref(1)
const layerPos = ref({ x: 0, y: 0 })
// 追踪所有 pointerId => { x, y } 的坐标
const pointerPositionsRef = ref < Record < number,
{ x: number;y: number } >> ({})
// 捏合开始时记录:初始距离、初始中点、初始 scale、初始 pos
const pinchDataRef = ref({
startDist: 0,
startCenter: { x: 0, y: 0 },
startScale: 1,
startPos: { x: 0, y: 0 },
})
// 记录 pointer 坐标
const updatePointerPosition = (pointerId: number, x: number, y: number) => {
pointerPositionsRef.value[pointerId] = { x, y }
}
const removePointerPosition = (pointerId: number) => {
delete pointerPositionsRef.value[pointerId]
}
// 双指缩放及移动功能:计算双指距离
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getDistance = (p1: any, p2: any) => {
const dx = p1.x - p2.x
const dy = p1.y - p2.y
return Math.sqrt(dx * dx + dy * dy)
}
// 双指缩放及移动功能:计算双指中心坐标
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getCenter = (p1: any, p2: any) => {
return { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }
}
// clamp 工具函数
const clamp = (value: number, min: number, max: number) => {
return Math.min(Math.max(value, min), max)
}
const handlePointerMove = (e: Konva.KonvaEventObject < PointerEvent > ) => {
// 防止触屏页面滑动
e.evt.preventDefault()
...(略)
// 按住空格时的鼠标拖拽
if (isDraggingPanRef.value) {
const newPos = stagePos
const lastPos = lastPanPosRef.value
if (newPos && lastPos) {
const dx = newPos.x - lastPos.x
const dy = newPos.y - lastPos.y
// 计算新的位置
const updatedPos = { x: layerPos.value.x + dx, y: layerPos.value.y + dy }
// ------- 防止溢出 Stage 边界 -------
const scaledWidth = parseFloat((stageSize.value.width * layerScale.value).toFixed(2))
const scaledHeight = parseFloat((stageSize.value.height * layerScale.value).toFixed(2))
// 计算可移动范围
const leftLimit = 0
const rightLimit = parseFloat((stageSize.value.width - scaledWidth).toFixed(2))
const topLimit = 0
const bottomLimit = parseFloat((stageSize.value.height - scaledHeight).toFixed(2))
// Clamp the updated position to prevent overflow
updatedPos.x = parseFloat(clamp(updatedPos.x, rightLimit, leftLimit).toFixed(2))
updatedPos.y = parseFloat(clamp(updatedPos.y, bottomLimit, topLimit).toFixed(2))
// Update layer position
layerPos.value = updatedPos
// Update last position
lastPanPosRef.value = { x: newPos.x, y: newPos.y }
}
return
}
// 单指画线
if (activePointersRef.value.size === 1) {
...(略)
}
// 双指缩放移动
else if (activePointersRef.value.size === 2) {
// 取出两根指头的坐标
const ids = [...activePointersRef.value]
const p1 = pointerPositionsRef.value[ids[0] as number]
const p2 = pointerPositionsRef.value[ids[1] as number]
if (!p1 || !p2) return // 容错
// 计算当前两指距离 & 中心
const dist = getDistance(p1, p2)
const newCenter = getCenter(p1, p2)
// 取出开捏时记录的数据
const { startDist, startCenter, startScale, startPos } = pinchDataRef.value
// 计算新的缩放比
const scaleRatio = dist / startDist
let newScale = startScale * scaleRatio
// 最小缩放限制
const MIN_SCALE = 1
newScale = Math.max(newScale, MIN_SCALE)
// 最大缩放限制
newScale = Math.min(newScale, maxScale)
newScale = parseFloat(newScale.toFixed(3))
// === 关键:让 Layer 围绕“捏合中心”缩放 ===
// 先把 startCenter (舞台坐标) 转到 Layer 本地坐标
const oldScale = startScale
const centerInLayer = {
x: (startCenter.x - startPos.x) / oldScale,
y: (startCenter.y - startPos.y) / oldScale,
}
// 让这个本地坐标点在 newScale 下映射到 newCenter
const newPos = {
x: newCenter.x - centerInLayer.x * newScale,
y: newCenter.y - centerInLayer.y * newScale,
}
// ------- 防止溢出 Stage 边界 -------
// 1) 假设 ImageLayer = (stageSize.width, stageSize.height)
// 缩放后实际大小:
const scaledWidth = parseFloat((stageSize.value.width * newScale).toFixed(2))
const scaledHeight = parseFloat((stageSize.value.height * newScale).toFixed(2))
// 2) 计算可移动范围
// 如果 scaledWidth > stageWidth,就可在 [rightLimit, leftLimit] 间移动
// leftLimit = 0 (不可拖出左侧)
// rightLimit = stageSize.width - scaledWidth (不可拖出右侧)
const leftLimit = 0
const rightLimit = parseFloat((stageSize.value.width - scaledWidth).toFixed(2))
const topLimit = 0
const bottomLimit = parseFloat((stageSize.value.height - scaledHeight).toFixed(2))
// 3) 根据实际需求 clamp
newPos.x = parseFloat(clamp(newPos.x, rightLimit, leftLimit).toFixed(2))
newPos.y = parseFloat(clamp(newPos.y, bottomLimit, topLimit).toFixed(2))
// 最终更新 Layer
layerScale.value = newScale
layerPos.value = newPos
return
}
}
2.4 自适应外层容器尺寸
一般来讲,目前手机、Pad上的绘图板软件,默认都是全屏运行,即使不是全屏,也会保持绘图板的比例或者大小不变(使用滚动条)。
但是在浏览器上,浏览器窗口或者绘图板组件的外层容器尺寸可能都是变化的,为了做到自适应,需要监听浏览器窗口发生变化的事件,对绘图板的尺寸进行重新计算。
这里涉及到三个方面的适配:
- Stage(画布)的尺寸适配。根据外层容器及底板图片尺寸进行适配,类似实现CSS的background-size: contain的填充效果。
- 底板图片的尺寸适配。图片尺寸与Stage尺寸是一样的。
- 笔划粗细和路径的适配。当画布尺寸发生变化时,笔划粗细和路径也要等比缩放,否则就会出现笔划与底板图片的错位。
相关Vue源码如下:
const handleResize = () => {
if (stageNodeRef.value) {
// 获取stage外层父容器的尺寸
...(略)
// 3. 计算 Stage 的大小
caleStageSize()
}
}
// 计算 Stage 的大小
const caleStageSize = () => {
...(略)
// 如果还没测量到容器大小 或 图片大小,暂不计算
if (!containerW || !containerH || !imageW || !imageH) {
return
}
...(略)
// 等比缩放至容器大小
...(略)
// 自动设定参考系
...(略)
// 计算缩放比例,并同步调整笔划坐标和strokeWidth
if (prevStageSize.width && prevStageSize.height) {
...(略)
// 调整所有笔划的坐标
lines.value = ...(略)
staticLines.value = ...(略)
// 调整 layerPos,当Stage尺寸变化时,图层的位移比例也要随之改变
...(略)
layerPos.value = ...(略)
// 更新前一个 Stage 尺寸
prevStageSizeRef.value = newStageSize
}
else {
// 初次设置前一个 Stage 尺寸
prevStageSizeRef.value = newStageSize
}
stageSize.value = { width: finalStageWidth, height: finalStageHeight }
}
onMounted(() => {
// 1. 动态测量 M-drawCanvas 容器大小
// 组件挂载后先进行一次测量
handleResize()
// 监听浏览器窗口 resize
window.addEventListener('resize', handleResize)
// 2. 加载图片,获取图片的实际宽高
const img = new window\.Image()
// 你自己的图片地址,可以动态传入
img.src = imageSrc
img.onload = () => {
...(略)
img.onload = null
// 3. 计算 Stage 的大小
caleStageSize()
...(略)
}
...(略)
})
onUnmounted(() => {
// 清理函数:组件卸载时,移除事件监听
window.removeEventListener('resize', handleResize)
})
2.5 双指接触和抬起的瞬间导致误触笔划
在两个手指接触屏幕进行缩放动作的瞬间,很难保证每次都是两个手指同时接触屏幕。可能自我感觉是同时接触了,但对于设备来说,哪怕某一个手指抢先接触0.1秒,也会导致先接触屏幕的手指作为单指操作留下笔划。
同理,当双指抬起的瞬间,也很有可能有一个手指抢先抬起0.1秒,导致最后一个抬起的手指留下笔划。
要解决这个问题,代码实现不难,主要是把思路想清楚。
设置一个isDrawing用来表示当前是否正在书写。
当单点接触时,将isDrawing设置为true,
如果有多点接触时,isDrawing为true,说明发生了某个手指抢先接触屏幕了,这时只需要将上一个笔划删除,然后将isDrawing设置成false即可。
相关Vue源码如下:
const handlePointerDown = (e: Konva.KonvaEventObject < PointerEvent > ) => {
// 防止触屏页面滑动
e.evt.preventDefault()
...(略)
// 判断指针数量
if (activePointersRef.value.size === 1) {
...(略)
isDrawing.value = true
...(略)
}
else if (activePointersRef.value.size === 2) {
// --- 双指 => 准备缩放/移动 ---
// 如果本来 isDrawing = true,说明第一指已开始画线,立即删除
if (isDrawing.value) {
// 删除最后那条正在绘制的笔划
if (lines.value.length > 0) {
lines.value = lines.value.slice(0, -1)
}
isDrawing.value = false
currentLineIndexRef.value = null
}
...(略)
}
}
2.6 笔划过多导致的卡顿
在华为Pad和苹果iPad上测试时发现,当笔划达到300划以上时,可以感觉到明显的卡顿。
Konva.js是一款主流的优秀Canvas框架,很多非常高端复杂的Canvas项目都是用它来开发的,不可能区区300多个笔划就会卡顿。
问题很快就锁定在了Vue方面。
存放笔划层代码如下:
<script setup lang="ts">
import type { LineType } from './types'
const { lines, tension, lineCap, lineJoin } = defineProps({
...(略)
})
</script>
<template>
<v-line
v-for="(line, i) in lines"
:key="'penLine' + i"
:config="{
points: line.points,
stroke: line.strokeColor,
strokeWidth: line.realStrokeWidth,
tension,
lineCap,
lineJoin,
globalCompositeOperation: line.tool === 'eraser' ? 'destination-out' : 'source-over',
perfectDrawEnabled: false,
shadowForStrokeEnabled: false,
}"
/>
</template>
按照Vue组件的方式,将笔划<v-line>循环输出。每次有新的笔划时,lines会发生改变,从而再次从第1个笔划开始渲染。也就是说,当写第301笔时,实际上是从头渲染了301个笔划,而不是只新增渲染第301笔。
解决方法就创建一个Layer专门存放旧笔划,Stage的分层如下:
<v-stage ...(略)>
<!-- 底板图片层 -->
<v-layer ...(略)>
<ImageLayer ...(略) />
</v-layer>
<!-- 静态笔划层 -->
<v-layer ...(略)>
<PenLines v-memo="[staticLines.length, stageSize.width]" ...(略) />
</v-layer>
<!-- 动态笔划层 -->
<v-layer ...(略)>
<PenLines :key="lines.length" ...(略) />
</v-layer>
<!-- 擦除提示笔划层 -->
<v-layer ...(略)>
<EraserLines :key="eraserHintLines.length" ...(略) />
</v-layer>
<!-- DEBUG信息层 -->
<DebugInfo ...(略) />
</v-stage>
以上,就是把旧笔划放到静态笔划层,把新笔划放到动态笔划层。请注意静态笔划层与动态笔划层的区别。静态笔划层在<PenLines>组件上使用了v-memo属性,当staticLines的长度发生变化或者画布尺寸发生改变时,才会重新渲染该组件。而动态笔划层在<PenLines>组件上使用的是key属性,也就是每次lines的长度发生变化都会重新渲染。
那么,如何定义什么是旧笔划,什么是新笔划呢?
这里用到了两个关键的变量:
// 最大实时渲染的笔划数量
maxRealRenderLines = 20,
// 最大可撤销的笔划数量
maxUndoLines = 10,
当动态层笔划达到 maxRealRenderLines 的个数(例如:20个)时,就把动态笔划层的前 maxRealRenderLines - maxUndoLines 个笔划(例如:10个)搬迁至静态笔划层。这样的话,即使笔划数量过多,也只有在每次迁移笔划时才会略感到一丝卡顿,并不是每次笔划都卡顿,在很大程度上缓解了压力。
但是,这样又带来了新的问题:橡皮擦笔划只能擦除动态笔划层的笔划。
解决办法就是:
- 首先,给橡皮擦笔划添加一个唯一的自增eraserID,在添加至动态笔划层的同时,也同步添加至静态笔划层。
- 然后,当静态笔划层的笔划搬迁至动态笔划层后,根据eraserID,将前一个重复eraserID的橡皮擦笔划删除。
- 最后,删除静态笔划层的所有eraserID属性,保持数据的精简。
细心的你可能会问,当静态笔划层已经累计了很多笔划时(例如:1000个),同步向静态笔划层添加橡皮擦笔划的时候,会导致静态笔划层重新渲染,这样不会卡顿吗?
答案是:几乎察觉不到。
原因是,橡皮擦笔划的添加时机与普通笔划的时机不一样。
正在书写的普通笔划是实时更新路径并渲染在动态笔划层的;
而橡皮擦笔划的实时渲染是放在了橡皮擦笔划层,这个层是个临时层,最多只能保留一个橡皮擦笔划。当触点离开时,就会把这个橡皮擦笔划同时搬迁到静态笔划层和动态笔划层。因此,只有橡皮擦搬迁时才会重新渲染静态笔划层,这个重渲的过程可能在下一个笔划开始时就完成了,因此几乎感觉不到卡顿。
2.7 其他性能优化Tips
Konva官网的教程专门有一章内容是性能优化的:
《HTML5 Canvas All Konva performance tips list》
在解决笔划卡顿过多导致卡顿的问题时,借鉴过这里的优化Tips,虽然最终发现是React循环输出大量笔划导致的。但这里的优化Tips,还是能提供一丢丢的性能提升的。
- 优化点1:给静态笔划层Layer设置listening=false
本次需求只是渲染笔划,并不需要对笔划进行点击选中、拖动等操作,因此可以取消其事件监听。 在旧版本的Konva中,这个没有事件监听的Layer使用的是<FastLayer>,在目前的Konva版本中已经废弃了<FastLayer>,改为了Layer的listening属性(默认是true),可以将其改为false来提高渲染性能。Konva官网的说法是:取消了事件监听,Layer性能可提高一倍。
设置了listening: false后,该Layer不再监听本身的事件,因此会响应页面本身的事件(例如:页面滚动、下拉刷新等)。在本Demo中,如果对底板图片层Layer设置了listening=false,将导致在画布上的任何操作都被页面本身事件所取代,无法正常使用。
《Disable Listening Shapes Tip》
- 优化点2:关闭Line的perfectDraw和shadowForStroke
直接看代码就明白了:
<v-line
...(略)
:config="{
...(略)
perfectDrawEnabled: false
shadowForStrokeEnabled: false
}
/>
详细解释请看官网说明:
《Disable Perfect Draw》
《Disable shadow for stroke》
- 优化点3:减少小数点后的位数
本项目涉及大量坐标、长度等数据计算问题,由于浮点数精度问题,JavaScript 使用 IEEE 754 双精度浮点数(64 位)表示 小数,某些小数无法用二进制精确表示,导致计算误差。
例如:计算 0.1 + 0.2 实际上得到的结果是0.30000000000000004。
虽然计算结果的细微误差不会对实际效果产生影响,但保留太多位数会增加不必要的计算量。
通过类似parseFloat(xxx.toFixed(2))的方法来修正精度问题。
2.8 iPad与HUAWEI MatePad体验差异
总体来说,差异不是非常明显。
当笔划数量过多时(600+),iPad依然非常丝滑,但HUAWEI MatePad在进行画布缩放时,丝滑程度略逊于iPad(但整体感觉还算丝滑)。
此外,在HUAWEI MatePad上,dev环境下,Vue自带的vueDevTools会影响到其附近区域的笔划事件判断。
当在vueDevTools悬浮图标附近的画布区域进行笔划,可能画出很短的线就断了,还可能造成单点触控和多点触控的误判。
关闭Vue的DevTools即可消除影响。
关闭方式就是在vue.vonfig.ts文件中修改以下部分:
- plugins: [vue(), vueDevTools()],
+ plugins: [vue()],
在iPad上不存在此问题,具体原因不详。
2.9 Debug信息输出
为了方便调试程序,增加了专用于Debug的Layer,用于实时显示当前的事件、触点数量、动态笔划数、静态笔划数、待删除的橡皮笔划数(在向静态层迁移前,动态笔划层的橡皮笔划也同时向静态笔划层复制一份,迁移后将删除重复的橡皮擦笔划)、可撤销次数,便于开发调试。
3 项目Git源码
本项目已上传至Gitee和GitHub,方便各位下载。
Gitee: gitee.com/betaq/draw-…
GitHub:
项目源码解压密码
🔑🔑💖💖 解压密码,见我的公众号【卧梅又闻花】原文。 💖💖🔑🔑