perf: 优化并重构工作流幕布UI表现

This commit is contained in:
2026-03-05 21:40:05 +08:00
parent 03c5f2cd2e
commit 265bb79ba3
12 changed files with 1384 additions and 273 deletions

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import {
Background,
Controls,
type Edge,
type EdgeTypes,
MarkerType,
MiniMap,
type Node,
@@ -16,6 +16,11 @@
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';
@@ -31,6 +36,7 @@
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';
const { onInit }: { onInit: any; [key: string]: any } = $props();
const svelteFlow = useSvelteFlow();
@@ -39,10 +45,259 @@
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);
let canvasLocked = $state(false);
const asString = (value: unknown) => (value == null ? '' : String(value));
const options = getOptions();
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 (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);
}
}
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) {
@@ -110,10 +365,28 @@
const { getNodesFromSource } = useGetNodesFromSource();
const { getNodeRelativePosition } = useGetNodeRelativePosition();
const { ensureParentInNodesBefore } = useEnsureParentInNodesBefore();
const onconnectend = (_: any, state: any) => {
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) {
@@ -244,7 +517,8 @@
const onconnectstart = (event: any, node: any) => {
// console.log('onconnectstart: ', event, node);
const point = getEventClientPosition(event);
connectStartPoint = { x: point.x, y: point.y };
};
@@ -255,6 +529,11 @@
const { copyHandler, pasteHandler } = useCopyPasteHandler();
const handleKeyDown = (e: KeyboardEvent) => {
if (nodePickerVisible && e.key === 'Escape') {
e.preventDefault();
closeNodePicker();
return;
}
if (isInEditableElement()) {
return;
@@ -287,28 +566,45 @@
pasteHandler(event);
};
function handleGlobalPointerDown(event: PointerEvent) {
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)));
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('paste', handleGlobalPaste);
window.addEventListener('pointerdown', handleGlobalPointerDown);
});
onDestroy(() => {
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 = getOptions().customNodes;
const customNodes = options.customNodes;
if (customNodes) {
for (let key of Object.keys(customNodes)) {
customNodeTypes[key] = CustomNode as any;
}
}
const onDataChange = getOptions().onDataChange;
const onDataChange = options.onDataChange;
$effect(() => {
onDataChange?.({
nodes: store.getNodes(),
@@ -321,11 +617,22 @@
</script>
<div style="position: relative; height: 100%; width: 100%;overflow: hidden">
<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={!canvasLocked}
zoomOnScroll={!canvasLocked}
zoomOnDoubleClick={!canvasLocked}
ondrop={onDrop}
ondragover={onDragOver}
isValidConnection={isValidConnection}
@@ -333,40 +640,12 @@
onconnectstart={onconnectstart}
onconnect={onconnect}
connectionRadius={50}
connectionLineComponent={FlowConnectionLine}
onedgeclick={(e) => {
showEdgePanel = true;
currentEdge = e.edge;
}}
onbeforeconnect={(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 {
...edge,
id: genShortId(),
data: {
...((edge as any).data || {}),
managedByConditionNode: true,
branchId,
branchLabel,
condition: `matchedBranchId === '${branchId}'`
}
};
}
return {
...edge,
id:genShortId(),
}
}}
onbeforeconnect={(edge: any) => normalizeEdgeBeforeConnect(edge)}
ondelete={onDelete}
onclick={(e) => {
const el = e.target as HTMLElement;
@@ -379,18 +658,15 @@
currentEdge = null;
}}
defaultEdgeOptions={{
// animated: true,
// label: 'edge label',
type: 'flow',
markerEnd: {
type: MarkerType.ArrowClosed,
// color: 'red',
width: 20,
height: 20
}
}}
>
<Background />
<Controls />
<MiniMap />
{#if showEdgePanel}
@@ -455,7 +731,93 @@
</Panel>
{/if}
</SvelteFlow>
<Toolbar />
{#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}
<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>
</div>
<style>
@@ -497,4 +859,31 @@
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>