Files
zui/src/components/nodes/VariableNode.tsx
2026-03-09 14:11:33 +01:00

139 lines
5.2 KiB
TypeScript

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 }: 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]" 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