Bootstrap

Konva.js绘图工具

切换operationType不同状态,使用不同工具
在这里插入图片描述
index.vue

 <KonvaCard ref="konvaCard" v-model:operationType="operationType" @getkonvaData="getkonvaData" />
 
// 获取konva数据
const getkonvaData = (data: any) => {
  console.log('getkonvaData', data)
}

KonvaCard.vue

<script lang="ts" setup>
import { propTypes } from '@/utils/propTypes'
import { ref, watch, nextTick } from 'vue'
import Konva from 'konva'

defineOptions({ name: 'KonvaCard' })
const message = useMessage() // 消息弹窗
const props = defineProps({
  operationType: propTypes.string.def('')
  // operationType:select、drag、pen、reactangle、text、copy、delete、hand
})
const emit = defineEmits(['update:operationType', 'saveData'])

const bgImageConfig = ref({})
const configStage = ref<object>({ width: 1200, height: 450, fill: 'red' }) // 画布配置
const linesArr = ref<object[]>([
  {
    points: [] as number[],
    closed: false,
    draggable: false // 是否被拖动
  }
]) // 配置 Polygon
// 配置虚线 Line(用于预览鼠标到最后一个节点的虚线)
const configTempLine = ref({
  points: [] as number[],
  stroke: '#1784FC',
  strokeWidth: 4,
  dash: [8, 3] // 虚线样式[长度, 间隔]
})
const showTempLine = ref(false) // 控制是否显示虚线
const points = ref<object[]>([]) // 存储多边形的顶点
const selectedShapeName = ref('') // 选中的图形名称
const rectangles = ref<any[]>([]) // 矩形数据列表
const stageRef = ref<any>() // 画布实例
const transformer = ref(null) // 变换器实例
const texts = ref<any[]>([]) // 文本数据列表
const textNode = ref<any>('') // 引用 text 节点
const isEditing = ref(false)
const editorPos = ref({ x: 0, y: 0 }) // 编辑器位置和宽度
const editorWidth = ref(0)
const editorHeight = ref(0)
const textContent = ref('双击编辑文字') // 当前文本内容

watch(
  () => props.operationType,
  (newVal, oldVal) => {
    if (newVal === 'pen' && oldVal !== newVal) {
      lineConfigInit()
    } else if (newVal === 'reactangle' && oldVal !== newVal) {
      addRectangle()
    } else if (newVal === 'drag' && oldVal !== newVal) {
      //拖拽默认显示第一个多边形的节点
      console.log('🚀 ~ nextTick ~ stageRef.value:', stageRef.value)
      const stage = stageRef.value?.getStage()
      const firstPenLineNode = stage.findOne('.penLine0')
      points.value = transformCoordinates(firstPenLineNode.points())
      // 禁用节点拖拽
      let rectAll = stage.find('Rect')
      let lineAll = stage.find('Line')
      rectAll.forEach((rect) => rect.draggable(false))
      lineAll.forEach((line) => line.draggable(false))
    } else if (newVal === 'text' && oldVal !== newVal) {
      addText()
    } else if (newVal === 'hand' && oldVal !== newVal) {
      selectInit()
    }
    if (newVal !== 'drag') {
      points.value = [] // 清空多边形顶点
    }
    if (newVal !== 'select') {
      const transformerNode = transformer.value?.getNode()
      transformerNode.nodes([])
    }
  }
)

watch(
  () => rectangles.value,
  (newVal, oldVal) => {
    console.log('🚀 ~ newVal:', newVal)
    if (newVal.length > 0) {
      // 为每个矩形设置 node 引用
      rectangles.value.forEach((rect, index) => {
        rect.node = rectangles.value[index]
      })
    }
  },
  { deep: true }
)

// 设置背景图片
const setBgImage = (url: string) => {
  let imageObj = new Image()
  imageObj.src = url
  imageObj.onload = () => {
    // width: 1200, height: 450
    bgImageConfig.value = {
      image: imageObj,
      x: configStage.value?.width / 2,
      y: configStage.value?.height / 2,
      offsetX: imageObj.width / 2,
      offsetY: imageObj.height / 2
    }
  }
}

// 处理鼠标点击事件(左键)
const onMouseDown = (event: any) => {
  if (event.evt.button !== 0) return // 如果未启用绘制,或不是左键点击则返回
  penMounseDown(event) // 钢笔状态下的鼠标点击
  selectedMouseDown(event) // 选择模式下的鼠标点击
  dragMouseDown(event) // 拖动模式下的鼠标点击
  rectMouseDown(event) // 矩形模式下的鼠标点击
  resteTransformer(event) // 清空选中
  copyShape(event) // 复制图形
  deleteShape(event) // 删除图形
}

// 处理鼠标移动事件
const onMouseMove = (event: any) => {
  if (points.value.length === 0) return // 如果未启用绘制或没有点则返回
  penMouseMove(event) // 钢笔状态下的鼠标移动
}

// 处理右键点击事件(闭合路径)
const onRightClick = (event: any) => {
  penRightClick(event) // 钢笔状态下的右键点击
}

// line-多边形 点击某个多边形 ()左键
const lineMouseDown = (event) => {
  if (event.evt.button !== 0) return
  // 拖拽模式下点击多边形
  if (props.operationType === 'drag') {
    selectedShapeName.value = event.target.name()
    const newPoints = event.target.points()
    points.value = transformCoordinates(newPoints)
    const transformerNode = transformer.value?.getNode()
    transformerNode.nodes([])
  }
}

/**
 * ------------------------钢笔相关函数------------------------
 */
// 钢笔状态下的鼠标点击(左键)
const penMounseDown = (event) => {
  if (props.operationType !== 'pen') return // 如果不是钢笔模式,则返回
  // 获取 canvas 的位置和大小
  const canvas = event.target.getStage().getContainer() // 获取 canvas 容器
  const canvasRect = canvas.getBoundingClientRect() // 获取 canvas 相对视口的位置
  // 计算鼠标点击的相对 canvas 坐标
  const x = event.evt.clientX - canvasRect.left // 相对于 canvas 左边界的 x 坐标
  const y = event.evt.clientY - canvasRect.top
  points.value.push({ x, y })
  if (!linesArr.value.length) {
    linesArr.value[0] = { points: [], closed: false, draggable: false }
    linesArr.value[0].points = points.value.flatMap((p) => [p.x, p.y])
  } else {
    const lastIndex = linesArr.value.length - 1
    linesArr.value[lastIndex].points = points.value.flatMap((p) => [p.x, p.y])
  }
  // 更新虚线的起点为当前点
  configTempLine.value.points = [x, y, x, y] // 初始时两个点重合
  showTempLine.value = true // 显示虚线
}

// 钢笔状态下的鼠标移动(更新虚线的终点)
const penMouseMove = (event) => {
  if (props.operationType !== 'pen') return // 如果不是钢笔模式,则返回
  // 获取 canvas 的位置和大小
  const canvas = event.target.getStage().getContainer()
  const canvasRect = canvas.getBoundingClientRect()
  // 计算鼠标移动的相对 canvas 坐标
  const x = event.evt.clientX - canvasRect.left
  const y = event.evt.clientY - canvasRect.top
  // 更新虚线的终点为鼠标当前位置
  const lastPoint = points.value[points.value.length - 1] // 获取最后一个点
  configTempLine.value.points = [lastPoint.x, lastPoint.y, x, y] // 从最后一个点到鼠标位置
}

// 钢笔状态下的右键点击(闭合路径)
const penRightClick = (event) => {
  if (props.operationType !== 'pen') return // 如果不是钢笔模式,则返回
  event.evt.preventDefault() // 阻止右键菜单弹出
  // 如果正在绘制,右键点击时闭合路径
  if (points.value.length > 2) {
    // 闭合路径:将第一个点与最后一个点连接
    const lastIndex = linesArr.value.length - 1
    linesArr.value[lastIndex].closed = true // 闭合路径
    points.value.push(points.value[0]) // 将第一个点与最后一个点连接
    linesArr.value[lastIndex].points = points.value.flatMap((p) => [p.x, p.y])
    linesArr.value.push({ points: [], closed: false, draggable: false })
    console.log('🚀 ~ penRightClick ~ linesArr.value:', linesArr.value)
    showTempLine.value = false // 隐藏虚线
    points.value = [] // 清空点集
  } else {
    showTempLine.value = false // 隐藏虚线
    points.value = []
  }
}

/**
 * ---------------------------选择模式相关函数----------------------------
 */
// 选择模式下的鼠标点击(左键)
const selectedMouseDown = (event) => {
  if (props.operationType !== 'select' && props.operationType !== 'reactangle') return // 如果不是选择模式,则返回
  // clicked on stage - clear selection
  if (event.target === event.target.getStage()) {
    selectedShapeName.value = ''
    updateTransformer()
    return
  }
  // clicked on transformer - do nothing
  const clickedOnTransformer = event.target.getParent().className === 'Transformer'
  if (clickedOnTransformer) {
    return
  }
  // find clicked rect by its name
  const name = event.target.name()
  selectedShapeName.value = name
  updateTransformer()
}

// 变换器更新绑定
const updateTransformer = () => {
  const transformerNode = transformer.value?.getNode()
  const stage = transformerNode.getStage()
  const selectedNode = stage.findOne('.' + selectedShapeName.value)
  selectedNode?.draggable(true) // 拖动
  // 如果选中的节点已经与 transformer 绑定,则无需做任何操作
  if (selectedNode === transformerNode.node()) {
    return
  }
  if (selectedNode) {
    // 如果选中了某个节点,则将 transformer 绑定到该节点
    transformerNode.nodes([selectedNode])
  } else {
    // remove transformer
    transformerNode.nodes([])
  }
}

// 多边形变换结束事件
const lineTransformEnd = (index, e) => {
  // 变换结束时更新图形的位置、大小、旋转、缩放
  linesArr.value[index].x = e.target.x()
  linesArr.value[index].y = e.target.y()
  linesArr.value[index].rotation = e.target.rotation()
  linesArr.value[index].scaleX = e.target.scaleX()
  linesArr.value[index].scaleY = e.target.scaleY()
}

/**
 * ------------------------矩形相关函数-----------------------------
 */
// 添加一个新的矩形,并设置其为选中状态
const addRectangle = () => {
  // 创建一个新的矩形
  const newRect = {
    config: {
      x: configStage.value?.width / 2,
      y: configStage.value?.height / 2,
      width: 200, // 默认宽度
      height: 100, // 默认高度
      draggable: true, // 设置为可拖拽,
      fill: '#52a5ff88',
      stroke: '#1784FC',
      strokeWidth: 4,
      lineCap: 'round',
      lineJoin: 'round',
      strokeScaleEnabled: false // 线条缩放
    },
    node: null as any // 用于存储该矩形的 Konva 节点
  }
  // 将矩形加入矩形数组
  rectangles.value.push(newRect)
  // 设置为选中的矩形
  selectedShapeName.value = `rect${rectangles.value.length - 1}`
  nextTick(() => {
    updateTransformer()
  })
  console.log('🚀 ~ addRectangle ~ selectedShapeName.value:', selectedShapeName.value)
  // selectedRectIndex.value = rectangles.value.length - 1
}

const rectMouseDown = (event) => {
  if (props.operationType !== 'reactangle') return // 如果不是矩形模式,则返回
  if (event.target === event.target.getStage()) {
    selectedShapeName.value = ''
    updateTransformer()
    emit('update:operationType', '') // 切换
    return
  }
}

// 矩形模式下的鼠标点击(左键)
const resteTransformer = (event) => {
  if (props.operationType === 'text' && isEditing.value) return // 清空变换器选择,除非是文本模式的编辑模式
  // 点击在 stage 之外,则清空选中矩形
  if (event.target === event.target.getStage()) {
    selectedShapeName.value = ''
    updateTransformer()
    return
  }
}

/**
 * ------------------------拖拽节点相关函数-------------------
 */
// 拖动线条的顶点
const onLineDragMove = (index: number, event: any) => {
  const lastPoint = points.value.length - 1 // 获取最后一个点
  if (index === 0 || index === lastPoint) {
    points.value[0] = { x: event.target.x(), y: event.target.y() }
    points.value[lastPoint] = { x: event.target.x(), y: event.target.y() }
  } else {
    points.value[index] = { x: event.target.x(), y: event.target.y() }
  }
  const stage = event.target.getStage()
  const selectedNode = stage.findOne('.' + selectedShapeName.value)
  const firstNode = stage.findOne('.penLine0')
  if (!selectedShapeName.value) {
    firstNode.points(points.value.flatMap((p) => [p.x, p.y]))
    // linesArr.value[0].points = points.value.flatMap((p) => [p.x, p.y])
  } else {
    const flatPoints = points.value.flatMap((p) => [p.x, p.y])
    selectedNode.points(flatPoints)
    selectedNode.getLayer()?.batchDraw()
  }
}

// 拖动模式下的鼠标点击(左键)
const dragMouseDown = (event) => {
  if (props.operationType !== 'drag') return // 如果不是拖拽模式,则返回
  if (event.target === event.target.getStage()) {
    points.value = [] // 清空点集
    return
  }
}

// 拖动矩形 == 选择变换
const rectDragDown = (event) => {
  if (props.operationType !== 'drag') return // 如果不是拖拽模式,则返回
  const name = event.target.name()
  selectedShapeName.value = name
  updateTransformer()
  points.value = [] // 清空多边形顶点点集
}

/**
 * --------------------------文本相关函数-----------------------------
 */
// 添加一个新的文本,并设置其为选中状态
const addText = () => {
  // 创建一个新的文本
  //
  const newText = { x: configStage.value?.width / 2, y: configStage.value?.height / 2, text: '双击编辑文字' }
  texts.value.push(newText)
  // 设置为选中的矩形
  selectedShapeName.value = `text${texts.value.length - 1}`
  nextTick(() => {
    updateTransformer()
  })
  // emit('update:operationType', '') // 切换

  console.log('🚀 ~ addRectangle ~ selectedShapeName.value:', selectedShapeName.value)
}

// 文本变换
const textTransform = (event) => {
  const stage = event.target.getStage()
  selectedShapeName.value = event.target.name()
  const selectedTextNode = stage.findOne('.' + selectedShapeName.value)
  const currentWidth = selectedTextNode.width()
  const currentHeight = selectedTextNode.height()
  const scaleX = selectedTextNode.scaleX()
  const scaleY = selectedTextNode.scaleY()
  selectedTextNode.setAttrs({
    width: currentWidth * scaleX, // 通过缩放比例调整宽度
    height: currentHeight * scaleY // 通过缩放比例调整高度
  })
  // selectedTextNode.setAttrs({
  //   width: Math.max(selectedTextNode.width() * selectedTextNode.scaleX(), 20),
  //   scaleX: 1,
  //   scaleY: 1
  // })
}

// 启动编辑模式
const dbClickText = (event: any) => {
  if (props.operationType !== 'text') return // 如果不是文本模式,则返回
  console.log('🚀 ~ dbClickText ~ event:', event)
  // const node = textNode.value
  // if (!node) return
  const stage = event.target.getStage()
  const name = event.target.name() // 获取当前文本的位置
  selectedShapeName.value = name
  const selectedNode = stage.findOne('.' + name)
  const position = selectedNode.getClientRect()
  // 显示可编辑的文本框并设置位置
  isEditing.value = true
  editorPos.value = { x: position.x, y: position.y }
  editorWidth.value = position.width
  editorHeight.value = position.height
  // 设置文本框内容
  textContent.value = selectedNode.text()
  // 聚焦到输入框
  nextTick(() => {
    const editor = document.querySelector('.editable-text') as HTMLElement
    editor.focus()
  })
}

// 输入框内容更新
const onTextInput = (event: Event) => {
  const target = event.target as HTMLDivElement
  textContent.value = target.innerText // 获取当前输入框的文本
}

// 停止编辑
const stopEditing = () => {
  const stage = stageRef.value?.getStage()
  const selectedNode = stage.findOne('.' + selectedShapeName.value)
  selectedNode.setAttr('text', textContent.value) // 更新 Konva Text 节点的内容
  isEditing.value = false
  emit('update:operationType', '') // 切换
}

/**
 * --------------------------复制相关函数-----------------------------
 */
// 复制图形
const copyShape = async (event) => {
  if (props.operationType !== 'copy' || event.target === event.target.getStage()) return // 如果不是复制模式,则返回
  await message.confirm('确定要复制当前图形吗?')
  const shape = event.target
  const confing = { x: shape.x() + 20, y: shape.y() + 20, name: '' }
  if (shape instanceof Konva.Rect) {
    confing.name = 'rect' + rectangles.value.length
  } else if (shape instanceof Konva.Line) {
    confing.name = 'line' + linesArr.value.length
  } else if (shape instanceof Konva.Text) {
    confing.name = 'text' + texts.value.length
  }
  const newShape = shape.clone() // 克隆选中的节点
  newShape.setAttrs(confing)
  shape.getLayer()?.add(newShape)
  shape.getLayer()?.batchDraw()
}

/**
 * --------------------------删除相关函数-----------------------------
 */
// 删除图形
const deleteShape = async (event) => {
  if (props.operationType !== 'delete' || event.target === event.target.getStage()) return // 如果不是复制模式,则返回
  await message.confirm('确定要删除当前图形吗?')
  const shape = event.target
  shape.destroy() // 删除选中的节点
  shape.getLayer()?.batchDraw()
  console.log('linesArr.value', linesArr.value)
  // shape.getLayer()?.add(newShape)
  // shape.getLayer()?.batchDraw()
}

/**
 * ---------------------------其他函数------------------------------
 */
// 多边形初始化
const lineConfigInit = () => {
  showTempLine.value = false
}

// 转化line的坐标
const transformCoordinates = (coords: number[]): { x: number; y: number }[] => {
  const result: { x: number; y: number }[] = []
  for (let i = 0; i < coords.length; i += 2) {
    result.push({ x: coords[i], y: coords[i + 1] })
  }
  return result
}

// 选择模式初始化
const selectInit = () => {
  selectedShapeName.value = ''
  updateTransformer()
}

const layerRef = ref<Konva.Layer>()
// 获取所有的节点信息
const getAllRectangles = () => {
  // 获取 Konva.Layer 实例
  const layer = layerRef.value?.getNode() // 获取 Konva.Layer 实例
  if (!layer) return []
  const rectsData = layer.find('Rect') // 获取所有节点
  console.log('🚀 ~ getAllRectangles ~ rectsData:', rectsData)
  const linesData = layer.find('Line') // 获取所有节点
  const textData = layer.find('Text') // 获取所有节点
  const rectsDataFilter = rectsData.filter((node: any) => node.name().includes('rect'))
  const linesDataFilter = linesData.filter((node: any) => node.points().length > 0)
  const textsDataFilter = textData.filter((node: any) => node.name)
  // const rectsData = allNodes.filter((node: any) => node instanceof Konva.Rect) // 获取所有矩形节点
  // const linesData = allNodes.filter((node: any) => node instanceof Konva.Line) // 获取所有线条节点
  // const textData = allNodes.filter((node: any) => node instanceof Konva.Line) // 获取所有文本节点
  const data = {
    rectsData: rectsDataFilter?.map((item) => item.attrs),
    linesData: linesDataFilter?.map((item) => item.attrs),
    textData: textsDataFilter?.map((item) => item.attrs)
  }
  emit('update:operationType', '')
  emit('saveData', data)

  console.log('🚀 ~ getAllRectangles ~ data:', data)
}

defineExpose({
  setBgImage,
  getAllRectangles
})

onMounted(() => {
  setBgImage('https://konvajs.org/assets/yoda.jpg')
})
</script>
<template>
  <h3>selectedShapeName.value: {{ selectedShapeName }}</h3>

  <div class="flex justify-center items-center">
    <div class="bg-#F1F1F1 position-relative">
      <v-stage
        ref="stageRef"
        :config="configStage"
        @mousedown="onMouseDown"
        @contextmenu="onRightClick"
        @mousemove="onMouseMove"
        @touchstart="onMouseDown"
      >
        <v-layer ref="layerRef">
          <v-image :config="bgImageConfig" />
          <v-line
            v-for="(lineItem, index) in linesArr"
            :key="index"
            :name="'line' + index"
            @mousedown="lineMouseDown"
            @transformend="lineTransformEnd(index, $event)"
            :config="{
              points: lineItem.points,
              fill: '#52a5ff88',
              stroke: '#1784FC',
              strokeWidth: 4,
              lineCap: 'round',
              lineJoin: 'round',
              closed: lineItem.closed,
              draggable: lineItem.draggable, // 是否被拖动
              strokeScaleEnabled: false // 线条缩放
            }"
          />
          <v-line v-if="showTempLine" :config="configTempLine" />
          <v-circle
            v-for="(point, index) in points"
            :key="index"
            :config="{
              x: point.x,
              y: point.y,
              radius: 6,
              fill: '#1784FC',
              stroke: 'white',
              strokeWidth: 3,
              draggable: true
            }"
            @dragmove="onLineDragMove(index, $event)"
          />
          <v-rect
            v-for="(rect, index) in rectangles"
            :key="index"
            :name="'rect' + index"
            :config="rect.config"
            @mousedown="rectDragDown"
            ref="rects"
          />
          <v-text
            ref="textNode"
            v-for="(text, index) in texts"
            :key="index"
            :name="'text' + index"
            :config="{
              x: text.x,
              y: text.y,
              text: text.text,
              fontSize: 18,
              fontFamily: 'Arial',
              fill: 'black',
              draggable: true
            }"
            @transform="textTransform"
            @dblclick="dbClickText"
          />
          <v-transformer :config="{ ignoreStroke: true, borderDash: [6, 3], keepRatio: false }" ref="transformer" />
        </v-layer>
      </v-stage>
      <div
        v-show="isEditing"
        ref="editor"
        contenteditable="true"
        class="editable-text"
        :style="{
          left: editorPos.x + 'px',
          top: editorPos.y + 'px',
          width: editorWidth + 'px',
          minHeight: editorHeight + 'px'
        }"
        @blur="stopEditing"
        @input="onTextInput"
      ></div>
    </div>
  </div>
</template>

<style scoped>
.editable-text {
  position: absolute;
  background-color: #fff;
  /* border: 1px solid #aaa; */
  font-family: Arial;
  font-size: 18px;
  padding: 2px;
  margin-top: -4px;
  outline: none;
  /* white-space: nowrap; */
  word-wrap: break-word;
}
</style>

;