276 lines
8.9 KiB
TypeScript
276 lines
8.9 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useMemo, useState, ChangeEvent } from 'react'
|
|
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
|
import { nanoid } from 'nanoid'
|
|
import { useEditorContext } from '@/components/editor/EditorContext'
|
|
import OptionConditionEditor from '@/components/editor/OptionConditionEditor'
|
|
import NodeLockIndicator from '@/components/editor/NodeLockIndicator'
|
|
import type { Condition } from '@/types/flowchart'
|
|
|
|
type ChoiceOption = {
|
|
id: string
|
|
label: string
|
|
condition?: Condition
|
|
}
|
|
|
|
type ChoiceNodeData = {
|
|
prompt: string
|
|
options: ChoiceOption[]
|
|
}
|
|
|
|
const MIN_OPTIONS = 2
|
|
const MAX_OPTIONS = 6
|
|
|
|
export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
|
const { setNodes } = useReactFlow()
|
|
const { variables, nodeLocks, onNodeFocus, onNodeBlur } = useEditorContext()
|
|
const [editingConditionOptionId, setEditingConditionOptionId] = useState<string | null>(null)
|
|
|
|
const lockInfo = nodeLocks.get(id)
|
|
const isLockedByOther = !!lockInfo
|
|
|
|
// --- Handlers de Atualização ---
|
|
|
|
const updatePrompt = useCallback(
|
|
(e: ChangeEvent<HTMLInputElement>) => {
|
|
setNodes((nodes) =>
|
|
nodes.map((node) =>
|
|
node.id === id
|
|
? { ...node, data: { ...node.data, prompt: e.target.value } }
|
|
: node
|
|
)
|
|
)
|
|
},
|
|
[id, setNodes]
|
|
)
|
|
|
|
const updateOptionLabel = useCallback(
|
|
(optionId: string, label: string) => {
|
|
setNodes((nodes) =>
|
|
nodes.map((node) =>
|
|
node.id === id
|
|
? {
|
|
...node,
|
|
data: {
|
|
...node.data,
|
|
options: node.data.options.map((opt: ChoiceOption) =>
|
|
opt.id === optionId ? { ...opt, label } : opt
|
|
),
|
|
},
|
|
}
|
|
: node
|
|
)
|
|
)
|
|
},
|
|
[id, setNodes]
|
|
)
|
|
|
|
const handleSaveCondition = useCallback((optionId: string, condition: Condition) => {
|
|
setNodes((nodes) =>
|
|
nodes.map((node) =>
|
|
node.id === id
|
|
? {
|
|
...node,
|
|
data: {
|
|
...node.data,
|
|
options: node.data.options.map((opt: ChoiceOption) =>
|
|
opt.id === optionId ? { ...opt, condition } : opt
|
|
),
|
|
},
|
|
}
|
|
: node
|
|
)
|
|
)
|
|
setEditingConditionOptionId(null)
|
|
}, [id, setNodes])
|
|
|
|
const handleRemoveCondition = useCallback((optionId: string) => {
|
|
setNodes((nodes) =>
|
|
nodes.map((node) =>
|
|
node.id === id
|
|
? {
|
|
...node,
|
|
data: {
|
|
...node.data,
|
|
options: node.data.options.map((opt: ChoiceOption) =>
|
|
opt.id === optionId ? { ...opt, condition: undefined } : opt
|
|
),
|
|
},
|
|
}
|
|
: node
|
|
)
|
|
)
|
|
setEditingConditionOptionId(null)
|
|
}, [id, setNodes])
|
|
|
|
const addOption = useCallback(() => {
|
|
if (data.options.length >= MAX_OPTIONS) return
|
|
setNodes((nodes) =>
|
|
nodes.map((node) =>
|
|
node.id === id
|
|
? {
|
|
...node,
|
|
data: {
|
|
...node.data,
|
|
options: [...node.data.options, { id: nanoid(), label: '' }],
|
|
},
|
|
}
|
|
: node
|
|
)
|
|
)
|
|
}, [id, data.options.length, setNodes])
|
|
|
|
const removeOption = useCallback(
|
|
(optionId: string) => {
|
|
if (data.options.length <= MIN_OPTIONS) return
|
|
setNodes((nodes) =>
|
|
nodes.map((node) =>
|
|
node.id === id
|
|
? {
|
|
...node,
|
|
data: {
|
|
...node.data,
|
|
options: node.data.options.filter((opt: ChoiceOption) => opt.id !== optionId),
|
|
},
|
|
}
|
|
: node
|
|
)
|
|
)
|
|
},
|
|
[id, data.options.length, setNodes]
|
|
)
|
|
|
|
// --- Auxiliares ---
|
|
|
|
const editingOption = useMemo(() => {
|
|
if (!editingConditionOptionId) return null
|
|
return data.options.find((opt) => opt.id === editingConditionOptionId) || null
|
|
}, [editingConditionOptionId, data.options])
|
|
|
|
const hasInvalidConditionReference = useCallback(
|
|
(option: ChoiceOption) => {
|
|
if (!option.condition?.variableId) return false
|
|
return !variables.some((v) => v.id === option.condition!.variableId)
|
|
},
|
|
[variables]
|
|
)
|
|
|
|
const handleFocus = useCallback(() => {
|
|
onNodeFocus(id)
|
|
}, [id, onNodeFocus])
|
|
|
|
return (
|
|
<div
|
|
className="relative min-w-[240px] rounded-lg border-2 border-green-500 bg-white p-3 shadow-md dark:border-green-400 dark:bg-zinc-900"
|
|
onFocus={handleFocus}
|
|
onBlur={onNodeBlur}
|
|
>
|
|
{isLockedByOther && (
|
|
<NodeLockIndicator lock={lockInfo} color={lockInfo.color} />
|
|
)}
|
|
{isLockedByOther && (
|
|
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-lg bg-black/10">
|
|
<span className="rounded bg-black/70 px-2 py-1 text-xs text-white">
|
|
Being edited by {lockInfo.displayName}
|
|
</span>
|
|
</div>
|
|
)}
|
|
<Handle type="target" position={Position.Top} className="!bg-green-500" />
|
|
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<span className="text-[10px] font-bold uppercase text-green-600">Choice Node</span>
|
|
</div>
|
|
|
|
<input
|
|
type="text"
|
|
value={data.prompt || ''}
|
|
onChange={updatePrompt}
|
|
placeholder="Dialogue prompt..."
|
|
className="mb-3 w-full rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-zinc-700 dark:bg-zinc-800"
|
|
/>
|
|
|
|
<div className="space-y-3">
|
|
{data.options.map((option, index) => (
|
|
<div key={option.id} className="group relative">
|
|
<div className="flex items-center gap-1">
|
|
<input
|
|
type="text"
|
|
value={option.label}
|
|
onChange={(e) => updateOptionLabel(option.id, e.target.value)}
|
|
placeholder={`Option ${index + 1}`}
|
|
className="flex-1 rounded border border-zinc-200 px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-zinc-700 dark:bg-zinc-800"
|
|
/>
|
|
|
|
{/* Botão de Condição */}
|
|
<button
|
|
onClick={() => setEditingConditionOptionId(option.id)}
|
|
className={`p-1 rounded transition-colors ${
|
|
option.condition
|
|
? hasInvalidConditionReference(option)
|
|
? 'bg-red-100 text-red-600 dark:bg-red-900/30'
|
|
: 'bg-amber-100 text-amber-600 dark:bg-amber-900/30'
|
|
: 'text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800'
|
|
}`}
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
</svg>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => removeOption(option.id)}
|
|
className="text-zinc-400 hover:text-red-500"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Visualização da Condição */}
|
|
{option.condition && (
|
|
<div className={`mt-1 text-[9px] font-mono px-1 rounded ${
|
|
hasInvalidConditionReference(option) ? 'text-red-500 bg-red-50' : 'text-amber-600 bg-amber-50'
|
|
}`}>
|
|
IF: {option.condition.variableName} {option.condition.operator} {option.condition.value}
|
|
{hasInvalidConditionReference(option) && " (Variable Missing!)"}
|
|
</div>
|
|
)}
|
|
|
|
<Handle
|
|
type="source"
|
|
position={Position.Bottom}
|
|
id={option.id}
|
|
style={{ left: `${((index + 1) / (data.options.length + 1)) * 100}%` }}
|
|
className="!bg-green-500"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<button
|
|
onClick={addOption}
|
|
disabled={data.options.length >= MAX_OPTIONS}
|
|
className="mt-3 w-full border border-dashed border-zinc-300 py-1 text-[10px] text-zinc-500 hover:bg-zinc-50 disabled:opacity-50 dark:border-zinc-700 dark:hover:bg-zinc-800"
|
|
>
|
|
+ Add Option
|
|
</button>
|
|
|
|
{/* Modal de Edição de Condição */}
|
|
{editingOption && (
|
|
<OptionConditionEditor
|
|
condition={editingOption.condition}
|
|
onChange={(cond) => {
|
|
if (cond) {
|
|
handleSaveCondition(editingOption.id, cond)
|
|
} else {
|
|
handleRemoveCondition(editingOption.id)
|
|
}
|
|
}}
|
|
onClose={() => setEditingConditionOptionId(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
} |