Vue-Konva 使用(缩放 / 还原 / 拖动) 示例(当前示例为vue3版本)
目标
- 在一个 Vue 单文件组件中,完成以下交互:
- 鼠标滚轮相对指针位置缩放(向上放大、向下缩小)
- 按钮(支持长按)居中放大 / 居中缩小 / 还原
- 按住
Ctrl + 左键拖动画布(仅在放大后可拖动) - 缩小时居中显示、禁止拖动
安装与注册
- 安装依赖(任选包管理器):
bash
npm i vue-konva konva
# 或
pnpm add vue-konva konva
- 注册插件(两种方式):
- 入口处全局注册:
ts
// main.ts
import { createApp } from 'vue';
import VueKonva from 'vue-konva';
import App from './App.vue';
const app = createApp(App);
// 使用 v- 前缀,也可以自定义:{ prefix: 'V' }
app.use(VueKonva, { prefix: 'V' });
app.mount('#app');
- 组件内自注册(在组件
setup阶段注册,确保首帧渲染前完成):
ts
// 在组件 setup 阶段注册,避免首次渲染前出现未注册错误
import { getCurrentInstance } from 'vue';
import VueKonva from 'vue-konva';
const app = getCurrentInstance()?.appContext.app;
app?.use(VueKonva, { prefix: 'V' });
组件与事件说明
VStage:KonvaStage的 Vue 封装,是画布根容器。- 事件:
@wheel(滚轮缩放)、@mousedown/@mousemove/@mouseup/@mouseleave(平移手势)
- 事件:
VLayer:KonvaLayer的 Vue 封装,是真正的<canvas>层,可放多个图层。VRect/VText/VImage/VTransformer:Konva 基础与变换组件。
交互设计要点
- 指针相对缩放:通过记录指针位置
pos,计算缩放前指针对应的画布坐标mousePointTo,再反算缩放后画布位置,使"缩放围绕指针"发生。 - 居中缩放按钮:以画布中心为缩放锚点,保证视觉稳定。
- 边界约束:
- 当
scale <= 1(缩小)时,强制居中显示,禁止平移。 - 当
scale > 1(放大)时,限制平移在可视范围内,避免出现大面积空白。
- 当
- Ctrl 拖动:按住
Ctrl + 左键时,禁用元素交互(不可拖动 / 不响应点击),专注于画布平移。
单文件组件示例(可直接使用)
复制到你的项目中即可运行。若已在入口处注册了
VueKonva,组件内注册可删除。
vue
<script setup lang="ts">
import { getCurrentInstance, onMounted, onUnmounted, ref } from 'vue';
import VueKonva from 'vue-konva';
// 组件内自注册 VueKonva(你也可以在应用入口统一注册)
const app = getCurrentInstance()?.appContext.app;
app?.use(VueKonva, { prefix: 'V' });
// 画布尺寸(根据实际需求调整)
const canvasSize = { width: 800, height: 600 };
// VStage 的模板引用,用于获取 Konva Stage 实例
const stageRef = ref<any>(null);
// Ctrl + 左键平移相关状态
const isPanning = ref(false); // 是否正在平移
const panStartPointer = ref<{ x: number, y: number } | null>(null); // 平移起点的指针位置
const panStartStagePos = ref<{ x: number, y: number } | null>(null); // 平移起点的画布位置
const isCtrlPressing = ref(false); // 是否按住了 Ctrl 键(影响元素交互与光标)
// 获取 Konva Stage 实例
function getStage() {
const node = stageRef.value?.getNode?.();
return node ?? null;
}
// 连续缩放比例步进(越接近 1 越平滑)
const SCALE_STEP = 1.05;
// 约束画布位置:
// - 缩放 ≤ 1 时,居中显示,不允许漂移
// - 缩放 > 1 时,限制在可视范围内,避免出现空白
function clampStagePosition(stage: any) {
const s = stage.scaleX();
if (s <= 1) {
const cx = stage.width() * (1 - s) / 2;
const cy = stage.height() * (1 - s) / 2;
stage.position({ x: cx, y: cy });
return;
}
const maxX = 0;
const minX = stage.width() * (1 - s);
const maxY = 0;
const minY = stage.height() * (1 - s);
const pos = stage.position();
const clampedX = Math.min(maxX, Math.max(minX, pos.x));
const clampedY = Math.min(maxY, Math.max(minY, pos.y));
stage.position({ x: clampedX, y: clampedY });
}
// 指针相对缩放:保持指针下的内容为缩放中心
// factor > 1 放大;factor < 1 缩小
// doClamp = true(滚轮):需要边界约束
// doClamp = false(按钮):不立即约束,但当缩小到 ≤1 时仍需居中
function zoomBy(factor: number, pointer?: { x: number, y: number }, doClamp = true) {
const stage = getStage();
if (!stage)
return;
const oldScale = stage.scaleX();
const pos = pointer ?? stage.getPointerPosition() ?? { x: stage.width() / 2, y: stage.height() / 2 };
// 计算指针相对缩放锚点
const mousePointTo = {
x: (pos.x - stage.x()) / oldScale,
y: (pos.y - stage.y()) / oldScale,
};
// 应用缩放
const newScale = oldScale * factor;
stage.scale({ x: newScale, y: newScale });
// 反算缩放后的位置,使缩放相对锚点
const newPos = {
x: pos.x - mousePointTo.x * newScale,
y: pos.y - mousePointTo.y * newScale,
};
stage.position(newPos);
// 边界约束策略
if (doClamp) {
clampStagePosition(stage);
}
else {
if (newScale <= 1)
clampStagePosition(stage);
}
stage.batchDraw();
}
// 按钮:从画布中心放大
function zoomIn() {
const stage = getStage();
if (!stage)
return;
zoomBy(SCALE_STEP, { x: stage.width() / 2, y: stage.height() / 2 }, false);
}
// 按钮:从画布中心缩小
function zoomOut() {
const stage = getStage();
if (!stage)
return;
zoomBy(1 / SCALE_STEP, { x: stage.width() / 2, y: stage.height() / 2 }, false);
}
// 长按:支持按住按钮持续缩放,短按触发一次
const holdTimeout = ref<number | null>(null);
const holdInterval = ref<number | null>(null);
const holdAction = ref<'in' | 'out' | null>(null);
const HOLD_DELAY_MS = 250; // 长按判定延迟
const HOLD_INTERVAL_MS = 60; // 长按时的连续触发间隔
function clearHoldTimers() {
if (holdTimeout.value) {
window.clearTimeout(holdTimeout.value);
holdTimeout.value = null;
}
if (holdInterval.value) {
window.clearInterval(holdInterval.value);
holdInterval.value = null;
}
}
function onPressStart(action: 'in' | 'out') {
clearHoldTimers();
holdAction.value = action;
holdTimeout.value = window.setTimeout(() => {
holdInterval.value = window.setInterval(() => {
if (holdAction.value === 'in')
zoomIn();
else
zoomOut();
}, HOLD_INTERVAL_MS);
}, HOLD_DELAY_MS);
}
function onPressEnd() {
// 短按:尚未进入长按循环时触发一次
if (holdTimeout.value && !holdInterval.value && holdAction.value) {
if (holdAction.value === 'in')
zoomIn();
else
zoomOut();
}
clearHoldTimers();
holdAction.value = null;
}
// 还原:缩放为 1,位置归位(中心)
function resetZoom() {
const stage = getStage();
if (!stage)
return;
stage.scale({ x: 1, y: 1 });
stage.position({ x: 0, y: 0 });
stage.batchDraw();
}
// 滚轮缩放:向上放大,向下缩小;相对指针的位置
function onWheel(e: any) {
const stage = getStage();
if (!stage)
return;
e.evt?.preventDefault?.();
const direction = e.evt?.deltaY > 0 ? (1 / SCALE_STEP) : SCALE_STEP;
zoomBy(direction, stage.getPointerPosition() ?? undefined);
}
// Ctrl+左键开始平移(仅当缩放 > 1 时有效)
function onStageMouseDown(e: any) {
const stage = getStage();
if (!stage)
return;
if (e.evt?.ctrlKey && e.evt?.button === 0) {
if (stage.scaleX() <= 1)
return;
e.evt.preventDefault();
isPanning.value = true;
panStartPointer.value = stage.getPointerPosition() ?? null;
const pos = stage.position();
panStartStagePos.value = { x: pos.x, y: pos.y };
}
}
// 平移中:根据起点与当前指针计算偏移,并做边界约束
function onStageMouseMove() {
if (!isPanning.value)
return;
const stage = getStage();
if (!stage)
return;
const pointer = stage.getPointerPosition();
if (!pointer || !panStartPointer.value || !panStartStagePos.value)
return;
const dx = pointer.x - panStartPointer.value.x;
const dy = pointer.y - panStartPointer.value.y;
stage.position({ x: panStartStagePos.value.x + dx, y: panStartStagePos.value.y + dy });
clampStagePosition(stage);
stage.batchDraw();
}
// 结束平移,重置状态
function onStageMouseUp() {
isPanning.value = false;
panStartPointer.value = null;
panStartStagePos.value = null;
}
// 记录 Ctrl 按键状态,用于切换元素交互与容器鼠标样式
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Control')
isCtrlPressing.value = true;
}
function onKeyUp(e: KeyboardEvent) {
if (e.key === 'Control')
isCtrlPressing.value = false;
}
onMounted(() => {
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
});
onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
clearHoldTimers();
});
// 示例图形配置(矩形&文字)
// Ctrl 按下时禁用元素交互(不可拖动/不可点击),专注画布平移
const rectConfig = ref({
x: canvasSize.width / 2 - 100,
y: canvasSize.height / 2 - 50,
width: 200,
height: 100,
fill: '#80c7ff',
stroke: '#0ea5e9',
strokeWidth: 1,
draggable: true,
});
const textConfig = ref({
x: canvasSize.width / 2 - 50,
y: canvasSize.height / 2 + 70,
text: 'Hello, Konva!',
fontSize: 18,
fill: '#334155',
});
</script>
<template>
<div class="konva-demo-container" :class="{ 'cursor-pointer': isCtrlPressing }">
<!-- 工具栏:居左上角 -->
<div class="toolbar">
<button class="btn btn-outline"
@mousedown="onPressStart('in')" @mouseup="onPressEnd" @mouseleave="onPressEnd"
@touchstart.prevent="onPressStart('in')" @touchend="onPressEnd">
+ 放大
</button>
<button class="btn btn-outline"
@mousedown="onPressStart('out')" @mouseup="onPressEnd" @mouseleave="onPressEnd"
@touchstart.prevent="onPressStart('out')" @touchend="onPressEnd">
- 缩小
</button>
<button class="btn btn-secondary" @click="resetZoom">
⟳ 还原
</button>
<span class="hint">按住 Ctrl + 左键可拖动画布(仅放大后可用)</span>
</div>
<!-- 画布容器(Konva) -->
<div class="stage-wrapper" :style="{ width: `${canvasSize.width}px`, height: `${canvasSize.height}px` }">
<VStage
ref="stageRef"
:config="{ width: canvasSize.width, height: canvasSize.height }"
@wheel="onWheel"
@mousedown="onStageMouseDown"
@mousemove="onStageMouseMove"
@mouseup="onStageMouseUp"
@mouseleave="onStageMouseUp"
>
<VLayer>
<!-- 示例矩形:Ctrl 时禁用交互 -->
<VRect :config="{ ...rectConfig, draggable: !isCtrlPressing, listening: !isCtrlPressing }" />
<!-- 示例文字(不拖动,仅展示) -->
<VText :config="textConfig" />
</VLayer>
</VStage>
</div>
</div>
</template>
<style scoped>
.konva-demo-container {
position: relative;
display: flex;
align-items: flex-start;
justify-content: center;
min-height: 100vh;
background: linear-gradient(90deg, #f0f0f0 1px, transparent 1px), linear-gradient(#f0f0f0 1px, transparent 1px);
background-size: 20px 20px;
}
.toolbar {
position: absolute;
top: 16px;
left: 16px;
display: flex;
gap: 8px;
align-items: center;
z-index: 10;
}
.btn {
height: 32px;
padding: 0 12px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
user-select: none;
}
.btn-outline {
border: 1px solid #cbd5e1;
background: #ffffff;
}
.btn-outline:hover {
background: #f8fafc;
}
.btn-secondary {
color: #0f172a;
background: #e2e8f0;
border: 1px solid #cbd5e1;
}
.hint {
font-size: 12px;
color: #64748b;
margin-left: 8px;
}
.stage-wrapper {
margin-top: 64px;
border-radius: 8px;
box-shadow: 0 4px 24px rgba(2, 6, 23, 0.08);
background: #fff;
}
</style>
常见问题与优化建议
- 为什么缩小时会"跑左上角"?
- 未做"缩放 ≤ 1 的居中约束"会导致位置归零(左上角)。本文示例通过
clampStagePosition在缩小时居中。
- 未做"缩放 ≤ 1 的居中约束"会导致位置归零(左上角)。本文示例通过
- 为什么放大后拖动会被限制?
- 为避免出现空白区域,拖动范围会被限制在内容可视范围内。
- 如何让按钮也按"指针位置"缩放?
- 将
zoomIn/zoomOut的第二个参数换成stage.getPointerPosition()即可,但习惯上按钮用画布中心更稳定。
- 将
- 光标反馈(增强):
- 可在
Ctrl按下时用cursor: grab,平移过程中用cursor: grabbing增强手感。
- 可在
参考文档
- Konva 官方(Vue):konvajs.org/docs/vue/in...
- 指针相对缩放(官方示例):konvajs.org/docs/sandbo...
- vue-konva README(前缀 / 获取 Stage 引用):github.com/konvajs/vue...
- StackOverflow:通过
ref获取 Stage:stackoverflow.com/questions/5...