vue3 画布编辑器「平移」天坑?只需 5 行代码,完美优雅复刻大厂体验!

在做流程图编辑器时遇到一个看似简单却坑点满满的需求:让用户用鼠标拖拽来平移画布。试了一圈方案后决定自己造轮子------结果不仅解决了问题,还顺便把组合键触发、方向键微调、边界限制、智能光标全做进去了。

|----------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------|
| | | | |

先说结论

css 复制代码
npm install use-canvas-drag
指标 数据
核心代码 326 行 TypeScript
Gzip 大小 ~1KB
运行时依赖 (仅 peerDependency vue@3)
支持功能 左键/右键/中键 + Shift/Ctrl/Alt/Meta 组合 + 方向键 + 边界限制

为什么不直接用现成的?

方案一:CSS overflow: auto

最简单的方案,但问题致命:

  • 只能滚轮滚动,无法鼠标拖拽
  • ❌ 无法区分左右中键
  • ❌ 无法加组合键(比如 Shift + 右键才拖拽)

方案二:panzoom

老牌库,但:

import 复制代码
panzoom(element, {
  zoomSpeed: 1,      // 我不需要缩放...
  minZoom: 0.1,      // 不需要...
  maxZoom: 5,        // 不需要...
})
  • ~8KB gzip,大部分是缩放功能我不用
  • API 设计偏传统,不适合 Vue3 Composition API
  • 不支持自定义触发方式(想用右键拖?自己 hack 吧)
  • 没有 TypeScript 类型提示

方案三:手写

当然可以,但你很快会遇到这些问题:

  1. mousedown / mousemove / mouseup 的事件绑定顺序?
  2. 鼠标移出容器后怎么处理?
  3. 怎么防止拖拽时选中文字?
  4. 右键菜单怎么阻止?
  5. 光标怎么切换?
  6. 边界限制怎么算?
  7. 方向键微调怎么做?
  8. ...

每个问题都不难,但合在一起就是一堆样板代码。

我的方案:useCanvasDrag

最简用法 ------ 5 行搞定

xml 复制代码
<template>
  <div ref="canvasRef" class="canvas"
    @mousedown="handlers.onMouseDown"
    @contextmenu="handlers.onContextMenu"
    @keydown="handlers.onKeyDown">
    <!-- 内容 -->
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { useCanvasDrag } from 'use-canvas-drag'

const canvasRef = ref()
const { handlers } = useCanvasDrag({
  container: () => canvasRef.value,
  mode: 'right',        // 右键拖拽
})
</script>

核心亮点一:灵活的触发规则引擎

这是整个库的灵魂设计。支持三种形式:

1️⃣ 快捷字符串

mode: 复制代码
mode: 'right'           // 右键拖拽
mode: 'middle'          // 中键(滚轮点击)拖拽

2️⃣ 修饰键组合

mode: 复制代码
mode: 'Ctrl+right'       // 必须按住 Ctrl + 右键
mode: 'Alt+middle'       // Alt + 中键
// 大小写不敏感!以下等价:
mode: 'shift+left'       // ✅ 小写
mode: 'Shift+Left'       // ✅ 混合大小写
mode: 'SHIFT+LEFT'       // ✅ 全大写

为什么需要这个? 因为在实际项目中,左键通常用来选元素,右键可能弹出菜单。用组合键可以完美避免冲突!

3️⃣ 多规则并行 + 自定义对象

// 复制代码
mode: ['left', 'shift+right']
// 含义:左键直接能拖,或者按住 shift + 右键也能拖

// 对象:完全自定义
mode: {
  button: 0,
  modifiers: { shift: true, ctrl: true }
}
// 含义:必须同时按住 Shift + Ctrl + 左键

🔬 规则引擎源码解析

ini 复制代码
function parseTrigger(str: string): DragButtonConfig {
  const lower = str.toLowerCase();
  
  // 1. 纯按钮:'left' | 'right' | 'middle'
  if (MouseButtons.includes(lower)) {
    return MouseButton[lower];
  }

  // 2. 组合键:'Shift+left' | 'Ctrl+right'
  if (str.includes('+')) {
    const parts = str.split('+');
    const modifiers: DragButtonConfig['modifiers'] = {};
    let mouseButton: number | undefined;

    for (const part of parts) {
      const p = part.toLowerCase();     // ← 关键:统一转小写,容错
      if (p === 'shift') modifiers.shift = true;
      else if (p === 'ctrl') modifiers.ctrl = true;
      else if (p === 'alt') modifiers.alt = true;
      else if (p === 'meta') modifiers.meta = true;
      else if (MouseButtons.includes(p)) {
        mouseButton = MouseButton[p]?.button;  // ← 找到目标按键
      }
    }

    if (mouseButton !== undefined) {
      return {
        button: mouseButton,
        // 优化:无修饰键时不传空对象,减少后续判断开销
        modifiers: Object.keys(modifiers).length ? modifiers : undefined
      };
    }
  }

  // 3. 兜底:非法输入返回默认左键,不会崩溃
  return MouseButton['left'];
}

三个设计细节:

  • 容错优先 :全部 toLowerCase() 处理,不管用户怎么写都能识别
  • 精简输出 :没有修饰键时 modifiersundefined 而非 {},减少后续 if 判断
  • 安全兜底:非法字符串返回默认值而非抛错,生产环境更稳定

事件匹配层同样简洁有力:

const 复制代码
  return rules.value.some(rule => {
    // 配置了哪些修饰键,就检查哪些(白名单模式)
    if (rule.modifiers?.shift && !e.shiftKey) return false;
    if (rule.modifiers?.ctrl && !e.ctrlKey) return false;
    if (rule.modifiers?.alt && !e.altKey) return false;
    if (rule.modifiers?.meta && !e.metaKey) return false;
    // button 为 undefined 表示"任意按键都匹配"
    return rule.button === undefined || rule.button === e.button;
  });
};

some() 实现 OR 语义------只要命中一条规则就放行。

核心亮点二:推荐用法 ------ 修饰键组合模式 ⭐

这是 v0.6.0 最重要的使用建议:

复制代码
const { handlers } = useCanvasDrag({
  container: () => canvasRef.value,
  // 推荐用法:按住 Shift 才能拖拽
  mode: ['shift+left', 'shift+right'],
  arrowKeys: true,          // 同时启用方向键微调
})
</script>

为什么这是最佳实践?

想象你在做一个流程图编辑器:

  • 用户需要在画布上点击节点 → 左键不能被拖拽占用
  • 用户需要右键弹出菜单 → 右键也不能被拖拽占用
  • 但用户又需要移动画布来看远处的内容

解决方案:用修饰键分离关注点!

操作 触发方式 说明
选中元素 左键单击 正常操作
弹出菜单 右键单击 正常操作
拖拽画布 Shift + 左/右键 特殊操作
微调位置 ↑↓←→ 方向键 精确控制

这种设计的精髓在于:日常操作零干扰,需要时按住一个键就能进入拖拽模式。

核心亮点三:智能化体验(v0.6.0 重构)

v0.6.0 对自动化体验做了精细化的策略调整。核心思路:纯按钮 vs 带修饰键的按钮,交互语义完全不同

智能光标策略

// 复制代码
const hasLeftButton = computed(() =>
  rules.value.some(r => (r.button === undefined || r.button === 0) && !r.modifiers)
);
// 只有纯右键才禁用右键菜单
const hasRightButton = computed(() =>
  rules.value.some(r => (r.button === 2) && !r.modifiers)
);

对比一下两种模式的光标行为:

mode 配置 默认光标 拖拽中光标 原因
'left' grab grabbing 纯左键拖拽,用户预期看到抓取手势
'shift+left' 默认 grabbing 需要按住 Shift 才触发,平时不应暗示可拖拽
'right' 默认 --- 右键拖拽通常无需视觉暗示
['left', 'shift+right'] grab grabbing 包含纯左键规则

这个设计决策很重要: 如果配置的是 shift+left,用户平时在画布上操作时看到的不应该是 grab 光标------因为他需要先按住 Shift 才能拖拽。grab 光标会给用户错误的心理预期。

智能右键菜单策略

同理,右键菜单也不是无脑禁用的:

mode 配置 右键菜单行为
'right' 🚫 自动禁用(纯右键用于拖拽)
'shift+right' ✅ 保留(正常右键可用)
['left', 'right'] 🚫 自动禁用(包含纯右键规则)

核心亮点四:方向键微调

const 复制代码
  if (!isEnabled.value) return;
  const el = getContainer();

  // 方向键微调模式
  if (arrowKeys && isArrowKey(e.key)) {
    const STEP = 30;              // 每次移动 30px
    let dx = 0, dy = 0;
    switch (e.key) {
      case 'ArrowUp': dy = -STEP; break;
      case 'ArrowDown': dy = STEP; break;
      case 'ArrowLeft': dx = -STEP; break;
      case 'ArrowRight': dx = STEP; break;
    }
    el.scrollLeft += dx;
    el.scrollTop += dy;
    onDrag?.({ x: dx, y: dy });
    e.preventDefault();            // 阻止页面滚动
  }
}

配合自动聚焦机制,用户体验非常顺滑:

// 复制代码
if (!el.hasAttribute('tabindex')) {
  el.tabIndex = -1;
}
el.focus();

用户点击画布后,无需手动 focus,直接按方向键就能微调画布位置。这对精确对齐场景特别有用。

核心亮点五:边界限制

function 复制代码
  if (!bounds) return { x: targetX, y: targetY };

  if (bounds === true || bounds === 'content') {
    // 自动计算内容范围,防止滚过头出现空白区
    const maxScrollLeft = el.scrollWidth - el.clientWidth;
    const maxScrollTop = el.scrollHeight - el.clientHeight;
    return {
      x: Math.max(0, Math.min(targetX, maxScrollLeft)),
      y: Math.max(0, Math.min(targetY, maxScrollTop)),
    };
  }

  // 自定义边界
  return {
    x: Math.max(bounds.left ?? -Infinity, Math.min(targetX, bounds.right ?? Infinity)),
    y: Math.max(bounds.top ?? -Infinity, Math.min(targetY, bounds.bottom ?? Infinity)),
  };
}

三种用法覆盖所有场景:

// 复制代码
bounds: undefined

// 限制在内容范围内(最常用)
bounds: true

// 精确控制
bounds: { left: -500, right: 2500, top: -300, bottom: 1800 }

完整实战示例:流程图画布