feat: add node help system and registry for extensible node types
- Implemented a help system for different node types (config, render, variable, function) with detailed usage instructions. - Created a node registry to manage node types, including registration, retrieval, and validation of connections between nodes. - Defined central node and edge types for the application to streamline state management. - Added Nunjucks autocomplete functionality to enhance user experience in template editing. - Developed a syntax highlighting parser for PlantUML and Nunjucks within the CodeMirror editor. - Registered built-in node types at application startup, including their default configurations and help entries. - Introduced a theme context provider to manage light/dark mode preferences across the application. - Created utility functions for class name management using clsx and tailwind-merge. - Set up Tailwind CSS for styling with custom themes and responsive design. - Configured Vite for development with proxy settings for backend API calls and Kroki diagram service.
This commit is contained in:
630
frontend/src/App.tsx
Normal file
630
frontend/src/App.tsx
Normal file
@@ -0,0 +1,630 @@
|
||||
import React, { useCallback, useMemo, useRef } from 'react'
|
||||
import {
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
Controls,
|
||||
Background,
|
||||
MiniMap,
|
||||
addEdge,
|
||||
applyNodeChanges,
|
||||
applyEdgeChanges,
|
||||
useNodesInitialized,
|
||||
useReactFlow,
|
||||
type Node,
|
||||
type Edge,
|
||||
type Connection,
|
||||
type ColorMode,
|
||||
type NodeChange,
|
||||
type EdgeChange,
|
||||
} from '@xyflow/react'
|
||||
import { AnimatedEdge } from './components/base/AnimatedEdge'
|
||||
import FlowContext from './lib/flowContext'
|
||||
import { useTheme } from './lib/themeContext'
|
||||
import { useGraphStateWithHistory } from './hooks/useGraphStateWithHistory'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuGroup
|
||||
} from "@/components/ui/context-menu"
|
||||
import { AppMenubar } from '@/components/AppMenubar'
|
||||
import { FlowKeyboardShortcuts } from '@/components/FlowKeyboardShortcuts'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ClipboardPaste, FolderOpen, FileStack, CircleDotDashed } from 'lucide-react'
|
||||
import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId } from './lib/flowUtils'
|
||||
import {
|
||||
getRegisteredNodeTypes,
|
||||
getRegisteredNodeTypeIds,
|
||||
getDefaultStyle,
|
||||
isConnectionAllowed,
|
||||
} from './lib/nodeRegistry'
|
||||
import type { AppNode, AppEdge } from './lib/nodeTypes'
|
||||
|
||||
const SNAP_GRID: [number, number] = [15, 15]
|
||||
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],
|
||||
})
|
||||
|
||||
const NODE_GAP = 150
|
||||
const initialNodes: AppNode[] = [
|
||||
{
|
||||
id: 'var_001',
|
||||
position: { x: 50, y: 100 },
|
||||
data: { value: 'Zoe', valueType: 'string' as const },
|
||||
type: 'variable',
|
||||
style: DEFAULT_NODE_STYLE.variable,
|
||||
},
|
||||
{
|
||||
id: 'cfg_001',
|
||||
position: { x: 50 + DEFAULT_NODE_STYLE.variable.width + NODE_GAP, y: 50 },
|
||||
data: {
|
||||
plantuml: '@startuml\nactor User\nparticipant "{{ var_001 }}" as R\nUser -> R : loves\n@enduml\n',
|
||||
title: 'config-cfg_001',
|
||||
},
|
||||
type: 'config',
|
||||
style: DEFAULT_NODE_STYLE.config,
|
||||
},
|
||||
{
|
||||
id: 'rnd_001',
|
||||
position: {
|
||||
x: 50 + DEFAULT_NODE_STYLE.variable.width + NODE_GAP + DEFAULT_NODE_STYLE.config.width + NODE_GAP,
|
||||
y: 50,
|
||||
},
|
||||
data: {},
|
||||
type: 'render',
|
||||
style: DEFAULT_NODE_STYLE.render,
|
||||
},
|
||||
]
|
||||
|
||||
const initialEdges: AppEdge[] = [
|
||||
{ id: 'e-var_001-cfg_001', source: 'var_001', target: 'cfg_001', type: 'animated' },
|
||||
{ id: 'e-cfg_001-rnd_001', source: 'cfg_001', target: 'rnd_001', type: 'animated' },
|
||||
]
|
||||
|
||||
function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } {
|
||||
return {
|
||||
nodes: initialNodes.map((n) => ({
|
||||
...n,
|
||||
data: n.data && typeof n.data === 'object' ? { ...(n.data as object) } : n.data,
|
||||
})),
|
||||
edges: initialEdges.map((e) => ({ ...e })),
|
||||
}
|
||||
}
|
||||
|
||||
const PROJECT_FILE_EXT = '.zui.json'
|
||||
const PROJECT_VERSION = 1
|
||||
|
||||
export type ProjectMessage = { type: 'success' | 'error'; text: string }
|
||||
|
||||
/** Calls fitView when nodes are initialized (e.g. after load/import). Must be rendered inside ReactFlowProvider. */
|
||||
function FlowFitViewOnLoad() {
|
||||
const nodesInitialized = useNodesInitialized()
|
||||
const { fitView } = useReactFlow()
|
||||
React.useEffect(() => {
|
||||
if (nodesInitialized) {
|
||||
fitView?.({ duration: 200 })
|
||||
}
|
||||
}, [nodesInitialized, fitView])
|
||||
return null
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { theme } = useTheme()
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
setNodes,
|
||||
setEdges,
|
||||
setNodesSilent,
|
||||
applyGraph,
|
||||
saveForDragEnd,
|
||||
commitDragEnd,
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
setStateImmediate,
|
||||
} = useGraphStateWithHistory(getExampleGraph().nodes, getExampleGraph().edges)
|
||||
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const [rfInstance, setRfInstance] = React.useState<any | null>(null)
|
||||
const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null)
|
||||
const [connectionFrom, setConnectionFrom] = React.useState<{ nodeId: string; sourceHandle?: string } | null>(null)
|
||||
const [projectMessage, setProjectMessage] = React.useState<ProjectMessage | null>(null)
|
||||
|
||||
const wrapperRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const lastClickRef = React.useRef<{ clientX: number; clientY: number } | null>(null)
|
||||
const flowActionsRef = React.useRef<{ pasteAtViewportCenter: () => void; fitView: () => void } | null>(null)
|
||||
const [contextTarget, setContextTarget] = React.useState<null | { type: 'canvas'; clientX: number; clientY: number }>(null)
|
||||
const [lastCreatedNodeId, setLastCreatedNodeId] = React.useState<string | null>(null)
|
||||
const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null)
|
||||
const nodesRef = React.useRef(nodes)
|
||||
nodesRef.current = nodes
|
||||
|
||||
// Example backend API fetch (dev proxy or Docker nginx → backend)
|
||||
const [apiTodosCount, setApiTodosCount] = React.useState<number | null>(null)
|
||||
React.useEffect(() => {
|
||||
fetch('/api/todos')
|
||||
.then((r) => r.json())
|
||||
.then((data: unknown) => {
|
||||
if (Array.isArray(data)) setApiTodosCount(data.length)
|
||||
})
|
||||
.catch(() => setApiTodosCount(-1))
|
||||
}, [])
|
||||
|
||||
// Throttle node changes during drag: merge changes and flush at most once per animation frame to reduce re-renders
|
||||
const pendingChangesRef = useRef<NodeChange<Node>[]>([])
|
||||
const rafRef = useRef<number | null>(null)
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange<Node>[]) => {
|
||||
if (changes.length === 0) return
|
||||
const pending = pendingChangesRef.current
|
||||
for (const c of changes) {
|
||||
const id = (c as { id?: string }).id
|
||||
if (id != null) {
|
||||
const i = pending.findIndex((p) => (p as { id?: string }).id === id)
|
||||
if (i >= 0) pending[i] = c
|
||||
else pending.push(c)
|
||||
} else {
|
||||
pending.push(c)
|
||||
}
|
||||
}
|
||||
if (rafRef.current === null) {
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = null
|
||||
const toApply = pendingChangesRef.current.splice(0, pendingChangesRef.current.length)
|
||||
if (toApply.length > 0) {
|
||||
setNodesSilent((nds) => applyNodeChanges(toApply, nds))
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
[setNodesSilent]
|
||||
)
|
||||
|
||||
const nodeTypes = React.useMemo(
|
||||
() => Object.fromEntries(getRegisteredNodeTypes().map((r) => [r.id, r.component])),
|
||||
[]
|
||||
)
|
||||
|
||||
const edgeTypes = React.useMemo(() => ({ animated: AnimatedEdge }), [])
|
||||
|
||||
const defaultEdgeOptions = React.useMemo(() => ({ type: 'animated' as const }), [])
|
||||
|
||||
const onEdgesChange = useCallback(
|
||||
(changes: EdgeChange<Edge>[]) => {
|
||||
if (changes.length === 0) return
|
||||
setEdges((eds) => applyEdgeChanges(changes, eds))
|
||||
},
|
||||
[setEdges]
|
||||
)
|
||||
|
||||
const onConnect = React.useCallback(
|
||||
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
|
||||
[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
|
||||
return isConnectionAllowed(
|
||||
sourceType,
|
||||
targetType,
|
||||
connection.source,
|
||||
connection.target
|
||||
)
|
||||
},
|
||||
[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) => {
|
||||
setRfInstance(instance)
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!projectMessage) return
|
||||
const t = setTimeout(() => setProjectMessage(null), 3000)
|
||||
return () => clearTimeout(t)
|
||||
}, [projectMessage])
|
||||
|
||||
const onNodeDragStart = useCallback(() => {
|
||||
saveForDragEnd()
|
||||
}, [saveForDragEnd])
|
||||
|
||||
const onNodeDragStop = useCallback(() => {
|
||||
commitDragEnd()
|
||||
}, [commitDragEnd])
|
||||
|
||||
const handleExportProject = useCallback(() => {
|
||||
const state = { version: PROJECT_VERSION, nodes, edges }
|
||||
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `project${PROJECT_FILE_EXT}`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
setProjectMessage({ type: 'success', text: 'Project exported' })
|
||||
}, [nodes, edges])
|
||||
|
||||
const handleImportProject = useCallback(() => {
|
||||
importInputRef.current?.click()
|
||||
}, [])
|
||||
|
||||
const handleLoadExample = useCallback(() => {
|
||||
const { nodes: exampleNodes, edges: exampleEdges } = getExampleGraph()
|
||||
setStateImmediate({ nodes: exampleNodes, edges: exampleEdges })
|
||||
setProjectMessage({ type: 'success', text: 'Example loaded' })
|
||||
}, [setStateImmediate])
|
||||
|
||||
const onImportFileChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const text = reader.result as string
|
||||
const state = JSON.parse(text) as { version?: number; nodes?: unknown[]; edges?: unknown[] }
|
||||
if (!state || !Array.isArray(state.nodes) || !Array.isArray(state.edges)) {
|
||||
setProjectMessage({ type: 'error', text: 'Invalid file: expected nodes and edges arrays' })
|
||||
return
|
||||
}
|
||||
setStateImmediate({ nodes: state.nodes as AppNode[], edges: state.edges as AppEdge[] })
|
||||
setProjectMessage(
|
||||
state.version != null && state.version > PROJECT_VERSION
|
||||
? { type: 'error', text: 'Project was created with a newer app version' }
|
||||
: { type: 'success', text: 'Project loaded' }
|
||||
)
|
||||
} catch {
|
||||
setProjectMessage({ type: 'error', text: 'Invalid file: not valid JSON' })
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
},
|
||||
[setStateImmediate]
|
||||
)
|
||||
|
||||
const flowContextValue = useMemo(
|
||||
() => ({
|
||||
nodes,
|
||||
setNodes,
|
||||
edges,
|
||||
setEdges,
|
||||
renamingNodeId,
|
||||
setRenamingNodeId,
|
||||
connectionFrom,
|
||||
setConnectionFrom,
|
||||
isValidConnection,
|
||||
flowActionsRef,
|
||||
}),
|
||||
[
|
||||
nodes,
|
||||
setNodes,
|
||||
edges,
|
||||
setEdges,
|
||||
renamingNodeId,
|
||||
setRenamingNodeId,
|
||||
connectionFrom,
|
||||
setConnectionFrom,
|
||||
isValidConnection,
|
||||
flowActionsRef,
|
||||
]
|
||||
)
|
||||
|
||||
const onContextMenuCapture = React.useCallback((ev: React.MouseEvent) => {
|
||||
const target = ev.target as HTMLElement
|
||||
const nodeEl = target.closest('.react-flow__node')
|
||||
if (nodeEl) {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Disable canvas zoom when the cursor is over a node (scroll/pinch only zooms when over the pane)
|
||||
const onWheelCapture = React.useCallback((ev: React.WheelEvent) => {
|
||||
const target = ev.target as HTMLElement
|
||||
if (target.closest('.react-flow__node')) {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onCanvasContextMenu = React.useCallback((ev: React.MouseEvent) => {
|
||||
ev.preventDefault()
|
||||
const clientX = ev.clientX
|
||||
const clientY = ev.clientY
|
||||
lastClickRef.current = { clientX, clientY }
|
||||
setContextTarget({ type: 'canvas', clientX, clientY })
|
||||
}, [])
|
||||
|
||||
const getMenuPosition = React.useCallback(() => {
|
||||
if (!rfInstance) return null
|
||||
const click = contextTarget ?? lastClickRef.current
|
||||
const clientX = click?.clientX ?? window.innerWidth / 2
|
||||
const clientY = click?.clientY ?? window.innerHeight / 2
|
||||
try {
|
||||
const screenToFlow = rfInstance.screenToFlowPosition ?? rfInstance.project
|
||||
const p = screenToFlow.call(rfInstance, { x: clientX, y: clientY })
|
||||
return snapToGrid(p.x, p.y)
|
||||
} catch {
|
||||
return snapToGrid(clientX, clientY)
|
||||
}
|
||||
}, [rfInstance, contextTarget])
|
||||
|
||||
const createNode = React.useCallback(
|
||||
(type: string) => {
|
||||
const position = getMenuPosition()
|
||||
if (position == null) return
|
||||
const nodeType = type as Node['type']
|
||||
const newId = getNextNodeId(nodeType, nodesRef.current.map((n) => n.id))
|
||||
const dataMap = getDefaultDataForType(nodeType, newId)
|
||||
const style = getDefaultStyle(nodeType)
|
||||
const newNode: Node = {
|
||||
id: newId,
|
||||
type: nodeType,
|
||||
position: { x: position.x, y: position.y },
|
||||
data: dataMap,
|
||||
style,
|
||||
}
|
||||
setNodes((nds) => nds.concat(newNode))
|
||||
setLastCreatedNodeId(newId)
|
||||
lastClickRef.current = null
|
||||
setContextTarget(null)
|
||||
},
|
||||
[getMenuPosition, setNodes]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (lastCreatedNodeId == null) return
|
||||
const id = lastCreatedNodeId
|
||||
const raf = requestAnimationFrame(() => {
|
||||
const nodeEl = document.querySelector(`.react-flow__node[data-id="${id}"]`) as HTMLElement | null
|
||||
if (nodeEl) {
|
||||
setAriaAnnouncement('Node created')
|
||||
nodeEl.setAttribute('tabindex', '-1')
|
||||
nodeEl.focus({ preventScroll: false })
|
||||
setTimeout(() => setAriaAnnouncement(null), 1000)
|
||||
}
|
||||
setLastCreatedNodeId(null)
|
||||
})
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [lastCreatedNodeId, nodes])
|
||||
|
||||
const pasteNode = React.useCallback(async () => {
|
||||
const position = getMenuPosition()
|
||||
if (position == null) return
|
||||
try {
|
||||
const text = await navigator.clipboard?.readText()
|
||||
if (!text) return
|
||||
const raw = JSON.parse(text) as { id?: string; type?: string; data?: any; position?: { x: number; y: number }; style?: any }
|
||||
const validIds = getRegisteredNodeTypeIds()
|
||||
if (!raw || typeof raw.type !== 'string' || !validIds.includes(raw.type)) return
|
||||
setNodes((nds) => {
|
||||
const newId = getNextNodeId(raw.type as Node['type'], nds.map((n) => n.id))
|
||||
const data = raw.data != null && typeof raw.data === 'object' ? { ...raw.data } : {}
|
||||
if (raw.type === 'config' && data.title != null) data.title = `config-${newId}`
|
||||
const style = getDefaultStyle(raw.type)
|
||||
const newNode: Node = {
|
||||
id: newId,
|
||||
type: raw.type as Node['type'],
|
||||
position: { x: position.x, y: position.y },
|
||||
data,
|
||||
style,
|
||||
}
|
||||
return nds.concat(newNode)
|
||||
})
|
||||
lastClickRef.current = null
|
||||
setContextTarget(null)
|
||||
} catch {
|
||||
// Invalid clipboard or not a copied node — do nothing
|
||||
}
|
||||
}, [getMenuPosition, setNodes])
|
||||
|
||||
const deleteNode = React.useCallback(
|
||||
(id: string | undefined) => {
|
||||
if (!id) return
|
||||
applyGraph(({ nodes: nds, edges: eds }) => ({
|
||||
nodes: nds.filter((n) => n.id !== id),
|
||||
edges: eds.filter((e) => e.source !== id && e.target !== id),
|
||||
}))
|
||||
setContextTarget(null)
|
||||
},
|
||||
[applyGraph]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="reactflow-wrapper flex flex-col h-full"
|
||||
ref={wrapperRef}
|
||||
onContextMenuCapture={onContextMenuCapture}
|
||||
onContextMenu={onCanvasContextMenu}
|
||||
style={{ position: 'relative' }}
|
||||
>
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic
|
||||
className="sr-only"
|
||||
>
|
||||
{ariaAnnouncement}
|
||||
</div>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept=".json,.zui.json,application/json"
|
||||
className="hidden"
|
||||
onChange={onImportFileChange}
|
||||
aria-hidden
|
||||
/>
|
||||
<AppMenubar
|
||||
onImport={handleImportProject}
|
||||
onExport={handleExportProject}
|
||||
undo={undo}
|
||||
redo={redo}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
onFitView={() => flowActionsRef.current?.fitView?.()}
|
||||
/>
|
||||
{apiTodosCount !== null && (
|
||||
<div className="shrink-0 px-3 py-1 text-xs text-muted-foreground border-b border-border/50">
|
||||
Backend API: {apiTodosCount >= 0 ? `${apiTodosCount} todos` : 'unavailable'}
|
||||
</div>
|
||||
)}
|
||||
{projectMessage && (
|
||||
<div
|
||||
role="status"
|
||||
className={`shrink-0 px-3 py-2 text-sm ${projectMessage.type === 'success'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200'
|
||||
}`}
|
||||
>
|
||||
{projectMessage.text}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 relative flex flex-col">
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<FlowContext.Provider value={flowContextValue}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className="flex-1 min-h-0 w-full relative"
|
||||
onWheelCapture={onWheelCapture}
|
||||
role="application"
|
||||
aria-label="Graph canvas"
|
||||
tabIndex={0}
|
||||
>
|
||||
{nodes.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||||
<div className="pointer-events-auto rounded-lg border border-border bg-background/95 dark:bg-background/90 shadow-sm">
|
||||
<Empty className="p-6 md:p-8">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<CircleDotDashed className="size-6" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Start adding a new node!</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Right‑click to add nodes. <br />Import a project or paste a node.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent className="flex-row flex-wrap justify-center gap-2">
|
||||
<Button onClick={handleImportProject} variant="outline" size="sm">
|
||||
<FolderOpen className="size-4" />
|
||||
Import…
|
||||
</Button>
|
||||
<Button onClick={handleLoadExample} variant="outline" size="sm">
|
||||
<FileStack className="size-4" />
|
||||
Load example
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
|
||||
<FlowFitViewOnLoad />
|
||||
<FlowKeyboardShortcuts />
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onConnectStart={onConnectStart}
|
||||
onConnectEnd={onConnectEnd}
|
||||
onNodeDragStart={onNodeDragStart}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
isValidConnection={isValidConnection}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
colorMode={theme as ColorMode}
|
||||
snapToGrid
|
||||
snapGrid={SNAP_GRID}
|
||||
fitView
|
||||
onInit={onInit}
|
||||
nodeDragThreshold={1}
|
||||
nodesDraggable
|
||||
nodesConnectable
|
||||
elementsSelectable
|
||||
>
|
||||
<Background variant="dots" gap={20} />
|
||||
<div role="group" aria-label="Canvas controls: zoom and fit view">
|
||||
<Controls />
|
||||
</div>
|
||||
<div role="region" aria-label="Minimap: overview of the graph">
|
||||
<MiniMap />
|
||||
</div>
|
||||
</ReactFlow>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent className="w-48" aria-label="Canvas menu: create node or paste">
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>Create Node</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="w-44">
|
||||
<ContextMenuGroup>
|
||||
{getRegisteredNodeTypes().map((desc, index) => (
|
||||
<React.Fragment key={desc.id}>
|
||||
{index === 2 ? <ContextMenuSeparator /> : null}
|
||||
<ContextMenuItem onSelect={() => createNode(desc.id)}>
|
||||
{desc.menuIcon}
|
||||
{desc.menuLabel}
|
||||
</ContextMenuItem>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ContextMenuGroup>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={() => pasteNode()}>
|
||||
<ClipboardPaste className="mr-2 h-4 w-4" />
|
||||
Paste
|
||||
</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</FlowContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
143
frontend/src/components/AppMenubar.tsx
Normal file
143
frontend/src/components/AppMenubar.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import {
|
||||
Menubar,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarMenu,
|
||||
MenubarSeparator,
|
||||
MenubarTrigger,
|
||||
MenubarSub,
|
||||
MenubarSubTrigger,
|
||||
MenubarSubContent,
|
||||
MenubarCheckboxItem,
|
||||
} from '@/components/ui/menubar'
|
||||
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
||||
import { useTheme } from '@/lib/themeContext'
|
||||
import { Download, FolderOpen, Moon, Redo2, Sun, Undo2 } from 'lucide-react'
|
||||
|
||||
type AppMenubarProps = {
|
||||
onImport: () => void
|
||||
onExport: () => void
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
canUndo: boolean
|
||||
canRedo: boolean
|
||||
onFitView?: () => void
|
||||
}
|
||||
|
||||
const UNDO_KEYS = { key: 'z', shiftKey: false }
|
||||
const REDO_KEYS = { key: 'z', shiftKey: true }
|
||||
|
||||
function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
|
||||
const mod = ev.ctrlKey || ev.metaKey
|
||||
return (
|
||||
ev.key.toLowerCase() === want.key &&
|
||||
!!mod &&
|
||||
!!ev.shiftKey === want.shiftKey
|
||||
)
|
||||
}
|
||||
|
||||
export function AppMenubar({ onImport, onExport, undo, redo, canUndo, canRedo, onFitView }: AppMenubarProps) {
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (ev: KeyboardEvent) => {
|
||||
if (matchKey(ev, UNDO_KEYS)) {
|
||||
if (canUndo) {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
undo()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (matchKey(ev, REDO_KEYS)) {
|
||||
if (canRedo) {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
redo()
|
||||
}
|
||||
}
|
||||
}
|
||||
// Capture phase so we run before CodeMirror/inputs; then graph undo applies even when focus is in an editor
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||
}, [undo, redo, canUndo, canRedo])
|
||||
|
||||
return (
|
||||
<Menubar className="shrink-0 rounded-none border-x-0 border-t-0 border-b-0">
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="font-medium">Project</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onClick={onImport} className="gap-2">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Import…
|
||||
</MenubarItem>
|
||||
<MenubarItem onClick={onExport} className="gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
Export…
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="font-medium">Edit</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onClick={undo} disabled={!canUndo} className="gap-2">
|
||||
<Undo2 className="h-4 w-4" />
|
||||
Undo
|
||||
<span className="ml-auto pl-4">
|
||||
<KbdGroup>
|
||||
<Kbd>⌘ + Z</Kbd>
|
||||
</KbdGroup>
|
||||
</span>
|
||||
</MenubarItem>
|
||||
<MenubarItem onClick={redo} disabled={!canRedo} className="gap-2">
|
||||
<Redo2 className="h-4 w-4" />
|
||||
Redo
|
||||
<span className="ml-auto pl-4">
|
||||
<KbdGroup>
|
||||
<Kbd>⌘ + ⇧ + Z</Kbd>
|
||||
</KbdGroup>
|
||||
</span>
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="font-medium">View</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
{onFitView && (
|
||||
<MenubarItem onClick={onFitView} className="gap-2">
|
||||
Fit View
|
||||
<span className="ml-auto pl-4">
|
||||
<KbdGroup>
|
||||
<Kbd>⌘0</Kbd>
|
||||
</KbdGroup>
|
||||
</span>
|
||||
</MenubarItem>
|
||||
)}
|
||||
{onFitView && <MenubarSeparator />}
|
||||
<MenubarSub>
|
||||
<MenubarSubTrigger>Theme</MenubarSubTrigger>
|
||||
<MenubarSubContent>
|
||||
<MenubarCheckboxItem
|
||||
checked={theme === 'light'}
|
||||
onCheckedChange={() => setTheme('light')}
|
||||
className="gap-2"
|
||||
>
|
||||
<Sun className="h-4 w-4" />
|
||||
Light
|
||||
</MenubarCheckboxItem>
|
||||
<MenubarCheckboxItem
|
||||
checked={theme === 'dark'}
|
||||
onCheckedChange={() => setTheme('dark')}
|
||||
className="gap-2"
|
||||
>
|
||||
<Moon className="h-4 w-4" />
|
||||
Dark
|
||||
</MenubarCheckboxItem>
|
||||
</MenubarSubContent>
|
||||
</MenubarSub>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
)
|
||||
}
|
||||
158
frontend/src/components/FlowKeyboardShortcuts.tsx
Normal file
158
frontend/src/components/FlowKeyboardShortcuts.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import React, { useCallback, useContext, useEffect } from 'react'
|
||||
import { useReactFlow } from '@xyflow/react'
|
||||
import type { Node } from '@xyflow/react'
|
||||
import FlowContext from '@/lib/flowContext'
|
||||
import { getNextNodeId, getDefaultDataForType } from '@/lib/flowUtils'
|
||||
import { getDefaultStyle, getRegisteredNodeTypeIds } from '@/lib/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 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 newId = getNextNodeId(raw.type, nds.map((n) => n.id))
|
||||
const data: Record<string, unknown> =
|
||||
raw.data != null && typeof raw.data === 'object'
|
||||
? { ...raw.data }
|
||||
: (getDefaultDataForType(raw.type, newId) as Record<string, unknown>)
|
||||
if (raw.type === 'config') data.title = `config-${newId}`
|
||||
const style = getDefaultStyle(raw.type)
|
||||
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') {
|
||||
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
|
||||
}
|
||||
117
frontend/src/components/base/AnimatedEdge.tsx
Normal file
117
frontend/src/components/base/AnimatedEdge.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React, { useContext, useMemo } from 'react'
|
||||
import {
|
||||
BaseEdge,
|
||||
getBezierPath,
|
||||
type EdgeProps,
|
||||
} from '@xyflow/react'
|
||||
import FlowContext from '../../lib/flowContext'
|
||||
import { getConnectionLabelForTarget } from '../../lib/nodeRegistry'
|
||||
|
||||
const EDGE_STROKE_WIDTH = 2
|
||||
const DOT_MARKER_R = 1.5
|
||||
|
||||
export function AnimatedEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
style,
|
||||
label: labelProp,
|
||||
interactionWidth,
|
||||
target,
|
||||
}: EdgeProps) {
|
||||
const ctx = useContext(FlowContext)
|
||||
const nodes = ctx?.nodes ?? []
|
||||
const targetNode = useMemo(() => nodes.find((n: any) => n.id === target), [nodes, target])
|
||||
const derivedLabel = useMemo(
|
||||
() => getConnectionLabelForTarget(targetNode?.type),
|
||||
[targetNode?.type]
|
||||
)
|
||||
const label = labelProp ?? derivedLabel
|
||||
|
||||
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"
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
24
frontend/src/components/base/BaseHandle.tsx
Normal file
24
frontend/src/components/base/BaseHandle.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { Handle, type HandleProps } from "@xyflow/react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type BaseHandleProps = HandleProps;
|
||||
|
||||
export function BaseHandle({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof Handle>) {
|
||||
return (
|
||||
<Handle
|
||||
{...props}
|
||||
className={cn(
|
||||
"dark:border-secondary dark:bg-secondary h-[11px] w-[11px] rounded-full border border-slate-300 bg-slate-100 transition",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Handle>
|
||||
);
|
||||
}
|
||||
184
frontend/src/components/base/BaseNode.tsx
Normal file
184
frontend/src/components/base/BaseNode.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { NodeResizeControl } from "@xyflow/react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type BaseNodeProps = ComponentProps<"div"> & {
|
||||
/** When true, shows a bottom-right resize handle. 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;
|
||||
};
|
||||
|
||||
export function BaseNode({
|
||||
className,
|
||||
style,
|
||||
dimensions,
|
||||
resizable,
|
||||
nodeId,
|
||||
handles,
|
||||
children,
|
||||
selected,
|
||||
...props
|
||||
}: BaseNodeProps) {
|
||||
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;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card text-card-foreground relative rounded-md border",
|
||||
"hover:ring-1",
|
||||
selected && "border-primary shadow-[0_0_0_2px_hsl(var(--primary)_/_0.4)]",
|
||||
className,
|
||||
)}
|
||||
data-selected={selected}
|
||||
style={appliedStyle}
|
||||
tabIndex={0}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="min-h-0 flex-1 flex flex-col overflow-hidden"
|
||||
style={{ contain: "layout" }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{resizable && nodeId && (
|
||||
<NodeResizeControl
|
||||
nodeId={nodeId}
|
||||
position="bottom-right"
|
||||
minWidth={120}
|
||||
minHeight={80}
|
||||
className={cn(
|
||||
"!border-0 !bg-transparent !rounded-none !p-0",
|
||||
"absolute bottom-0 right-0 w-8 h-8 cursor-se-resize select-none",
|
||||
"nodrag nopan touch-none",
|
||||
)}
|
||||
style={{ margin: 0, touchAction: "none", userSelect: "none" }}
|
||||
>
|
||||
</NodeResizeControl>
|
||||
)}
|
||||
{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,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: ReactNode;
|
||||
/** Optional right-side content (e.g. type selector). */
|
||||
right?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"shrink-0 mx-0 my-0 flex flex-row items-center justify-between gap-2 px-3 py-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
56
frontend/src/components/base/NodeFooterEdgeIndicators.tsx
Normal file
56
frontend/src/components/base/NodeFooterEdgeIndicators.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React, { useContext, useMemo } from 'react'
|
||||
import FlowContext from '../../lib/flowContext'
|
||||
import { ArrowDownLeft, ArrowUpRight } from 'lucide-react'
|
||||
import { NodeHelpPopover } from './NodeHelpPopover'
|
||||
import { getNodeType } from '../../lib/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
|
||||
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
<span className="shrink-0 ml-auto">
|
||||
<NodeHelpPopover nodeType={nodeType} />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
frontend/src/components/base/NodeHandles.tsx
Normal file
76
frontend/src/components/base/NodeHandles.tsx
Normal 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/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 ?? undefined,
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
82
frontend/src/components/base/NodeHeaderTitle.tsx
Normal file
82
frontend/src/components/base/NodeHeaderTitle.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import FlowContext from '../../lib/flowContext'
|
||||
import { replaceNodeIdInGraph } from '../../lib/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)
|
||||
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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
35
frontend/src/components/base/NodeHelpPopover.tsx
Normal file
35
frontend/src/components/base/NodeHelpPopover.tsx
Normal 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/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>
|
||||
)
|
||||
}
|
||||
185
frontend/src/components/base/NodeMenubar.tsx
Normal file
185
frontend/src/components/base/NodeMenubar.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { useCallback, useContext } from 'react'
|
||||
import FlowContext from '../../lib/flowContext'
|
||||
import { getNextNodeId, getResetDataForType } from '../../lib/flowUtils'
|
||||
import { getDefaultStyle } from '../../lib/nodeRegistry'
|
||||
import {
|
||||
Menubar,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarMenu,
|
||||
MenubarSeparator,
|
||||
MenubarShortcut,
|
||||
MenubarSub,
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
MenubarTrigger,
|
||||
} from '../ui/menubar'
|
||||
import { Kbd } from '../ui/kbd'
|
||||
|
||||
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuContent, insertTagsContent, insertMenuLabel = 'Insert', insertContentDirect, insertTagsLabel = 'Tags', nodeMenuExtraContent }: 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 onDuplicate = useCallback(() => {
|
||||
if (!setNodes || !node) return
|
||||
const pos = node.position ?? { x: 0, y: 0 }
|
||||
setNodes((nds: any[]) => {
|
||||
const newId = getNextNodeId(nodeType, nds.map((n: any) => n.id))
|
||||
const newNode = {
|
||||
id: newId,
|
||||
type: node.type,
|
||||
position: { x: pos.x + DUPLICATE_OFFSET.x, y: pos.y + DUPLICATE_OFFSET.y },
|
||||
data: typeof node.data === 'object' && node.data !== null ? { ...node.data } : node.data,
|
||||
style: getDefaultStyle(nodeType),
|
||||
}
|
||||
if (newNode.data?.title && nodeType === 'config') newNode.data.title = `${newId}`
|
||||
return nds.concat(newNode)
|
||||
})
|
||||
}, [node, nodeType, setNodes])
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
if (!node) return
|
||||
const copy = { id: node.id, type: node.type, data: node.data, position: node.position, style: node.style }
|
||||
navigator.clipboard?.writeText(JSON.stringify(copy)).catch(() => { })
|
||||
}, [node])
|
||||
|
||||
const onReset = useCallback(() => {
|
||||
if (!setNodes) return
|
||||
const resetData = getResetDataForType(nodeType, nodeId)
|
||||
setNodes((nds: any[]) =>
|
||||
nds.map((n) => (n.id === nodeId ? { ...n, data: resetData } : n))
|
||||
)
|
||||
}, [nodeId, nodeType, setNodes])
|
||||
|
||||
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])
|
||||
|
||||
const onPaste = useCallback(() => {
|
||||
ctx?.flowActionsRef?.current?.pasteAtViewportCenter?.()
|
||||
}, [ctx])
|
||||
|
||||
const onFitView = useCallback(() => {
|
||||
ctx?.flowActionsRef?.current?.fitView?.()
|
||||
}, [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]">
|
||||
{/* Edit */}
|
||||
<MenubarItem className="text-xs" onClick={onDuplicate}>
|
||||
Duplicate
|
||||
<MenubarShortcut className="ml-auto pl-4"><Kbd>⌘D</Kbd></MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem className="text-xs" onClick={onCopy}>
|
||||
Copy
|
||||
<MenubarShortcut className="ml-auto pl-4"><Kbd>⌘C</Kbd></MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem className="text-xs" onClick={onPaste}>
|
||||
Paste
|
||||
<MenubarShortcut className="ml-auto pl-4"><Kbd>⌘V</Kbd></MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
{/* View */}
|
||||
<MenubarItem className="text-xs" onClick={onFitView}>
|
||||
Fit View
|
||||
<MenubarShortcut className="ml-auto pl-4"><Kbd>⌘0</Kbd></MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
{/* Node */}
|
||||
<MenubarItem className="text-xs" onClick={onRename}>
|
||||
Rename
|
||||
</MenubarItem>
|
||||
<MenubarItem className="text-xs" onClick={onReset}>
|
||||
Clear
|
||||
</MenubarItem>
|
||||
{nodeMenuExtraContent}
|
||||
<MenubarSeparator />
|
||||
<MenubarItem className="text-xs text-destructive focus:text-destructive" onClick={onDelete}>
|
||||
Delete
|
||||
</MenubarItem>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
206
frontend/src/components/base/NodeStatusIndicator.tsx
Normal file
206
frontend/src/components/base/NodeStatusIndicator.tsx
Normal 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}</>
|
||||
}
|
||||
}
|
||||
356
frontend/src/components/nodes/ConfigNode.tsx
Normal file
356
frontend/src/components/nodes/ConfigNode.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
import React, { useCallback, useMemo, useRef } from 'react'
|
||||
import { autocompletion } from '@codemirror/autocomplete'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import {
|
||||
AbstractNodeProps,
|
||||
createAbstractNodeComponent,
|
||||
useAbstractNode,
|
||||
type FlowNode,
|
||||
} from '../../lib/abstractNode'
|
||||
import { useResizeHeight } from '../../hooks/useResizeHeight'
|
||||
import { nunjucksCompletionSource } from '../../lib/nunjucksAutocomplete'
|
||||
import { plantumlLanguage } from '../../lib/plantumlLanguage'
|
||||
import { useTheme } from '../../lib/themeContext'
|
||||
import {
|
||||
CONFIG_TYPES,
|
||||
getConfigContent,
|
||||
getConfigType,
|
||||
getConfigTypeId,
|
||||
isGroup,
|
||||
type ConfigTypeId,
|
||||
} from '../../lib/configTypes'
|
||||
import {
|
||||
BaseNode,
|
||||
BaseNodeContent,
|
||||
BaseNodeFooter,
|
||||
BaseNodeHeaderRow,
|
||||
} from '../base/BaseNode'
|
||||
import { Code2, ScrollText, Variable } from 'lucide-react'
|
||||
import {
|
||||
MenubarItem,
|
||||
MenubarSeparator,
|
||||
MenubarShortcut,
|
||||
MenubarSub,
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
} from '../ui/menubar'
|
||||
import { Kbd } from '../ui/kbd'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { InputHandle, OutputHandle } from '../base/NodeHandles'
|
||||
import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
|
||||
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
|
||||
import { NodeMenubar } from '../base/NodeMenubar'
|
||||
|
||||
export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; title?: string }
|
||||
|
||||
type Props = AbstractNodeProps<ConfigNodeData>
|
||||
|
||||
function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const configTypeId = getConfigTypeId(data ?? {})
|
||||
const configType = getConfigType(configTypeId)
|
||||
const content = getConfigContent(data ?? {})
|
||||
const { theme } = useTheme()
|
||||
const { nodes, sourceIds, updateData } = useAbstractNode<ConfigNodeData>(id, data ?? {})
|
||||
const editorRef = useRef<unknown>(null)
|
||||
|
||||
const connectedConfigNodes = useMemo(
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'config'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const connectedVariableNodes = useMemo(
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'variable'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const connectedFunctionNodes = useMemo(
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'function'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0
|
||||
|
||||
const onChange = useCallback(
|
||||
(val: string) => updateData({ content: val, configType: configTypeId }),
|
||||
[updateData, configTypeId]
|
||||
)
|
||||
|
||||
const setConfigType = useCallback(
|
||||
(newTypeId: ConfigTypeId) => {
|
||||
if (newTypeId === configTypeId) return
|
||||
updateData({
|
||||
configType: newTypeId,
|
||||
content: getConfigContent({ ...data, configType: newTypeId }) ?? '',
|
||||
})
|
||||
},
|
||||
[configTypeId, data, updateData]
|
||||
)
|
||||
|
||||
const insertAt = useCallback(
|
||||
(insertText: string, mode: 'prepend' | 'append' | 'cursor') => {
|
||||
const ref = editorRef.current as { view: { state: { doc: { length: number; toString(): string }; selection: { main: { from: number } } }; dispatch: (arg: { changes: { from: number; to: number; insert: string } }) => void } } | null
|
||||
if (ref?.view) {
|
||||
const view = ref.view
|
||||
const doc = view.state.doc
|
||||
const len = doc.length
|
||||
let from: number
|
||||
if (mode === 'prepend') {
|
||||
from = 0
|
||||
} else if (mode === 'append') {
|
||||
from = len
|
||||
} else {
|
||||
const main = view.state.selection.main
|
||||
from = main.from
|
||||
}
|
||||
view.dispatch({ changes: { from, to: from, insert: insertText } })
|
||||
const newVal = view.state.doc.toString()
|
||||
onChange(newVal)
|
||||
return
|
||||
}
|
||||
if (mode === 'prepend') {
|
||||
onChange(insertText + content)
|
||||
} else {
|
||||
onChange(content + insertText)
|
||||
}
|
||||
},
|
||||
[onChange, content]
|
||||
)
|
||||
|
||||
const insertExtendsFromNode = useCallback(
|
||||
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
||||
insertAt(`{% extends "${sourceNode.id}" %}\n`, mode)
|
||||
},
|
||||
[insertAt]
|
||||
)
|
||||
|
||||
const insertIncludeFromNode = useCallback(
|
||||
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
||||
insertAt(`{% include "${sourceNode.id}" %}\n`, mode)
|
||||
},
|
||||
[insertAt]
|
||||
)
|
||||
|
||||
const insertImportFromNode = useCallback(
|
||||
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
||||
insertAt(`{% import "${sourceNode.id}" as ${sourceNode.id} %}\n`, mode)
|
||||
},
|
||||
[insertAt]
|
||||
)
|
||||
|
||||
const insertVariableReference = useCallback(
|
||||
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
||||
insertAt(`{{ ${sourceNode.id} }}`, mode)
|
||||
},
|
||||
[insertAt]
|
||||
)
|
||||
|
||||
const insertFunctionCall = useCallback(
|
||||
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
||||
insertAt(`{{ '' | ${sourceNode.id} }}`, mode)
|
||||
},
|
||||
[insertAt]
|
||||
)
|
||||
|
||||
const variableIds = useMemo(() => connectedVariableNodes.map((n: any) => n.id), [connectedVariableNodes])
|
||||
const functionIds = useMemo(() => connectedFunctionNodes.map((n: any) => n.id), [connectedFunctionNodes])
|
||||
const configTitles = useMemo(
|
||||
() => connectedConfigNodes.map((n: any) => n.data?.title ?? n.id),
|
||||
[connectedConfigNodes],
|
||||
)
|
||||
const extensions = useMemo(() => {
|
||||
const lang =
|
||||
configTypeId === 'wireframe'
|
||||
? javascript()
|
||||
: configType.language === 'plantuml'
|
||||
? plantumlLanguage.extension
|
||||
: markdown()
|
||||
return [
|
||||
lang,
|
||||
autocompletion({
|
||||
override: [nunjucksCompletionSource(variableIds, configTitles, functionIds)],
|
||||
activateOnTyping: true,
|
||||
}),
|
||||
]
|
||||
}, [configTypeId, configType.language, variableIds, functionIds, configTitles])
|
||||
const [editorHeight, editorContainerRef] = useResizeHeight(180)
|
||||
|
||||
const insertBlocksContent = useMemo(() => {
|
||||
const templatingGroup = configType.insertBlocks.find(
|
||||
(b): b is import('../../lib/configTypes').InsertBlockGroup => isGroup(b) && b.label === 'Templating'
|
||||
)
|
||||
const typeBlocks = configType.insertBlocks.filter((b) => !(isGroup(b) && b.label === 'Templating'))
|
||||
const insertShortcut = <MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
|
||||
const typeItems = typeBlocks.flatMap((block) =>
|
||||
isGroup(block)
|
||||
? block.items.map(({ label, snippet }) => (
|
||||
<MenubarItem
|
||||
key={label}
|
||||
className="text-xs flex items-center group"
|
||||
onClick={() => insertAt(snippet, 'cursor')}
|
||||
>
|
||||
{label}
|
||||
{insertShortcut}
|
||||
</MenubarItem>
|
||||
))
|
||||
: [
|
||||
<MenubarItem
|
||||
key={block.label}
|
||||
className="text-xs flex items-center group"
|
||||
onClick={() => insertAt(block.snippet, 'cursor')}
|
||||
>
|
||||
{block.label}
|
||||
{insertShortcut}
|
||||
</MenubarItem>,
|
||||
]
|
||||
)
|
||||
const templatingItems =
|
||||
templatingGroup?.items.map(({ label, snippet }) => (
|
||||
<MenubarItem
|
||||
key={label}
|
||||
className="text-xs flex items-center group"
|
||||
onClick={() => insertAt(snippet, 'cursor')}
|
||||
>
|
||||
{label}
|
||||
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
|
||||
</MenubarItem>
|
||||
)) ?? []
|
||||
return (
|
||||
<>
|
||||
{typeItems}
|
||||
{templatingItems.length > 0 && (
|
||||
<>
|
||||
<MenubarSeparator />
|
||||
{templatingItems}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}, [configType.insertBlocks, insertAt])
|
||||
|
||||
const dimensions =
|
||||
width != null && height != null && width > 0 && height > 0
|
||||
? { width, height }
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<BaseNode className="min-w-80 min-h-[280px]" dimensions={dimensions} resizable nodeId={id} selected={selected} handles={<><InputHandle id="ain" nodeId={id} /><OutputHandle id="out" /></>}>
|
||||
<BaseNodeHeaderRow
|
||||
icon={<ScrollText className="size-4" />}
|
||||
title={<NodeHeaderTitle nodeId={id} displayTitle={`${id}`} />}
|
||||
right={
|
||||
<Select value={configTypeId} onValueChange={(v) => setConfigType(v as ConfigTypeId)}>
|
||||
<SelectTrigger className="h-7 w-[7rem] text-xs font-normal">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONFIG_TYPES.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id} className="text-xs">
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
|
||||
<BaseNodeContent>
|
||||
<div className="shrink-0 w-full">
|
||||
<NodeMenubar
|
||||
nodeId={id}
|
||||
nodeType="config"
|
||||
inputsMenuContent={
|
||||
hasDependencies ? (
|
||||
<>
|
||||
{connectedConfigNodes.map((n: any) => (
|
||||
<MenubarSub key={`${n.id}`}>
|
||||
<MenubarSubTrigger className="text-xs flex items-center gap-2">
|
||||
<ScrollText className="size-3.5 shrink-0" />
|
||||
{n.data?.title ?? n.id}
|
||||
</MenubarSubTrigger>
|
||||
<MenubarSubContent>
|
||||
<MenubarItem
|
||||
className="text-xs flex items-center group"
|
||||
onClick={() => insertExtendsFromNode(n, 'cursor')}
|
||||
>
|
||||
Extend
|
||||
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem
|
||||
className="text-xs flex items-center group"
|
||||
onClick={() => insertIncludeFromNode(n, 'cursor')}
|
||||
>
|
||||
Include
|
||||
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem
|
||||
className="text-xs flex items-center group"
|
||||
onClick={() => insertImportFromNode(n, 'cursor')}
|
||||
>
|
||||
Import
|
||||
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
|
||||
</MenubarItem>
|
||||
</MenubarSubContent>
|
||||
</MenubarSub>
|
||||
))}
|
||||
{connectedVariableNodes.map((n: any) => (
|
||||
<MenubarItem
|
||||
key={n.id}
|
||||
className="text-xs flex items-center gap-2 group"
|
||||
onClick={() => insertVariableReference(n, 'cursor')}
|
||||
>
|
||||
<Variable className="size-3.5 shrink-0" />
|
||||
{n.id}
|
||||
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
|
||||
</MenubarItem>
|
||||
))}
|
||||
{connectedFunctionNodes.map((n: any) => (
|
||||
<MenubarItem
|
||||
key={n.id}
|
||||
className="text-xs flex items-center gap-2 group"
|
||||
onClick={() => insertFunctionCall(n, 'cursor')}
|
||||
>
|
||||
<Code2 className="size-3.5 shrink-0" />
|
||||
{n.id}
|
||||
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
|
||||
</MenubarItem>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground px-2 py-1">Connect nodes to insert references</span>
|
||||
)
|
||||
}
|
||||
insertMenuLabel="Blocks"
|
||||
insertContentDirect
|
||||
insertTagsContent={insertBlocksContent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref={editorContainerRef} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
|
||||
<CodeMirror
|
||||
// @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules
|
||||
ref={editorRef}
|
||||
value={content}
|
||||
height={`${editorHeight}px`}
|
||||
theme={theme}
|
||||
extensions={extensions}
|
||||
onChange={onChange}
|
||||
basicSetup={{ lineNumbers: true, foldGutter: false }}
|
||||
className="text-sm [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0"
|
||||
/>
|
||||
</div>
|
||||
</BaseNodeContent>
|
||||
|
||||
<BaseNodeFooter>
|
||||
<NodeFooterEdgeIndicators nodeId={id} nodeType="config">
|
||||
{configType.label} · {content ? `${content.length} chars` : 'none'}
|
||||
</NodeFooterEdgeIndicators>
|
||||
</BaseNodeFooter>
|
||||
</BaseNode>
|
||||
)
|
||||
}
|
||||
|
||||
export const ConfigNode = createAbstractNodeComponent<ConfigNodeData>(
|
||||
'ConfigNode',
|
||||
ConfigNodeComponent
|
||||
)
|
||||
|
||||
export default ConfigNode
|
||||
163
frontend/src/components/nodes/FunctionNode.tsx
Normal file
163
frontend/src/components/nodes/FunctionNode.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React, { useCallback, useMemo, useRef } from 'react'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import {
|
||||
AbstractNodeProps,
|
||||
createAbstractNodeComponent,
|
||||
useAbstractNode,
|
||||
type FlowNode,
|
||||
} from '../../lib/abstractNode'
|
||||
import { useResizeHeight } from '../../hooks/useResizeHeight'
|
||||
import { useTheme } from '../../lib/themeContext'
|
||||
import {
|
||||
BaseNode,
|
||||
BaseNodeContent,
|
||||
BaseNodeFooter,
|
||||
BaseNodeHeaderRow,
|
||||
} from '../base/BaseNode'
|
||||
import { InputHandle, OutputHandle } from '../base/NodeHandles'
|
||||
import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
|
||||
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
|
||||
import { NodeMenubar } from '../base/NodeMenubar'
|
||||
import { MenubarItem, MenubarShortcut } from '../ui/menubar'
|
||||
import { Kbd } from '../ui/kbd'
|
||||
import { Code2, Variable } from 'lucide-react'
|
||||
|
||||
export type FunctionNodeData = { body?: string }
|
||||
|
||||
type Props = AbstractNodeProps<FunctionNodeData>
|
||||
|
||||
function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const bodyValue = data?.body ?? ''
|
||||
const { theme } = useTheme()
|
||||
const { nodes, sourceIds, updateData } = useAbstractNode<FunctionNodeData>(id, data ?? {})
|
||||
const editorRef = useRef<unknown>(null)
|
||||
|
||||
const connectedVariableNodes = useMemo(
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'variable'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const connectedFunctionNodes = useMemo(
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'function'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const hasConnectedInputs = connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0
|
||||
|
||||
const onChange = useCallback(
|
||||
(val: string) => updateData({ body: val }),
|
||||
[updateData]
|
||||
)
|
||||
|
||||
const insertAt = useCallback(
|
||||
(insertText: string, mode: 'prepend' | 'append' | 'cursor') => {
|
||||
const ref = editorRef.current as { view: { state: { doc: { length: number }; selection: { main: { from: number } } }; dispatch: (arg: { changes: { from: number; to: number; insert: string } }) => void } } | null
|
||||
if (ref?.view) {
|
||||
const view = ref.view
|
||||
const doc = view.state.doc
|
||||
const len = doc.length
|
||||
let from: number
|
||||
if (mode === 'prepend') from = 0
|
||||
else if (mode === 'append') from = len
|
||||
else from = view.state.selection.main.from
|
||||
view.dispatch({ changes: { from, to: from, insert: insertText } })
|
||||
onChange(view.state.doc.toString())
|
||||
return
|
||||
}
|
||||
if (mode === 'prepend') onChange(insertText + bodyValue)
|
||||
else onChange(bodyValue + insertText)
|
||||
},
|
||||
[onChange, bodyValue]
|
||||
)
|
||||
|
||||
const insertVariableAtCursor = useCallback(
|
||||
(variableNode: any) => {
|
||||
insertAt(variableNode.id, 'cursor')
|
||||
},
|
||||
[insertAt]
|
||||
)
|
||||
|
||||
const insertFunctionAtCursor = useCallback(
|
||||
(functionNode: any) => {
|
||||
insertAt(functionNode.id, 'cursor')
|
||||
},
|
||||
[insertAt]
|
||||
)
|
||||
|
||||
const extensions = useMemo(() => [javascript()], [])
|
||||
const [editorHeight, editorContainerRef] = useResizeHeight(120)
|
||||
const dimensions =
|
||||
width != null && height != null && width > 0 && height > 0
|
||||
? { width, height }
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<BaseNode className="min-w-72 min-h-[260px]" dimensions={dimensions} resizable nodeId={id} selected={selected} handles={<><InputHandle id="in" nodeId={id} /><OutputHandle id="out" /></>}>
|
||||
<BaseNodeHeaderRow icon={<Code2 className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
|
||||
|
||||
<BaseNodeContent>
|
||||
<div className="shrink-0 w-full">
|
||||
<NodeMenubar
|
||||
nodeId={id}
|
||||
nodeType="function"
|
||||
inputsMenuContent={
|
||||
hasConnectedInputs ? (
|
||||
<>
|
||||
{connectedVariableNodes.map((n: any) => (
|
||||
<MenubarItem
|
||||
key={n.id}
|
||||
className="text-xs flex items-center gap-2 group"
|
||||
onClick={() => insertVariableAtCursor(n)}
|
||||
>
|
||||
<Variable className="size-3.5 shrink-0" />
|
||||
{n.id}
|
||||
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
|
||||
</MenubarItem>
|
||||
))}
|
||||
{connectedFunctionNodes.map((n: any) => (
|
||||
<MenubarItem
|
||||
key={n.id}
|
||||
className="text-xs flex items-center gap-2 group"
|
||||
onClick={() => insertFunctionAtCursor(n)}
|
||||
>
|
||||
<Code2 className="size-3.5 shrink-0" />
|
||||
{n.id}
|
||||
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
|
||||
</MenubarItem>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground px-2 py-1">Connect nodes to insert at cursor</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div ref={editorContainerRef} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
|
||||
<CodeMirror
|
||||
// @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules
|
||||
ref={editorRef}
|
||||
value={bodyValue}
|
||||
height={`${editorHeight}px`}
|
||||
theme={theme}
|
||||
extensions={extensions}
|
||||
onChange={onChange}
|
||||
basicSetup={{ lineNumbers: true, foldGutter: false }}
|
||||
className="text-xs [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0"
|
||||
/>
|
||||
</div>
|
||||
</BaseNodeContent>
|
||||
|
||||
<BaseNodeFooter>
|
||||
<NodeFooterEdgeIndicators nodeId={id} nodeType="function">
|
||||
{bodyValue ? `JavaScript · ${bodyValue.length} chars` : 'JavaScript · none'}
|
||||
</NodeFooterEdgeIndicators>
|
||||
</BaseNodeFooter>
|
||||
</BaseNode>
|
||||
)
|
||||
}
|
||||
|
||||
export const FunctionNode = createAbstractNodeComponent<FunctionNodeData>(
|
||||
'FunctionNode',
|
||||
FunctionNodeComponent
|
||||
)
|
||||
|
||||
export default FunctionNode
|
||||
786
frontend/src/components/nodes/RenderingNode.tsx
Normal file
786
frontend/src/components/nodes/RenderingNode.tsx
Normal file
@@ -0,0 +1,786 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import nunjucks from 'nunjucks'
|
||||
import {
|
||||
AbstractNodeProps,
|
||||
createAbstractNodeComponent,
|
||||
useAbstractNode,
|
||||
} from '../../lib/abstractNode'
|
||||
import { getConfigContent, getConfigType, getConfigTypeId } from '../../lib/configTypes'
|
||||
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '../ui/empty'
|
||||
import {
|
||||
BaseNode,
|
||||
BaseNodeContent,
|
||||
BaseNodeFooter,
|
||||
BaseNodeHeaderRow,
|
||||
} from '../base/BaseNode'
|
||||
import { getDefaultDataForType, getNextNodeId } from '../../lib/flowUtils'
|
||||
import { getDefaultStyle } from '../../lib/nodeRegistry'
|
||||
import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
|
||||
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
|
||||
import { NodeMenubar } from '../base/NodeMenubar'
|
||||
import { NodeStatusIndicator } from '../base/NodeStatusIndicator'
|
||||
import { MenubarItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '../ui/menubar'
|
||||
import { Sparkles, ZoomIn, ZoomOut, RotateCcw, RotateCw } from 'lucide-react'
|
||||
import { InputHandle } from '../base/NodeHandles'
|
||||
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'
|
||||
import { Input } from '../ui/input'
|
||||
import { Button } from '../ui/button'
|
||||
|
||||
export type RenderingNodeData = {
|
||||
viewportWidth?: number
|
||||
viewportHeight?: number
|
||||
}
|
||||
|
||||
const DEFAULT_VIEWPORT_WIDTH = 1200
|
||||
const DEFAULT_VIEWPORT_HEIGHT = 800
|
||||
|
||||
type Props = AbstractNodeProps<RenderingNodeData>
|
||||
|
||||
function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const [renderedContent, setRenderedContent] = useState<string | null>(null)
|
||||
const [error, setError] = useState<null | { kind: string; message: string }>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [retryCount, setRetryCount] = useState(0)
|
||||
const runIdRef = useRef(0)
|
||||
const loadingStartedAtRef = useRef<number | null>(null)
|
||||
const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const { nodes, edges, setNodes, setEdges, sourceIds, updateData } = useAbstractNode<RenderingNodeData>(id, data ?? {})
|
||||
const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH
|
||||
const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT
|
||||
|
||||
const incomingIds = sourceIds
|
||||
const srcId = incomingIds.length > 0 ? incomingIds[0] : null
|
||||
const srcNode = nodes.find((n: any) => n.id === srcId)
|
||||
const configTypeId = srcNode?.type === 'config' ? getConfigTypeId(srcNode.data) : 'plantuml'
|
||||
const sourceContent = srcNode?.type === 'config' ? getConfigContent(srcNode.data) : ''
|
||||
const srcData = srcNode?.data ?? {}
|
||||
|
||||
/** Set of node IDs that can affect this render node (configs in the chain + variables/functions feeding them) */
|
||||
const connectedNodeIds = useMemo(() => {
|
||||
const out = new Set<string>()
|
||||
const isReachable = (startId: string, targetId: string) => {
|
||||
const q: string[] = [startId]
|
||||
const seen = new Set<string>([startId])
|
||||
while (q.length) {
|
||||
const cur = q.shift()!
|
||||
if (cur === targetId) return true
|
||||
for (const e of edges) {
|
||||
if (e.source === cur && !seen.has(e.target)) {
|
||||
seen.add(e.target)
|
||||
q.push(e.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
const resolveRef = (name: string) => {
|
||||
const refName = name.replace(/\.(puml|html)$/, '').trim()
|
||||
return nodes.find((n: any) => n.id === refName || n.data?.title === refName)?.id ?? refName
|
||||
}
|
||||
const getTemplateRefs = (content: string): string[] => {
|
||||
const refs: string[] = []
|
||||
const extendMatch = content.match(/\{\%\s*extends\s+["']([^"']+)["']\s*\%\}/)
|
||||
if (extendMatch) refs.push(extendMatch[1].trim())
|
||||
const includeRegex = /\{\%\s*include\s+["']([^"']+)["']\s*\%\}/g
|
||||
let m
|
||||
while ((m = includeRegex.exec(content)) !== null) refs.push(m[1].trim())
|
||||
const importRegex = /\{\%\s*import\s+["']([^"']+)["']\s+as\s+\w+\s*\%\}/g
|
||||
while ((m = importRegex.exec(content)) !== null) refs.push(m[1].trim())
|
||||
return refs
|
||||
}
|
||||
const addConfigRefs = (nodeId: string, visited: Set<string>) => {
|
||||
if (visited.has(nodeId)) return
|
||||
const node = nodes.find((n: any) => n.id === nodeId && n.type === 'config')
|
||||
if (!node) return
|
||||
visited.add(nodeId)
|
||||
out.add(nodeId)
|
||||
const content = getConfigContent(node.data)
|
||||
for (const ref of getTemplateRefs(content)) {
|
||||
const refId = resolveRef(ref)
|
||||
if (refId && nodes.some((n: any) => n.id === refId && n.type === 'config') && isReachable(refId, id))
|
||||
addConfigRefs(refId, visited)
|
||||
}
|
||||
}
|
||||
const configVisited = new Set<string>()
|
||||
for (const nid of incomingIds) {
|
||||
const node = nodes.find((n: any) => n.id === nid)
|
||||
if (node?.type === 'config') addConfigRefs(nid, configVisited)
|
||||
else out.add(nid)
|
||||
}
|
||||
for (const e of edges) {
|
||||
if (out.has(e.target)) out.add(e.source)
|
||||
}
|
||||
return out
|
||||
}, [nodes, edges, id, incomingIds])
|
||||
|
||||
const configSignature = useMemo(
|
||||
() =>
|
||||
nodes
|
||||
.filter((n: any) => n.type === 'config' && connectedNodeIds.has(n.id))
|
||||
.map((n: any) => `${n.id}:${n.data?.title ?? ''}:${getConfigContent(n.data)}`)
|
||||
.sort()
|
||||
.join('|'),
|
||||
[nodes, connectedNodeIds]
|
||||
)
|
||||
|
||||
const edgesSignature = useMemo(
|
||||
() =>
|
||||
edges
|
||||
.filter((e: any) => connectedNodeIds.has(e.source) && (connectedNodeIds.has(e.target) || e.target === id))
|
||||
.map((e: any) => `${e.source}->${e.target}`)
|
||||
.sort()
|
||||
.join('|'),
|
||||
[edges, connectedNodeIds, id]
|
||||
)
|
||||
|
||||
const variablesSignature = useMemo(
|
||||
() =>
|
||||
nodes
|
||||
.filter((n: any) => n.type === 'variable' && connectedNodeIds.has(n.id))
|
||||
.map((n: any) => `${n.id}:${n.data?.value}`)
|
||||
.sort()
|
||||
.join('|'),
|
||||
[nodes, connectedNodeIds]
|
||||
)
|
||||
|
||||
const functionsSignature = useMemo(
|
||||
() =>
|
||||
nodes
|
||||
.filter((n: any) => n.type === 'function' && connectedNodeIds.has(n.id))
|
||||
.map((n: any) => `${n.id}:${n.data?.body ?? ''}`)
|
||||
.sort()
|
||||
.join('|'),
|
||||
[nodes, connectedNodeIds]
|
||||
)
|
||||
|
||||
const RENDER_DEBOUNCE_MS = 250
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourceContent && incomingIds.length > 0) {
|
||||
setRenderedContent(null)
|
||||
setError({ kind: 'no-content', message: 'No content on connected configuration node' })
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
if (incomingIds.length === 0) {
|
||||
setRenderedContent(null)
|
||||
setError(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
runIdRef.current += 1
|
||||
const thisRunId = runIdRef.current
|
||||
let cancelled = false
|
||||
|
||||
const run = async () => {
|
||||
loadingStartedAtRef.current = Date.now()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const configIdsUsed = new Set<string>()
|
||||
|
||||
const isReachable = (startId: string, targetId: string) => {
|
||||
const q: string[] = [startId]
|
||||
const seen = new Set<string>([startId])
|
||||
while (q.length) {
|
||||
const cur = q.shift()!
|
||||
if (cur === targetId) return true
|
||||
for (const e of edges) {
|
||||
if (e.source === cur && !seen.has(e.target)) {
|
||||
seen.add(e.target)
|
||||
q.push(e.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const resolveExtendsRef = (name: string): string => {
|
||||
const refName = name.replace(/\.(puml|html)$/, '').trim()
|
||||
return nodes.find((n: any) => n.id === refName || n.data?.title === refName)?.id ?? refName
|
||||
}
|
||||
|
||||
/** Collect refs from {% extends %}, {% include %}, {% import %} in template content */
|
||||
const getTemplateRefs = (content: string): string[] => {
|
||||
const refs: string[] = []
|
||||
const extendMatch = content.match(/\{\%\s*extends\s+["']([^"']+)["']\s*\%\}/)
|
||||
if (extendMatch) refs.push(extendMatch[1].trim())
|
||||
const includeRegex = /\{\%\s*include\s+["']([^"']+)["']\s*\%\}/g
|
||||
let m
|
||||
while ((m = includeRegex.exec(content)) !== null) refs.push(m[1].trim())
|
||||
const importRegex = /\{\%\s*import\s+["']([^"']+)["']\s+as\s+\w+\s*\%\}/g
|
||||
while ((m = importRegex.exec(content)) !== null) refs.push(m[1].trim())
|
||||
return refs
|
||||
}
|
||||
|
||||
const addConfigAndRefs = (templateName: string, visited = new Set<string>()) => {
|
||||
const refId = resolveExtendsRef(templateName)
|
||||
if (visited.has(refId)) throw new Error(`Circular reference detected: ${templateName}`)
|
||||
const node = nodes.find((n: any) => n.id === refId && n.type === 'config')
|
||||
if (!node) throw new Error(`Config not found: ${templateName}`)
|
||||
if (refId !== srcId && !isReachable(refId, id))
|
||||
throw new Error(`Referenced config not connected to renderer: ${templateName}`)
|
||||
visited.add(refId)
|
||||
configIdsUsed.add(refId)
|
||||
const content = getConfigContent(node.data)
|
||||
for (const ref of getTemplateRefs(content)) addConfigAndRefs(ref, visited)
|
||||
}
|
||||
|
||||
if (srcId && srcNode?.type === 'config') addConfigAndRefs(srcId)
|
||||
|
||||
// Loader for Nunjucks {% extends %}, {% include %}, {% import %}: resolve template name to config node's plantuml
|
||||
const configLoader = {
|
||||
getSource: (name: string): { src: string; path: string } | null => {
|
||||
const refId = resolveExtendsRef(name)
|
||||
const node = nodes.find((n: any) => n.id === refId && n.type === 'config')
|
||||
if (!node) return null
|
||||
if (refId !== srcId && !isReachable(refId, id))
|
||||
throw new Error(`Referenced config not connected to renderer: ${name}`)
|
||||
return {
|
||||
src: getConfigContent(node.data),
|
||||
path: name,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Context: variables connected to configs, plus variables connected to functions that feed configs (so they can be injected as constants).
|
||||
const nunjucksContext = Object.create(null) as Record<string, unknown>
|
||||
const setVarInContext = (src: any) => {
|
||||
const v = src.data?.value
|
||||
const str = v === undefined || v === null ? '' : String(v)
|
||||
nunjucksContext[src.id] =
|
||||
v === undefined || v === null ? '' : typeof v === 'boolean' || typeof v === 'number' ? v : str
|
||||
}
|
||||
for (const e of edges) {
|
||||
if (!configIdsUsed.has(e.target)) continue
|
||||
const src = nodes.find((n: any) => n.id === e.source)
|
||||
if (src?.type === 'variable') setVarInContext(src)
|
||||
}
|
||||
// All function node ids that feed (directly or transitively) into config — need to register them and collect their variables
|
||||
const functionIdsToRegister = new Set<string>()
|
||||
let added = true
|
||||
while (added) {
|
||||
added = false
|
||||
for (const e of edges) {
|
||||
const src = nodes.find((n: any) => n.id === e.source)
|
||||
if (src?.type !== 'function') continue
|
||||
const targetInScope = configIdsUsed.has(e.target) || functionIdsToRegister.has(e.target)
|
||||
if (!targetInScope) continue
|
||||
if (!functionIdsToRegister.has(src.id)) {
|
||||
functionIdsToRegister.add(src.id)
|
||||
added = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const e of edges) {
|
||||
if (!configIdsUsed.has(e.target) && !functionIdsToRegister.has(e.target)) continue
|
||||
const src = nodes.find((n: any) => n.id === e.source)
|
||||
if (src?.type === 'function') {
|
||||
for (const e2 of edges) {
|
||||
if (e2.target !== src.id) continue
|
||||
const vNode = nodes.find((n: any) => n.id === e2.source)
|
||||
if (vNode?.type === 'variable') setVarInContext(vNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const env = new nunjucks.Environment([configLoader], { autoescape: false })
|
||||
|
||||
// Register each connected function node as a Nunjucks custom filter (async so sync and async user code both work).
|
||||
// Supports either named params: function(num, x, y, kwargs) { return num + (kwargs.bar || 10); } or legacy: args array.
|
||||
const formatFilterResult = (r: unknown): string => {
|
||||
if (r === undefined || r === null) return ''
|
||||
if (typeof r === 'string' || typeof r === 'number' || typeof r === 'boolean') return String(r)
|
||||
return String(r)
|
||||
}
|
||||
/** Parse function(num, x, y, kwargs) { body } or (num, x, y, kwargs) => body to get param names and inner body. */
|
||||
const parseFunctionSignature = (body: string): { paramNames: string[]; innerBody: string } | null => {
|
||||
const withCommentsStripped = body.replace(/^\s*\/\/[^\n]*\n?/gm, '').trim()
|
||||
const trimmed = withCommentsStripped.trim()
|
||||
const fnMatch = trimmed.match(/^function\s*\(([^)]*)\)\s*\{([\s\S]*)\}\s*$/)
|
||||
if (fnMatch) {
|
||||
const paramNames = fnMatch[1].split(',').map((p) => p.trim()).filter(Boolean)
|
||||
return { paramNames, innerBody: fnMatch[2].trim() }
|
||||
}
|
||||
const arrowBlockMatch = trimmed.match(/^\(([^)]*)\)\s*=>\s*\{([\s\S]*)\}\s*$/)
|
||||
if (arrowBlockMatch) {
|
||||
const paramNames = arrowBlockMatch[1].split(',').map((p) => p.trim()).filter(Boolean)
|
||||
return { paramNames, innerBody: arrowBlockMatch[2].trim() }
|
||||
}
|
||||
const arrowExprMatch = trimmed.match(/^\(([^)]*)\)\s*=>\s*(.+)\s*$/s)
|
||||
if (arrowExprMatch) {
|
||||
const paramNames = arrowExprMatch[1].split(',').map((p) => p.trim()).filter(Boolean)
|
||||
return { paramNames, innerBody: 'return ' + arrowExprMatch[2].trim() }
|
||||
}
|
||||
return null
|
||||
}
|
||||
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === 'object' && v !== null && !Array.isArray(v)
|
||||
|
||||
// For each registered function: which variable/function node ids are connected to it?
|
||||
const functionConnectedVariableIds = Object.create(null) as Record<string, string[]>
|
||||
const functionConnectedFunctionIds = Object.create(null) as Record<string, string[]>
|
||||
for (const fid of functionIdsToRegister) {
|
||||
for (const e of edges) {
|
||||
if (e.target !== fid) continue
|
||||
const src = nodes.find((n: any) => n.id === e.source)
|
||||
if (src?.type === 'variable') {
|
||||
if (!functionConnectedVariableIds[fid]) functionConnectedVariableIds[fid] = []
|
||||
functionConnectedVariableIds[fid].push(src.id)
|
||||
} else if (src?.type === 'function') {
|
||||
if (!functionConnectedFunctionIds[fid]) functionConnectedFunctionIds[fid] = []
|
||||
functionConnectedFunctionIds[fid].push(src.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const fid of functionIdsToRegister) {
|
||||
const src = nodes.find((n: any) => n.id === fid)
|
||||
if (!src || src.type !== 'function') continue
|
||||
const body = src.data?.body ?? 'return args[0];'
|
||||
const parsed = parseFunctionSignature(body)
|
||||
const connectedVarIds = new Set<string>(functionConnectedVariableIds[fid] ?? [])
|
||||
const connectedFuncIds = functionConnectedFunctionIds[fid] ?? []
|
||||
env.addFilter(
|
||||
src.id,
|
||||
(value: unknown, ...args: unknown[]) => {
|
||||
const callback = args[args.length - 1] as (err: Error | null, res: string) => void
|
||||
const raw = [value, ...args.slice(0, -1)]
|
||||
const hasKwargs = raw.length > 0 && isPlainObject(raw[raw.length - 1])
|
||||
const positionals = hasKwargs ? raw.slice(0, -1) : raw
|
||||
const kwargs = hasKwargs ? (raw[raw.length - 1] as Record<string, unknown>) : Object.create(null)
|
||||
|
||||
// Cache for nested filter results so sync-looking code like `num + fn_003(4)` works:
|
||||
// callable throws Suspend when result isn't ready; we await, cache, then re-run.
|
||||
// Cached values are strings (Nunjucks); coerce to number when numeric so 2 + fn_003(4) => 10 not "28".
|
||||
const nestedCache = new Map<string, string>()
|
||||
const coerceCached = (s: string): string | number => {
|
||||
const n = Number(s)
|
||||
return s.trim() !== '' && !Number.isNaN(n) ? n : s
|
||||
}
|
||||
const makeCallable = (filterId: string) => (input: unknown) => {
|
||||
const key = `${filterId}::${JSON.stringify(input)}`
|
||||
if (nestedCache.has(key)) return coerceCached(nestedCache.get(key)!)
|
||||
const p = new Promise<string>((resolve, reject) => {
|
||||
env.getFilter(filterId)(input, (err: Error | null, res: string) =>
|
||||
err ? reject(err) : resolve(res)
|
||||
)
|
||||
})
|
||||
p.then((res) => nestedCache.set(key, res))
|
||||
const suspend = { __suspend: true as const, promise: p, key }
|
||||
throw suspend
|
||||
}
|
||||
|
||||
let invoke: () => unknown
|
||||
if (parsed) {
|
||||
const { paramNames, innerBody } = parsed
|
||||
const lastParam = paramNames[paramNames.length - 1]
|
||||
const invocationArgs = paramNames.map((name, i) => {
|
||||
if (name === lastParam && lastParam === 'kwargs') return kwargs
|
||||
if (connectedVarIds.has(name) && name in nunjucksContext)
|
||||
return nunjucksContext[name]
|
||||
if (connectedFuncIds.includes(name)) return makeCallable(name)
|
||||
return positionals[i]
|
||||
})
|
||||
const extraVarIds = [...connectedVarIds].filter((vid) => !paramNames.includes(vid))
|
||||
const extraFuncIds = connectedFuncIds.filter((fid2) => !paramNames.includes(fid2))
|
||||
const allParamNames = [...paramNames, ...extraVarIds, ...extraFuncIds]
|
||||
const allArgs = [
|
||||
...invocationArgs,
|
||||
...extraVarIds.map((vid) => nunjucksContext[vid]),
|
||||
...extraFuncIds.map((fid2) => makeCallable(fid2)),
|
||||
]
|
||||
const fn = new Function(...allParamNames, innerBody)
|
||||
invoke = () => fn(...allArgs)
|
||||
} else {
|
||||
const fn = new Function('args', body)
|
||||
invoke = () => fn(positionals)
|
||||
}
|
||||
|
||||
const done = (err: Error | null, res: string) => {
|
||||
callback(err, res)
|
||||
}
|
||||
const runInvoke = () => {
|
||||
try {
|
||||
const result = invoke()
|
||||
if (result != null && typeof (result as Promise<unknown>).then === 'function') {
|
||||
(result as Promise<unknown>).then(
|
||||
(r) => done(null, formatFilterResult(r)),
|
||||
(err) => done(err instanceof Error ? err : new Error(String(err)), '')
|
||||
)
|
||||
} else {
|
||||
done(null, formatFilterResult(result))
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const s = e as { __suspend?: boolean; promise?: Promise<string>; key?: string }
|
||||
if (s?.__suspend && s.promise) {
|
||||
s.promise.then(() => runInvoke(), (err) =>
|
||||
done(err instanceof Error ? err : new Error(String(err)), '')
|
||||
)
|
||||
} else {
|
||||
done(e instanceof Error ? e : new Error(String(e)), '')
|
||||
}
|
||||
}
|
||||
}
|
||||
runInvoke()
|
||||
},
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
env.render(srcId!, nunjucksContext, async (nunjucksErr: Error | null, afterNunjucks: string) => {
|
||||
if (cancelled || thisRunId !== runIdRef.current) return
|
||||
if (nunjucksErr) {
|
||||
setSvgContent(null)
|
||||
setError({ kind: 'render', message: `Nunjucks: ${nunjucksErr.message}` })
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const resolvedContent = afterNunjucks
|
||||
const typeRenderer = getConfigType(configTypeId)
|
||||
|
||||
try {
|
||||
const renderOptions = configTypeId === 'wireframe' ? { width: viewportWidth, height: viewportHeight } : undefined
|
||||
const htmlOrSvg = await typeRenderer.render(resolvedContent, renderOptions)
|
||||
if (cancelled || thisRunId !== runIdRef.current) return
|
||||
setRenderedContent(htmlOrSvg)
|
||||
setError(null)
|
||||
} catch (err: any) {
|
||||
if (cancelled || thisRunId !== runIdRef.current) return
|
||||
const msg = err?.message ?? 'Render error'
|
||||
setRenderedContent(null)
|
||||
setError({ kind: 'render', message: msg })
|
||||
} finally {
|
||||
if (!cancelled && thisRunId === runIdRef.current) {
|
||||
const startedAt = loadingStartedAtRef.current ?? 0
|
||||
const elapsed = Date.now() - startedAt
|
||||
const remaining = Math.max(0, 1000 - elapsed)
|
||||
if (remaining > 0) {
|
||||
minLoadingTimeoutRef.current = setTimeout(() => {
|
||||
minLoadingTimeoutRef.current = null
|
||||
if (!cancelled && thisRunId === runIdRef.current) setLoading(false)
|
||||
}, remaining)
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (err: any) {
|
||||
if (!cancelled && thisRunId === runIdRef.current) {
|
||||
setRenderedContent(null)
|
||||
setError({ kind: 'render', message: err?.message ?? 'Render error' })
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const debounceTimer = setTimeout(run, RENDER_DEBOUNCE_MS)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(debounceTimer)
|
||||
if (minLoadingTimeoutRef.current != null) {
|
||||
clearTimeout(minLoadingTimeoutRef.current)
|
||||
minLoadingTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
// 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, viewportWidth, viewportHeight, retryCount])
|
||||
|
||||
const dimensions =
|
||||
width != null && height != null && width > 0 && height > 0
|
||||
? { width, height }
|
||||
: undefined
|
||||
|
||||
// Kroki and other SVG sources may prepend <?xml ... ?> so we detect by presence of <svg> tag
|
||||
const isSvgOutput = Boolean(renderedContent?.trim() && /<svg[\s>]/i.test(renderedContent.trim()))
|
||||
|
||||
const downloadSvg = useCallback(() => {
|
||||
if (!renderedContent || !isSvgOutput) return
|
||||
const blob = new Blob([renderedContent], { type: 'image/svg+xml' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${id}.svg`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}, [id, renderedContent, isSvgOutput])
|
||||
|
||||
const downloadPng = useCallback(() => {
|
||||
if (!renderedContent || !isSvgOutput) return
|
||||
const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(renderedContent)
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = img.naturalWidth
|
||||
canvas.height = img.naturalHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.drawImage(img, 0, 0)
|
||||
const pngUrl = canvas.toDataURL('image/png')
|
||||
const a = document.createElement('a')
|
||||
a.href = pngUrl
|
||||
a.download = `${id}.png`
|
||||
a.click()
|
||||
}
|
||||
img.onerror = () => { }
|
||||
img.src = dataUrl
|
||||
}, [id, renderedContent, isSvgOutput])
|
||||
|
||||
const copyPng = useCallback(() => {
|
||||
if (!renderedContent || !isSvgOutput) return
|
||||
const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(renderedContent)
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = img.naturalWidth
|
||||
canvas.height = img.naturalHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.drawImage(img, 0, 0)
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) navigator.clipboard?.write([new ClipboardItem({ 'image/png': blob })]).catch(() => {})
|
||||
}, 'image/png')
|
||||
}
|
||||
img.onerror = () => {}
|
||||
img.src = dataUrl
|
||||
}, [renderedContent, isSvgOutput])
|
||||
|
||||
const copySvg = useCallback(() => {
|
||||
if (!renderedContent || !isSvgOutput) return
|
||||
const blob = new Blob([renderedContent], { type: 'image/svg+xml' })
|
||||
navigator.clipboard?.write([new ClipboardItem({ 'image/svg+xml': blob })]).catch(() => {})
|
||||
}, [renderedContent, isSvgOutput])
|
||||
|
||||
const status = loading ? 'loading' : error ? 'error' : renderedContent ? 'success' : 'initial'
|
||||
|
||||
const [viewportDraft, setViewportDraft] = useState({ width: viewportWidth, height: viewportHeight })
|
||||
useEffect(() => {
|
||||
setViewportDraft({ width: viewportWidth, height: viewportHeight })
|
||||
}, [viewportWidth, viewportHeight])
|
||||
|
||||
const onViewportDraftChange = useCallback((field: 'width' | 'height', value: number) => {
|
||||
setViewportDraft((prev) => ({ ...prev, [field]: Math.min(4000, Math.max(200, value)) }))
|
||||
}, [])
|
||||
const onViewportApply = useCallback(() => {
|
||||
updateData({ viewportWidth: viewportDraft.width, viewportHeight: viewportDraft.height })
|
||||
}, [updateData, viewportDraft.width, viewportDraft.height])
|
||||
|
||||
return (
|
||||
<NodeStatusIndicator status={status} variant="border" 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" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
|
||||
|
||||
<BaseNodeContent>
|
||||
<div className="shrink-0 w-full">
|
||||
<NodeMenubar
|
||||
nodeId={id}
|
||||
nodeType="render"
|
||||
nodeMenuExtraContent={
|
||||
<>
|
||||
{isSvgOutput && (
|
||||
<MenubarSub>
|
||||
<MenubarSubTrigger className="text-xs">Viewport</MenubarSubTrigger>
|
||||
<MenubarSubContent className="min-w-[12rem] p-2">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground shrink-0">Width</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={200}
|
||||
max={4000}
|
||||
value={viewportDraft.width}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10)
|
||||
if (!Number.isNaN(v)) onViewportDraftChange('width', v)
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground shrink-0">Height</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={200}
|
||||
max={4000}
|
||||
value={viewportDraft.height}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10)
|
||||
if (!Number.isNaN(v)) onViewportDraftChange('height', v)
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onViewportApply}
|
||||
className="mt-1 w-full rounded bg-primary px-2 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</MenubarSubContent>
|
||||
</MenubarSub>
|
||||
)}
|
||||
<MenubarSub>
|
||||
<MenubarSeparator />
|
||||
<MenubarSubTrigger className="text-xs" disabled={!isSvgOutput}>
|
||||
Export / Copy
|
||||
</MenubarSubTrigger>
|
||||
<MenubarSubContent className="min-w-[10rem]" aria-label="Export or copy diagram">
|
||||
<MenubarItem className="text-xs" onClick={downloadSvg} disabled={!isSvgOutput}>
|
||||
Download SVG
|
||||
</MenubarItem>
|
||||
<MenubarItem className="text-xs" onClick={downloadPng} disabled={!isSvgOutput}>
|
||||
Download PNG
|
||||
</MenubarItem>
|
||||
<MenubarItem className="text-xs" onClick={copySvg} disabled={!isSvgOutput}>
|
||||
Copy SVG
|
||||
</MenubarItem>
|
||||
<MenubarItem className="text-xs" onClick={copyPng} disabled={!isSvgOutput}>
|
||||
Copy image
|
||||
</MenubarItem>
|
||||
</MenubarSubContent>
|
||||
</MenubarSub>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 flex flex-col">
|
||||
{incomingIds.length === 0 ? (
|
||||
<Empty className="min-h-0 flex-1">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Sparkles className="size-6" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No configuration connected</EmptyTitle>
|
||||
<EmptyDescription>Connect a Configuration node or create one. The renderer will display the diagram or document.</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<button
|
||||
className="inline-flex items-center rounded bg-primary px-3 py-1 text-xs text-primary-foreground"
|
||||
onClick={() => {
|
||||
if (!setNodes || !setEdges) return
|
||||
const nid = getNextNodeId('config', nodes.map((n: any) => n.id))
|
||||
const thisNode = nodes.find((n: any) => n.id === id)
|
||||
const pos = thisNode?.position ?? { x: 0, y: 0 }
|
||||
const newPos = { x: pos.x - 220, y: pos.y }
|
||||
const newNode = { id: nid, type: 'config', position: newPos, data: getDefaultDataForType('config', nid), style: getDefaultStyle('config') }
|
||||
setNodes((nds: any[]) => nds.concat(newNode))
|
||||
setEdges((eds: any[]) => eds.concat({ id: `e-${nid}-${id}`, source: nid, target: id }))
|
||||
}}
|
||||
>
|
||||
Create Config
|
||||
</button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : error ? (
|
||||
srcData?.renderError ? (
|
||||
srcData.renderError(error)
|
||||
) : srcData?.errorHtml ? (
|
||||
<div className="p-3 text-xs text-red-700 dark:text-red-400" dangerouslySetInnerHTML={{ __html: String(srcData.errorHtml) }} />
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<p className="text-xs text-red-700 dark:text-red-400">{error.message}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => setRetryCount((c) => c + 1)}
|
||||
>
|
||||
<RotateCw className="size-3 mr-1" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
) : loading ? (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering…</div>
|
||||
) : renderedContent ? (
|
||||
isSvgOutput ? (
|
||||
<div className="rendering-viewport nodrag nopan relative min-h-0 flex-1 w-full min-w-0 overflow-hidden bg-white dark:bg-secondary">
|
||||
<TransformWrapper
|
||||
initialScale={1}
|
||||
minScale={0.2}
|
||||
maxScale={4}
|
||||
centerOnInit
|
||||
onInit={(ref) => ref?.centerView(1, 0, 0)}
|
||||
panning={{ disabled: true }}
|
||||
wheel={{ disabled: true }}
|
||||
doubleClick={{ disabled: true }}
|
||||
>
|
||||
{({ zoomIn, zoomOut, resetTransform }) => (
|
||||
<>
|
||||
<div className="react-flow__controls absolute bottom-2 left-2 z-10 nodrag nopan">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomIn()}
|
||||
className="react-flow__controls-button"
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomIn className="size-3 max-w-[12px] max-h-[12px]" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomOut()}
|
||||
className="react-flow__controls-button"
|
||||
title="Zoom out"
|
||||
>
|
||||
<ZoomOut className="size-3 max-w-[12px] max-h-[12px]" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetTransform()}
|
||||
className="react-flow__controls-button"
|
||||
title="Reset view (fit all)"
|
||||
>
|
||||
<RotateCcw className="size-3 max-w-[12px] max-h-[12px]" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute inset-0 nodrag nopan">
|
||||
<TransformComponent
|
||||
wrapperClass="!w-full !h-full"
|
||||
contentClass="!w-full !h-full flex items-center justify-center nodrag nopan"
|
||||
>
|
||||
<div
|
||||
className="rendering-diagram flex items-center justify-center min-h-full min-w-full p-4 nodrag nopan"
|
||||
dangerouslySetInnerHTML={{ __html: renderedContent }}
|
||||
/>
|
||||
</TransformComponent>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TransformWrapper>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="rendering-markdown min-h-0 flex-1 w-full overflow-auto p-3 text-sm [&_h1]:text-xl [&_h2]:text-lg [&_h3]:text-base [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-2 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded"
|
||||
dangerouslySetInnerHTML={{ __html: renderedContent }}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</BaseNodeContent>
|
||||
|
||||
<BaseNodeFooter>
|
||||
<NodeFooterEdgeIndicators nodeId={id} nodeType="render">
|
||||
{renderedContent
|
||||
? `${getConfigType(configTypeId).label} · ${renderedContent.length} chars`
|
||||
: error
|
||||
? 'Error'
|
||||
: '—'}
|
||||
</NodeFooterEdgeIndicators>
|
||||
</BaseNodeFooter>
|
||||
</BaseNode>
|
||||
</NodeStatusIndicator>
|
||||
)
|
||||
}
|
||||
|
||||
export const RenderingNode = createAbstractNodeComponent<RenderingNodeData>(
|
||||
'RenderingNode',
|
||||
RenderingNodeComponent
|
||||
)
|
||||
|
||||
export default RenderingNode
|
||||
138
frontend/src/components/nodes/VariableNode.tsx
Normal file
138
frontend/src/components/nodes/VariableNode.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import {
|
||||
AbstractNodeProps,
|
||||
createAbstractNodeComponent,
|
||||
useAbstractNode,
|
||||
} from '../../lib/abstractNode'
|
||||
import {
|
||||
BaseNode,
|
||||
BaseNodeContent,
|
||||
BaseNodeFooter,
|
||||
BaseNodeHeaderRow,
|
||||
} from '../base/BaseNode'
|
||||
import { NodeMenubar } from '../base/NodeMenubar'
|
||||
import { Input } from '../ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { Switch } from '../ui/switch'
|
||||
import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
|
||||
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
|
||||
import { OutputHandle } from '../base/NodeHandles'
|
||||
import { Variable } from 'lucide-react'
|
||||
|
||||
export type ValueType = 'string' | 'number' | 'boolean'
|
||||
|
||||
export type VariableNodeData = {
|
||||
value?: string | number | boolean
|
||||
valueType?: ValueType
|
||||
}
|
||||
|
||||
type Props = AbstractNodeProps<VariableNodeData>
|
||||
|
||||
const DEFAULT_BY_TYPE: Record<ValueType, string | number | boolean> = {
|
||||
string: '',
|
||||
number: 0,
|
||||
boolean: false,
|
||||
}
|
||||
|
||||
function coerceValue(raw: string, valueType: ValueType): string | number | boolean {
|
||||
switch (valueType) {
|
||||
case 'number': {
|
||||
const n = Number(raw)
|
||||
return Number.isNaN(n) ? 0 : n
|
||||
}
|
||||
case 'boolean':
|
||||
return /^(1|true|yes|on)$/i.test(raw.trim())
|
||||
default:
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
function VariableNodeComponent({ id, data, selected }: Props) {
|
||||
const { updateData } = useAbstractNode<VariableNodeData>(id, data ?? {})
|
||||
|
||||
const valueType: ValueType = data?.valueType ?? 'string'
|
||||
const value = data?.value ?? DEFAULT_BY_TYPE[valueType]
|
||||
const displayValue = typeof value === 'string' ? value : String(value)
|
||||
|
||||
const onTypeChange = useCallback(
|
||||
(nextType: string) => {
|
||||
const type = nextType as ValueType
|
||||
const raw = typeof value === 'boolean' ? (value ? 'true' : 'false') : String(value)
|
||||
const nextValue = coerceValue(raw, type)
|
||||
updateData({ valueType: type, value: nextValue })
|
||||
},
|
||||
[value, updateData]
|
||||
)
|
||||
|
||||
const onValueChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const raw = e.target.value
|
||||
const nextValue = coerceValue(raw, valueType)
|
||||
updateData({ value: nextValue })
|
||||
},
|
||||
[valueType, updateData]
|
||||
)
|
||||
|
||||
const onBooleanChange = useCallback(
|
||||
(checked: boolean) => updateData({ value: checked }),
|
||||
[updateData]
|
||||
)
|
||||
|
||||
return (
|
||||
<BaseNode className="min-w-56 min-h-[180px]" selected={selected} handles={<OutputHandle id="out" />}>
|
||||
<BaseNodeHeaderRow icon={<Variable className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
|
||||
|
||||
<BaseNodeContent>
|
||||
<div className="shrink-0 w-full">
|
||||
<NodeMenubar nodeId={id} nodeType="variable" />
|
||||
</div>
|
||||
<div className="flex flex-col p-2 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Type</label>
|
||||
<Select value={valueType} onValueChange={onTypeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string">String</SelectItem>
|
||||
<SelectItem value="number">Number</SelectItem>
|
||||
<SelectItem value="boolean">Boolean</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Value</label>
|
||||
{valueType === 'boolean' ? (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Switch
|
||||
checked={value === true}
|
||||
onCheckedChange={onBooleanChange}
|
||||
/>
|
||||
<span className="text-muted-foreground">{value ? 'true' : 'false'}</span>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
type={valueType === 'number' ? 'number' : 'text'}
|
||||
value={valueType === 'number' ? (value as number) : displayValue}
|
||||
onChange={onValueChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</BaseNodeContent>
|
||||
|
||||
<BaseNodeFooter>
|
||||
<NodeFooterEdgeIndicators nodeId={id} nodeType="variable">
|
||||
{`${valueType.charAt(0).toUpperCase() + valueType.slice(1)} · ${displayValue.length ? `${displayValue.length} chars` : 'none'}`}
|
||||
</NodeFooterEdgeIndicators>
|
||||
</BaseNodeFooter>
|
||||
</BaseNode>
|
||||
)
|
||||
}
|
||||
|
||||
export const VariableNode = createAbstractNodeComponent<VariableNodeData>(
|
||||
'VariableNode',
|
||||
VariableNodeComponent
|
||||
)
|
||||
|
||||
export default VariableNode
|
||||
83
frontend/src/components/ui/button-group.tsx
Normal file
83
frontend/src/components/ui/button-group.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
||||
vertical:
|
||||
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"bg-muted shadow-xs flex items-center gap-2 rounded-md border px-4 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
}
|
||||
57
frontend/src/components/ui/button.tsx
Normal file
57
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
198
frontend/src/components/ui/context-menu.tsx
Normal file
198
frontend/src/components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import * as React from "react"
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ContextMenu = ContextMenuPrimitive.Root
|
||||
|
||||
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
|
||||
|
||||
const ContextMenuGroup = ContextMenuPrimitive.Group
|
||||
|
||||
const ContextMenuPortal = ContextMenuPrimitive.Portal
|
||||
|
||||
const ContextMenuSub = ContextMenuPrimitive.Sub
|
||||
|
||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
|
||||
|
||||
const ContextMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
))
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const ContextMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
|
||||
|
||||
const ContextMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
))
|
||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
|
||||
|
||||
const ContextMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
ContextMenuCheckboxItem.displayName =
|
||||
ContextMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-4 w-4 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
))
|
||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const ContextMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
|
||||
|
||||
const ContextMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
ContextMenuShortcut.displayName = "ContextMenuShortcut"
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
||||
104
frontend/src/components/ui/empty.tsx
Normal file
104
frontend/src/components/ui/empty.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty"
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 text-balance rounded-lg border-dashed p-6 text-center md:p-12",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
className={cn(
|
||||
"flex max-w-sm flex-col items-center gap-2 text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMediaVariants = cva(
|
||||
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function EmptyMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
data-variant={variant}
|
||||
className={cn(emptyMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
className={cn("text-lg font-medium tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-description"
|
||||
className={cn(
|
||||
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-content"
|
||||
className={cn(
|
||||
"flex w-full min-w-0 max-w-sm flex-col items-center gap-4 text-balance text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
EmptyMedia,
|
||||
}
|
||||
22
frontend/src/components/ui/input.tsx
Normal file
22
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
28
frontend/src/components/ui/kbd.tsx
Normal file
28
frontend/src/components/ui/kbd.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 select-none items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium",
|
||||
"[&_svg:not([class*='size-'])]:size-3",
|
||||
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd-group"
|
||||
className={cn("inline-flex items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup }
|
||||
254
frontend/src/components/ui/menubar.tsx
Normal file
254
frontend/src/components/ui/menubar.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import * as React from "react"
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function MenubarMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
||||
return <MenubarPrimitive.Menu {...props} />
|
||||
}
|
||||
|
||||
function MenubarGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
||||
return <MenubarPrimitive.Group {...props} />
|
||||
}
|
||||
|
||||
function MenubarPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
||||
return <MenubarPrimitive.Portal {...props} />
|
||||
}
|
||||
|
||||
function MenubarRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
||||
return <MenubarPrimitive.RadioGroup {...props} />
|
||||
}
|
||||
|
||||
function MenubarSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
||||
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
|
||||
}
|
||||
|
||||
const Menubar = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Menubar.displayName = MenubarPrimitive.Root.displayName
|
||||
|
||||
const MenubarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
|
||||
|
||||
const MenubarSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
))
|
||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
|
||||
|
||||
const MenubarSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-menubar-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
|
||||
|
||||
const MenubarContent = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
|
||||
>(
|
||||
(
|
||||
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
|
||||
ref
|
||||
) => (
|
||||
<MenubarPrimitive.Portal>
|
||||
<MenubarPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-menubar-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPrimitive.Portal>
|
||||
)
|
||||
)
|
||||
MenubarContent.displayName = MenubarPrimitive.Content.displayName
|
||||
|
||||
const MenubarItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarItem.displayName = MenubarPrimitive.Item.displayName
|
||||
|
||||
const MenubarCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
))
|
||||
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
|
||||
|
||||
const MenubarRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Circle className="h-4 w-4 fill-current" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
))
|
||||
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
|
||||
|
||||
const MenubarLabel = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
|
||||
|
||||
const MenubarSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
|
||||
|
||||
const MenubarShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
MenubarShortcut.displayname = "MenubarShortcut"
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarPortal,
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
MenubarGroup,
|
||||
MenubarSub,
|
||||
MenubarShortcut,
|
||||
}
|
||||
31
frontend/src/components/ui/popover.tsx
Normal file
31
frontend/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
157
frontend/src/components/ui/select.tsx
Normal file
157
frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
29
frontend/src/components/ui/separator.tsx
Normal file
29
frontend/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
27
frontend/src/components/ui/switch.tsx
Normal file
27
frontend/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
117
frontend/src/hooks/useGraphStateWithHistory.ts
Normal file
117
frontend/src/hooks/useGraphStateWithHistory.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { AppNode, AppEdge } from '@/lib/nodeTypes'
|
||||
|
||||
export type GraphState = { nodes: AppNode[]; edges: AppEdge[] }
|
||||
|
||||
function cloneState(state: GraphState): GraphState {
|
||||
return {
|
||||
nodes: state.nodes.map((n) => ({ ...n, data: n.data && typeof n.data === 'object' ? { ...n.data } : n.data })),
|
||||
edges: state.edges.map((e) => ({ ...e })),
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_HISTORY = 100
|
||||
|
||||
export function useGraphStateWithHistory(initialNodes: AppNode[], initialEdges: AppEdge[]) {
|
||||
const [nodes, setNodesState] = useState<AppNode[]>(initialNodes)
|
||||
const [edges, setEdgesState] = useState<AppEdge[]>(initialEdges)
|
||||
const [historySizes, setHistorySizes] = useState({ past: 0, future: 0 })
|
||||
|
||||
const pastRef = useRef<GraphState[]>([])
|
||||
const futureRef = useRef<GraphState[]>([])
|
||||
const preDragRef = useRef<GraphState | null>(null)
|
||||
const nodesRef = useRef(nodes)
|
||||
const edgesRef = useRef(edges)
|
||||
nodesRef.current = nodes
|
||||
edgesRef.current = edges
|
||||
|
||||
const pushToPast = useCallback((state: GraphState) => {
|
||||
pastRef.current = pastRef.current.slice(-(MAX_HISTORY - 1))
|
||||
pastRef.current.push(cloneState(state))
|
||||
futureRef.current = []
|
||||
setHistorySizes({ past: pastRef.current.length, future: 0 })
|
||||
}, [])
|
||||
|
||||
const setNodes = useCallback((updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => {
|
||||
pushToPast({ nodes: nodesRef.current, edges: edgesRef.current })
|
||||
setNodesState(typeof updater === 'function' ? updater : () => updater)
|
||||
}, [pushToPast])
|
||||
|
||||
const setEdges = useCallback((updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => {
|
||||
pushToPast({ nodes: nodesRef.current, edges: edgesRef.current })
|
||||
setEdgesState(typeof updater === 'function' ? updater : () => updater)
|
||||
}, [pushToPast])
|
||||
|
||||
const setNodesSilent = useCallback((updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => {
|
||||
setNodesState(typeof updater === 'function' ? updater : () => updater)
|
||||
}, [])
|
||||
|
||||
const setEdgesSilent = useCallback((updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => {
|
||||
setEdgesState(typeof updater === 'function' ? updater : () => updater)
|
||||
}, [])
|
||||
|
||||
const applyGraph = useCallback((updater: (state: GraphState) => GraphState) => {
|
||||
pushToPast({ nodes: nodesRef.current, edges: edgesRef.current })
|
||||
const next = updater({ nodes: nodesRef.current, edges: edgesRef.current })
|
||||
setNodesState(next.nodes)
|
||||
setEdgesState(next.edges)
|
||||
}, [pushToPast])
|
||||
|
||||
const saveForDragEnd = useCallback(() => {
|
||||
preDragRef.current = cloneState({ nodes: nodesRef.current, edges: edgesRef.current })
|
||||
}, [])
|
||||
|
||||
const commitDragEnd = useCallback(() => {
|
||||
if (preDragRef.current) {
|
||||
pastRef.current = pastRef.current.slice(-(MAX_HISTORY - 1))
|
||||
pastRef.current.push(preDragRef.current)
|
||||
futureRef.current = []
|
||||
preDragRef.current = null
|
||||
setHistorySizes({ past: pastRef.current.length, future: 0 })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const undo = useCallback(() => {
|
||||
if (pastRef.current.length === 0) return
|
||||
const prev = pastRef.current.pop()!
|
||||
futureRef.current.push(cloneState({ nodes: nodesRef.current, edges: edgesRef.current }))
|
||||
setNodesState(prev.nodes)
|
||||
setEdgesState(prev.edges)
|
||||
setHistorySizes({ past: pastRef.current.length, future: futureRef.current.length })
|
||||
}, [])
|
||||
|
||||
const redo = useCallback(() => {
|
||||
if (futureRef.current.length === 0) return
|
||||
const next = futureRef.current.pop()!
|
||||
pastRef.current.push(cloneState({ nodes: nodesRef.current, edges: edgesRef.current }))
|
||||
setNodesState(next.nodes)
|
||||
setEdgesState(next.edges)
|
||||
setHistorySizes({ past: pastRef.current.length, future: futureRef.current.length })
|
||||
}, [])
|
||||
|
||||
const setStateImmediate = useCallback((state: GraphState) => {
|
||||
setNodesState(state.nodes)
|
||||
setEdgesState(state.edges)
|
||||
pastRef.current = []
|
||||
futureRef.current = []
|
||||
preDragRef.current = null
|
||||
setHistorySizes({ past: 0, future: 0 })
|
||||
}, [])
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
setNodes,
|
||||
setEdges,
|
||||
setNodesSilent,
|
||||
setEdgesSilent,
|
||||
applyGraph,
|
||||
saveForDragEnd,
|
||||
commitDragEnd,
|
||||
undo,
|
||||
redo,
|
||||
canUndo: historySizes.past > 0,
|
||||
canRedo: historySizes.future > 0,
|
||||
setStateImmediate,
|
||||
}
|
||||
}
|
||||
27
frontend/src/hooks/useResizeHeight.ts
Normal file
27
frontend/src/hooks/useResizeHeight.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Returns the height of the observed element, updating when it resizes (e.g. node resize).
|
||||
* Used to give CodeMirror and other components an explicit height that tracks their container.
|
||||
*/
|
||||
export function useResizeHeight(defaultHeight: number): [number, React.RefObject<HTMLDivElement | null>] {
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
const [height, setHeight] = useState(defaultHeight)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry?.contentRect.height != null && entry.contentRect.height > 0) {
|
||||
setHeight(entry.contentRect.height)
|
||||
}
|
||||
})
|
||||
ro.observe(el)
|
||||
setHeight(el.getBoundingClientRect().height)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
return [height, ref]
|
||||
}
|
||||
135
frontend/src/lib/abstractNode.ts
Normal file
135
frontend/src/lib/abstractNode.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Abstract node layer: shared types, hook, and factory for flow node components.
|
||||
*
|
||||
* - **AbstractNodeProps<TData>** — Typed props (id, data, width?, height?, selected?) for your node.
|
||||
* - **useAbstractNode(id, data)** — Flow context plus helpers: nodes, edges, setNodes, setEdges,
|
||||
* updateData(partial), incomingEdges, outgoingEdges, sourceIds, targetIds.
|
||||
* - **createAbstractNodeComponent(displayName, Component)** — Wraps with memo + nodePropsAreEqual.
|
||||
*
|
||||
* Example: define NodeData type, Props = AbstractNodeProps<NodeData>, use useAbstractNode in the
|
||||
* component, then export const MyNode = createAbstractNodeComponent('MyNode', MyNodeComponent).
|
||||
*/
|
||||
|
||||
import React, { useCallback, useContext, useMemo } from 'react'
|
||||
import FlowContext from './flowContext'
|
||||
import { nodePropsAreEqual } from './flowUtils'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Props passed by React Flow to custom node components. Extend data with your node's shape. */
|
||||
export type AbstractNodeProps<TData = Record<string, unknown>> = {
|
||||
id: string
|
||||
data: TData
|
||||
width?: number
|
||||
height?: number
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
/** Edge shape used in flow context (minimal for connection logic). */
|
||||
export type FlowEdge = { id: string; source: string; target: string; [k: string]: unknown }
|
||||
|
||||
/** Node shape used in flow context (minimal for reading graph). */
|
||||
export type FlowNode = { id: string; type?: string; data?: unknown; position?: { x: number; y: number }; [k: string]: unknown }
|
||||
|
||||
/** Result of useAbstractNode: flow context plus helpers scoped to this node. */
|
||||
export type AbstractNodeContext<TData = Record<string, unknown>> = {
|
||||
id: string
|
||||
data: TData
|
||||
nodes: FlowNode[]
|
||||
edges: FlowEdge[]
|
||||
setNodes: (updater: (nodes: FlowNode[]) => FlowNode[]) => void
|
||||
setEdges: (updater: (edges: FlowEdge[]) => FlowEdge[]) => void
|
||||
/** Merge partial data into this node's data. Stable reference. */
|
||||
updateData: (partial: Partial<TData>) => void
|
||||
/** Incoming edge IDs (edges whose target is this node). */
|
||||
incomingEdges: FlowEdge[]
|
||||
/** Outgoing edge IDs (edges whose source is this node). */
|
||||
outgoingEdges: FlowEdge[]
|
||||
/** Source node IDs connected to this node (incoming). */
|
||||
sourceIds: string[]
|
||||
/** Target node IDs this node connects to (outgoing). */
|
||||
targetIds: string[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Provides flow context and helpers for the current node. Use in any node component
|
||||
* that receives id and data; updateData(partial) merges into this node's data.
|
||||
*/
|
||||
export function useAbstractNode<TData = Record<string, unknown>>(
|
||||
id: string,
|
||||
data: TData
|
||||
): AbstractNodeContext<TData> {
|
||||
const ctx = useContext(FlowContext)
|
||||
const nodes = ctx?.nodes ?? []
|
||||
const edges = ctx?.edges ?? []
|
||||
const setNodes = ctx?.setNodes
|
||||
const setEdges = ctx?.setEdges
|
||||
|
||||
const updateData = useCallback(
|
||||
(partial: Partial<TData>) => {
|
||||
if (!setNodes) return
|
||||
setNodes((nds: FlowNode[]) =>
|
||||
nds.map((n) =>
|
||||
n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n
|
||||
)
|
||||
)
|
||||
},
|
||||
[id, setNodes]
|
||||
)
|
||||
|
||||
const incomingEdges = useMemo(
|
||||
() => (edges as FlowEdge[]).filter((e) => e.target === id),
|
||||
[edges, id]
|
||||
)
|
||||
const outgoingEdges = useMemo(
|
||||
() => (edges as FlowEdge[]).filter((e) => e.source === id),
|
||||
[edges, id]
|
||||
)
|
||||
const sourceIds = useMemo(
|
||||
() => incomingEdges.map((e) => e.source).sort(),
|
||||
[incomingEdges]
|
||||
)
|
||||
const targetIds = useMemo(
|
||||
() => outgoingEdges.map((e) => e.target).sort(),
|
||||
[outgoingEdges]
|
||||
)
|
||||
|
||||
return {
|
||||
id,
|
||||
data,
|
||||
nodes,
|
||||
edges,
|
||||
setNodes: setNodes ?? (() => {}),
|
||||
setEdges: setEdges ?? (() => {}),
|
||||
updateData,
|
||||
incomingEdges,
|
||||
outgoingEdges,
|
||||
sourceIds,
|
||||
targetIds,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wraps a node component with React.memo and nodePropsAreEqual so only id/data/width/height/selected
|
||||
* changes trigger re-renders. Use with AbstractNodeProps<TData> for typed props.
|
||||
*/
|
||||
export function createAbstractNodeComponent<TData = Record<string, unknown>>(
|
||||
displayName: string,
|
||||
Component: React.ComponentType<AbstractNodeProps<TData>>
|
||||
): React.MemoExoticComponent<React.ComponentType<AbstractNodeProps<TData>>> {
|
||||
const Wrapped = React.memo(Component, nodePropsAreEqual) as React.MemoExoticComponent<
|
||||
React.ComponentType<AbstractNodeProps<TData>>
|
||||
>
|
||||
Wrapped.displayName = displayName
|
||||
return Wrapped
|
||||
}
|
||||
232
frontend/src/lib/configTypes.ts
Normal file
232
frontend/src/lib/configTypes.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Registry of config node types. Each type defines syntax highlighting,
|
||||
* insert-menu blocks, and how to render the content (after Nunjucks) for the renderer.
|
||||
* Add a new entry here and wire its language in ConfigNode to add a new config type.
|
||||
*/
|
||||
|
||||
export type ConfigTypeId = 'plantuml' | 'markdown' | 'wireframe'
|
||||
|
||||
/** A single insert option (label + snippet to insert at cursor). */
|
||||
export type InsertBlock = { label: string; snippet: string }
|
||||
|
||||
/** A group of insert options (e.g. "Diagram" with startuml, actor, etc.). */
|
||||
export type InsertBlockGroup = { label: string; items: InsertBlock[] }
|
||||
|
||||
export type InsertBlockOrGroup = InsertBlock | InsertBlockGroup
|
||||
|
||||
function isGroup(b: InsertBlockOrGroup): b is InsertBlockGroup {
|
||||
return 'items' in b && Array.isArray((b as InsertBlockGroup).items)
|
||||
}
|
||||
|
||||
/** Optional options passed to render (e.g. node size for wireframe SVG). */
|
||||
export type RenderOptions = { width?: number; height?: number }
|
||||
|
||||
export type ConfigType = {
|
||||
id: ConfigTypeId
|
||||
label: string
|
||||
/** CodeMirror language key; used to pick the extension in ConfigNode. */
|
||||
language: ConfigTypeId
|
||||
/** Options under Insert → [type-specific]. Can be flat blocks or groups. */
|
||||
insertBlocks: InsertBlockOrGroup[]
|
||||
/** Render resolved content (after Nunjucks) to HTML/SVG string for the renderer. */
|
||||
render: (content: string, options?: RenderOptions) => Promise<string>
|
||||
}
|
||||
|
||||
const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg'
|
||||
const KROKI_TIMEOUT_MS = 15000
|
||||
|
||||
const PLANTUML_INSERT_BLOCKS: InsertBlockOrGroup[] = [
|
||||
{ label: 'Diagram start/end', snippet: '@startuml\n\n@enduml' },
|
||||
{ label: 'Actor', snippet: 'actor ' },
|
||||
{ label: 'Participant', snippet: 'participant "" as ' },
|
||||
{ label: 'Arrow', snippet: ' -> ' },
|
||||
{ label: 'Note', snippet: 'note right of ' },
|
||||
{
|
||||
label: 'Templating',
|
||||
items: [
|
||||
{ label: 'Variable {{ }}', snippet: '{{ }}' },
|
||||
{ label: 'if / endif', snippet: '{% if %}\n \n{% endif %}' },
|
||||
{ label: 'for / endfor', snippet: '{% for in %}\n \n{% endfor %}' },
|
||||
{ label: 'set', snippet: '{% set = %}' },
|
||||
{ label: 'extends', snippet: '{% extends "" %}' },
|
||||
{ label: 'include', snippet: '{% include "" %}' },
|
||||
{ label: 'import', snippet: '{% import "" as %}' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const MARKDOWN_INSERT_BLOCKS: InsertBlockOrGroup[] = [
|
||||
{ label: 'Heading 1', snippet: '# ' },
|
||||
{ label: 'Heading 2', snippet: '## ' },
|
||||
{ label: 'Heading 3', snippet: '### ' },
|
||||
{ label: 'Bold', snippet: '****' },
|
||||
{ label: 'Italic', snippet: '**' },
|
||||
{ label: 'Code inline', snippet: '``' },
|
||||
{ label: 'Code block', snippet: '```\n\n```' },
|
||||
{ label: 'Link', snippet: '[]()' },
|
||||
{ label: 'Image', snippet: '![]()' },
|
||||
{ label: 'List item', snippet: '- ' },
|
||||
{ label: 'Blockquote', snippet: '> ' },
|
||||
{
|
||||
label: 'Templating',
|
||||
items: [
|
||||
{ label: 'Variable {{ }}', snippet: '{{ }}' },
|
||||
{ label: 'if / endif', snippet: '{% if %}\n \n{% endif %}' },
|
||||
{ label: 'for / endfor', snippet: '{% for in %}\n \n{% endfor %}' },
|
||||
{ label: 'set', snippet: '{% set = %}' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** Wireweave DSL: https://github.com/wireweave/core */
|
||||
const WIREFRAME_INSERT_BLOCKS: InsertBlockOrGroup[] = [
|
||||
{ label: 'Page', snippet: 'page "Title" {\n \n}' },
|
||||
{ label: 'Card', snippet: 'card p=4 {\n \n}' },
|
||||
{ label: 'Title', snippet: 'title "' },
|
||||
{ label: 'Text', snippet: 'text "' },
|
||||
{ label: 'Button', snippet: 'button "Label"' },
|
||||
{ label: 'Primary button', snippet: 'button "Label" primary' },
|
||||
{ label: 'Input', snippet: 'input placeholder=""' },
|
||||
{ label: 'Row', snippet: 'row {\n col span=6 { }\n}' },
|
||||
{ label: 'Col', snippet: 'col span=6 { }' },
|
||||
{ label: 'Header / Main / Footer', snippet: 'header { }\nmain { }\nfooter { }' },
|
||||
{ label: 'Image', snippet: 'image "" w=400 h=300' },
|
||||
{
|
||||
label: 'Templating',
|
||||
items: [
|
||||
{ label: 'Variable {{ }}', snippet: '{{ }}' },
|
||||
{ label: 'if / endif', snippet: '{% if %}\n \n{% endif %}' },
|
||||
{ label: 'for / endfor', snippet: '{% for in %}\n \n{% endfor %}' },
|
||||
{ label: 'set', snippet: '{% set = %}' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** Markdown to HTML using dynamic import to avoid loading if only PlantUML is used. */
|
||||
async function renderMarkdown(content: string): Promise<string> {
|
||||
const { marked } = await import('marked')
|
||||
const html = typeof marked.parse === 'function' ? await marked.parse(content) : (marked as (s: string) => string)(content)
|
||||
return typeof html === 'string' ? html : String(html)
|
||||
}
|
||||
|
||||
/** Wireweave DSL to SVG; theme from document dark mode. See https://www.wireweave.org/ */
|
||||
async function renderWireframe(content: string, options?: RenderOptions): Promise<string> {
|
||||
const { parse, renderToSvg } = await import('@wireweave/core')
|
||||
const doc = parse(content)
|
||||
const isDark =
|
||||
typeof document !== 'undefined' &&
|
||||
document.documentElement?.classList?.contains('dark')
|
||||
const { svg } = renderToSvg(doc, {
|
||||
theme: isDark ? 'dark' : 'light',
|
||||
width: options?.width ?? 1200,
|
||||
height: options?.height,
|
||||
padding: 24,
|
||||
})
|
||||
return svg
|
||||
}
|
||||
|
||||
export const CONFIG_TYPES: ConfigType[] = [
|
||||
{
|
||||
id: 'plantuml',
|
||||
label: 'Diagram',
|
||||
language: 'plantuml',
|
||||
insertBlocks: PLANTUML_INSERT_BLOCKS,
|
||||
render: async (content: string) => {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), KROKI_TIMEOUT_MS)
|
||||
try {
|
||||
const res = await fetch(KROKI_PLANTUML_SVG, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: content,
|
||||
signal: controller.signal,
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
if (res.status >= 500) {
|
||||
throw new Error('Diagram service unavailable. Try again later.')
|
||||
}
|
||||
throw new Error(res.status === 400 ? err || 'Invalid PlantUML' : `Kroki error: ${res.status}`)
|
||||
}
|
||||
return res.text()
|
||||
} catch (err: unknown) {
|
||||
clearTimeout(timeoutId)
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error('Diagram request timed out. The service may be slow or unavailable.')
|
||||
}
|
||||
if (err.message.includes('fetch') || err.message.includes('network') || err.message.includes('Failed to fetch')) {
|
||||
throw new Error('Diagram service unavailable. Check your connection or try again later.')
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'markdown',
|
||||
label: 'Markdown',
|
||||
language: 'markdown',
|
||||
insertBlocks: MARKDOWN_INSERT_BLOCKS,
|
||||
render: renderMarkdown,
|
||||
},
|
||||
{
|
||||
id: 'wireframe',
|
||||
label: 'Wireframe',
|
||||
language: 'markdown',
|
||||
insertBlocks: WIREFRAME_INSERT_BLOCKS,
|
||||
render: renderWireframe,
|
||||
},
|
||||
]
|
||||
|
||||
export const CONFIG_TYPE_IDS = CONFIG_TYPES.map((t) => t.id)
|
||||
export const DEFAULT_CONFIG_TYPE_ID: ConfigTypeId = 'plantuml'
|
||||
|
||||
export function getConfigType(id: ConfigTypeId): ConfigType {
|
||||
const t = CONFIG_TYPES.find((c) => c.id === id)
|
||||
if (!t) throw new Error(`Unknown config type: ${id}`)
|
||||
return t
|
||||
}
|
||||
|
||||
export function getDefaultContentForConfigType(configTypeId: ConfigTypeId): string {
|
||||
if (configTypeId === 'plantuml') return '@startuml\n\n@enduml\n'
|
||||
if (configTypeId === 'markdown') return ''
|
||||
if (configTypeId === 'wireframe') {
|
||||
return `page "Hello" {
|
||||
card p=4 {
|
||||
title "Welcome"
|
||||
text "Hello, wireweave!"
|
||||
button "Get Started" primary
|
||||
}
|
||||
}`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Flatten insert blocks for iteration: either a single block or a group's items. */
|
||||
export function* iterateInsertBlocks(blocks: InsertBlockOrGroup[]): Generator<InsertBlock> {
|
||||
for (const b of blocks) {
|
||||
if (isGroup(b)) {
|
||||
for (const item of b.items) yield item
|
||||
} else {
|
||||
yield b
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { isGroup }
|
||||
|
||||
/** Content from config node data (backward compat: content ?? plantuml). */
|
||||
export function getConfigContent(data: Record<string, unknown> | undefined): string {
|
||||
if (!data) return ''
|
||||
const content = data.content ?? data.plantuml
|
||||
return typeof content === 'string' ? content : ''
|
||||
}
|
||||
|
||||
/** Config type id from data (default plantuml). */
|
||||
export function getConfigTypeId(data: Record<string, unknown> | undefined): ConfigTypeId {
|
||||
if (!data || data.configType == null) return 'plantuml'
|
||||
const id = data.configType
|
||||
return CONFIG_TYPE_IDS.includes(id as ConfigTypeId) ? (id as ConfigTypeId) : 'plantuml'
|
||||
}
|
||||
29
frontend/src/lib/flowContext.tsx
Normal file
29
frontend/src/lib/flowContext.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react'
|
||||
import type { Connection } from '@xyflow/react'
|
||||
import type { AppNode, AppEdge } from '@/lib/nodeTypes'
|
||||
|
||||
export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null
|
||||
|
||||
export type FlowActions = {
|
||||
pasteAtViewportCenter: () => void
|
||||
fitView: () => void
|
||||
}
|
||||
|
||||
export type FlowContextValue = {
|
||||
nodes: AppNode[]
|
||||
setNodes: (updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => void
|
||||
edges: AppEdge[]
|
||||
setEdges: (updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => void
|
||||
renamingNodeId: string | null
|
||||
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
|
||||
/** Set by FlowKeyboardShortcuts so Node menubar can trigger paste / fit view. */
|
||||
flowActionsRef: React.MutableRefObject<FlowActions | null>
|
||||
}
|
||||
|
||||
const FlowContext = React.createContext<FlowContextValue | null>(null)
|
||||
|
||||
export default FlowContext
|
||||
97
frontend/src/lib/flowUtils.ts
Normal file
97
frontend/src/lib/flowUtils.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Use as second argument to React.memo() for node components.
|
||||
* Skips re-render when only position (or other unrelated props) changed,
|
||||
* so dragging one node doesn't force other nodes to re-render.
|
||||
*/
|
||||
|
||||
import {
|
||||
getIdPrefix,
|
||||
getDefaultDataForType as getDefaultDataFromRegistry,
|
||||
getResetDataForType as getResetDataFromRegistry,
|
||||
getDefaultStyle,
|
||||
} from './nodeRegistry'
|
||||
|
||||
export function nodePropsAreEqual<P extends { id?: string; data?: any; width?: number; height?: number; selected?: boolean }>(
|
||||
prev: P,
|
||||
next: P
|
||||
): boolean {
|
||||
return (
|
||||
prev.id === next.id &&
|
||||
prev.data === next.data &&
|
||||
prev.width === next.width &&
|
||||
prev.height === next.height &&
|
||||
prev.selected === next.selected
|
||||
)
|
||||
}
|
||||
|
||||
/** @deprecated Use getIdPrefix from nodeRegistry for new code. Kept for compatibility. */
|
||||
export const PREFIX_BY_TYPE: Record<string, string> = {
|
||||
config: 'cfg_',
|
||||
render: 'rnd_',
|
||||
variable: 'var_',
|
||||
function: 'fn_',
|
||||
}
|
||||
|
||||
/** Next node id for type: prefix + 3-digit increasing number (001, 002, …). Uses nodeRegistry for prefix when available. */
|
||||
export function getNextNodeId(type: string, existingIds: string[]): string {
|
||||
const prefix = getIdPrefix(type)
|
||||
const re = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)$`)
|
||||
let max = 0
|
||||
for (const id of existingIds) {
|
||||
const m = id.match(re)
|
||||
if (m) max = Math.max(max, parseInt(m[1], 10))
|
||||
}
|
||||
return `${prefix}${String(max + 1).padStart(3, '0')}`
|
||||
}
|
||||
|
||||
/** Recursively replace oldId with newId in string values (for rename propagation) */
|
||||
function replaceInData(value: unknown, oldId: string, newId: string): unknown {
|
||||
if (typeof value === 'string') return value.split(oldId).join(newId)
|
||||
if (value === null || typeof value !== 'object') return value
|
||||
if (Array.isArray(value)) return value.map((v) => replaceInData(v, oldId, newId))
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(value)) out[k] = replaceInData(v, oldId, newId)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Update graph after renaming a node: change node id and all references in data and edges */
|
||||
export function replaceNodeIdInGraph(
|
||||
nodes: Array<{ id: string; data?: any; [k: string]: any }>,
|
||||
edges: Array<{ id: string; source: string; target: string; [k: string]: any }>,
|
||||
oldId: string,
|
||||
newId: string
|
||||
): { nodes: typeof nodes; edges: typeof edges } {
|
||||
const newNodes = nodes.map((n) =>
|
||||
n.id === oldId ? { ...n, id: newId } : { ...n, data: replaceInData(n.data, oldId, newId) as any }
|
||||
)
|
||||
const newEdges = edges.map((e) => ({
|
||||
...e,
|
||||
id: e.id.includes(oldId) ? e.id.split(oldId).join(newId) : e.id,
|
||||
source: e.source === oldId ? newId : e.source,
|
||||
target: e.target === oldId ? newId : e.target,
|
||||
}))
|
||||
return { nodes: newNodes, edges: newEdges }
|
||||
}
|
||||
|
||||
/** @deprecated Use getDefaultStyle from nodeRegistry. Kept for compatibility. */
|
||||
export const DEFAULT_NODE_STYLE: Record<string, { width: number; height: number }> = {
|
||||
config: { width: 320, height: 320 },
|
||||
render: { width: 384, height: 320 },
|
||||
variable: { width: 224, height: 180 },
|
||||
function: { width: 288, height: 260 },
|
||||
}
|
||||
|
||||
/** Default data for a new node. Uses nodeRegistry when type is registered. */
|
||||
export function getDefaultDataForType(type: string, newId?: string): any {
|
||||
return getDefaultDataFromRegistry(type, newId)
|
||||
}
|
||||
|
||||
/** Data for Reset action. Uses nodeRegistry when type is registered. */
|
||||
export function getResetDataForType(type: string, nodeId?: string): any {
|
||||
return getResetDataFromRegistry(type, nodeId)
|
||||
}
|
||||
|
||||
/** Default style for a type. Uses nodeRegistry when type is registered. */
|
||||
export function getDefaultStyleForType(type: string): { width: number; height: number } {
|
||||
return getDefaultStyle(type)
|
||||
}
|
||||
84
frontend/src/lib/nodeHelp.tsx
Normal file
84
frontend/src/lib/nodeHelp.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import type React from 'react'
|
||||
|
||||
export type NodeType = 'config' | 'render' | 'variable' | 'function'
|
||||
|
||||
export type NodeHelpEntry = {
|
||||
title: string
|
||||
content: React.ReactNode
|
||||
}
|
||||
|
||||
const Code = ({ children }: { children: React.ReactNode }) => (
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-xs font-mono">{children}</code>
|
||||
)
|
||||
|
||||
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
|
||||
<div className="mt-3 first:mt-0">
|
||||
<h4 className="text-xs font-semibold text-foreground">{title}</h4>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{children}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const NODE_HELP: Record<NodeType, NodeHelpEntry> = {
|
||||
config: {
|
||||
title: 'Config node',
|
||||
content: (
|
||||
<>
|
||||
<Section title="How to use">
|
||||
<p>Config nodes hold PlantUML + Nunjucks template content. Connect one config to a Render node to display the diagram. Use the editor to write <Code>@startuml</Code> blocks and Nunjucks tags (<Code>{'{{ }}'}</Code>, <Code>{'{% %}'}</Code>).</p>
|
||||
</Section>
|
||||
<Section title="Using in another node">
|
||||
<p>From another config, reference this template:</p>
|
||||
<ul className="list-disc pl-4 mt-1 space-y-0.5">
|
||||
<li><Code>{'{% extends "configId" %}'}</Code> — inherit layout</li>
|
||||
<li><Code>{'{% include "configId" %}'}</Code> — inline content</li>
|
||||
<li><Code>{'{% import "configId" as alias %}'}</Code> — use as macro namespace</li>
|
||||
</ul>
|
||||
<p className="mt-2">Replace <Code>configId</Code> with this node’s id or its title. Connect variables/functions to this config; they are available as <Code>{'{{ varId }}'}</Code> and <Code>{'{{ x | fnId }}'}</Code> in the template.</p>
|
||||
</Section>
|
||||
</>
|
||||
),
|
||||
},
|
||||
render: {
|
||||
title: 'Renderer node',
|
||||
content: (
|
||||
<>
|
||||
<Section title="How to use">
|
||||
<p>Connect a single Config node (input) to this Render node. It resolves the config’s PlantUML + Nunjucks (variables, function filters, extends/include), sends the result to the diagram service, and shows the SVG here.</p>
|
||||
</Section>
|
||||
<Section title="Using in another node">
|
||||
<p>Renderer nodes are terminal: they only consume configs. They are not referenced from other nodes. To reuse a diagram, reference the Config node from another Config (extends/include), then connect that config to a Render node.</p>
|
||||
</Section>
|
||||
</>
|
||||
),
|
||||
},
|
||||
variable: {
|
||||
title: 'Variable node',
|
||||
content: (
|
||||
<>
|
||||
<Section title="How to use">
|
||||
<p>Variables hold a value (string, number, or boolean). Connect a Variable node to a Config node to expose it in that config’s template.</p>
|
||||
</Section>
|
||||
<Section title="Using in another node">
|
||||
<p>In a Config template connected to this variable, use <Code>{'{{ '}<em>nodeId</em>{' }}'}</Code> where <em>nodeId</em> is this node’s id. Example: if the variable node id is <Code>var_001</Code>, write <Code>{'{{ var_001 }}'}</Code> in the config.</p>
|
||||
</Section>
|
||||
</>
|
||||
),
|
||||
},
|
||||
function: {
|
||||
title: 'Function node',
|
||||
content: (
|
||||
<>
|
||||
<Section title="How to use">
|
||||
<p>Functions are Nunjucks custom filters. Write a body using either named parameters (<Code>function(num, x, kwargs) { ... }</Code>) or the <Code>args</Code> array. Connect this node to a Config to use the filter in that config’s template.</p>
|
||||
</Section>
|
||||
<Section title="Using in another node">
|
||||
<p>In a Config template, use the filter syntax: <Code>{'{{ value | '}<em>nodeId</em>{' }}'}</Code> or <Code>{'{{ value | '}<em>nodeId</em>{'(arg1, key=val) }}'}</Code>. The first argument is the value before <Code>|</Code>; extra arguments and keyword args are passed as in Nunjucks. Return a value or a Promise for async filters.</p>
|
||||
</Section>
|
||||
</>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export function getNodeHelp(nodeType: NodeType): NodeHelpEntry {
|
||||
return NODE_HELP[nodeType] ?? { title: nodeType, content: null }
|
||||
}
|
||||
116
frontend/src/lib/nodeRegistry.ts
Normal file
116
frontend/src/lib/nodeRegistry.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Extensible node type registry. Register node types with registerNodeType();
|
||||
* built-in types are registered in registerBuiltinNodes.ts.
|
||||
* Use getRegisteredNodeTypes() / getNodeType(id) for defaults, validation, and UI.
|
||||
*/
|
||||
|
||||
import type React from 'react'
|
||||
import type { Node } from '@xyflow/react'
|
||||
|
||||
export type NodeHelpEntry = {
|
||||
title: string
|
||||
content: React.ReactNode
|
||||
}
|
||||
|
||||
export type NodeTypeDescriptor = {
|
||||
id: string
|
||||
component: React.ComponentType<any>
|
||||
defaultStyle: { width: number; height: number }
|
||||
defaultData: Record<string, unknown>
|
||||
idPrefix: string
|
||||
hasInput: boolean
|
||||
hasOutput: boolean
|
||||
/** When this type is the connection target, which source types are allowed. Omit = all. */
|
||||
allowedSourceTypes?: string[]
|
||||
/** When this type is the connection source, which target types are allowed. Omit = all. */
|
||||
allowedTargetTypes?: string[]
|
||||
help: NodeHelpEntry
|
||||
menuLabel: string
|
||||
menuIcon: React.ReactNode
|
||||
/** Optional: override default data for new nodes (e.g. set title from newId). */
|
||||
getDefaultData?: (newId?: string) => Record<string, unknown>
|
||||
/** Optional: data for Reset action; if omitted, getDefaultData(nodeId) or defaultData is used. */
|
||||
getResetData?: (nodeId?: string) => Record<string, unknown>
|
||||
/** Optional: label shown on edge when this type is the target (e.g. "render", "add input"). */
|
||||
connectionLabel?: string
|
||||
}
|
||||
|
||||
const registry = new Map<string, NodeTypeDescriptor>()
|
||||
|
||||
export function registerNodeType(descriptor: NodeTypeDescriptor): void {
|
||||
if (registry.has(descriptor.id)) {
|
||||
console.warn(`[nodeRegistry] Overwriting existing node type: ${descriptor.id}`)
|
||||
}
|
||||
registry.set(descriptor.id, descriptor)
|
||||
}
|
||||
|
||||
export function getNodeType(id: string): NodeTypeDescriptor | undefined {
|
||||
return registry.get(id)
|
||||
}
|
||||
|
||||
export function getRegisteredNodeTypes(): NodeTypeDescriptor[] {
|
||||
return Array.from(registry.values())
|
||||
}
|
||||
|
||||
export function getRegisteredNodeTypeIds(): string[] {
|
||||
return Array.from(registry.keys())
|
||||
}
|
||||
|
||||
/** Default data for a new node of this type. Uses getDefaultData from descriptor if provided. */
|
||||
export function getDefaultDataForType(type: string, newId?: string): Record<string, unknown> {
|
||||
const desc = registry.get(type)
|
||||
if (!desc) return {}
|
||||
if (desc.getDefaultData) return { ...desc.getDefaultData(newId) }
|
||||
const base = { ...desc.defaultData }
|
||||
if (type === 'config' && newId) (base as any).title = `${newId}`
|
||||
return base
|
||||
}
|
||||
|
||||
/** Data for Reset action. Uses getResetData from descriptor if provided. */
|
||||
export function getResetDataForType(type: string, nodeId?: string): Record<string, unknown> {
|
||||
const desc = registry.get(type)
|
||||
if (!desc) return getDefaultDataForType(type, nodeId)
|
||||
if (desc.getResetData) return { ...desc.getResetData(nodeId) }
|
||||
return getDefaultDataForType(type, nodeId)
|
||||
}
|
||||
|
||||
/** Id prefix for this type (e.g. cfg_, rnd_). Used by getNextNodeId. */
|
||||
export function getIdPrefix(type: string): string {
|
||||
return getNodeType(type)?.idPrefix ?? 'node_'
|
||||
}
|
||||
|
||||
/** Default style for this type. */
|
||||
export function getDefaultStyle(type: string): { width: number; height: number } {
|
||||
const desc = getNodeType(type)
|
||||
if (desc) return desc.defaultStyle
|
||||
return { width: 320, height: 320 }
|
||||
}
|
||||
|
||||
/** Whether a connection from source to target is allowed based on registered types. */
|
||||
export function isConnectionAllowed(
|
||||
sourceType: string,
|
||||
targetType: string,
|
||||
sourceNodeId: string,
|
||||
targetNodeId: string
|
||||
): boolean {
|
||||
if (sourceNodeId === targetNodeId) return false
|
||||
const sourceDesc = getNodeType(sourceType)
|
||||
const targetDesc = getNodeType(targetType)
|
||||
if (!sourceDesc || !targetDesc) return false
|
||||
if (!targetDesc.hasInput) return false
|
||||
if (!sourceDesc.hasOutput) return false
|
||||
if (targetDesc.allowedSourceTypes != null && !targetDesc.allowedSourceTypes.includes(sourceType)) return false
|
||||
if (sourceDesc.allowedTargetTypes != null && !sourceDesc.allowedTargetTypes.includes(targetType)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** Edge label when target is of this type (for AnimatedEdge). */
|
||||
export function getConnectionLabelForTarget(targetType: string): string | undefined {
|
||||
return getNodeType(targetType)?.connectionLabel
|
||||
}
|
||||
|
||||
/** Help entry for a node type. Use in NodeHelpPopover. */
|
||||
export function getNodeHelp(nodeType: string): NodeHelpEntry {
|
||||
const desc = getNodeType(nodeType)
|
||||
return desc?.help ?? { title: nodeType, content: null }
|
||||
}
|
||||
13
frontend/src/lib/nodeTypes.ts
Normal file
13
frontend/src/lib/nodeTypes.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Central node and edge types for the app. Use AppNode / AppEdge in graph state and context.
|
||||
*/
|
||||
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { ConfigNodeData } from '@/components/nodes/ConfigNode'
|
||||
import type { RenderingNodeData } from '@/components/nodes/RenderingNode'
|
||||
import type { VariableNodeData } from '@/components/nodes/VariableNode'
|
||||
import type { FunctionNodeData } from '@/components/nodes/FunctionNode'
|
||||
|
||||
export type AppNodeData = ConfigNodeData | RenderingNodeData | VariableNodeData | FunctionNodeData
|
||||
export type AppNode = Node<AppNodeData>
|
||||
export type AppEdge = Edge
|
||||
92
frontend/src/lib/nunjucksAutocomplete.ts
Normal file
92
frontend/src/lib/nunjucksAutocomplete.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { CompletionContext, CompletionResult } from '@codemirror/autocomplete'
|
||||
import type { EditorState } from '@codemirror/state'
|
||||
|
||||
const NUNJUCKS_KEYWORDS = [
|
||||
'if', 'endif', 'elif', 'else', 'for', 'endfor', 'in', 'and', 'or', 'not',
|
||||
'true', 'false', 'none', 'macro', 'endmacro', 'set', 'endset', 'block', 'endblock',
|
||||
'extends', 'include', 'import', 'with', 'endwith', 'filter', 'endfilter', 'raw', 'endraw',
|
||||
]
|
||||
|
||||
const NUNJUCKS_FILTERS = [
|
||||
'default', 'length', 'upper', 'lower', 'title', 'trim', 'join', 'replace',
|
||||
'first', 'last', 'round', 'int', 'float', 'string', 'list', 'sort', 'groupby',
|
||||
'trim', 'escape', 'safe', 'striptags', 'capitalize', 'reverse', 'batch', 'slice',
|
||||
]
|
||||
|
||||
/** Get line text (CodeMirror 6: doc.line(n) is 1-based) */
|
||||
function getLineText(state: EditorState, lineNo0Based: number): string {
|
||||
return state.doc.line(lineNo0Based + 1).text
|
||||
}
|
||||
|
||||
/** Detect if position is inside {{ or {% from the start of the line */
|
||||
function insideNunjucks(state: EditorState, lineNo0Based: number, posInLine: number): boolean {
|
||||
const line = getLineText(state, lineNo0Based)
|
||||
const before = line.slice(0, posInLine)
|
||||
const openVar = before.lastIndexOf('{{')
|
||||
const openTag = before.lastIndexOf('{%')
|
||||
const closeVar = before.lastIndexOf('}}')
|
||||
const closeTag = before.lastIndexOf('%}')
|
||||
if (openVar > -1 && (closeVar === -1 || closeVar < openVar)) return true
|
||||
if (openTag > -1 && (closeTag === -1 || closeTag < openTag)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Get the word fragment before the cursor for matching */
|
||||
function wordBefore(state: EditorState, lineNo0Based: number, posInLine: number): string {
|
||||
const line = getLineText(state, lineNo0Based)
|
||||
let start = posInLine
|
||||
while (start > 0 && /[\w.-]/.test(line[start - 1])) start -= 1
|
||||
return line.slice(start, posInLine)
|
||||
}
|
||||
|
||||
export function nunjucksCompletionSource(
|
||||
variableIds: string[],
|
||||
configTitles?: string[],
|
||||
functionIds?: string[],
|
||||
): (context: CompletionContext) => CompletionResult | null {
|
||||
return (context: CompletionContext) => {
|
||||
const { state, pos } = context
|
||||
const line = state.doc.lineAt(pos)
|
||||
if (!insideNunjucks(state, line.number - 1, pos - line.from)) return null
|
||||
|
||||
const word = wordBefore(state, line.number - 1, pos - line.from)
|
||||
const from = pos - word.length
|
||||
|
||||
const options: { label: string; type?: string; info?: string }[] = []
|
||||
|
||||
for (const id of variableIds) {
|
||||
if (!word || id.toLowerCase().startsWith(word.toLowerCase())) {
|
||||
options.push({ label: id, type: 'variable', info: 'Variable' })
|
||||
}
|
||||
}
|
||||
for (const id of functionIds ?? []) {
|
||||
if (!word || id.toLowerCase().startsWith(word.toLowerCase())) {
|
||||
options.push({ label: id, type: 'function', info: "Filter: {{ '' | " + id + " }}" })
|
||||
}
|
||||
}
|
||||
for (const kw of NUNJUCKS_KEYWORDS) {
|
||||
if (!word || kw.startsWith(word.toLowerCase())) {
|
||||
options.push({ label: kw, type: 'keyword', info: 'Nunjucks keyword' })
|
||||
}
|
||||
}
|
||||
for (const f of NUNJUCKS_FILTERS) {
|
||||
if (!word || f.startsWith(word.toLowerCase())) {
|
||||
options.push({ label: `${f}`, type: 'function', info: `Filter: ${f}` })
|
||||
}
|
||||
}
|
||||
if (configTitles?.length) {
|
||||
for (const t of configTitles) {
|
||||
if (!word || t.toLowerCase().startsWith(word.toLowerCase())) {
|
||||
options.push({ label: t, type: 'variable', info: 'Config' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.length === 0) return null
|
||||
return {
|
||||
from,
|
||||
options: options.slice(0, 50),
|
||||
validFor: /^[\w.-]*$/,
|
||||
}
|
||||
}
|
||||
}
|
||||
79
frontend/src/lib/plantumlLanguage.ts
Normal file
79
frontend/src/lib/plantumlLanguage.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { StreamLanguage } from '@codemirror/language'
|
||||
|
||||
/** Nunjucks block comment {# ... #} */
|
||||
function tokenNunjucksComment(stream: { match: (re: RegExp) => string | null; next: () => string; eol: () => boolean }) {
|
||||
if (stream.match(/^\{#/)) {
|
||||
while (!stream.eol()) {
|
||||
if (stream.match(/#\}/)) return 'comment'
|
||||
stream.next()
|
||||
}
|
||||
return 'comment'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Nunjucks variable {{ ... }} or tag {% ... %} - tokenize the whole block */
|
||||
function tokenNunjucksBlock(stream: { match: (re: RegExp) => string | null; next: () => string; eol: () => boolean }) {
|
||||
if (stream.match(/^\{\{/)) {
|
||||
while (!stream.eol()) {
|
||||
if (stream.match(/\}\}/)) return 'variableName.special'
|
||||
stream.next()
|
||||
}
|
||||
return 'variableName.special'
|
||||
}
|
||||
if (stream.match(/^\{\%/)) {
|
||||
while (!stream.eol()) {
|
||||
if (stream.match(/%\}/)) return 'keyword'
|
||||
stream.next()
|
||||
}
|
||||
return 'keyword'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Simple PlantUML + Nunjucks stream parser for syntax highlighting in CodeMirror */
|
||||
const plantumlParser = StreamLanguage.define({
|
||||
name: 'plantuml',
|
||||
token(stream) {
|
||||
// Nunjucks {# ... #} comment
|
||||
const nunjucksComment = tokenNunjucksComment(stream)
|
||||
if (nunjucksComment) return nunjucksComment
|
||||
// Nunjucks {{ }} and {% %}
|
||||
const nunjucksBlock = tokenNunjucksBlock(stream)
|
||||
if (nunjucksBlock) return nunjucksBlock
|
||||
|
||||
// Single-quote line comment (PlantUML)
|
||||
if (stream.match(/^'/)) {
|
||||
stream.skipToEnd()
|
||||
return 'comment'
|
||||
}
|
||||
// Double-quoted string
|
||||
if (stream.match(/^"/)) {
|
||||
let escaped = false
|
||||
while (!stream.eol()) {
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
stream.next()
|
||||
continue
|
||||
}
|
||||
const ch = stream.next()
|
||||
if (ch === '\\') escaped = true
|
||||
else if (ch === '"') break
|
||||
}
|
||||
return 'string'
|
||||
}
|
||||
// @directives (@startuml, @enduml, etc.)
|
||||
if (stream.match(/^@\w+/)) return 'meta'
|
||||
// Skip whitespace
|
||||
if (stream.eatSpace()) return null
|
||||
// Arrows and connectors
|
||||
if (stream.match(/^->>?|<-<?|-->>?|<<--?|<-?>/)) return 'keyword'
|
||||
// Keywords (participant, actor, as, title, etc.)
|
||||
if (stream.match(/^(participant|actor|as|title|autonumber|left|right|of|over|activate|deactivate|destroy|create|group|opt|alt|else|loop|par|end|note|legend|skinparam|start|stop|if|endif|elseif|while|endwhile|repeat|until|switch|case|endswitch|class|interface|enum|package|namespace|abstract|static|extends|implements)\b/i)) return 'keyword'
|
||||
// Any other character (identifier, punctuation, etc.)
|
||||
stream.next()
|
||||
return null
|
||||
},
|
||||
})
|
||||
|
||||
export const plantumlLanguage = plantumlParser
|
||||
93
frontend/src/lib/registerBuiltinNodes.tsx
Normal file
93
frontend/src/lib/registerBuiltinNodes.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Registers built-in node types (config, render, variable, function).
|
||||
* Import this once at app startup (e.g. in main.tsx) so the registry is populated.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { ScrollText, Sparkles, Variable, Code2 } from 'lucide-react'
|
||||
import { registerNodeType } from './nodeRegistry'
|
||||
import { NODE_HELP } from './nodeHelp'
|
||||
import ConfigNode from '../components/nodes/ConfigNode'
|
||||
import RenderingNode from '../components/nodes/RenderingNode'
|
||||
import VariableNode from '../components/nodes/VariableNode'
|
||||
import FunctionNode from '../components/nodes/FunctionNode'
|
||||
|
||||
const ICON_CLASS = 'mr-2 h-4 w-4'
|
||||
|
||||
export function registerBuiltinNodes(): void {
|
||||
registerNodeType({
|
||||
id: 'config',
|
||||
component: ConfigNode,
|
||||
defaultStyle: { width: 320, height: 320 },
|
||||
defaultData: { configType: 'plantuml', content: '@startuml\n\n@enduml\n', title: '' },
|
||||
idPrefix: 'cfg_',
|
||||
hasInput: true,
|
||||
hasOutput: true,
|
||||
allowedSourceTypes: ['config', 'variable', 'function'],
|
||||
allowedTargetTypes: ['config', 'render'],
|
||||
help: NODE_HELP.config,
|
||||
menuLabel: 'Config',
|
||||
menuIcon: <ScrollText className={ICON_CLASS} />,
|
||||
getDefaultData: (newId) => ({
|
||||
configType: 'plantuml',
|
||||
content: '@startuml\n\n@enduml\n',
|
||||
title: newId ?? '',
|
||||
}),
|
||||
getResetData: (nodeId) => ({
|
||||
configType: 'plantuml',
|
||||
content: '@startuml\n\n@enduml\n',
|
||||
title: nodeId ?? '',
|
||||
}),
|
||||
connectionLabel: 'adding input',
|
||||
})
|
||||
|
||||
registerNodeType({
|
||||
id: 'render',
|
||||
component: RenderingNode,
|
||||
defaultStyle: { width: 384, height: 320 },
|
||||
defaultData: { viewportWidth: 1200, viewportHeight: 800 },
|
||||
idPrefix: 'rnd_',
|
||||
hasInput: true,
|
||||
hasOutput: false,
|
||||
allowedSourceTypes: ['config'],
|
||||
help: NODE_HELP.render,
|
||||
menuLabel: 'Renderer',
|
||||
menuIcon: <Sparkles className={ICON_CLASS} />,
|
||||
connectionLabel: 'rendering',
|
||||
})
|
||||
|
||||
registerNodeType({
|
||||
id: 'variable',
|
||||
component: VariableNode,
|
||||
defaultStyle: { width: 224, height: 180 },
|
||||
defaultData: { value: '', valueType: 'string' },
|
||||
idPrefix: 'var_',
|
||||
hasInput: false,
|
||||
hasOutput: true,
|
||||
allowedTargetTypes: ['config', 'function'],
|
||||
help: NODE_HELP.variable,
|
||||
menuLabel: 'Variable',
|
||||
menuIcon: <Variable className={ICON_CLASS} />,
|
||||
})
|
||||
|
||||
registerNodeType({
|
||||
id: 'function',
|
||||
component: FunctionNode,
|
||||
defaultStyle: { width: 288, height: 260 },
|
||||
defaultData: {
|
||||
body: `function(num, kwargs) {
|
||||
return num + (kwargs.bar || 0);
|
||||
}`,
|
||||
},
|
||||
idPrefix: 'fn_',
|
||||
hasInput: true,
|
||||
hasOutput: true,
|
||||
allowedSourceTypes: ['config', 'variable', 'function'],
|
||||
allowedTargetTypes: ['config', 'function'],
|
||||
help: NODE_HELP.function,
|
||||
menuLabel: 'Function',
|
||||
menuIcon: <Code2 className={ICON_CLASS} />,
|
||||
getResetData: () => ({ body: '' }),
|
||||
connectionLabel: 'adding input',
|
||||
})
|
||||
}
|
||||
83
frontend/src/lib/themeContext.tsx
Normal file
83
frontend/src/lib/themeContext.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
const STORAGE_KEY = 'zui-theme'
|
||||
|
||||
export type Theme = 'light' | 'dark'
|
||||
|
||||
function readStored(): Theme | null {
|
||||
try {
|
||||
const s = localStorage.getItem(STORAGE_KEY)
|
||||
if (s === 'light' || s === 'dark') return s
|
||||
} catch (_) {}
|
||||
return null
|
||||
}
|
||||
|
||||
function systemPrefersDark(): boolean {
|
||||
try {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
toggleTheme: () => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
const stored = readStored()
|
||||
if (stored) return stored
|
||||
return systemPrefersDark() ? 'dark' : 'light'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark')
|
||||
} else {
|
||||
root.classList.remove('dark')
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
useEffect(() => {
|
||||
const m = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handler = () => {
|
||||
if (readStored() != null) return
|
||||
setThemeState(m.matches ? 'dark' : 'light')
|
||||
}
|
||||
m.addEventListener('change', handler)
|
||||
return () => m.removeEventListener('change', handler)
|
||||
}, [])
|
||||
|
||||
const setTheme = useCallback((next: Theme) => {
|
||||
setThemeState(next)
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next)
|
||||
} catch (_) {}
|
||||
}, [])
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setThemeState((prev) => {
|
||||
const next = prev === 'light' ? 'dark' : 'light'
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next)
|
||||
} catch (_) {}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const value = useMemo(() => ({ theme, setTheme, toggleTheme }), [theme, setTheme, toggleTheme])
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext)
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
|
||||
return ctx
|
||||
}
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
17
frontend/src/main.tsx
Normal file
17
frontend/src/main.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import { ThemeProvider } from './lib/themeContext'
|
||||
import { registerBuiltinNodes } from './lib/registerBuiltinNodes'
|
||||
import './styles.css'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
|
||||
registerBuiltinNodes()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
239
frontend/src/styles.css
Normal file
239
frontend/src/styles.css
Normal file
@@ -0,0 +1,239 @@
|
||||
@import "shadcn/dist/tailwind.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
.reactflow-wrapper {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Node specific overrides */
|
||||
.react-flow__node {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Focused node: clear focus ring so keyboard/screen reader users see which node is active */
|
||||
.react-flow__node:focus {
|
||||
outline: none;
|
||||
}
|
||||
.react-flow__node:focus-visible > div:first-child {
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.4);
|
||||
}
|
||||
.dark .react-flow__node:focus-visible > div:first-child {
|
||||
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.5);
|
||||
}
|
||||
|
||||
.react-flow__node .react-flow__handle {
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.dark .react-flow__node .react-flow__handle {
|
||||
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;
|
||||
}
|
||||
|
||||
/* Viewport control buttons: same look as React Flow controls; Lucide icons use stroke. */
|
||||
.rendering-viewport .react-flow__controls-button svg {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Rendering node: diagram/wireframe SVG must fit inside the content area. */
|
||||
.rendering-diagram {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.rendering-diagram svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
.dark .rendering-diagram svg {
|
||||
filter: invert(0.92) hue-rotate(180deg);
|
||||
}
|
||||
|
||||
/* Wireframe: fit HTML preview in viewport (inspired by https://www.wireweave.org/ ). */
|
||||
.rendering-wireframe__viewport {
|
||||
container-type: inline-size;
|
||||
container-name: wireframe;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.rendering-wireframe__content {
|
||||
width: 1200px;
|
||||
/* Scale to fit container width so content stays within the node */
|
||||
zoom: calc(100cqw / 1200px);
|
||||
min-height: min-content;
|
||||
}
|
||||
|
||||
/* Animated React Flow edges: thicker stroke + path animation */
|
||||
.react-flow__edge path.animated-edge-path,
|
||||
.animated-edge-path {
|
||||
stroke-width: 4;
|
||||
stroke: hsl(var(--foreground) / 0.6);
|
||||
fill: none;
|
||||
stroke-dasharray: 8 6;
|
||||
animation: edge-flow 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes edge-flow {
|
||||
from {
|
||||
stroke-dashoffset: 14;
|
||||
}
|
||||
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Resize control: snappy drag, no transition or selection delay */
|
||||
.react-flow__resize-control.bottom.right {
|
||||
cursor: se-resize;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.react-flow__resize-control.bottom.right,
|
||||
.react-flow__resize-control.bottom.right * {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Small helper for monospace pre output */
|
||||
pre {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, 'Roboto Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* Compact scrollbars that respect light/dark mode */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--muted-foreground) / 0.35) transparent;
|
||||
}
|
||||
.dark * {
|
||||
scrollbar-color: hsl(var(--muted-foreground) / 0.4) transparent;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--muted-foreground) / 0.35);
|
||||
border-radius: 3px;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.5);
|
||||
}
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
.dark *::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--muted-foreground) / 0.4);
|
||||
}
|
||||
.dark *::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.55);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user