lint: change folder names

This commit is contained in:
2026-03-12 11:21:10 +01:00
parent 86238b7efc
commit 4184a60706
35 changed files with 104 additions and 105 deletions

View File

@@ -0,0 +1,148 @@
import React, { useContext, useMemo } from 'react'
import {
BaseEdge,
getBezierPath,
type EdgeProps,
} from '@xyflow/react'
import FlowContext from '../../lib/graph/flowContext'
import { getConnectionStatus, CONNECTION_STATUS_CLASS } from '../../lib/connectionStatus'
import { getConnectionLabelForTarget } from '../../lib/graph/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,
targetY,
sourcePosition,
targetPosition,
style,
label: labelProp,
interactionWidth,
target,
}: EdgeProps) {
const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? []
const pathNodeIds = ctx?.connectionPathNodeIds ?? EMPTY_PATH_NODE_IDS
const pausedSegmentNodeIds = ctx?.connectionPathPausedSegmentNodeIds ?? EMPTY_PATH_NODE_IDS
const activeSegmentNodeIds = ctx?.connectionPathActiveSegmentNodeIds ?? EMPTY_PATH_NODE_IDS
const errorTargetNodeIds = useMemo(
() => new Set(ctx?.connectionPathErrorNodeIds ?? []),
[ctx?.connectionPathErrorNodeIds]
)
const targetNode = useMemo(() => nodes.find((n: any) => n.id === target), [nodes, target])
const derivedLabel = useMemo(
() => (targetNode?.type != null ? getConnectionLabelForTarget(targetNode.type) : undefined),
[targetNode?.type]
)
const label = labelProp ?? derivedLabel
const connectionStatus = useMemo(
() =>
getConnectionStatus({
source,
target,
pathNodeIds,
pausedSegmentNodeIds,
activeSegmentNodeIds,
errorTargetNodeIds,
}),
[
source,
target,
pathNodeIds,
pausedSegmentNodeIds,
activeSegmentNodeIds,
errorTargetNodeIds,
]
)
const statusClass = CONNECTION_STATUS_CLASS[connectionStatus]
const [edgePath, edgeLabelX, edgeLabelY] = getBezierPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
})
const startId = `animated-edge-dot-start-${id}`
const endId = `animated-edge-dot-end-${id}`
return (
<>
<defs>
<marker
id={startId}
markerWidth={DOT_MARKER_R * 2}
markerHeight={DOT_MARKER_R * 2}
refX={DOT_MARKER_R - 1}
refY={DOT_MARKER_R}
orient="auto"
>
<circle
r={DOT_MARKER_R}
cx={DOT_MARKER_R}
cy={DOT_MARKER_R}
className="fill-primary"
strokeWidth={2}
/>
</marker>
<marker
id={endId}
markerWidth={DOT_MARKER_R * 2}
markerHeight={DOT_MARKER_R * 2}
refX={DOT_MARKER_R + 1}
refY={DOT_MARKER_R}
orient="auto"
>
<circle
r={DOT_MARKER_R}
cx={DOT_MARKER_R}
cy={DOT_MARKER_R}
className="fill-primary"
strokeWidth={2}
/>
</marker>
</defs>
<BaseEdge
path={edgePath}
markerStart={`url(#${startId})`}
markerEnd={`url(#${endId})`}
style={{
strokeWidth: EDGE_STROKE_WIDTH,
...style,
}}
className={`animated-edge-path${statusClass ? ` ${statusClass}` : ''}`}
interactionWidth={interactionWidth}
/>
{label != null && (
<g transform={`translate(${edgeLabelX}, ${edgeLabelY})`} className="nodrag nopan">
<rect
x={-32}
y={-9}
width={64}
height={18}
rx={4}
ry={4}
className="fill-background stroke-border"
strokeWidth={1}
/>
<text
textAnchor="middle"
dominantBaseline="middle"
className="fill-foreground text-[10px] font-medium"
>
{label}
</text>
</g>
)}
</>
)
}

View File

@@ -0,0 +1,215 @@
import type { ComponentProps, ReactNode } from "react";
import { NodeResizer } from "@xyflow/react";
import { useConnectionPathRole } from "@/lib/graph/flowContext";
import { cn } from "@/lib/utils";
/** Default min size for resizable nodes (used by NodeResizer). */
export const RESIZE_MIN_WIDTH = 120;
export const RESIZE_MIN_HEIGHT = 80;
export type BaseNodeProps = ComponentProps<"div"> & {
/** When true, shows resize handles on corners/edges (via NodeResizer). Requires nodeId when used inside React Flow. */
resizable?: boolean;
/** Node id for the resize control (required when resizable is true). */
nodeId?: string;
/** Connection handles (InputHandle, OutputHandle). Rendered outside the overflow layer so they stay visible. */
handles?: ReactNode;
/** When provided, node uses this size (e.g. from React Flow width/height). Enables correct resize behavior. */
dimensions?: { width: number; height: number };
/** When true, node is selected (from React Flow NodeProps). Used for visible selected state. */
selected?: boolean;
/** Optional min/max for NodeResizer. Omit to use RESIZE_MIN_WIDTH / RESIZE_MIN_HEIGHT. */
resizeConstraints?: { minWidth?: number; minHeight?: number; maxWidth?: number; maxHeight?: number };
};
export function BaseNode({
className,
style,
dimensions,
resizable,
nodeId,
handles,
children,
selected,
resizeConstraints,
...props
}: BaseNodeProps) {
const connectionPathRole = useConnectionPathRole(nodeId);
const hasSize =
dimensions &&
dimensions.width > 0 &&
dimensions.height > 0;
// Don't set overflow: hidden on the root — handles are positioned at left/right -10px
// and would be clipped. The inner content wrapper has overflow-hidden for scrolling.
const appliedStyle = hasSize
? {
...style,
width: dimensions.width,
height: dimensions.height,
display: "flex" as const,
flexDirection: "column" as const,
contain: "layout" as const,
}
: style
? { ...style, display: "flex", flexDirection: "column" as const }
: undefined;
const minW = resizeConstraints?.minWidth ?? RESIZE_MIN_WIDTH;
const minH = resizeConstraints?.minHeight ?? RESIZE_MIN_HEIGHT;
return (
<div
className={cn(
"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}
>
<div
className="min-h-0 flex-1 flex flex-col overflow-hidden"
style={{ contain: "layout" }}
>
{children}
</div>
{resizable && nodeId && (
<NodeResizer
nodeId={nodeId}
isVisible={selected}
minWidth={minW}
minHeight={minH}
maxWidth={resizeConstraints?.maxWidth}
maxHeight={resizeConstraints?.maxHeight}
color="transparent"
handleClassName="base-node-resize-handle nodrag nopan"
/>
)}
{handles}
</div>
);
}
/**
* A container for a consistent header layout intended to be used inside the
* `<BaseNode />` component.
*/
export function BaseNodeHeader({
className,
...props
}: ComponentProps<"header">) {
return (
<header
{...props}
className={cn(
"shrink-0 mx-0 my-0 -mb-1 flex flex-row items-center justify-between gap-2 px-3 py-2",
// Remove or modify these classes if you modify the padding in the
// `<BaseNode />` component.
className,
)}
/>
);
}
/**
* Standard node header: icon on the left, title taking the rest. Use for all node types.
* Inlined so it does not depend on BaseNodeHeader/BaseNodeHeaderTitle (avoids reference errors with HMR).
*/
export function BaseNodeHeaderRow({
icon,
title,
right,
className,
onHeaderDoubleClick,
}: {
icon: ReactNode;
title: ReactNode;
/** Optional right-side content (e.g. type selector). */
right?: ReactNode;
className?: string;
/** When provided, double-click on the header triggers this (e.g. fullscreen). Header gets cursor-pointer and nodrag. */
onHeaderDoubleClick?: () => void;
}) {
return (
<header
role={onHeaderDoubleClick ? 'button' : undefined}
tabIndex={onHeaderDoubleClick ? 0 : undefined}
onDoubleClick={onHeaderDoubleClick}
onKeyDown={
onHeaderDoubleClick
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onHeaderDoubleClick();
}
}
: undefined
}
className={cn(
"shrink-0 mx-0 my-0 flex flex-row items-center justify-between gap-2 px-3 py-2",
onHeaderDoubleClick && "cursor-pointer",
className,
)}
title={onHeaderDoubleClick ? 'Double-click for full screen' : undefined}
>
{icon}
<h3
data-slot="base-node-title"
className="user-select-none flex-1 font-mono min-w-0 truncate"
>
{title}
</h3>
{right != null ? <div className="shrink-0 nodrag nopan">{right}</div> : null}
</header>
);
}
/**
* Single-line muted text for the footer. Use for status or hints in all node types.
*/
export function BaseNodeFooterText({
className,
...props
}: ComponentProps<"div">) {
return (
<div
className={cn("w-full text-xs text-muted-foreground", className)}
data-slot="base-node-footer-text"
{...props}
/>
);
}
export function BaseNodeContent({
className,
...props
}: ComponentProps<"div">) {
return (
<div
data-slot="base-node-content"
className={cn("min-h-0 flex-1 flex flex-col overflow-auto", className)}
{...props}
/>
);
}
export function BaseNodeFooter({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="base-node-footer"
className={cn(
"shrink-0 flex flex-col items-center gap-y-2 border-t p-1",
className,
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,162 @@
import React, { useCallback, useContext, useEffect } from 'react'
import { useReactFlow } from '@xyflow/react'
import type { Node } from '@xyflow/react'
import FlowContext from '@/lib/graph/flowContext'
import { getNextNodeId, getDefaultDataForType } from '@/lib/graph/flowUtils'
import { getDefaultStyle, getRegisteredNodeTypeIds } from '@/lib/graph/nodeRegistry'
const DUPLICATE_OFFSET = { x: 30, y: 30 }
function isMod(ev: KeyboardEvent) {
return ev.ctrlKey || ev.metaKey
}
/** Must be rendered inside ReactFlowProvider and FlowContext. Handles Escape, ⌘C, ⌘V, ⌘D, ⌘0. */
export function FlowKeyboardShortcuts() {
const { fitView, screenToFlowPosition } = useReactFlow()
const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? []
const setNodes = ctx?.setNodes
const setConnectionFrom = ctx?.setConnectionFrom
const flowActionsRef = ctx?.flowActionsRef
const pasteAtViewportCenter = useCallback(async () => {
if (!setNodes || !screenToFlowPosition) return
try {
const text = await navigator.clipboard?.readText()
if (!text) return
const raw = JSON.parse(text) as {
id?: string
type?: string
data?: Record<string, unknown>
position?: { x: number; y: number }
style?: unknown
}
const validIds = getRegisteredNodeTypeIds()
if (!raw || typeof raw.type !== 'string' || !validIds.includes(raw.type)) return
const nodeType = raw.type
const pane = document.querySelector('.react-flow__viewport')
const rect = pane?.getBoundingClientRect()
const center = rect
? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
: { x: window.innerWidth / 2, y: window.innerHeight / 2 }
const position = screenToFlowPosition(center)
setNodes((nds: Node[]) => {
const existingIds = nds.map((n) => n.id).filter((id): id is string => id != null)
const newId = getNextNodeId(nodeType, existingIds)
const data: Record<string, unknown> =
raw.data != null && typeof raw.data === 'object'
? { ...raw.data }
: (getDefaultDataForType(nodeType, newId) as Record<string, unknown>)
if (nodeType === 'config') data.title = `config-${newId}`
const style = getDefaultStyle(nodeType)
const newNode: Node = {
id: newId,
type: raw.type as Node['type'],
position: { x: position.x, y: position.y },
data,
style,
}
return nds.concat(newNode)
})
} catch {
// Invalid clipboard or not a copied node
}
}, [setNodes, screenToFlowPosition])
const doFitView = useCallback(() => {
fitView?.({ duration: 200 })
}, [fitView])
useEffect(() => {
if (flowActionsRef) {
flowActionsRef.current = { pasteAtViewportCenter, fitView: doFitView }
return () => {
flowActionsRef.current = null
}
}
}, [flowActionsRef, pasteAtViewportCenter, doFitView])
useEffect(() => {
const onKeyDown = (ev: KeyboardEvent) => {
if (ev.key === 'Escape') {
const openDialog = document.querySelector('[role="dialog"]')
if (openDialog && ev.target instanceof Node && openDialog.contains(ev.target)) return
setConnectionFrom?.(null)
setNodes?.((nds) => nds.map((n) => ({ ...n, selected: false })))
ev.preventDefault()
return
}
if (ev.key === 'c' && isMod(ev) && !ev.shiftKey) {
const selectedNodes = nodes.filter((n) => (n as Node & { selected?: boolean }).selected)
if (selectedNodes.length === 1) {
const node = selectedNodes[0] as Node & { selected?: boolean }
const copy = {
id: node.id,
type: node.type,
data: node.data,
position: node.position,
style: node.style,
}
navigator.clipboard?.writeText(JSON.stringify(copy)).catch(() => { })
ev.preventDefault()
}
return
}
if (ev.key === 'v' && isMod(ev) && !ev.shiftKey) {
pasteAtViewportCenter()
ev.preventDefault()
return
}
if (ev.key === 'd' && isMod(ev) && !ev.shiftKey) {
const selectedNodes = nodes.filter((n) => (n as Node & { selected?: boolean }).selected)
if (selectedNodes.length > 0 && setNodes) {
setNodes((nds: Node[]) => {
const existingIds = nds.map((n) => n.id)
const toAdd: Node[] = []
for (const node of selectedNodes) {
const n = node as Node & { selected?: boolean }
const pos = n.position ?? { x: 0, y: 0 }
const newId = getNextNodeId(String(n.type), [...existingIds, ...toAdd.map((x) => x.id)])
existingIds.push(newId)
const newNode: Node = {
id: newId,
type: n.type,
position: { x: pos.x + DUPLICATE_OFFSET.x, y: pos.y + DUPLICATE_OFFSET.y },
data: typeof n.data === 'object' && n.data !== null ? { ...(n.data as object) } : n.data,
style: getDefaultStyle(String(n.type)),
}
if (
newNode.data &&
typeof newNode.data === 'object' &&
'title' in newNode.data &&
String(n.type) === 'config'
) {
; (newNode.data as Record<string, unknown>).title = `${newId}`
}
toAdd.push(newNode)
}
return nds.concat(toAdd)
})
ev.preventDefault()
}
return
}
if (ev.key === '0' && isMod(ev) && !ev.shiftKey) {
doFitView()
ev.preventDefault()
return
}
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [
nodes,
setNodes,
setConnectionFrom,
pasteAtViewportCenter,
doFitView,
])
return null
}

View File

@@ -0,0 +1,63 @@
import React, { useContext, useMemo } from 'react'
import FlowContext from '../../lib/graph/flowContext'
import { ArrowDownLeft, ArrowUpRight } from 'lucide-react'
import { NodeHelpPopover } from './NodeHelpPopover'
import { getNodeType, getNodeClassificationLabel } from '../../lib/graph/nodeRegistry'
type Props = {
nodeId: string
nodeType: string
children?: React.ReactNode
}
export function NodeFooterEdgeIndicators({ nodeId, nodeType, children }: Props) {
const ctx = useContext(FlowContext)
const edges = ctx?.edges ?? []
const { inputs, outputs } = useMemo(() => {
let inputs = 0
let outputs = 0
for (const e of edges) {
if (e.target === nodeId) inputs += 1
if (e.source === nodeId) outputs += 1
}
return { inputs, outputs }
}, [edges, nodeId])
const descriptor = getNodeType(nodeType)
const showInput = descriptor?.hasInput ?? false
const showOutput = descriptor?.hasOutput ?? false
const classificationLabel = getNodeClassificationLabel(nodeType)
return (
<div className="flex items-center gap-2 w-full text-xs text-muted-foreground">
{showInput && (
<span className="flex items-center gap-1 shrink-0" title="Input connections">
<ArrowDownLeft className="size-3.5" />
<span>{inputs}</span>
</span>
)}
{showOutput && (
<span className="flex items-center gap-1 shrink-0" title="Output connections">
<ArrowUpRight className="size-3.5" />
<span>{outputs}</span>
</span>
)}
{children != null && (
<>
{(showInput || showOutput) && <span className="shrink-0">|</span>}
<span className="min-w-0 truncate">{children}</span>
</>
)}
{classificationLabel != null && (
<>
{(showInput || showOutput || children != null) && <span className="shrink-0">|</span>}
<span className="shrink-0" title="Node classification">{classificationLabel}</span>
</>
)}
<span className="shrink-0 ml-auto">
<NodeHelpPopover nodeType={nodeType} />
</span>
</div>
)
}

View File

@@ -0,0 +1,76 @@
import React, { useContext } from 'react'
import { Handle, Position } from '@xyflow/react'
import { ArrowDownLeft, ArrowUpRight } from 'lucide-react'
import FlowContext from '@/lib/graph/flowContext'
import { cn } from '@/lib/utils'
type NodeHandleProps = {
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, 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 ?? null,
target: nodeId!,
targetHandle: id,
})
return (
<Handle
type="target"
position={Position.Left}
id={id}
className={cn(isValidTarget && 'connection-valid-target', isConnecting && !isValidTarget && 'connection-invalid-target')}
style={{
top: 20,
width: 20,
height: 20,
left: -15,
borderRadius: '9999px',
background: 'transparent',
border: 'none',
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<ArrowDownLeft className="w-4 h-4 text-foreground pointer-events-none" />
</Handle>
)
}
export function OutputHandle({ id }: NodeHandleProps) {
return (
<Handle
type="source"
position={Position.Right}
id={id}
style={{
top: 20,
width: 20,
height: 20,
right: -15,
borderRadius: '9999px',
background: 'transparent',
border: 'none',
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<ArrowUpRight className="w-4 h-4 text-foreground pointer-events-none" />
</Handle>
)
}

View File

@@ -0,0 +1,83 @@
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'
import FlowContext from '../../lib/graph/flowContext'
import type { AppNode } from '../../lib/graph/nodeTypes'
import { replaceNodeIdInGraph } from '../../lib/graph/flowUtils'
type Props = {
nodeId: string
displayTitle: string
}
export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? []
const setNodes = ctx?.setNodes
const edges = ctx?.edges ?? []
const setEdges = ctx?.setEdges
const renamingNodeId = ctx?.renamingNodeId ?? null
const setRenamingNodeId = ctx?.setRenamingNodeId
const [inputValue, setInputValue] = useState(nodeId)
const inputRef = useRef<HTMLInputElement>(null)
const isRenaming = renamingNodeId === nodeId
useEffect(() => {
if (isRenaming) {
setInputValue(nodeId)
inputRef.current?.focus()
inputRef.current?.select()
}
}, [isRenaming, nodeId])
const applyRename = useCallback(() => {
if (!setNodes || !setEdges || !setRenamingNodeId) return
const newId = inputValue.trim()
if (!newId || newId === nodeId) {
setRenamingNodeId(null)
return
}
const existingIds = nodes.map((n: any) => n.id)
if (existingIds.includes(newId)) {
return
}
const { nodes: nextNodes, edges: nextEdges } = replaceNodeIdInGraph(nodes, edges, nodeId, newId)
setNodes(nextNodes as AppNode[])
setEdges(nextEdges)
setRenamingNodeId(null)
}, [nodeId, inputValue, nodes, edges, setNodes, setEdges, setRenamingNodeId])
const cancelRename = useCallback(() => {
setRenamingNodeId?.(null)
}, [setRenamingNodeId])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
applyRename()
} else if (e.key === 'Escape') {
e.preventDefault()
cancelRename()
}
},
[applyRename, cancelRename]
)
if (!isRenaming) {
return <>{displayTitle}</>
}
return (
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={cancelRename}
className="nodrag nopan flex-1 min-w-0 rounded border border-input bg-background px-1.5 py-0 text-sm font-semibold outline-none focus:ring-1 focus:ring-ring"
data-slot="base-node-title"
/>
)
}

View File

@@ -0,0 +1,35 @@
import React from 'react'
import { HelpCircle } from 'lucide-react'
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
import { getNodeHelp } from '../../lib/graph/nodeRegistry'
import { cn } from '../../lib/utils'
type Props = {
nodeType: string
className?: string
}
export function NodeHelpPopover({ nodeType, className }: Props) {
const { title, content } = getNodeHelp(nodeType)
return (
<Popover>
<PopoverTrigger
asChild
className={cn(
'shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
className
)}
aria-label={`Help: ${title}`}
>
<button type="button">
<HelpCircle className="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent side="top" align="end" className="w-80 max-h-[70vh] overflow-y-auto">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
<div className="mt-2">{content}</div>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,130 @@
import React, { useCallback, useContext } from 'react'
import FlowContext from '../../lib/graph/flowContext'
import {
Menubar,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarSeparator,
MenubarShortcut,
MenubarSub,
MenubarSubContent,
MenubarSubTrigger,
MenubarTrigger,
} from '../ui/menubar'
import { Kbd } from '../ui/kbd'
type Props = {
nodeId: string
nodeType: string
/** Content for Insert → Inputs (function nodes); when inputsMenuContent is set, Inputs is a separate menu and this is not used in Insert */
editInputsContent?: React.ReactNode
/** When set, Inputs is rendered as its own top-level menu (config nodes); Insert then only shows markup-specific options */
inputsMenuContent?: React.ReactNode
/** Content for Insert → [Blocks/Tags] (e.g. type-specific snippets, config nodes) */
insertTagsContent?: React.ReactNode
/** Label for the Insert/Blocks menu trigger (default "Insert") */
insertMenuLabel?: string
/** When true, render insertTagsContent directly in the menu (no submenu level). Use for flat Blocks list. */
insertContentDirect?: boolean
/** Label for Insert submenu that shows insertTagsContent when not insertContentDirect (default "Tags") */
insertTagsLabel?: string
/** Extra content in Node menu (e.g. Export submenu for render nodes), before the separator */
nodeMenuExtraContent?: React.ReactNode
/** When set, renders a top-level "Data" menu with this content (e.g. data nodes) */
dataMenuContent?: React.ReactNode
}
export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuContent, insertTagsContent, insertMenuLabel = 'Insert', insertContentDirect, insertTagsLabel = 'Tags', nodeMenuExtraContent, dataMenuContent }: Props) {
const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? []
const setNodes = ctx?.setNodes
const setEdges = ctx?.setEdges
const edges = ctx?.edges ?? []
const node = nodes.find((n: any) => n.id === nodeId)
const hasEdit = nodeType === 'config' || nodeType === 'function'
const hasConnectedNodes = edges.some((e: any) => e.target === nodeId)
const onDelete = useCallback(() => {
if (!setNodes || !setEdges) return
setNodes((nds: any[]) => nds.filter((n: any) => n.id !== nodeId))
setEdges((eds: any[]) => eds.filter((e: any) => e.source !== nodeId && e.target !== nodeId))
}, [nodeId, setNodes, setEdges])
const onRename = useCallback(() => {
ctx?.setRenamingNodeId?.(nodeId)
}, [nodeId, ctx])
return (
<Menubar className="h-auto min-h-0 flex items-center bg-none p-1 border-t-0 border-l-0 border-r-0 border-b border-b-secondary shadow-none rounded-none text-muted-foreground">
<MenubarMenu>
<MenubarTrigger className="px-1.5 py-0 text-xs">
Node
</MenubarTrigger>
<MenubarContent className="min-w-[12rem]">
<MenubarItem className="text-xs" onClick={onRename}>
Rename
</MenubarItem>
{nodeMenuExtraContent}
<MenubarSeparator />
<MenubarItem className="text-xs text-destructive focus:text-destructive" onClick={onDelete}>
Delete
<MenubarShortcut className="ml-auto pl-4"><Kbd></Kbd></MenubarShortcut>
</MenubarItem>
</MenubarContent>
</MenubarMenu>
{dataMenuContent != null && (
<MenubarMenu>
<MenubarTrigger className="px-1.5 py-0 text-xs">
Data
</MenubarTrigger>
<MenubarContent className="min-w-[12rem]">
{dataMenuContent}
</MenubarContent>
</MenubarMenu>
)}
{inputsMenuContent != null && (
<MenubarMenu>
<MenubarTrigger className="px-1.5 py-0 text-xs" disabled={!hasConnectedNodes}>
Inputs
</MenubarTrigger>
<MenubarContent className="min-w-[12rem]">
{inputsMenuContent}
</MenubarContent>
</MenubarMenu>
)}
{hasEdit && (insertTagsContent != null || (editInputsContent != null && inputsMenuContent == null)) && (
<MenubarMenu>
<MenubarTrigger className="px-1.5 py-0 text-xs">
{insertMenuLabel}
</MenubarTrigger>
<MenubarContent className="min-w-[10rem]">
{inputsMenuContent == null && editInputsContent != null && (
<MenubarSub>
<MenubarSubTrigger className="text-xs" disabled={!hasConnectedNodes}>
Inputs
</MenubarSubTrigger>
<MenubarSubContent className="min-w-[12rem]">
{editInputsContent}
</MenubarSubContent>
</MenubarSub>
)}
{insertTagsContent != null &&
(insertContentDirect ? (
insertTagsContent
) : (
<MenubarSub>
<MenubarSubTrigger className="text-xs">
{insertTagsLabel}
</MenubarSubTrigger>
<MenubarSubContent className="min-w-[12rem]">
{insertTagsContent}
</MenubarSubContent>
</MenubarSub>
))}
</MenubarContent>
</MenubarMenu>
)}
</Menubar>
)
}

View File

@@ -0,0 +1,206 @@
import { useId, useLayoutEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
export type NodeStatus = 'loading' | 'success' | 'error' | 'initial'
export type NodeStatusVariant = 'overlay' | 'border'
export type NodeStatusIndicatorProps = {
status?: NodeStatus
variant?: NodeStatusVariant
children: ReactNode
/** Optional: node width/height so the spinner can match the border exactly */
width?: number
height?: number
}
const R = 6 // rounded corner radius (0.375rem ≈ 6px)
const DURATION_MS = 2400
/** Ease-out with overshoot: 0→70%→85%→100% keyframe values */
function dashOffsetAtProgress(progress: number, pathLength: number): number {
if (progress <= 0.7) return ((pathLength + 30) * progress) / 0.7
if (progress <= 0.85)
return pathLength + 30 + (25 * (progress - 0.7)) / 0.15
return pathLength + 55 - (5 * (progress - 0.85)) / 0.15
}
/** One solid segment (half path length) moving along the border, with gradient opacity 0→1 along the segment.
* Uses ResizeObserver so the border always matches the actual rendered node size (avoids min-width/min-height mismatch). */
function BorderLoadingIndicator({
children,
width,
height,
}: {
children: ReactNode
width?: number
height?: number
}) {
const containerRef = useRef<HTMLDivElement>(null)
const [measured, setMeasured] = useState({ w: 0, h: 0 })
const hasMeasured = measured.w > 0 && measured.h > 0
const fallbackW = (width != null && height != null && width > 0 && height > 0) ? width + 4 : 0
const fallbackH = (width != null && height != null && width > 0 && height > 0) ? height + 4 : 0
const w = hasMeasured ? measured.w + 4 : fallbackW
const h = hasMeasured ? measured.h + 4 : fallbackH
const hasSize = w > 0 && h > 0
const pathD = hasSize
? `M ${R + 2} ${2} L ${w - R - 2} ${2} Q ${w - 2} ${2} ${w - 2} ${R + 2} L ${w - 2} ${h - R - 2} Q ${w - 2} ${h - 2} ${w - R - 2} ${h - 2} L ${R + 2} ${h - 2} Q ${2} ${h - 2} ${2} ${h - R - 2} L ${2} ${R + 2} Q ${2} ${2} ${R + 2} ${2} Z`
: ''
const gradientId = useId().replace(/:/g, '-')
const pathRef = useRef<SVGPathElement>(null)
const gradientRef = useRef<SVGLinearGradientElement>(null)
const rafRef = useRef<number>(0)
const startTimeRef = useRef<number>(0)
useLayoutEffect(() => {
const container = containerRef.current
if (!container) return
// Observe the first child (BaseNode) so we use its actual rendered size.
const target =
container.firstElementChild instanceof HTMLElement
? container.firstElementChild
: container
const syncMeasure = () => {
const cw = (target as HTMLElement).offsetWidth
const ch = (target as HTMLElement).offsetHeight
if (cw > 0 && ch > 0) {
queueMicrotask(() => setMeasured({ w: cw, h: ch }))
}
}
syncMeasure()
const ro = new ResizeObserver((entries) => {
const entry = entries[0]
if (!entry) return
const { width: cw, height: ch } = entry.contentRect
setMeasured({ w: Math.round(cw), h: Math.round(ch) })
})
ro.observe(target)
return () => ro.disconnect()
}, [])
useLayoutEffect(() => {
if (!hasSize) return
const pathEl = pathRef.current
const gradientEl = gradientRef.current
if (!pathEl || !gradientEl) return
const totalLen = pathEl.getTotalLength()
const segmentLen = totalLen * 0.5
const gapLen = totalLen * 0.5 + 80
pathEl.style.strokeDasharray = `${segmentLen} ${gapLen}`
const tick = () => {
const elapsed = (performance.now() - startTimeRef.current) % DURATION_MS
const progress = Math.min(1, elapsed / DURATION_MS)
const dashOffset = dashOffsetAtProgress(progress, totalLen)
pathEl.style.strokeDashoffset = String(dashOffset)
const startLen = dashOffset % totalLen
const endLen = (dashOffset + segmentLen) % totalLen
const startPt = pathEl.getPointAtLength(startLen)
const endPt = pathEl.getPointAtLength(endLen)
gradientEl.setAttribute('x1', String(startPt.x))
gradientEl.setAttribute('y1', String(startPt.y))
gradientEl.setAttribute('x2', String(endPt.x))
gradientEl.setAttribute('y2', String(endPt.y))
rafRef.current = requestAnimationFrame(tick)
}
startTimeRef.current = performance.now()
rafRef.current = requestAnimationFrame(tick)
return () => cancelAnimationFrame(rafRef.current)
}, [hasSize, w, h])
return (
<div ref={containerRef} className="relative w-full h-full overflow-visible">
{children}
{hasSize && (
<svg
className="absolute pointer-events-none z-10"
style={{ top: -2, left: -2, width: w, height: h }}
aria-hidden
>
<defs>
<linearGradient
ref={gradientRef}
id={gradientId}
gradientUnits="userSpaceOnUse"
x1="0"
y1="0"
x2="1"
y2="0"
>
<stop offset="0" stopColor="hsl(var(--primary))" stopOpacity="0" />
<stop offset="1" stopColor="hsl(var(--primary))" stopOpacity="1" />
</linearGradient>
</defs>
<path
ref={pathRef}
className="fill-none stroke-[2]"
d={pathD}
stroke={`url(#${gradientId})`}
style={{ strokeLinecap: 'round' }}
/>
</svg>
)}
{!hasSize && (
<>
<style>{`
@keyframes node-status-pulse {
0%, 100% { opacity: 0.35; }
50% { opacity: 0.7; }
}
`}</style>
<div
className="absolute -inset-[2px] rounded-md border-[1.5px] border-primary pointer-events-none z-10"
style={{ opacity: 0.8, animation: 'node-status-pulse 2s ease-in-out infinite' }}
aria-hidden
/>
</>
)}
</div>
)
}
/** Error state: red/destructive border around the node */
function ErrorStatusBorder({ children, className }: { children: ReactNode; className?: string }) {
return (
<div className={cn('rounded-md ring-2 ring-destructive', className)}>
{children}
</div>
)
}
/** Success state: optional subtle border (e.g. green) - not required per user, keep minimal */
function SuccessStatusBorder({ children }: { children: ReactNode }) {
return <div className="rounded-md">{children}</div>
}
export function NodeStatusIndicator({
status,
variant = 'border',
children,
width,
height,
}: NodeStatusIndicatorProps) {
switch (status) {
case 'loading':
return variant === 'border' ? (
<BorderLoadingIndicator width={width} height={height}>{children}</BorderLoadingIndicator>
) : (
<>{children}</>
)
case 'error':
return <ErrorStatusBorder>{children}</ErrorStatusBorder>
case 'success':
return <SuccessStatusBorder>{children}</SuccessStatusBorder>
default:
return <>{children}</>
}
}