FPGA_WebLab/src/components/DiagramCanvas.vue

451 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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