input highlights

This commit is contained in:
2026-03-08 09:29:41 +01:00
parent 4065bde50d
commit 78c3ebddd7
7 changed files with 90 additions and 7 deletions

View File

@@ -68,6 +68,7 @@ export default function App() {
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges) const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges)
const [rfInstance, setRfInstance] = React.useState<any | null>(null) const [rfInstance, setRfInstance] = React.useState<any | null>(null)
const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null) const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null)
const [connectionFrom, setConnectionFrom] = React.useState<{ nodeId: string; sourceHandle?: string } | null>(null)
const wrapperRef = React.useRef<HTMLDivElement | null>(null) const wrapperRef = React.useRef<HTMLDivElement | null>(null)
const lastClickRef = React.useRef<{ clientX: number; clientY: number } | null>(null) const lastClickRef = React.useRef<{ clientX: number; clientY: number } | null>(null)
@@ -85,6 +86,37 @@ export default function App() {
[setEdges] [setEdges]
) )
const isValidConnection = React.useCallback(
(connection: Connection) => {
const sourceNode = nodes.find((n) => n.id === connection.source)
const targetNode = nodes.find((n) => n.id === connection.target)
const sourceType = sourceNode?.type
const targetType = targetNode?.type
if (!sourceType || !targetType) return false
if (connection.source === connection.target) return false
if (targetType === 'variable') return false
if (targetType === 'render' && (sourceType === 'variable' || sourceType === 'function')) return false
if (sourceType === 'render') return false
return true
},
[nodes]
)
const onConnectStart = React.useCallback(
(_: React.MouseEvent | React.TouchEvent, params: { nodeId?: string | null; handleId?: string | null; handleType?: string | null }) => {
if (params.handleType !== 'source' || !params.nodeId) {
setConnectionFrom(null)
return
}
setConnectionFrom({ nodeId: params.nodeId, sourceHandle: params.handleId ?? undefined })
},
[]
)
const onConnectEnd = React.useCallback(() => {
setConnectionFrom(null)
}, [])
const onInit = React.useCallback((instance: any) => { const onInit = React.useCallback((instance: any) => {
setRfInstance(instance) setRfInstance(instance)
}, []) }, [])
@@ -204,7 +236,7 @@ export default function App() {
> >
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />} {theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button> </Button>
<FlowContext.Provider value={{ nodes, setNodes, edges, setEdges, renamingNodeId, setRenamingNodeId }}> <FlowContext.Provider value={{ nodes, setNodes, edges, setEdges, renamingNodeId, setRenamingNodeId, connectionFrom, setConnectionFrom, isValidConnection }}>
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<div style={{ width: '100%', height: '100%' }}> <div style={{ width: '100%', height: '100%' }}>
@@ -215,6 +247,9 @@ export default function App() {
onNodesChange={onNodesChange} onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
onConnect={onConnect} onConnect={onConnect}
onConnectStart={onConnectStart}
onConnectEnd={onConnectEnd}
isValidConnection={isValidConnection}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
edgeTypes={edgeTypes} edgeTypes={edgeTypes}
defaultEdgeOptions={{ type: 'animated' }} defaultEdgeOptions={{ type: 'animated' }}

View File

@@ -161,7 +161,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
: undefined : undefined
return ( return (
<BaseNode className="min-w-80 min-h-[280px]" dimensions={dimensions} resizable nodeId={id} handles={<><InputHandle id="ain" /><OutputHandle id="out" /></>}> <BaseNode className="min-w-80 min-h-[280px]" dimensions={dimensions} resizable nodeId={id} handles={<><InputHandle id="ain" nodeId={id} /><OutputHandle id="out" /></>}>
<BaseNodeHeaderRow icon={<ScrollText className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={`${id}`} />} /> <BaseNodeHeaderRow icon={<ScrollText className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={`${id}`} />} />
<BaseNodeContent> <BaseNodeContent>

View File

@@ -48,7 +48,7 @@ export const FunctionNode = memo(function FunctionNode({ id, data, width, height
: undefined : undefined
return ( return (
<BaseNode className="min-w-72 min-h-[260px]" dimensions={dimensions} resizable nodeId={id} handles={<><InputHandle id="in" /><OutputHandle id="out" /></>}> <BaseNode className="min-w-72 min-h-[260px]" dimensions={dimensions} resizable nodeId={id} handles={<><InputHandle id="in" nodeId={id} /><OutputHandle id="out" /></>}>
<BaseNodeHeaderRow icon={<Code2 className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} /> <BaseNodeHeaderRow icon={<Code2 className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
<BaseNodeContent> <BaseNodeContent>

View File

@@ -1,17 +1,35 @@
import React from 'react' import React, { useContext } from 'react'
import { Handle, Position } from '@xyflow/react' import { Handle, Position } from '@xyflow/react'
import { ArrowDownLeft, ArrowUpRight } from 'lucide-react' import { ArrowDownLeft, ArrowUpRight } from 'lucide-react'
import FlowContext from '@/lib/flowContext'
import { cn } from '@/lib/utils'
type NodeHandleProps = { type NodeHandleProps = {
id: string id: string
/** Pass when this handle is a connection target so valid highlight can show as soon as connection starts */
nodeId?: string
} }
export function InputHandle({ id }: NodeHandleProps) { export function InputHandle({ id, nodeId }: NodeHandleProps) {
const ctx = useContext(FlowContext)
const connectionFrom = ctx?.connectionFrom ?? null
const isValidConnection = ctx?.isValidConnection
const isConnecting = Boolean(nodeId && connectionFrom && connectionFrom.nodeId !== nodeId)
const isValidTarget =
isConnecting &&
isValidConnection?.({
source: connectionFrom!.nodeId,
sourceHandle: connectionFrom!.sourceHandle ?? undefined,
target: nodeId!,
targetHandle: id,
})
return ( return (
<Handle <Handle
type="target" type="target"
position={Position.Left} position={Position.Left}
id={id} id={id}
className={cn(isValidTarget && 'connection-valid-target', isConnecting && !isValidTarget && 'connection-invalid-target')}
style={{ style={{
top: 20, top: 20,
width: 20, width: 20,

View File

@@ -16,7 +16,7 @@ import { NodeMenubar } from './NodeMenubar'
import { NodeStatusIndicator } from './NodeStatusIndicator' import { NodeStatusIndicator } from './NodeStatusIndicator'
import { MenubarItem, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '../ui/menubar' import { MenubarItem, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '../ui/menubar'
import { Sparkles } from 'lucide-react' import { Sparkles } from 'lucide-react'
import { InputHandle, OutputHandle } from './NodeHandles' import { InputHandle } from './NodeHandles'
// Use relative URL so Vite dev proxy (and optional prod proxy) avoids CORS // Use relative URL so Vite dev proxy (and optional prod proxy) avoids CORS
const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg' const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg'
@@ -414,7 +414,7 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
return ( return (
<NodeStatusIndicator status={status} variant="border" width={dimensions?.width} height={dimensions?.height}> <NodeStatusIndicator status={status} variant="border" width={dimensions?.width} height={dimensions?.height}>
<BaseNode className="min-w-96 min-h-[320px]" dimensions={dimensions} resizable nodeId={id} handles={<><InputHandle id="ain" /><OutputHandle id="out" /></>}> <BaseNode className="min-w-96 min-h-[320px]" dimensions={dimensions} resizable nodeId={id} handles={<InputHandle id="ain" nodeId={id} />}>
<BaseNodeHeaderRow icon={<Sparkles className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} /> <BaseNodeHeaderRow icon={<Sparkles className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
<BaseNodeContent> <BaseNodeContent>

View File

@@ -1,4 +1,7 @@
import React from 'react' import React from 'react'
import type { Connection } from '@xyflow/react'
export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null
export type FlowContextValue = { export type FlowContextValue = {
nodes: any[] nodes: any[]
@@ -7,6 +10,10 @@ export type FlowContextValue = {
setEdges: (updater: any) => void setEdges: (updater: any) => void
renamingNodeId: string | null renamingNodeId: string | null
setRenamingNodeId: (id: string | null) => void setRenamingNodeId: (id: string | null) => void
/** Set when user starts dragging from an output handle; cleared on connect end. Used to highlight valid targets. */
connectionFrom: ConnectionFrom
setConnectionFrom: (v: ConnectionFrom) => void
isValidConnection: (connection: Connection) => boolean
} }
const FlowContext = React.createContext<FlowContextValue | null>(null) const FlowContext = React.createContext<FlowContextValue | null>(null)

View File

@@ -33,6 +33,29 @@ body {
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.15); box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.15);
} }
/* Connection validation: valid targets clearly highlighted (from drag start and when hovering), invalid dimmed while connecting */
.react-flow__handle.react-flow__handle-connecting.react-flow__handle-valid,
.react-flow__handle.connection-valid-target {
background: hsl(var(--primary)) !important;
color: hsl(var(--primary-foreground));
box-shadow: 0 0 0 3px hsl(var(--primary)), 0 0 12px 2px hsl(var(--primary) / 0.5) !important;
opacity: 1 !important;
}
.react-flow__handle.react-flow__handle-connecting.react-flow__handle-valid svg,
.react-flow__handle.connection-valid-target svg {
color: inherit;
}
.dark .react-flow__handle.react-flow__handle-connecting.react-flow__handle-valid,
.dark .react-flow__handle.connection-valid-target {
background: hsl(var(--primary)) !important;
box-shadow: 0 0 0 3px hsl(var(--primary)), 0 0 14px 4px hsl(var(--primary) / 0.6) !important;
}
.react-flow__handle.react-flow__handle-connecting:not(.react-flow__handle-valid):not(.connection-valid-target),
.react-flow__handle.connection-invalid-target {
opacity: 0.25 !important;
pointer-events: none;
}
/* Animated React Flow edges: thicker stroke + path animation */ /* Animated React Flow edges: thicker stroke + path animation */
.react-flow__edge path.animated-edge-path, .react-flow__edge path.animated-edge-path,
.animated-edge-path { .animated-edge-path {