fix(editor): route loops from endpoint heights

This commit is contained in:
Lucio Lelii 2026-09-30 08:50:57 +02:00
parent 09abd7e3f6
commit 11bcfb5973
2 changed files with 37 additions and 10 deletions

View File

@ -6,7 +6,7 @@ import { DEFAULT_NODE_CAPABILITIES, RESOURCE_NODE_CAPABILITIES } from '@models/f
import { HFNode } from '@models/nodes';
import { vi } from 'vitest';
import { isResourceNode, resolveNodeCapabilities, ReteRuntimeContext, syncResourceLinkPresentation } from './rete-editor';
import { isResourceNode, refreshLoopMarkers, resolveNodeCapabilities, ReteRuntimeContext, syncResourceLinkPresentation } from './rete-editor';
function makeNode(typeName: string, nodeFamily: 'block' | 'container' | 'resource'): HFNode {
return { data: { typeName, nodeFamily } } as unknown as HFNode;
@ -87,6 +87,39 @@ describe('isResourceNode', () => {
});
});
describe('loop route placement', () => {
it('starts 48px above its highest endpoint, irrespective of a higher node between them', async () => {
const start = { id: 'start', data: { typeName: 'Start', nodeFamily: 'block' } } as any;
const router = { id: 'router', data: { typeName: 'Router', nodeFamily: 'block' } } as any;
const intermediate = { id: 'intermediate', data: { typeName: 'Step', nodeFamily: 'block' } } as any;
const forward = { id: 'forward', source: 'start', sourceOutput: 'out', target: 'router', targetInput: 'in' } as any;
const loop = { id: 'loop', source: 'router', sourceOutput: 'out', target: 'start', targetInput: 'in', loop: {} } as any;
const editor = {
getNodes: () => [start, router, intermediate],
getConnections: () => [forward, loop]
} as any;
const area = {
nodeViews: new Map([
['start', { position: { x: 0, y: 500 }, element: { offsetWidth: 100 } }],
['router', { position: { x: 400, y: 400 }, element: { offsetWidth: 100 } }],
['intermediate', { position: { x: 200, y: 100 }, element: { offsetWidth: 100 } }]
]),
update: vi.fn().mockResolvedValue(undefined)
} as any;
const runtime = makeRuntime({
blocksService: {
peekBlockType: vi.fn().mockImplementation((type: string) =>
type === 'Router' ? { capabilities: { ...DEFAULT_NODE_CAPABILITIES, routesExclusively: true } } : null)
} as any
});
await refreshLoopMarkers(editor, area, runtime);
expect(loop.__loopBack).toBe(true);
expect(loop.__loopTop).toBe(352);
});
});
describe('the link between a resource and a step that uses it', () => {
it('replaces an S3 operation choice with PostgreSQL choices when its resource changes', async () => {
const { NodeEditor, ClassicPreset } = await import('rete');

View File

@ -977,21 +977,15 @@ export async function refreshLoopMarkers(
}
/**
* The initial route of a loop: above the nodes it has to avoid when the loop is first established.
* The initial route of a loop: 48px above its highest endpoint. Nodes in between are deliberately
* ignored, since they may subsequently be moved across this fixed route.
*/
function loopTop(area: AreaPlugin<HFSchemes, AreaExtra>, connection: LoopAwareConnection): number | undefined {
const ends = [connection.source, connection.target]
.map((id) => area.nodeViews.get(id))
.filter((view): view is NonNullable<typeof view> => view != null);
if (!ends.length) return undefined;
// The way back runs across everything between its two ends, so it has to clear the tallest of
// those nodes too - the loop's own steps usually sit there - not only the two it connects.
const left = Math.min(...ends.map((view) => view.position.x));
const right = Math.max(...ends.map((view) => view.position.x + view.element.offsetWidth));
const tops = [...area.nodeViews.values()]
.filter((view) => view.position.x + view.element.offsetWidth >= left && view.position.x <= right)
.map((view) => view.position.y);
return Math.min(...tops, ...ends.map((view) => view.position.y)) - 48;
return Math.min(...ends.map((view) => view.position.y)) - 48;
}
/**