Files
yys-editor/src/components/flow/FlowEditor.vue
rookie4show 4a4a55110b refactor: 重构属性编辑面板,支持Tab分离和节点类型切换
- 将属性面板分为游戏属性和图像属性两个Tab
- 游戏属性Tab包含节点基本信息、类型切换和特定属性
- 图像属性Tab包含所有样式设置(填充、描边、阴影等)
- 资产选择器节点支持在式神和御魂之间切换
- 切换节点类型时自动清空已选资产
- 优化AssetSelectorPanel,移除重复的资产库选择器
2026-02-14 21:50:51 +08:00

1078 lines
32 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="editor-layout" :style="{ height }">
<!-- 中间流程图区域 -->
<div class="flow-container" :class="{ 'snapline-disabled': !snaplineEnabled }">
<div class="flow-controls">
<div class="control-row toggles">
<label class="control-toggle">
<input type="checkbox" v-model="selectionEnabled" />
<span>框选</span>
</label>
<label class="control-toggle">
<input type="checkbox" v-model="snapGridEnabled" />
<span>吸附网格</span>
</label>
<label class="control-toggle">
<input type="checkbox" v-model="snaplineEnabled" />
<span>对齐线</span>
</label>
<span class="control-hint">已选 {{ selectedCount }}</span>
<button class="control-button" type="button" @click="showAllNodes">显示全部</button>
</div>
<div class="control-row">
<div class="control-label">对齐</div>
<div class="control-buttons">
<button
v-for="btn in alignmentButtons"
:key="btn.key"
class="control-button"
type="button"
:disabled="selectedCount < 2"
@click="() => alignSelected(btn.key)"
>
{{ btn.label }}
</button>
</div>
</div>
<div class="control-row">
<div class="control-label">分布</div>
<div class="control-buttons">
<button
v-for="btn in distributeButtons"
:key="btn.key"
class="control-button"
type="button"
:disabled="selectedCount < 3"
@click="() => distributeSelected(btn.key)"
>
{{ btn.label }}
</button>
</div>
</div>
</div>
<div class="container" ref="containerRef" :style="{ height: '100%' }"></div>
</div>
<!-- 右侧属性面板 -->
<PropertyPanel :height="height" :node="selectedNode" :lf="lf" />
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
import LogicFlow, { EventType } from '@logicflow/core';
import type { Position, NodeData, EdgeData, BaseNodeModel, GraphModel, GraphData } from '@logicflow/core';
import '@logicflow/core/lib/style/index.css';
import { Menu, Label, Snapshot, SelectionSelect, MiniMap, Control } from '@logicflow/extension';
import '@logicflow/extension/lib/style/index.css';
import '@logicflow/core/es/index.css';
import '@logicflow/extension/es/index.css';
import { translateEdgeData, translateNodeData } from '@logicflow/core/es/keyboard/shortcut';
import { register } from '@logicflow/vue-node-registry';
import ShikigamiSelectNode from './nodes/yys/ShikigamiSelectNode.vue';
import YuhunSelectNode from './nodes/yys/YuhunSelectNode.vue';
import PropertySelectNode from './nodes/yys/PropertySelectNode.vue';
import ImageNode from './nodes/common/ImageNode.vue';
import AssetSelectorNode from './nodes/common/AssetSelectorNode.vue';
// import TextNode from './nodes/common/TextNode.vue';
import PropertyPanel from './PropertyPanel.vue';
import { useGlobalMessage } from '@/ts/useGlobalMessage';
import { setLogicFlowInstance, destroyLogicFlowInstance } from '@/ts/useLogicFlow';
import { normalizePropertiesWithStyle, normalizeNodeStyle, styleEquals } from '@/ts/nodeStyle';
import { useCanvasSettings } from '@/ts/useCanvasSettings';
type AlignType = 'left' | 'right' | 'top' | 'bottom' | 'hcenter' | 'vcenter';
type DistributeType = 'horizontal' | 'vertical';
const MOVE_STEP = 2;
const MOVE_STEP_LARGE = 10;
const COPY_TRANSLATION = 40;
const props = defineProps<{
height?: string;
}>();
const containerRef = ref<HTMLElement | null>(null);
const lf = ref<LogicFlow | null>(null);
const selectedCount = ref(0);
const { selectionEnabled, snapGridEnabled, snaplineEnabled } = useCanvasSettings();
const alignmentButtons: { key: AlignType; label: string }[] = [
{ key: 'left', label: '左对齐' },
{ key: 'right', label: '右对齐' },
{ key: 'top', label: '上对齐' },
{ key: 'bottom', label: '下对齐' },
{ key: 'hcenter', label: '水平居中' },
{ key: 'vcenter', label: '垂直居中' }
];
const distributeButtons: { key: DistributeType; label: string }[] = [
{ key: 'horizontal', label: '水平等距' },
{ key: 'vertical', label: '垂直等距' }
];
const { showMessage } = useGlobalMessage();
// 当前选中节点
const selectedNode = ref<any>(null);
const copyBuffer = ref<GraphData | null>(null);
let nextPasteDistance = COPY_TRANSLATION;
function isInputLike(event?: KeyboardEvent) {
const target = event?.target as HTMLElement | null;
if (!target) return false;
const tag = target.tagName?.toLowerCase();
return ['input', 'textarea', 'select', 'option'].includes(tag) || target.isContentEditable;
}
function shouldSkipShortcut(event?: KeyboardEvent) {
const lfInstance = lf.value as any;
if (!lfInstance) return true;
if (lfInstance.keyboard?.disabled) return true;
if (lfInstance.graphModel?.textEditElement) return true;
if (isInputLike(event)) return true;
return false;
}
function ensureMeta(meta?: Record<string, any>) {
const next: Record<string, any> = meta ? { ...meta } : {};
if (next.visible == null) next.visible = true;
if (next.locked == null) next.locked = false;
return next;
}
function applyMetaToModel(model: BaseNodeModel, metaInput?: Record<string, any>) {
const lfInstance = lf.value;
const meta = ensureMeta(metaInput ?? (model.getProperties?.() as any)?.meta ?? (model as any)?.properties?.meta);
model.visible = meta.visible !== false;
model.draggable = !meta.locked;
model.setHittable?.(!meta.locked);
model.setHitable?.(!meta.locked);
model.setIsShowAnchor?.(!meta.locked);
model.setRotatable?.(!meta.locked);
model.setResizable?.(!meta.locked);
if (lfInstance) {
const connectedEdges = lfInstance.getNodeEdges(model.id);
connectedEdges.forEach((edgeModel) => {
edgeModel.visible = meta.visible !== false;
});
}
}
function applyStyleToModel(model: BaseNodeModel, styleInput?: Record<string, any>) {
const style = normalizeNodeStyle(styleInput, { width: model.width, height: model.height });
if (style.width && model.width !== style.width) {
model.width = style.width;
}
if (style.height && model.height !== style.height) {
model.height = style.height;
}
}
function normalizeNodeModel(model: BaseNodeModel) {
const lfInstance = lf.value;
if (!lfInstance) return;
const props = (model.getProperties?.() as any) ?? (model as any)?.properties ?? {};
const incomingMeta = ensureMeta(props.meta);
const normalized = normalizePropertiesWithStyle(
{ ...props, meta: incomingMeta },
{ width: props.width ?? model.width, height: props.height ?? model.height }
);
const currentMeta = ensureMeta((model as any)?.properties?.meta);
const metaChanged =
currentMeta.visible !== incomingMeta.visible ||
currentMeta.locked !== incomingMeta.locked ||
currentMeta.groupId !== incomingMeta.groupId;
const styleChanged =
!styleEquals(props.style, normalized.style) ||
props.width !== normalized.width ||
props.height !== normalized.height;
if (metaChanged || styleChanged) {
lfInstance.setProperties(model.id, normalized);
}
applyMetaToModel(model, normalized.meta);
applyStyleToModel(model, normalized.style);
}
function normalizeAllNodes() {
const lfInstance = lf.value;
if (!lfInstance) return;
lfInstance.graphModel?.nodes.forEach((model: BaseNodeModel) => normalizeNodeModel(model));
// 清除新节点标记
const allNodes = lfInstance.graphModel?.nodes || [];
allNodes.forEach(node => {
delete (node as any)._isNewNode;
});
}
function updateNodeMeta(model: BaseNodeModel, updater: (meta: Record<string, any>) => Record<string, any>) {
const lfInstance = lf.value;
if (!lfInstance) return;
const props = (model.getProperties?.() as any) ?? (model as any)?.properties ?? {};
const nextMeta = updater(ensureMeta(props.meta));
lfInstance.setProperties(model.id, { ...props, meta: nextMeta });
applyMetaToModel(model, nextMeta);
}
function getSelectedNodeModelsFiltered(options?: { includeHidden?: boolean; includeLocked?: boolean }) {
const includeHidden = options?.includeHidden ?? false;
const includeLocked = options?.includeLocked ?? false;
const graphModel = lf.value?.graphModel;
if (!graphModel) return [];
return graphModel.selectNodes.filter((model: BaseNodeModel) => {
const meta = ensureMeta((model.getProperties?.() as any)?.meta ?? (model as any)?.properties?.meta);
if (!includeHidden && meta.visible === false) return false;
if (!includeLocked && meta.locked) return false;
return true;
});
}
function getSelectedNodeModels() {
const graphModel = lf.value?.graphModel;
if (!graphModel) return [];
return [...graphModel.selectNodes];
}
function collectGroupNodeIds(models: BaseNodeModel[]) {
const graphModel = lf.value?.graphModel;
if (!graphModel) return [];
const ids = new Set<string>();
models.forEach((model) => {
const meta = ensureMeta((model.getProperties?.() as any)?.meta ?? (model as any)?.properties?.meta);
if (meta.locked) return;
if (meta.groupId) {
graphModel.nodes.forEach((node) => {
const peerMeta = ensureMeta((node.getProperties?.() as any)?.meta ?? (node as any)?.properties?.meta);
if (peerMeta.groupId === meta.groupId && !peerMeta.locked) {
ids.add(node.id);
}
});
} else {
ids.add(model.id);
}
});
return Array.from(ids);
}
function moveSelectedNodes(deltaX: number, deltaY: number) {
const graphModel = lf.value?.graphModel;
if (!graphModel) return;
const targets = collectGroupNodeIds(getSelectedNodeModelsFiltered());
if (!targets.length) return;
graphModel.moveNodes(targets, deltaX, deltaY);
}
// ========== 图层命令 ==========
function bringToFront(nodeId?: string) {
const lfInstance = lf.value;
if (!lfInstance) return;
const targetId = nodeId || selectedNode.value?.id;
if (!targetId) return;
// 诊断日志:查看所有节点的 zIndex
const allNodes = lfInstance.graphModel.nodes;
console.log('[置于顶层] 目标节点ID:', targetId);
console.log('[置于顶层] 所有节点的 zIndex:', allNodes.map(n => ({ id: n.id, zIndex: n.zIndex })));
lfInstance.setElementZIndex(targetId, 'top');
// 操作后再次查看
console.log('[置于顶层] 操作后所有节点的 zIndex:', allNodes.map(n => ({ id: n.id, zIndex: n.zIndex })));
}
function sendToBack(nodeId?: string) {
const lfInstance = lf.value;
if (!lfInstance) return;
const targetId = nodeId || selectedNode.value?.id;
if (!targetId) return;
const currentNode = lfInstance.getNodeModelById(targetId);
if (!currentNode) return;
const allNodes = lfInstance.graphModel.nodes;
console.log('[置于底层] 目标节点ID:', targetId);
console.log('[置于底层] 所有节点的 zIndex:', allNodes.map(n => ({ id: n.id, zIndex: n.zIndex })));
// 修复:找到所有节点中最小的 zIndex然后设置为比它更小
const allZIndexes = allNodes.map(n => n.zIndex).filter(z => z !== undefined);
const minZIndex = allZIndexes.length > 0 ? Math.min(...allZIndexes) : 1;
const newZIndex = minZIndex - 1;
currentNode.setZIndex(newZIndex);
// 操作后再次查看
console.log('[置于底层] 操作后所有节点的 zIndex:', allNodes.map(n => ({ id: n.id, zIndex: n.zIndex })));
}
function bringForward(nodeId?: string) {
const lfInstance = lf.value;
if (!lfInstance) return;
const targetId = nodeId || selectedNode.value?.id;
if (!targetId) return;
const currentNode = lfInstance.getNodeModelById(targetId);
if (!currentNode) return;
const currentZIndex = currentNode.zIndex;
currentNode.setZIndex(currentZIndex + 1);
}
function sendBackward(nodeId?: string) {
const lfInstance = lf.value;
if (!lfInstance) return;
const targetId = nodeId || selectedNode.value?.id;
if (!targetId) return;
const currentNode = lfInstance.getNodeModelById(targetId);
if (!currentNode) return;
const currentZIndex = currentNode.zIndex;
currentNode.setZIndex(currentZIndex - 1);
}
// ========== 删除操作 ==========
function deleteSelectedElements(event?: KeyboardEvent) {
if (shouldSkipShortcut(event)) return true;
const lfInstance = lf.value;
if (!lfInstance) return true;
const { edges } = lfInstance.getSelectElements(true);
const nodes = getSelectedNodeModelsFiltered({ includeHidden: false, includeLocked: true });
const lockedNodes = nodes.filter((node) => ensureMeta((node as any).properties?.meta).locked);
edges.forEach((edge) => edge.id && lfInstance.deleteEdge(edge.id));
nodes
.filter((node) => {
const meta = ensureMeta((node as any).properties?.meta);
return !meta.locked && meta.visible !== false;
})
.forEach((node) => node.id && lfInstance.deleteNode(node.id));
if (lockedNodes.length) {
showMessage('warning', '部分节点已锁定,未删除');
}
updateSelectedCount();
selectedNode.value = null;
return false;
}
function deleteNode(nodeId: string) {
const lfInstance = lf.value;
if (!lfInstance) return;
const node = lfInstance.getNodeModelById(nodeId);
if (!node) return;
const meta = ensureMeta((node as any).properties?.meta);
if (meta.locked) {
showMessage('warning', '节点已锁定,无法删除');
return;
}
lfInstance.deleteNode(nodeId);
}
function toggleLockSelected(event?: KeyboardEvent) {
if (shouldSkipShortcut(event)) return true;
const models = getSelectedNodeModels();
if (!models.length) {
showMessage('info', '请选择节点后再执行锁定/解锁');
return true;
}
const hasUnlocked = models.some((model) => !ensureMeta((model.getProperties?.() as any)?.meta).locked);
models.forEach((model) => {
updateNodeMeta(model, (meta) => ({ ...meta, locked: hasUnlocked ? true : false }));
});
return false;
}
function toggleVisibilitySelected(event?: KeyboardEvent) {
if (shouldSkipShortcut(event)) return true;
const models = getSelectedNodeModelsFiltered({ includeLocked: true });
if (!models.length) {
showMessage('info', '请选择节点后再执行显示/隐藏');
return true;
}
const hasVisible = models.some((model) => ensureMeta((model.getProperties?.() as any)?.meta).visible !== false);
models.forEach((model) => {
updateNodeMeta(model, (meta) => ({ ...meta, visible: !hasVisible ? true : false }));
});
if (hasVisible) {
selectedNode.value = null;
}
updateSelectedCount();
return false;
}
function showAllNodes() {
const lfInstance = lf.value;
if (!lfInstance) return;
let changed = 0;
lfInstance.graphModel?.nodes.forEach((model: BaseNodeModel) => {
const props = (model.getProperties?.() as any) ?? (model as any)?.properties ?? {};
const meta = ensureMeta(props.meta);
if (meta.visible === false) {
meta.visible = true;
lfInstance.setProperties(model.id, { ...props, meta });
applyMetaToModel(model, meta);
changed += 1;
}
});
if (changed > 0) {
showMessage('success', `已显示 ${changed} 个节点`);
} else {
showMessage('info', '没有隐藏的节点');
}
updateSelectedCount();
}
function groupSelectedNodes(event?: KeyboardEvent) {
if (shouldSkipShortcut(event)) return true;
const models = getSelectedNodeModelsFiltered();
if (models.length < 2) {
showMessage('warning', '请选择至少两个未锁定的节点进行分组');
return true;
}
const groupId = `group_${Date.now().toString(36)}`;
models.forEach((model) => {
updateNodeMeta(model, (meta) => ({ ...meta, groupId }));
});
return false;
}
function ungroupSelectedNodes(event?: KeyboardEvent) {
if (shouldSkipShortcut(event)) return true;
const models = getSelectedNodeModels();
if (!models.length) {
showMessage('info', '请选择节点后再执行解组');
return true;
}
models.forEach((model) => {
updateNodeMeta(model, (meta) => {
const nextMeta = { ...meta };
delete nextMeta.groupId;
return nextMeta;
});
});
return false;
}
function handleArrowMove(direction: 'left' | 'right' | 'up' | 'down', event?: KeyboardEvent) {
if (shouldSkipShortcut(event)) return true;
const step = (event?.shiftKey ? MOVE_STEP_LARGE : MOVE_STEP) * (direction === 'left' || direction === 'up' ? -1 : 1);
if (direction === 'left' || direction === 'right') {
moveSelectedNodes(step, 0);
} else {
moveSelectedNodes(0, step);
}
return false;
}
function remapGroupIds(nodes: GraphData['nodes']) {
const map = new Map<string, string>();
const seed = Date.now().toString(36);
nodes.forEach((node, index) => {
const meta = ensureMeta((node as any).properties?.meta);
if (meta.groupId) {
if (!map.has(meta.groupId)) {
map.set(meta.groupId, `group_${seed}_${index}`);
}
meta.groupId = map.get(meta.groupId);
}
(node as any).properties = { ...(node as any).properties, meta };
});
}
function handleCopy(event?: KeyboardEvent) {
if (shouldSkipShortcut(event)) return true;
const lfInstance = lf.value;
if (!lfInstance) return true;
const elements = lfInstance.getSelectElements(false);
if (!elements.nodes.length && !elements.edges.length) {
copyBuffer.value = null;
return true;
}
const nodes = elements.nodes.map((node) => translateNodeData(JSON.parse(JSON.stringify(node)), COPY_TRANSLATION));
const edges = elements.edges.map((edge) => translateEdgeData(JSON.parse(JSON.stringify(edge)), COPY_TRANSLATION));
remapGroupIds(nodes);
copyBuffer.value = { nodes, edges };
nextPasteDistance = COPY_TRANSLATION;
return false;
}
function handlePaste(event?: KeyboardEvent) {
if (shouldSkipShortcut(event)) return true;
const lfInstance = lf.value;
if (!lfInstance || !copyBuffer.value) return true;
lfInstance.clearSelectElements();
const added = lfInstance.addElements(copyBuffer.value, nextPasteDistance);
if (added) {
added.nodes.forEach((model) => {
normalizeNodeModel(model);
lfInstance.selectElementById(model.id, true);
});
added.edges.forEach((edge) => lfInstance.selectElementById(edge.id, true));
copyBuffer.value.nodes.forEach((node) => translateNodeData(node, COPY_TRANSLATION));
copyBuffer.value.edges.forEach((edge) => translateEdgeData(edge, COPY_TRANSLATION));
nextPasteDistance += COPY_TRANSLATION;
updateSelectedCount(lfInstance.graphModel);
}
return false;
}
function handleNodeDrag(args: { data: NodeData; deltaX: number; deltaY: number }) {
const { data, deltaX, deltaY } = args;
if (!deltaX && !deltaY) return;
const graphModel = lf.value?.graphModel;
if (!graphModel) return;
const model = graphModel.getNodeModelById(data.id);
if (!model) return;
const targets = collectGroupNodeIds([model]).filter((id) => id !== model.id);
if (!targets.length) return;
graphModel.moveNodes(targets, deltaX, deltaY);
}
function updateSelectedCount(model?: GraphModel) {
const lfInstance = lf.value;
const graphModel = model ?? lfInstance?.graphModel;
selectedCount.value = graphModel?.selectNodes.length ?? 0;
}
function applySelectionSelect(enabled: boolean) {
const lfInstance = lf.value as any;
if (!lfInstance) return;
if (enabled) {
lfInstance.openSelectionSelect?.();
} else {
lfInstance.closeSelectionSelect?.();
}
}
function applySnapGrid(enabled: boolean) {
const lfInstance = lf.value;
if (!lfInstance) return;
lfInstance.updateEditConfig({ snapGrid: enabled });
}
function getSelectedRects() {
const lfInstance = lf.value;
if (!lfInstance) return [];
const actionable = getSelectedNodeModelsFiltered();
return actionable.map((model: BaseNodeModel) => {
const bounds = model.getBounds();
const width = bounds.maxX - bounds.minX;
const height = bounds.maxY - bounds.minY;
return {
model,
bounds,
width,
height,
centerX: (bounds.maxX + bounds.minX) / 2,
centerY: (bounds.maxY + bounds.minY) / 2
};
});
}
function alignSelected(direction: AlignType) {
const rects = getSelectedRects();
if (rects.length < 2) {
showMessage('warning', '请选择至少两个节点再执行对齐');
return;
}
const minX = Math.min(...rects.map((item) => item.bounds.minX));
const maxX = Math.max(...rects.map((item) => item.bounds.maxX));
const minY = Math.min(...rects.map((item) => item.bounds.minY));
const maxY = Math.max(...rects.map((item) => item.bounds.maxY));
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
rects.forEach(({ model, width, height }) => {
let targetX = model.x;
let targetY = model.y;
switch (direction) {
case 'left':
targetX = minX + width / 2;
break;
case 'right':
targetX = maxX - width / 2;
break;
case 'hcenter':
targetX = centerX;
break;
case 'top':
targetY = minY + height / 2;
break;
case 'bottom':
targetY = maxY - height / 2;
break;
case 'vcenter':
targetY = centerY;
break;
}
model.moveTo(targetX, targetY);
});
}
function distributeSelected(type: DistributeType) {
const rects = getSelectedRects();
if (rects.length < 3) {
showMessage('warning', '请选择至少三个节点再执行分布');
return;
}
const sorted = [...rects].sort((a, b) =>
type === 'horizontal' ? a.bounds.minX - b.bounds.minX : a.bounds.minY - b.bounds.minY
);
const first = sorted[0];
const last = sorted[sorted.length - 1];
if (type === 'horizontal') {
const totalWidth = sorted.reduce((sum, item) => sum + item.width, 0);
const gap = (last.bounds.maxX - first.bounds.minX - totalWidth) / (sorted.length - 1);
let cursor = first.bounds.minX + first.width;
for (let i = 1; i < sorted.length - 1; i += 1) {
cursor += gap;
const item = sorted[i];
item.model.moveTo(cursor + item.width / 2, item.centerY);
cursor += item.width;
}
} else {
const totalHeight = sorted.reduce((sum, item) => sum + item.height, 0);
const gap = (last.bounds.maxY - first.bounds.minY - totalHeight) / (sorted.length - 1);
let cursor = first.bounds.minY + first.height;
for (let i = 1; i < sorted.length - 1; i += 1) {
cursor += gap;
const item = sorted[i];
item.model.moveTo(item.centerX, cursor + item.height / 2);
cursor += item.height;
}
}
}
// 注册自定义节点
function registerNodes(lfInstance: LogicFlow) {
register({ type: 'shikigamiSelect', component: ShikigamiSelectNode }, lfInstance);
register({ type: 'yuhunSelect', component: YuhunSelectNode }, lfInstance);
register({ type: 'propertySelect', component: PropertySelectNode }, lfInstance);
register({ type: 'imageNode', component: ImageNode }, lfInstance);
register({ type: 'assetSelector', component: AssetSelectorNode }, lfInstance);
// register({ type: 'textNode', component: TextNode }, lfInstance);
}
// 初始化 LogicFlow
onMounted(() => {
lf.value = new LogicFlow({
container: containerRef.value,
grid: { type: 'dot', size: 10 },
allowResize: true,
allowRotate: true,
overlapMode: -1,
snapline: snaplineEnabled.value,
keyboard: {
enabled: true
},
plugins: [Menu, Label, Snapshot, SelectionSelect, MiniMap, Control],
pluginsOptions: {
label: {
isMultiple: true,
maxCount: 3,
labelWidth: 80,
// textOverflowMode -> 'ellipsis' | 'wrap' | 'clip' | 'nowrap' | 'default'
textOverflowMode: 'wrap',
},
miniMap: {
isShowHeader: false,
isShowCloseIcon: true,
width: 200,
height: 140,
rightPosition: 16,
bottomPosition: 16
}
},
});
const lfInstance = lf.value;
if (!lfInstance) return;
lfInstance.keyboard.off(['cmd + c', 'ctrl + c']);
lfInstance.keyboard.off(['cmd + v', 'ctrl + v']);
lfInstance.keyboard.off(['backspace']);
const bindShortcut = (keys: string | string[], handler: (event?: KeyboardEvent) => boolean | void) => {
lfInstance.keyboard.on(keys, (event: KeyboardEvent) => handler(event));
};
bindShortcut(['del', 'backspace'], deleteSelectedElements);
bindShortcut(['left'], (event) => handleArrowMove('left', event));
bindShortcut(['right'], (event) => handleArrowMove('right', event));
bindShortcut(['up'], (event) => handleArrowMove('up', event));
bindShortcut(['down'], (event) => handleArrowMove('down', event));
bindShortcut(['cmd + c', 'ctrl + c'], handleCopy);
bindShortcut(['cmd + v', 'ctrl + v'], handlePaste);
bindShortcut(['cmd + g', 'ctrl + g'], groupSelectedNodes);
bindShortcut(['cmd + u', 'ctrl + u'], ungroupSelectedNodes);
bindShortcut(['cmd + l', 'ctrl + l'], toggleLockSelected);
bindShortcut(['cmd + shift + h', 'ctrl + shift + h'], toggleVisibilitySelected);
lfInstance.extension.menu.addMenuConfig({
nodeMenu: [
{
text: '置于顶层',
callback(node: NodeData) {
bringToFront(node.id);
}
},
{
text: '上移一层',
callback(node: NodeData) {
bringForward(node.id);
}
},
{
text: '下移一层',
callback(node: NodeData) {
sendBackward(node.id);
}
},
{
text: '置于底层',
callback(node: NodeData) {
sendToBack(node.id);
}
},
{
text: '---' // 分隔线
},
{
text: '复制 (Ctrl+C)',
callback() {
handleCopy();
}
},
{
text: '粘贴 (Ctrl+V)',
callback() {
handlePaste();
}
},
{
text: '---' // 分隔线
},
{
text: '组合 (Ctrl+G)',
callback() {
groupSelectedNodes();
}
},
{
text: '解组 (Ctrl+U)',
callback() {
ungroupSelectedNodes();
}
},
{
text: '---' // 分隔线
},
{
text: '锁定/解锁 (Ctrl+L)',
callback() {
toggleLockSelected();
}
},
{
text: '显示/隐藏 (Ctrl+Shift+H)',
callback() {
toggleVisibilitySelected();
}
},
{
text: '---' // 分隔线
},
{
text: '删除节点 (Del)',
callback(node: NodeData) {
deleteNode(node.id);
}
}
],
edgeMenu: [
{
text: '删除边',
callback(edge: EdgeData) {
lfInstance.deleteEdge(edge.id);
}
}
],
graphMenu: [
{
text: '添加节点',
callback(data: Position) {
lfInstance.addNode({
type: 'rect',
x: data.x,
y: data.y
});
}
},
{
text: '粘贴 (Ctrl+V)',
callback(data: Position) {
handlePaste();
}
}
]
});
// 配置多选时的右键菜单(选区菜单)
lfInstance.extension.menu.setMenuByType({
type: 'lf:defaultSelectionMenu',
menu: [
{
text: '复制 (Ctrl+C)',
callback() {
handleCopy();
}
},
{
text: '粘贴 (Ctrl+V)',
callback() {
handlePaste();
}
},
{
text: '---' // 分隔线
},
{
text: '组合 (Ctrl+G)',
callback() {
groupSelectedNodes();
}
},
{
text: '解组 (Ctrl+U)',
callback() {
ungroupSelectedNodes();
}
},
{
text: '---' // 分隔线
},
{
text: '锁定/解锁 (Ctrl+L)',
callback() {
toggleLockSelected();
}
},
{
text: '显示/隐藏 (Ctrl+Shift+H)',
callback() {
toggleVisibilitySelected();
}
},
{
text: '---' // 分隔线
},
{
text: '删除选中 (Del)',
callback() {
deleteSelectedElements();
}
}
]
});
registerNodes(lfInstance);
setLogicFlowInstance(lfInstance);
// 监听所有可能的节点添加事件
lfInstance.on(EventType.NODE_ADD, ({ data }) => {
const model = lfInstance.getNodeModelById(data.id);
if (model) {
normalizeNodeModel(model);
// 设置新节点的 zIndex 为 1000
model.setZIndex(1000);
// 标记这个节点是新创建的,避免被 normalizeAllNodes 重置
(model as any)._isNewNode = true;
}
});
// 监听 DND 添加节点事件
lfInstance.on('node:dnd-add', ({ data }) => {
const model = lfInstance.getNodeModelById(data.id);
if (model) {
// 设置新节点的 zIndex 为 1000
model.setZIndex(1000);
// 标记这个节点是新创建的
(model as any)._isNewNode = true;
}
});
lfInstance.on(EventType.GRAPH_RENDERED, () => {
normalizeAllNodes();
});
// 监听节点点击事件,更新选中节点
lfInstance.on(EventType.NODE_CLICK, ({ data }) => {
selectedNode.value = data;
});
// 监听空白点击事件,取消选中
lfInstance.on(EventType.BLANK_CLICK, () => {
selectedNode.value = null;
updateSelectedCount();
});
// 节点属性改变,如果当前节点是选中节点,则同步更新 selectedNode
lfInstance.on(EventType.NODE_PROPERTIES_CHANGE, (data) => {
const nodeId = data.id;
if (selectedNode.value && nodeId === selectedNode.value.id) {
if (data.properties) {
selectedNode.value = {
...selectedNode.value,
properties: data.properties
};
}
}
const model = lfInstance.getNodeModelById(nodeId);
if (model) normalizeNodeModel(model);
});
lfInstance.on('selection:selected', () => updateSelectedCount());
lfInstance.on('selection:drop', () => updateSelectedCount());
});
watch(selectionEnabled, (enabled) => {
applySelectionSelect(enabled);
});
watch(snapGridEnabled, (enabled) => {
applySnapGrid(enabled);
});
watch(snaplineEnabled, (enabled) => {
const lfInstance = lf.value as any;
if (!lfInstance) return;
if (!enabled) {
lfInstance.snaplineModel?.clearSnapline?.();
}
});
// 销毁 LogicFlow
onBeforeUnmount(() => {
lf.value?.destroy();
lf.value = null;
destroyLogicFlowInstance();
});
</script>
<style scoped>
.editor-layout {
display: flex;
height: 100%;
}
.flow-container {
flex: 1;
position: relative;
overflow: hidden;
}
.container {
width: 100%;
min-height: 300px;
background: #fff;
height: 100%;
}
.flow-controls {
position: absolute;
top: 10px;
left: 10px;
z-index: 10;
background: rgba(255, 255, 255, 0.95);
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 10px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
max-width: 460px;
font-size: 12px;
}
.control-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 6px;
}
.control-row:last-child {
margin-bottom: 0;
}
.control-label {
font-weight: 600;
color: #303133;
}
.control-buttons {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.control-button {
border: 1px solid #dcdfe6;
background: #fff;
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
color: #303133;
}
.control-button:hover:enabled {
background: #f5f7fa;
}
.control-button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.control-toggle {
display: flex;
align-items: center;
gap: 4px;
color: #606266;
}
.control-hint {
color: #909399;
}
.context-menu {
position: fixed;
background: white;
border: 1px solid #dcdfe6;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
padding: 5px 0;
z-index: 9999;
min-width: 120px;
user-select: none;
}
.menu-item {
padding: 8px 16px;
cursor: pointer;
font-size: 14px;
color: #606266;
white-space: nowrap;
}
.menu-item:hover {
background-color: #f5f7fa;
color: #409eff;
}
.snapline-disabled .lf-snapline {
display: none;
}
</style>