在做流程图编辑器时遇到一个看似简单却坑点满满的需求:让用户用鼠标拖拽来平移画布。试了一圈方案后决定自己造轮子——结果不仅解决了问题,还顺便把组合键触发、方向键微调、边界限制、智能光标全做进去了。
先说结论
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 类型提示
方案三:手写
当然可以,但你很快会遇到这些问题:
- mousedown / mousemove / mouseup 的事件绑定顺序?
- 鼠标移出容器后怎么处理?
- 怎么防止拖拽时选中文字?
- 右键菜单怎么阻止?
- 光标怎么切换?
- 边界限制怎么算?
- 方向键微调怎么做?
- ...
每个问题都不难,但合在一起就是一堆样板代码。
我的方案: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' | ✅ grab | grabbing | 纯左键拖拽,用户预期看到抓取手势 |
'shift+left' | 默认 | grabbing | 需要按住 Shift 才触发,平时不应暗示可拖拽 |
'right' | 默认 | — | 右键拖拽通常无需视觉暗示 |
['left', 'shift+right'] | grab | grabbing | 包含纯左键规则 |
这个设计决策很重要: 如果配置的是 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-drag | panzoom | 手写实现 | 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 讨论 👋