一个近乎完美的Konva手写板诞生记(Vue版)

1,135 阅读18分钟

封面_掘金_vue.png

原创声明-掘金.png

最近,开发了一个用于移动批阅的Web手写板工具。大致业务需求如下:

手写板工具读取指定图片并作为底板图片显示,用户在PC/手机/平板电脑上使用鼠标/手指/触控笔在底板图片上进行批注,最后将笔划和底板图片合成一张图片。

其中还包括一些功能需求,例如:

  1. 支持笔划的粗细、颜色设定
  2. 支持橡皮擦功能
  3. 支持画布的缩放、移动
  4. 支持撤销上一步操作
  5. 支持导出合成的图像
  6. 保存和读取笔划数据

一开始,因为任务时间紧,对开源工具进行了调研,最终选择了相对更接近需求的 react-canvas-draw 这套开源代码。这套代码在2021年11月就停更了,幸运的是,放在React18的项目中可以正常运行,但是它所依赖的其他npm包的版本不能轻易更新。在实际使用中,发现了它的一些不足:

  1. 没有橡皮擦功能
  2. 笔划过多(约200+)的时候会卡顿
  3. 横竖屏切换时,笔划与画面错位,这是因为笔划的粗细和坐标都是固定值,没有参考系进行适配调整
  4. 无法区分手指和触控笔,导致使用触控笔的时候,手掌等部位会蹭出多余笔划
  5. 在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版)》

最终成果

先看下界面。

界面_vue.png

实现的功能如下:

  1. 加载远程图片URL或base64编码图片,作为底板图像
  2. 支持笔划的粗细和颜色设定
  3. 支持橡皮擦的粗细设定
  4. 支持对触控方式(鼠标/手指/触控笔)的开启和关闭
  5. 支持鼠标滚轮缩放及按住空格键的同时(光标变成小手)进行鼠标拖拽移动
  6. 支持双指缩放和移动(仅可手指,触控笔不能)
  7. 支持撤销上一步操作,可设置最多可撤销的次数
  8. 支持保存和读取笔划数据
  9. 支持导出合成图片或base64编码
  10. 支持清空所有笔划
  11. 支持根据底板图片及外层容器实时自动调整合适的画布尺寸
  12. 支持设置参考系,以参考系为标准,当画布尺寸改变时,能够自动调整笔划的粗细
  13. 支持实时显示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?》

konvajs.org/docs/sandbo…

缩放的大致思路就是记录两指的坐标,并计算出两指坐标连线的中心坐标,以这个中心坐标作为缩放的中心点,然后根据两个手指的初始距离和当前距离计算出缩放比例。

双指移动也是根据双指连线的中心坐标的位置进行画布移动。

当然,只看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上的绘图板软件,默认都是全屏运行,即使不是全屏,也会保持绘图板的比例或者大小不变(使用滚动条)。

但是在浏览器上,浏览器窗口或者绘图板组件的外层容器尺寸可能都是变化的,为了做到自适应,需要监听浏览器窗口发生变化的事件,对绘图板的尺寸进行重新计算。

这里涉及到三个方面的适配:

  1. Stage(画布)的尺寸适配。根据外层容器及底板图片尺寸进行适配,类似实现CSS的background-size: contain的填充效果。
  2. 底板图片的尺寸适配。图片尺寸与Stage尺寸是一样的。
  3. 笔划粗细和路径的适配。当画布尺寸发生变化时,笔划粗细和路径也要等比缩放,否则就会出现笔划与底板图片的错位。

相关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个)搬迁至静态笔划层。这样的话,即使笔划数量过多,也只有在每次迁移笔划时才会略感到一丝卡顿,并不是每次笔划都卡顿,在很大程度上缓解了压力。

但是,这样又带来了新的问题:橡皮擦笔划只能擦除动态笔划层的笔划。

解决办法就是:

  1. 首先,给橡皮擦笔划添加一个唯一的自增eraserID,在添加至动态笔划层的同时,也同步添加至静态笔划层。
  2. 然后,当静态笔划层的笔划搬迁至动态笔划层后,根据eraserID,将前一个重复eraserID的橡皮擦笔划删除。
  3. 最后,删除静态笔划层的所有eraserID属性,保持数据的精简。

细心的你可能会问,当静态笔划层已经累计了很多笔划时(例如:1000个),同步向静态笔划层添加橡皮擦笔划的时候,会导致静态笔划层重新渲染,这样不会卡顿吗?

答案是:几乎察觉不到。

原因是,橡皮擦笔划的添加时机与普通笔划的时机不一样。

正在书写的普通笔划是实时更新路径并渲染在动态笔划层的;

而橡皮擦笔划的实时渲染是放在了橡皮擦笔划层,这个层是个临时层,最多只能保留一个橡皮擦笔划。当触点离开时,就会把这个橡皮擦笔划同时搬迁到静态笔划层和动态笔划层。因此,只有橡皮擦搬迁时才会重新渲染静态笔划层,这个重渲的过程可能在下一个笔划开始时就完成了,因此几乎感觉不到卡顿。

2.7 其他性能优化Tips

Konva官网的教程专门有一章内容是性能优化的:

《HTML5 Canvas All Konva performance tips list》

konvajs.org/docs/perfor…

在解决笔划卡顿过多导致卡顿的问题时,借鉴过这里的优化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》

konvajs.org/docs/perfor…

  • 优化点2:关闭Line的perfectDraw和shadowForStroke

直接看代码就明白了:

<v-line
    ...(略)
    :config="{
        ...(略)
        perfectDrawEnabled: false
        shadowForStrokeEnabled: false
    }
/>

详细解释请看官网说明:

《Disable Perfect Draw》

konvajs.org/docs/perfor…

《Disable shadow for stroke》

konvajs.org/docs/perfor…

  • 优化点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,用于实时显示当前的事件、触点数量、动态笔划数、静态笔划数、待删除的橡皮笔划数(在向静态层迁移前,动态笔划层的橡皮笔划也同时向静态笔划层复制一份,迁移后将删除重复的橡皮擦笔划)、可撤销次数,便于开发调试。

DEBUG.png

3 项目Git源码

本项目已上传至Gitee和GitHub,方便各位下载。

Gitee: gitee.com/betaq/draw-…

GitHub:

github.com/Yuezi32/dra…

项目源码解压密码

🔑🔑💖💖 解压密码,见我的公众号【卧梅又闻花】原文。 💖💖🔑🔑

《一个近乎完美的Konva手写板诞生记(Vue版)》