切换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>