Implement whole-node easy-connect handle system with floating edges

Migrated from 4-position handle system (top/right/bottom/left) to React Flow's
easy-connect pattern where the entire node surface is connectable and edges
dynamically route to the nearest point on the node border.

Key changes:
- Migration utility removes old 4-position handle references for backwards compatibility
- Full-coverage invisible handles on CustomNode and GroupNode (maximized state)
- Floating edges use node.measured dimensions and node.internals.positionAbsolute
- useInternalNode hook for correct absolute positioning of nodes in groups
- All edges now omit handle fields, allowing dynamic border calculations

This improves UX by making nodes easier to connect (whole surface vs tiny handles)
and edges intelligently route to optimal connection points.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2026-01-24 13:01:04 +01:00
parent ae552a9fbd
commit c9c888d0ac
11 changed files with 567 additions and 123 deletions

View file

@ -4,11 +4,10 @@ import {
getBezierPath, getBezierPath,
EdgeLabelRenderer, EdgeLabelRenderer,
BaseEdge, BaseEdge,
useNodes, useInternalNode,
} from '@xyflow/react'; } from '@xyflow/react';
import { useGraphStore } from '../../stores/graphStore'; import { useGraphStore } from '../../stores/graphStore';
import type { Relation } from '../../types'; import type { Relation } from '../../types';
import type { Group } from '../../types';
import LabelBadge from '../Common/LabelBadge'; import LabelBadge from '../Common/LabelBadge';
import { getFloatingEdgeParams } from '../../utils/edgeUtils'; import { getFloatingEdgeParams } from '../../utils/edgeUtils';
import { useActiveFilters, edgeMatchesFilters } from '../../hooks/useActiveFilters'; import { useActiveFilters, edgeMatchesFilters } from '../../hooks/useActiveFilters';
@ -45,18 +44,11 @@ const CustomEdge = ({
// Get active filters based on mode (editing vs presentation) // Get active filters based on mode (editing vs presentation)
const filters = useActiveFilters(); const filters = useActiveFilters();
// Get all nodes to check if source/target are minimized groups // Get internal nodes for floating edge calculations with correct absolute positioning
const nodes = useNodes(); const sourceNode = useInternalNode(source);
const sourceNode = nodes.find((n) => n.id === source); const targetNode = useInternalNode(target);
const targetNode = nodes.find((n) => n.id === target);
// Check if either endpoint is a minimized group // Always use floating edges for easy-connect (dynamic border point calculation)
const sourceIsMinimizedGroup = sourceNode?.type === 'group' && (sourceNode.data as Group['data']).minimized;
const targetIsMinimizedGroup = targetNode?.type === 'group' && (targetNode.data as Group['data']).minimized;
// Calculate floating edge parameters if needed
// When connecting to groups (especially minimized ones), we need to use floating edges
// because groups don't have specific handles
let finalSourceX = sourceX; let finalSourceX = sourceX;
let finalSourceY = sourceY; let finalSourceY = sourceY;
let finalTargetX = targetX; let finalTargetX = targetX;
@ -64,25 +56,15 @@ const CustomEdge = ({
let finalSourcePosition = sourcePosition; let finalSourcePosition = sourcePosition;
let finalTargetPosition = targetPosition; let finalTargetPosition = targetPosition;
// Check if we need to use floating edge calculations // Use floating edge calculations for ALL edges to get smart border connection
const needsFloatingEdge = (sourceIsMinimizedGroup || targetIsMinimizedGroup) && sourceNode && targetNode; if (sourceNode && targetNode) {
if (needsFloatingEdge) {
const floatingParams = getFloatingEdgeParams(sourceNode, targetNode); const floatingParams = getFloatingEdgeParams(sourceNode, targetNode);
finalSourceX = floatingParams.sx;
// When either endpoint is a minimized group, use floating positions for that side finalSourceY = floatingParams.sy;
// IMPORTANT: When BOTH are groups, we must use floating for BOTH sides finalSourcePosition = floatingParams.sourcePos;
if (sourceIsMinimizedGroup) { finalTargetX = floatingParams.tx;
finalSourceX = floatingParams.sx; finalTargetY = floatingParams.ty;
finalSourceY = floatingParams.sy; finalTargetPosition = floatingParams.targetPos;
finalSourcePosition = floatingParams.sourcePos;
}
if (targetIsMinimizedGroup) {
finalTargetX = floatingParams.tx;
finalTargetY = floatingParams.ty;
finalTargetPosition = floatingParams.targetPos;
}
} }
// Calculate the bezier path // Calculate the bezier path

View file

@ -1,5 +1,5 @@
import { memo, useMemo } from "react"; import { memo, useMemo } from "react";
import { Handle, Position, NodeProps, useConnection } from "@xyflow/react"; import { Handle, Position, NodeProps } from "@xyflow/react";
import { useGraphStore } from "../../stores/graphStore"; import { useGraphStore } from "../../stores/graphStore";
import { import {
getContrastColor, getContrastColor,
@ -16,7 +16,7 @@ import { useActiveFilters, nodeMatchesFilters } from "../../hooks/useActiveFilte
* *
* Features: * Features:
* - Visual representation with type-based coloring * - Visual representation with type-based coloring
* - Connection handles (top, right, bottom, left) * - Easy-connect: whole node is connectable, edges auto-route to nearest border point
* - Label display * - Label display
* - Type badge * - Type badge
* *
@ -29,10 +29,6 @@ const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
// Get active filters based on mode (editing vs presentation) // Get active filters based on mode (editing vs presentation)
const filters = useActiveFilters(); const filters = useActiveFilters();
// Check if any connection is being made (to show handles)
const connection = useConnection();
const isConnecting = !!connection.inProgress;
// Find the node type configuration // Find the node type configuration
const nodeTypeConfig = nodeTypes.find((nt) => nt.id === data.type); const nodeTypeConfig = nodeTypes.find((nt) => nt.id === data.type);
const nodeColor = nodeTypeConfig?.color || "#6b7280"; const nodeColor = nodeTypeConfig?.color || "#6b7280";
@ -46,9 +42,6 @@ const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
? adjustColorBrightness(nodeColor, -20) ? adjustColorBrightness(nodeColor, -20)
: nodeColor; : nodeColor;
// Show handles when selected or when connecting
const showHandles = selected || isConnecting;
// Check if this node matches the filter criteria // Check if this node matches the filter criteria
const isMatch = useMemo(() => { const isMatch = useMemo(() => {
return nodeMatchesFilters( return nodeMatchesFilters(
@ -85,64 +78,39 @@ const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
opacity: nodeOpacity, opacity: nodeOpacity,
}} }}
> >
{/* Connection handles - shown only when selected or connecting */} {/* Invisible handles for easy-connect - floating edges calculate actual connection points */}
{/* Target handle - full node coverage for incoming connections */}
<Handle <Handle
type="source" type="target"
position={Position.Top} position={Position.Top}
id="top"
isConnectable={true} isConnectable={true}
isConnectableStart={true}
isConnectableEnd={true}
className="w-2 h-2 transition-opacity"
style={{ style={{
background: adjustColorBrightness(nodeColor, -30), width: '100%',
opacity: showHandles ? 1 : 0, height: '100%',
border: `1px solid ${textColor}`, top: 0,
left: 0,
borderRadius: 0,
opacity: 0,
border: 'none',
background: 'transparent',
transform: 'none',
}} }}
/> />
{/* Source handle - full node coverage for outgoing connections */}
<Handle
type="source"
position={Position.Right}
id="right"
isConnectable={true}
isConnectableStart={true}
isConnectableEnd={true}
className="w-2 h-2 transition-opacity"
style={{
background: adjustColorBrightness(nodeColor, -30),
opacity: showHandles ? 1 : 0,
border: `1px solid ${textColor}`,
}}
/>
<Handle <Handle
type="source" type="source"
position={Position.Bottom} position={Position.Bottom}
id="bottom"
isConnectable={true} isConnectable={true}
isConnectableStart={true}
isConnectableEnd={true}
className="w-2 h-2 transition-opacity"
style={{ style={{
background: adjustColorBrightness(nodeColor, -30), width: '100%',
opacity: showHandles ? 1 : 0, height: '100%',
border: `1px solid ${textColor}`, top: 0,
}} left: 0,
/> borderRadius: 0,
opacity: 0,
<Handle border: 'none',
type="source" background: 'transparent',
position={Position.Left} transform: 'none',
id="left"
isConnectable={true}
isConnectableStart={true}
isConnectableEnd={true}
className="w-2 h-2 transition-opacity"
style={{
background: adjustColorBrightness(nodeColor, -30),
opacity: showHandles ? 1 : 0,
border: `1px solid ${textColor}`,
}} }}
/> />

View file

@ -220,6 +220,42 @@ const GroupNode = ({ id, data, selected }: NodeProps<Group>) => {
position: 'relative', position: 'relative',
}} }}
> >
{/* Invisible handles for easy-connect - floating edges calculate actual connection points */}
{/* Target handle - full node coverage for incoming connections */}
<Handle
type="target"
position={Position.Top}
isConnectable={true}
style={{
width: '100%',
height: '100%',
top: 0,
left: 0,
borderRadius: 0,
opacity: 0,
border: 'none',
background: 'transparent',
transform: 'none',
}}
/>
{/* Source handle - full node coverage for outgoing connections */}
<Handle
type="source"
position={Position.Bottom}
isConnectable={true}
style={{
width: '100%',
height: '100%',
top: 0,
left: 0,
borderRadius: 0,
opacity: 0,
border: 'none',
background: 'transparent',
transform: 'none',
}}
/>
{/* Background color overlay - uses group's custom color */} {/* Background color overlay - uses group's custom color */}
<div <div
style={{ style={{

View file

@ -1256,6 +1256,69 @@ describe('graphStore', () => {
const loadedNode = state.nodes[0]; const loadedNode = state.nodes[0];
expect(loadedNode.parentId).toBe('group-1'); expect(loadedNode.parentId).toBe('group-1');
}); });
it('should migrate old 4-position handle references by removing handles', () => {
const { loadGraphState } = useGraphStore.getState();
// Create edges with old handle format
const edgeWithOldHandles: Relation = {
...createMockEdge('edge-1', 'node-1', 'node-2'),
sourceHandle: 'right',
targetHandle: 'left',
};
const edgeWithTopBottom: Relation = {
...createMockEdge('edge-2', 'node-1', 'node-2'),
sourceHandle: 'top',
targetHandle: 'bottom',
};
loadGraphState({
nodes: [createMockNode('node-1'), createMockNode('node-2')],
edges: [edgeWithOldHandles, edgeWithTopBottom],
groups: [],
nodeTypes: [],
edgeTypes: [],
labels: [],
});
const state = useGraphStore.getState();
// Both edges should have handles removed (undefined) for floating edge pattern
expect(state.edges[0].sourceHandle).toBeUndefined();
expect(state.edges[0].targetHandle).toBeUndefined();
expect(state.edges[1].sourceHandle).toBeUndefined();
expect(state.edges[1].targetHandle).toBeUndefined();
// Other fields should be preserved
expect(state.edges[0].id).toBe('edge-1');
expect(state.edges[0].source).toBe('node-1');
expect(state.edges[0].target).toBe('node-2');
});
it('should preserve undefined/null handles', () => {
const { loadGraphState } = useGraphStore.getState();
// Create edge without handles (new format)
const edgeWithoutHandles: Relation = {
...createMockEdge('edge-1', 'node-1', 'node-2'),
};
loadGraphState({
nodes: [createMockNode('node-1'), createMockNode('node-2')],
edges: [edgeWithoutHandles],
groups: [],
nodeTypes: [],
edgeTypes: [],
labels: [],
});
const state = useGraphStore.getState();
// Handles should remain undefined
expect(state.edges[0].sourceHandle).toBeUndefined();
expect(state.edges[0].targetHandle).toBeUndefined();
});
}); });
}); });

View file

@ -14,6 +14,7 @@ import type {
} from '../types'; } from '../types';
import { MINIMIZED_GROUP_WIDTH, MINIMIZED_GROUP_HEIGHT } from '../constants'; import { MINIMIZED_GROUP_WIDTH, MINIMIZED_GROUP_HEIGHT } from '../constants';
import { migrateTangibleConfigs } from '../utils/tangibleMigration'; import { migrateTangibleConfigs } from '../utils/tangibleMigration';
import { migrateRelationHandlesArray } from '../utils/handleMigration';
/** /**
* IMPORTANT: DO NOT USE THIS STORE DIRECTLY IN COMPONENTS * IMPORTANT: DO NOT USE THIS STORE DIRECTLY IN COMPONENTS
@ -641,10 +642,13 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
? migrateTangibleConfigs(data.tangibles) ? migrateTangibleConfigs(data.tangibles)
: []; : [];
// Apply handle migration for backward compatibility (remove old 4-position handles)
const migratedEdges = migrateRelationHandlesArray(data.edges);
// Atomic update: all state changes happen in a single set() call // Atomic update: all state changes happen in a single set() call
set({ set({
nodes: sanitizedNodes, nodes: sanitizedNodes,
edges: data.edges, edges: migratedEdges,
groups: data.groups || [], groups: data.groups || [],
nodeTypes: data.nodeTypes, nodeTypes: data.nodeTypes,
edgeTypes: data.edgeTypes, edgeTypes: data.edgeTypes,

View file

@ -225,15 +225,25 @@ export function serializeActors(actors: Actor[]): SerializedActor[] {
* Serialize relations for storage (strip React Flow internals) * Serialize relations for storage (strip React Flow internals)
*/ */
export function serializeRelations(relations: Relation[]): SerializedRelation[] { export function serializeRelations(relations: Relation[]): SerializedRelation[] {
return relations.map(relation => ({ return relations.map(relation => {
id: relation.id, const serialized: SerializedRelation = {
source: relation.source, id: relation.id,
target: relation.target, source: relation.source,
type: relation.type, target: relation.target,
data: relation.data, type: relation.type,
sourceHandle: relation.sourceHandle, data: relation.data,
targetHandle: relation.targetHandle, };
}));
// Only include handles if they exist and are non-null/non-undefined
if (relation.sourceHandle != null) {
serialized.sourceHandle = relation.sourceHandle;
}
if (relation.targetHandle != null) {
serialized.targetHandle = relation.targetHandle;
}
return serialized;
});
} }
/** /**

View file

@ -4,6 +4,7 @@ import { useGraphStore } from '../graphStore';
import { useTimelineStore } from '../timelineStore'; import { useTimelineStore } from '../timelineStore';
import type { Actor, Relation, Group, NodeTypeConfig, EdgeTypeConfig, LabelConfig, TangibleConfig } from '../../types'; import type { Actor, Relation, Group, NodeTypeConfig, EdgeTypeConfig, LabelConfig, TangibleConfig } from '../../types';
import { getCurrentGraphFromDocument } from './documentUtils'; import { getCurrentGraphFromDocument } from './documentUtils';
import { migrateRelationHandlesArray } from '../../utils/handleMigration';
/** /**
* useActiveDocument Hook * useActiveDocument Hook
@ -97,8 +98,11 @@ export function useActiveDocument() {
isLoadingRef.current = true; isLoadingRef.current = true;
lastLoadedDocIdRef.current = activeDocumentId; lastLoadedDocIdRef.current = activeDocumentId;
// Apply handle migration for backward compatibility (remove old 4-position handles)
const migratedEdges = migrateRelationHandlesArray(currentGraph.edges);
setNodes(currentGraph.nodes as never[]); setNodes(currentGraph.nodes as never[]);
setEdges(currentGraph.edges as never[]); setEdges(migratedEdges as never[]);
setGroups(currentGraph.groups as never[]); setGroups(currentGraph.groups as never[]);
setNodeTypes(currentGraph.nodeTypes as never[]); setNodeTypes(currentGraph.nodeTypes as never[]);
setEdgeTypes(currentGraph.edgeTypes as never[]); setEdgeTypes(currentGraph.edgeTypes as never[]);
@ -109,7 +113,7 @@ export function useActiveDocument() {
lastSyncedStateRef.current = { lastSyncedStateRef.current = {
documentId: activeDocumentId, documentId: activeDocumentId,
nodes: currentGraph.nodes as Actor[], nodes: currentGraph.nodes as Actor[],
edges: currentGraph.edges as Relation[], edges: migratedEdges as Relation[],
groups: currentGraph.groups as Group[], groups: currentGraph.groups as Group[],
nodeTypes: currentGraph.nodeTypes as NodeTypeConfig[], nodeTypes: currentGraph.nodeTypes as NodeTypeConfig[],
edgeTypes: currentGraph.edgeTypes as EdgeTypeConfig[], edgeTypes: currentGraph.edgeTypes as EdgeTypeConfig[],

View file

@ -33,6 +33,7 @@ import { Cite } from '@citation-js/core';
import type { CSLReference } from '../types/bibliography'; import type { CSLReference } from '../types/bibliography';
import { needsStorageCleanup, cleanupAllStorage } from '../utils/cleanupStorage'; import { needsStorageCleanup, cleanupAllStorage } from '../utils/cleanupStorage';
import { migrateTangibleConfigs } from '../utils/tangibleMigration'; import { migrateTangibleConfigs } from '../utils/tangibleMigration';
import { migrateRelationHandlesArray } from '../utils/handleMigration';
/** /**
* Workspace Store * Workspace Store
@ -318,6 +319,16 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
doc.tangibles = migrateTangibleConfigs(doc.tangibles); doc.tangibles = migrateTangibleConfigs(doc.tangibles);
} }
// Apply handle migration to all timeline states for backward compatibility
if (doc.timeline && doc.timeline.states) {
Object.keys(doc.timeline.states).forEach((stateId) => {
const state = doc.timeline.states[stateId];
if (state && state.graph && state.graph.edges) {
state.graph.edges = migrateRelationHandlesArray(state.graph.edges);
}
});
}
// Load timeline if it exists // Load timeline if it exists
if (doc.timeline) { if (doc.timeline) {
useTimelineStore.getState().loadTimeline(documentId, doc.timeline as unknown as Timeline); useTimelineStore.getState().loadTimeline(documentId, doc.timeline as unknown as Timeline);
@ -626,6 +637,16 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
importedDoc.tangibles = migrateTangibleConfigs(importedDoc.tangibles); importedDoc.tangibles = migrateTangibleConfigs(importedDoc.tangibles);
} }
// Apply handle migration to all timeline states for backward compatibility
if (importedDoc.timeline && importedDoc.timeline.states) {
Object.keys(importedDoc.timeline.states).forEach((stateId) => {
const state = importedDoc.timeline.states[stateId];
if (state && state.graph && state.graph.edges) {
state.graph.edges = migrateRelationHandlesArray(state.graph.edges);
}
});
}
const metadata: DocumentMetadata = { const metadata: DocumentMetadata = {
id: documentId, id: documentId,
title: importedDoc.metadata.title || 'Imported Analysis', title: importedDoc.metadata.title || 'Imported Analysis',
@ -938,6 +959,16 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
doc.tangibles = migrateTangibleConfigs(doc.tangibles); doc.tangibles = migrateTangibleConfigs(doc.tangibles);
} }
// Apply handle migration to all timeline states for backward compatibility
if (doc.timeline && doc.timeline.states) {
Object.keys(doc.timeline.states).forEach((stateId) => {
const state = doc.timeline.states[stateId];
if (state && state.graph && state.graph.edges) {
state.graph.edges = migrateRelationHandlesArray(state.graph.edges);
}
});
}
saveDocumentToStorage(docId, doc); saveDocumentToStorage(docId, doc);
const metadata = { const metadata = {

View file

@ -0,0 +1,291 @@
import { describe, it, expect } from 'vitest';
import { migrateRelationHandles, migrateRelationHandlesArray } from '../handleMigration';
import type { SerializedRelation } from '../../stores/persistence/types';
describe('handleMigration', () => {
describe('migrateRelationHandles', () => {
it('should migrate old "top" source handle by removing handles', () => {
const oldFormat: SerializedRelation = {
id: 'edge-1',
source: 'node-1',
target: 'node-2',
sourceHandle: 'top',
targetHandle: 'bottom',
};
const result = migrateRelationHandles(oldFormat);
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
expect(result.id).toBe('edge-1');
expect(result.source).toBe('node-1');
expect(result.target).toBe('node-2');
});
it('should migrate old "right" source handle', () => {
const oldFormat: SerializedRelation = {
id: 'edge-2',
source: 'node-1',
target: 'node-2',
sourceHandle: 'right',
targetHandle: 'left',
};
const result = migrateRelationHandles(oldFormat);
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
});
it('should migrate old "bottom" source handle', () => {
const oldFormat: SerializedRelation = {
id: 'edge-3',
source: 'node-1',
target: 'node-2',
sourceHandle: 'bottom',
targetHandle: 'top',
};
const result = migrateRelationHandles(oldFormat);
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
});
it('should migrate old "left" source handle', () => {
const oldFormat: SerializedRelation = {
id: 'edge-4',
source: 'node-1',
target: 'node-2',
sourceHandle: 'left',
targetHandle: 'right',
};
const result = migrateRelationHandles(oldFormat);
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
});
it('should migrate when only source handle is old format', () => {
const mixed: SerializedRelation = {
id: 'edge-5',
source: 'node-1',
target: 'node-2',
sourceHandle: 'top',
};
const result = migrateRelationHandles(mixed);
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
});
it('should migrate when only target handle is old format', () => {
const mixed: SerializedRelation = {
id: 'edge-6',
source: 'node-1',
target: 'node-2',
targetHandle: 'bottom',
};
const result = migrateRelationHandles(mixed);
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
});
it('should leave relations with undefined handles unchanged', () => {
const newFormat: SerializedRelation = {
id: 'edge-7',
source: 'node-1',
target: 'node-2',
};
const result = migrateRelationHandles(newFormat);
expect(result).toEqual(newFormat);
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
});
it('should leave relations with null handles unchanged', () => {
const newFormat: SerializedRelation = {
id: 'edge-8',
source: 'node-1',
target: 'node-2',
sourceHandle: null,
targetHandle: null,
};
const result = migrateRelationHandles(newFormat);
expect(result).toEqual(newFormat);
expect(result.sourceHandle).toBeNull();
expect(result.targetHandle).toBeNull();
});
it('should leave relations with custom handle IDs unchanged', () => {
const customHandles: SerializedRelation = {
id: 'edge-9',
source: 'node-1',
target: 'node-2',
sourceHandle: 'custom-source-1',
targetHandle: 'custom-target-1',
};
const result = migrateRelationHandles(customHandles);
expect(result).toEqual(customHandles);
expect(result.sourceHandle).toBe('custom-source-1');
expect(result.targetHandle).toBe('custom-target-1');
});
it('should preserve type and data fields', () => {
const withData: SerializedRelation = {
id: 'edge-10',
source: 'node-1',
target: 'node-2',
sourceHandle: 'right',
targetHandle: 'left',
type: 'custom-edge',
data: {
label: 'Test Edge',
description: 'Test description',
},
};
const result = migrateRelationHandles(withData);
expect(result.type).toBe('custom-edge');
expect(result.data).toEqual({
label: 'Test Edge',
description: 'Test description',
});
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
});
it('should be idempotent (running twice produces same result)', () => {
const oldFormat: SerializedRelation = {
id: 'edge-11',
source: 'node-1',
target: 'node-2',
sourceHandle: 'top',
targetHandle: 'bottom',
};
const firstMigration = migrateRelationHandles(oldFormat);
const secondMigration = migrateRelationHandles(firstMigration);
expect(firstMigration).toEqual(secondMigration);
expect(secondMigration.sourceHandle).toBeUndefined();
expect(secondMigration.targetHandle).toBeUndefined();
});
it('should handle mixed old and custom handles', () => {
const mixed: SerializedRelation = {
id: 'edge-12',
source: 'node-1',
target: 'node-2',
sourceHandle: 'top', // Old format
targetHandle: 'custom-target', // Custom handle
};
const result = migrateRelationHandles(mixed);
// Should migrate because sourceHandle is old format (removes both handles)
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
});
});
describe('migrateRelationHandlesArray', () => {
it('should migrate an array of relations', () => {
const relations: SerializedRelation[] = [
{
id: 'edge-1',
source: 'node-1',
target: 'node-2',
sourceHandle: 'right',
targetHandle: 'left',
},
{
id: 'edge-2',
source: 'node-2',
target: 'node-3',
// Already new format (undefined)
},
{
id: 'edge-3',
source: 'node-3',
target: 'node-4',
sourceHandle: 'custom-handle',
targetHandle: 'custom-target',
},
];
const result = migrateRelationHandlesArray(relations);
expect(result).toHaveLength(3);
// First should be migrated (old format removed)
expect(result[0].sourceHandle).toBeUndefined();
expect(result[0].targetHandle).toBeUndefined();
// Second should remain unchanged
expect(result[1]).toEqual(relations[1]);
// Third should remain unchanged (custom handles)
expect(result[2]).toEqual(relations[2]);
});
it('should handle empty array', () => {
const result = migrateRelationHandlesArray([]);
expect(result).toEqual([]);
});
it('should handle array with all old format relations', () => {
const relations: SerializedRelation[] = [
{
id: 'edge-1',
source: 'node-1',
target: 'node-2',
sourceHandle: 'top',
targetHandle: 'bottom',
},
{
id: 'edge-2',
source: 'node-2',
target: 'node-3',
sourceHandle: 'right',
targetHandle: 'left',
},
];
const result = migrateRelationHandlesArray(relations);
expect(result).toHaveLength(2);
result.forEach((relation) => {
expect(relation.sourceHandle).toBeUndefined();
expect(relation.targetHandle).toBeUndefined();
});
});
it('should handle array with all new format relations', () => {
const relations: SerializedRelation[] = [
{
id: 'edge-1',
source: 'node-1',
target: 'node-2',
},
{
id: 'edge-2',
source: 'node-2',
target: 'node-3',
},
];
const result = migrateRelationHandlesArray(relations);
expect(result).toEqual(relations);
});
});
});

View file

@ -1,7 +1,6 @@
import type { Relation, RelationData } from '../types'; import type { Relation, RelationData } from '../types';
import type { Node } from '@xyflow/react'; import type { Node } from '@xyflow/react';
import { Position } from '@xyflow/react'; import { Position } from '@xyflow/react';
import { MINIMIZED_GROUP_WIDTH, MINIMIZED_GROUP_HEIGHT } from '../constants';
/** /**
* Generates a unique ID for edges * Generates a unique ID for edges
@ -15,27 +14,33 @@ export const generateEdgeId = (source: string, target: string): string => {
* Used for floating edges to connect at the closest point on the node * Used for floating edges to connect at the closest point on the node
*/ */
function getNodeIntersection(intersectionNode: Node, targetNode: Node) { function getNodeIntersection(intersectionNode: Node, targetNode: Node) {
const { // Use positionAbsolute for correct positioning of nodes inside groups
width: intersectionNodeWidth, // positionAbsolute accounts for parent group offset, while position is relative
height: intersectionNodeHeight, // @ts-ignore - internals.positionAbsolute exists at runtime but not in public types
position: intersectionNodePosition, const intersectionNodePosition = intersectionNode.internals?.positionAbsolute ?? intersectionNode.position;
} = intersectionNode; // @ts-ignore - internals.positionAbsolute exists at runtime but not in public types
const targetPosition = targetNode.position; const targetPosition = targetNode.internals?.positionAbsolute ?? targetNode.position;
// Use fallback dimensions if width/height are not set (e.g., for groups without measured dimensions) // Use measured dimensions from React Flow (stored in node.measured)
const w = (intersectionNodeWidth ?? MINIMIZED_GROUP_WIDTH) / 2; // If undefined, node hasn't been measured yet - return center
const h = (intersectionNodeHeight ?? MINIMIZED_GROUP_HEIGHT) / 2; const intersectionNodeWidth = intersectionNode.measured?.width ?? intersectionNode.width;
const intersectionNodeHeight = intersectionNode.measured?.height ?? intersectionNode.height;
const targetNodeWidth = targetNode.measured?.width ?? targetNode.width;
const targetNodeHeight = targetNode.measured?.height ?? targetNode.height;
if (!intersectionNodeWidth || !intersectionNodeHeight || !targetNodeWidth || !targetNodeHeight) {
const centerX = intersectionNodePosition.x + (intersectionNodeWidth ?? 0) / 2;
const centerY = intersectionNodePosition.y + (intersectionNodeHeight ?? 0) / 2;
return { x: centerX, y: centerY };
}
const w = intersectionNodeWidth / 2;
const h = intersectionNodeHeight / 2;
const x2 = intersectionNodePosition.x + w; const x2 = intersectionNodePosition.x + w;
const y2 = intersectionNodePosition.y + h; const y2 = intersectionNodePosition.y + h;
const x1 = targetPosition.x + (targetNode.width ?? MINIMIZED_GROUP_WIDTH) / 2; const x1 = targetPosition.x + targetNodeWidth / 2;
const y1 = targetPosition.y + (targetNode.height ?? MINIMIZED_GROUP_HEIGHT) / 2; const y1 = targetPosition.y + targetNodeHeight / 2;
// Guard against division by zero
if (w === 0 || h === 0) {
// If node has no dimensions, return center point
return { x: x2, y: y2 };
}
const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h); const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h);
const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h); const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h);
@ -52,15 +57,22 @@ function getNodeIntersection(intersectionNode: Node, targetNode: Node) {
* Get the position (top, right, bottom, left) of the handle based on the intersection point * Get the position (top, right, bottom, left) of the handle based on the intersection point
*/ */
function getEdgePosition(node: Node, intersectionPoint: { x: number; y: number }) { function getEdgePosition(node: Node, intersectionPoint: { x: number; y: number }) {
const n = { ...node.position, ...node }; // Use positionAbsolute for correct positioning of nodes inside groups
const nx = Math.round(n.x); // @ts-ignore - internals.positionAbsolute exists at runtime but not in public types
const ny = Math.round(n.y); const nodePosition = node.internals?.positionAbsolute ?? node.position;
const nx = Math.round(nodePosition.x);
const ny = Math.round(nodePosition.y);
const px = Math.round(intersectionPoint.x); const px = Math.round(intersectionPoint.x);
const py = Math.round(intersectionPoint.y); const py = Math.round(intersectionPoint.y);
// Use fallback dimensions if not set (same as getNodeIntersection) // Use measured dimensions from React Flow (stored in node.measured)
const width = node.width ?? MINIMIZED_GROUP_WIDTH; // If not available, default to Top
const height = node.height ?? MINIMIZED_GROUP_HEIGHT; const width = node.measured?.width ?? node.width;
const height = node.measured?.height ?? node.height;
if (!width || !height) {
return Position.Top;
}
if (px <= nx + 1) { if (px <= nx + 1) {
return Position.Left; return Position.Left;

View file

@ -0,0 +1,43 @@
import type { SerializedRelation } from '../stores/persistence/types';
/**
* List of old 4-position handle identifiers that should be migrated
*/
const OLD_HANDLE_POSITIONS = ['top', 'right', 'bottom', 'left'] as const;
/**
* Migrates a relation from the old 4-position handle system to the new easy-connect handle system.
* This function ensures backward compatibility with existing constellation files.
*
* Old format uses specific position handles: "top", "right", "bottom", "left"
* New format omits handle fields entirely, allowing floating edges to calculate connection points dynamically
*
* @param relation - The relation to migrate
* @returns The migrated relation
*/
export function migrateRelationHandles(relation: SerializedRelation): SerializedRelation {
// Check if either handle uses old format
const hasOldSourceHandle =
relation.sourceHandle != null && OLD_HANDLE_POSITIONS.includes(relation.sourceHandle as any);
const hasOldTargetHandle =
relation.targetHandle != null && OLD_HANDLE_POSITIONS.includes(relation.targetHandle as any);
// If old format detected, remove handle fields entirely for floating edge pattern
if (hasOldSourceHandle || hasOldTargetHandle) {
const { sourceHandle, targetHandle, ...relationWithoutHandles } = relation;
return relationWithoutHandles;
}
// Otherwise return unchanged
return relation;
}
/**
* Migrates an array of relations.
*
* @param relations - Array of relations to migrate
* @returns Array of migrated relations
*/
export function migrateRelationHandlesArray(relations: SerializedRelation[]): SerializedRelation[] {
return relations.map(migrateRelationHandles);
}