feat: 完成画布拖拽交互开发

This commit is contained in:
2026-08-15 18:00:49 +08:00
commit a043fd9d8e
38 changed files with 5206 additions and 0 deletions
+250
View File
@@ -0,0 +1,250 @@
<!-- 主画布 -->
<script setup lang="ts">
import { createNode, getMaterialComponent } from '@/materials'
import type { MaterialSchema } from '@/schema/materials'
import { useEditorStore } from '@/stores/editor'
import { storeToRefs } from 'pinia'
import Moveable, { type OnDrag, type OnResize } from 'vue3-moveable'
import { VueSelecto } from 'vue3-selecto'
import SketchRuler from 'vue3-sketch-ruler'
import 'vue3-sketch-ruler/lib/style.css'
defineOptions({
name: 'CanvasRoot',
})
const editorStore = useEditorStore()
const { nodes, selectedNodeIds, canvas } = storeToRefs(editorStore)
const moveableRef = useTemplateRef('moveableRef')
const stageRef = useTemplateRef('stageRef')
// 标尺厚度
const RULER_THICK = 20
// 缩放比例
const scale = ref(1)
// 标尺线条
const lines = ref({ h: [], v: [] })
const canvasWidth = toRef(canvas.value, 'width')
const canvasHeight = toRef(canvas.value, 'height')
/** 画布样式*/
const canvasStyle = computed(() => {
return {
width: canvasWidth.value,
height: canvasHeight.value,
backgroundColor: canvas.value.backgroundColor,
}
})
// canvas容器的ref
const wrapRef = ref<HTMLElement | null>(null)
// 外层视口(含标尺),只给 SketchRuler 用
const viewportSize = reactive({ width: 0, height: 0 })
// 当前选中的节点dom元素
const selectNodeTarget = shallowRef<HTMLElement[] | null>(null)
let resizeObserver: ResizeObserver | null = null
/** 捕获父级容器实际宽高,作为标尺视口 */
const updateCanvasSize = () => {
const el = wrapRef.value
if (!el) return
const { width, height } = el.getBoundingClientRect()
viewportSize.width = Math.floor(width)
viewportSize.height = Math.floor(height)
}
// 获取节点的样式
const getNodeStyle = (node: MaterialSchema, zIndex: number) => {
return {
width: `${node.layout.width}px`,
height: `${node.layout.height}px`,
left: `${node.layout.x}px`,
top: `${node.layout.y}px`,
zIndex: zIndex + 1,
}
}
// 标尺颜色主题
const palette = {
bgColor: '#1f2937',
longfgColor: '#6b7280',
fontColor: '#9ca3af',
fontShadowColor: '#0e8da7',
shadowColor: 'rgba(14, 141, 167, 0.14)',
lineColor: '#22c55e',
lineType: 'solid',
lockLineColor: '#4b5563',
borderColor: '#374151',
hoverBg: '#111827',
hoverColor: '#ffffff',
}
watch(
selectedNodeIds,
(newVal) => {
console.log('选中的节点id变化', newVal)
selectNodeTarget.value = newVal
.map((id) => stageRef.value?.querySelector<HTMLElement>(`[data-node-id="${id}"]`))
.filter((target): target is HTMLElement => target != null)
},
{ deep: true, flush: 'post' },
)
// 拖拽放置节点
const onDrop = (e: DragEvent) => {
const data = e.dataTransfer?.getData('schema')
if (data) {
const node = createNode(JSON.parse(data))
node.layout.x = e.offsetX - node.layout.width / 2
node.layout.y = e.offsetY - node.layout.height / 2
editorStore.addNode(node)
editorStore.selectNode(node.id)
}
}
// 选中节点
const onSelect = (node: MaterialSchema) => {
editorStore.selectNode(node.id)
// 处理第一次选中节点时,moveable组件没有正确获取到target导致无法移动问题
nextTick(() => {
moveableRef.value?.updateTarget()
})
}
const getNodeByTarget = (target: HTMLElement) => {
const nodeId = target.getAttribute('data-node-id')
if (!nodeId) return null
return editorStore.findNodeById(nodeId)
}
// 拖拽节点
const onDrag = (e: OnDrag) => {
console.log('拖拽事件', e)
e.target.style.setProperty('left', `${e.left}px`)
e.target.style.setProperty('top', `${e.top}px`)
const node = getNodeByTarget(e.target as HTMLElement)
console.log('拖拽节点', node)
if (!node) return
node.layout.x = e.left
node.layout.y = e.top
}
// 缩放节点
const onResize = (e: OnResize) => {
e.target.style.setProperty('width', `${e.width}px`)
e.target.style.setProperty('height', `${e.height}px`)
const node = getNodeByTarget(e.target as HTMLElement)
if (!node) return
node.layout.width = e.width
node.layout.height = e.height
// 处理左侧圆点的拖拽,改变节点位置
onDrag(e.drag)
}
// 清空选中节点
const onClearSelect = () => {
editorStore.clearSelect()
}
// 选中区域结束
const onSelectEnd = (e: any) => {
const ids = e.selected.map((el: HTMLElement) => el.getAttribute('data-node-id'))
editorStore.selectNodes(ids)
console.log('选中节点id', ids)
}
// 拖拽节点组
const onDragGroup = (e: any) => {
e.events.forEach(onDrag)
}
// 拖动节点组
const onResizeGroup = (e: any) => {
e.events.forEach(onResize)
}
// SketchRuler缩放变化
const onZoomChange = () => {
// 更新moveableRef的选框,防止缩放时选框位置错位
moveableRef.value?.updateRect()
}
onMounted(() => {
updateCanvasSize()
if (!wrapRef.value) return
resizeObserver = new ResizeObserver(updateCanvasSize)
resizeObserver.observe(wrapRef.value)
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
resizeObserver = null
})
</script>
<template>
<div ref="wrapRef" class="h-full w-full min-h-0 min-w-0 overflow-hidden">
<SketchRuler
v-if="viewportSize.width && viewportSize.height"
:palette="palette"
:thick="RULER_THICK"
:width="viewportSize.width"
:height="viewportSize.height"
:canvas-width="canvasStyle.width"
:canvas-height="canvasStyle.height"
:scale="scale"
:lines="lines"
@zoomchange="onZoomChange"
>
<div
ref="stageRef"
class="relative"
:style="{
width: `${canvasStyle.width}px`,
height: `${canvasStyle.height}px`,
backgroundColor: canvasStyle.backgroundColor,
}"
@dragover.prevent
@drop="onDrop"
@mousedown.self="onClearSelect"
>
<div
v-for="(node, index) in nodes"
:key="node.id"
:data-node-id="node.id"
class="canvas-node absolute"
:style="getNodeStyle(node, index)"
@mousedown="onSelect(node)"
>
<component :is="getMaterialComponent(node.type)" :schema="node" />
</div>
</div>
</SketchRuler>
<VueSelecto
v-if="stageRef"
:container="stageRef"
:dragContainer="stageRef"
:select-from-inside="false"
:selectableTargets="['.canvas-node']"
toggle-continue-select="shift"
@selectEnd="onSelectEnd"
/>
<Moveable
ref="moveableRef"
:target="selectNodeTarget"
:draggable="true"
:resizable="true"
:origin="false"
@drag="onDrag"
@drag-group="onDragGroup"
@resize="onResize"
@resize-group="onResizeGroup"
/>
</div>
</template>
<style scoped></style>