diff --git a/packages/ui-vue/components/flow-canvas/src/components/context-menu.component.tsx b/packages/ui-vue/components/flow-canvas/src/components/context-menu.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a9492f9e069264b9a020e3e105bf20a399a468ab --- /dev/null +++ b/packages/ui-vue/components/flow-canvas/src/components/context-menu.component.tsx @@ -0,0 +1,152 @@ +import { defineComponent, ref, nextTick, onMounted, onBeforeUnmount } from "vue"; + +export interface ContextMenuOption { + id: string; + label: string; + icon?: string; + disabled?: boolean; + danger?: boolean; + onClick: () => void; +} + +export interface ContextMenuProps { + visible: boolean; + position: { x: number; y: number }; + options: ContextMenuOption[]; + onClose: () => void; +} + +export const contextMenuProps = { + visible: { type: Boolean, default: false }, + position: { + type: Object as () => { x: number; y: number }, + default: () => ({ x: 0, y: 0 }) + }, + options: { + type: Array as () => ContextMenuOption[], + default: () => [] + }, + onClose: { type: Function, required: true } +}; + +export default defineComponent({ + name: 'ContextMenu', + props: contextMenuProps, + setup(props: ContextMenuProps) { + const menuRef = ref(); + const adjustedPosition = ref({ x: 0, y: 0 }); + + const adjustPosition = () => { + if (!menuRef.value || !props.visible) return; + + nextTick(() => { + const menu = menuRef.value!; + const rect = menu.getBoundingClientRect(); + const viewport = { + width: window.innerWidth, + height: window.innerHeight + }; + + let { x, y } = props.position; + + if (x + rect.width > viewport.width) { + x = viewport.width - rect.width - 10; + } + + if (y + rect.height > viewport.height) { + y = viewport.height - rect.height - 10; + } + + x = Math.max(10, x); + y = Math.max(10, y); + + adjustedPosition.value = { x, y }; + }); + }; + + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.value && !menuRef.value.contains(event.target as Node)) { + props.onClose(); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + props.onClose(); + } + }; + + const handleOptionClick = (option: ContextMenuOption) => { + if (!option.disabled) { + option.onClick(); + props.onClose(); + } + }; + + onMounted(() => { + document.addEventListener('click', handleClickOutside); + document.addEventListener('keydown', handleKeyDown); + }); + + onBeforeUnmount(() => { + document.removeEventListener('click', handleClickOutside); + document.removeEventListener('keydown', handleKeyDown); + }); + + (() => { + if (props.visible) { + adjustedPosition.value = props.position; + adjustPosition(); + } + })(); + + return () => { + if (!props.visible) return null; + + return ( +
+
e.stopPropagation()} + > + {props.options.map((option) => ( +
handleOptionClick(option)} + > + {option.icon && ( + {option.icon} + )} + {option.label} +
+ ))} +
+
+ ); + }; + } +}); \ No newline at end of file diff --git a/packages/ui-vue/components/flow-canvas/src/components/flow-node-item.component.tsx b/packages/ui-vue/components/flow-canvas/src/components/flow-node-item.component.tsx index be2a143a51265a373d7a4c485b82c453d7526ab0..196a3d6d5e42728d777f12d67c8928a1312a059e 100644 --- a/packages/ui-vue/components/flow-canvas/src/components/flow-node-item.component.tsx +++ b/packages/ui-vue/components/flow-canvas/src/components/flow-node-item.component.tsx @@ -8,12 +8,16 @@ import { usePortEventDispatcher } from "../hooks/use-port-event-dispatcher"; export default defineComponent({ name: 'FFlowNodeItem', props: flowNodeItemProps, - emits: ['connection-start', 'connection-end', 'node-drag', 'node-select'], + emits: ['connection-start', 'connection-end', 'node-drag', 'node-select', 'contextmenu'], setup(props: FlowNodeItemProps, context) { - const id = ref(props.id); - const schema = ref(props.modelValue); + const id = ref(props.node?.id || props.id); + const schema = ref(props.modelValue || props.node); const isSelected = ref(false); + const nodeX = computed(() => props.node?.position?.x || props.x || 0); + const nodeY = computed(() => props.node?.position?.y || props.y || 0); + const nodeType = computed(() => props.node?.type || props.type || ''); + const useDrawingBezierComposition = inject('use-drawing-bezier-composition') as UseDrawingBezier; const { startAncherElement, @@ -39,8 +43,8 @@ export default defineComponent({ const flowNodeItemStyle = computed(() => { return { - 'left': `${props.x}px`, - 'top': `${props.y}px`, + 'left': `${nodeX.value}px`, + 'top': `${nodeY.value}px`, 'position': 'absolute', 'cursor': 'move' } as Record; @@ -60,8 +64,8 @@ export default defineComponent({ isDragging.value = true; dragStartX.value = event.clientX; dragStartY.value = event.clientY; - originalX.value = props.x; - originalY.value = props.y; + originalX.value = nodeX.value; + originalY.value = nodeY.value; const handleMouseMove = (moveEvent: MouseEvent) => { if (!isDragging.value) { return; } @@ -71,7 +75,7 @@ export default defineComponent({ const newX = originalX.value + deltaX; const newY = originalY.value + deltaY; - context.emit('node-drag', { id: props.id, x: newX, y: newY }); + context.emit('node-drag', { id: id.value, x: newX, y: newY }); }; const handleMouseUp = () => { @@ -170,8 +174,8 @@ export default defineComponent({ }); function renderNodeContent() { - const nodeType = schema.value?.type || 'empty'; - const NodeContentComponent: any = getNodeComponentByType(nodeType); + const nodeTypeValue = nodeType.value || 'empty'; + const NodeContentComponent: any = getNodeComponentByType(nodeTypeValue); return { return ( -
+
{ + event.stopPropagation(); + if (props.onClick) { + props.onClick(id.value); + } + }} + onContextmenu={(event) => { + event.stopPropagation(); + context.emit('contextmenu', event); + }} + > {renderPorts()}
{renderNodeContent()} diff --git a/packages/ui-vue/components/flow-canvas/src/components/flow-node-item.props.ts b/packages/ui-vue/components/flow-canvas/src/components/flow-node-item.props.ts index e192f2ba933e106ec4a0c98effe0a1ec8cb10d98..b24e6f3a85b557e8f83e3d78e04613987f5682ab 100644 --- a/packages/ui-vue/components/flow-canvas/src/components/flow-node-item.props.ts +++ b/packages/ui-vue/components/flow-canvas/src/components/flow-node-item.props.ts @@ -7,6 +7,8 @@ export const flowNodeItemProps = { modelValue: { type: Object }, + node: { type: Object, default: null }, + x: { type: Number, default: 0 }, y: { type: Number, default: 0 }, @@ -25,7 +27,9 @@ export const flowNodeItemProps = { icon: { type: String, default: '' }, - defaultOutput: { type: Array, default: () => [] }, + class: { type: String, default: '' }, + + onClick: { type: Function, default: null }, onNodeDrag: { type: Function, default: null } } as Record; diff --git a/packages/ui-vue/components/flow-canvas/src/components/node-configs.ts b/packages/ui-vue/components/flow-canvas/src/components/node-configs.ts new file mode 100644 index 0000000000000000000000000000000000000000..4b0717f75e5a3cc97199946bef46c0e2b11cc2cc --- /dev/null +++ b/packages/ui-vue/components/flow-canvas/src/components/node-configs.ts @@ -0,0 +1,68 @@ +import { Component } from 'vue'; +import { NodeConfig, NodeConfigFactory, NodeType, nodeConfigFactories } from './node-map'; + +export interface NodeTemplate { + type: string; + displayName: string; + description: string; + icon: string; + category: 'logic' | 'ai' | 'image' | 'flow'; + configFactory: NodeConfigFactory; +} + +export const nodeTemplates: NodeTemplate[] = [ + { + type: NodeType.START, + displayName: '开始节点', + description: '流程开始节点', + icon: '▶️', + category: 'flow', + configFactory: nodeConfigFactories[NodeType.START] + }, + { + type: NodeType.END, + displayName: '结束节点', + description: '流程结束节点', + icon: '⏹️', + category: 'flow', + configFactory: nodeConfigFactories[NodeType.END] + }, + { + type: NodeType.IF, + displayName: '条件判断', + description: '条件分支节点', + icon: '❓', + category: 'logic', + configFactory: nodeConfigFactories[NodeType.IF] + }, + { + type: NodeType.SELECTOR, + displayName: '选择器', + description: '多选项选择节点', + icon: '📋', + category: 'logic', + configFactory: nodeConfigFactories[NodeType.SELECTOR] + } +]; + +export function getNodeTemplatesByCategory(category: string): NodeTemplate[] { + return nodeTemplates.filter(template => template.category === category); +} + +export function getNodeTemplateByType(nodeType: string): NodeTemplate | undefined { + return nodeTemplates.find(template => template.type === nodeType); +} + +export function getNodeCategories(): string[] { + return [...new Set(nodeTemplates.map(template => template.category))]; +} + +export function getCategoryDisplayName(category: string): string { + const categoryNames: Record = { + 'flow': '流程控制', + 'logic': '逻辑判断', + 'ai': 'AI 功能', + 'image': '图像处理' + }; + return categoryNames[category] || category; +} \ No newline at end of file diff --git a/packages/ui-vue/components/flow-canvas/src/components/node-map.ts b/packages/ui-vue/components/flow-canvas/src/components/node-map.ts index a995ba880a51d2c0c5277ec60127900299b7396f..c9ed6dd63b4eb7a360b82523c17e7097508ecf1c 100644 --- a/packages/ui-vue/components/flow-canvas/src/components/node-map.ts +++ b/packages/ui-vue/components/flow-canvas/src/components/node-map.ts @@ -9,7 +9,36 @@ export interface NodeComponentMap { [key: string]: Component; } -// 节点类型枚举 +export interface NodeConfig { + id: string; + type: string; + label: string; + icon?: string; + input?: Array<{ + id: string; + type: string; + direction: string; + position: string; + autoConnect: boolean; + }>; + output?: Array<{ + id: string; + type: string; + direction: string; + position: string; + autoConnect: boolean; + }>; + conditions?: Array<{ + id: string; + label: string; + expression?: string; + output?: Array; + }>; + options?: Array; +} + +export type NodeConfigFactory = (nodeId: string) => NodeConfig; + export enum NodeType { START = 'start-node', END = 'end-node', @@ -18,7 +47,6 @@ export enum NodeType { EMPTY = 'empty-node' } -// 节点组件映射表 export const nodeMap: NodeComponentMap = { [NodeType.START]: StartNodeComponent, [NodeType.END]: EndNodeComponent, @@ -27,11 +55,104 @@ export const nodeMap: NodeComponentMap = { [NodeType.EMPTY]: EmptyNodeComponent }; -// 工厂函数:根据节点类型获取对应的组件 +export const nodeConfigFactories: Record = { + [NodeType.START]: (nodeId: string): NodeConfig => ({ + id: nodeId, + type: 'start-node', + label: '开始', + output: [{ + id: `${nodeId}-output`, + type: 'port', + direction: 'output', + position: 'right', + autoConnect: true + }] + }), + + [NodeType.END]: (nodeId: string): NodeConfig => ({ + id: nodeId, + type: 'end-node', + label: '结束', + input: [{ + id: `${nodeId}-input`, + type: 'port', + direction: 'input', + position: 'left', + autoConnect: true + }] + }), + + [NodeType.SELECTOR]: (nodeId: string): NodeConfig => ({ + id: nodeId, + type: 'selector-node', + label: '选择节点', + input: [{ + id: `${nodeId}-input`, + type: 'port', + direction: 'input', + position: 'left', + autoConnect: true + }] + }), + + [NodeType.IF]: (nodeId: string): NodeConfig => ({ + id: nodeId, + type: 'if-node', + label: '选择器', + input: [{ + id: `${nodeId}-input`, + type: 'port', + direction: 'input', + position: 'left', + autoConnect: true + }], + conditions: [ + { + id: `${nodeId}-condition-1`, + label: '如果', + expression: '', + output: [{ + id: `${nodeId}-condition-1-output`, + type: 'port', + direction: 'output', + position: 'right', + parentId: `${nodeId}-condition-1`, + autoConnect: true + }] + }, + { + id: `${nodeId}-condition-2`, + label: '否则如果', + expression: '', + output: [{ + id: `${nodeId}-condition-2-output`, + type: 'port', + direction: 'output', + position: 'right', + parentId: `${nodeId}-condition-2`, + autoConnect: true + }] + } + ] + }), + + [NodeType.EMPTY]: (nodeId: string): NodeConfig => ({ + id: nodeId, + type: 'empty-node', + label: 'Empty Node', + input: [{ + id: `${nodeId}-input`, + type: 'port', + direction: 'input', + position: 'left', + autoConnect: true + }] + }) +}; + export function getNodeComponentByType(type: string): Component { const normalizedType = type.toLowerCase(); - // 处理类型映射 const typeMapping: Record = { 'start': NodeType.START, 'end': NodeType.END, @@ -46,17 +167,36 @@ export function getNodeComponentByType(type: string): Component { return nodeMap[mappedType] || nodeMap[NodeType.EMPTY]; } -// 检查节点类型是否有效 +export function createNodeConfig(nodeType: string, nodeId: string): NodeConfig { + const configFactory = nodeConfigFactories[nodeType]; + + if (!configFactory) { + return nodeConfigFactories[NodeType.EMPTY](nodeId); + } + + return configFactory(nodeId); +} + +export function getAllSupportedNodeTypes(): Array<{ + type: string; + displayName: string; + hasConfig: boolean; +}> { + return Object.keys(nodeConfigFactories).map(type => ({ + type, + displayName: getNodeDisplayName(type), + hasConfig: true + })); +} + export function isValidNodeType(type: string): boolean { return Object.values(NodeType).includes(type as NodeType); } -// 获取所有支持的节点类型 export function getSupportedNodeTypes(): string[] { return Object.values(NodeType); } -// 获取节点组件的显示名称 export function getNodeDisplayName(type: string): string { const displayNames: Record = { [NodeType.START]: '开始', @@ -68,3 +208,14 @@ export function getNodeDisplayName(type: string): string { return displayNames[type] || '未知'; } + +export function registerNodeType( + nodeType: string, + component: Component, + configFactory: NodeConfigFactory, + displayName?: string +): void { + nodeMap[nodeType] = component; + + nodeConfigFactories[nodeType] = configFactory; +} diff --git a/packages/ui-vue/components/flow-canvas/src/components/node-selector-panel.component.tsx b/packages/ui-vue/components/flow-canvas/src/components/node-selector-panel.component.tsx index 6650a2e509776176cd1ed091f9f0d8b2e511a088..5b2b16dc48819fdf730464f743db7e67d75028ca 100644 --- a/packages/ui-vue/components/flow-canvas/src/components/node-selector-panel.component.tsx +++ b/packages/ui-vue/components/flow-canvas/src/components/node-selector-panel.component.tsx @@ -1,5 +1,6 @@ import { defineComponent } from "vue"; -import { nodeTypeOptions, NodeTypeCategory } from "../schema/configs/node-type-options"; +import { getAllSupportedNodeTypes, getNodeDisplayName, NodeType } from "./node-map"; +import { getNodeTemplatesByCategory, getCategoryDisplayName } from "./node-configs"; export default defineComponent({ name: 'NodeSelectorPanel', @@ -18,23 +19,29 @@ export default defineComponent({ props.onClose(); }; - function renderCategoryRow(category: NodeTypeCategory) { + function renderCategoryRow(category: string) { + const templates = getNodeTemplatesByCategory(category); + const leftOptions = templates.slice(0, Math.ceil(templates.length / 2)); + const rightOptions = templates.slice(Math.ceil(templates.length / 2)); + return ( -
-
{category.title}
+
+
{getCategoryDisplayName(category)}
- {category.leftOptions.map((option) => -
handleNodeSelect(option.id)}> - {option.text} + {leftOptions.map((template) => +
handleNodeSelect(template.type)}> + {template.icon} + {template.displayName} +
)}
- {category.rightOptions.map((option) => -
handleNodeSelect(option.id)}> - {option.text} + {rightOptions.map((template) => +
handleNodeSelect(template.type)}> + {template.icon} + {template.displayName} +
)} @@ -47,6 +54,8 @@ export default defineComponent({ return () => { if (!props.visible) return null; + const categories = ['flow', 'logic', 'ai', 'image']; + return (
- {nodeTypeOptions.map((category) => renderCategoryRow(category))} + {categories.map((category) => renderCategoryRow(category))}
diff --git a/packages/ui-vue/components/flow-canvas/src/components/node-selector.component.tsx b/packages/ui-vue/components/flow-canvas/src/components/node-selector.component.tsx index 9785622f3d7345d0aa1478bf3ece663c8acb57cc..f24e66200eac59ee7d1fee624dd84ae51b5c576c 100644 --- a/packages/ui-vue/components/flow-canvas/src/components/node-selector.component.tsx +++ b/packages/ui-vue/components/flow-canvas/src/components/node-selector.component.tsx @@ -47,7 +47,6 @@ export default defineComponent({ }; const handleBackdropClick = (event: MouseEvent) => { - // 如果点击的是背景,则取消选择 if (event.target === event.currentTarget) { handleCancel(); } diff --git a/packages/ui-vue/components/flow-canvas/src/components/start-node.component.tsx b/packages/ui-vue/components/flow-canvas/src/components/start-node.component.tsx index 7f3872ffe6a34e84d5bc255eb6fb388a43e241c7..fc45ba53d3962340c28fe7528ce3a7de43b1c1dd 100644 --- a/packages/ui-vue/components/flow-canvas/src/components/start-node.component.tsx +++ b/packages/ui-vue/components/flow-canvas/src/components/start-node.component.tsx @@ -10,7 +10,6 @@ export default defineComponent({ const schema = ref(props.modelValue); const label = ref(schema.value?.label); - // 播放图标 SVG const renderPlayIcon = () => ( { return (
- {/* 圆形图标 */}
{renderPlayIcon()}
- {/* 文字标签 */}
{label.value || '开始'}
diff --git a/packages/ui-vue/components/flow-canvas/src/composition/types.ts b/packages/ui-vue/components/flow-canvas/src/composition/types.ts index 414bd374f5170097db2c1a496ccb8ec6a45a143c..9bda336c14eb27f1c6726633b9bb9dad6289fd64 100644 --- a/packages/ui-vue/components/flow-canvas/src/composition/types.ts +++ b/packages/ui-vue/components/flow-canvas/src/composition/types.ts @@ -39,8 +39,32 @@ export interface UseBezierCurve { drawing: (curveId: string, startPoint: CurvePoint, endPoint: CurvePoint, startPointPosition: string, endPoinPosition: string) => void; - // 新增:重绘指定连接 redrawConnection: (startPointId: string, endPointId: string) => void; + setConnectionClickHandler: (handler: (connectionId: string) => void) => void; + updateConnectionSelection: (connectionId: string, selected: boolean) => void; + setConnectionContextMenuHandler: (handler: (connectionId: string, event: MouseEvent) => void) => void; + setConnectionInsertNodeHandler: (handler: (connectionId: string, position: { x: number; y: number }) => void) => void; +} + +export interface DeleteResult { + success: boolean; + message: string; + deletedItems?: { + nodes?: string[]; + connections?: string[]; + }; +} + +export interface SelectionState { + selectedNodeId: string | null; + selectedConnectionId: string | null; +} + +export interface ContextMenuState { + visible: boolean; + position: { x: number; y: number }; + type: 'node' | 'connection' | null; + targetId: string | null; } export interface UseDrawingBezier { @@ -50,13 +74,12 @@ export interface UseDrawingBezier { finishToDraw: (event: MouseEvent) => void; - getAncherPointPosition: (ancherElement: HTMLElement | null) => string; + getAncherPointPosition: (ancher: HTMLElement | null) => string; isAncherPoint: (element: HTMLElement) => boolean; startAncherElement: Ref; - // 新增:节点选择相关功能 pendingConnection: Ref; showNodeSelector: Ref; nodeSelectorPosition: Ref<{ x: number; y: number }>; @@ -79,7 +102,6 @@ export interface UseConnections { removeConnection: (startAncherId: string, endAcherId: string) => void; - // 新增:获取与指定节点相关的连接 getConnectionsByNodeId: (nodeId: string) => Array<{ startPortId: string; endPortId: string; @@ -88,7 +110,6 @@ export interface UseConnections { connectionId: string; }>; - // 新增:获取所有连接 getAllConnections: () => Array<{ startPortId: string; endPortId: string; diff --git a/packages/ui-vue/components/flow-canvas/src/composition/use-bezier-curve.ts b/packages/ui-vue/components/flow-canvas/src/composition/use-bezier-curve.ts index 318a7e10c19b4e9e1d1ef27971e3cf5ab1679d7b..8d1cbd0245dd2b3067bfa71b0062fad6dc78d0ea 100644 --- a/packages/ui-vue/components/flow-canvas/src/composition/use-bezier-curve.ts +++ b/packages/ui-vue/components/flow-canvas/src/composition/use-bezier-curve.ts @@ -2,19 +2,107 @@ import { CurveOrientation, CurvePoint, CurvePointOffset, UseBezierCurve } from "./types"; -export function useBezierCurve(): UseBezierCurve { +export function useBezierCurve(): UseBezierCurve & { + onConnectionClick?: (connectionId: string) => void; + setConnectionClickHandler: (handler: (connectionId: string) => void) => void; + updateConnectionSelection: (connectionId: string, selected: boolean) => void; + setConnectionContextMenuHandler: (handler: (connectionId: string, event: MouseEvent) => void) => void; + setConnectionInsertNodeHandler: (handler: (connectionId: string, position: { x: number; y: number }) => void) => void; +} { const widthOffset = 50; const controlPointOffset = 2 * widthOffset; const lineWidth = 2; - const defaultPointOffset: CurvePointOffset = { aroundDirection: 'none', left: lineWidth, right: lineWidth, top: lineWidth, bottom: lineWidth, x: 0, y: 0 }; + const defaultPointOffset: CurvePointOffset = { + aroundDirection: 'none', + left: lineWidth, + right: lineWidth, + top: lineWidth, + bottom: lineWidth, + x: 0, + y: 0 + }; + + let connectionClickHandler: ((connectionId: string) => void) | null = null; + + let connectionContextMenuHandler: ((connectionId: string, event: MouseEvent) => void) | null = null; + + let connectionInsertNodeHandler: ((connectionId: string, position: { x: number; y: number }) => void) | null = null; + + function setConnectionClickHandler(handler: (connectionId: string) => void) { + connectionClickHandler = handler; + } + + function setConnectionContextMenuHandler(handler: (connectionId: string, event: MouseEvent) => void) { + connectionContextMenuHandler = handler; + + const allConnections = document.querySelectorAll('.flow-connection'); + allConnections.forEach(conn => { + if ((conn as any).setContextMenuHandler) { + (conn as any).setContextMenuHandler(handler); + } + }); + } + + function setConnectionInsertNodeHandler(handler: (connectionId: string, position: { x: number; y: number }) => void) { + connectionInsertNodeHandler = handler; + + const allConnections = document.querySelectorAll('.flow-connection'); + allConnections.forEach(conn => { + if ((conn as any).setInsertNodeHandler) { + (conn as any).setInsertNodeHandler(handler); + } + }); + } + + function getAncherPointPositionFromElement(ancherElement: HTMLElement | null): string { + if (!ancherElement) { return 'center'; } + + const classNames = ancherElement.className.split(' '); + + const circleAncherClass = classNames.find((className: string) => className.startsWith('circle-')) || ''; + if (circleAncherClass) { + switch (circleAncherClass) { + case 'circle-left': return 'west'; + case 'circle-right': return 'east'; + case 'circle-top': return 'north'; + case 'circle-bottom': return 'south'; + default: return 'center'; + } + } + + const portClass = classNames.find((className: string) => className.startsWith('port-')) || ''; + if (portClass) { + switch (portClass) { + case 'port-left': return 'west'; + case 'port-right': return 'east'; + case 'port-top': return 'north'; + case 'port-bottom': return 'south'; + default: return 'center'; + } + } + + const dataPosition = ancherElement.getAttribute('data-port-position'); + if (dataPosition) { + switch (dataPosition) { + case 'left': return 'west'; + case 'right': return 'east'; + case 'top': return 'north'; + case 'bottom': return 'south'; + default: return 'center'; + } + } + + return 'center'; + } function createCurvePath() { const curvePath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); curvePath.setAttribute("fill", "none"); curvePath.setAttribute("stroke", "#4d53e8"); curvePath.setAttribute("stroke-width", "2"); - curvePath.setAttribute("class", ""); + curvePath.setAttribute("class", "flow-connection-path"); + curvePath.setAttribute("cursor", "pointer"); return curvePath; } @@ -24,6 +112,8 @@ export function useBezierCurve(): UseBezierCurve { arrowPath.setAttribute("stroke", "#4d53e8"); arrowPath.setAttribute("stroke-width", "2"); arrowPath.setAttribute("stroke-linecap", "round"); + arrowPath.setAttribute("class", "flow-connection-arrow"); + arrowPath.setAttribute("cursor", "pointer"); return arrowPath; } @@ -36,49 +126,212 @@ export function useBezierCurve(): UseBezierCurve { return arrowPath; } + // 增强的连线事件绑定 - 添加点击选择功能和插入按钮 function bindCurveEvents(curveId: string, curveElement: HTMLElement, curvePath: SVGPathElement, arrowPath: SVGPathElement) { - const deleteLine = (payload: KeyboardEvent) => { - if (payload.key === 'Delete') { - const splitIndex = curveId.indexOf('^'); - document.removeEventListener('keydown', deleteLine); - curveElement.remove(); + let isSelected = false; + let insertButton: HTMLElement | null = null; + + let contextMenuHandler: ((connectionId: string, event: MouseEvent) => void) | null = null; + + let insertNodeHandler: ((connectionId: string, position: { x: number; y: number }) => void) | null = null; + + const createInsertButton = () => { + if (insertButton) return insertButton; + + insertButton = document.createElement('div'); + insertButton.className = 'connection-insert-button'; + insertButton.innerHTML = '➕'; + insertButton.style.cssText = ` + position: absolute; + width: 20px; + height: 20px; + background: #2a87ff; + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + cursor: pointer; + z-index: 100; + border: 2px solid white; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); + display: none; + user-select: none; + `; + + insertButton.addEventListener('click', (e) => { + e.stopPropagation(); + if (insertNodeHandler) { + const rect = insertButton!.getBoundingClientRect(); + insertNodeHandler(curveId, { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2 + }); + } + hideInsertButton(); + }); + + document.body.appendChild(insertButton); + return insertButton; + }; + + // 显示插入按钮 + const showInsertButton = (event: MouseEvent) => { + if (isSelected) return; // 选中状态下不显示插入按钮 + + const button = createInsertButton(); + const rect = curveElement.getBoundingClientRect(); + + // 计算按钮位置(连线中点) + const svgElement = curveElement.querySelector('svg'); + if (svgElement) { + const pathElement = svgElement.querySelector('path'); + if (pathElement) { + try { + const pathLength = pathElement.getTotalLength(); + const midPoint = pathElement.getPointAtLength(pathLength / 2); + + button.style.left = `${rect.left + midPoint.x - 10}px`; + button.style.top = `${rect.top + midPoint.y - 10}px`; + button.style.display = 'flex'; + } catch (e) { + // 如果获取路径点失败,使用鼠标位置 + button.style.left = `${event.clientX - 10}px`; + button.style.top = `${event.clientY - 10}px`; + button.style.display = 'flex'; + } + } } }; - curvePath.addEventListener('mouseenter', () => { - curvePath.setAttribute("stroke", "#37d0ff"); - arrowPath.setAttribute("stroke", "#37d0ff"); - curvePath.setAttribute("stroke-width", "3"); - document.addEventListener('keydown', deleteLine); - }); + // 隐藏插入按钮 + const hideInsertButton = () => { + if (insertButton) { + insertButton.style.display = 'none'; + } + }; - curvePath.addEventListener('mouseleave', () => { - curvePath.setAttribute("stroke", "#4d53e8"); - curvePath.setAttribute("stroke-width", "2"); - arrowPath.setAttribute("stroke", "#4d53e8"); - document.removeEventListener('keydown', deleteLine); - }); + // 连线点击选择 + const handleConnectionClick = (event: MouseEvent) => { + event.stopPropagation(); + hideInsertButton(); // 点击时隐藏插入按钮 + if (connectionClickHandler) { + connectionClickHandler(curveId); + } + }; + + // 连线右键菜单 + const handleConnectionContextMenu = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + hideInsertButton(); // 右键时隐藏插入按钮 + if (contextMenuHandler) { + contextMenuHandler(curveId, event); + } + }; + + // 连线选择状态更新 + const updateSelectionState = (selected: boolean) => { + isSelected = selected; + if (selected) { + hideInsertButton(); // 选中时隐藏插入按钮 + curvePath.setAttribute("stroke", "#ff6b6b"); + curvePath.setAttribute("stroke-width", "3"); + arrowPath.setAttribute("stroke", "#ff6b6b"); + curveElement.setAttribute("data-selected", "true"); + } else { + curvePath.setAttribute("stroke", "#4d53e8"); + curvePath.setAttribute("stroke-width", "2"); + arrowPath.setAttribute("stroke", "#4d53e8"); + curveElement.removeAttribute("data-selected"); + } + }; + + // 鼠标悬停效果 + const handleMouseEnter = (event: MouseEvent) => { + if (!isSelected) { + curvePath.setAttribute("stroke", "#37d0ff"); + arrowPath.setAttribute("stroke", "#37d0ff"); + curvePath.setAttribute("stroke-width", "3"); + showInsertButton(event); + } + }; + + const handleMouseLeave = () => { + if (!isSelected) { + curvePath.setAttribute("stroke", "#4d53e8"); + curvePath.setAttribute("stroke-width", "2"); + arrowPath.setAttribute("stroke", "#4d53e8"); + } + // 延迟隐藏,给用户时间点击按钮 + setTimeout(hideInsertButton, 200); + }; + + // 绑定事件 + curvePath.addEventListener('click', handleConnectionClick); + arrowPath.addEventListener('click', handleConnectionClick); + curvePath.addEventListener('contextmenu', handleConnectionContextMenu); + arrowPath.addEventListener('contextmenu', handleConnectionContextMenu); + curvePath.addEventListener('mouseenter', handleMouseEnter); + curvePath.addEventListener('mouseleave', handleMouseLeave); + arrowPath.addEventListener('mouseenter', handleMouseEnter); + arrowPath.addEventListener('mouseleave', handleMouseLeave); + + // 存储方法到元素上,供外部调用 + (curveElement as any).updateSelectionState = updateSelectionState; + (curveElement as any).setContextMenuHandler = (handler: (connectionId: string, event: MouseEvent) => void) => { + contextMenuHandler = handler; + }; + (curveElement as any).setInsertNodeHandler = (handler: (connectionId: string, position: { x: number; y: number }) => void) => { + insertNodeHandler = handler; + }; + + // 清理函数 + (curveElement as any).cleanup = () => { + if (insertButton) { + insertButton.remove(); + insertButton = null; + } + }; } function createCurveElement(curveId: string) { let curveElement = document.getElementById(curveId) as HTMLElement; if (curveElement == null) { const curveSVG = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + curveSVG.setAttribute("class", "flow-connection-svg"); + const curvePath = createCurvePath(); curveSVG.appendChild(curvePath); + const arrowPath = createArrowPath(); curveSVG.appendChild(arrowPath); + const firstControlPointPath = createControlPointPath(); curveSVG.appendChild(firstControlPointPath); + const secondControlPointPath = createControlPointPath(); curveSVG.appendChild(secondControlPointPath); curveElement = document.createElement("div"); curveElement.id = curveId; + curveElement.className = "flow-connection"; curveElement.style.position = "absolute"; curveElement.appendChild(curveSVG); + bindCurveEvents(curveId, curveElement, curvePath, arrowPath); + // 设置右键菜单处理器 + if (connectionContextMenuHandler) { + (curveElement as any).setContextMenuHandler(connectionContextMenuHandler); + } + + // 设置插入节点处理器 + if (connectionInsertNodeHandler) { + (curveElement as any).setInsertNodeHandler(connectionInsertNodeHandler); + } + const svgContainer = document.getElementById("svg-container"); if (svgContainer) { svgContainer.appendChild(curveElement); @@ -88,6 +341,14 @@ export function useBezierCurve(): UseBezierCurve { return curveElement; } + // 更新连线选择状态的方法 + function updateConnectionSelection(connectionId: string, selected: boolean) { + const curveElement = document.getElementById(connectionId); + if (curveElement && (curveElement as any).updateSelectionState) { + (curveElement as any).updateSelectionState(selected); + } + } + function getFirstPointOffset(startDirection: string, pathDirection: string, connectDirection: string, width: number, height: number) { const firstPointOffset = { x: 0, y: 0 } as CurvePointOffset; if (startDirection === 'west') { @@ -802,62 +1063,13 @@ export function useBezierCurve(): UseBezierCurve { drawing(`${startPointId}_${endPointId}`, startPoint, endPoint, startPointPosition, endPointPosition); } - function getAncherPointPositionFromElement(ancherElement: HTMLElement | null): string { - if (!ancherElement) return 'center'; - - const classNames = ancherElement.className.split(' '); - - const circleAncherClass = classNames.find((className: string) => className.startsWith('circle-')) || ''; - if (circleAncherClass) { - switch (circleAncherClass) { - case 'circle-left': - return 'west'; - case 'circle-right': - return 'east'; - case 'circle-top': - return 'north'; - case 'circle-bottom': - return 'south'; - default: - return 'center'; - } - } - - const portClass = classNames.find((className: string) => className.startsWith('port-')) || ''; - if (portClass) { - switch (portClass) { - case 'port-left': - return 'west'; - case 'port-right': - return 'east'; - case 'port-top': - return 'north'; - case 'port-bottom': - return 'south'; - default: - return 'center'; - } - } - - const dataPosition = ancherElement.getAttribute('data-port-position'); - if (dataPosition) { - switch (dataPosition) { - case 'left': - return 'west'; - case 'right': - return 'east'; - case 'top': - return 'north'; - case 'bottom': - return 'south'; - default: - return 'center'; - } - } - - return 'center'; - } - - return { connect, drawing, redrawConnection }; - + return { + connect, + drawing, + redrawConnection, + setConnectionClickHandler, + updateConnectionSelection, + setConnectionContextMenuHandler, + setConnectionInsertNodeHandler + }; } diff --git a/packages/ui-vue/components/flow-canvas/src/composition/use-connection-manager.ts b/packages/ui-vue/components/flow-canvas/src/composition/use-connection-manager.ts new file mode 100644 index 0000000000000000000000000000000000000000..86a0ca73b08805c8a8f2a2e26dfeddb91f043e9d --- /dev/null +++ b/packages/ui-vue/components/flow-canvas/src/composition/use-connection-manager.ts @@ -0,0 +1,163 @@ +import { ref, Ref } from 'vue'; +import { UseBezierCurve, UseConnections } from './types'; +import { getElementPosition } from '../utils/element-utils'; + +export interface ConnectionManagerOptions { + schema: Ref; + useBezierCurveComposition: UseBezierCurve; + useConnectionsComposition: UseConnections; + onSchemaUpdate: (newSchema: any) => void; +} + +export interface UseConnectionManager { + restoreConnections: () => Promise; + createConnection: (startPortId: string, endPortId: string, startNodeId: string, endNodeId: string) => Promise; + deleteConnection: (startPortId: string, endPortId: string) => boolean; + insertNodeInConnection: (connectionId: string, newNodeId: string, newNodeType: string) => Promise; + getConnectionsByNodeId: (nodeId: string) => Array<{ startPortId: string; endPortId: string; connectionId: string }>; +} + +export function useConnectionManager(options: ConnectionManagerOptions): UseConnectionManager { + const { schema, useBezierCurveComposition, useConnectionsComposition, onSchemaUpdate } = options; + + const extractNodeIdFromPortId = (portId: string): string => { + const selectorOptionMatch = portId.match(/^(.+?)-option-\d+-output$/); + if (selectorOptionMatch) { + return selectorOptionMatch[1]; + } + const conditionMatch = portId.match(/^(.+?)-condition-\d+-output$/); + if (conditionMatch) { + return conditionMatch[1]; + } + const simpleMatch = portId.match(/^(.+?)-(input|output)$/); + if (simpleMatch) { + return simpleMatch[1]; + } + return portId; + }; + + const waitForElement = async (elementId: string, maxAttempts = 10): Promise => { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const element = document.getElementById(elementId); + if (element) { + return element; + } + await new Promise(resolve => setTimeout(resolve, 50)); + } + throw new Error(`Element not found: ${elementId}`); + }; + + const saveConnectionToSchema = (startPortId: string, endPortId: string): void => { + const connection = { + id: `connection-${Date.now()}`, + type: 'flow-connection', + source: startPortId, + target: endPortId, + lineType: 'bezier', + lineStyle: { + strokeWidth: 2, + strokeColor: '#4d53e8' + } + }; + schema.value.contents.push(connection); + onSchemaUpdate(schema.value); + }; + + const restoreConnections = async (): Promise => { + if (!schema.value?.contents) { + return; + } + const connections = schema.value.contents.filter( + (item: any) => item.type === 'flow-connection' + ); + + const connectionPromises = connections.map(async (conn: any) => { + const startElement = await waitForElement(conn.source); + const endElement = await waitForElement(conn.target); + const startPos = getElementPosition(startElement); + const endPos = getElementPosition(endElement); + useBezierCurveComposition.connect(conn.source, conn.target, startPos, endPos); + useConnectionsComposition.addConnection( + extractNodeIdFromPortId(conn.source), + conn.source, + extractNodeIdFromPortId(conn.target), + conn.target + ); + }); + + await Promise.all(connectionPromises); + }; + + const createConnection = async (startPortId: string, endPortId: string, startNodeId: string, endNodeId: string): Promise => { + const startElement = await waitForElement(startPortId); + const endElement = await waitForElement(endPortId); + const startPos = getElementPosition(startElement); + const endPos = getElementPosition(endElement); + useBezierCurveComposition.connect(startPortId, endPortId, startPos, endPos); + useConnectionsComposition.addConnection(startNodeId, startPortId, endNodeId, endPortId); + saveConnectionToSchema(startPortId, endPortId); + return true; + }; + + const deleteConnection = (startPortId: string, endPortId: string): boolean => { + const connectionId = `${startPortId}_${endPortId}`; + const connectionElement = document.getElementById(connectionId); + if (connectionElement) { + connectionElement.remove(); + } + const connectionIndex = schema.value.contents.findIndex( + (item: any) => item.type === 'flow-connection' && item.source === startPortId && item.target === endPortId + ); + if (connectionIndex >= 0) { + schema.value.contents.splice(connectionIndex, 1); + } + useConnectionsComposition.removeConnection(startPortId, endPortId); + onSchemaUpdate(schema.value); + return true; + }; + + const insertNodeInConnection = async (connectionId: string, newNodeId: string, newNodeType: string): Promise => { + const [startPortId, endPortId] = connectionId.split('_'); + const connectionIndex = schema.value.contents.findIndex( + (item: any) => item.type === 'flow-connection' && item.source === startPortId && item.target === endPortId + ); + if (connectionIndex < 0) { + return false; + } + const originalConnection = schema.value.contents[connectionIndex]; + if (!deleteConnection(startPortId, endPortId)) { + return false; + } + const newNodeInputId = `${newNodeId}-input`; + const newNodeOutputId = `${newNodeId}-output`; + const success1 = await createConnection( + startPortId, + newNodeInputId, + extractNodeIdFromPortId(startPortId), + newNodeId + ); + const success2 = await createConnection( + newNodeOutputId, + endPortId, + newNodeId, + extractNodeIdFromPortId(endPortId) + ); + if (success1 && success2) { + return true; + } else { + return false; + } + }; + + const getConnectionsByNodeId = (nodeId: string) => { + return useConnectionsComposition.getConnectionsByNodeId(nodeId); + }; + + return { + restoreConnections, + createConnection, + deleteConnection, + insertNodeInConnection, + getConnectionsByNodeId + }; +} \ No newline at end of file diff --git a/packages/ui-vue/components/flow-canvas/src/composition/use-drawing-bezier.ts b/packages/ui-vue/components/flow-canvas/src/composition/use-drawing-bezier.ts index db413476721f0120ee84883d85fe5db8965a55f2..d00ea65e1cb6abc93e368d9b48269b0523cf4265 100644 --- a/packages/ui-vue/components/flow-canvas/src/composition/use-drawing-bezier.ts +++ b/packages/ui-vue/components/flow-canvas/src/composition/use-drawing-bezier.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-use-before-define */ import { ref } from "vue"; import { UseBezierCurve, UseDrawingBezier } from "./types"; @@ -25,26 +26,52 @@ export function useDrawingBezier( const startAncherElement = ref(); const { drawing } = useBezierCurveComposition; - // 选择节点相关状态 const pendingConnection = ref(null); const showNodeSelector = ref(false); const nodeSelectorPosition = ref({ x: 0, y: 0 }); + function extractNodeIdFromPortId(portId: string): string { + const selectorOptionMatch = portId.match(/^(.+?)-option-\d+-output$/); + if (selectorOptionMatch) { + return selectorOptionMatch[1]; + } + + const conditionMatch = portId.match(/^(.+?)-condition-\d+-output$/); + if (conditionMatch) { + return conditionMatch[1]; + } + + const simpleMatch = portId.match(/^(.+?)-(input|output)$/); + if (simpleMatch) { + return simpleMatch[1]; + } + + return portId; + } + function convertToNumber(pxStr: string) { return Number(pxStr.replace('px', '')); } + interface DragStartInfo { + sourceNodeId: string; + sourcePortId: string; + startPosition: { x: number; y: number }; + startTime: number; + } + function isAncherPoint(element: HTMLElement) { const classNames = element && element.className && element.className.split ? element.className.split(' ') : []; return classNames.includes('f-flow-ancher'); } function getAncherPointPosition(ancherElement: HTMLElement | null) { - if (!ancherElement) return 'center'; + if (!ancherElement) { + return 'center'; + } const classNames = ancherElement.className.split(' '); - // 检查是否有 circle- 开头的类名 const circleAncherClass = classNames.find((className: string) => className.startsWith('circle-')) || ''; if (circleAncherClass) { switch (circleAncherClass) { @@ -61,7 +88,6 @@ export function useDrawingBezier( } } - // 如果没有 circle- 类名,检查 port- 类名 const portClass = classNames.find((className: string) => className.startsWith('port-')) || ''; if (portClass) { switch (portClass) { @@ -78,7 +104,6 @@ export function useDrawingBezier( } } - // 检查 data-port-position 属性 const dataPosition = ancherElement.getAttribute('data-port-position'); if (dataPosition) { switch (dataPosition) { @@ -95,17 +120,14 @@ export function useDrawingBezier( } } - // 默认返回 center return 'center'; } - // 修复后的坐标计算函数 function getAnchorPointPosition(anchorEl: HTMLElement, canvasEl?: HTMLElement): { x: number; y: number } { if (!anchorEl) { return { x: 0, y: 0 }; } - // 获取画布容器 const svgContainer = canvasEl || document.getElementById('svg-container'); if (!svgContainer) { return { x: 0, y: 0 }; @@ -128,17 +150,19 @@ export function useDrawingBezier( ancherPosition.value = getAncherPointPosition(ancherElement); } + function resetDragState() { + } + function updateDrawingLine(mouseMovingArgs: MouseEvent) { - // 获取画布容器 const svgContainer = document.getElementById('svg-container'); - if (!svgContainer) return; + if (!svgContainer) { + return; + } - // 计算鼠标相对于画布的位置 const canvasRect = svgContainer.getBoundingClientRect(); const mouseX = mouseMovingArgs.clientX - canvasRect.left; const mouseY = mouseMovingArgs.clientY - canvasRect.top; - // 更新待连接状态的鼠标位置 if (pendingConnection.value) { pendingConnection.value.mousePosition = { x: mouseX, y: mouseY }; } @@ -173,35 +197,18 @@ export function useDrawingBezier( document.removeEventListener('mouseup', handleMouseUp); } - - function handleMouseUp(event: MouseEvent) { - - const targetElement = event.target as HTMLElement; - - // 检查是否松开在有效的输入端口上 - if (targetElement && - targetElement.classList.contains('f-flow-ancher') && - targetElement.classList.contains('input-port') && - startAncherElement.value) { - - finishToDraw(event); + function showNodeSelectorPanel(mouseEvent: MouseEvent) { + if (!startAncherElement.value) { return; } - if (startAncherElement.value) { - showNodeSelectorPanel(event); - } - } - - function showNodeSelectorPanel(mouseEvent: MouseEvent) { - if (!startAncherElement.value) return; - const svgContainer = document.getElementById('svg-container'); - if (!svgContainer) return; + if (!svgContainer) { + return; + } const canvasRect = svgContainer.getBoundingClientRect(); - // 设置待连接状态 pendingConnection.value = { startNodeId: extractNodeIdFromPortId(startAncherElement.value.id), startPortId: startAncherElement.value.id, @@ -213,67 +220,59 @@ export function useDrawingBezier( }; nodeSelectorPosition.value = { - x: mouseEvent.clientX + 10, // 向右偏移10px避免遮挡 - y: mouseEvent.clientY + 10 // 向下偏移10px + x: mouseEvent.clientX + 10, + y: mouseEvent.clientY + 10 }; showNodeSelector.value = true; - // 暂时保留连线,等用户选择 releaseDrawingEvents(); } - // 取消节点选择 - function cancelNodeSelection() { + function handleMouseUp(event: MouseEvent) { + const targetElement = event.target as HTMLElement; + + if (targetElement && + targetElement.classList.contains('f-flow-ancher') && + targetElement.classList.contains('input-port') && + startAncherElement.value) { + + finishToDraw(event); + return; + } + if (startAncherElement.value) { + showNodeSelectorPanel(event); + } + } + + function cancelNodeSelection() { showNodeSelector.value = false; - // 清除临时连线 if (startAncherElement.value) { eraseDrawingLine(`${startAncherElement.value.id}_curve_to`); } - // 重置状态 pendingConnection.value = null; startAncherElement.value = undefined; } - // 完成节点选择(第4步会完善这里) function completeNodeSelection(targetNodeType: string) { showNodeSelector.value = false; - // 清除临时连线 if (startAncherElement.value) { eraseDrawingLine(`${startAncherElement.value.id}_curve_to`); } - // 重置所有相关状态 pendingConnection.value = null; startAncherElement.value = undefined; - // 确保事件监听器被正确清理 releaseDrawingEvents(); } - // 辅助函数:从端口ID提取节点ID - function extractNodeIdFromPortId(portId: string): string { - const selectorOptionMatch = portId.match(/^(.+?)-option-\d+-output$/); - if (selectorOptionMatch) return selectorOptionMatch[1]; - - const conditionMatch = portId.match(/^(.+?)-condition-\d+-output$/); - if (conditionMatch) return conditionMatch[1]; - - const simpleMatch = portId.match(/^(.+?)-(input|output)$/); - if (simpleMatch) return simpleMatch[1]; - - return portId; - } - function finishToDraw(event: MouseEvent) { startAncherElement.value = undefined; releaseDrawingEvents(); } - // 修复后的 drawFrom 函数 function drawFrom(ancherElementId: string, payload: MouseEvent) { - startAncherElement.value = document.getElementById(ancherElementId) as HTMLElement; if (!startAncherElement.value) { return; @@ -284,10 +283,8 @@ export function useDrawingBezier( return; } - // 使用修复后的坐标转换函数 const startPosition = getAnchorPointPosition(startAncherElement.value, svgContainer); - // 重置起始位置 - 修复的关键点 startX.value = startPosition.x; startY.value = startPosition.y; startPoint.value = startPosition; @@ -304,7 +301,6 @@ export function useDrawingBezier( isAncherPoint, startAncherElement, - // 新增的节点选择相关功能 pendingConnection, showNodeSelector, nodeSelectorPosition, diff --git a/packages/ui-vue/components/flow-canvas/src/composition/use-drawing.ts b/packages/ui-vue/components/flow-canvas/src/composition/use-drawing.ts index 663f962d7b1d5a69e579944b1eec1b3c5c00ac43..6ed51941a5e7db6f11704bf1c5f7f606322e80e4 100644 --- a/packages/ui-vue/components/flow-canvas/src/composition/use-drawing.ts +++ b/packages/ui-vue/components/flow-canvas/src/composition/use-drawing.ts @@ -59,7 +59,6 @@ export function useDrawing( } function finishToDraw(event: MouseEvent) { - // eslint-disable-next-line no-use-before-define releaseDrawingEvents(); eraseDrawingLine(); confirmToDrawLine(startAncherElement.value as HTMLElement, event.target as HTMLElement); diff --git a/packages/ui-vue/components/flow-canvas/src/flow-canvas.component.tsx b/packages/ui-vue/components/flow-canvas/src/flow-canvas.component.tsx index ce02d0abcd7ca8e866fdea4d95929eaeaae4d06a..7848efe5aee9b7e126781f8633ba3d3a9fd6e404 100644 --- a/packages/ui-vue/components/flow-canvas/src/flow-canvas.component.tsx +++ b/packages/ui-vue/components/flow-canvas/src/flow-canvas.component.tsx @@ -1,13 +1,17 @@ -import { computed, defineComponent, nextTick, onMounted, provide, ref, watch } from "vue"; +import { computed, defineComponent, nextTick, onMounted, provide, ref, watch, onBeforeUnmount } from "vue"; import { FlowCanvasProps, flowCanvasProps } from "./flow-canvas.props"; import './flow-canvas.css'; import { useBezierCurve } from "./composition/use-bezier-curve"; import { useDrawingBezier } from "./composition/use-drawing-bezier"; import { useConnections } from "./composition/use-connections"; +import { useConnectionManager } from "./composition/use-connection-manager"; +import { createNodeConfig } from "./components/node-map"; +import { getElementPosition } from "./utils/element-utils"; import FFlowNodeItem from './components/flow-node-item.component'; import NodeSelectorPanel from './components/node-selector-panel.component'; +import ContextMenu, { ContextMenuOption } from './components/context-menu.component'; export default defineComponent({ name: 'FFlowCanvas', @@ -15,302 +19,411 @@ export default defineComponent({ emits: ['update:modelValue'], setup(props: FlowCanvasProps, context) { const schema = ref(props.modelValue); + const selectedNodeId = ref(null); + const selectedConnectionId = ref(null); + const useBezierCurveComposition = useBezierCurve(); const useDrawingBezierComposition = useDrawingBezier(useBezierCurveComposition); const useConnectionsComposition = useConnections([]); - provide('use-bezier-curve-composition', useBezierCurveComposition); - provide('use-connections-composition', useConnectionsComposition); - provide('use-drawing-bezier-composition', useDrawingBezierComposition); + const connectionManager = useConnectionManager({ + schema, + useBezierCurveComposition, + useConnectionsComposition, + onSchemaUpdate: (newSchema) => context.emit('update:modelValue', newSchema) + }); - function redrawNodeConnections(nodeId: string) { - const { getConnectionsByNodeId } = useConnectionsComposition; - const { redrawConnection } = useBezierCurveComposition; + const contextMenu = ref({ + visible: false, + position: { x: 0, y: 0 }, + type: null as 'node' | 'connection' | null, + targetId: null as string | null + }); - const relatedConnections = getConnectionsByNodeId(nodeId); + const insertNodeState = ref<{ + connectionId: string | null; + position: { x: number; y: number }; + showSelector: boolean; + }>({ + connectionId: null, + position: { x: 0, y: 0 }, + showSelector: false + }); - relatedConnections.forEach((connection) => { - try { - redrawConnection(connection.startPortId, connection.endPortId); - } catch (error) { - } - }); - } + const findConnectionIndex = (source: string, target: string): number => { + return schema.value?.contents?.findIndex( + (item: any) => item.type === 'flow-connection' && item.source === source && item.target === target + ) ?? -1; + }; - function handleNodeDrag(event: { id: string; x: number; y: number }) { - if (!schema.value || !schema.value.diagram) { return; } + const findNodeContentIndex = (nodeId: string): number => { + return schema.value?.contents?.findIndex((item: any) => item.id === nodeId) ?? -1; + }; - const nodeIndex = schema.value.diagram.nodes.findIndex((node: any) => node.id === event.id); - if (nodeIndex !== -1) { - schema.value.diagram.nodes[nodeIndex].position = { x: event.x, y: event.y }; + const findNodeDiagramIndex = (nodeId: string): number => { + return schema.value?.diagram?.nodes?.findIndex((node: any) => node.id === nodeId) ?? -1; + }; - nextTick(() => { - redrawNodeConnections(event.id); - }); + const removeConnectionFromSchema = (source: string, target: string): boolean => { + const index = findConnectionIndex(source, target); + if (index >= 0) { + schema.value.contents.splice(index, 1); + return true; + } + return false; + }; - context.emit('update:modelValue', schema.value); + const removeNodeFromSchema = (nodeId: string): boolean => { + const contentIndex = findNodeContentIndex(nodeId); + if (contentIndex >= 0) { + schema.value.contents.splice(contentIndex, 1); } - } - function createNode(nodeType: string, position: { x: number; y: number }) { - if (!schema.value) { return null; } + const diagramIndex = findNodeDiagramIndex(nodeId); + if (diagramIndex >= 0 && schema.value.diagram?.nodes) { + schema.value.diagram.nodes.splice(diagramIndex, 1); + } + + return contentIndex >= 0 || diagramIndex >= 0; + }; + + const closeContextMenu = (): void => { + contextMenu.value = { + visible: false, + position: { x: 0, y: 0 }, + type: null, + targetId: null + }; + }; + + const createNode = (nodeType: string, position: { x: number; y: number }): string | null => { + if (!schema.value) { + return null; + } const timestamp = Date.now(); const nodeId = `${nodeType}-${timestamp}`; - const nodeConfigs: Record = { - 'selector-node': { - id: nodeId, - type: 'selector-node', - label: '选择节点', - input: [{ - id: `${nodeId}-input`, - type: 'port', - direction: 'input', - position: 'left', - autoConnect: true - }] - }, - 'if-node': { - id: nodeId, - type: 'if-node', - label: '选择器', - input: [{ - id: `${nodeId}-input`, - type: 'port', - direction: 'input', - position: 'left', - autoConnect: true - }], - conditions: [ - { - id: `${nodeId}-condition-1`, - label: '如果', - expression: '', - output: [{ - id: `${nodeId}-condition-1-output`, - type: 'port', - direction: 'output', - position: 'right', - parentId: `${nodeId}-condition-1`, - autoConnect: true - }] - }, - { - id: `${nodeId}-condition-2`, - label: '否则如果', - expression: '', - output: [{ - id: `${nodeId}-condition-2-output`, - type: 'port', - direction: 'output', - position: 'right', - parentId: `${nodeId}-condition-2`, - autoConnect: true - }] - } - ] - }, - 'start-node': { - id: nodeId, - type: 'start-node', - label: '开始', - output: [{ - id: `${nodeId}-output`, - type: 'port', - direction: 'output', - position: 'right', - autoConnect: true - }] - }, - 'end-node': { - id: nodeId, - type: 'end-node', - label: '结束', - input: [{ - id: `${nodeId}-input`, - type: 'port', - direction: 'input', - position: 'left', - autoConnect: true - }] - } - }; - const newNode = nodeConfigs[nodeType] || { - id: nodeId, - type: 'empty-node', - label: nodeType, - input: [{ - id: `${nodeId}-input`, - type: 'port', - direction: 'input', - position: 'left', - autoConnect: true - }] - }; + + const newNode = createNodeConfig(nodeType, nodeId); + schema.value.contents.push(newNode); + if (!schema.value.diagram) { schema.value.diagram = { nodes: [] }; } + schema.value.diagram.nodes.push({ id: nodeId, position: position }); + context.emit('update:modelValue', schema.value); + return nodeId; - } - - function getElementPosition(element: HTMLElement): string { - const classNames = element.className.split(' '); - const circleClass = classNames.find(name => name.startsWith('circle-')); - if (circleClass) { - switch (circleClass) { - case 'circle-left': return 'west'; - case 'circle-right': return 'east'; - case 'circle-top': return 'north'; - case 'circle-bottom': return 'south'; - } + }; + + const deleteSelectedNode = (): void => { + if (!selectedNodeId.value || !schema.value) { + return; } - const portClass = classNames.find(name => name.startsWith('port-')); - if (portClass) { - switch (portClass) { - case 'port-left': return 'west'; - case 'port-right': return 'east'; - case 'port-top': return 'north'; - case 'port-bottom': return 'south'; + + const nodeId = selectedNodeId.value; + const relatedConnections = useConnectionsComposition.getConnectionsByNodeId(nodeId); + + relatedConnections.forEach(conn => { + const connectionElement = document.getElementById(conn.connectionId); + if (connectionElement) { + connectionElement.remove(); } - } - return 'center'; - } - - function createConnection(pendingConn: any, newNodeId: string) { - const { connect } = useBezierCurveComposition; - const { addConnection } = useConnectionsComposition; - const { startPortId } = pendingConn; - const endPortId = `${newNodeId}-input`; - const startElement = document.getElementById(startPortId); - const endElement = document.getElementById(endPortId); - - if (!startElement) { + removeConnectionFromSchema(conn.startPortId, conn.endPortId); + }); + + removeNodeFromSchema(nodeId); + selectedNodeId.value = null; + context.emit('update:modelValue', schema.value); + }; + + const deleteSelectedConnection = (): void => { + if (!selectedConnectionId.value || !schema.value) { return; } - if (!endElement) { - nextTick(() => createConnection(pendingConn, newNodeId)); + const [startPortId, endPortId] = selectedConnectionId.value.split('_'); + const connectionElement = document.getElementById(selectedConnectionId.value); + if (connectionElement) { + connectionElement.remove(); + } + + removeConnectionFromSchema(startPortId, endPortId); + useConnectionsComposition.removeConnection(startPortId, endPortId); + selectedConnectionId.value = null; + context.emit('update:modelValue', schema.value); + }; + + const handleNodeDrag = (event: { id: string; x: number; y: number }): void => { + const nodeIndex = findNodeDiagramIndex(event.id); + if (nodeIndex >= 0 && schema.value.diagram?.nodes) { + schema.value.diagram.nodes[nodeIndex].position = { x: event.x, y: event.y }; + const connections = connectionManager.getConnectionsByNodeId(event.id); + connections.forEach(conn => { + useBezierCurveComposition.redrawConnection(conn.startPortId, conn.endPortId); + }); + context.emit('update:modelValue', schema.value); + } + }; + + const handleNodeSelect = async (nodeType: string): Promise => { + const { pendingConnection } = useDrawingBezierComposition; + if (!pendingConnection.value) { return; } - try { - const startPos = getElementPosition(startElement); - const endPos = getElementPosition(endElement); - connect(startPortId, endPortId, startPos, endPos); - addConnection( - pendingConn.startNodeId, - startPortId, - newNodeId, - endPortId + const connectionInfo = { ...pendingConnection.value }; + + const newNodePosition = { + x: connectionInfo.mousePosition.x + 30, + y: connectionInfo.mousePosition.y - 50 + }; + + const newNodeId = createNode(nodeType, newNodePosition); + if (newNodeId) { + await connectionManager.createConnection( + connectionInfo.startPortId, + `${newNodeId}-input`, + connectionInfo.startNodeId, + newNodeId ); + } - saveConnectionToSchema(startPortId, endPortId); - } catch (error) { + useDrawingBezierComposition.completeNodeSelection(nodeType); + }; + + const handleNodeSelectorClose = (): void => { + useDrawingBezierComposition.completeNodeSelection(''); + }; + + const handleInsertNodeSelect = (nodeType: string): void => { + if (!insertNodeState.value.connectionId) { + return; } - } - - function saveConnectionToSchema(startPortId: string, endPortId: string) { - const connection = { - id: `connection-${Date.now()}`, - type: 'flow-connection', - source: startPortId, - target: endPortId, - lineType: 'bezier', - lineStyle: { - strokeWidth: 2, - strokeColor: '#4d53e8' - } - }; - schema.value.contents.push(connection); - context.emit('update:modelValue', schema.value); - } + const connectionId = insertNodeState.value.connectionId; + const [startPortId, endPortId] = connectionId.split('_'); + const connectionIndex = findConnectionIndex(startPortId, endPortId); - function restoreConnections() { - if (!schema.value?.contents) return; + if (connectionIndex === -1) { + return; + } - const connections = schema.value.contents.filter( - (item: any) => item.type === 'flow-connection' - ); + const originalConnection = schema.value.contents[connectionIndex]; + const nodePosition = { + x: insertNodeState.value.position.x - 100, + y: insertNodeState.value.position.y - 50 + }; + + const newNodeId = createNode(nodeType, nodePosition); + if (newNodeId) { + schema.value.contents.splice(connectionIndex, 1); + const connectionElement = document.getElementById(connectionId); + if (connectionElement) { + connectionElement.remove(); + } + + const newNodeInputId = `${newNodeId}-input`; + const newNodeOutputId = `${newNodeId}-output`; + + const connection1 = { + id: `connection-${Date.now()}-1`, + type: 'flow-connection', + source: startPortId, + target: newNodeInputId, + lineType: 'bezier', + lineStyle: originalConnection.lineStyle + }; + + const connection2 = { + id: `connection-${Date.now()}-2`, + type: 'flow-connection', + source: newNodeOutputId, + target: endPortId, + lineType: 'bezier', + lineStyle: originalConnection.lineStyle + }; + + schema.value.contents.push(connection1, connection2); + context.emit('update:modelValue', schema.value); - connections.forEach((conn: any) => { nextTick(() => { - const startElement = document.getElementById(conn.source); - const endElement = document.getElementById(conn.target); - - if (startElement && endElement) { - const startPos = getElementPosition(startElement); - const endPos = getElementPosition(endElement); - - useBezierCurveComposition.connect( - conn.source, conn.target, startPos, endPos - ); - - useConnectionsComposition.addConnection( - extractNodeIdFromPortId(conn.source), - conn.source, - extractNodeIdFromPortId(conn.target), - conn.target - ); + setTimeout(() => { + const startElement = document.getElementById(startPortId); + const newNodeInputElement = document.getElementById(newNodeInputId); + if (startElement && newNodeInputElement) { + const startPos = getElementPosition(startElement); + const inputPos = getElementPosition(newNodeInputElement); + useBezierCurveComposition.connect(startPortId, newNodeInputId, startPos, inputPos); + } + + const newNodeOutputElement = document.getElementById(newNodeOutputId); + const endElement = document.getElementById(endPortId); + if (newNodeOutputElement && endElement) { + const outputPos = getElementPosition(newNodeOutputElement); + const endPos = getElementPosition(endElement); + useBezierCurveComposition.connect(newNodeOutputId, endPortId, outputPos, endPos); + } + }, 100); + }); + } + + handleInsertNodeClose(); + }; + + const handleInsertNodeClose = (): void => { + insertNodeState.value = { + connectionId: null, + position: { x: 0, y: 0 }, + showSelector: false + }; + }; + + const getContextMenuOptions = (): ContextMenuOption[] => { + const options: ContextMenuOption[] = []; + + if (contextMenu.value.type === 'node') { + options.push({ + id: 'delete-node', + label: '删除节点', + onClick: () => { + deleteSelectedNode(); + closeContextMenu(); } }); - }); - } + } else if (contextMenu.value.type === 'connection') { + options.push({ + id: 'delete-connection', + label: '删除连接', + onClick: () => { + deleteSelectedConnection(); + closeContextMenu(); + } + }); + } - function extractNodeIdFromPortId(portId: string): string { - const selectorOptionMatch = portId.match(/^(.+?)-option-\d+-output$/); - if (selectorOptionMatch) return selectorOptionMatch[1]; + return options; + }; - const conditionMatch = portId.match(/^(.+?)-condition-\d+-output$/); - if (conditionMatch) return conditionMatch[1]; + const handleConnectionContextMenu = (connectionId: string, event: MouseEvent): void => { + event.preventDefault(); + event.stopPropagation(); - const simpleMatch = portId.match(/^(.+?)-(input|output)$/); - if (simpleMatch) return simpleMatch[1]; + contextMenu.value = { + visible: true, + position: { x: event.clientX, y: event.clientY }, + type: 'connection', + targetId: connectionId + }; + }; - return portId; - } + const handleConnectionInsertNode = (connectionId: string, position: { x: number; y: number }): void => { + insertNodeState.value = { + connectionId, + position, + showSelector: true + }; + }; - function handleNodeSelect(nodeType: string) { - const { pendingConnection, completeNodeSelection } = useDrawingBezierComposition; - if (!pendingConnection.value) { - return; + const handleConnectionClick = (connectionId: string): void => { + closeContextMenu(); + selectedNodeId.value = null; + + const wasSelected = selectedConnectionId.value === connectionId; + if (selectedConnectionId.value && selectedConnectionId.value !== connectionId) { + useBezierCurveComposition.updateConnectionSelection(selectedConnectionId.value, false); } - const newNodePosition = { - x: pendingConnection.value.mousePosition.x + 30, - y: pendingConnection.value.mousePosition.y - 50 + + selectedConnectionId.value = wasSelected ? null : connectionId; + + if (selectedConnectionId.value) { + useBezierCurveComposition.updateConnectionSelection(connectionId, true); + } + }; + + const handleNodeContextMenu = (nodeId: string, event: MouseEvent): void => { + event.preventDefault(); + event.stopPropagation(); + + contextMenu.value = { + visible: true, + position: { x: event.clientX, y: event.clientY }, + type: 'node', + targetId: nodeId }; - const newNodeId = createNode(nodeType, newNodePosition); - if (newNodeId) { - nextTick(() => { - createConnection(pendingConnection.value!, newNodeId); - }); + }; + + const handleNodeClick = (nodeId: string): void => { + closeContextMenu(); + selectedConnectionId.value = null; + + if (selectedConnectionId.value) { + useBezierCurveComposition.updateConnectionSelection(selectedConnectionId.value, false); + selectedConnectionId.value = null; } - completeNodeSelection(nodeType); - } - function handleNodeSelectorClose() { - useDrawingBezierComposition.cancelNodeSelection(); - } + selectedNodeId.value = selectedNodeId.value === nodeId ? null : nodeId; + }; - watch(() => props.modelValue, (newValue) => { - schema.value = newValue; - }, { deep: true }); + const handleCanvasClick = (event: MouseEvent): void => { + if (event.target === event.currentTarget) { + closeContextMenu(); + selectedNodeId.value = null; + selectedConnectionId.value = null; + } + }; + + const handleCanvasContextMenu = (event: MouseEvent): void => { + event.preventDefault(); + closeContextMenu(); + }; + + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Delete' || event.key === 'Backspace') { + if (selectedNodeId.value) { + deleteSelectedNode(); + } else if (selectedConnectionId.value) { + deleteSelectedConnection(); + } + } + }; + + useBezierCurveComposition.setConnectionClickHandler(handleConnectionClick); + useBezierCurveComposition.setConnectionContextMenuHandler(handleConnectionContextMenu); + useBezierCurveComposition.setConnectionInsertNodeHandler(handleConnectionInsertNode); + + provide('use-bezier-curve-composition', useBezierCurveComposition); + provide('use-connections-composition', useConnectionsComposition); + provide('use-drawing-bezier-composition', useDrawingBezierComposition); onMounted(() => { schema.value = props.modelValue; - restoreConnections(); + connectionManager.restoreConnections(); + document.addEventListener('keydown', handleKeyDown); }); + onBeforeUnmount(() => { + document.removeEventListener('keydown', handleKeyDown); + }); + + watch(() => props.modelValue, (newValue) => { + schema.value = newValue; + }, { deep: true }); + return () => { const { showNodeSelector, nodeSelectorPosition } = useDrawingBezierComposition; return ( -
+
) => { const nodePosition = schema.value.diagram?.nodes?.find((node: any) => node.id === flowNodeItem.id); const position = nodePosition?.position || { x: 0, y: 0 }; + const isSelected = selectedNodeId.value === flowNodeItem.id; return ( handleNodeClick(flowNodeItem.id)} + onContextmenu={(event: MouseEvent) => handleNodeContextMenu(flowNodeItem.id, event)} onNodeDrag={handleNodeDrag} /> ); }) } + + + + +
); diff --git a/packages/ui-vue/components/flow-canvas/src/flow-canvas.css b/packages/ui-vue/components/flow-canvas/src/flow-canvas.css index a5d5633002e414800691ccea5306207e1449b4ef..59945e9ac511ae42a5ad43116d6512b4609fe19d 100644 --- a/packages/ui-vue/components/flow-canvas/src/flow-canvas.css +++ b/packages/ui-vue/components/flow-canvas/src/flow-canvas.css @@ -9,6 +9,15 @@ z-index: 1; } +.f-flow-canvas .br-node.selected { + z-index: 10; +} + +.f-flow-canvas .br-node.selected .node-content { + border-color: #2a87ff; + box-shadow: 0 0 0 2px rgba(42, 135, 255, 0.2); +} + .f-flow-canvas .node-content { border: 2px solid #1c1f2314; cursor: pointer; @@ -322,7 +331,6 @@ border-color: #2a87ff; } -/* 端口偏移配置 - 针对不同节点类型的嵌套端口 */ .f-flow-canvas .if-node-content .condition-item .port-output { right: -18px !important; } @@ -330,3 +338,176 @@ .f-flow-canvas .selector-node-content .option-item .port-output { right: -6px !important; } + +.f-flow-canvas .flow-connection { + pointer-events: auto; + z-index: 5; +} + +.f-flow-canvas .flow-connection-svg { + width: 100%; + height: 100%; + pointer-events: auto; +} + +.f-flow-canvas .flow-connection-path { + cursor: pointer; + pointer-events: auto; + transition: stroke 0.2s ease, stroke-width 0.2s ease; +} + +.f-flow-canvas .flow-connection-arrow { + cursor: pointer; + pointer-events: auto; + transition: stroke 0.2s ease; +} + +.f-flow-canvas .flow-connection[data-selected='true'] .flow-connection-path { + stroke: #ff6b6b !important; + stroke-width: 3 !important; +} + +.f-flow-canvas .flow-connection[data-selected='true'] .flow-connection-arrow { + stroke: #ff6b6b !important; +} + +.f-flow-canvas .flow-connection:hover .flow-connection-path { + stroke: #37d0ff; + stroke-width: 3; +} + +.f-flow-canvas .flow-connection:hover .flow-connection-arrow { + stroke: #37d0ff; +} + +.connection-insert-button { + position: absolute; + width: 20px; + height: 20px; + background: #2a87ff; + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + cursor: pointer; + z-index: 100; + border: 2px solid white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + user-select: none; + transition: all 0.2s ease; +} + +.connection-insert-button:hover { + background: #1a77ee; + transform: scale(1.1); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} + +.connection-insert-button:active { + transform: scale(0.95); +} + +.f-flow-canvas .br-node.selected { + z-index: 10; +} + +.f-flow-canvas .br-node.selected .node-content { + border-color: #2a87ff; + box-shadow: 0 0 0 2px rgba(42, 135, 255, 0.3); + transform: scale(1.02); + transition: all 0.2s ease; +} + +.f-flow-canvas .delete-hint { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 8px 16px; + border-radius: 4px; + font-size: 12px; + z-index: 1000; + pointer-events: none; + opacity: 0; + transition: opacity 0.3s ease; +} + +.context-menu-overlay { + pointer-events: auto; +} + +.context-menu { + background: white; + border: 1px solid #e5e7eb; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + padding: 4px 0; + min-width: 160px; + font-size: 14px; + z-index: 10000; +} + +.context-menu-item { + display: flex; + align-items: center; + padding: 8px 12px; + cursor: pointer; + color: #374151; + transition: background-color 0.15s ease; + user-select: none; +} + +.context-menu-item:hover:not(.disabled) { + background-color: #f3f4f6; +} + +.context-menu-item.disabled { + color: #9ca3af; + cursor: not-allowed; +} + +.context-menu-item.danger { + color: #ef4444; +} + +.context-menu-item.danger:hover:not(.disabled) { + background-color: #fef2f2; + color: #dc2626; +} + +.menu-item-icon { + margin-right: 8px; + width: 16px; + text-align: center; + font-size: 12px; +} + +.menu-item-label { + flex: 1; + white-space: nowrap; +} + +.context-menu-divider { + height: 1px; + background: #e5e7eb; + margin: 4px 0; +} + +.context-menu { + animation: contextMenuFadeIn 0.15s ease-out; +} + +@keyframes contextMenuFadeIn { + from { + opacity: 0; + transform: scale(0.95) translateY(-10px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} diff --git a/packages/ui-vue/components/flow-canvas/src/flow-canvas.props.ts b/packages/ui-vue/components/flow-canvas/src/flow-canvas.props.ts index e4f9ae59f10d745d994d3b2992dde865ade76ddd..20470f42caa5e17c54111ac0c36d7cb44ffaddb0 100644 --- a/packages/ui-vue/components/flow-canvas/src/flow-canvas.props.ts +++ b/packages/ui-vue/components/flow-canvas/src/flow-canvas.props.ts @@ -1,9 +1,6 @@ import { ExtractPropTypes } from "vue"; export const flowCanvasProps = { - /** - * 组件值 - */ modelValue: { type: Object, default: {} }, } as Record; diff --git a/packages/ui-vue/components/flow-canvas/src/hooks/use-port-event-dispatcher.ts b/packages/ui-vue/components/flow-canvas/src/hooks/use-port-event-dispatcher.ts index 3e375d8c209d19dd81424f5a8084181365051a45..d1ca0a49fea3875a8668b37fa50ae6610ceaf656 100644 --- a/packages/ui-vue/components/flow-canvas/src/hooks/use-port-event-dispatcher.ts +++ b/packages/ui-vue/components/flow-canvas/src/hooks/use-port-event-dispatcher.ts @@ -19,15 +19,31 @@ export function usePortEventDispatcher() { const useDrawingBezierComposition = inject('use-drawing-bezier-composition') as UseDrawingBezier; const { drawFrom } = useDrawingBezierComposition; - // 拖拽状态管理 const isDragging = ref(false); const dragStartInfo = ref(null); - // 处理output port的鼠标按下事件 + function extractNodeIdFromPortId(portId: string): string { + const selectorOptionMatch = portId.match(/^(.+?)-option-\d+-output$/); + if (selectorOptionMatch) { + return selectorOptionMatch[1]; + } + + const conditionMatch = portId.match(/^(.+?)-condition-\d+-output$/); + if (conditionMatch) { + return conditionMatch[1]; + } + + const simpleMatch = portId.match(/^(.+?)-(input|output)$/); + if (simpleMatch) { + return simpleMatch[1]; + } + + return portId; + } + const handleOutputPortMouseDown = (port: Port, event: MouseEvent) => { event.stopPropagation(); - // 记录拖拽起点信息 isDragging.value = true; dragStartInfo.value = { sourceNodeId: extractNodeIdFromPortId(port.id), @@ -36,15 +52,18 @@ export function usePortEventDispatcher() { startTime: Date.now() }; - // 开始绘制连线(触发第2步) drawFrom(port.id, event); }; + function resetDragState() { + isDragging.value = false; + dragStartInfo.value = null; + } + const handleInputPortMouseUp = (port: Port, event: MouseEvent) => { event.stopPropagation(); if (isDragging.value) { - // 正常连接,重置拖拽状态 resetDragState(); } }; @@ -54,35 +73,6 @@ export function usePortEventDispatcher() { handleOutputPortMouseDown({ id: portData.id } as Port, event); }; - // 辅助函数:从端口ID提取节点ID - function extractNodeIdFromPortId(portId: string): string { - // 处理选择器节点的选项输出端口:selector-node-option-1-output - const selectorOptionMatch = portId.match(/^(.+?)-option-\d+-output$/); - if (selectorOptionMatch) { - return selectorOptionMatch[1]; - } - - // 处理条件节点的条件输出端口:condition-check-condition-1-output - const conditionMatch = portId.match(/^(.+?)-condition-\d+-output$/); - if (conditionMatch) { - return conditionMatch[1]; - } - - // 处理简单的输入输出端口:start-node-output - const simpleMatch = portId.match(/^(.+?)-(input|output)$/); - if (simpleMatch) { - return simpleMatch[1]; - } - - return portId; - } - - // 重置拖拽状态 - function resetDragState() { - isDragging.value = false; - dragStartInfo.value = null; - } - return { handleOutputPortMouseDown, handleInputPortMouseUp, @@ -91,4 +81,4 @@ export function usePortEventDispatcher() { dragStartInfo, resetDragState }; -} \ No newline at end of file +} diff --git a/packages/ui-vue/components/flow-canvas/src/utils/element-utils.ts b/packages/ui-vue/components/flow-canvas/src/utils/element-utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..e7908002588ff392a1a75cf6f4f254279ce3077b --- /dev/null +++ b/packages/ui-vue/components/flow-canvas/src/utils/element-utils.ts @@ -0,0 +1,34 @@ +export const getElementPosition = (element: HTMLElement): string => { + const classNames = element.className.split(' '); + const circleClass = classNames.find(name => name.startsWith('circle-')); + if (circleClass) { + switch (circleClass) { + case 'circle-left': + return 'west'; + case 'circle-right': + return 'east'; + case 'circle-top': + return 'north'; + case 'circle-bottom': + return 'south'; + default: + break; + } + } + const portClass = classNames.find(name => name.startsWith('port-')); + if (portClass) { + switch (portClass) { + case 'port-left': + return 'west'; + case 'port-right': + return 'east'; + case 'port-top': + return 'north'; + case 'port-bottom': + return 'south'; + default: + break; + } + } + return 'center'; +}; \ No newline at end of file diff --git a/packages/ui-vue/components/flow-canvas/src/utils/render-ports.util.ts b/packages/ui-vue/components/flow-canvas/src/utils/render-ports.util.ts index 2698505d12f7436fbbdb2e0aff20230a839cf5a8..c525146b639c06af4c0bd4d0a890bdc1bd5f446b 100644 --- a/packages/ui-vue/components/flow-canvas/src/utils/render-ports.util.ts +++ b/packages/ui-vue/components/flow-canvas/src/utils/render-ports.util.ts @@ -16,7 +16,7 @@ export interface PortRenderOptions { const PORT_OFFSET_CONFIG = { 'selector-node': { - output: 18, // 改为18px,与会议建议一致 + output: 18, input: 4 }, 'if-node': {