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:
2026-03-09 20:04:31 +01:00
parent 11fd9cd54d
commit b3c2c6711f
67 changed files with 1286 additions and 2124 deletions

View 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>
)
}

View 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
}

View 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>
)}
</>
)
}

View 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>
);
}

View 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}
/>
);
}

View 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>
)
}

View File

@@ -0,0 +1,76 @@
import React, { useContext } from 'react'
import { Handle, Position } from '@xyflow/react'
import { ArrowDownLeft, ArrowUpRight } from 'lucide-react'
import FlowContext from '@/lib/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>
)
}

View 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"
/>
)
}

View File

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

View File

@@ -0,0 +1,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>
)
}

View File

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

View 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

View 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

View 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

View 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

View 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,
}

View 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 }

View 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,
}

View 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,
}

View 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 }

View 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 }

View 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,
}

View 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 }

View 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,
}

View 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 }

View 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 }