feat: remake most of forntend

This commit is contained in:
alivender
2025-04-26 19:59:35 +08:00
parent 4e741f9ef8
commit bc4f44ecaa
41 changed files with 84095 additions and 672 deletions

View File

@@ -1,370 +1,450 @@
<template>
<div class="flex-1 min-w-[60%] bg-base-200 relative overflow-auto" ref="canvasContainer"
@mousedown="handleCanvasMouseDown"
<div class="flex-1 min-w-[60%] bg-base-200 relative overflow-hidden diagram-container" ref="canvasContainer"
@mousedown="handleCanvasMouseDown"
@mousedown.middle.prevent="startMiddleDrag"
@mousemove="onDrag"
@mouseup="stopDrag"
@mouseleave="stopDrag"
@wheel="onZoom">
<div
ref="canvas"
class="diagram-canvas"
:style="{transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`}">
<!-- wokwi-elements FPGA开发板 -->
<wokwi-fpga-board class="absolute top-10 left-10" style="width:600px;height:400px;"></wokwi-fpga-board>
<!-- 放置其他元器件的区域 -->
<div v-for="component in components" :key="component.id"
class="absolute cursor-move component-wrapper"
@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': selectedComponent === component.id
'component-selected': selectedComponentId === component.id
}"
:style="{
top: component.y + 'px',
left: component.x + 'px',
zIndex: selectedComponentId === component.id ? 999 : 1
}"
:style="{top: component.y + 'px', left: component.x + 'px', width: 'auto', height: 'auto'}"
@mousedown.left.stop="startComponentDrag($event, component)"
@mouseover="hoveredComponent = component.id"
@mouseleave="hoveredComponent = null">
<component :is="component.type"></component>
@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-2 right-2 bg-base-100 px-2 py-1 rounded-md opacity-70">
{{ Math.round(scale * 100) }}%
<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">
// 引入wokwi-elements
import "@wokwi/elements";
import { ref, reactive, onMounted, watch } from 'vue';
import { ref, reactive, onMounted, onUnmounted } from 'vue';
// 定义组件接受的属性
const props = defineProps<{
initialComponents?: Array<ComponentItem>,
}>();
// 定义组件发出的事件
const emit = defineEmits(['component-selected', 'component-moved']);
// 定义组件接口
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 isMiddleDragging = ref(false);
const dragStart = reactive({ x: 0, y: 0 });
const canvas = ref(null);
const canvasContainer = ref(null);
// 元器件管理
const components = ref<ComponentItem[]>([]);
const draggingComponent = ref<ComponentItem | null>(null);
// 监听props变化更新本地组件数组
watch(() => props.initialComponents, (newComponents) => {
if (newComponents) {
// 通过创建深拷贝来断开引用连接
components.value = JSON.parse(JSON.stringify(newComponents));
}
}, { immediate: true, deep: true });
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 hoveredComponent = ref(null); // 鼠标悬停的元器件ID
const selectedComponent = ref(null); // 当前选中的元器件ID
// 画布拖拽
function startDrag(e) {
// 只处理左键拖拽
if (e.button !== 0) return;
// 组件引用跟踪
const componentRefs = ref<Record<string, any>>({});
// 确保其他拖拽状态被重置
isMiddleDragging.value = false;
isDragging.value = true;
dragStart.x = e.clientX - position.x;
dragStart.y = e.clientY - position.y;
// --- 缩放功能 ---
const MIN_SCALE = 0.2;
const MAX_SCALE = 3.0;
function onZoom(e: WheelEvent) {
e.preventDefault();
}
// 中键拖拽画布(无论点击到哪里)
function startMiddleDrag(e) {
// 确保是中键
if (e.button !== 1) return;
e.preventDefault();
e.stopPropagation();
if (!canvasContainer.value) return;
// 确保其他拖拽状态被重
isDragging.value = false;
// 获取容器的位
const containerRect = canvasContainer.value.getBoundingClientRect();
// 设置中键拖拽状态
isMiddleDragging.value = true;
// 计算鼠标在容器内的相对位置
const mouseX = e.clientX - containerRect.left;
const mouseY = e.clientY - containerRect.top;
// 记录起始位置
dragStart.x = e.clientX - position.x;
dragStart.y = e.clientY - position.y;
}
// 处理画布鼠标按下事件
function handleCanvasMouseDown(e) {
// 如果不是左键,则不做处理
if (e.button !== 0) return;
// 计算鼠标在画布坐标系中的位置
const mouseXCanvas = (mouseX - position.x) / scale.value;
const mouseYCanvas = (mouseY - position.y) / scale.value;
// 如果是直接点击画布(而不是元器件),清除选中状态
if (e.target === canvasContainer.value || e.target === canvas.value) {
selectedComponent.value = null;
emit('component-selected', null);
}
// 计算缩放值
const zoomFactor = 1.1; // 每次放大/缩小10%
const direction = e.deltaY > 0 ? -1 : 1;
// 继续处理拖拽
startDrag(e);
}
function onDrag(e) {
// 如果左键或中键拖拽正在进行中
if (isDragging.value || isMiddleDragging.value) {
// 防止拖拽过程中选中文本
e.preventDefault();
position.x = e.clientX - dragStart.x;
position.y = e.clientY - dragStart.y;
}
}
function stopDrag() {
isDragging.value = false;
isMiddleDragging.value = false;
}
// 画布缩放
function onZoom(e) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
const newScale = Math.max(0.3, Math.min(3, scale.value + delta));
// 计算新的缩放值
let newScale = direction > 0 ? scale.value * zoomFactor : scale.value / zoomFactor;
newScale = Math.max(MIN_SCALE, Math.min(newScale, MAX_SCALE));
// 保持鼠标位置不变的缩放
if (canvas.value && canvasContainer.value) {
const rect = canvasContainer.value.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// 计算鼠标在画布中的相对位置
const mouseXInCanvas = (mouseX - position.x) / scale.value;
const mouseYInCanvas = (mouseY - position.y) / scale.value;
// 调整位置以保持鼠标位置不变
position.x = mouseX - mouseXInCanvas * newScale;
position.y = mouseY - mouseYInCanvas * newScale;
}
// 计算新的位置,使鼠标指针位置在缩放前后保持不变
position.x = mouseX - mouseXCanvas * newScale;
position.y = mouseY - mouseYCanvas * newScale;
// 更新缩放值
scale.value = newScale;
}
// 元器件拖拽
function startComponentDrag(e, component) {
// 确保只处理左键拖拽元器件
if (e.button !== 0) return;
// --- 动态组件渲染 ---
const getComponentDefinition = (type: string) => {
const module = props.componentModules[type];
if (!module) return null;
e.stopPropagation();
draggingComponent.value = component;
// 设置选中元器件并通知父组件
selectedComponent.value = component.id;
emit('component-selected', component);
// 保存起始位置和鼠标位置
const initialX = component.x;
const initialY = component.y;
const startX = e.clientX;
const startY = e.clientY;
const mouseMoveHandler = (moveEvent) => {
if (!draggingComponent.value) return;
// 计算鼠标移动的距离(在屏幕坐标系中)
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
// 将移动距离转换为画布坐标系中的距离
const canvasDx = dx / scale.value;
const canvasDy = dy / scale.value;
// 更新组件位置(相对于初始位置的增量)
draggingComponent.value.x = initialX + canvasDx;
draggingComponent.value.y = initialY + canvasDy;
// 通知父组件元器件位置已变化
emit('component-moved', {
id: draggingComponent.value.id,
x: draggingComponent.value.x,
y: draggingComponent.value.y
});
};
const mouseUpHandler = () => {
draggingComponent.value = null;
// 保持选中状态,不清除 selectedComponent
window.removeEventListener('mousemove', mouseMoveHandler);
window.removeEventListener('mouseup', mouseUpHandler);
};
window.addEventListener('mousemove', mouseMoveHandler);
window.addEventListener('mouseup', mouseUpHandler);
}
// 自定义FPGA开发板组件未实现只是示例
customElements.define('wokwi-fpga-board', class extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
width: 600px;
height: 400px;
background-color: #2e7d32;
border: 8px solid #1b5e20;
border-radius: 10px;
position: relative;
}
.board-label {
position: absolute;
top: 20px;
left: 20px;
color: white;
font-family: Arial, sans-serif;
font-size: 18px;
font-weight: bold;
}
.board-components {
position: absolute;
top: 60px;
left: 40px;
right: 40px;
bottom: 40px;
background-color: #388e3c;
border-radius: 5px;
}
.pins {
position: absolute;
height: 10px;
display: flex;
gap: 10px;
}
.pins.top {
top: 0;
left: 80px;
right: 80px;
}
.pins.bottom {
bottom: 0;
left: 80px;
right: 80px;
}
.pin {
width: 10px;
height: 20px;
background-color: silver;
border-radius: 2px;
}
</style>
<div class="board-label">FPGA开发板</div>
<div class="board-components"></div>
<div class="pins top">
${Array(20).fill().map(() => '<div class="pin"></div>').join('')}
</div>
<div class="pins bottom">
${Array(20).fill().map(() => '<div class="pin"></div>').join('')}
</div>
`;
// 确保我们返回一个有效的组件定义
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 addComponent(component: ComponentItem) {
components.value.push(component);
// 准备组件属性,确保类型正确
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 resetCanvas() {
position.x = 0;
position.y = 0;
scale.value = 1;
// --- 画布交互逻辑 ---
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({
addComponent,
resetCanvas
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;
}
/* 禁用滚动条 */
.flex-1.min-w-\[60\%\].bg-base-200.relative.overflow-auto::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
.flex-1.min-w-\[60\%\].bg-base-200.relative.overflow-auto {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
/* 元器件容器样式 */
.component-wrapper {
position: relative;
padding: 5px;
box-sizing: border-box;
display: inline-block; /* 确保元素宽度基于内容 */
max-width: fit-content; /* 强制宽度适应内容 */
max-height: fit-content; /* 强制高度适应内容 */
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;
}
/* 悬停状态 */
.component-hover::before {
content: '';
position: absolute;
top: -4px;
left: -4px;
right: -4px;
bottom: -4px;
border: 3px dashed #3498db;
pointer-events: none;
z-index: 1;
border-radius: 4px;
box-sizing: content-box;
/* 悬停状态 - 使用outline而非伪元素 */
.component-hover {
outline: 2px dashed #3498db;
outline-offset: 2px;
z-index: 2;
}
/* 选中状态 */
.component-selected::before {
content: '';
position: absolute;
top: -4px;
left: -4px;
right: -4px;
bottom: -4px;
border: 4px dashed #e74c3c;
border-color: #e74c3c #f39c12 #3498db #2ecc71;
pointer-events: none;
z-index: 1;
border-radius: 4px;
box-sizing: content-box;
/* 选中状态 - 使用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>