From e16f839f2d4aae303116721d60fb248e75dd1cb7 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Fri, 27 Feb 2026 22:28:34 +1300 Subject: [PATCH] fix(45): use ref-based measurement for ConditionalSwitch handle alignment Replace static pixel math with useLayoutEffect + offsetTop measurement to position output handles at the actual vertical center of their corresponding rule/default rows. This is robust against header height, padding, and text preview variations that caused the previous static offset to misalign. Co-Authored-By: Claude Opus 4.6 --- .../nodes/ConditionalSwitchNode.tsx | 50 +++++++++++++++---- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/src/components/nodes/ConditionalSwitchNode.tsx b/src/components/nodes/ConditionalSwitchNode.tsx index c7f0d7ca..b94f245c 100644 --- a/src/components/nodes/ConditionalSwitchNode.tsx +++ b/src/components/nodes/ConditionalSwitchNode.tsx @@ -1,6 +1,6 @@ "use client"; -import { memo, useMemo, useEffect, useState, useCallback } from "react"; +import { memo, useMemo, useEffect, useLayoutEffect, useState, useCallback, useRef } from "react"; import { Handle, Position, useUpdateNodeInternals, useReactFlow, NodeProps, useEdges } from "@xyflow/react"; import { BaseNode } from "./BaseNode"; import { useWorkflowStore } from "@/store/workflowStore"; @@ -80,15 +80,40 @@ export const ConditionalSwitchNode = memo(({ id, data, selected }: NodeProps>({}); + const defaultRowRef = useRef(null); + const [handleTops, setHandleTops] = useState>({}); + + // Track rule IDs for re-measurement on add/remove/reorder + const ruleIds = useMemo(() => nodeData.rules.map(r => r.id).join(','), [nodeData.rules]); + + // Measure actual row centers relative to the node element (before paint) + useLayoutEffect(() => { + const positions: Record = {}; + + for (const [ruleId, el] of Object.entries(ruleRowRefs.current)) { + if (el) { + positions[ruleId] = el.offsetTop + el.offsetHeight / 2; + } + } + + const defaultEl = defaultRowRef.current; + if (defaultEl) { + positions['default'] = defaultEl.offsetTop + defaultEl.offsetHeight / 2; + } + + setHandleTops(positions); + }, [ruleIds]); + + // Fallback handle positioning (used before first measurement) const handleSpacing = 32; - const textPreviewHeight = 20; // Height of the text preview (h-5 = 20px) - const baseOffset = 38 + textPreviewHeight; // Clear header bar + text preview + const fallbackBase = 70; // approximate: header + padding + text preview + half row // Dynamic height based on rule count (rules + default) const ruleCount = nodeData.rules.length; const totalOutputs = ruleCount + 1; // rules + default - const lastHandleTop = baseOffset + totalOutputs * handleSpacing; + const lastHandleTop = fallbackBase + totalOutputs * handleSpacing; const minHeight = lastHandleTop + 40; // Extra space for add button // Resize node and notify React Flow when rule count changes @@ -234,7 +259,14 @@ export const ConditionalSwitchNode = memo(({ id, data, selected }: NodeProps ( -
+
{ + if (el) ruleRowRefs.current[rule.id] = el; + else delete ruleRowRefs.current[rule.id]; + }} + className="flex items-center gap-1 group h-8" + > {/* Match status indicator */}
{rule.isMatched ? ( @@ -333,7 +365,7 @@ export const ConditionalSwitchNode = memo(({ id, data, selected }: NodeProps +
{defaultMatched ? ( @@ -368,7 +400,7 @@ export const ConditionalSwitchNode = memo(({ id, data, selected }: NodeProps