diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx
index b1e4c8b..9edf400 100644
--- a/src/app/editor/[projectId]/FlowchartEditor.tsx
+++ b/src/app/editor/[projectId]/FlowchartEditor.tsx
@@ -124,7 +124,15 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
const edgeCount = edges.filter(
(e) => e.data?.condition?.variableId === variableId
).length
- return nodeCount + edgeCount
+ const choiceOptionCount = nodes.filter(
+ (n) => n.type === 'choice'
+ ).reduce((count, n) => {
+ const options = n.data?.options || []
+ return count + options.filter(
+ (opt: { condition?: { variableId?: string } }) => opt.condition?.variableId === variableId
+ ).length
+ }, 0)
+ return nodeCount + edgeCount + choiceOptionCount
},
[nodes, edges]
)
diff --git a/src/components/editor/OptionConditionEditor.tsx b/src/components/editor/OptionConditionEditor.tsx
new file mode 100644
index 0000000..b96b5c3
--- /dev/null
+++ b/src/components/editor/OptionConditionEditor.tsx
@@ -0,0 +1,315 @@
+'use client'
+
+import { useCallback, useMemo, useState } from 'react'
+import Combobox from '@/components/editor/Combobox'
+import type { ComboboxItem } from '@/components/editor/Combobox'
+import { useEditorContext } from '@/components/editor/EditorContext'
+import type { Condition } from '@/types/flowchart'
+
+type OptionConditionEditorProps = {
+ condition: Condition | undefined
+ onChange: (condition: Condition | undefined) => void
+ onClose: () => void
+}
+
+export default function OptionConditionEditor({
+ condition,
+ onChange,
+ onClose,
+}: OptionConditionEditorProps) {
+ const { variables, onAddVariable } = useEditorContext()
+
+ const [showAddForm, setShowAddForm] = useState(false)
+ const [newName, setNewName] = useState('')
+ const [newType, setNewType] = useState<'numeric' | 'string' | 'boolean'>('numeric')
+
+ const variableItems: ComboboxItem[] = useMemo(
+ () =>
+ variables.map((v) => ({
+ id: v.id,
+ label: v.name,
+ badge: v.type,
+ })),
+ [variables]
+ )
+
+ const selectedVariable = useMemo(() => {
+ if (!condition?.variableId) return undefined
+ return variables.find((v) => v.id === condition.variableId)
+ }, [condition?.variableId, variables])
+
+ const hasInvalidReference = useMemo(() => {
+ if (!condition?.variableId) return false
+ return !variables.some((v) => v.id === condition.variableId)
+ }, [condition?.variableId, variables])
+
+ const availableOperators = useMemo(() => {
+ if (!selectedVariable || selectedVariable.type === 'numeric') {
+ return [
+ { value: '==', label: '==' },
+ { value: '!=', label: '!=' },
+ { value: '>', label: '>' },
+ { value: '<', label: '<' },
+ { value: '>=', label: '>=' },
+ { value: '<=', label: '<=' },
+ ] as const
+ }
+ return [
+ { value: '==', label: '==' },
+ { value: '!=', label: '!=' },
+ ] as const
+ }, [selectedVariable])
+
+ const handleVariableSelect = useCallback(
+ (variableId: string) => {
+ const variable = variables.find((v) => v.id === variableId)
+ const defaultValue = variable
+ ? variable.type === 'numeric'
+ ? 0
+ : variable.type === 'boolean'
+ ? false
+ : ''
+ : 0
+ const validOperator =
+ variable && variable.type !== 'numeric' && condition?.operator && !['==', '!='].includes(condition.operator)
+ ? '=='
+ : condition?.operator || '=='
+
+ onChange({
+ variableName: variable?.name || '',
+ variableId,
+ operator: validOperator as Condition['operator'],
+ value: defaultValue,
+ })
+ },
+ [variables, condition?.operator, onChange]
+ )
+
+ const handleOperatorChange = useCallback(
+ (operator: string) => {
+ if (!condition) return
+ onChange({
+ ...condition,
+ operator: operator as Condition['operator'],
+ })
+ },
+ [condition, onChange]
+ )
+
+ const handleValueChange = useCallback(
+ (value: number | string | boolean) => {
+ if (!condition) return
+ onChange({
+ ...condition,
+ value,
+ })
+ },
+ [condition, onChange]
+ )
+
+ const handleRemoveCondition = useCallback(() => {
+ onChange(undefined)
+ onClose()
+ }, [onChange, onClose])
+
+ const handleAddNew = useCallback(() => {
+ setShowAddForm(true)
+ setNewName('')
+ setNewType('numeric')
+ }, [])
+
+ const handleSubmitNew = useCallback(() => {
+ if (!newName.trim()) return
+ const defaultValue = newType === 'numeric' ? 0 : newType === 'boolean' ? false : ''
+ const newId = onAddVariable(newName.trim(), newType, defaultValue)
+ onChange({
+ variableName: newName.trim(),
+ variableId: newId,
+ operator: '==',
+ value: defaultValue,
+ })
+ setShowAddForm(false)
+ }, [newName, newType, onAddVariable, onChange])
+
+ const handleCancelNew = useCallback(() => {
+ setShowAddForm(false)
+ }, [])
+
+ const renderValueInput = () => {
+ const varType = selectedVariable?.type || 'numeric'
+
+ if (varType === 'boolean') {
+ return (
+
+ )
+ }
+
+ if (varType === 'string') {
+ return (
+ handleValueChange(e.target.value)}
+ placeholder="Value..."
+ className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400"
+ />
+ )
+ }
+
+ return (
+ handleValueChange(parseFloat(e.target.value) || 0)}
+ className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
+ />
+ )
+ }
+
+ return (
+
+
+
+
+
+ Option Condition
+
+
+
+
+ {/* Variable selector */}
+
+
+
+
+
+ {hasInvalidReference && (
+
+ Variable not found
+
+ )}
+
+
+ {/* Inline add form */}
+ {showAddForm && (
+
+
+ New variable
+
+
+ setNewName(e.target.value)}
+ placeholder="Name"
+ className="w-full rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400"
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') handleSubmitNew()
+ if (e.key === 'Escape') handleCancelNew()
+ }}
+ autoFocus
+ />
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Operator and value (shown when variable is selected) */}
+ {condition?.variableId && (
+ <>
+
+
+
+
+
+
+
+ {renderValueInput()}
+
+ >
+ )}
+
+ {/* Actions */}
+
+ {condition?.variableId ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ )
+}
diff --git a/src/components/editor/nodes/ChoiceNode.tsx b/src/components/editor/nodes/ChoiceNode.tsx
index 7073d2d..1eb9e5a 100644
--- a/src/components/editor/nodes/ChoiceNode.tsx
+++ b/src/components/editor/nodes/ChoiceNode.tsx
@@ -1,12 +1,16 @@
'use client'
-import { useCallback, ChangeEvent } from 'react'
+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 type { Condition } from '@/types/flowchart'
type ChoiceOption = {
id: string
label: string
+ condition?: Condition
}
type ChoiceNodeData = {
@@ -19,6 +23,8 @@ const MAX_OPTIONS = 6
export default function ChoiceNode({ id, data }: NodeProps) {
const { setNodes } = useReactFlow()
+ const { variables } = useEditorContext()
+ const [editingConditionOptionId, setEditingConditionOptionId] = useState(null)
const updatePrompt = useCallback(
(e: ChangeEvent) => {
@@ -54,6 +60,27 @@ export default function ChoiceNode({ id, data }: NodeProps) {
[id, setNodes]
)
+ const updateOptionCondition = useCallback(
+ (optionId: string, condition: Condition | undefined) => {
+ 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
+ )
+ )
+ },
+ [id, setNodes]
+ )
+
const addOption = useCallback(() => {
if (data.options.length >= MAX_OPTIONS) return
setNodes((nodes) =>
@@ -96,68 +123,118 @@ export default function ChoiceNode({ id, data }: NodeProps) {
[id, data.options.length, setNodes]
)
+ 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]
+ )
+
return (
-
-
+ <>
+
+
-
- Choice
+
+ Choice
+
+
+
+
+
+ {data.options.map((option, index) => (
+
+
+
updateOptionLabel(option.id, e.target.value)}
+ placeholder={`Option ${index + 1}`}
+ className="flex-1 rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
+ />
+
+
+
+
+ {option.condition?.variableId && (
+
+ if {option.condition.variableName} {option.condition.operator} {String(option.condition.value)}
+
+ )}
+
+ ))}
+
+
+
-
-
-
- {data.options.map((option, index) => (
-
- updateOptionLabel(option.id, e.target.value)}
- placeholder={`Option ${index + 1}`}
- className="flex-1 rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
- />
-
-
-
- ))}
-
-
-
-
+ {editingOption && (
+
updateOptionCondition(editingOption.id, condition)}
+ onClose={() => setEditingConditionOptionId(null)}
+ />
+ )}
+ >
)
}
diff --git a/src/types/flowchart.ts b/src/types/flowchart.ts
index 0379054..4f4461c 100644
--- a/src/types/flowchart.ts
+++ b/src/types/flowchart.ts
@@ -21,6 +21,14 @@ export type Variable = {
description?: string;
};
+// Condition type for conditional edges and choice options
+export type Condition = {
+ variableName: string;
+ variableId?: string;
+ operator: '>' | '<' | '==' | '>=' | '<=' | '!=';
+ value: number | string | boolean;
+};
+
// DialogueNode type: represents character speech/dialogue
export type DialogueNodeData = {
speaker?: string;
@@ -39,6 +47,7 @@ export type DialogueNode = {
export type ChoiceOption = {
id: string;
label: string;
+ condition?: Condition;
};
// ChoiceNode type: represents branching decisions
@@ -70,14 +79,6 @@ export type VariableNode = {
// Union type for all node types
export type FlowchartNode = DialogueNode | ChoiceNode | VariableNode;
-// Condition type for conditional edges
-export type Condition = {
- variableName: string;
- variableId?: string;
- operator: '>' | '<' | '==' | '>=' | '<=' | '!=';
- value: number | string | boolean;
-};
-
// FlowchartEdge type: represents connections between nodes
export type FlowchartEdge = {
id: string;