|
1 | 1 | <script lang="ts"> |
2 | | - import type { EdgeTypes, OnBeforeConnect, OnBeforeReconnect } from '@xyflow/svelte'; |
3 | | - import { Background, Controls, MiniMap, Panel, SvelteFlow } from '@xyflow/svelte'; |
4 | | - import { setContext } from 'svelte'; |
| 2 | + import type { EdgeTypes, Node, OnBeforeConnect, OnBeforeReconnect } from '@xyflow/svelte'; |
| 3 | + import { Background, Controls, MiniMap, SvelteFlow } from '@xyflow/svelte'; |
| 4 | + import { onDestroy, setContext } from 'svelte'; |
5 | 5 |
|
6 | 6 | import type { Issue } from '../engine/HydraEngine.js'; |
7 | 7 | import { getAllDefinitions } from '../nodes/registry.js'; |
8 | 8 | import type { InputValue, IREdge, IRNode, NodeDefinition } from '../types.js'; |
9 | 9 | import { getLayoutedElements } from '../utils/layout.js'; |
| 10 | + import { createLayoutAnimator } from '../utils/layoutAnimator.js'; |
10 | 11 | import CustomEdge from './CustomEdge.svelte'; |
11 | 12 | import CustomNode from './CustomNode.svelte'; |
12 | 13 | let { |
|
23 | 24 | validationIssues?: Issue[]; |
24 | 25 | }>(); |
25 | 26 |
|
| 27 | + let displayNodes = $state.raw<IRNode[]>([]); |
| 28 | + let isInitialized = $state(false); |
| 29 | +
|
26 | 30 | type NodeValidationStatus = { |
27 | 31 | hasError: boolean; |
28 | 32 | hasWarning: boolean; |
|
127 | 131 | }); |
128 | 132 | } |
129 | 133 |
|
130 | | - function onLayout(direction: 'TB' | 'LR') { |
131 | | - const layouted = getLayoutedElements(nodes, edges, direction); |
| 134 | + const POSITION_TOLERANCE = 0.5; |
| 135 | + const LAYOUT_DIRECTION = 'TB'; |
| 136 | +
|
| 137 | + const animator = createLayoutAnimator(250); |
| 138 | +
|
| 139 | + function shouldApplyAutoLayout(currentNodes: IRNode[], layoutedNodes: IRNode[]): boolean { |
| 140 | + if (currentNodes.length !== layoutedNodes.length) { |
| 141 | + return true; |
| 142 | + } |
| 143 | +
|
| 144 | + const layoutMap = new Map(layoutedNodes.map((node) => [node.id, node])); |
| 145 | +
|
| 146 | + for (const node of currentNodes) { |
| 147 | + const layoutNode = layoutMap.get(node.id); |
| 148 | + if (!layoutNode) { |
| 149 | + return true; |
| 150 | + } |
| 151 | +
|
| 152 | + const deltaX = Math.abs((node.position?.x ?? 0) - layoutNode.position.x); |
| 153 | + const deltaY = Math.abs((node.position?.y ?? 0) - layoutNode.position.y); |
| 154 | +
|
| 155 | + if (deltaX > POSITION_TOLERANCE || deltaY > POSITION_TOLERANCE) { |
| 156 | + return true; |
| 157 | + } |
| 158 | +
|
| 159 | + if ( |
| 160 | + node.sourcePosition !== layoutNode.sourcePosition || |
| 161 | + node.targetPosition !== layoutNode.targetPosition |
| 162 | + ) { |
| 163 | + return true; |
| 164 | + } |
| 165 | + } |
| 166 | +
|
| 167 | + return false; |
| 168 | + } |
| 169 | +
|
| 170 | + $effect(() => { |
| 171 | + if (nodes.length === 0) { |
| 172 | + return; |
| 173 | + } |
| 174 | +
|
| 175 | + const layouted = getLayoutedElements(nodes, edges, LAYOUT_DIRECTION); |
| 176 | +
|
| 177 | + if (!shouldApplyAutoLayout(nodes, layouted.nodes)) { |
| 178 | + return; |
| 179 | + } |
| 180 | +
|
132 | 181 | nodes = [...layouted.nodes]; |
133 | 182 | edges = [...layouted.edges]; |
134 | | - } |
| 183 | + }); |
| 184 | +
|
| 185 | + $effect(() => { |
| 186 | + if (nodes.length === 0) { |
| 187 | + isInitialized = true; |
| 188 | + animator.handleLayoutChange([], (next) => { |
| 189 | + displayNodes = next; |
| 190 | + }); |
| 191 | + return; |
| 192 | + } |
| 193 | +
|
| 194 | + if (!isInitialized) { |
| 195 | + displayNodes = [...nodes]; |
| 196 | + isInitialized = true; |
| 197 | + animator.handleLayoutChange(nodes, (next) => { |
| 198 | + displayNodes = next; |
| 199 | + }); |
| 200 | + return; |
| 201 | + } |
| 202 | +
|
| 203 | + animator.handleLayoutChange(nodes, (next) => { |
| 204 | + displayNodes = next; |
| 205 | + }); |
| 206 | + }); |
| 207 | +
|
| 208 | + onDestroy(() => { |
| 209 | + animator.stop(); |
| 210 | + }); |
135 | 211 |
|
136 | 212 | function disconnectExistingTarget( |
137 | 213 | targetNodeId: string | null | undefined, |
|
181 | 257 | <div class="flow-canvas"> |
182 | 258 | {#if Object.keys(nodeTypes).length > 0} |
183 | 259 | <SvelteFlow |
184 | | - bind:nodes |
| 260 | + bind:nodes={displayNodes as Node[]} |
185 | 261 | bind:edges |
186 | 262 | {nodeTypes} |
187 | 263 | {edgeTypes} |
188 | 264 | fitView |
| 265 | + nodesDraggable={false} |
189 | 266 | onbeforeconnect={handleBeforeConnect} |
190 | 267 | onbeforereconnect={handleBeforeReconnect} |
191 | 268 | class="flow-container" |
192 | 269 | > |
193 | 270 | <Background /> |
194 | | - <Controls /> |
| 271 | + <Controls showLock={false} /> |
195 | 272 | <MiniMap /> |
196 | | - <Panel position="top-right"> |
197 | | - <div class="layout-buttons"> |
198 | | - <button onclick={() => onLayout('TB')} class="layout-btn"> Vertical </button> |
199 | | - <button onclick={() => onLayout('LR')} class="layout-btn"> Horizontal </button> |
200 | | - </div> |
201 | | - </Panel> |
202 | 273 | </SvelteFlow> |
203 | 274 | {:else} |
204 | 275 | <div class="loading-canvas"> |
|
270 | 341 | padding: 6px 12px; |
271 | 342 | } |
272 | 343 |
|
273 | | - .layout-buttons { |
274 | | - display: flex; |
275 | | - gap: 8px; |
276 | | - background: rgba(255, 255, 255, 0.95); |
277 | | - padding: 12px; |
278 | | - border-radius: 8px; |
279 | | - backdrop-filter: blur(4px); |
280 | | - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); |
281 | | - } |
282 | | -
|
283 | | - .layout-btn { |
284 | | - padding: 8px 12px; |
285 | | - background: #4caf50; |
286 | | - color: white; |
287 | | - border: none; |
288 | | - border-radius: 6px; |
289 | | - cursor: pointer; |
290 | | - font-size: 12px; |
291 | | - font-weight: 600; |
292 | | - transition: all 0.2s; |
293 | | - } |
294 | | -
|
295 | | - .layout-btn:hover { |
296 | | - background: #45a049; |
297 | | - transform: translateY(-1px); |
298 | | - } |
299 | | -
|
300 | 344 | .flow-canvas { |
301 | 345 | flex: 1; |
302 | 346 | position: relative; |
|
0 commit comments