diff --git a/src/components/nodes/BaseNode.tsx b/src/components/nodes/BaseNode.tsx
index fc1e6b9e..2a1c2e8a 100644
--- a/src/components/nodes/BaseNode.tsx
+++ b/src/components/nodes/BaseNode.tsx
@@ -314,17 +314,17 @@ export function BaseNode({
>
{
if (isPreviewMode) return;
diff --git a/src/components/nodes/NodeHandle.tsx b/src/components/nodes/NodeHandle.tsx
index 810c5bf1..77549e30 100644
--- a/src/components/nodes/NodeHandle.tsx
+++ b/src/components/nodes/NodeHandle.tsx
@@ -1,9 +1,10 @@
"use client";
import { Handle, Position, useNodeId, useReactFlow, type HandleProps } from "@xyflow/react";
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useRef, useState } from "react";
import type { CSSProperties, MouseEvent, PointerEvent, ReactNode } from "react";
import { useWorkflowStore } from "@/store/workflowStore";
+import { selectNodeById } from "@/store/utils/nodesById";
import { useNodeHandleMenu } from "./NodeHandleMenuContext";
type NodeHandleVariant = "default" | "compact";
@@ -15,6 +16,8 @@ type NodeHandleProps = Omit & {
visible?: boolean;
};
+const MAGNET_WRITE_EPSILON = 0.5;
+
function cx(...values: Array) {
return values.filter(Boolean).join(" ");
}
@@ -91,11 +94,13 @@ export function NodeHandle({
const menu = useNodeHandleMenu();
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
const handleRef = useRef(null);
+ const hitTargetRef = useRef(null);
+ const iconRef = useRef(null);
+ const magnetOffsetRef = useRef({ x: 0, y: 0, scale: "1" });
const [isZoneHovered, setIsZoneHovered] = useState(false);
- const [magnetOffset, setMagnetOffset] = useState({ x: 0, y: 0 });
const isSelected = useWorkflowStore((state) => {
if (!nodeId) return false;
- return state.nodes.some((node) => node.id === nodeId && node.selected);
+ return Boolean(selectNodeById(state.nodes, nodeId)?.selected);
});
const isNodeHovered = useWorkflowStore((state) => (
nodeId ? state.hoveredNodeId === nodeId : false
@@ -146,6 +151,28 @@ export function NodeHandle({
height: INTERACTION_ZONE_SIZE,
transform: zoneTransform,
} satisfies CSSProperties;
+ const visualTransform = `translate(calc(-50% + ${visualOffset.x}px + var(--handle-magnet-x, 0px)), calc(-50% + ${visualOffset.y}px + var(--handle-magnet-y, 0px)))`;
+
+ const applyMagnetOffset = useCallback((x: number, y: number) => {
+ const nextScale = x !== 0 || y !== 0 ? "1.08" : "1";
+ const current = magnetOffsetRef.current;
+ if (
+ Math.abs(current.x - x) < MAGNET_WRITE_EPSILON &&
+ Math.abs(current.y - y) < MAGNET_WRITE_EPSILON &&
+ current.scale === nextScale
+ ) {
+ return;
+ }
+ magnetOffsetRef.current = { x, y, scale: nextScale };
+
+ for (const element of [hitTargetRef.current, iconRef.current]) {
+ if (!element) continue;
+ element.style.setProperty("--handle-magnet-x", `${x}px`);
+ element.style.setProperty("--handle-magnet-y", `${y}px`);
+ }
+
+ iconRef.current?.style.setProperty("--handle-magnet-scale", nextScale);
+ }, []);
const snapVisualHandleToPointer = useCallback((clientX: number, clientY: number) => {
const rect = handleRef.current?.getBoundingClientRect();
@@ -174,30 +201,8 @@ export function NodeHandle({
return { x: pointerLocalX, y: pointerLocalY };
})();
- setMagnetOffset({
- x: clampedLocal.x - visualOffset.x,
- y: clampedLocal.y - visualOffset.y,
- });
- }, [getZoom, position, visualOffset.x, visualOffset.y]);
-
- useEffect(() => {
- if (!isVisible) {
- setMagnetOffset({ x: 0, y: 0 });
- return;
- }
-
- const handlePointerMove = (event: globalThis.PointerEvent) => {
- if (!isZoneHovered) {
- setMagnetOffset((current) => current.x === 0 && current.y === 0 ? current : { x: 0, y: 0 });
- return;
- }
-
- snapVisualHandleToPointer(event.clientX, event.clientY);
- };
-
- window.addEventListener("pointermove", handlePointerMove);
- return () => window.removeEventListener("pointermove", handlePointerMove);
- }, [isVisible, isZoneHovered, snapVisualHandleToPointer]);
+ applyMagnetOffset(clampedLocal.x - visualOffset.x, clampedLocal.y - visualOffset.y);
+ }, [applyMagnetOffset, getZoom, position, visualOffset.x, visualOffset.y]);
const handlePointerDown = (event: PointerEvent) => {
pointerDownRef.current = { x: event.clientX, y: event.clientY };
@@ -258,10 +263,11 @@ export function NodeHandle({
}}
onPointerLeave={() => {
setIsZoneHovered(false);
- setMagnetOffset({ x: 0, y: 0 });
+ applyMagnetOffset(0, 0);
}}
/>
{icon ?? }