input highlights
This commit is contained in:
37
src/App.tsx
37
src/App.tsx
@@ -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' }}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user