developing #10
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
|
|
@ -23,8 +23,9 @@ import DialogueNode from '@/components/editor/nodes/DialogueNode'
|
|||
import ChoiceNode from '@/components/editor/nodes/ChoiceNode'
|
||||
import VariableNode from '@/components/editor/nodes/VariableNode'
|
||||
import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal'
|
||||
import ConditionEditor from '@/components/editor/ConditionEditor'
|
||||
import { EditorProvider } from '@/components/editor/EditorContext'
|
||||
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable } from '@/types/flowchart'
|
||||
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart'
|
||||
|
||||
type FlowchartEditorProps = {
|
||||
projectId: string
|
||||
|
|
@ -81,6 +82,7 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
|||
const [characters, setCharacters] = useState<Character[]>(initialData.characters)
|
||||
const [variables, setVariables] = useState<Variable[]>(initialData.variables)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
|
||||
|
||||
const handleAddCharacter = useCallback(
|
||||
(name: string, color: string): string => {
|
||||
|
|
@ -219,6 +221,31 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
|||
console.log('Deleted edges:', deletedEdges.map((e) => e.id))
|
||||
}, [])
|
||||
|
||||
// Handle edge click to open condition editor
|
||||
const onEdgeClick = useCallback((_event: React.MouseEvent, edge: Edge) => {
|
||||
setSelectedEdgeId(edge.id)
|
||||
}, [])
|
||||
|
||||
// Handle condition change from ConditionEditor
|
||||
const handleConditionChange = useCallback(
|
||||
(edgeId: string, condition: Condition | undefined) => {
|
||||
setEdges((eds) =>
|
||||
eds.map((edge) =>
|
||||
edge.id === edgeId
|
||||
? { ...edge, data: condition ? { condition } : undefined }
|
||||
: edge
|
||||
)
|
||||
)
|
||||
},
|
||||
[setEdges]
|
||||
)
|
||||
|
||||
// Get the selected edge's condition data
|
||||
const selectedEdge = useMemo(
|
||||
() => (selectedEdgeId ? edges.find((e) => e.id === selectedEdgeId) : null),
|
||||
[selectedEdgeId, edges]
|
||||
)
|
||||
|
||||
return (
|
||||
<EditorProvider value={editorContextValue}>
|
||||
<div className="flex h-full w-full flex-col">
|
||||
|
|
@ -239,6 +266,7 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
|||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onEdgesDelete={onEdgesDelete}
|
||||
onEdgeClick={onEdgeClick}
|
||||
onConnect={onConnect}
|
||||
deleteKeyCode={['Delete', 'Backspace']}
|
||||
fitView
|
||||
|
|
@ -258,6 +286,14 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
|||
getVariableUsageCount={getVariableUsageCount}
|
||||
/>
|
||||
)}
|
||||
{selectedEdge && (
|
||||
<ConditionEditor
|
||||
edgeId={selectedEdge.id}
|
||||
condition={selectedEdge.data?.condition}
|
||||
onChange={handleConditionChange}
|
||||
onClose={() => setSelectedEdgeId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</EditorProvider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,322 @@
|
|||
'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 ConditionEditorProps = {
|
||||
edgeId: string
|
||||
condition: Condition | undefined
|
||||
onChange: (edgeId: string, condition: Condition | undefined) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function ConditionEditor({
|
||||
edgeId,
|
||||
condition,
|
||||
onChange,
|
||||
onClose,
|
||||
}: ConditionEditorProps) {
|
||||
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])
|
||||
|
||||
// Determine operators based on variable type
|
||||
const availableOperators = useMemo(() => {
|
||||
if (!selectedVariable || selectedVariable.type === 'numeric') {
|
||||
return [
|
||||
{ value: '==', label: '==' },
|
||||
{ value: '!=', label: '!=' },
|
||||
{ value: '>', label: '>' },
|
||||
{ value: '<', label: '<' },
|
||||
{ value: '>=', label: '>=' },
|
||||
{ value: '<=', label: '<=' },
|
||||
] as const
|
||||
}
|
||||
// string and boolean only support == and !=
|
||||
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
|
||||
// Reset operator if current one is not valid for new type
|
||||
const validOperator =
|
||||
variable && variable.type !== 'numeric' && condition?.operator && !['==', '!='].includes(condition.operator)
|
||||
? '=='
|
||||
: condition?.operator || '=='
|
||||
|
||||
onChange(edgeId, {
|
||||
variableName: variable?.name || '',
|
||||
variableId,
|
||||
operator: validOperator as Condition['operator'],
|
||||
value: defaultValue,
|
||||
})
|
||||
},
|
||||
[variables, condition?.operator, edgeId, onChange]
|
||||
)
|
||||
|
||||
const handleOperatorChange = useCallback(
|
||||
(operator: string) => {
|
||||
if (!condition) return
|
||||
onChange(edgeId, {
|
||||
...condition,
|
||||
operator: operator as Condition['operator'],
|
||||
})
|
||||
},
|
||||
[condition, edgeId, onChange]
|
||||
)
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(value: number | string | boolean) => {
|
||||
if (!condition) return
|
||||
onChange(edgeId, {
|
||||
...condition,
|
||||
value,
|
||||
})
|
||||
},
|
||||
[condition, edgeId, onChange]
|
||||
)
|
||||
|
||||
const handleRemoveCondition = useCallback(() => {
|
||||
onChange(edgeId, undefined)
|
||||
onClose()
|
||||
}, [edgeId, 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(edgeId, {
|
||||
variableName: newName.trim(),
|
||||
variableId: newId,
|
||||
operator: '==',
|
||||
value: defaultValue,
|
||||
})
|
||||
setShowAddForm(false)
|
||||
}, [newName, newType, onAddVariable, edgeId, onChange])
|
||||
|
||||
const handleCancelNew = useCallback(() => {
|
||||
setShowAddForm(false)
|
||||
}, [])
|
||||
|
||||
// Render value input based on variable type
|
||||
const renderValueInput = () => {
|
||||
const varType = selectedVariable?.type || 'numeric'
|
||||
|
||||
if (varType === 'boolean') {
|
||||
return (
|
||||
<select
|
||||
value={String(condition?.value ?? false)}
|
||||
onChange={(e) => handleValueChange(e.target.value === 'true')}
|
||||
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"
|
||||
>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
if (varType === 'string') {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={String(condition?.value ?? '')}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// numeric
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={typeof condition?.value === 'number' ? condition.value : 0}
|
||||
onChange={(e) => 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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||
<div className="relative w-full max-w-sm rounded-lg border border-zinc-200 bg-white p-4 shadow-xl dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
Edge Condition
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-0.5 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-700 dark:hover:text-zinc-300"
|
||||
>
|
||||
<svg className="h-4 w-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>
|
||||
|
||||
{/* Variable selector */}
|
||||
<div className="mb-3">
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
Variable
|
||||
</label>
|
||||
<div className={hasInvalidReference ? 'rounded ring-2 ring-orange-500' : ''}>
|
||||
<Combobox
|
||||
items={variableItems}
|
||||
value={condition?.variableId}
|
||||
onChange={handleVariableSelect}
|
||||
placeholder="Select variable..."
|
||||
onAddNew={handleAddNew}
|
||||
/>
|
||||
</div>
|
||||
{hasInvalidReference && (
|
||||
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
|
||||
Variable not found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline add form */}
|
||||
{showAddForm && (
|
||||
<div className="mb-3 rounded border border-zinc-300 bg-zinc-50 p-2 dark:border-zinc-600 dark:bg-zinc-900">
|
||||
<div className="mb-1 text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
New variable
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
<select
|
||||
value={newType}
|
||||
onChange={(e) => setNewType(e.target.value as 'numeric' | 'string' | 'boolean')}
|
||||
className="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"
|
||||
>
|
||||
<option value="numeric">numeric</option>
|
||||
<option value="string">string</option>
|
||||
<option value="boolean">boolean</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-end gap-1">
|
||||
<button
|
||||
onClick={handleCancelNew}
|
||||
className="rounded px-2 py-0.5 text-xs text-zinc-500 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmitNew}
|
||||
disabled={!newName.trim()}
|
||||
className="rounded bg-blue-600 px-2 py-0.5 text-xs text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Operator and value (shown when variable is selected) */}
|
||||
{condition?.variableId && (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
Operator
|
||||
</label>
|
||||
<select
|
||||
value={condition.operator || '=='}
|
||||
onChange={(e) => handleOperatorChange(e.target.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"
|
||||
>
|
||||
{availableOperators.map((op) => (
|
||||
<option key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
Value
|
||||
</label>
|
||||
{renderValueInput()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between">
|
||||
{condition?.variableId ? (
|
||||
<button
|
||||
onClick={handleRemoveCondition}
|
||||
className="rounded px-2 py-1 text-xs text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
>
|
||||
Remove condition
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-700"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -75,7 +75,7 @@ export type Condition = {
|
|||
variableName: string;
|
||||
variableId?: string;
|
||||
operator: '>' | '<' | '==' | '>=' | '<=' | '!=';
|
||||
value: number;
|
||||
value: number | string | boolean;
|
||||
};
|
||||
|
||||
// FlowchartEdge type: represents connections between nodes
|
||||
|
|
|
|||
Loading…
Reference in New Issue