1003 lines
35 KiB
Svelte
1003 lines
35 KiB
Svelte
<script lang="ts">
|
|
import {
|
|
Background,
|
|
type Edge,
|
|
type EdgeTypes,
|
|
MarkerType,
|
|
MiniMap,
|
|
type Node,
|
|
type NodeTypes,
|
|
Panel,
|
|
SvelteFlow,
|
|
useSvelteFlow
|
|
} from '@xyflow/svelte';
|
|
import '@xyflow/svelte/dist/style.css';
|
|
import '../styles/index.ts';
|
|
import {store} from '#store/stores.svelte';
|
|
import {nodeTypes} from './nodes';
|
|
import Toolbar from './Toolbar.svelte';
|
|
import NodePicker from './core/NodePicker.svelte';
|
|
import FlowEdge from './core/FlowEdge.svelte';
|
|
import FlowLinePath from './core/FlowLinePath.svelte';
|
|
import FlowMarkerDefs from './core/FlowMarkerDefs.svelte';
|
|
import FlowConnectionLine from './core/FlowConnectionLine.svelte';
|
|
import {genShortId} from './utils/IdGen';
|
|
import {useGetNode} from './utils/useGetNode.svelte';
|
|
import {useEnsureParentInNodesBefore} from './utils/useEnsureParentInNodesBefore.svelte';
|
|
import {Textarea} from './base';
|
|
import {useGetEdgesByTarget} from './utils/useGetEdgesByTarget.svelte';
|
|
import {getOptions} from './utils/NodeUtils';
|
|
import CustomNode from './nodes/CustomNode.svelte';
|
|
import {useUpdateEdgeData} from './utils/useUpdateEdgeData.svelte';
|
|
import {Button} from '#components/base/index';
|
|
import {useDeleteEdge} from '#components/utils/useDeleteEdge.svelte';
|
|
import {useGetNodesFromSource} from '#components/utils/useGetNodesFromSource.svelte';
|
|
import {useGetNodeRelativePosition} from '#components/utils/useGetNodeRelativePosition.svelte';
|
|
import {useCopyPasteHandler} from '#components/utils/useCopyPasteHandler.svelte';
|
|
import {onDestroy, onMount} from 'svelte';
|
|
import {isInEditableElement} from '#components/utils/isInEditableElement';
|
|
import {getAvailableNodes, type NodePaletteItem} from './utils/nodePalette';
|
|
import {
|
|
buildSequentialFieldBindingPatches,
|
|
ensureStartNodeParameters,
|
|
START_NODE_TYPE,
|
|
} from '../utils/workflowNodeFields';
|
|
|
|
const { onInit }: { onInit: any; [key: string]: any } = $props();
|
|
const svelteFlow = useSvelteFlow();
|
|
|
|
onInit(svelteFlow);
|
|
|
|
let showEdgePanel = $state(false);
|
|
let currentEdge = $state<Edge | null>(null);
|
|
let nodePickerVisible = $state(false);
|
|
let pendingConnection = $state<null | {
|
|
source: string;
|
|
sourceHandle?: string;
|
|
startClientX: number;
|
|
startClientY: number;
|
|
clientX: number;
|
|
clientY: number;
|
|
}>(null);
|
|
let flowRootEl = $state<HTMLDivElement | null>(null);
|
|
let inlineNodePickerEl = $state<HTMLDivElement | null>(null);
|
|
let connectStartPoint = $state<{ x: number; y: number } | null>(null);
|
|
const asString = (value: unknown) => (value == null ? '' : String(value));
|
|
const options = getOptions();
|
|
const readonly = options.readonly === true;
|
|
let canvasLocked = $state(readonly);
|
|
const hideBottomDock = options.hideBottomDock === true;
|
|
const availableNodes = getAvailableNodes(options);
|
|
const onRunTest = options.onRunTest;
|
|
|
|
const { updateEdgeData } = useUpdateEdgeData();
|
|
|
|
function getEventClientPosition(event: any) {
|
|
if (typeof event?.clientX === 'number' && typeof event?.clientY === 'number') {
|
|
return { x: event.clientX, y: event.clientY };
|
|
}
|
|
const touch = event?.changedTouches?.[0] || event?.touches?.[0];
|
|
if (touch && typeof touch.clientX === 'number' && typeof touch.clientY === 'number') {
|
|
return { x: touch.clientX, y: touch.clientY };
|
|
}
|
|
if (flowRootEl) {
|
|
const rect = flowRootEl.getBoundingClientRect();
|
|
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
|
}
|
|
return { x: window.innerWidth / 2, y: window.innerHeight / 2 };
|
|
}
|
|
|
|
function ensureEdgeVisualDefaults(edge: any) {
|
|
return {
|
|
...edge,
|
|
type: edge.type || 'flow',
|
|
markerEnd: edge.markerEnd || {
|
|
type: MarkerType.ArrowClosed,
|
|
width: 20,
|
|
height: 20
|
|
}
|
|
};
|
|
}
|
|
|
|
function normalizeEdgeBeforeConnect(edge: any) {
|
|
const sourceNode = edge.source ? getNode(edge.source) : null;
|
|
const sourceHandle = edge.sourceHandle || '';
|
|
const isConditionBranchEdge = sourceNode?.type === 'conditionNode'
|
|
&& typeof sourceHandle === 'string'
|
|
&& sourceHandle.startsWith('branch_');
|
|
|
|
if (isConditionBranchEdge) {
|
|
const branchId = sourceHandle.slice(7);
|
|
const branches = (sourceNode?.data?.branches || []) as Array<any>;
|
|
const branch = branches.find((item) => item?.id === branchId);
|
|
const branchLabel = branch?.label || '条件分支';
|
|
return ensureEdgeVisualDefaults({
|
|
...edge,
|
|
id: genShortId(),
|
|
data: {
|
|
...((edge as any).data || {}),
|
|
managedByConditionNode: true,
|
|
branchId,
|
|
branchLabel,
|
|
condition: `matchedBranchId === '${branchId}'`
|
|
}
|
|
});
|
|
}
|
|
|
|
return ensureEdgeVisualDefaults({
|
|
...edge,
|
|
id: edge.id || genShortId()
|
|
});
|
|
}
|
|
|
|
function addNodeByPalette(
|
|
nodeItem: NodePaletteItem,
|
|
position: { x: number; y: number },
|
|
connection?: { source: string; sourceHandle?: string }
|
|
) {
|
|
const sourceNode = connection?.source ? getNode(connection.source) : undefined;
|
|
const newNode = {
|
|
id: `node_${genShortId()}`,
|
|
position: { ...position },
|
|
type: nodeItem.type,
|
|
data: {
|
|
title: nodeItem.title,
|
|
description: nodeItem.description,
|
|
...(nodeItem.extra || {})
|
|
}
|
|
} as Node;
|
|
|
|
if (newNode.type === START_NODE_TYPE) {
|
|
newNode.data = {
|
|
...(newNode.data || {}),
|
|
parameters: ensureStartNodeParameters((newNode.data?.parameters as Array<any>) || [])
|
|
};
|
|
}
|
|
|
|
if (sourceNode) {
|
|
if (connection?.sourceHandle === 'loop_handle') {
|
|
newNode.parentId = sourceNode.id;
|
|
} else if (sourceNode.parentId) {
|
|
newNode.parentId = sourceNode.parentId;
|
|
}
|
|
|
|
if (newNode.parentId) {
|
|
const { x, y } = getNodeRelativePosition(newNode.parentId);
|
|
newNode.position = {
|
|
x: position.x - x,
|
|
y: position.y - y
|
|
};
|
|
}
|
|
}
|
|
|
|
store.addNode(newNode);
|
|
store.selectNodeOnly(newNode.id);
|
|
|
|
if (newNode.parentId) {
|
|
ensureParentInNodesBefore(newNode.parentId, newNode.id);
|
|
}
|
|
|
|
if (sourceNode && connection?.source) {
|
|
const edge = normalizeEdgeBeforeConnect({
|
|
source: connection.source,
|
|
sourceHandle: connection.sourceHandle || undefined,
|
|
target: newNode.id
|
|
});
|
|
store.addEdge(edge as Edge);
|
|
}
|
|
|
|
applyAutoBindingsForNode(newNode.id);
|
|
}
|
|
|
|
function closeNodePicker() {
|
|
nodePickerVisible = false;
|
|
pendingConnection = null;
|
|
connectStartPoint = null;
|
|
}
|
|
|
|
function handleToolbarSelectNode(nodeItem: NodePaletteItem) {
|
|
const rect = flowRootEl?.getBoundingClientRect();
|
|
const clientX = rect ? rect.left + rect.width / 2 : window.innerWidth / 2;
|
|
const clientY = rect ? rect.top + rect.height / 2 - 80 : window.innerHeight / 2;
|
|
const position = svelteFlow.screenToFlowPosition({ x: clientX, y: clientY });
|
|
addNodeByPalette(nodeItem, position);
|
|
}
|
|
|
|
function handlePickerSelectNode(nodeItem: NodePaletteItem) {
|
|
if (!pendingConnection) {
|
|
handleToolbarSelectNode(nodeItem);
|
|
closeNodePicker();
|
|
return;
|
|
}
|
|
const position = svelteFlow.screenToFlowPosition({
|
|
x: pendingConnection.clientX,
|
|
y: pendingConnection.clientY
|
|
});
|
|
addNodeByPalette(nodeItem, position, {
|
|
source: pendingConnection.source,
|
|
sourceHandle: pendingConnection.sourceHandle
|
|
});
|
|
closeNodePicker();
|
|
}
|
|
|
|
const inlineNodePickerStyle = $derived.by(() => {
|
|
if (!nodePickerVisible || !pendingConnection || !flowRootEl) {
|
|
return '';
|
|
}
|
|
const rect = flowRootEl.getBoundingClientRect();
|
|
const margin = 12;
|
|
const width = Math.min(480, Math.max(300, rect.width - margin * 2));
|
|
const maxHeight = Math.min(380, Math.max(240, rect.height - margin * 2));
|
|
const anchorX = pendingConnection.clientX - rect.left;
|
|
const anchorY = pendingConnection.clientY - rect.top;
|
|
|
|
let left = anchorX + 14;
|
|
if (left + width > rect.width - margin) {
|
|
left = rect.width - width - margin;
|
|
}
|
|
if (left < margin) {
|
|
left = margin;
|
|
}
|
|
|
|
let top = anchorY - 24;
|
|
if (top + maxHeight > rect.height - margin) {
|
|
top = rect.height - maxHeight - margin;
|
|
}
|
|
if (top < margin) {
|
|
top = margin;
|
|
}
|
|
|
|
return `left:${left}px;top:${top}px;width:${width}px;max-height:${maxHeight}px;`;
|
|
});
|
|
|
|
const pendingConnectionLine = $derived.by(() => {
|
|
if (!nodePickerVisible || !pendingConnection || !flowRootEl) {
|
|
return null;
|
|
}
|
|
const rect = flowRootEl.getBoundingClientRect();
|
|
const x1 = pendingConnection.startClientX - rect.left;
|
|
const y1 = pendingConnection.startClientY - rect.top;
|
|
const x2 = pendingConnection.clientX - rect.left;
|
|
const y2 = pendingConnection.clientY - rect.top;
|
|
const deltaX = x2 - x1;
|
|
const c1x = x1 + Math.max(26, deltaX * 0.35);
|
|
const c2x = x2 - Math.max(26, deltaX * 0.35);
|
|
return {
|
|
x1,
|
|
y1,
|
|
x2,
|
|
y2,
|
|
path: `M ${x1} ${y1} C ${c1x} ${y1}, ${c2x} ${y2}, ${x2} ${y2}`
|
|
};
|
|
});
|
|
|
|
// 支持的缩放百分比选项
|
|
const zoomOptions = [25, 50, 75, 100, 125, 150, 200];
|
|
let currentZoomPercent = $state(100);
|
|
let zoomSelectValue = $state('100');
|
|
|
|
// 监听 viewport 变化同步缩放显示值
|
|
$effect(() => {
|
|
const vp = store.getViewport();
|
|
if (vp) {
|
|
currentZoomPercent = Math.round(vp.zoom * 100);
|
|
zoomSelectValue = String(currentZoomPercent);
|
|
}
|
|
});
|
|
|
|
function handleZoomSelect(event: Event) {
|
|
const value = parseInt((event.target as HTMLSelectElement).value, 10);
|
|
if (!isNaN(value)) {
|
|
zoomSelectValue = String(value);
|
|
const zoom = value / 100;
|
|
const viewport = store.getViewport() || { x: 0, y: 0, zoom: 1 };
|
|
if (typeof (svelteFlow as any).setViewport === 'function') {
|
|
(svelteFlow as any).setViewport(
|
|
{
|
|
x: viewport.x,
|
|
y: viewport.y,
|
|
zoom
|
|
},
|
|
{ duration: 180 }
|
|
);
|
|
} else {
|
|
svelteFlow.zoomTo(zoom, { duration: 180 });
|
|
}
|
|
}
|
|
}
|
|
|
|
function fitViewCanvas() {
|
|
svelteFlow.fitView({ duration: 220, padding: 0.2 });
|
|
}
|
|
|
|
function toggleCanvasLock() {
|
|
canvasLocked = !canvasLocked;
|
|
}
|
|
|
|
const onDragOver = (event: DragEvent) => {
|
|
event.preventDefault();
|
|
if (event.dataTransfer) {
|
|
event.dataTransfer.dropEffect = 'move';
|
|
}
|
|
};
|
|
|
|
const onDrop = (event: DragEvent) => {
|
|
event.preventDefault();
|
|
|
|
const position = svelteFlow.screenToFlowPosition({
|
|
x: event.clientX - 250,
|
|
y: event.clientY - 100
|
|
});
|
|
|
|
const baseNodeJsonString = event.dataTransfer?.getData('application/tinyflow');
|
|
if (!baseNodeJsonString) {
|
|
return;
|
|
}
|
|
|
|
const baseNode = JSON.parse(baseNodeJsonString);
|
|
const newNode = {
|
|
id: `node_${genShortId()}`,
|
|
position,
|
|
data: {},
|
|
...baseNode
|
|
} satisfies Node;
|
|
|
|
store.addNode(newNode);
|
|
store.selectNodeOnly(newNode.id);
|
|
};
|
|
|
|
|
|
const { getNode } = useGetNode();
|
|
|
|
|
|
const isValidConnection = (conn: any) => {
|
|
const sourceNode = getNode(conn.source)!;
|
|
const targetNode = getNode(conn.target)!;
|
|
|
|
// 阻止循环节点连接到父级节点 或者 父级节点连接到子级节点
|
|
if (conn.sourceHandle === 'loop_handle' || sourceNode.parentId) {
|
|
const edges = svelteFlow.getEdges();
|
|
for (let edge of edges) {
|
|
if (edge.target === conn.target) {
|
|
const edgeSourceNode = getNode(edge.source) as Node;
|
|
if (conn.sourceHandle === 'loop_handle' && edgeSourceNode.parentId !== sourceNode.id) {
|
|
return false;
|
|
}
|
|
if (sourceNode.parentId && edgeSourceNode.parentId !== sourceNode.parentId) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!sourceNode.parentId && targetNode.parentId && targetNode.parentId !== sourceNode.id) {
|
|
return false;
|
|
}
|
|
|
|
// 允许链接
|
|
return true;
|
|
};
|
|
|
|
const { getNodesFromSource } = useGetNodesFromSource();
|
|
const { getNodeRelativePosition } = useGetNodeRelativePosition();
|
|
const { ensureParentInNodesBefore } = useEnsureParentInNodesBefore();
|
|
|
|
function collectAffectedNodeIds(rootNodeIds: string[], edges: Edge[] = store.getEdges()) {
|
|
const affectedNodeIds = new Set<string>();
|
|
const visit = (nodeId: string) => {
|
|
if (!nodeId || affectedNodeIds.has(nodeId)) {
|
|
return;
|
|
}
|
|
affectedNodeIds.add(nodeId);
|
|
edges
|
|
.filter((edge) => edge.source === nodeId && edge.sourceHandle !== 'loop_handle')
|
|
.forEach((edge) => {
|
|
if (edge.target) {
|
|
visit(edge.target);
|
|
}
|
|
});
|
|
};
|
|
rootNodeIds.forEach(visit);
|
|
return Array.from(affectedNodeIds);
|
|
}
|
|
|
|
function reconcileBindingsForNodes(
|
|
nodeIds: string[],
|
|
options?: {
|
|
nodes?: Node[];
|
|
edges?: Edge[];
|
|
}
|
|
) {
|
|
const uniqueNodeIds = Array.from(new Set(nodeIds.filter((nodeId) => asString(nodeId).trim())));
|
|
if (uniqueNodeIds.length === 0) {
|
|
return;
|
|
}
|
|
queueMicrotask(() => {
|
|
const nodes = options?.nodes || store.getNodes();
|
|
const edges = options?.edges || store.getEdges();
|
|
const patches = buildSequentialFieldBindingPatches(uniqueNodeIds, nodes, edges);
|
|
patches.forEach(({ nodeId, patch }) => {
|
|
store.updateNodeData(nodeId, patch);
|
|
});
|
|
});
|
|
}
|
|
|
|
const onconnectend = (event: any, state: any) => {
|
|
if (!state.isValid) {
|
|
if (state.toNode) {
|
|
return;
|
|
}
|
|
const fromNode = state.fromNode as Node | null;
|
|
if (fromNode) {
|
|
const fromHandle = state.fromHandle as { id?: string } | null;
|
|
const point = getEventClientPosition(event);
|
|
pendingConnection = {
|
|
source: fromNode.id,
|
|
sourceHandle: fromHandle?.id,
|
|
startClientX: connectStartPoint?.x ?? point.x,
|
|
startClientY: connectStartPoint?.y ?? point.y,
|
|
clientX: point.x,
|
|
clientY: point.y
|
|
};
|
|
nodePickerVisible = true;
|
|
}
|
|
return;
|
|
}
|
|
closeNodePicker();
|
|
|
|
const toNode = state.toNode as Node;
|
|
if (toNode.parentId) {
|
|
return;
|
|
}
|
|
|
|
const fromNode = state.fromNode as Node;
|
|
const fromHande = state.fromHandle as any;
|
|
|
|
const newNode = {
|
|
position: { ...toNode.position }
|
|
} as Node;
|
|
|
|
if (fromHande.id === 'loop_handle') {
|
|
newNode.parentId = fromNode.id;
|
|
} else if (fromNode.parentId) {
|
|
newNode.parentId = fromNode.parentId;
|
|
}
|
|
|
|
if (newNode.parentId) {
|
|
|
|
const { x, y } = getNodeRelativePosition(newNode.parentId);
|
|
|
|
newNode.position = {
|
|
x: toNode.position.x - x,
|
|
y: toNode.position.y - y
|
|
};
|
|
|
|
svelteFlow.updateNode(toNode.id, newNode);
|
|
|
|
// 更新目标节点的所有后续的链接节点
|
|
const nodesFromToNode = getNodesFromSource(toNode.id);
|
|
nodesFromToNode.forEach((node) => {
|
|
svelteFlow.updateNode(node.id, {
|
|
parentId: newNode.parentId,
|
|
position: {
|
|
x: node.position.x - x,
|
|
y: node.position.y - y
|
|
}
|
|
});
|
|
});
|
|
|
|
ensureParentInNodesBefore(newNode.parentId, toNode.id);
|
|
}
|
|
|
|
// 显示边面板
|
|
setTimeout(() => {
|
|
store.getEdges().forEach((edge) => {
|
|
if (edge.target === toNode.id && edge.source == fromNode.id) {
|
|
showEdgePanel = true;
|
|
currentEdge = edge;
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
|
|
const { getEdgesByTarget } = useGetEdgesByTarget();
|
|
const onDelete = (params: any) => {
|
|
const deleteEdges = params.edges as Edge[];
|
|
const affectedRootNodeIds = new Set<string>();
|
|
deleteEdges.forEach((edge) => {
|
|
if (edge.target) {
|
|
affectedRootNodeIds.add(edge.target);
|
|
}
|
|
if (edge.id === currentEdge?.id) {
|
|
currentEdge = null;
|
|
showEdgePanel = false;
|
|
}
|
|
const targetNode = getNode(edge.target) as Node;
|
|
if (targetNode && targetNode.parentId) {
|
|
const nodeEdges = getEdgesByTarget(edge.target);
|
|
// const loopNode = getNode(targetNode.parentId) as Node;
|
|
const { x, y } = getNodeRelativePosition(targetNode.parentId);
|
|
if (nodeEdges.length === 0) {
|
|
svelteFlow.updateNode(targetNode.id, {
|
|
parentId: undefined,
|
|
position: {
|
|
x: targetNode.position.x + x,
|
|
y: targetNode.position.y + y
|
|
}
|
|
});
|
|
|
|
// 更新目标节点的所有后续的链接节点
|
|
const nodesFromSource = getNodesFromSource(targetNode.id);
|
|
nodesFromSource.forEach((node) => {
|
|
svelteFlow.updateNode(node.id, {
|
|
parentId: undefined,
|
|
position: {
|
|
x: node.position.x + x,
|
|
y: node.position.y + y
|
|
}
|
|
});
|
|
});
|
|
} else {
|
|
let hasSameParent = false;
|
|
for (let i = 0; i < nodeEdges.length; i++) {
|
|
const edge = nodeEdges[i];
|
|
const sourceNode = getNode(edge.source) as Node;
|
|
if (sourceNode.parentId || sourceNode.type === 'loopNode') {
|
|
hasSameParent = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!hasSameParent) {
|
|
svelteFlow.updateNode(targetNode.id, {
|
|
parentId: undefined,
|
|
position: {
|
|
x: targetNode.position.x + x,
|
|
y: targetNode.position.y + y
|
|
}
|
|
});
|
|
|
|
// 更新目标节点的所有后续的链接节点
|
|
const nodesFromSource = getNodesFromSource(targetNode.id);
|
|
nodesFromSource.forEach((node) => {
|
|
svelteFlow.updateNode(node.id, {
|
|
parentId: undefined,
|
|
position: {
|
|
x: node.position.x + x,
|
|
y: node.position.y + y
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
if (affectedRootNodeIds.size > 0) {
|
|
queueMicrotask(() => {
|
|
reconcileBindingsForNodes(collectAffectedNodeIds(Array.from(affectedRootNodeIds)));
|
|
});
|
|
}
|
|
};
|
|
|
|
const { deleteEdge } = useDeleteEdge();
|
|
|
|
|
|
const onconnectstart = (event: any, node: any) => {
|
|
const point = getEventClientPosition(event);
|
|
connectStartPoint = { x: point.x, y: point.y };
|
|
};
|
|
|
|
|
|
const onconnect = (event: any) => {
|
|
const targetNodeId = asString(event?.target).trim();
|
|
if (!targetNodeId) {
|
|
return;
|
|
}
|
|
const sourceNodeId = asString(event?.source).trim();
|
|
const projectedEdges = [...store.getEdges()];
|
|
const hasSameEdge = projectedEdges.some((edge) =>
|
|
edge.source === sourceNodeId
|
|
&& edge.target === targetNodeId
|
|
&& (edge.sourceHandle || '') === asString(event?.sourceHandle).trim()
|
|
&& (edge.targetHandle || '') === asString(event?.targetHandle).trim()
|
|
);
|
|
|
|
if (!hasSameEdge && sourceNodeId) {
|
|
projectedEdges.push({
|
|
id: asString(event?.id).trim() || `edge_${genShortId()}`,
|
|
source: sourceNodeId,
|
|
target: targetNodeId,
|
|
sourceHandle: asString(event?.sourceHandle).trim() || undefined,
|
|
targetHandle: asString(event?.targetHandle).trim() || undefined,
|
|
} as Edge);
|
|
}
|
|
|
|
reconcileBindingsForNodes(
|
|
collectAffectedNodeIds([targetNodeId], projectedEdges),
|
|
{
|
|
edges: projectedEdges
|
|
}
|
|
);
|
|
};
|
|
|
|
function applyAutoBindingsForNode(nodeId: string) {
|
|
reconcileBindingsForNodes([nodeId]);
|
|
}
|
|
|
|
const { copyHandler, pasteHandler } = useCopyPasteHandler();
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (nodePickerVisible && e.key === 'Escape') {
|
|
e.preventDefault();
|
|
closeNodePicker();
|
|
return;
|
|
}
|
|
|
|
if (isInEditableElement()) {
|
|
return;
|
|
}
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
|
|
e.preventDefault();
|
|
copyHandler(e);
|
|
}
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
|
e.preventDefault();
|
|
// 全选所有节点
|
|
store.updateNodes((nodes) => {
|
|
return nodes.map(node => ({ ...node, selected: true }));
|
|
});
|
|
|
|
store.updateEdges((edges) => {
|
|
return edges.map(edge => ({ ...edge, selected: true }));
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleGlobalPaste = async (event: ClipboardEvent) => {
|
|
// 只在“非输入态”下处理流程图粘贴
|
|
if (isInEditableElement()) {
|
|
return;
|
|
}
|
|
|
|
pasteHandler(event);
|
|
};
|
|
|
|
function handleGlobalPointerDown(event: PointerEvent) {
|
|
if (readonly) {
|
|
return;
|
|
}
|
|
if (!nodePickerVisible || !inlineNodePickerEl) {
|
|
return;
|
|
}
|
|
const target = event.target as HTMLElement | null;
|
|
if (target && inlineNodePickerEl.contains(target)) {
|
|
return;
|
|
}
|
|
closeNodePicker();
|
|
}
|
|
|
|
onMount(() => {
|
|
store.updateEdges((edges) => edges.map((edge) => ensureEdgeVisualDefaults(edge)));
|
|
if (!readonly) {
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
window.addEventListener('paste', handleGlobalPaste);
|
|
window.addEventListener('pointerdown', handleGlobalPointerDown);
|
|
}
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (!readonly) {
|
|
window.removeEventListener('keydown', handleKeyDown);
|
|
window.removeEventListener('paste', handleGlobalPaste);
|
|
window.removeEventListener('pointerdown', handleGlobalPointerDown);
|
|
}
|
|
});
|
|
|
|
const customNodeTypes = {
|
|
// ...nodeTypes
|
|
} as NodeTypes;
|
|
const customEdgeTypes = {
|
|
flow: FlowEdge
|
|
} as EdgeTypes;
|
|
|
|
const customNodes = options.customNodes;
|
|
if (customNodes) {
|
|
for (let key of Object.keys(customNodes)) {
|
|
customNodeTypes[key] = CustomNode as any;
|
|
}
|
|
}
|
|
|
|
const onDataChange = options.onDataChange;
|
|
$effect(() => {
|
|
onDataChange?.({
|
|
nodes: store.getNodes(),
|
|
edges: store.getEdges(),
|
|
viewport: store.getViewport()
|
|
});
|
|
});
|
|
|
|
|
|
</script>
|
|
|
|
|
|
<div
|
|
class:tf-canvas-locked={canvasLocked}
|
|
style="position: relative; height: 100%; width: 100%;overflow: hidden"
|
|
bind:this={flowRootEl}
|
|
>
|
|
<SvelteFlow nodeTypes={{ ...nodeTypes, ...customNodeTypes}}
|
|
edgeTypes={customEdgeTypes}
|
|
bind:nodes={store.getNodes, store.setNodes}
|
|
bind:edges={store.getEdges, store.setEdges}
|
|
bind:viewport={store.getViewport, store.setViewport}
|
|
nodesDraggable={!canvasLocked}
|
|
nodesConnectable={!canvasLocked}
|
|
elementsSelectable={!canvasLocked}
|
|
panOnDrag={readonly ? true : !canvasLocked}
|
|
zoomOnScroll={readonly ? true : !canvasLocked}
|
|
zoomOnDoubleClick={readonly ? true : !canvasLocked}
|
|
ondrop={readonly ? undefined : onDrop}
|
|
ondragover={readonly ? undefined : onDragOver}
|
|
isValidConnection={isValidConnection}
|
|
onconnectend={readonly ? undefined : onconnectend}
|
|
onconnectstart={readonly ? undefined : onconnectstart}
|
|
onconnect={readonly ? undefined : onconnect}
|
|
connectionRadius={50}
|
|
connectionLineComponent={FlowConnectionLine}
|
|
onedgeclick={(e) => {
|
|
if (readonly) {
|
|
return;
|
|
}
|
|
showEdgePanel = true;
|
|
currentEdge = e.edge;
|
|
}}
|
|
onbeforeconnect={(edge: any) => normalizeEdgeBeforeConnect(edge)}
|
|
ondelete={readonly ? undefined : onDelete}
|
|
onclick={(e) => {
|
|
if (readonly) {
|
|
return;
|
|
}
|
|
const el = e.target as HTMLElement;
|
|
if (el.classList.contains("svelte-flow__edge-interaction")
|
|
|| el.classList.contains('panel-content')
|
|
|| el.closest('.panel-content')){
|
|
return
|
|
}
|
|
showEdgePanel = false;
|
|
currentEdge = null;
|
|
}}
|
|
defaultEdgeOptions={{
|
|
type: 'flow',
|
|
markerEnd: {
|
|
type: MarkerType.ArrowClosed,
|
|
width: 20,
|
|
height: 20
|
|
}
|
|
}}
|
|
>
|
|
<Background />
|
|
<MiniMap />
|
|
|
|
{#if showEdgePanel}
|
|
<Panel>
|
|
<div class="panel-content">
|
|
<div>边属性设置</div>
|
|
<div class="setting-title">边条件设置</div>
|
|
<div class="setting-item">
|
|
{#if currentEdge?.data?.managedByConditionNode}
|
|
<div class="readonly-edge-settings">
|
|
<div class="readonly-edge-branch">
|
|
分支:{currentEdge?.data?.branchLabel || currentEdge?.data?.branchId || '-'}
|
|
</div>
|
|
<Textarea
|
|
rows={3}
|
|
style="width: 100%"
|
|
disabled={true}
|
|
value={asString(currentEdge?.data?.condition)}
|
|
/>
|
|
<div class="readonly-edge-tip">
|
|
该连线由条件节点托管,条件表达式不可在边面板手动修改。
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<Textarea
|
|
rows={3}
|
|
placeholder="请输入边条件"
|
|
style="width: 100%"
|
|
value={asString(currentEdge?.data?.condition)}
|
|
onchange={(e)=>{
|
|
if (currentEdge){
|
|
updateEdgeData(currentEdge.id, {
|
|
condition: (e.target as HTMLTextAreaElement)?.value || ''
|
|
})
|
|
}
|
|
}}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
<div class="setting-item" style="padding: 8px 0">
|
|
<Button
|
|
onclick={() => {
|
|
if (currentEdge?.id) {
|
|
deleteEdge(currentEdge.id)
|
|
}
|
|
showEdgePanel = false;
|
|
}}
|
|
>
|
|
删除
|
|
</Button>
|
|
|
|
<Button
|
|
primary={true}
|
|
onclick={() => {
|
|
showEdgePanel = false;
|
|
}}
|
|
>
|
|
保存
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
{/if}
|
|
</SvelteFlow>
|
|
{#if nodePickerVisible}
|
|
{#if pendingConnectionLine}
|
|
<svg class="node-picker-connection-line" width="100%" height="100%">
|
|
<FlowMarkerDefs id="tf-flow-inline-arrow-closed" />
|
|
<FlowLinePath
|
|
path={pendingConnectionLine.path}
|
|
markerEnd="url(#tf-flow-inline-arrow-closed)"
|
|
animated={false}
|
|
/>
|
|
</svg>
|
|
{/if}
|
|
<div
|
|
class="node-picker-inline"
|
|
style={inlineNodePickerStyle}
|
|
bind:this={inlineNodePickerEl}
|
|
>
|
|
<NodePicker nodes={availableNodes} onSelect={handlePickerSelectNode} />
|
|
</div>
|
|
{/if}
|
|
{#if !hideBottomDock}
|
|
<div class="tf-bottom-dock">
|
|
<div class="tf-unified-bar">
|
|
<!-- 缩放百分比选择器 -->
|
|
<select
|
|
class="tf-zoom-select tf-zoom-select-simple"
|
|
value={zoomSelectValue}
|
|
onchange={handleZoomSelect}
|
|
aria-label="缩放比例"
|
|
>
|
|
{#each zoomOptions as opt}
|
|
<option value={String(opt)}>{opt}%</option>
|
|
{/each}
|
|
{#if !zoomOptions.includes(currentZoomPercent)}
|
|
<option value={String(currentZoomPercent)} selected>{currentZoomPercent}%</option>
|
|
{/if}
|
|
</select>
|
|
|
|
<!-- 适配视图按钮 -->
|
|
<button
|
|
class="tf-bar-btn"
|
|
onclick={fitViewCanvas}
|
|
aria-label="适配视图"
|
|
title="适配视图"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M7 7H10V5H5V10H7V7ZM14 5V7H17V10H19V5H14ZM17 17H14V19H19V14H17V17ZM7 14H5V19H10V17H7V14Z"></path>
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- 锁定/解锁按钮 -->
|
|
<button
|
|
class="tf-bar-btn {canvasLocked ? 'tf-bar-btn-active' : ''}"
|
|
onclick={toggleCanvasLock}
|
|
aria-label={canvasLocked ? '解锁画布' : '锁定画布'}
|
|
title={canvasLocked ? '解锁画布' : '锁定画布'}
|
|
>
|
|
{#if canvasLocked}
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M6 10V8C6 4.68629 8.68629 2 12 2C15.3137 2 18 4.68629 18 8V10H19C19.5523 10 20 10.4477 20 11V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V11C4 10.4477 4.44772 10 5 10H6ZM8 10H16V8C16 5.79086 14.2091 4 12 4C9.79086 4 8 5.79086 8 8V10Z"></path>
|
|
</svg>
|
|
{:else}
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M7 10V8C7 5.23858 9.23858 3 12 3C14.7614 3 17 5.23858 17 8V10H20V22H4V10H7ZM9 10H15V8C15 6.34315 13.6569 5 12 5C10.3431 5 9 6.34315 9 8V10Z"></path>
|
|
</svg>
|
|
{/if}
|
|
</button>
|
|
|
|
<!-- 分割线 -->
|
|
<div class="tf-bar-divider"></div>
|
|
|
|
<!-- 增加节点按钮(由 Toolbar 渲染) -->
|
|
<Toolbar nodes={availableNodes} onSelectNode={handleToolbarSelectNode} />
|
|
|
|
</div>
|
|
{#if onRunTest}
|
|
<button
|
|
class="tf-bar-run-btn"
|
|
onclick={() => onRunTest?.()}
|
|
aria-label="试运行"
|
|
title="试运行"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M8 5V19L19 12L8 5Z"></path>
|
|
</svg>
|
|
试运行
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.panel-content {
|
|
padding: 10px;
|
|
background-color: var(--tf-bg-surface);
|
|
border-radius: 5px;
|
|
box-shadow: var(--tf-shadow-soft);
|
|
width: 200px;
|
|
border: 1px solid var(--tf-border-color);
|
|
color: var(--tf-text-primary);
|
|
}
|
|
|
|
.setting-title {
|
|
margin: 10px 0;
|
|
font-size: 12px;
|
|
color: var(--tf-text-muted);
|
|
}
|
|
|
|
.setting-item {
|
|
display: flex;
|
|
gap: 5px;
|
|
align-items: center;
|
|
justify-content: end;
|
|
}
|
|
|
|
.readonly-edge-settings {
|
|
width: 100%;
|
|
}
|
|
|
|
.readonly-edge-branch {
|
|
font-size: 12px;
|
|
color: var(--tf-text-secondary);
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.readonly-edge-tip {
|
|
margin-top: 6px;
|
|
font-size: 12px;
|
|
color: var(--tf-text-muted);
|
|
}
|
|
|
|
.node-picker-inline {
|
|
position: absolute;
|
|
z-index: 270;
|
|
min-width: 300px;
|
|
border: 1px solid var(--tf-border-color);
|
|
border-radius: 12px;
|
|
background: var(--tf-bg-surface);
|
|
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.2);
|
|
padding: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.node-picker-connection-line {
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 268;
|
|
pointer-events: none;
|
|
}
|
|
|
|
:global(.tf-canvas-locked .svelte-flow__pane),
|
|
:global(.tf-canvas-locked .svelte-flow__nodes),
|
|
:global(.tf-canvas-locked .svelte-flow__edges),
|
|
:global(.tf-canvas-locked .svelte-flow__connectionline),
|
|
:global(.tf-canvas-locked .svelte-flow__selection) {
|
|
pointer-events: none !important;
|
|
}
|
|
</style>
|