Skip to content

实现 在线白板 / 画板 工具

分类:developer | 标签:白板、画板、绘图 自由绘制、图形、便签、撤销与导出 PNG

2.1 功能说明

自由绘制、图形、便签、撤销与导出 PNG

2.1.1 使用指南

功能说明

一个纯浏览器实现的在线画板,支持自由画笔、直线、矩形、椭圆、文字与橡皮擦,可自定义颜色和线宽,随时撤销并导出 PNG。所有内容只存在于本机浏览器中,不会上传到任何服务器。

工具说明

画笔:按住并拖动即可自由绘制,笔触自动圆角连接,适合手写和批注。

直线:按下确定起点,拖动过程中实时预览,松开确定终点。

矩形 / 椭圆:按下拖动画出外接矩形,松开完成绘制,拖动方向不限。

文字:选中后在画布上点击一下,弹出输入框,确认后按当前颜色和字号写入画布。字号随线宽联动,线宽越大字越大。

橡皮擦:以当前线宽用白色覆盖画布内容。因为导出的 PNG 需要白色底,橡皮采用「涂白」而不是「挖透明」的方式,这样擦除区域和背景完全一致,导出后也不会出现透明空洞。

撤销机制

每次开始一笔绘制之前,工具会把当前画布用 toDataURL 存成一帧快照压入栈中,最多保留 20 帧。点击撤销时弹出最近一帧并重新绘制回画布,因此撤销是按「一次操作」而不是按像素回退的。超过 20 步的历史会被丢弃,属于内存与可用性之间的取舍;如果需要长历史,建议中途先导出一次 PNG。

画布与保存

画布初始宽度自动适配容器,默认高度 600 像素,也可以手动输入宽高后点击「应用尺寸」,调整时会尽量保留已有内容,超出部分被裁掉。画布区域可横向和纵向滚动。

开启「自动保存」后,每完成一笔会把画布内容写入浏览器 localStorage,刷新页面或下次打开时自动恢复。清空画布会同时清除保存的内容。若画布很大导致存储超限,自动保存会静默失败并提示,此时请手动导出 PNG 备份。

输入设备

使用 Pointer Events 统一处理鼠标、触摸屏和手写笔,在平板与触屏笔记本上可以直接用手指或触控笔书写。

2.2 代码实现

2.2.1 组件结构

本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-inputel-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。

2.2.2 核心逻辑概览

<script setup> 中定义的主要函数/方法:

  • fillWhite()
  • setupCanvas()
  • applySize()
  • fitContainer()
  • pushSnapshot()
  • restoreFromDataUrl()
  • undo()
  • clearCanvas()
  • persist()
  • restoreSaved()
  • getPos()
  • applyStroke()
  • drawText()
  • onPointerDown()
  • onPointerMove()
  • drawShape()
  • onPointerUp()
  • exportPng()
  • handleResize()

2.2.3 关键实现代码

以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。

vue
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import ToolPageShell from '@/components/ToolPageShell.vue'

const STORAGE_KEY = 'tool-whiteboard-content-v1'
const MAX_HISTORY = 20

const wrapRef = ref(null)
const canvasRef = ref(null)

const tool = ref('pen')
const color = ref('#303133')
const lineWidth = ref(4)
const autoSave = ref(true)
const notice = ref('')

const canvasW = ref(1000)
const canvasH = ref(600)
const inputW = ref(1000)
const inputH = ref(600)

const snapshots = ref([])

let ctx = null
let drawing = false
let startX = 0
let startY = 0
let baseFrame = null // 形状拖拽预览用的底图 ImageData
let activePointerId = null
let moved = false

/* ---------------- 画布初始化 ---------------- */

function fillWhite() {
  ctx.save()
  ctx.globalCompositeOperation = 'source-over'
  ctx.fillStyle = '#ffffff'
  ctx.fillRect(0, 0, canvasRef.value.width, canvasRef.value.height)
  ctx.restore()
}

function setupCanvas(width, height, keepContent) {
  const canvas = canvasRef.value
  let previous = null
  if (keepContent && canvas.width && canvas.height) {
    previous = document.createElement('canvas')
    previous.width = canvas.width
    previous.height = canvas.height
    previous.getContext('2d').drawImage(canvas, 0, 0)
  }
  canvas.width = width
  canvas.height = height
  canvasW.value = width
  canvasH.value = height
  ctx = canvas.getContext('2d')
  ctx.lineCap = 'round'
  ctx.lineJoin = 'round'
  fillWhite()
  if (previous) ctx.drawImage(previous, 0, 0)
}

function applySize() {
  setupCanvas(Number(inputW.value) || 1000, Number(inputH.value) || 600, true)
  snapshots.value = []
  persist()
}

function fitContainer() {
  const wrap = wrapRef.value
  if (!wrap) return
  const width = Math.max(320, Math.floor(wrap.clientWidth - 4))
  inputW.value = width
  setupCanvas(width, Number(inputH.value) || 600, true)
  snapshots.value = []
  persist()
}

/* ---------------- 快照与撤销 ---------------- */

function pushSnapshot() {
  try {
    snapshots.value.push(canvasRef.value.toDataURL('image/png'))
    if (snapshots.value.length > MAX_HISTORY) snapshots.value.shift()
  } catch (e) {
    notice.value = '快照保存失败,撤销功能可能不可用'
  }
}

function restoreFromDataUrl(dataUrl, afterDone) {
  const img = new Image()
  img.onload = () => {
    fillWhite()
    ctx.drawImage(img, 0, 0)
    if (afterDone) afterDone()
  }
  img.src = dataUrl
}

function undo() {
  const last = snapshots.value.pop()
  if (!last) return
  restoreFromDataUrl(last, persist)
}

function clearCanvas() {
  pushSnapshot()
  fillWhite()
  try {
    localStorage.removeItem(STORAGE_KEY)
  } catch (e) {
    // 忽略存储异常
  }
}

/* ---------------- 本地保存 ---------------- */

function persist() {
  if (!autoSave.value) return
  try {
    localStorage.setItem(STORAGE_KEY, canvasRef.value.toDataURL('image/png'))
  } catch (e) {
    notice.value = '内容超出本地存储上限,自动保存已跳过,请手动导出 PNG 备份'
  }
}

function restoreSaved() {
  let saved = null
  try {
    saved = localStorage.getItem(STORAGE_KEY)
  } catch (e) {
    saved = null
  }
  if (!saved) return
  const img = new Image()
  img.onload = () => {
    if (img.width !== canvasRef.value.width || img.height !== canvasRef.value.height) {
      setupCanvas(img.width, img.height, false)
      inputW.value = img.width
      inputH.value = img.height
    }
    ctx.drawImage(img, 0, 0)
  }
  img.src = saved
}

/* ---------------- 绘制 ---------------- */

function getPos(evt) {
  const rect = canvasRef.value.getBoundingClientRect()
  const scaleX = canvasRef.value.width / rect.width
  const scaleY = canvasRef.value.height / rect.height
  return {
    x: (evt.clientX - rect.left) * scaleX,
    y: (evt.clientY - rect.top) * scaleY
  }
}

function applyStroke() {
  ctx.globalCompositeOperation = 'source-over'
  ctx.strokeStyle = tool.value === 'eraser' ? '#ffffff' : color.value
  ctx.fillStyle = tool.value === 'eraser' ? '#ffffff' : color.value
  ctx.lineWidth = lineWidth.value
  ctx.lineCap = 'round'
  ctx.lineJoin = 'round'
}

function drawText(x, y) {
  const input = window.prompt('请输入要写入画布的文字', '')
  if (!input) return
  pushSnapshot()
  const fontSize = Math.max(12, lineWidth.value * 6)
  ctx.save()
  ctx.globalCompositeOperation = 'source-over'
  ctx.fillStyle = color.value
  ctx.font = fontSize + 'px "Microsoft YaHei", "PingFang SC", sans-serif'
  ctx.textBaseline = 'top'
  const lines = input.split('\n')
  lines.forEach((line, index) => {
    ctx.fillText(line, x, y + index * fontSize * 1.25)
  })
  ctx.restore()
  persist()
}

function onPointerDown(evt) {
  if (!ctx) return
  const pos = getPos(evt)

  if (tool.value === 'text') {
    drawText(pos.x, pos.y)
    return
  }

  evt.preventDefault()
  activePointerId = evt.pointerId
  if (canvasRef.value.setPointerCapture) {
    canvasRef.value.setPointerCapture(evt.pointerId)
  }

  pushSnapshot()
  drawing = true
  moved = false
  startX = pos.x
  startY = pos.y
  applyStroke()

  if (tool.value === 'pen' || tool.value === 'eraser') {
    ctx.beginPath()
    ctx.moveTo(pos.x, pos.y)
    // 单击也留下一个点
    ctx.lineTo(pos.x + 0.01, pos.y + 0.01)
    ctx.stroke()
  } else {
    baseFrame = ctx.getImageData(0, 0, canvasRef.value.width, canvasRef.value.height)
  }
}

function onPointerMove(evt) {
  if (!drawing || !ctx) return
  if (activePointerId !== null && evt.pointerId !== activePointerId) return
  evt.preventDefault()
  moved = true
  const pos = getPos(evt)

  if (tool.value === 'pen' || tool.value === 'eraser') {
    ctx.lineTo(pos.x, pos.y)
    ctx.stroke()
    return
  }

  if (baseFrame) ctx.putImageData(baseFrame, 0, 0)
  applyStroke()
  drawShape(startX, startY, pos.x, pos.y)
}

function drawShape(x1, y1, x2, y2) {
  if (tool.value === 'line') {
    ctx.beginPath()
    ctx.moveTo(x1, y1)
    ctx.lineTo(x2, y2)
    ctx.stroke()
    return
  }
  const x = Math.min(x1, x2)
  const y = Math.min(y1, y2)
  const w = Math.abs(x2 - x1)
  const h = Math.abs(y2 - y1)
  if (tool.value === 'rect') {
    ctx.beginPath()
    ctx.rect(x, y, w, h)
    ctx.stroke()
  } else if (tool.value === 'ellipse') {
    ctx.beginPath()
    ctx.ellipse(x + w / 2, y + h / 2, w / 2, h / 2, 0, 0, Math.PI * 2)
    ctx.stroke()
  }
}

function onPointerUp(evt) {
  if (!drawing) return
  if (activePointerId !== null && evt && evt.pointerId !== activePointerId) return
  const isShape = tool.value !== 'pen' && tool.value !== 'eraser'
  drawing = false
  baseFrame = null
  activePointerId = null
  if (evt && canvasRef.value.releasePointerCapture && evt.pointerId !== undefined) {
    try {
      canvasRef.value.releasePointerCapture(evt.pointerId)
    } catch (e) {
      // 指针已释放时忽略
    }
  }
  ctx.beginPath()
  // 图形工具只按下未拖动时不产生内容,回收多余的历史帧
  if (isShape && !moved) {
    snapshots.value.pop()
    return
  }
  persist()
}

/* ---------------- 导出 ---------------- */

function exportPng() {
  canvasRef.value.toBlob((blob) => {
    if (!blob) return
    const url = URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = url
    a.download = 'whiteboard-' + Date.now() + '.png'
    document.body.appendChild(a)
    a.click()
    document.body.removeChild(a)
    setTimeout(() => URL.revokeObjectURL(url), 2000)
  }, 'image/png')
}

/* ---------------- 生命周期 ---------------- */

function handleResize() {
  // 仅在画布仍是初始自适应宽度时跟随容器,避免覆盖用户手动设置
  const wrap = wrapRef.value
  if (!wrap) return
  const width = Math.max(320, Math.floor(wrap.clientWidth - 4))
  if (Math.abs(width - canvasW.value) > 120 && !snapshots.value.length) {
    inputW.value = width
    setupCanvas(width, canvasH.value, true)
  }
}

onMounted(async () => {
  await nextTick()
  const wrap = wrapRef.value
  const width = wrap ? Math.max(320, Math.floor(wrap.clientWidth - 4)) : 1000
  inputW.value = width
  inputH.value = 600
  setupCanvas(width, 600, false)
  restoreSaved()
  window.addEventListener('resize', handleResize)
  // 指针在画布外抬起时的兜底,避免笔画卡住
  window.addEventListener('pointerup', onPointerUp)
})

onBeforeUnmount(() => {
  window.removeEventListener('resize', handleResize)
  window.removeEventListener('pointerup', onPointerUp)
})

2.3 效果截图

在线白板 / 画板 效果截图

工具访问地址:https://www.i91tools.com/tools/whiteboard