fix(flow): support dynamic-group palette and restore framework clipboard

This commit is contained in:
2026-02-27 17:38:08 +08:00
parent a20c1a99bf
commit 15386795cb
5 changed files with 110 additions and 101 deletions

View File

@@ -180,6 +180,7 @@ watch(
<div id="main-container" :style="{ height: contentHeight, overflow: 'auto' }">
<FlowEditor
:height="contentHeight"
:enable-label="false"
/>
</div>
</div>

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { ref } from 'vue';
import { getLogicFlowInstance } from '@/ts/useLogicFlow';
// 使用嵌套结构定义组件分组
@@ -30,6 +29,26 @@ const componentGroups = [
style: { background: '#fff', border: '2px solid black', borderRadius: '50%' }
}
},
{
id: 'dynamic-group',
name: '动态分组',
type: 'dynamic-group',
description: '可折叠的动态分组容器',
data: {
children: [],
collapsible: true,
isCollapsed: false,
width: 420,
height: 250,
collapsedWidth: 100,
collapsedHeight: 60,
radius: 6,
isRestrict: false,
autoResize: false,
transformWithContainer: false,
autoToFront: true
}
},
{
id: 'image',
name: '图片',

View File

@@ -77,11 +77,10 @@ import { ref, watch, onMounted, onBeforeUnmount, nextTick } 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 { Menu, Label, Snapshot, SelectionSelect, MiniMap, Control, DynamicGroup } 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 PropertySelectNode from './nodes/yys/PropertySelectNode.vue';
@@ -104,7 +103,6 @@ type DistributeType = 'horizontal' | 'vertical';
const MOVE_STEP = 2;
const MOVE_STEP_LARGE = 10;
const COPY_TRANSLATION = 40;
const RIGHT_MOUSE_BUTTON = 2;
const RIGHT_DRAG_THRESHOLD = 2;
const RIGHT_DRAG_CONTEXTMENU_SUPPRESS_MS = 300;
@@ -113,7 +111,7 @@ const props = withDefaults(defineProps<{
height?: string;
enableLabel?: boolean;
}>(), {
enableLabel: true
enableLabel: false
});
const flowHostRef = ref<HTMLElement | null>(null);
@@ -137,10 +135,8 @@ const { showMessage } = useGlobalMessage();
// 当前选中节点
const selectedNode = ref<any>(null);
const copyBuffer = ref<GraphData | null>(null);
const groupRuleWarnings = ref<GroupRuleWarning[]>([]);
const flowControlsCollapsed = ref(true);
let nextPasteDistance = COPY_TRANSLATION;
let containerResizeObserver: ResizeObserver | null = null;
let groupRuleValidationTimer: ReturnType<typeof setTimeout> | null = null;
let unsubscribeSharedGroupRules: (() => void) | null = null;
@@ -151,6 +147,22 @@ let rightDragLastY = 0;
let rightDragDistance = 0;
let suppressContextMenuUntil = 0;
function logClipboardDebug(stage: string, payload: Record<string, unknown> = {}) {
if (!import.meta.env.DEV) return;
const lfInstance = lf.value as any;
const graphModel = lfInstance?.graphModel;
const selectNodeIds: string[] = graphModel?.selectNodes?.map((node: BaseNodeModel) => node.id) ?? [];
const selectElementIds: string[] = graphModel?.selectElements
? Array.from(graphModel.selectElements.keys())
: [];
console.info('[FlowClipboardDebug]', stage, {
selectedCount: selectedCount.value,
selectNodeIds,
selectElementIds,
...payload
});
}
const resolveResizeHost = () => {
const container = containerRef.value;
if (!container) return null;
@@ -347,6 +359,48 @@ function normalizeAllNodes() {
});
}
function sanitizeLabelInProperties(properties: Record<string, any> | undefined) {
if (!properties || !Object.prototype.hasOwnProperty.call(properties, '_label')) {
return properties;
}
const currentLabel = properties._label;
if (!Array.isArray(currentLabel)) {
return properties;
}
const cleaned = currentLabel.filter((item) => item && typeof item === 'object');
if (cleaned.length === currentLabel.length) {
return properties;
}
if (!cleaned.length) {
const { _label, ...rest } = properties;
return rest;
}
return {
...properties,
_label: cleaned
};
}
function sanitizeGraphLabels() {
const graphModel = lf.value?.graphModel as any;
if (!graphModel) return;
const sanitizeModel = (model: any) => {
const props = model?.getProperties?.() ?? model?.properties;
if (!props) return;
const next = sanitizeLabelInProperties(props);
if (!next || next === props) return;
if (typeof model.setProperties === 'function') {
model.setProperties(next);
return;
}
model.properties = next;
};
(graphModel.nodes ?? []).forEach((model: any) => sanitizeModel(model));
(graphModel.edges ?? []).forEach((model: any) => sanitizeModel(model));
}
function updateNodeMeta(model: BaseNodeModel, updater: (meta: Record<string, any>) => Record<string, any>) {
const lfInstance = lf.value;
if (!lfInstance) return;
@@ -610,59 +664,6 @@ function handleArrowMove(direction: 'left' | 'right' | 'up' | 'down', event?: Ke
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;
@@ -856,6 +857,7 @@ onMounted(() => {
}
},
plugins: [
DynamicGroup,
Menu,
...(props.enableLabel ? [Label] : []),
Snapshot,
@@ -884,8 +886,6 @@ onMounted(() => {
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) => {
@@ -897,8 +897,6 @@ onMounted(() => {
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);
@@ -933,21 +931,6 @@ onMounted(() => {
{
text: '---' // 分隔线
},
{
text: '复制 (Ctrl+C)',
callback() {
handleCopy();
}
},
{
text: '粘贴 (Ctrl+V)',
callback() {
handlePaste();
}
},
{
text: '---' // 分隔线
},
{
text: '组合 (Ctrl+G)',
callback() {
@@ -1005,11 +988,8 @@ onMounted(() => {
}
},
{
text: '粘贴 (Ctrl+V)',
callback(data: Position) {
handlePaste();
}
}
text: '提示:使用 Ctrl+V 粘贴',
},
]
});
@@ -1017,21 +997,6 @@ onMounted(() => {
lfInstance.extension.menu.setMenuByType({
type: 'lf:defaultSelectionMenu',
menu: [
{
text: '复制 (Ctrl+C)',
callback() {
handleCopy();
}
},
{
text: '粘贴 (Ctrl+V)',
callback() {
handlePaste();
}
},
{
text: '---' // 分隔线
},
{
text: '组合 (Ctrl+G)',
callback() {
@@ -1079,6 +1044,12 @@ onMounted(() => {
// 监听所有可能的节点添加事件
lfInstance.on(EventType.NODE_ADD, ({ data }) => {
if (!data?.id) {
logClipboardDebug('node:add-invalid-payload', {
payload: data ?? null
});
return;
}
const model = lfInstance.getNodeModelById(data.id);
if (model) {
normalizeNodeModel(model);
@@ -1092,6 +1063,7 @@ onMounted(() => {
// 监听 DND 添加节点事件
lfInstance.on('node:dnd-add', ({ data }) => {
if (!data?.id) return;
const model = lfInstance.getNodeModelById(data.id);
if (model) {
// 设置新节点的 zIndex 为 1000
@@ -1103,6 +1075,7 @@ onMounted(() => {
});
lfInstance.on(EventType.GRAPH_RENDERED, () => {
sanitizeGraphLabels();
applySelectionSelect(selectionEnabled.value);
normalizeAllNodes();
scheduleGroupRuleValidation(0);
@@ -1145,8 +1118,16 @@ onMounted(() => {
scheduleGroupRuleValidation();
});
lfInstance.on('selection:selected', () => updateSelectedCount());
lfInstance.on('selection:drop', () => updateSelectedCount());
lfInstance.on('selection:selected', () => {
sanitizeGraphLabels();
updateSelectedCount();
logClipboardDebug('selection:selected');
});
lfInstance.on('selection:drop', () => {
sanitizeGraphLabels();
updateSelectedCount();
logClipboardDebug('selection:drop');
});
nextTick(() => {
queueCanvasResize();

View File

@@ -20,6 +20,13 @@ export const NODE_REGISTRY: Record<NodeType, NodeTypeConfig> = {
description: '椭圆容器,可设置背景和边框'
},
[NodeType.DYNAMIC_GROUP]: {
type: NodeType.DYNAMIC_GROUP,
category: NodeCategory.LAYOUT,
label: '动态分组',
description: '支持折叠/收起与节点归组的容器'
},
[NodeType.ASSET_SELECTOR]: {
type: NodeType.ASSET_SELECTOR,
category: NodeCategory.ASSET,

View File

@@ -13,6 +13,7 @@ export enum NodeType {
// 布局容器类
RECT = 'rect',
ELLIPSE = 'ellipse',
DYNAMIC_GROUP = 'dynamic-group',
// 图形资产类(统一入口,内部切换资产库)
ASSET_SELECTOR = 'assetSelector',