Archived
451 lines
14 KiB
Vue
451 lines
14 KiB
Vue
<template>
|
|
<div class="flex-1 min-w-[60%] bg-base-200 relative overflow-hidden diagram-container" ref="canvasContainer"
|
|
@mousedown="handleCanvasMouseDown"
|
|
@mousedown.middle.prevent="startMiddleDrag"
|
|
@wheel.prevent="onZoom">
|
|
<div
|
|
ref="canvas"
|
|
class="diagram-canvas"
|
|
:style="{ transform: `translate(${position.x}px, ${position.y}px) scale(${scale})` }"> <!-- 渲染画布上的组件 -->
|
|
<div v-for="component in props.components" :key="component.id"
|
|
class="component-wrapper"
|
|
:class="{
|
|
'component-hover': hoveredComponent === component.id,
|
|
'component-selected': selectedComponentId === component.id
|
|
}"
|
|
:style="{
|
|
top: component.y + 'px',
|
|
left: component.x + 'px',
|
|
zIndex: selectedComponentId === component.id ? 999 : 1
|
|
}"
|
|
@mousedown.left.stop="startComponentDrag($event, component)"
|
|
@mouseover="hoveredComponent = component.id"
|
|
@mouseleave="hoveredComponent = null"><!-- 动态渲染组件 -->
|
|
<component
|
|
:is="getComponentDefinition(component.type)"
|
|
v-if="props.componentModules[component.type]"
|
|
v-bind="prepareComponentProps(component.props || {})"
|
|
@update:bindKey="(value: string) => updateComponentProp(component.id, 'bindKey', value)"
|
|
:ref="el => { if (el) componentRefs[component.id] = el; }"
|
|
/>
|
|
|
|
<!-- Fallback if component module not loaded yet -->
|
|
<div v-else class="p-2 text-xs text-gray-400 border border-dashed border-gray-400">
|
|
Loading {{ component.type }}...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 缩放指示器 -->
|
|
<div class="absolute bottom-4 right-4 bg-base-100 px-3 py-1.5 rounded-md shadow-md z-20" style="opacity: 0.9;">
|
|
<span class="text-sm font-medium">{{ Math.round(scale * 100) }}%</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, reactive, onMounted, onUnmounted } from 'vue';
|
|
|
|
// 定义组件接受的属性
|
|
interface ComponentItem {
|
|
id: string;
|
|
type: string;
|
|
name: string;
|
|
x: number;
|
|
y: number;
|
|
props?: Record<string, any>;
|
|
}
|
|
|
|
const props = defineProps<{
|
|
components: ComponentItem[],
|
|
componentModules: Record<string, any>
|
|
}>();
|
|
|
|
// 定义组件发出的事件
|
|
const emit = defineEmits(['component-selected', 'component-moved', 'update-component-prop', 'component-delete']);
|
|
|
|
// --- 画布状态 ---
|
|
const canvasContainer = ref<HTMLElement | null>(null);
|
|
const canvas = ref<HTMLElement | null>(null);
|
|
const position = reactive({ x: 0, y: 0 });
|
|
const scale = ref(1);
|
|
const isDragging = ref(false);
|
|
const isMiddleDragging = ref(false);
|
|
const dragStart = reactive({ x: 0, y: 0 });
|
|
const selectedComponentId = ref<string | null>(null);
|
|
const hoveredComponent = ref<string | null>(null);
|
|
const draggingComponentId = ref<string | null>(null);
|
|
const componentDragOffset = reactive({ x: 0, y: 0 });
|
|
|
|
// 组件引用跟踪
|
|
const componentRefs = ref<Record<string, any>>({});
|
|
|
|
// --- 缩放功能 ---
|
|
const MIN_SCALE = 0.2;
|
|
const MAX_SCALE = 3.0;
|
|
|
|
function onZoom(e: WheelEvent) {
|
|
e.preventDefault();
|
|
|
|
if (!canvasContainer.value) return;
|
|
|
|
// 获取容器的位置
|
|
const containerRect = canvasContainer.value.getBoundingClientRect();
|
|
|
|
// 计算鼠标在容器内的相对位置
|
|
const mouseX = e.clientX - containerRect.left;
|
|
const mouseY = e.clientY - containerRect.top;
|
|
|
|
// 计算鼠标在画布坐标系中的位置
|
|
const mouseXCanvas = (mouseX - position.x) / scale.value;
|
|
const mouseYCanvas = (mouseY - position.y) / scale.value;
|
|
|
|
// 计算缩放值
|
|
const zoomFactor = 1.1; // 每次放大/缩小10%
|
|
const direction = e.deltaY > 0 ? -1 : 1;
|
|
|
|
// 计算新的缩放值
|
|
let newScale = direction > 0 ? scale.value * zoomFactor : scale.value / zoomFactor;
|
|
newScale = Math.max(MIN_SCALE, Math.min(newScale, MAX_SCALE));
|
|
|
|
// 计算新的位置,使鼠标指针位置在缩放前后保持不变
|
|
position.x = mouseX - mouseXCanvas * newScale;
|
|
position.y = mouseY - mouseYCanvas * newScale;
|
|
|
|
// 更新缩放值
|
|
scale.value = newScale;
|
|
}
|
|
|
|
// --- 动态组件渲染 ---
|
|
const getComponentDefinition = (type: string) => {
|
|
const module = props.componentModules[type];
|
|
if (!module) return null;
|
|
|
|
// 确保我们返回一个有效的组件定义
|
|
if (module.default) {
|
|
return module.default;
|
|
} else if (module.__esModule && module.default) {
|
|
// 有时 Vue 的动态导入会将默认导出包装在 __esModule 属性下
|
|
return module.default;
|
|
} else {
|
|
console.warn(`Module for ${type} found but default export is missing`, module);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// 准备组件属性,确保类型正确
|
|
function prepareComponentProps(props: Record<string, any>): Record<string, any> {
|
|
const result: Record<string, any> = {};
|
|
for (const key in props) {
|
|
let value = props[key];
|
|
// 只要不是 null/undefined 且不是 string,就强制转字符串
|
|
if (
|
|
(key === 'style' || key === 'direction' || key === 'type') &&
|
|
value != null &&
|
|
typeof value !== 'string'
|
|
) {
|
|
value = String(value);
|
|
}
|
|
result[key] = value;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// --- 画布交互逻辑 ---
|
|
function handleCanvasMouseDown(e: MouseEvent) {
|
|
// 如果是直接点击画布(而不是元器件),清除选中状态
|
|
if (e.target === canvasContainer.value || e.target === canvas.value) {
|
|
if (selectedComponentId.value !== null) {
|
|
selectedComponentId.value = null;
|
|
emit('component-selected', null);
|
|
}
|
|
}
|
|
|
|
// 左键拖拽画布逻辑
|
|
if (e.button === 0 && (e.target === canvasContainer.value || e.target === canvas.value)) {
|
|
startDrag(e);
|
|
}
|
|
}
|
|
|
|
// 左键拖拽画布
|
|
function startDrag(e: MouseEvent) {
|
|
if (e.button !== 0 || draggingComponentId.value) return;
|
|
|
|
isDragging.value = true;
|
|
isMiddleDragging.value = false;
|
|
dragStart.x = e.clientX - position.x;
|
|
dragStart.y = e.clientY - position.y;
|
|
|
|
document.addEventListener('mousemove', onDrag);
|
|
document.addEventListener('mouseup', stopDrag);
|
|
e.preventDefault();
|
|
}
|
|
|
|
// 中键拖拽画布
|
|
function startMiddleDrag(e: MouseEvent) {
|
|
if (e.button !== 1) return;
|
|
|
|
isMiddleDragging.value = true;
|
|
isDragging.value = false;
|
|
draggingComponentId.value = null;
|
|
|
|
dragStart.x = e.clientX - position.x;
|
|
dragStart.y = e.clientY - position.y;
|
|
|
|
document.addEventListener('mousemove', onDrag);
|
|
document.addEventListener('mouseup', stopDrag);
|
|
e.preventDefault();
|
|
}
|
|
|
|
// 拖拽画布过程
|
|
function onDrag(e: MouseEvent) {
|
|
if (!isDragging.value && !isMiddleDragging.value) return;
|
|
|
|
position.x = e.clientX - dragStart.x;
|
|
position.y = e.clientY - dragStart.y;
|
|
}
|
|
|
|
// 停止拖拽画布
|
|
function stopDrag() {
|
|
isDragging.value = false;
|
|
isMiddleDragging.value = false;
|
|
|
|
document.removeEventListener('mousemove', onDrag);
|
|
document.removeEventListener('mouseup', stopDrag);
|
|
}
|
|
|
|
// --- 组件拖拽交互 ---
|
|
function startComponentDrag(e: MouseEvent, component: ComponentItem) {
|
|
const target = e.target as HTMLElement;
|
|
|
|
// 检查点击的是否为交互元素 (如按钮、开关等)
|
|
const isInteractiveElement = (
|
|
target.tagName === 'rect' ||
|
|
target.tagName === 'circle' ||
|
|
target.tagName === 'path' ||
|
|
target.hasAttribute('fill-opacity') ||
|
|
(typeof target.className === 'string' &&
|
|
(target.className.includes('glow') || target.className.includes('interactive')))
|
|
);
|
|
|
|
// 仍然选中组件,无论是否为交互元素
|
|
if (selectedComponentId.value !== component.id) {
|
|
selectedComponentId.value = component.id;
|
|
emit('component-selected', component);
|
|
}
|
|
|
|
// 如果是交互元素,则不启动拖拽
|
|
if (isInteractiveElement || e.button !== 0) {
|
|
return;
|
|
}
|
|
|
|
// 阻止事件冒泡
|
|
e.stopPropagation();
|
|
|
|
// 设置拖拽状态
|
|
draggingComponentId.value = component.id;
|
|
isDragging.value = false;
|
|
isMiddleDragging.value = false;
|
|
|
|
// 获取容器位置
|
|
if (!canvasContainer.value) return;
|
|
const containerRect = canvasContainer.value.getBoundingClientRect();
|
|
|
|
// 计算鼠标在画布坐标系中的位置
|
|
const mouseX_canvas = (e.clientX - containerRect.left - position.x) / scale.value;
|
|
const mouseY_canvas = (e.clientY - containerRect.top - position.y) / scale.value;
|
|
|
|
// 计算鼠标相对于组件左上角的偏移量
|
|
componentDragOffset.x = mouseX_canvas - component.x;
|
|
componentDragOffset.y = mouseY_canvas - component.y;
|
|
|
|
// 添加全局监听器
|
|
document.addEventListener('mousemove', onComponentDrag);
|
|
document.addEventListener('mouseup', stopComponentDrag);
|
|
}
|
|
|
|
// 拖拽组件过程
|
|
function onComponentDrag(e: MouseEvent) {
|
|
if (!draggingComponentId.value || !canvasContainer.value) return;
|
|
|
|
// 防止触发组件内部的事件
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
|
|
const containerRect = canvasContainer.value.getBoundingClientRect();
|
|
|
|
// 计算鼠标在画布坐标系中的位置
|
|
const mouseX_canvas = (e.clientX - containerRect.left - position.x) / scale.value;
|
|
const mouseY_canvas = (e.clientY - containerRect.top - position.y) / scale.value;
|
|
|
|
// 计算组件新位置
|
|
const newX = mouseX_canvas - componentDragOffset.x;
|
|
const newY = mouseY_canvas - componentDragOffset.y;
|
|
|
|
// 通知父组件更新位置
|
|
emit('component-moved', {
|
|
id: draggingComponentId.value,
|
|
x: Math.round(newX),
|
|
y: Math.round(newY),
|
|
});
|
|
}
|
|
|
|
// 停止拖拽组件
|
|
function stopComponentDrag() {
|
|
draggingComponentId.value = null;
|
|
|
|
document.removeEventListener('mousemove', onComponentDrag);
|
|
document.removeEventListener('mouseup', stopComponentDrag);
|
|
}
|
|
|
|
// 更新组件属性
|
|
function updateComponentProp(componentId: string, propName: string, value: any) {
|
|
emit('update-component-prop', { id: componentId, propName, value });
|
|
}
|
|
|
|
// 获取组件引用,用于外部访问
|
|
function getComponentRef(componentId: string) {
|
|
const component = props.components.find(c => c.id === componentId);
|
|
if (!component) return null;
|
|
|
|
// 查找组件的引用
|
|
return componentRefs.value[component.id] || null;
|
|
}
|
|
|
|
// 暴露给父组件的方法
|
|
defineExpose({
|
|
getComponentRef
|
|
});
|
|
|
|
// --- 生命周期钩子 ---
|
|
onMounted(() => {
|
|
// 初始化中心位置
|
|
if (canvasContainer.value) {
|
|
position.x = canvasContainer.value.clientWidth / 2;
|
|
position.y = canvasContainer.value.clientHeight / 2;
|
|
}
|
|
if (canvasContainer.value) {
|
|
canvasContainer.value.addEventListener('wheel', onZoom);
|
|
}
|
|
// 添加键盘事件监听器
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
});
|
|
|
|
// 处理键盘事件
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
// 如果当前有选中的组件,并且按下了Delete键
|
|
if (selectedComponentId.value && (e.key === 'Delete' || e.key === 'Backspace')) {
|
|
// 触发删除元器件事件
|
|
emit('component-delete', selectedComponentId.value);
|
|
// 清除选中状态
|
|
selectedComponentId.value = null;
|
|
}
|
|
}
|
|
|
|
onUnmounted(() => {
|
|
// 清理事件监听器
|
|
document.removeEventListener('mousemove', onComponentDrag);
|
|
document.removeEventListener('mouseup', stopComponentDrag);
|
|
document.removeEventListener('mousemove', onDrag);
|
|
document.removeEventListener('mouseup', stopDrag);
|
|
if (canvasContainer.value) {
|
|
canvasContainer.value.removeEventListener('wheel', onZoom);
|
|
}
|
|
// 移除键盘事件监听器
|
|
window.removeEventListener('keydown', handleKeyDown);
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.diagram-container {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
background-image:
|
|
linear-gradient(to right, rgba(100, 100, 100, 0.1) 1px, transparent 1px),
|
|
linear-gradient(to bottom, rgba(100, 100, 100, 0.1) 1px, transparent 1px),
|
|
linear-gradient(to right, rgba(80, 80, 80, 0.2) 100px, transparent 100px),
|
|
linear-gradient(to bottom, rgba(80, 80, 80, 0.2) 100px, transparent 100px);
|
|
background-size: 20px 20px, 20px 20px, 100px 100px, 100px 100px;
|
|
background-position: 0 0;
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
-moz-user-select: none;
|
|
-ms-user-select: none;
|
|
}
|
|
|
|
.diagram-canvas {
|
|
position: relative;
|
|
width: 4000px;
|
|
height: 4000px;
|
|
transform-origin: 0 0;
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
-moz-user-select: none;
|
|
-ms-user-select: none;
|
|
}
|
|
|
|
/* 元器件容器样式 */
|
|
.component-wrapper {
|
|
position: absolute;
|
|
padding: 0; /* 移除内边距,确保元素大小与内容完全匹配 */
|
|
box-sizing: content-box; /* 使用content-box确保内容尺寸不受padding影响 */
|
|
display: inline-block;
|
|
overflow: visible; /* 允许内容溢出(用于显示边框) */
|
|
cursor: move; /* 显示移动光标 */
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
-moz-user-select: none;
|
|
-ms-user-select: none;
|
|
}
|
|
|
|
/* 悬停状态 - 使用outline而非伪元素 */
|
|
.component-hover {
|
|
outline: 2px dashed #3498db;
|
|
outline-offset: 2px;
|
|
z-index: 2;
|
|
}
|
|
|
|
/* 选中状态 - 使用outline而非伪元素 */
|
|
.component-selected {
|
|
outline: 3px dashed;
|
|
outline-color: #e74c3c #f39c12 #3498db #2ecc71;
|
|
outline-offset: 3px;
|
|
z-index: 999 !important; /* 使用更高的z-index确保始终在顶层 */
|
|
}
|
|
|
|
/* 为黑暗模式设置不同的网格线颜色 */
|
|
:root[data-theme="dark"] .diagram-container {
|
|
background-image:
|
|
linear-gradient(to right, rgba(200, 200, 200, 0.1) 1px, transparent 1px),
|
|
linear-gradient(to bottom, rgba(200, 200, 200, 0.1) 1px, transparent 1px),
|
|
linear-gradient(to right, rgba(180, 180, 180, 0.15) 100px, transparent 100px),
|
|
linear-gradient(to bottom, rgba(180, 180, 180, 0.15) 100px, transparent 100px);
|
|
}
|
|
|
|
/* 深度选择器 - 默认阻止SVG内部元素的鼠标事件,但允许SVG本身和特定交互元素 */
|
|
.component-wrapper :deep(svg) {
|
|
pointer-events: auto; /* 确保SVG本身可以接收鼠标事件用于拖拽 */
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
-moz-user-select: none;
|
|
-ms-user-select: none;
|
|
}
|
|
|
|
.component-wrapper :deep(svg *:not([class*="interactive"]):not(rect.glow):not(circle[fill-opacity]):not([fill-opacity])) {
|
|
pointer-events: none; /* 非交互元素不接收鼠标事件 */
|
|
}
|
|
|
|
/* 允许特定SVG元素接收鼠标事件,用于交互 */
|
|
.component-wrapper :deep(svg circle[fill-opacity]),
|
|
.component-wrapper :deep(svg rect[fill-opacity]),
|
|
.component-wrapper :deep(svg rect[class*="glow"]),
|
|
.component-wrapper :deep(svg rect.glow),
|
|
.component-wrapper :deep(svg [class*="interactive"]),
|
|
.component-wrapper :deep(button),
|
|
.component-wrapper :deep(input) {
|
|
pointer-events: auto !important;
|
|
}
|
|
</style>
|