From c9c888d0ac00d9d8ec2b88cbf03eb6638d68b744 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Sat, 24 Jan 2026 13:01:04 +0100 Subject: [PATCH 1/5] 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 --- src/components/Edges/CustomEdge.tsx | 44 +-- src/components/Nodes/CustomNode.tsx | 80 ++---- src/components/Nodes/GroupNode.tsx | 36 +++ src/stores/graphStore.test.ts | 63 +++++ src/stores/graphStore.ts | 6 +- src/stores/workspace/documentUtils.ts | 28 +- src/stores/workspace/useActiveDocument.ts | 8 +- src/stores/workspaceStore.ts | 31 +++ src/utils/__tests__/handleMigration.test.ts | 291 ++++++++++++++++++++ src/utils/edgeUtils.ts | 60 ++-- src/utils/handleMigration.ts | 43 +++ 11 files changed, 567 insertions(+), 123 deletions(-) create mode 100644 src/utils/__tests__/handleMigration.test.ts create mode 100644 src/utils/handleMigration.ts diff --git a/src/components/Edges/CustomEdge.tsx b/src/components/Edges/CustomEdge.tsx index 1b6c1e7..3f5362c 100644 --- a/src/components/Edges/CustomEdge.tsx +++ b/src/components/Edges/CustomEdge.tsx @@ -4,11 +4,10 @@ import { getBezierPath, EdgeLabelRenderer, BaseEdge, - useNodes, + useInternalNode, } from '@xyflow/react'; import { useGraphStore } from '../../stores/graphStore'; import type { Relation } from '../../types'; -import type { Group } from '../../types'; import LabelBadge from '../Common/LabelBadge'; import { getFloatingEdgeParams } from '../../utils/edgeUtils'; import { useActiveFilters, edgeMatchesFilters } from '../../hooks/useActiveFilters'; @@ -45,18 +44,11 @@ const CustomEdge = ({ // Get active filters based on mode (editing vs presentation) const filters = useActiveFilters(); - // Get all nodes to check if source/target are minimized groups - const nodes = useNodes(); - const sourceNode = nodes.find((n) => n.id === source); - const targetNode = nodes.find((n) => n.id === target); + // Get internal nodes for floating edge calculations with correct absolute positioning + const sourceNode = useInternalNode(source); + const targetNode = useInternalNode(target); - // Check if either endpoint is a minimized group - 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 + // Always use floating edges for easy-connect (dynamic border point calculation) let finalSourceX = sourceX; let finalSourceY = sourceY; let finalTargetX = targetX; @@ -64,25 +56,15 @@ const CustomEdge = ({ let finalSourcePosition = sourcePosition; let finalTargetPosition = targetPosition; - // Check if we need to use floating edge calculations - const needsFloatingEdge = (sourceIsMinimizedGroup || targetIsMinimizedGroup) && sourceNode && targetNode; - - if (needsFloatingEdge) { + // Use floating edge calculations for ALL edges to get smart border connection + if (sourceNode && targetNode) { const floatingParams = getFloatingEdgeParams(sourceNode, targetNode); - - // When either endpoint is a minimized group, use floating positions for that side - // IMPORTANT: When BOTH are groups, we must use floating for BOTH sides - if (sourceIsMinimizedGroup) { - finalSourceX = floatingParams.sx; - finalSourceY = floatingParams.sy; - finalSourcePosition = floatingParams.sourcePos; - } - - if (targetIsMinimizedGroup) { - finalTargetX = floatingParams.tx; - finalTargetY = floatingParams.ty; - finalTargetPosition = floatingParams.targetPos; - } + finalSourceX = floatingParams.sx; + finalSourceY = floatingParams.sy; + finalSourcePosition = floatingParams.sourcePos; + finalTargetX = floatingParams.tx; + finalTargetY = floatingParams.ty; + finalTargetPosition = floatingParams.targetPos; } // Calculate the bezier path diff --git a/src/components/Nodes/CustomNode.tsx b/src/components/Nodes/CustomNode.tsx index 049010a..005f21b 100644 --- a/src/components/Nodes/CustomNode.tsx +++ b/src/components/Nodes/CustomNode.tsx @@ -1,5 +1,5 @@ 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 { getContrastColor, @@ -16,7 +16,7 @@ import { useActiveFilters, nodeMatchesFilters } from "../../hooks/useActiveFilte * * Features: * - 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 * - Type badge * @@ -29,10 +29,6 @@ const CustomNode = ({ data, selected }: NodeProps) => { // Get active filters based on mode (editing vs presentation) 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 const nodeTypeConfig = nodeTypes.find((nt) => nt.id === data.type); const nodeColor = nodeTypeConfig?.color || "#6b7280"; @@ -46,9 +42,6 @@ const CustomNode = ({ data, selected }: NodeProps) => { ? adjustColorBrightness(nodeColor, -20) : nodeColor; - // Show handles when selected or when connecting - const showHandles = selected || isConnecting; - // Check if this node matches the filter criteria const isMatch = useMemo(() => { return nodeMatchesFilters( @@ -85,64 +78,39 @@ const CustomNode = ({ data, selected }: NodeProps) => { 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 */} - - - + {/* Source handle - full node coverage for outgoing connections */} - - diff --git a/src/components/Nodes/GroupNode.tsx b/src/components/Nodes/GroupNode.tsx index 9500f9d..9982915 100644 --- a/src/components/Nodes/GroupNode.tsx +++ b/src/components/Nodes/GroupNode.tsx @@ -220,6 +220,42 @@ const GroupNode = ({ id, data, selected }: NodeProps) => { position: 'relative', }} > + {/* Invisible handles for easy-connect - floating edges calculate actual connection points */} + {/* Target handle - full node coverage for incoming connections */} + + {/* Source handle - full node coverage for outgoing connections */} + + {/* Background color overlay - uses group's custom color */}
{ const loadedNode = state.nodes[0]; 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(); + }); }); }); diff --git a/src/stores/graphStore.ts b/src/stores/graphStore.ts index 2b47c1d..e6ec99a 100644 --- a/src/stores/graphStore.ts +++ b/src/stores/graphStore.ts @@ -14,6 +14,7 @@ import type { } from '../types'; import { MINIMIZED_GROUP_WIDTH, MINIMIZED_GROUP_HEIGHT } from '../constants'; import { migrateTangibleConfigs } from '../utils/tangibleMigration'; +import { migrateRelationHandlesArray } from '../utils/handleMigration'; /** * ⚠️ IMPORTANT: DO NOT USE THIS STORE DIRECTLY IN COMPONENTS ⚠️ @@ -641,10 +642,13 @@ export const useGraphStore = create((set) => ({ ? 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 set({ nodes: sanitizedNodes, - edges: data.edges, + edges: migratedEdges, groups: data.groups || [], nodeTypes: data.nodeTypes, edgeTypes: data.edgeTypes, diff --git a/src/stores/workspace/documentUtils.ts b/src/stores/workspace/documentUtils.ts index f603232..e34cefe 100644 --- a/src/stores/workspace/documentUtils.ts +++ b/src/stores/workspace/documentUtils.ts @@ -225,15 +225,25 @@ export function serializeActors(actors: Actor[]): SerializedActor[] { * Serialize relations for storage (strip React Flow internals) */ export function serializeRelations(relations: Relation[]): SerializedRelation[] { - return relations.map(relation => ({ - id: relation.id, - source: relation.source, - target: relation.target, - type: relation.type, - data: relation.data, - sourceHandle: relation.sourceHandle, - targetHandle: relation.targetHandle, - })); + return relations.map(relation => { + const serialized: SerializedRelation = { + id: relation.id, + source: relation.source, + target: relation.target, + type: relation.type, + data: relation.data, + }; + + // 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; + }); } /** diff --git a/src/stores/workspace/useActiveDocument.ts b/src/stores/workspace/useActiveDocument.ts index ddf951a..48d9b88 100644 --- a/src/stores/workspace/useActiveDocument.ts +++ b/src/stores/workspace/useActiveDocument.ts @@ -4,6 +4,7 @@ import { useGraphStore } from '../graphStore'; import { useTimelineStore } from '../timelineStore'; import type { Actor, Relation, Group, NodeTypeConfig, EdgeTypeConfig, LabelConfig, TangibleConfig } from '../../types'; import { getCurrentGraphFromDocument } from './documentUtils'; +import { migrateRelationHandlesArray } from '../../utils/handleMigration'; /** * useActiveDocument Hook @@ -97,8 +98,11 @@ export function useActiveDocument() { isLoadingRef.current = true; lastLoadedDocIdRef.current = activeDocumentId; + // Apply handle migration for backward compatibility (remove old 4-position handles) + const migratedEdges = migrateRelationHandlesArray(currentGraph.edges); + setNodes(currentGraph.nodes as never[]); - setEdges(currentGraph.edges as never[]); + setEdges(migratedEdges as never[]); setGroups(currentGraph.groups as never[]); setNodeTypes(currentGraph.nodeTypes as never[]); setEdgeTypes(currentGraph.edgeTypes as never[]); @@ -109,7 +113,7 @@ export function useActiveDocument() { lastSyncedStateRef.current = { documentId: activeDocumentId, nodes: currentGraph.nodes as Actor[], - edges: currentGraph.edges as Relation[], + edges: migratedEdges as Relation[], groups: currentGraph.groups as Group[], nodeTypes: currentGraph.nodeTypes as NodeTypeConfig[], edgeTypes: currentGraph.edgeTypes as EdgeTypeConfig[], diff --git a/src/stores/workspaceStore.ts b/src/stores/workspaceStore.ts index b812336..1679816 100644 --- a/src/stores/workspaceStore.ts +++ b/src/stores/workspaceStore.ts @@ -33,6 +33,7 @@ import { Cite } from '@citation-js/core'; import type { CSLReference } from '../types/bibliography'; import { needsStorageCleanup, cleanupAllStorage } from '../utils/cleanupStorage'; import { migrateTangibleConfigs } from '../utils/tangibleMigration'; +import { migrateRelationHandlesArray } from '../utils/handleMigration'; /** * Workspace Store @@ -318,6 +319,16 @@ export const useWorkspaceStore = create((set, get) 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 if (doc.timeline) { useTimelineStore.getState().loadTimeline(documentId, doc.timeline as unknown as Timeline); @@ -626,6 +637,16 @@ export const useWorkspaceStore = create((set, get) 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 = { id: documentId, title: importedDoc.metadata.title || 'Imported Analysis', @@ -938,6 +959,16 @@ export const useWorkspaceStore = create((set, get) 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); const metadata = { diff --git a/src/utils/__tests__/handleMigration.test.ts b/src/utils/__tests__/handleMigration.test.ts new file mode 100644 index 0000000..699dfe7 --- /dev/null +++ b/src/utils/__tests__/handleMigration.test.ts @@ -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); + }); + }); +}); diff --git a/src/utils/edgeUtils.ts b/src/utils/edgeUtils.ts index 1b19ca9..ccc12be 100644 --- a/src/utils/edgeUtils.ts +++ b/src/utils/edgeUtils.ts @@ -1,7 +1,6 @@ import type { Relation, RelationData } from '../types'; import type { Node } from '@xyflow/react'; import { Position } from '@xyflow/react'; -import { MINIMIZED_GROUP_WIDTH, MINIMIZED_GROUP_HEIGHT } from '../constants'; /** * 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 */ function getNodeIntersection(intersectionNode: Node, targetNode: Node) { - const { - width: intersectionNodeWidth, - height: intersectionNodeHeight, - position: intersectionNodePosition, - } = intersectionNode; - const targetPosition = targetNode.position; + // Use positionAbsolute for correct positioning of nodes inside groups + // positionAbsolute accounts for parent group offset, while position is relative + // @ts-ignore - internals.positionAbsolute exists at runtime but not in public types + const intersectionNodePosition = intersectionNode.internals?.positionAbsolute ?? intersectionNode.position; + // @ts-ignore - internals.positionAbsolute exists at runtime but not in public types + const targetPosition = targetNode.internals?.positionAbsolute ?? targetNode.position; - // Use fallback dimensions if width/height are not set (e.g., for groups without measured dimensions) - const w = (intersectionNodeWidth ?? MINIMIZED_GROUP_WIDTH) / 2; - const h = (intersectionNodeHeight ?? MINIMIZED_GROUP_HEIGHT) / 2; + // Use measured dimensions from React Flow (stored in node.measured) + // If undefined, node hasn't been measured yet - return center + 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 y2 = intersectionNodePosition.y + h; - const x1 = targetPosition.x + (targetNode.width ?? MINIMIZED_GROUP_WIDTH) / 2; - const y1 = targetPosition.y + (targetNode.height ?? MINIMIZED_GROUP_HEIGHT) / 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 x1 = targetPosition.x + targetNodeWidth / 2; + const y1 = targetPosition.y + targetNodeHeight / 2; const xx1 = (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 */ function getEdgePosition(node: Node, intersectionPoint: { x: number; y: number }) { - const n = { ...node.position, ...node }; - const nx = Math.round(n.x); - const ny = Math.round(n.y); + // Use positionAbsolute for correct positioning of nodes inside groups + // @ts-ignore - internals.positionAbsolute exists at runtime but not in public types + 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 py = Math.round(intersectionPoint.y); - // Use fallback dimensions if not set (same as getNodeIntersection) - const width = node.width ?? MINIMIZED_GROUP_WIDTH; - const height = node.height ?? MINIMIZED_GROUP_HEIGHT; + // Use measured dimensions from React Flow (stored in node.measured) + // If not available, default to Top + const width = node.measured?.width ?? node.width; + const height = node.measured?.height ?? node.height; + + if (!width || !height) { + return Position.Top; + } if (px <= nx + 1) { return Position.Left; diff --git a/src/utils/handleMigration.ts b/src/utils/handleMigration.ts new file mode 100644 index 0000000..a693865 --- /dev/null +++ b/src/utils/handleMigration.ts @@ -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); +} From 8d71da76b22d58888f2a743864b97bb8d90431b7 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Sat, 24 Jan 2026 16:03:34 +0100 Subject: [PATCH 2/5] Add shape-aware edge connections and edge-only handles Improvements: - Edges now follow actual node shape contours (circle, ellipse, pill, rectangle) - Smooth arrow rotation using normal vectors at intersection points - Custom bezier curves with control points aligned to shape normals - Edge-only handles (30px strips) leaving center free for node dragging - Proper offset calculations to prevent edge-shape overlap Technical changes: - Add getCircleIntersection() for perfect circle geometry - Add getEllipseIntersection() with gradient-based normals - Add getPillIntersection() for stadium shape (rounded caps + straight sides) - Update getFloatingEdgeParams() to accept and use node shapes - CustomEdge determines shapes from nodeType config and creates custom bezier paths - Replace full-node handles with 4 edge-positioned handles (top/right/bottom/left) Co-Authored-By: Claude Sonnet 4.5 --- src/components/Edges/CustomEdge.tsx | 80 ++++--- src/components/Nodes/CustomNode.tsx | 71 ++++-- src/components/Nodes/GroupNode.tsx | 50 +++- src/utils/edgeUtils.ts | 342 ++++++++++++++++++++++------ 4 files changed, 424 insertions(+), 119 deletions(-) diff --git a/src/components/Edges/CustomEdge.tsx b/src/components/Edges/CustomEdge.tsx index 3f5362c..8457ee5 100644 --- a/src/components/Edges/CustomEdge.tsx +++ b/src/components/Edges/CustomEdge.tsx @@ -1,7 +1,6 @@ import { memo, useMemo } from 'react'; import { EdgeProps, - getBezierPath, EdgeLabelRenderer, BaseEdge, useInternalNode, @@ -33,13 +32,12 @@ const CustomEdge = ({ sourceY, targetX, targetY, - sourcePosition, - targetPosition, data, selected, }: EdgeProps) => { const edgeTypes = useGraphStore((state) => state.edgeTypes); const labels = useGraphStore((state) => state.labels); + const nodeTypes = useGraphStore((state) => state.nodeTypes); // Get active filters based on mode (editing vs presentation) const filters = useActiveFilters(); @@ -48,34 +46,58 @@ const CustomEdge = ({ const sourceNode = useInternalNode(source); const targetNode = useInternalNode(target); - // Always use floating edges for easy-connect (dynamic border point calculation) - let finalSourceX = sourceX; - let finalSourceY = sourceY; - let finalTargetX = targetX; - let finalTargetY = targetY; - let finalSourcePosition = sourcePosition; - let finalTargetPosition = targetPosition; + // Determine node shapes from node type configuration + const sourceShape = useMemo(() => { + if (!sourceNode) return 'rectangle'; + // Groups always use rectangle shape + if (sourceNode.type === 'group') return 'rectangle'; + const nodeData = sourceNode.data as { type?: string }; + const nodeTypeConfig = nodeTypes.find((nt) => nt.id === nodeData?.type); + return nodeTypeConfig?.shape || 'rectangle'; + }, [sourceNode, nodeTypes]); - // Use floating edge calculations for ALL edges to get smart border connection - if (sourceNode && targetNode) { - const floatingParams = getFloatingEdgeParams(sourceNode, targetNode); - finalSourceX = floatingParams.sx; - finalSourceY = floatingParams.sy; - finalSourcePosition = floatingParams.sourcePos; - finalTargetX = floatingParams.tx; - finalTargetY = floatingParams.ty; - finalTargetPosition = floatingParams.targetPos; - } + const targetShape = useMemo(() => { + if (!targetNode) return 'rectangle'; + // Groups always use rectangle shape + if (targetNode.type === 'group') return 'rectangle'; + const nodeData = targetNode.data as { type?: string }; + const nodeTypeConfig = nodeTypes.find((nt) => nt.id === nodeData?.type); + return nodeTypeConfig?.shape || 'rectangle'; + }, [targetNode, nodeTypes]); - // Calculate the bezier path - const [edgePath, labelX, labelY] = getBezierPath({ - sourceX: finalSourceX, - sourceY: finalSourceY, - sourcePosition: finalSourcePosition, - targetX: finalTargetX, - targetY: finalTargetY, - targetPosition: finalTargetPosition, - }); + // Calculate floating edge parameters with custom bezier control points + const edgeParams = useMemo(() => { + if (!sourceNode || !targetNode) { + // Fallback to default React Flow positioning + return { + edgePath: `M ${sourceX},${sourceY} L ${targetX},${targetY}`, + labelX: (sourceX + targetX) / 2, + labelY: (sourceY + targetY) / 2, + }; + } + + const params = getFloatingEdgeParams(sourceNode, targetNode, sourceShape, targetShape); + + // Create cubic bezier path using custom control points + const edgePath = `M ${params.sx},${params.sy} C ${params.sourceControlX},${params.sourceControlY} ${params.targetControlX},${params.targetControlY} ${params.tx},${params.ty}`; + + // Calculate label position at midpoint of the bezier curve (t=0.5) + const t = 0.5; + const labelX = + Math.pow(1 - t, 3) * params.sx + + 3 * Math.pow(1 - t, 2) * t * params.sourceControlX + + 3 * (1 - t) * Math.pow(t, 2) * params.targetControlX + + Math.pow(t, 3) * params.tx; + const labelY = + Math.pow(1 - t, 3) * params.sy + + 3 * Math.pow(1 - t, 2) * t * params.sourceControlY + + 3 * (1 - t) * Math.pow(t, 2) * params.targetControlY + + Math.pow(t, 3) * params.ty; + + return { edgePath, labelX, labelY }; + }, [sourceNode, targetNode, sourceShape, targetShape, sourceX, sourceY, targetX, targetY]); + + const { edgePath, labelX, labelY } = edgeParams; // Check if this is an aggregated edge const isAggregated = !!(data as { aggregatedCount?: number })?.aggregatedCount; diff --git a/src/components/Nodes/CustomNode.tsx b/src/components/Nodes/CustomNode.tsx index 005f21b..820302d 100644 --- a/src/components/Nodes/CustomNode.tsx +++ b/src/components/Nodes/CustomNode.tsx @@ -9,7 +9,10 @@ import { getIconComponent } from "../../utils/iconUtils"; import type { Actor } from "../../types"; import NodeShapeRenderer from "./Shapes/NodeShapeRenderer"; import LabelBadge from "../Common/LabelBadge"; -import { useActiveFilters, nodeMatchesFilters } from "../../hooks/useActiveFilters"; +import { + useActiveFilters, + nodeMatchesFilters, +} from "../../hooks/useActiveFilters"; /** * CustomNode - Represents an actor in the constellation graph @@ -50,7 +53,7 @@ const CustomNode = ({ data, selected }: NodeProps) => { data.label || "", data.description || "", nodeLabel, - filters + filters, ); }, [ data.type, @@ -78,39 +81,73 @@ const CustomNode = ({ data, selected }: NodeProps) => { opacity: nodeOpacity, }} > - {/* Invisible handles for easy-connect - floating edges calculate actual connection points */} - {/* Target handle - full node coverage for incoming connections */} + {/* Invisible handles positioned around edges - center remains free for dragging */} + {/* Top edge handle */} - {/* Source handle - full node coverage for outgoing connections */} + {/* Right edge handle */} + + {/* Bottom edge handle */} + {/* Left edge handle */} + diff --git a/src/components/Nodes/GroupNode.tsx b/src/components/Nodes/GroupNode.tsx index 9982915..0650ce2 100644 --- a/src/components/Nodes/GroupNode.tsx +++ b/src/components/Nodes/GroupNode.tsx @@ -220,39 +220,73 @@ const GroupNode = ({ id, data, selected }: NodeProps) => { position: 'relative', }} > - {/* Invisible handles for easy-connect - floating edges calculate actual connection points */} - {/* Target handle - full node coverage for incoming connections */} + {/* Invisible handles positioned around edges - center remains free for dragging */} + {/* Top edge handle */} - {/* Source handle - full node coverage for outgoing connections */} + {/* Right edge handle */} + + {/* Bottom edge handle */} + {/* Left edge handle */} + diff --git a/src/utils/edgeUtils.ts b/src/utils/edgeUtils.ts index ccc12be..e9b7053 100644 --- a/src/utils/edgeUtils.ts +++ b/src/utils/edgeUtils.ts @@ -1,6 +1,5 @@ -import type { Relation, RelationData } from '../types'; +import type { Relation, RelationData, NodeShape } from '../types'; import type { Node } from '@xyflow/react'; -import { Position } from '@xyflow/react'; /** * Generates a unique ID for edges @@ -10,10 +9,186 @@ export const generateEdgeId = (source: string, target: string): string => { }; /** - * Calculate the intersection point between a line and a rectangle - * Used for floating edges to connect at the closest point on the node + * Calculate intersection point with a circle + * Returns both the intersection point and the normal vector (outward direction) */ -function getNodeIntersection(intersectionNode: Node, targetNode: Node) { +function getCircleIntersection( + centerX: number, + centerY: number, + radius: number, + targetX: number, + targetY: number, + offset: number = 3 +): { x: number; y: number; angle: number } { + const dx = targetX - centerX; + const dy = targetY - centerY; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance === 0) { + return { x: centerX + radius + offset, y: centerY, angle: 0 }; + } + + // Normalized direction vector + const nx = dx / distance; + const ny = dy / distance; + + // Point on circle border in direction of target, with offset + return { + x: centerX + nx * (radius + offset), + y: centerY + ny * (radius + offset), + angle: Math.atan2(ny, nx), // Normal angle pointing outward + }; +} + +/** + * Calculate intersection point with an ellipse + * Returns both the intersection point and the normal vector (outward direction) + */ +function getEllipseIntersection( + centerX: number, + centerY: number, + radiusX: number, + radiusY: number, + targetX: number, + targetY: number, + offset: number = 3 +): { x: number; y: number; angle: number } { + const dx = targetX - centerX; + const dy = targetY - centerY; + + if (dx === 0 && dy === 0) { + return { x: centerX + radiusX + offset, y: centerY, angle: 0 }; + } + + // Angle to target point + const angle = Math.atan2(dy, dx); + + // Point on ellipse border + const px = radiusX * Math.cos(angle); + const py = radiusY * Math.sin(angle); + + // Normal vector at this point on the ellipse + // For ellipse, the gradient at point (px, py) is (px/radiusX^2, py/radiusY^2) + const normalX = px / (radiusX * radiusX); + const normalY = py / (radiusY * radiusY); + const normalLength = Math.sqrt(normalX * normalX + normalY * normalY); + const nx = normalX / normalLength; + const ny = normalY / normalLength; + + // Normal angle + const normalAngle = Math.atan2(ny, nx); + + // Offset point slightly outside ellipse border + return { + x: centerX + px + nx * offset, + y: centerY + py + ny * offset, + angle: normalAngle, + }; +} + +/** + * Calculate intersection point with a pill (stadium) shape + * A pill has rounded caps on the ends and straight sides + */ +function getPillIntersection( + centerX: number, + centerY: number, + width: number, + height: number, + targetX: number, + targetY: number, + offset: number = 4 +): { x: number; y: number; angle: number } { + const dx = targetX - centerX; + const dy = targetY - centerY; + + if (dx === 0 && dy === 0) { + return { x: centerX + width / 2 + offset, y: centerY, angle: 0 }; + } + + // Determine pill orientation and cap radius + const isHorizontal = width >= height; + const capRadius = isHorizontal ? height / 2 : width / 2; + + if (isHorizontal) { + // Horizontal pill: semicircular caps on left and right + const leftCapX = centerX - (width / 2 - capRadius); + const rightCapX = centerX + (width / 2 - capRadius); + + // Check if pointing toward left cap + if (dx < 0 && Math.abs(dx) >= Math.abs(dy)) { + return getCircleIntersection(leftCapX, centerY, capRadius, targetX, targetY, offset); + } + // Check if pointing toward right cap + else if (dx > 0 && Math.abs(dx) >= Math.abs(dy)) { + return getCircleIntersection(rightCapX, centerY, capRadius, targetX, targetY, offset); + } + // Otherwise it's pointing toward top or bottom straight edge + else { + const side = dy < 0 ? -1 : 1; + const intersectY = centerY + side * capRadius; + + // Calculate x position where line from target to center intersects the horizontal edge + // Line equation: (y - centerY) / (x - centerX) = dy / dx + // Solving for x when y = intersectY: x = centerX + dx * (intersectY - centerY) / dy + const intersectX = Math.abs(dy) > 0.001 + ? centerX + dx * (intersectY - centerY) / dy + : centerX; + + const normalAngle = side < 0 ? -Math.PI / 2 : Math.PI / 2; + + return { + x: intersectX, + y: intersectY + side * offset, + angle: normalAngle, + }; + } + } else { + // Vertical pill: semicircular caps on top and bottom + const topCapY = centerY - (height / 2 - capRadius); + const bottomCapY = centerY + (height / 2 - capRadius); + + // Check if pointing toward top cap + if (dy < 0 && Math.abs(dy) >= Math.abs(dx)) { + return getCircleIntersection(centerX, topCapY, capRadius, targetX, targetY, offset); + } + // Check if pointing toward bottom cap + else if (dy > 0 && Math.abs(dy) >= Math.abs(dx)) { + return getCircleIntersection(centerX, bottomCapY, capRadius, targetX, targetY, offset); + } + // Otherwise it's pointing toward left or right straight edge + else { + const side = dx < 0 ? -1 : 1; + const intersectX = centerX + side * capRadius; + + // Calculate y position where line from target to center intersects the vertical edge + // Line equation: (y - centerY) / (x - centerX) = dy / dx + // Solving for y when x = intersectX: y = centerY + dy * (intersectX - centerX) / dx + const intersectY = Math.abs(dx) > 0.001 + ? centerY + dy * (intersectX - centerX) / dx + : centerY; + + const normalAngle = side < 0 ? Math.PI : 0; + + return { + x: intersectX + side * offset, + y: intersectY, + angle: normalAngle, + }; + } + } +} + +/** + * Calculate the intersection point between a line and a node shape + * Returns the intersection point and the normal angle at that point + */ +function getNodeIntersection( + intersectionNode: Node, + targetNode: Node, + intersectionShape: NodeShape = 'rectangle', + offset: number = 2 +): { x: number; y: number; angle: number } { // Use positionAbsolute for correct positioning of nodes inside groups // positionAbsolute accounts for parent group offset, while position is relative // @ts-ignore - internals.positionAbsolute exists at runtime but not in public types @@ -31,83 +206,120 @@ function getNodeIntersection(intersectionNode: Node, targetNode: Node) { if (!intersectionNodeWidth || !intersectionNodeHeight || !targetNodeWidth || !targetNodeHeight) { const centerX = intersectionNodePosition.x + (intersectionNodeWidth ?? 0) / 2; const centerY = intersectionNodePosition.y + (intersectionNodeHeight ?? 0) / 2; - return { x: centerX, y: centerY }; + return { x: centerX, y: centerY, angle: 0 }; } - const w = intersectionNodeWidth / 2; - const h = intersectionNodeHeight / 2; + // Calculate centers + const intersectionCenterX = intersectionNodePosition.x + intersectionNodeWidth / 2; + const intersectionCenterY = intersectionNodePosition.y + intersectionNodeHeight / 2; + const targetCenterX = targetPosition.x + targetNodeWidth / 2; + const targetCenterY = targetPosition.y + targetNodeHeight / 2; - const x2 = intersectionNodePosition.x + w; - const y2 = intersectionNodePosition.y + h; - const x1 = targetPosition.x + targetNodeWidth / 2; - const y1 = targetPosition.y + targetNodeHeight / 2; + // Handle different shapes + if (intersectionShape === 'circle') { + // Use minimum dimension as radius for perfect circle + const radius = Math.min(intersectionNodeWidth, intersectionNodeHeight) / 2; + return getCircleIntersection( + intersectionCenterX, + intersectionCenterY, + radius, + targetCenterX, + targetCenterY, + offset + ); + } else if (intersectionShape === 'pill') { + // Pill shape has rounded caps and straight sides + return getPillIntersection( + intersectionCenterX, + intersectionCenterY, + intersectionNodeWidth, + intersectionNodeHeight, + targetCenterX, + targetCenterY, + offset + ); + } else if (intersectionShape === 'ellipse') { + // For ellipse, use width/height as radii + const radiusX = intersectionNodeWidth / 2; + const radiusY = intersectionNodeHeight / 2; + return getEllipseIntersection( + intersectionCenterX, + intersectionCenterY, + radiusX, + radiusY, + targetCenterX, + targetCenterY, + offset + ); + } else { + // Rectangle and roundedRectangle use the original algorithm with offset + const w = intersectionNodeWidth / 2; + const h = intersectionNodeHeight / 2; - const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h); - const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h); - const a = 1 / (Math.abs(xx1) + Math.abs(yy1)); - const xx3 = a * xx1; - const yy3 = a * yy1; - const x = w * (xx3 + yy3) + x2; - const y = h * (-xx3 + yy3) + y2; + const x2 = intersectionCenterX; + const y2 = intersectionCenterY; + const x1 = targetCenterX; + const y1 = targetCenterY; - return { x, y }; -} + const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h); + const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h); + const a = 1 / (Math.abs(xx1) + Math.abs(yy1)); + const xx3 = a * xx1; + const yy3 = a * yy1; + const x = w * (xx3 + yy3) + x2; + const y = h * (-xx3 + yy3) + y2; -/** - * Get the position (top, right, bottom, left) of the handle based on the intersection point - */ -function getEdgePosition(node: Node, intersectionPoint: { x: number; y: number }) { - // Use positionAbsolute for correct positioning of nodes inside groups - // @ts-ignore - internals.positionAbsolute exists at runtime but not in public types - 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 py = Math.round(intersectionPoint.y); + // Calculate normal angle for rectangle edges + const dx = x - x2; + const dy = y - y2; + const angle = Math.atan2(dy, dx); - // Use measured dimensions from React Flow (stored in node.measured) - // If not available, default to Top - const width = node.measured?.width ?? node.width; - const height = node.measured?.height ?? node.height; + // Apply offset + const offsetX = x + Math.cos(angle) * offset; + const offsetY = y + Math.sin(angle) * offset; - if (!width || !height) { - return Position.Top; + return { x: offsetX, y: offsetY, angle }; } - - if (px <= nx + 1) { - return Position.Left; - } - if (px >= nx + width - 1) { - return Position.Right; - } - if (py <= ny + 1) { - return Position.Top; - } - if (py >= ny + height - 1) { - return Position.Bottom; - } - - return Position.Top; } /** * Calculate the parameters for a floating edge between two nodes - * Returns source/target coordinates and positions for dynamic edge routing + * Returns source/target coordinates with angles for smooth bezier curves */ -export function getFloatingEdgeParams(sourceNode: Node, targetNode: Node) { - const sourceIntersectionPoint = getNodeIntersection(sourceNode, targetNode); - const targetIntersectionPoint = getNodeIntersection(targetNode, sourceNode); +export function getFloatingEdgeParams( + sourceNode: Node, + targetNode: Node, + sourceShape: NodeShape = 'rectangle', + targetShape: NodeShape = 'rectangle' +) { + const sourceIntersection = getNodeIntersection(sourceNode, targetNode, sourceShape); + const targetIntersection = getNodeIntersection(targetNode, sourceNode, targetShape); - const sourcePos = getEdgePosition(sourceNode, sourceIntersectionPoint); - const targetPos = getEdgePosition(targetNode, targetIntersectionPoint); + // Calculate control point distance based on distance between nodes + const distance = Math.sqrt( + Math.pow(targetIntersection.x - sourceIntersection.x, 2) + + Math.pow(targetIntersection.y - sourceIntersection.y, 2) + ); + // Use 40% of distance for more pronounced curves, with reasonable limits + const controlPointDistance = Math.min(Math.max(distance * 0.4, 40), 150); + + // Calculate control points using the normal angles + const sourceControlX = sourceIntersection.x + Math.cos(sourceIntersection.angle) * controlPointDistance; + const sourceControlY = sourceIntersection.y + Math.sin(sourceIntersection.angle) * controlPointDistance; + const targetControlX = targetIntersection.x + Math.cos(targetIntersection.angle) * controlPointDistance; + const targetControlY = targetIntersection.y + Math.sin(targetIntersection.angle) * controlPointDistance; return { - sx: sourceIntersectionPoint.x, - sy: sourceIntersectionPoint.y, - tx: targetIntersectionPoint.x, - ty: targetIntersectionPoint.y, - sourcePos, - targetPos, + sx: sourceIntersection.x, + sy: sourceIntersection.y, + tx: targetIntersection.x, + ty: targetIntersection.y, + sourceControlX, + sourceControlY, + targetControlX, + targetControlY, + sourceAngle: sourceIntersection.angle, + targetAngle: targetIntersection.angle, }; } From 318cdee15cd427ac379e47f7f95b52b2f7830bab Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Sat, 24 Jan 2026 16:05:50 +0100 Subject: [PATCH 3/5] Fix lint errors: change @ts-ignore to @ts-expect-error and fix type assertions --- src/utils/edgeUtils.ts | 4 ++-- src/utils/handleMigration.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/utils/edgeUtils.ts b/src/utils/edgeUtils.ts index e9b7053..14a8c3a 100644 --- a/src/utils/edgeUtils.ts +++ b/src/utils/edgeUtils.ts @@ -191,9 +191,9 @@ function getNodeIntersection( ): { x: number; y: number; angle: number } { // Use positionAbsolute for correct positioning of nodes inside groups // positionAbsolute accounts for parent group offset, while position is relative - // @ts-ignore - internals.positionAbsolute exists at runtime but not in public types + // @ts-expect-error - internals.positionAbsolute exists at runtime but not in public types const intersectionNodePosition = intersectionNode.internals?.positionAbsolute ?? intersectionNode.position; - // @ts-ignore - internals.positionAbsolute exists at runtime but not in public types + // @ts-expect-error - internals.positionAbsolute exists at runtime but not in public types const targetPosition = targetNode.internals?.positionAbsolute ?? targetNode.position; // Use measured dimensions from React Flow (stored in node.measured) diff --git a/src/utils/handleMigration.ts b/src/utils/handleMigration.ts index a693865..f74fd33 100644 --- a/src/utils/handleMigration.ts +++ b/src/utils/handleMigration.ts @@ -18,12 +18,13 @@ const OLD_HANDLE_POSITIONS = ['top', 'right', 'bottom', 'left'] as const; 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); + relation.sourceHandle != null && OLD_HANDLE_POSITIONS.includes(relation.sourceHandle as typeof OLD_HANDLE_POSITIONS[number]); const hasOldTargetHandle = - relation.targetHandle != null && OLD_HANDLE_POSITIONS.includes(relation.targetHandle as any); + relation.targetHandle != null && OLD_HANDLE_POSITIONS.includes(relation.targetHandle as typeof OLD_HANDLE_POSITIONS[number]); // If old format detected, remove handle fields entirely for floating edge pattern if (hasOldSourceHandle || hasOldTargetHandle) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { sourceHandle, targetHandle, ...relationWithoutHandles } = relation; return relationWithoutHandles; } From 4b865762a1cb6a6a12515acd3af6435e54cf1924 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Sat, 24 Jan 2026 16:17:23 +0100 Subject: [PATCH 4/5] Address PR review comments Edge calculation improvements: - Add zero radius/radii guards in circle and ellipse intersection functions - Add clamping for pill straight edge intersections to prevent overflow - Ensure intersection points stay within valid pill boundaries Handle improvements: - Add bidirectional connection support with overlapping source/target handles - Each edge now has both source and target handles (8 total per node) - Allows edges to connect in any direction from any side - Fixes handle type restrictions that prevented flexible connections Co-Authored-By: Claude Sonnet 4.5 --- src/components/Nodes/CustomNode.tsx | 85 +++++++++++++++++++++++++++-- src/components/Nodes/GroupNode.tsx | 85 +++++++++++++++++++++++++++-- src/utils/edgeUtils.ts | 20 ++++++- 3 files changed, 180 insertions(+), 10 deletions(-) diff --git a/src/components/Nodes/CustomNode.tsx b/src/components/Nodes/CustomNode.tsx index 820302d..8b76947 100644 --- a/src/components/Nodes/CustomNode.tsx +++ b/src/components/Nodes/CustomNode.tsx @@ -82,10 +82,13 @@ const CustomNode = ({ data, selected }: NodeProps) => { }} > {/* Invisible handles positioned around edges - center remains free for dragging */} - {/* Top edge handle */} + {/* Bidirectional handles (source + target overlapping at each edge) */} + + {/* Top edge handles */} ) => { cursor: "crosshair", }} /> - {/* Right edge handle */} + + {/* Right edge handles */} + ) => { cursor: "crosshair", }} /> - {/* Bottom edge handle */} + + {/* Bottom edge handles */} + ) => { cursor: "crosshair", }} /> - {/* Left edge handle */} + + + {/* Left edge handles */} + ) => { }} > {/* Invisible handles positioned around edges - center remains free for dragging */} - {/* Top edge handle */} + {/* Bidirectional handles (source + target overlapping at each edge) */} + + {/* Top edge handles */} ) => { cursor: 'crosshair', }} /> - {/* Right edge handle */} + + {/* Right edge handles */} + ) => { cursor: 'crosshair', }} /> - {/* Bottom edge handle */} + + {/* Bottom edge handles */} + ) => { cursor: 'crosshair', }} /> - {/* Left edge handle */} + + + {/* Left edge handles */} + 0.001 + let intersectX = Math.abs(dy) > 0.001 ? centerX + dx * (intersectY - centerY) / dy : centerX; + // Clamp intersection to the straight horizontal segment between the caps + intersectX = Math.min(Math.max(intersectX, leftCapX), rightCapX); + const normalAngle = side < 0 ? -Math.PI / 2 : Math.PI / 2; return { @@ -164,10 +177,13 @@ function getPillIntersection( // Calculate y position where line from target to center intersects the vertical edge // Line equation: (y - centerY) / (x - centerX) = dy / dx // Solving for y when x = intersectX: y = centerY + dy * (intersectX - centerX) / dx - const intersectY = Math.abs(dx) > 0.001 + let intersectY = Math.abs(dx) > 0.001 ? centerY + dy * (intersectX - centerX) / dx : centerY; + // Clamp intersection to the straight vertical segment between the caps + intersectY = Math.min(Math.max(intersectY, topCapY), bottomCapY); + const normalAngle = side < 0 ? Math.PI : 0; return { From 93a5f38112c346475c5bf607053e78ad6c0a09cd Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Sat, 24 Jan 2026 16:33:17 +0100 Subject: [PATCH 5/5] Omit handle fields from serialization entirely Since edges use floating calculations that ignore handle positions, the handle IDs (like 'top-source', 'right-target') should never be persisted. They're only used to define clickable areas for connections. This ensures consistency: both migrated old edges and newly created edges will have no handle fields in saved JSON files. Addresses PR review comment about serialization inconsistency. Co-Authored-By: Claude Sonnet 4.5 --- src/stores/workspace/documentUtils.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/stores/workspace/documentUtils.ts b/src/stores/workspace/documentUtils.ts index e34cefe..7d345e8 100644 --- a/src/stores/workspace/documentUtils.ts +++ b/src/stores/workspace/documentUtils.ts @@ -226,23 +226,16 @@ export function serializeActors(actors: Actor[]): SerializedActor[] { */ export function serializeRelations(relations: Relation[]): SerializedRelation[] { return relations.map(relation => { - const serialized: SerializedRelation = { + // Omit handle fields entirely - edges use floating calculations + // The handle IDs (like "top-source", "right-target") are only for defining + // clickable areas and should not be persisted + return { id: relation.id, source: relation.source, target: relation.target, type: relation.type, data: relation.data, }; - - // 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; }); }