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

0 阅读9分钟

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

先说结论

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

为什么不直接用现成的?

方案一:CSS overflow: auto

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

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

方案二:panzoom

老牌库,但:

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 行搞定

<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: 'right'           // 右键拖拽
mode: 'middle'          // 中键(滚轮点击)拖拽

2️⃣ 修饰键组合

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 + 左键

🔬 规则引擎源码解析

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() 处理,不管用户怎么写都能识别
  • 精简输出:没有修饰键时 modifiers 为 undefined 而非 {},减少后续 if 判断
  • 安全兜底:非法字符串返回默认值而非抛错,生产环境更稳定

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

  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'✅ grabgrabbing纯左键拖拽,用户预期看到抓取手势
'shift+left'默认grabbing需要按住 Shift 才触发,平时不应暗示可拖拽
'right'默认右键拖拽通常无需视觉暗示
['left', 'shift+right']grabgrabbing包含纯左键规则

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

智能右键菜单策略

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

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

核心亮点四:方向键微调

  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,直接按方向键就能微调画布位置。这对精确对齐场景特别有用。

核心亮点五:边界限制

  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 }

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

  <div
    ref="canvasRef"
    class="canvas"
    @mousedown="handlers.onMouseDown"
    @contextmenu="handlers.onContextMenu"
    @keydown="handlers.onKeyDown"
  >
    <!-- 流程图节点 -->
    <div
      v-for="node in nodes"
      :key="node.id"
      class="node"
      :style="{ left: node.x + 'px', top: node.y + 'px' }"
      @click="selectNode(node)"
      @contextmenu.prevent="showContextMenu($event, node)"
    >
      {{ node.label }}
    </div>
  </div>

  <!-- 工具栏 -->
  <div class="toolbar">
    <button @click="toggleMode">当前: {{ isEditMode ? '编辑' : '预览' }}</button>
    <span v-if="isPanning" class="status">🔘 拖拽中...</span>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useCanvasDrag } from 'use-canvas-drag'

const canvasRef = ref<HTMLDivElement>()
const isEditMode = ref(true)
const nodes = reactive([
  { id: 1, label: '开始', x: 100, y: 100 },
  { id: 2, label: '处理', x: 300, y: 200 },
  { id: 3, label: '结束', x: 500, y: 300 },
])

const { handlers, isPanning, stopPan } = useCanvasDrag({
  container: () => canvasRef.value!,

  // 核心:Shift + 左键/右键拖动画布
  // 这样左键可以正常选节点,右键可以正常弹菜单
  mode: ['shift+left', 'shift+right'],

  arrowKeys: true,

  // 限制在内容范围内
  bounds: true,

  // 响应式开关:预览模式下禁止拖拽
  enabled: isEditMode,

  onStartDrag: () => console.log('开始拖拽画布'),
  onDrag: ({ x, y }) => console.log(`偏移: ${x.toFixed(0)}, ${y.toFixed(0)}`),
  onEndDrag: () => console.log('结束拖拽'),
})

function selectNode(node: typeof nodes[number]) {
  console.log('选中节点:', node.label)
}

function showContextMenu(e: MouseEvent, node: typeof nodes[number]) {
  console.log('右键菜单:', node.label, e)
}

function toggleMode() {
  isEditMode.value = !isEditMode.value
  // 切换到预览模式时,如果正在拖拽会自动停止
}
</script>

<style scoped>
.canvas {
  width: 100%;
  height: 600px;
  overflow: hidden;
  position: relative;
  background: #f0f0f0;
  background-image:
    radial-gradient(circle, #ccc 1px, transparent 1px);
  background-size: 20px 20px; /* 网格背景 */
}

.node {
  position: absolute;
  width: 140px;
  padding: 12px 16px;
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.12);
  cursor: pointer;
  user-select: none;
  transition: box-shadow 0.2s;
}
.node:hover {
  box-shadow: 0 4px 16px rgba(0,0,0,0.18);
}

.toolbar {
  padding: 8px 16px;
  background: white;
  border-bottom: 1px solid #eee;
  display: flex;
  align-items: center;
  gap: 12px;
}
.status {
  color: #e67e22;
  font-weight: 500;
}
</style>

这个示例展示了完整的能力矩阵:

  • shift+left/right 拖动画布 ← 核心卖点
  • 左键正常选节点 ← 不被拖拽抢占
  • 右键正常弹菜单 ← 不被拖拽抢占
  • 方向键微调位置 ← 精确控制
  • 响应式 enabled 开关 ← 编辑/预览模式切换
  • bounds 边界限制 ← 不会滚丢

技术要点总结

为什么要绑定 document 而不是容器?

document.addEventListener('mouseup', handleMouseUp);

经典的 Drag & Drop 模式:当用户快速拖拽导致鼠标移出容器区域时,如果事件只绑在容器上就会丢失。绑在 document 上确保永远不会跟丢鼠标

容器缓存策略


const getContainer = (): HTMLElement | null => {
  if (cachedContainer && cachedContainer.isConnected) {
    return cachedContainer;  // 缓存命中且仍在 DOM 中
  }
  // ... 重新查询 DOM ...
  cachedContainer = result;
  return result;
};

每次事件触发都会调用 getContainer(),通过缓存 + isConnected 检查避免频繁 DOM 查询。同时能感知 DOM 元素是否被移除并自动重新查询。

enabled 响应式支持

const isEnabled = computed(() => unref(enabled));

接受 boolean | Ref<boolean>,通过 unref 统一处理。静态值和响应式值都能兼容:

// 静态
enabled: true

// 响应式
enabled: isEditingMode   // ref<boolean>

与其他方案的全面对比

特性use-canvas-dragpanzoom手写实现interact.js
体积 (gzip)~1KB~8KB~80KB
运行时依赖
Vue3 Composable
右键拖拽✅ 一等公民⚠️ 需 hack手写⚠️ 复杂
组合键触发✅ 核心特性手写
方向键微调✅ 内置手写
边界限制✅ 内置手写
智能光标✅ v0.6.0手写⚠️
智能右键菜单✅ v0.6.0⚠️手写⚠️
TypeScript✅ 完整类型⚠️ 部分自己写

适用场景

场景推荐 mode说明
流程图 / 白板编辑器['shift+left', 'shift+right']左键选元素,Shift+拖拽移动画布
地图 / GIS'left'经典的左键拖拽平移
图片查看器'middle'中键平移,滚轮缩放
游戏地图编辑器['left', 'arrowKeys']左键拖 + 方向键精调
在线 PPT'right'右键拖拽,左键选对象
思维导图'shift+left'左键编辑节点,Shift+拖拽移动视角

版本迭代之路

版本核心变更
v0.6.0智能化重构:纯按钮 vs 修饰键的差异化处理;推荐用法文档
v0.5.6修复修饰键大小写 bug;初版自动光标/右键菜单
v0.5.1简化 mode 类型;独立方向键模式
v0.5.0键盘方向键支持;自动聚焦机制;双重校验
v0.4.1修复类型导出

从 v0.4.1 到 v0.6.0,短短几天迭代了 16个版本,每次都基于实际使用中的反馈在打磨。

写在最后

这个库的设计哲学是:

把复杂留给自己,把简洁留给使用者。

326 行代码背后是对以下问题的思考:

  • 如何设计一个表达力强书写简洁的触发规则 DSL?
  • 如何在自动化便利可控性之间取得平衡?
  • 如何让 API 开箱即用的同时又不失灵活性

如果你也在做画布类应用,欢迎试试,有问题欢迎提 issue 讨论 👋

github.com/Lruibo/use-…