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); +}