Vue-Konva 使用(缩放 / 还原 / 拖动) 示例

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:Konva Stage 的 Vue 封装,是画布根容器。
    • 事件:@wheel(滚轮缩放)、@mousedown/@mousemove/@mouseup/@mouseleave(平移手势)
  • VLayer:Konva Layer 的 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 在缩小时居中。
  • 为什么放大后拖动会被限制?
    • 为避免出现空白区域,拖动范围会被限制在内容可视范围内。
  • 如何让按钮也按"指针位置"缩放?
    • zoomIn/zoomOut 的第二个参数换成 stage.getPointerPosition() 即可,但习惯上按钮用画布中心更稳定。
  • 光标反馈(增强):
    • 可在 Ctrl 按下时用 cursor: grab,平移过程中用 cursor: grabbing 增强手感。

参考文档

相关推荐
renxhui1 小时前
Flutter 布局 ↔ Android XML 布局 对照表(含常用属性)
前端
俺叫啥好嘞1 小时前
日志输出配置
java·服务器·前端
一 乐2 小时前
运动会|基于SpingBoot+vue的高校体育运动会管理系统(源码+数据库+文档)
java·前端·javascript·数据库·vue.js·学习·springboot
7***n752 小时前
JavaScript混合现实案例
开发语言·javascript·mr
X_hope2 小时前
巧妙浏览器事件监听API:addEventListener的第三个参数
前端·javascript
極光未晚2 小时前
Node.js的"老伙计":Express框架入门记
前端·node.js
1***Q7842 小时前
TypeScript类型兼容
前端·javascript·typescript
多啦C梦a2 小时前
React useTransition 全网最通俗深度讲解:为什么它能让页面“不卡”?
前端·javascript·react.js
inCBle2 小时前
vue3+ts 封装一个通用流程复用工具函数
前端·vue.js·设计