From 5e5de19446ea7e4e5e77da806c84e82c941d2353 Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Wed, 12 Nov 2025 18:10:22 -0800 Subject: [PATCH] POC Highlight Edges when Task Input/Output is selected --- .../ReactFlow/FlowCanvas/Edges/SmoothEdge.tsx | 31 ++++++++- .../TaskNode/TaskNodeCard/Handles.tsx | 64 ++++++++++++++---- .../TaskNode/TaskNodeCard/TaskNodeInputs.tsx | 60 ++++++++++++----- .../TaskNode/TaskNodeCard/TaskNodeOutputs.tsx | 65 +++++++++++++------ .../hooks/useConnectionHighlighting.ts | 61 +++++++++++++++++ src/hooks/useComponentSpecToEdges.ts | 60 ++++++++++++++++- src/utils/nodes/highlightingState.ts | 56 ++++++++++++++++ 7 files changed, 342 insertions(+), 55 deletions(-) create mode 100644 src/components/shared/ReactFlow/FlowCanvas/hooks/useConnectionHighlighting.ts create mode 100644 src/utils/nodes/highlightingState.ts diff --git a/src/components/shared/ReactFlow/FlowCanvas/Edges/SmoothEdge.tsx b/src/components/shared/ReactFlow/FlowCanvas/Edges/SmoothEdge.tsx index 7797d62aa..1b638cfba 100644 --- a/src/components/shared/ReactFlow/FlowCanvas/Edges/SmoothEdge.tsx +++ b/src/components/shared/ReactFlow/FlowCanvas/Edges/SmoothEdge.tsx @@ -11,6 +11,7 @@ const SmoothEdge = ({ targetPosition, style = {}, selected, + data, }: EdgeProps) => { const [edgePath] = getBezierPath({ sourceX, @@ -21,8 +22,13 @@ const SmoothEdge = ({ targetPosition, }); - const edgeColor = selected ? "#38bdf8" : "#6b7280"; - const markerIdSuffix = selected ? "selected" : "default"; + const state = selected + ? "selected" + : data?.highlighted + ? "highlighted" + : "default"; + + const { edgeColor, markerIdSuffix } = getEdgeMetadata(state); return ( <> @@ -87,3 +93,24 @@ const SmoothEdge = ({ }; export default SmoothEdge; + +function getEdgeMetadata(state: "default" | "selected" | "highlighted") { + switch (state) { + case "selected": + return { + edgeColor: "#38bdf8", + markerIdSuffix: "selected", + }; + case "highlighted": + return { + edgeColor: "#ec4899", + markerIdSuffix: "highlighted", + }; + case "default": + default: + return { + edgeColor: "#6b7280", + markerIdSuffix: "default", + }; + } +} diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNodeCard/Handles.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNodeCard/Handles.tsx index 2d12ad9f7..8ca1b2631 100644 --- a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNodeCard/Handles.tsx +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNodeCard/Handles.tsx @@ -19,7 +19,10 @@ type InputHandleProps = { input: InputSpec; invalid: boolean; value?: string; - highlight?: boolean; + highlight?: { + searchHighlight: boolean; + connectionHighlight: boolean; + }; onLabelClick?: (e: ReactMouseEvent) => void; onHandleSelectionChange?: (key: string, selected: boolean) => void; }; @@ -119,12 +122,14 @@ export const InputHandle = ({ }; }, [selected]); + const { searchHighlight, connectionHighlight } = highlight || {}; + return (
@@ -138,8 +143,13 @@ export const InputHandle = ({ className={cn( "border-0! h-full! w-full! transform-none!", missing, + searchHighlight && + !connectionHighlight && + !selected && + !active && + "bg-green-500!", + connectionHighlight && !selected && !active && "bg-pink-500!", (selected || active) && "bg-blue-500!", - highlight && "bg-green-500!", state.readOnly && "cursor-pointer!", )} onClick={handleHandleClick} @@ -163,9 +173,18 @@ export const InputHandle = ({
) => void; onHandleSelectionChange?: (key: string, selected: boolean) => void; }; @@ -302,12 +324,14 @@ export const OutputHandle = ({ }; }, [selected]); + const { searchHighlight, connectionHighlight } = highlight || {}; + return (
@@ -321,9 +345,18 @@ export const OutputHandle = ({
@@ -344,9 +377,14 @@ export const OutputHandle = ({ isConnectable={true} onClick={handleHandleClick} className={cn( - "relative! border-0! !w-[12px] !h-[12px] transform-none! translate-x-6 cursor-pointer bg-gray-500!", + "relative! border-0! w-3! h-3! transform-none! translate-x-6 cursor-pointer bg-gray-500!", + searchHighlight && + !connectionHighlight && + !selected && + !active && + "bg-green-500!", + connectionHighlight && !selected && !active && "bg-pink-500!", (selected || active) && "bg-blue-500!", - highlight && "bg-green-500!", state.readOnly && "cursor-pointer!", )} data-testid={`output-handle-${output.name}`} @@ -355,11 +393,11 @@ export const OutputHandle = ({ ); }; -const getOutputHandleId = (outputName: string) => { +export const getOutputHandleId = (outputName: string) => { return `output_${outputName}`; }; -const getInputHandleId = (inputName: string) => { +export const getInputHandleId = (inputName: string) => { return `input_${inputName}`; }; diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNodeCard/TaskNodeInputs.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNodeCard/TaskNodeInputs.tsx index 05f5a0e51..a05fab964 100644 --- a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNodeCard/TaskNodeInputs.tsx +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNodeCard/TaskNodeInputs.tsx @@ -13,7 +13,8 @@ import { ComponentSearchFilter } from "@/utils/constants"; import { inputNameToNodeId } from "@/utils/nodes/nodeIdUtils"; import { checkArtifactMatchesSearchFilters } from "@/utils/searchUtils"; -import { InputHandle } from "./Handles"; +import { useConnectionHighlighting } from "../../hooks/useConnectionHighlighting"; +import { getInputHandleId, InputHandle } from "./Handles"; import { getDisplayValue } from "./handleUtils"; interface TaskNodeInputsProps { @@ -27,7 +28,7 @@ export function TaskNodeInputs({ expanded, onBackgroundClick, }: TaskNodeInputsProps) { - const { inputs, taskSpec, state, select } = useTaskNode(); + const { nodeId, inputs, taskSpec, state, select } = useTaskNode(); const { graphSpec } = useComponentSpec(); const { highlightSearchFilter, @@ -36,6 +37,9 @@ export function TaskNodeInputs({ highlightSearchResults, } = useForcedSearchContext(); + const { highlightConnections, clearHighlights, isHandleHighlighted } = + useConnectionHighlighting(); + const connection = useConnection(); const [isDragging, setIsDragging] = useState(false); @@ -87,31 +91,51 @@ export function TaskNodeInputs({ if (state.readOnly) return; const input = inputs.find((i) => i.name === inputName); - toggleHighlightRelatedHandles(selected, input); + + if (selected) { + toggleHighlightRelatedHandles(true, input); + + const handleId = getInputHandleId(inputName); + highlightConnections(nodeId, handleId, "input"); + } else { + resetSearchFilter(); + clearHighlights(); + } }, - [inputs, state.readOnly, toggleHighlightRelatedHandles], + [ + inputs, + state.readOnly, + toggleHighlightRelatedHandles, + highlightConnections, + clearHighlights, + resetSearchFilter, + ], ); const checkHighlight = useCallback( (input: InputSpec) => { - if ( - !highlightSearchResults || - !isValidFilterRequest(currentSearchFilter, { + // Search-based highlighting (green) + const searchHighlight = + highlightSearchResults && + isValidFilterRequest(currentSearchFilter, { includesFilter: ComponentSearchFilter.INPUTTYPE, - }) - ) { - return false; - } + }) && + checkArtifactMatchesSearchFilters( + currentSearchFilter.searchTerm, + currentSearchFilter.filters, + input, + ); - const matchFound = checkArtifactMatchesSearchFilters( - currentSearchFilter.searchTerm, - currentSearchFilter.filters, - input, - ); + // Connection-based highlighting (pink) + const handleId = getInputHandleId(input.name); + const connectionHighlight = isHandleHighlighted(nodeId, handleId); - return matchFound; + return { + searchHighlight, + connectionHighlight, + }; }, - [currentSearchFilter, highlightSearchResults], + [currentSearchFilter, highlightSearchResults, isHandleHighlighted, nodeId], ); const handleLabelClick = useCallback( diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNodeCard/TaskNodeOutputs.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNodeCard/TaskNodeOutputs.tsx index f20907c0e..c09d440eb 100644 --- a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNodeCard/TaskNodeOutputs.tsx +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNodeCard/TaskNodeOutputs.tsx @@ -10,7 +10,8 @@ import { ComponentSearchFilter } from "@/utils/constants"; import { outputNameToNodeId } from "@/utils/nodes/nodeIdUtils"; import { checkArtifactMatchesSearchFilters } from "@/utils/searchUtils"; -import { OutputHandle } from "./Handles"; +import { useConnectionHighlighting } from "../../hooks/useConnectionHighlighting"; +import { getOutputHandleId, OutputHandle } from "./Handles"; type TaskNodeOutputsProps = { condensed: boolean; @@ -31,6 +32,9 @@ export function TaskNodeOutputs({ highlightSearchResults, } = useForcedSearchContext(); + const { highlightConnections, clearHighlights, isHandleHighlighted } = + useConnectionHighlighting(); + const connection = useConnection(); const edges = useEdges(); @@ -78,31 +82,54 @@ export function TaskNodeOutputs({ if (state.readOnly) return; const output = outputs.find((o) => o.name === outputName); - toggleHighlightRelatedHandles(selected, output); + + if (selected) { + // Search-based highlighting (green) + toggleHighlightRelatedHandles(true, output); + + // Connection-based highlighting (pink) + const handleId = getOutputHandleId(outputName); + highlightConnections(nodeId, handleId, "output"); + } else { + resetSearchFilter(); + clearHighlights(); + } }, - [outputs, state.readOnly, toggleHighlightRelatedHandles], + [ + outputs, + state.readOnly, + toggleHighlightRelatedHandles, + nodeId, + highlightConnections, + clearHighlights, + resetSearchFilter, + ], ); const checkHighlight = useCallback( (output: OutputSpec) => { - if ( - !highlightSearchResults || - !isValidFilterRequest(currentSearchFilter, { + // Search-based highlighting (green) + const searchHighlight = + highlightSearchResults && + isValidFilterRequest(currentSearchFilter, { includesFilter: ComponentSearchFilter.OUTPUTTYPE, - }) - ) { - return false; - } - - const matchFound = checkArtifactMatchesSearchFilters( - currentSearchFilter?.searchTerm, - currentSearchFilter?.filters, - output, - ); - - return matchFound; + }) && + checkArtifactMatchesSearchFilters( + currentSearchFilter.searchTerm, + currentSearchFilter.filters, + output, + ); + + // Connection-based highlighting (pink) + const handleId = getOutputHandleId(output.name); + const connectionHighlight = isHandleHighlighted(nodeId, handleId); + + return { + searchHighlight, + connectionHighlight, + }; }, - [highlightSearchResults, currentSearchFilter], + [highlightSearchResults, currentSearchFilter, isHandleHighlighted, nodeId], ); const handleLabelClick = useCallback( diff --git a/src/components/shared/ReactFlow/FlowCanvas/hooks/useConnectionHighlighting.ts b/src/components/shared/ReactFlow/FlowCanvas/hooks/useConnectionHighlighting.ts new file mode 100644 index 000000000..765805afe --- /dev/null +++ b/src/components/shared/ReactFlow/FlowCanvas/hooks/useConnectionHighlighting.ts @@ -0,0 +1,61 @@ +import { useEdges } from "@xyflow/react"; +import { useCallback, useEffect, useState } from "react"; + +import { + clearHighlights, + getSelectedHandle, + highlightConnections, + subscribeToHandleHighlights, +} from "@/utils/nodes/highlightingState"; + +export const useConnectionHighlighting = () => { + const [, forceUpdate] = useState({}); + const edges = useEdges(); + + const triggerUpdate = useCallback(() => { + forceUpdate({}); + }, []); + + useEffect(() => { + return subscribeToHandleHighlights(triggerUpdate); + }, [triggerUpdate]); + + const isHandleHighlighted = useCallback( + (nodeId: string, handleId: string) => { + const globalSelectedHandle = getSelectedHandle(); + if (!globalSelectedHandle) return false; + + if ( + nodeId === globalSelectedHandle.nodeId && + handleId === globalSelectedHandle.handleId + ) { + return true; + } + + return edges.some((edge) => { + if (globalSelectedHandle.handleType === "input") { + return ( + edge.target === globalSelectedHandle.nodeId && + edge.targetHandle === globalSelectedHandle.handleId && + edge.source === nodeId && + edge.sourceHandle === handleId + ); + } else { + return ( + edge.source === globalSelectedHandle.nodeId && + edge.sourceHandle === globalSelectedHandle.handleId && + edge.target === nodeId && + edge.targetHandle === handleId + ); + } + }); + }, + [edges], + ); + + return { + highlightConnections, + clearHighlights, + isHandleHighlighted, + }; +}; diff --git a/src/hooks/useComponentSpecToEdges.ts b/src/hooks/useComponentSpecToEdges.ts index 48c9c3190..794e84a0f 100644 --- a/src/hooks/useComponentSpecToEdges.ts +++ b/src/hooks/useComponentSpecToEdges.ts @@ -4,7 +4,7 @@ import { MarkerType, useEdgesState, } from "@xyflow/react"; -import { useEffect } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import type { ArgumentType, @@ -14,6 +14,10 @@ import type { TaskOutputArgument, TaskSpec, } from "@/utils/componentSpec"; +import { + getSelectedHandle, + subscribeToEdgeHighlights, +} from "@/utils/nodes/highlightingState"; import { inputNameToNodeId, outputNameToNodeId, @@ -30,13 +34,63 @@ const useComponentSpecToEdges = ( getEdges(componentSpec), ); + const [, forceUpdate] = useState({}); + + const selectedHandle = getSelectedHandle(); + + const triggerUpdate = useCallback(() => { + forceUpdate({}); + }, []); + + useEffect(() => { + return subscribeToEdgeHighlights(triggerUpdate); + }, [triggerUpdate]); + useEffect(() => { const newEdges = getEdges(componentSpec); setFlowEdges(newEdges); - }, [componentSpec]); + }, [componentSpec, setFlowEdges]); + + const enhancedEdges = useMemo(() => { + if (!selectedHandle) { + return flowEdges; + } + + const highlightedEdgeIds = new Set(); + + flowEdges.forEach((edge) => { + if (selectedHandle.handleType === "input") { + if ( + edge.target === selectedHandle.nodeId && + edge.targetHandle === selectedHandle.handleId + ) { + highlightedEdgeIds.add(edge.id); + } + } else { + if ( + edge.source === selectedHandle.nodeId && + edge.sourceHandle === selectedHandle.handleId + ) { + highlightedEdgeIds.add(edge.id); + } + } + }); + + if (highlightedEdgeIds.size === 0) { + return flowEdges; + } + + return flowEdges.map((edge) => ({ + ...edge, + data: { + ...edge.data, + highlighted: highlightedEdgeIds.has(edge.id), + }, + })); + }, [flowEdges, selectedHandle]); return { - edges: flowEdges, + edges: enhancedEdges, onEdgesChange: onFlowEdgesChange, }; }; diff --git a/src/utils/nodes/highlightingState.ts b/src/utils/nodes/highlightingState.ts new file mode 100644 index 000000000..35f7720ef --- /dev/null +++ b/src/utils/nodes/highlightingState.ts @@ -0,0 +1,56 @@ +interface SelectedHandle { + nodeId: string; + handleId: string; + handleType: "input" | "output"; +} + +// Global state - single source of truth +let globalSelectedHandle: SelectedHandle | null = null; + +// Listeners for handle highlighting changes +const handleHighlightListeners = new Set<() => void>(); + +// Listeners for edge highlighting changes +const edgeHighlightListeners = new Set<() => void>(); + +const notifyHandleListeners = () => { + handleHighlightListeners.forEach((listener) => listener()); +}; + +const notifyEdgeListeners = () => { + edgeHighlightListeners.forEach((listener) => listener()); +}; + +// Public API +export const highlightConnections = ( + nodeId: string, + handleId: string, + handleType: "input" | "output", +) => { + globalSelectedHandle = { nodeId, handleId, handleType }; + notifyHandleListeners(); + notifyEdgeListeners(); +}; + +export const clearHighlights = () => { + globalSelectedHandle = null; + notifyHandleListeners(); + notifyEdgeListeners(); +}; + +export const getSelectedHandle = () => globalSelectedHandle; + +// Subscription functions +export const subscribeToHandleHighlights = (listener: () => void) => { + handleHighlightListeners.add(listener); + return () => { + handleHighlightListeners.delete(listener); + }; +}; + +export const subscribeToEdgeHighlights = (listener: () => void) => { + edgeHighlightListeners.add(listener); + return () => { + edgeHighlightListeners.delete(listener); + }; +};