|
|
@ -1,9 +1,10 @@ |
|
|
"use client"; |
|
|
"use client"; |
|
|
|
|
|
|
|
|
import { Handle, Position, useNodeId, useReactFlow, type HandleProps } from "@xyflow/react"; |
|
|
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 type { CSSProperties, MouseEvent, PointerEvent, ReactNode } from "react"; |
|
|
import { useWorkflowStore } from "@/store/workflowStore"; |
|
|
import { useWorkflowStore } from "@/store/workflowStore"; |
|
|
|
|
|
import { selectNodeById } from "@/store/utils/nodesById"; |
|
|
import { useNodeHandleMenu } from "./NodeHandleMenuContext"; |
|
|
import { useNodeHandleMenu } from "./NodeHandleMenuContext"; |
|
|
|
|
|
|
|
|
type NodeHandleVariant = "default" | "compact"; |
|
|
type NodeHandleVariant = "default" | "compact"; |
|
|
@ -15,6 +16,8 @@ type NodeHandleProps = Omit<HandleProps, "children"> & { |
|
|
visible?: boolean; |
|
|
visible?: boolean; |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
const MAGNET_WRITE_EPSILON = 0.5; |
|
|
|
|
|
|
|
|
function cx(...values: Array<string | false | null | undefined>) { |
|
|
function cx(...values: Array<string | false | null | undefined>) { |
|
|
return values.filter(Boolean).join(" "); |
|
|
return values.filter(Boolean).join(" "); |
|
|
} |
|
|
} |
|
|
@ -91,11 +94,13 @@ export function NodeHandle({ |
|
|
const menu = useNodeHandleMenu(); |
|
|
const menu = useNodeHandleMenu(); |
|
|
const pointerDownRef = useRef<{ x: number; y: number } | null>(null); |
|
|
const pointerDownRef = useRef<{ x: number; y: number } | null>(null); |
|
|
const handleRef = useRef<HTMLDivElement | null>(null); |
|
|
const handleRef = useRef<HTMLDivElement | null>(null); |
|
|
|
|
|
const hitTargetRef = useRef<HTMLSpanElement | null>(null); |
|
|
|
|
|
const iconRef = useRef<HTMLSpanElement | null>(null); |
|
|
|
|
|
const magnetOffsetRef = useRef({ x: 0, y: 0, scale: "1" }); |
|
|
const [isZoneHovered, setIsZoneHovered] = useState(false); |
|
|
const [isZoneHovered, setIsZoneHovered] = useState(false); |
|
|
const [magnetOffset, setMagnetOffset] = useState({ x: 0, y: 0 }); |
|
|
|
|
|
const isSelected = useWorkflowStore((state) => { |
|
|
const isSelected = useWorkflowStore((state) => { |
|
|
if (!nodeId) return false; |
|
|
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) => ( |
|
|
const isNodeHovered = useWorkflowStore((state) => ( |
|
|
nodeId ? state.hoveredNodeId === nodeId : false |
|
|
nodeId ? state.hoveredNodeId === nodeId : false |
|
|
@ -146,6 +151,28 @@ export function NodeHandle({ |
|
|
height: INTERACTION_ZONE_SIZE, |
|
|
height: INTERACTION_ZONE_SIZE, |
|
|
transform: zoneTransform, |
|
|
transform: zoneTransform, |
|
|
} satisfies CSSProperties; |
|
|
} 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 snapVisualHandleToPointer = useCallback((clientX: number, clientY: number) => { |
|
|
const rect = handleRef.current?.getBoundingClientRect(); |
|
|
const rect = handleRef.current?.getBoundingClientRect(); |
|
|
@ -174,30 +201,8 @@ export function NodeHandle({ |
|
|
return { x: pointerLocalX, y: pointerLocalY }; |
|
|
return { x: pointerLocalX, y: pointerLocalY }; |
|
|
})(); |
|
|
})(); |
|
|
|
|
|
|
|
|
setMagnetOffset({ |
|
|
applyMagnetOffset(clampedLocal.x - visualOffset.x, clampedLocal.y - visualOffset.y); |
|
|
x: clampedLocal.x - visualOffset.x, |
|
|
}, [applyMagnetOffset, getZoom, position, visualOffset.x, visualOffset.y]); |
|
|
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]); |
|
|
|
|
|
|
|
|
|
|
|
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => { |
|
|
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => { |
|
|
pointerDownRef.current = { x: event.clientX, y: event.clientY }; |
|
|
pointerDownRef.current = { x: event.clientX, y: event.clientY }; |
|
|
@ -258,10 +263,11 @@ export function NodeHandle({ |
|
|
}} |
|
|
}} |
|
|
onPointerLeave={() => { |
|
|
onPointerLeave={() => { |
|
|
setIsZoneHovered(false); |
|
|
setIsZoneHovered(false); |
|
|
setMagnetOffset({ x: 0, y: 0 }); |
|
|
applyMagnetOffset(0, 0); |
|
|
}} |
|
|
}} |
|
|
/> |
|
|
/> |
|
|
<span |
|
|
<span |
|
|
|
|
|
ref={hitTargetRef} |
|
|
aria-hidden="true" |
|
|
aria-hidden="true" |
|
|
className={cx( |
|
|
className={cx( |
|
|
"absolute left-1/2 top-1/2 block rounded-full opacity-0", |
|
|
"absolute left-1/2 top-1/2 block rounded-full opacity-0", |
|
|
@ -269,10 +275,11 @@ export function NodeHandle({ |
|
|
)} |
|
|
)} |
|
|
style={{ |
|
|
style={{ |
|
|
pointerEvents: "none", |
|
|
pointerEvents: "none", |
|
|
transform: `translate(calc(-50% + ${visualOffset.x + magnetOffset.x}px), calc(-50% + ${visualOffset.y + magnetOffset.y}px))`, |
|
|
transform: visualTransform, |
|
|
}} |
|
|
}} |
|
|
/> |
|
|
/> |
|
|
<span |
|
|
<span |
|
|
|
|
|
ref={iconRef} |
|
|
aria-hidden="true" |
|
|
aria-hidden="true" |
|
|
className={cx( |
|
|
className={cx( |
|
|
"pointer-events-none absolute left-1/2 top-1/2 flex h-[25px] w-[25px] items-center justify-center leading-none transition-[transform,opacity,color] duration-300 ease-out" |
|
|
"pointer-events-none absolute left-1/2 top-1/2 flex h-[25px] w-[25px] items-center justify-center leading-none transition-[transform,opacity,color] duration-300 ease-out" |
|
|
@ -280,7 +287,7 @@ export function NodeHandle({ |
|
|
style={{ |
|
|
style={{ |
|
|
color: "var(--handle-icon-color)", |
|
|
color: "var(--handle-icon-color)", |
|
|
opacity: isVisible ? 1 : 0, |
|
|
opacity: isVisible ? 1 : 0, |
|
|
transform: `translate(calc(-50% + ${visualOffset.x + magnetOffset.x}px), calc(-50% + ${visualOffset.y + magnetOffset.y}px)) scale(${magnetOffset.x !== 0 || magnetOffset.y !== 0 ? 1.08 : 1})`, |
|
|
transform: `${visualTransform} scale(var(--handle-magnet-scale, 1))`, |
|
|
}} |
|
|
}} |
|
|
> |
|
|
> |
|
|
{icon ?? <DefaultHandleIcon />} |
|
|
{icon ?? <DefaultHandleIcon />} |
|
|
|