Compare commits

..

4 Commits

Author SHA1 Message Date
3ef30bd58c fix: rendering animaiton 2026-03-11 22:20:21 +01:00
42676b2674 fix: color path 2026-03-11 22:18:01 +01:00
fddc6488ea fix: remove init constraint 2026-03-11 22:10:58 +01:00
1232d148e6 feat: ant path rendering on cahnges 2026-03-11 22:08:08 +01:00
11 changed files with 311 additions and 35 deletions

View File

@@ -66,6 +66,7 @@ import {
getNodeType,
isConnectionAllowed,
} from '@/lib/nodeRegistry'
import { getPathNodeIds } from '@/lib/graphPath'
import type { AppNode, AppEdge } from '@/lib/nodeTypes'
import { toast } from 'sonner'
import {
@@ -77,6 +78,8 @@ import {
const SNAP_GRID: [number, number] = [15, 15]
const DUPLICATE_OFFSET = { x: 30, y: 30 }
/** Minimum time (ms) the connection ant trail runs when a path update is in progress. */
const CONNECTION_PATH_UPDATE_MIN_MS = 1500
const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
x: Math.round(x / SNAP_GRID[0]) * SNAP_GRID[0],
y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1],
@@ -264,6 +267,75 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
const [isSelecting, setIsSelecting] = React.useState(false)
const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null)
const [fullscreenNodeId, setFullscreenNodeId] = React.useState<string | null>(null)
const [connectionPathUpdatingNodeIds, setConnectionPathUpdatingNodeIds] = React.useState<string[]>([])
const [connectionPathTriggerNodeIds, setConnectionPathTriggerNodeIds] = React.useState<string[]>([])
const pathUpdateNodeIdsRef = useRef<Set<string>>(new Set())
const pathUpdateStartTimeRef = useRef<number | null>(null)
const pathUpdateEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const clearPathUpdateSession = useCallback(() => {
setConnectionPathUpdatingNodeIds([])
setConnectionPathTriggerNodeIds([])
}, [])
const startConnectionPathUpdate = useCallback((nodeId: string) => {
const ref = pathUpdateNodeIdsRef.current
ref.add(nodeId)
if (ref.size === 1) {
pathUpdateStartTimeRef.current = Date.now()
if (pathUpdateEndTimeoutRef.current != null) {
clearTimeout(pathUpdateEndTimeoutRef.current)
pathUpdateEndTimeoutRef.current = null
}
}
setConnectionPathUpdatingNodeIds(Array.from(ref))
}, [])
const endConnectionPathUpdate = useCallback((nodeId: string) => {
const ref = pathUpdateNodeIdsRef.current
ref.delete(nodeId)
if (ref.size > 0) {
setConnectionPathUpdatingNodeIds(Array.from(ref))
return
}
const startedAt = pathUpdateStartTimeRef.current ?? 0
const elapsed = Date.now() - startedAt
const remaining = Math.max(0, CONNECTION_PATH_UPDATE_MIN_MS - elapsed)
if (remaining === 0) {
clearPathUpdateSession()
} else {
pathUpdateEndTimeoutRef.current = setTimeout(() => {
pathUpdateEndTimeoutRef.current = null
clearPathUpdateSession()
}, remaining)
}
}, [clearPathUpdateSession])
const pathTriggerBatchRef = useRef<Set<string>>(new Set())
const pathTriggerScheduledRef = useRef(false)
const addConnectionPathTrigger = useCallback((nodeId: string) => {
pathTriggerBatchRef.current.add(nodeId)
if (pathTriggerScheduledRef.current) return
pathTriggerScheduledRef.current = true
requestAnimationFrame(() => {
pathTriggerScheduledRef.current = false
const batch = new Set(pathTriggerBatchRef.current)
pathTriggerBatchRef.current = new Set()
if (batch.size === 0) return
setConnectionPathTriggerNodeIds((prev) => {
const next = new Set(prev)
batch.forEach((id) => next.add(id))
return next.size === prev.length && prev.every((id) => next.has(id)) ? prev : Array.from(next)
})
})
}, [])
React.useEffect(() => () => {
if (pathUpdateEndTimeoutRef.current != null) {
clearTimeout(pathUpdateEndTimeoutRef.current)
}
}, [])
const nodesRef = useRef(nodes)
nodesRef.current = nodes
@@ -471,6 +543,11 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
flowActionsRef.current?.pasteAtViewportCenter?.()
}, [])
const connectionPathNodeIds = useMemo(
() => getPathNodeIds(edges, connectionPathUpdatingNodeIds, connectionPathTriggerNodeIds),
[edges, connectionPathUpdatingNodeIds, connectionPathTriggerNodeIds]
)
const flowContextValue = useMemo(
() => ({
nodes,
@@ -485,6 +562,12 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
flowActionsRef,
fullscreenNodeId,
setFullscreenNodeId,
connectionPathUpdatingNodeIds,
connectionPathTriggerNodeIds,
addConnectionPathTrigger,
connectionPathNodeIds,
startConnectionPathUpdate,
endConnectionPathUpdate,
}),
[
nodes,
@@ -499,6 +582,12 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
flowActionsRef,
fullscreenNodeId,
setFullscreenNodeId,
connectionPathUpdatingNodeIds,
connectionPathTriggerNodeIds,
addConnectionPathTrigger,
connectionPathNodeIds,
startConnectionPathUpdate,
endConnectionPathUpdate,
]
)

View File

@@ -79,6 +79,8 @@ function CompactNodeView({ id, type, selected, width, height }: NodeProps) {
/**
* Wraps a node component so that when the viewport zoom is at or below
* CONTEXTUAL_ZOOM_THRESHOLD, the node renders as a compact icon-only view.
* Inner is always mounted (hidden when compact) so switching zoom does not
* remount and re-trigger effects (e.g. RenderingNode fetch).
*/
export function createContextualNode<P extends NodeProps>(
Inner: React.ComponentType<P>
@@ -86,22 +88,35 @@ export function createContextualNode<P extends NodeProps>(
function ContextualZoomNode(props: P) {
const { zoom } = useViewport()
const showCompact = zoom <= CONTEXTUAL_ZOOM_THRESHOLD
const p = props as NodeProps
const type = p.type
const defaultStyle = type ? getDefaultStyle(type) : { width: 320, height: 320 }
const w = p.width ?? defaultStyle.width
const h = p.height ?? defaultStyle.height
if (showCompact) {
const p = props as NodeProps
return (
<CompactNodeView
id={p.id}
type={p.type}
data={p.data}
selected={p.selected}
width={p.width}
height={p.height}
/>
)
}
return <Inner {...props} />
return (
<div
style={
showCompact
? { width: w, height: h, minWidth: w, minHeight: h, position: 'relative' }
: { display: 'contents' }
}
>
{showCompact && (
<CompactNodeView
id={p.id}
type={type}
data={p.data}
selected={p.selected}
width={p.width}
height={p.height}
/>
)}
<div style={{ display: showCompact ? 'none' : undefined }} aria-hidden={showCompact}>
<Inner {...props} />
</div>
</div>
)
}
ContextualZoomNode.displayName = `ContextualZoom(${Inner.displayName ?? Inner.name ?? 'Node'})`

View File

@@ -9,9 +9,11 @@ import { getConnectionLabelForTarget } from '../../lib/nodeRegistry'
const EDGE_STROKE_WIDTH = 2
const DOT_MARKER_R = 1.5
const EMPTY_PATH_NODE_IDS = new Set<string>()
export function AnimatedEdge({
id,
source,
sourceX,
sourceY,
targetX,
@@ -25,6 +27,7 @@ export function AnimatedEdge({
}: EdgeProps) {
const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? []
const pathNodeIds = ctx?.connectionPathNodeIds ?? EMPTY_PATH_NODE_IDS
const targetNode = useMemo(() => nodes.find((n: any) => n.id === target), [nodes, target])
const derivedLabel = useMemo(
() => (targetNode?.type != null ? getConnectionLabelForTarget(targetNode.type) : undefined),
@@ -32,6 +35,8 @@ export function AnimatedEdge({
)
const label = labelProp ?? derivedLabel
const isOnUpdatingPath = pathNodeIds.has(source) && pathNodeIds.has(target)
const [edgePath, edgeLabelX, edgeLabelY] = getBezierPath({
sourceX,
sourceY,
@@ -88,7 +93,7 @@ export function AnimatedEdge({
strokeWidth: EDGE_STROKE_WIDTH,
...style,
}}
className="animated-edge-path"
className={`animated-edge-path${isOnUpdatingPath ? ' animated-edge-path--updating' : ''}`}
interactionWidth={interactionWidth}
/>
{label != null && (

View File

@@ -1,6 +1,7 @@
import type { ComponentProps, ReactNode } from "react";
import { NodeResizer } from "@xyflow/react";
import { useConnectionPathRole } from "@/lib/flowContext";
import { cn } from "@/lib/utils";
/** Default min size for resizable nodes (used by NodeResizer). */
@@ -34,6 +35,7 @@ export function BaseNode({
resizeConstraints,
...props
}: BaseNodeProps) {
const connectionPathRole = useConnectionPathRole(nodeId);
const hasSize =
dimensions &&
dimensions.width > 0 &&
@@ -62,9 +64,13 @@ export function BaseNode({
"bg-card text-card-foreground relative rounded-md border transition-[border-color,box-shadow] duration-200",
"hover:ring-1",
selected && "border-primary/50 shadow-[0_0_0_2px_hsl(var(--primary)_/_0.15)] dark:border-primary/35 dark:shadow-[0_0_0_2px_hsl(var(--primary)_/_0.1)]",
connectionPathRole === "trigger" && "connection-path-trigger",
connectionPathRole === "updating" && "connection-path-updating",
connectionPathRole === "on-path" && "connection-path-on-path",
className,
)}
data-selected={selected}
data-path-role={connectionPathRole ?? undefined}
style={appliedStyle}
tabIndex={0}
{...props}

View File

@@ -55,6 +55,8 @@ function serializeNodeForContext(nodes: { id: string; type?: string; data?: unkn
function AgentNodeComponent({ id, data, width, height, selected }: Props) {
const flowContext = useContext(FlowContext)
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
const startConnectionPathUpdate = flowContext?.startConnectionPathUpdate
const endConnectionPathUpdate = flowContext?.endConnectionPathUpdate
const supportsFullscreen = getNodeType('agent')?.supportsFullscreen
const { nodes, sourceIds, updateData } = useAbstractNode<AgentNodeData>(id, data ?? {})
const { aiConnection } = usePlatform()
@@ -88,6 +90,7 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
const contextNodes = sourceIds.map((sid) => ({ id: sid, content: serializeNodeForContext(nodes, sid) }))
updateData({ error: undefined, loading: true })
startConnectionPathUpdate?.(id)
setRunning(true)
try {
const res = await fetch('/api/agent', {
@@ -107,16 +110,19 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
error: (json as { error?: string }).error ?? `Request failed: ${res.status}`,
outputMarkdown: undefined,
})
endConnectionPathUpdate?.(id)
return
}
const markdown = (json as { markdown?: string }).markdown ?? ''
updateData({ loading: false, error: undefined, outputMarkdown: markdown })
endConnectionPathUpdate?.(id)
} catch (err: unknown) {
updateData({
loading: false,
error: err instanceof Error ? err.message : 'Agent request failed',
outputMarkdown: undefined,
})
endConnectionPathUpdate?.(id)
} finally {
setRunning(false)
}
@@ -131,6 +137,7 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
<BaseNode
className="min-w-[360px] min-h-[320px]"
dimensions={dimensions}
nodeId={id}
selected={selected}
handles={
<>

View File

@@ -50,6 +50,8 @@ type ViewMode = 'preview' | 'raw'
function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
const flowContext = useContext(FlowContext)
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
const startConnectionPathUpdate = flowContext?.startConnectionPathUpdate
const endConnectionPathUpdate = flowContext?.endConnectionPathUpdate
const supportsFullscreen = getNodeType('render')?.supportsFullscreen
const [renderedContent, setRenderedContent] = useState<string | null>(null)
const [resolvedContent, setResolvedContent] = useState<string | null>(null)
@@ -195,6 +197,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
message: isAgentSource ? 'Run the Agent node to generate output.' : 'No content on connected configuration node',
})
setLoading(false)
endConnectionPathUpdate?.(id)
return
}
if (incomingIds.length === 0) {
@@ -202,6 +205,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
setResolvedContent(null)
setError(null)
setLoading(false)
endConnectionPathUpdate?.(id)
return
}
@@ -212,6 +216,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
const run = async () => {
loadingStartedAtRef.current = Date.now()
setLoading(true)
startConnectionPathUpdate?.(id)
setError(null)
try {
if (srcNode?.type === 'agent') {
@@ -223,6 +228,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
setRenderedContent(html)
setError(null)
setLoading(false)
endConnectionPathUpdate?.(id)
return
}
const configIdsUsed = new Set<string>()
@@ -497,6 +503,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
setResolvedContent(null)
setError({ kind: 'render', message: `Nunjucks: ${nunjucksErr.message}` })
setLoading(false)
endConnectionPathUpdate?.(id)
return
}
@@ -524,10 +531,14 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
if (remaining > 0) {
minLoadingTimeoutRef.current = setTimeout(() => {
minLoadingTimeoutRef.current = null
if (!cancelled && thisRunId === runIdRef.current) setLoading(false)
if (!cancelled && thisRunId === runIdRef.current) {
setLoading(false)
endConnectionPathUpdate?.(id)
}
}, remaining)
} else {
setLoading(false)
endConnectionPathUpdate?.(id)
}
}
}
@@ -537,6 +548,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
setRenderedContent(null)
setError({ kind: 'render', message: err?.message ?? 'Render error' })
setLoading(false)
endConnectionPathUpdate?.(id)
}
}
}
@@ -549,6 +561,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
clearTimeout(minLoadingTimeoutRef.current)
minLoadingTimeoutRef.current = null
}
endConnectionPathUpdate?.(id)
}
// Only re-run when inputs that affect the resolved output change (signatures + source). Debounced to avoid excessive re-renders while typing. retryCount triggers re-run on Retry.
}, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature, viewportWidth, viewportHeight, retryCount, isAgentSource])
@@ -674,7 +687,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
}, [configTypeId, configType.language])
return (
<NodeStatusIndicator status={status} variant="border" width={dimensions?.width} height={dimensions?.height}>
<NodeStatusIndicator status={status} variant="overlay" width={dimensions?.width} height={dimensions?.height}>
<BaseNode className="min-w-96 min-h-[320px]" dimensions={dimensions} resizable nodeId={id} selected={selected} handles={<InputHandle id="ain" nodeId={id} />}>
<BaseNodeHeaderRow
icon={<Sparkles className="size-4" />}

View File

@@ -84,7 +84,7 @@ function VariableNodeComponent({ id, data, width, height, selected }: Props) {
)
return (
<BaseNode className="min-w-56 min-h-[180px]" dimensions={dimensions} selected={selected} handles={<OutputHandle id="out" />}>
<BaseNode className="min-w-56 min-h-[180px]" dimensions={dimensions} nodeId={id} selected={selected} handles={<OutputHandle id="out" />}>
<BaseNodeHeaderRow icon={<Variable className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
<BaseNodeContent>

View File

@@ -72,6 +72,7 @@ export function useAbstractNode<TData = Record<string, unknown>>(
const setNodes = ctx?.setNodes
const setEdges = ctx?.setEdges
const addConnectionPathTrigger = ctx?.addConnectionPathTrigger
const updateData = useCallback(
(partial: Partial<TData>) => {
if (!setNodes) return
@@ -80,8 +81,9 @@ export function useAbstractNode<TData = Record<string, unknown>>(
n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n
) as AppNode[]
)
addConnectionPathTrigger?.(id)
},
[id, setNodes]
[id, setNodes, addConnectionPathTrigger]
)
const incomingEdges = useMemo(

View File

@@ -1,9 +1,12 @@
import React from 'react'
import React, { useMemo } from 'react'
import type { Connection } from '@xyflow/react'
import type { AppNode, AppEdge } from '@/lib/nodeTypes'
export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null
/** Role of a node in the current connection path update: pushing data, receiving/loading, or just on path. */
export type ConnectionPathRole = 'trigger' | 'updating' | 'on-path'
export type FlowActions = {
pasteAtViewportCenter: () => void
fitView: () => void
@@ -25,8 +28,38 @@ export type FlowContextValue = {
/** When set, graph centers on this node and a fullscreen dialog shows the node. Cleared on close. */
fullscreenNodeId: string | null
setFullscreenNodeId: (id: string | null) => void
/** Node ids currently updating (e.g. render loading, agent running). Only edges on those paths show the ant trail. */
connectionPathUpdatingNodeIds: string[]
/** Node ids that triggered the current update (e.g. variable/config that changed). Path is restricted to downstream(trigger) ∩ upstream(updating). */
connectionPathTriggerNodeIds: string[]
/** Call when this node's output changed and may trigger downstream updates (e.g. variable value, config content). */
addConnectionPathTrigger: (nodeId: string) => void
/** All node ids on the path of an update. Edges with both endpoints in this set animate. */
connectionPathNodeIds: Set<string>
/** Call when a path update starts for this node. Animation runs at least CONNECTION_PATH_UPDATE_MIN_MS. */
startConnectionPathUpdate: (nodeId: string) => void
/** Call when a path update ends for this node. If min duration not reached, animation continues until then. */
endConnectionPathUpdate: (nodeId: string) => void
}
const FlowContext = React.createContext<FlowContextValue | null>(null)
export default FlowContext
/**
* Returns this node's role in the current path update for styling (pushing vs receiving).
* Use with BaseNode's connectionPathRole prop or data-path-role for CSS.
*/
export function useConnectionPathRole(nodeId: string | undefined): ConnectionPathRole | null {
const ctx = React.useContext(FlowContext)
return useMemo(() => {
if (!nodeId) return null
const triggers = ctx?.connectionPathTriggerNodeIds
const updating = ctx?.connectionPathUpdatingNodeIds
const path = ctx?.connectionPathNodeIds
if (!path?.has(nodeId)) return null
if (triggers?.includes(nodeId)) return 'trigger'
if (updating?.includes(nodeId)) return 'updating'
return 'on-path'
}, [nodeId, ctx?.connectionPathTriggerNodeIds, ctx?.connectionPathUpdatingNodeIds, ctx?.connectionPathNodeIds])
}

View File

@@ -0,0 +1,67 @@
/**
* Graph path utilities: compute which nodes/edges are "on the path" of an update.
* Used to show connection ant trail only along the full chain (upstream → updating → downstream).
* Works with any node types; any node can signal it is updating via startConnectionPathUpdate(id).
*/
export type GraphEdge = { source: string; target: string }
/** Nodes reachable from seedIds by following edges forward (source → target). */
export function getDownstreamNodeIds(edges: GraphEdge[], seedIds: string[]): Set<string> {
const out = new Set<string>(seedIds)
let added = true
while (added) {
added = false
for (const e of edges) {
if (out.has(e.source) && !out.has(e.target)) {
out.add(e.target)
added = true
}
}
}
return out
}
/** Nodes that can reach any seed by following edges backward (target → source). */
export function getUpstreamNodeIds(edges: GraphEdge[], seedIds: string[]): Set<string> {
const out = new Set<string>(seedIds)
let added = true
while (added) {
added = false
for (const e of edges) {
if (out.has(e.target) && !out.has(e.source)) {
out.add(e.source)
added = true
}
}
}
return out
}
/**
* All node ids that lie on the path of an update.
* - If triggerNodeIds is non-empty: path = nodes that are both downstream of a trigger and
* upstream of an updating node (only the chain that actually triggered the update).
* - Otherwise: path = upstream updating downstream of updating (legacy full upstream/downstream).
* An edge should show the update animation iff both its source and target are in this set.
*/
export function getPathNodeIds(
edges: GraphEdge[],
updatingNodeIds: string[],
triggerNodeIds?: string[]
): Set<string> {
if (updatingNodeIds.length === 0) return new Set()
const upstream = getUpstreamNodeIds(edges, updatingNodeIds)
if (triggerNodeIds != null && triggerNodeIds.length > 0) {
const downstreamOfTrigger = getDownstreamNodeIds(edges, triggerNodeIds)
const path = new Set<string>()
upstream.forEach((id) => {
if (downstreamOfTrigger.has(id)) path.add(id)
})
return path
}
const downstream = getDownstreamNodeIds(edges, updatingNodeIds)
const path = new Set<string>(upstream)
downstream.forEach((id) => path.add(id))
return path
}

View File

@@ -24,9 +24,11 @@ body {
.react-flow__pane {
cursor: crosshair;
}
.react-flow.react-flow--panning .react-flow__pane {
cursor: grabbing;
}
.react-flow.react-flow--selecting .react-flow__pane {
cursor: crosshair;
}
@@ -133,26 +135,40 @@ body {
min-height: min-content;
}
/* Animated React Flow edges: thicker stroke + path animation */
/* Animated React Flow edges: ant trail always on; color shows updating path */
.react-flow__edge path.animated-edge-path,
.animated-edge-path {
stroke-width: 4;
stroke: hsl(var(--foreground) / 0.6);
stroke: hsl(var(--foreground) / 0.5);
fill: none;
stroke-dasharray: 8 6;
animation: edge-flow 0.6s linear infinite;
stroke-dashoffset: 0;
animation: edge-flow 1.2s linear infinite;
will-change: stroke-dashoffset;
transform: translateZ(0);
backface-visibility: hidden;
transition: stroke 0.4s ease-out;
}
.react-flow__edge path.animated-edge-path.animated-edge-path--updating,
.animated-edge-path.animated-edge-path--updating {
stroke: hsl(217 91% 60%);
}
@keyframes edge-flow {
from {
0% {
stroke-dashoffset: 14;
}
to {
100% {
stroke-dashoffset: 0;
}
}
/* Connection path roles: pushing (trigger) — classes on BaseNode root. Updating nodes have no extra border so rendering doesnt flash. */
.connection-path-trigger {
box-shadow: inset 3px 0 0 hsl(var(--primary));
}
/* NodeResizer: snappy drag; no border/background on handles; keep resize cursors. */
.react-flow__resize-control {
touch-action: none;
@@ -174,14 +190,37 @@ body {
}
/* Resize cursor per position (keep cursor style change on hover). */
.react-flow__resize-control.top.left { cursor: nw-resize; }
.react-flow__resize-control.top.right { cursor: ne-resize; }
.react-flow__resize-control.bottom.left { cursor: sw-resize; }
.react-flow__resize-control.bottom.right { cursor: se-resize; }
.react-flow__resize-control.top { cursor: n-resize; }
.react-flow__resize-control.right { cursor: e-resize; }
.react-flow__resize-control.bottom { cursor: s-resize; }
.react-flow__resize-control.left { cursor: w-resize; }
.react-flow__resize-control.top.left {
cursor: nw-resize;
}
.react-flow__resize-control.top.right {
cursor: ne-resize;
}
.react-flow__resize-control.bottom.left {
cursor: sw-resize;
}
.react-flow__resize-control.bottom.right {
cursor: se-resize;
}
.react-flow__resize-control.top {
cursor: n-resize;
}
.react-flow__resize-control.right {
cursor: e-resize;
}
.react-flow__resize-control.bottom {
cursor: s-resize;
}
.react-flow__resize-control.left {
cursor: w-resize;
}
/* Small helper for monospace pre output */
pre {