FPGA_WebLab/src/components/DiagramCanvas.vue

1211 lines
39 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 h-full w-full bg-base-200 relative overflow-hidden diagram-container" ref="canvasContainer"
@mousedown="handleCanvasMouseDown"
@mousedown.middle.prevent="startMiddleDrag"
@wheel.prevent="onZoom"
@contextmenu.prevent="handleContextMenu">
<!-- 工具栏 -->
<div class="absolute top-2 right-2 flex gap-2 z-30">
<button class="btn btn-sm btn-primary" @click="openDiagramFileSelector">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 19a2 2 0 01-2-2V7a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1M5 19h14a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 01-2 2z" />
</svg>
导入
</button>
<button class="btn btn-sm btn-primary" @click="exportDiagram">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
导出
</button>
<button class="btn btn-sm btn-primary" @click="emit('open-components')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
添加组件
</button>
</div>
<!-- 隐藏的文件输入 -->
<input
type="file"
ref="fileInput"
class="hidden"
accept=".json"
@change="handleFileSelected"
/>
<div
ref="canvas"
class="diagram-canvas"
:style="{ transform: `translate(${position.x}px, ${position.y}px) scale(${scale})` }"> <!-- 渲染连线 -->
<svg class="wires-layer" width="10000" height="10000">
<!-- 已完成的连线 -->
<WireComponent
v-for="(wire, index) in wireItems"
:key="wire.id"
:id="wire.id"
:start-x="wire.startX"
:start-y="wire.startY"
:end-x="wire.endX"
:end-y="wire.endY"
:stroke-color="wire.color || '#4a5568'"
:stroke-width="wire.strokeWidth"
:is-active="false"
:start-component-id="wire.startComponentId"
:start-pin-id="wire.startPinId"
:end-component-id="wire.endComponentId"
:end-pin-id="wire.endPinId"
:routing-mode="wire.routingMode"
:path-commands="wire.pathCommands"
/>
<!-- 正在创建的连线 -->
<WireComponent
v-if="isCreatingWire"
id="temp-wire"
:start-x="creatingWireStart.x"
:start-y="creatingWireStart.y"
:end-x="mousePosition.x"
:end-y="mousePosition.y"
stroke-color="#3182ce"
:stroke-width="2"
:is-active="true"
/>
</svg>
<!-- 渲染画布上的组件 -->
<div v-for="component in diagramParts" :key="component.id"
class="component-wrapper" :class="{
'component-hover': hoveredComponent === component.id,
'component-selected': selectedComponentId === component.id,
'component-disabled': !component.isOn,
'component-hidepins': component.hidepins
}" :style="{
top: component.y + 'px',
left: component.x + 'px',
zIndex: component.index ?? 0,
transform: component.rotate ? `rotate(${component.rotate}deg)` : 'none',
opacity: component.isOn ? 1 : 0.6,
display: 'block'
}"
@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.attrs || {}, component.id)" @update:bindKey="(value: string) => updateComponentProp(component.id, 'bindKey', value)" @pin-click="(pinInfo: any) => handlePinClick(component.id, pinInfo, pinInfo.originalEvent)" :ref="(el: any) => setComponentRef(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 flex items-center justify-center">
<div class="flex flex-col items-center">
<div class="loading loading-spinner loading-xs mb-1"></div>
<span>Loading {{ component.type }}...</span>
</div>
</div>
</div>
</div>
<!-- 通知组件 -->
<div v-if="showNotification" class="toast toast-top toast-center z-50 w-fit-content">
<div :class="`alert ${notificationType === 'success' ? 'alert-success' :
notificationType === 'error' ? 'alert-error' :
'alert-info'}`">
<span>{{ notificationMessage }}</span>
</div>
</div> <!-- 加载指示器 -->
<div v-if="isLoading" class="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center z-50">
<div class="loading loading-spinner loading-lg text-primary"></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, computed, watch } from 'vue';
import WireComponent from './equipments/Wire.vue';
// 导入 diagram 管理器
import {
loadDiagramData, saveDiagramData,
updatePartPosition, updatePartAttribute,
deletePart, addConnection, deleteConnection,
findConnectionsByPart, moveGroupComponents,
parseConnectionPin, connectionArrayToWireItem,
validateDiagramData
} from './diagramManager';
import type { DiagramData, DiagramPart, ConnectionArray, WireItem } from './diagramManager';
// 右键菜单处理函数
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
}
// 定义组件发出的事件
const emit = defineEmits(['diagram-updated', 'component-selected', 'component-moved', 'component-delete', 'wire-created', 'wire-deleted', 'load-component-module', 'open-components']);
// 定义组件接受的属性
const props = defineProps<{
componentModules: Record<string, any>
}>();
// --- 画布状态 ---
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 });
// Diagram 数据
const diagramData = ref<DiagramData>({
version: 1,
author: 'admin',
editor: 'me',
parts: [],
connections: []
});
// 组件引用跟踪
const componentRefs = ref<Record<string, any>>({});
// 计算属性:从 diagramData 中提取组件列表并按index属性排序
const diagramParts = computed<DiagramPart[]>(() => {
// 克隆原始数组以避免直接修改原始数据
const parts = [...diagramData.value.parts];
// 按照index属性进行排序index值大的排在后面显示在上层
// 如果没有定义index则默认为0
return parts.sort((a, b) => {
const indexA = a.index ?? 0;
const indexB = b.index ?? 0;
return indexA - indexB;
});
});
// 计算属性:转换连接为 WireItem 列表以供渲染
const wireItems = computed<WireItem[]>(() => {
// 检查组件是否仍然挂载
if (!document.body.contains(canvasContainer.value)) {
return []; // 如果组件已经卸载,返回空数组
}
return diagramData.value.connections.map((conn, index) => {
const [startPin, endPin] = conn;
const { componentId: startCompId, pinId: startPinId } = parseConnectionPin(startPin);
const { componentId: endCompId, pinId: endPinId } = parseConnectionPin(endPin);
// 查找对应的组件位置
const startComp = diagramParts.value.find(p => p.id === startCompId);
const endComp = diagramParts.value.find(p => p.id === endCompId);
// 默认位置(如果找不到组件)
const startPos = { x: 0, y: 0 };
const endPos = { x: 0, y: 0 };
// 如果找到组件,设置连线端点位置
if (startComp) {
startPos.x = startComp.x;
startPos.y = startComp.y;
// 尝试获取引脚精确位置(如果有实现)
const startCompRef = componentRefs.value?.[startCompId];
if (startCompRef && typeof startCompRef.getPinPosition === 'function') {
try {
const pinPos = startCompRef.getPinPosition(startPinId);
console.log(`线路${index} - 起点引脚位置(来自${startCompId}):`, pinPos);
if (pinPos) {
// 正确合并组件位置与引脚相对位置
startPos.x = startComp.x + pinPos.x;
startPos.y = startComp.y + pinPos.y;
console.log(`线路${index} - 计算后的起点位置:`, startPos);
}
} catch (error) {
console.error(`获取引脚位置出错:`, error);
}
}
}
if (endComp) {
endPos.x = endComp.x;
endPos.y = endComp.y;
// 尝试获取引脚精确位置
const endCompRef = componentRefs.value?.[endCompId];
if (endCompRef && typeof endCompRef.getPinPosition === 'function') {
try {
const pinPos = endCompRef.getPinPosition(endPinId);
console.log(`线路${index} - 终点引脚位置(来自${endCompId}):`, pinPos);
if (pinPos) {
// 正确合并组件位置与引脚相对位置
endPos.x = endComp.x + pinPos.x;
endPos.y = endComp.y + pinPos.y;
console.log(`线路${index} - 计算后的终点位置:`, endPos);
}
} catch (error) {
console.error(`获取引脚位置出错:`, error);
}
}
}
return connectionArrayToWireItem(conn, index, startPos, endPos);
});
});
// 连线创建状态
const isCreatingWire = ref(false);
const creatingWireStart = reactive({ x: 0, y: 0 });
const creatingWireStartInfo = reactive({
componentId: '',
pinId: '',
constraint: ''
});
const mousePosition = reactive({ x: 0, y: 0 });
// 加载状态
const isLoading = ref(false);
// 通知状态
const showNotification = ref(false);
const notificationMessage = ref('');
const notificationType = ref<'success' | 'error' | 'info'>('info');
// 保存toast定时器ID
const toastTimers: number[] = [];
// 文件选择引用
const fileInput = ref<HTMLInputElement | null>(null);
// --- 缩放功能 ---
const MIN_SCALE = 0.2;
const MAX_SCALE = 10.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(attrs: Record<string, any>, componentId?: string): Record<string, any> {
const result: Record<string, any> = { ...attrs };
if (componentId) {
result.componentId = componentId;
}
return result;
}
// 设置组件引用
function setComponentRef(componentId: string, el: any) {
if (componentRefs.value) {
if (el) {
componentRefs.value[componentId] = el;
} else {
delete componentRefs.value[componentId];
}
}
}
// 重置组件引用缓存
function resetComponentRefs() {
componentRefs.value = {};
}
// 加载组件模块
async function loadComponentModule(type: string) {
if (!props.componentModules[type]) {
try {
// 通知父组件需要加载此类型的组件
emit('load-component-module', type);
} catch (error) {
console.error(`Failed to request component module ${type}:`, error);
}
}
}
// --- 画布交互逻辑 ---
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: DiagramPart) {
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 (component.positionlock || 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;
// 获取当前拖动的组件
const draggedComponent = diagramParts.value.find(p => p.id === draggingComponentId.value);
if (!draggedComponent) return;
// 更新组件位置
diagramData.value = updatePartPosition(diagramData.value, draggingComponentId.value, Math.round(newX), Math.round(newY));
// 如果组件属于组,移动组内所有其他组件
if (draggedComponent.group) {
const deltaX = Math.round(newX) - draggedComponent.x;
const deltaY = Math.round(newY) - draggedComponent.y;
// 找出属于同一组但不是当前拖动组件的其他组件
const groupComponents = diagramParts.value.filter(
p => p.group === draggedComponent.group && p.id !== draggingComponentId.value && !p.positionlock
);
// 更新这些组件的位置
for (const groupComp of groupComponents) {
diagramData.value = updatePartPosition(
diagramData.value,
groupComp.id,
groupComp.x + deltaX,
groupComp.y + deltaY
);
}
}
// 通知父组件位置已更新
emit('component-moved', {
id: draggingComponentId.value,
x: Math.round(newX),
y: Math.round(newY),
});
// 通知图表已更新
emit('diagram-updated', diagramData.value);
}
// 停止拖拽组件
function stopComponentDrag() {
// 如果有组件被拖拽,保存当前状态
if (draggingComponentId.value) {
console.log(`组件拖拽结束: ${draggingComponentId.value}`);
// 保存图表数据
saveDiagramData(diagramData.value);
// 清除拖动状态
draggingComponentId.value = null;
}
document.removeEventListener('mousemove', onComponentDrag);
document.removeEventListener('mouseup', stopComponentDrag);
}
// 更新组件属性
function updateComponentProp(componentId: string, propName: string, value: any) {
diagramData.value = updatePartAttribute(diagramData.value, componentId, propName, value);
emit('diagram-updated', diagramData.value);
saveDiagramData(diagramData.value);
}
// --- 连线操作 ---
// 更新鼠标位置
function updateMousePosition(e: MouseEvent) {
if (!canvasContainer.value) return;
const containerRect = canvasContainer.value.getBoundingClientRect();
mousePosition.x = (e.clientX - containerRect.left - position.x) / scale.value;
mousePosition.y = (e.clientY - containerRect.top - position.y) / scale.value;
} // 处理引脚点击
function handlePinClick(componentId: string, pinInfo: any, event: MouseEvent) {
if (!canvasContainer.value) return;
updateMousePosition(event);
if (!pinInfo || !pinInfo.label) {
console.error('无效的针脚信息:', pinInfo);
return;
}
// 获取引脚ID
const pinId = pinInfo.label;
console.log('----引脚点击详情开始----');
console.log('组件ID:', componentId);
console.log('引脚ID:', pinId);
console.log('引脚原始信息:', pinInfo);
console.log('鼠标位置:', mousePosition);
if (!isCreatingWire.value) {
// 开始创建连线
const containerRect = canvasContainer.value.getBoundingClientRect();
// 获取初始位置信息
let pinPosition = pinInfo.position;
console.log('Pin信息:', pinInfo);
console.log('Pin初始位置:', pinPosition);
console.log('组件ID:', componentId);
console.log('引脚ID:', pinId);
// 从组件引用中获取组件实例
const component = componentRefs.value[componentId];
console.log('组件引用:', component);
// 查找组件部件对象以获取组件位置
const componentPart = diagramParts.value.find(p => p.id === componentId);
if (!componentPart) {
console.error('找不到组件部件对象:', componentId);
}
// 重新设置引脚位置(初始化)
pinPosition = { x: 0, y: 0 };
// 如果组件实例存在且有 getPinPosition 方法
if (component && typeof component.getPinPosition === 'function' && componentPart) {
try {
console.log('尝试从组件获取引脚位置');
console.log('组件部件位置:', componentPart.x, componentPart.y);
const pinRelativePos = component.getPinPosition(pinId);
console.log('组件返回的引脚相对位置:', pinRelativePos);
if (pinRelativePos) {
// 计算引脚的绝对位置 = 组件位置 + 引脚相对位置
pinPosition = {
x: componentPart.x + pinRelativePos.x,
y: componentPart.y + pinRelativePos.y
};
console.log('计算的引脚绝对位置:', pinPosition);
} else {
// 如果没有找到引脚位置,使用组件位置
pinPosition = {
x: componentPart.x,
y: componentPart.y
};
console.log('使用组件位置:', pinPosition);
}
} catch (error) {
console.error(`获取引脚位置出错:`, error);
}
} else if (componentPart) {
// 如果组件不存在或没有 getPinPosition 方法,使用组件的位置
pinPosition = {
x: componentPart.x,
y: componentPart.y
};
console.log('使用组件位置:', pinPosition);
}
console.log('最终使用的引脚位置:', pinPosition);
// 使用最终的引脚位置作为连线起点
setWireCreationStart(pinPosition.x, pinPosition.y, componentId, pinId, pinInfo.constraint);
document.addEventListener('mousemove', onCreatingWireMouseMove);
} else {
// 完成连线创建
if (componentId === creatingWireStartInfo.componentId && pinId === creatingWireStartInfo.pinId) {
// 如果点击的是同一个引脚,取消连线创建
cancelWireCreation();
return;
}
// 获取终点引脚位置
let endPosition = { x: 0, y: 0 };
const componentPart = diagramParts.value.find(p => p.id === componentId);
const endComponent = componentRefs.value[componentId];
console.log('终点组件部件:', componentPart);
console.log('终点组件引用:', endComponent);
// 如果找到组件,设置终点位置
if (componentPart) {
endPosition.x = componentPart.x;
endPosition.y = componentPart.y;
// 如果组件实现了getPinPosition方法使用它
if (endComponent && typeof endComponent.getPinPosition === 'function') {
try {
const pinPos = endComponent.getPinPosition(pinId);
console.log('终点组件返回的引脚位置:', pinPos);
if (pinPos) {
// 正确合并组件位置与引脚相对位置
endPosition = {
x: componentPart.x + pinPos.x,
y: componentPart.y + pinPos.x
};
console.log('终点引脚位置(来自组件方法):', endPosition);
}
} catch (error) {
console.error(`获取终点引脚位置出错:`, error);
}
} else {
// 对于没有提供引脚精确位置的组件,使用组件位置
console.log('终点组件没有提供引脚位置方法,使用组件位置');
}
} else {
console.error('找不到终点组件部件对象:', componentId);
}
console.log('最终使用的终点位置:', endPosition);
console.log('线路从', creatingWireStartInfo, '到', { componentId, pinId });
console.log('----引脚点击详情结束----');
// 创建新的连线
const newConnection: ConnectionArray = [
`${creatingWireStartInfo.componentId}:${creatingWireStartInfo.pinId}`,
`${componentId}:${pinId}`,
2, // 线宽
["right10", "*", "left10"] // 默认路径
];
// 更新图表数据
diagramData.value = {
...diagramData.value,
connections: [...diagramData.value.connections, newConnection]
};
// 通知连线创建
emit('wire-created', newConnection);
emit('diagram-updated', diagramData.value);
// 保存图表数据
saveDiagramData(diagramData.value);
// 重置连线创建状态
resetWireCreation();
document.removeEventListener('mousemove', onCreatingWireMouseMove);
}
}
// 开始创建连线
function setWireCreationStart(x: number, y: number, componentId: string, pinId: string, constraint?: string) {
isCreatingWire.value = true;
creatingWireStart.x = x;
creatingWireStart.y = y;
creatingWireStartInfo.componentId = componentId;
creatingWireStartInfo.pinId = pinId;
creatingWireStartInfo.constraint = constraint || '';
}
// 重置连线创建状态
function resetWireCreation() {
isCreatingWire.value = false;
creatingWireStart.x = 0;
creatingWireStart.y = 0;
creatingWireStartInfo.componentId = '';
creatingWireStartInfo.pinId = '';
creatingWireStartInfo.constraint = '';
}
// 取消连线创建
function cancelWireCreation() {
resetWireCreation();
document.removeEventListener('mousemove', onCreatingWireMouseMove);
}
// 连线创建过程中的鼠标移动
function onCreatingWireMouseMove(e: MouseEvent) {
updateMousePosition(e);
}
// 删除连线
function deleteWire(wireIndex: number) {
diagramData.value = deleteConnection(diagramData.value, wireIndex);
emit('wire-deleted', wireIndex);
emit('diagram-updated', diagramData.value);
saveDiagramData(diagramData.value);
}
// 删除组件
function deleteComponent(componentId: string) {
diagramData.value = deletePart(diagramData.value, componentId);
emit('component-delete', componentId);
emit('diagram-updated', diagramData.value);
saveDiagramData(diagramData.value);
// 清除选中状态
if (selectedComponentId.value === componentId) {
selectedComponentId.value = null;
}
}
// --- 文件操作功能 ---
// 打开文件选择器
function openDiagramFileSelector() {
if (fileInput.value) {
fileInput.value.click();
}
}
// 处理文件选择
function handleFileSelected(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) {
return;
}
// 设置加载状态
isLoading.value = true;
const reader = new FileReader();
reader.onload = (e) => {
try {
// 检查组件是否仍然挂载
if (!document.body.contains(canvasContainer.value)) {
return; // 如果组件已经卸载,不执行后续操作
}
const content = e.target?.result as string;
const parsed = JSON.parse(content);
// 使用验证函数检查文件格式
const validation = validateDiagramData(parsed);
if (!validation.isValid) {
showToast(`不是有效的diagram.json格式: ${validation.errors.join('; ')}`, 'error');
isLoading.value = false;
return;
}
// 更新画布数据
diagramData.value = parsed as DiagramData;
// 保存到本地文件
saveDiagramData(diagramData.value);
// 发出更新事件
emit('diagram-updated', diagramData.value);
showToast(`成功导入diagram文件`, 'success');
} catch (error) {
console.error('解析JSON文件出错:', error);
if (document.body.contains(canvasContainer.value)) {
showToast('解析文件出错请确认是有效的JSON格式', 'error');
}
} finally {
// 检查组件是否仍然挂载
if (document.body.contains(canvasContainer.value)) {
// 结束加载状态
isLoading.value = false;
}
// 清除文件输入,以便同一文件可以再次导入
target.value = '';
}
};
reader.onerror = () => {
// 检查组件是否仍然挂载
if (document.body.contains(canvasContainer.value)) {
showToast('读取文件时出错', 'error');
isLoading.value = false;
}
// 清除文件输入
target.value = '';
};
reader.readAsText(file);
}
// 导出当前diagram数据
function exportDiagram() {
try {
isLoading.value = true;
// 创建一个Blob对象
const jsonString = JSON.stringify(diagramData.value, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
// 创建一个下载链接
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'diagram.json';
a.click();
// 释放URL对象
const timerId = setTimeout(() => {
URL.revokeObjectURL(url);
// 检查组件是否仍然挂载
if (document.body.contains(canvasContainer.value)) {
isLoading.value = false;
showToast('成功导出diagram文件', 'success');
}
}, 100);
// 将定时器ID保存起来以便在组件卸载时清除
toastTimers.push(timerId);
} catch (error) {
console.error('导出diagram文件时出错:', error);
showToast('导出diagram文件时出错', 'error');
isLoading.value = false;
}
}
// 显示通知
function showToast(message: string, type: 'success' | 'error' | 'info' = 'info', duration = 3000) {
notificationMessage.value = message;
notificationType.value = type;
showNotification.value = true;
// 保存定时器ID以便清除
const timerId = setTimeout(() => {
// 检查组件是否仍然挂载
if (document.body.contains(canvasContainer.value)) {
showNotification.value = false;
}
}, duration);
// 将定时器ID保存起来以便在组件卸载时清除
toastTimers.push(timerId);
}
// --- 生命周期钩子 ---
onMounted(async () => {
// 重置组件引用
resetComponentRefs();
// 加载图表数据
try {
diagramData.value = await loadDiagramData();
// 预加载所有组件模块
const componentTypes = new Set<string>();
diagramData.value.parts.forEach(part => {
componentTypes.add(part.type);
});
console.log('DiagramCanvas: Requesting component modules:', Array.from(componentTypes));
// 通知父组件需要加载组件模块
componentTypes.forEach(type => {
if (!props.componentModules[type]) {
emit('load-component-module', type);
}
});
} catch (error) {
console.error('加载图表数据失败:', error);
}
// 初始化中心位置
if (canvasContainer.value) {
// 修改为将画布中心点放在容器中心点
position.x = canvasContainer.value.clientWidth / 2 - 5000; // 画布宽度的一半
position.y = canvasContainer.value.clientHeight / 2 - 5000; // 画布高度的一半
}
// 添加键盘事件监听器
window.addEventListener('keydown', handleKeyDown);
});
// 处理键盘事件
function handleKeyDown(e: KeyboardEvent) {
// 如果当前有选中的组件并且按下了Delete键
if (selectedComponentId.value && (e.key === 'Delete')) {
// 触发删除组件事件
deleteComponent(selectedComponentId.value);
}
// 如果当前正在创建连线并且按下了ESC键
if (isCreatingWire.value && e.key === 'Escape') {
// 取消连线创建
cancelWireCreation();
}
}
onUnmounted(() => {
// 清理事件监听器
document.removeEventListener('mousemove', onComponentDrag);
document.removeEventListener('mouseup', stopComponentDrag);
document.removeEventListener('mousemove', onDrag);
document.removeEventListener('mouseup', stopDrag);
document.removeEventListener('mousemove', onCreatingWireMouseMove);
// 移除键盘事件监听器
window.removeEventListener('keydown', handleKeyDown);
// 清除所有toast定时器
toastTimers.forEach(timerId => clearTimeout(timerId));
});
// --- 对外API ---
// 获取当前图表数据
function getDiagramData() {
return diagramData.value;
}
// 设置图表数据
function setDiagramData(data: DiagramData) {
diagramData.value = data;
emit('diagram-updated', data);
}
// 无加载动画的数据更新方法
function updateDiagramDataDirectly(data: DiagramData) {
// 检查组件是否仍然挂载
if (!document.body.contains(canvasContainer.value)) {
return; // 如果组件已经卸载,不执行后续操作
}
diagramData.value = data;
saveDiagramData(data);
// 发出diagram-updated事件
emit('diagram-updated', data);
}
// 暴露方法给父组件
defineExpose({
// 基本数据操作
getDiagramData: () => diagramData.value,
updateDiagramDataDirectly,
setDiagramData: (data: DiagramData) => {
// 检查组件是否仍然挂载
if (!document.body.contains(canvasContainer.value)) {
return; // 如果组件已经卸载,不执行后续操作
}
isLoading.value = true;
// 使用requestAnimationFrame确保UI更新
window.requestAnimationFrame(() => {
// 再次检查组件是否仍然挂载
if (!document.body.contains(canvasContainer.value)) {
return; // 如果组件已经卸载,不执行后续操作
}
diagramData.value = data;
saveDiagramData(data);
// 发出diagram-updated事件
emit('diagram-updated', data);
// 短暂延迟后结束加载状态以便UI能更新
const timerId = setTimeout(() => {
// 检查组件是否仍然挂载
if (document.body.contains(canvasContainer.value)) {
isLoading.value = false;
}
}, 200);
// 将定时器ID保存起来以便在组件卸载时清除
toastTimers.push(timerId);
});
},
// 文件操作
openDiagramFileSelector,
exportDiagram,
// 组件操作
getSelectedComponent: () => {
if (!selectedComponentId.value) return null;
return diagramParts.value.find(p => p.id === selectedComponentId.value) || null;
},
deleteSelectedComponent: () => {
if (selectedComponentId.value) {
deleteComponent(selectedComponentId.value);
}
},
// 画布状态
getCanvasPosition: () => ({ x: position.x, y: position.y }),
getScale: () => scale.value,
// 通知系统
showToast
});
// 监视器 - 当图表数据更改时保存
watch(diagramData, (newData) => {
saveDiagramData(newData);
}, { deep: true });
// 当组件模块加载完成后,确保组件引用正确建立
watch(() => props.componentModules, () => {
// 这里不需要特别处理Vue 会自动通过 setComponentRef 函数更新引用
}, { deep: true });
</script>
<style scoped>
.diagram-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
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;
display: flex;
flex-direction: column;
}
.diagram-canvas {
position: relative;
width: 10000px;
height: 10000px;
transform-origin: 0 0;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
/* 连线层样式 */
.wires-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: auto; /* 修复:允许线被点击 */
z-index: 50;
overflow: visible; /* 确保超出SVG范围的内容也能显示 */
}
.wires-layer path {
pointer-events: stroke;
cursor: pointer;
}
/* 元器件容器样式 */
.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;
}
/* 禁用状态 */
.component-disabled {
cursor: not-allowed;
filter: grayscale(70%);
}
/* 隐藏引脚状态 */
.component-hidepins :deep([data-pin-wrapper]) {
display: none;
}
/* 为黑暗模式设置不同的网格线颜色 */
/* :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;
}
.wire-active {
stroke: #0099ff !important;
filter: drop-shadow(0 0 4px #0099ffcc);
}
</style>