feat: [US-059] - Variable node variable dropdown
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
548f3743d1
commit
5493adf44a
|
|
@ -23,6 +23,7 @@ import DialogueNode from '@/components/editor/nodes/DialogueNode'
|
||||||
import ChoiceNode from '@/components/editor/nodes/ChoiceNode'
|
import ChoiceNode from '@/components/editor/nodes/ChoiceNode'
|
||||||
import VariableNode from '@/components/editor/nodes/VariableNode'
|
import VariableNode from '@/components/editor/nodes/VariableNode'
|
||||||
import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal'
|
import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal'
|
||||||
|
import { EditorProvider } from '@/components/editor/EditorContext'
|
||||||
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable } from '@/types/flowchart'
|
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable } from '@/types/flowchart'
|
||||||
|
|
||||||
type FlowchartEditorProps = {
|
type FlowchartEditorProps = {
|
||||||
|
|
@ -81,6 +82,31 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
||||||
const [variables, setVariables] = useState<Variable[]>(initialData.variables)
|
const [variables, setVariables] = useState<Variable[]>(initialData.variables)
|
||||||
const [showSettings, setShowSettings] = useState(false)
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
|
|
||||||
|
const handleAddCharacter = useCallback(
|
||||||
|
(name: string, color: string): string => {
|
||||||
|
const id = nanoid()
|
||||||
|
const newCharacter: Character = { id, name, color }
|
||||||
|
setCharacters((prev) => [...prev, newCharacter])
|
||||||
|
return id
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleAddVariableDefinition = useCallback(
|
||||||
|
(name: string, type: 'numeric' | 'string' | 'boolean', initialValue: number | string | boolean): string => {
|
||||||
|
const id = nanoid()
|
||||||
|
const newVariable: Variable = { id, name, type, initialValue }
|
||||||
|
setVariables((prev) => [...prev, newVariable])
|
||||||
|
return id
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const editorContextValue = useMemo(
|
||||||
|
() => ({ characters, onAddCharacter: handleAddCharacter, variables, onAddVariable: handleAddVariableDefinition }),
|
||||||
|
[characters, handleAddCharacter, variables, handleAddVariableDefinition]
|
||||||
|
)
|
||||||
|
|
||||||
const getCharacterUsageCount = useCallback(
|
const getCharacterUsageCount = useCallback(
|
||||||
(characterId: string) => {
|
(characterId: string) => {
|
||||||
return nodes.filter((n) => n.type === 'dialogue' && n.data?.characterId === characterId).length
|
return nodes.filter((n) => n.type === 'dialogue' && n.data?.characterId === characterId).length
|
||||||
|
|
@ -194,6 +220,7 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<EditorProvider value={editorContextValue}>
|
||||||
<div className="flex h-full w-full flex-col">
|
<div className="flex h-full w-full flex-col">
|
||||||
<Toolbar
|
<Toolbar
|
||||||
onAddDialogue={handleAddDialogue}
|
onAddDialogue={handleAddDialogue}
|
||||||
|
|
@ -232,6 +259,7 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</EditorProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { createContext, useContext } from 'react'
|
||||||
|
import type { Character, Variable } from '@/types/flowchart'
|
||||||
|
|
||||||
|
type EditorContextValue = {
|
||||||
|
characters: Character[]
|
||||||
|
onAddCharacter: (name: string, color: string) => string // returns new character id
|
||||||
|
variables: Variable[]
|
||||||
|
onAddVariable: (name: string, type: 'numeric' | 'string' | 'boolean', initialValue: number | string | boolean) => string // returns new variable id
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditorContext = createContext<EditorContextValue>({
|
||||||
|
characters: [],
|
||||||
|
onAddCharacter: () => '',
|
||||||
|
variables: [],
|
||||||
|
onAddVariable: () => '',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const EditorProvider = EditorContext.Provider
|
||||||
|
|
||||||
|
export function useEditorContext() {
|
||||||
|
return useContext(EditorContext)
|
||||||
|
}
|
||||||
|
|
@ -1,23 +1,52 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, ChangeEvent } from 'react'
|
import { useCallback, useMemo, useState, ChangeEvent } from 'react'
|
||||||
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
||||||
|
import Combobox from '@/components/editor/Combobox'
|
||||||
|
import type { ComboboxItem } from '@/components/editor/Combobox'
|
||||||
|
import { useEditorContext } from '@/components/editor/EditorContext'
|
||||||
|
|
||||||
type VariableNodeData = {
|
type VariableNodeData = {
|
||||||
variableName: string
|
variableName: string
|
||||||
|
variableId?: string
|
||||||
operation: 'set' | 'add' | 'subtract'
|
operation: 'set' | 'add' | 'subtract'
|
||||||
value: number
|
value: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VariableNode({ id, data }: NodeProps<VariableNodeData>) {
|
export default function VariableNode({ id, data }: NodeProps<VariableNodeData>) {
|
||||||
const { setNodes } = useReactFlow()
|
const { setNodes } = useReactFlow()
|
||||||
|
const { variables, onAddVariable } = useEditorContext()
|
||||||
|
|
||||||
const updateVariableName = useCallback(
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
(e: ChangeEvent<HTMLInputElement>) => {
|
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 (!data.variableId) return undefined
|
||||||
|
return variables.find((v) => v.id === data.variableId)
|
||||||
|
}, [data.variableId, variables])
|
||||||
|
|
||||||
|
const hasInvalidReference = useMemo(() => {
|
||||||
|
if (!data.variableId) return false
|
||||||
|
return !variables.some((v) => v.id === data.variableId)
|
||||||
|
}, [data.variableId, variables])
|
||||||
|
|
||||||
|
const updateNodeData = useCallback(
|
||||||
|
(updates: Partial<VariableNodeData>) => {
|
||||||
setNodes((nodes) =>
|
setNodes((nodes) =>
|
||||||
nodes.map((node) =>
|
nodes.map((node) =>
|
||||||
node.id === id
|
node.id === id
|
||||||
? { ...node, data: { ...node.data, variableName: e.target.value } }
|
? { ...node, data: { ...node.data, ...updates } }
|
||||||
: node
|
: node
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -25,35 +54,69 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
|
||||||
[id, setNodes]
|
[id, setNodes]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleVariableSelect = useCallback(
|
||||||
|
(variableId: string) => {
|
||||||
|
const variable = variables.find((v) => v.id === variableId)
|
||||||
|
const updates: Partial<VariableNodeData> = {
|
||||||
|
variableId,
|
||||||
|
variableName: variable?.name || '',
|
||||||
|
}
|
||||||
|
// Reset operation to 'set' if current operation is not valid for the variable's type
|
||||||
|
if (variable && variable.type !== 'numeric' && (data.operation === 'add' || data.operation === 'subtract')) {
|
||||||
|
updates.operation = 'set'
|
||||||
|
}
|
||||||
|
updateNodeData(updates)
|
||||||
|
},
|
||||||
|
[variables, data.operation, updateNodeData]
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
updateNodeData({
|
||||||
|
variableId: newId,
|
||||||
|
variableName: newName.trim(),
|
||||||
|
})
|
||||||
|
setShowAddForm(false)
|
||||||
|
}, [newName, newType, onAddVariable, updateNodeData])
|
||||||
|
|
||||||
|
const handleCancelNew = useCallback(() => {
|
||||||
|
setShowAddForm(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const updateOperation = useCallback(
|
const updateOperation = useCallback(
|
||||||
(e: ChangeEvent<HTMLSelectElement>) => {
|
(e: ChangeEvent<HTMLSelectElement>) => {
|
||||||
setNodes((nodes) =>
|
updateNodeData({ operation: e.target.value as 'set' | 'add' | 'subtract' })
|
||||||
nodes.map((node) =>
|
|
||||||
node.id === id
|
|
||||||
? { ...node, data: { ...node.data, operation: e.target.value as 'set' | 'add' | 'subtract' } }
|
|
||||||
: node
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
[id, setNodes]
|
[updateNodeData]
|
||||||
)
|
)
|
||||||
|
|
||||||
const updateValue = useCallback(
|
const updateValue = useCallback(
|
||||||
(e: ChangeEvent<HTMLInputElement>) => {
|
(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = parseFloat(e.target.value) || 0
|
const value = parseFloat(e.target.value) || 0
|
||||||
setNodes((nodes) =>
|
updateNodeData({ value })
|
||||||
nodes.map((node) =>
|
|
||||||
node.id === id
|
|
||||||
? { ...node, data: { ...node.data, value } }
|
|
||||||
: node
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
[id, setNodes]
|
[updateNodeData]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Filter operations based on selected variable type
|
||||||
|
const isNumeric = !selectedVariable || selectedVariable.type === 'numeric'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-[200px] rounded-lg border-2 border-orange-500 bg-orange-50 p-3 shadow-md dark:border-orange-400 dark:bg-orange-950">
|
<div
|
||||||
|
className={`min-w-[200px] rounded-lg border-2 ${
|
||||||
|
hasInvalidReference
|
||||||
|
? 'border-orange-500 dark:border-orange-400'
|
||||||
|
: 'border-orange-500 dark:border-orange-400'
|
||||||
|
} bg-orange-50 p-3 shadow-md dark:bg-orange-950`}
|
||||||
|
>
|
||||||
<Handle
|
<Handle
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Top}
|
position={Position.Top}
|
||||||
|
|
@ -65,13 +128,68 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
|
||||||
Variable
|
Variable
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={`mb-2 ${hasInvalidReference ? 'rounded ring-2 ring-orange-500' : ''}`}>
|
||||||
|
<Combobox
|
||||||
|
items={variableItems}
|
||||||
|
value={data.variableId}
|
||||||
|
onChange={handleVariableSelect}
|
||||||
|
placeholder="Select variable..."
|
||||||
|
onAddNew={handleAddNew}
|
||||||
|
/>
|
||||||
|
{hasInvalidReference && (
|
||||||
|
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
|
||||||
|
Variable not found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAddForm && (
|
||||||
|
<div className="mb-2 rounded border border-orange-300 bg-white p-2 dark:border-orange-600 dark:bg-zinc-800">
|
||||||
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={data.variableName || ''}
|
value={newName}
|
||||||
onChange={updateVariableName}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
placeholder="variableName"
|
placeholder="Name"
|
||||||
className="mb-2 w-full rounded border border-orange-300 bg-white px-2 py-1 text-sm focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 dark:border-orange-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
className="w-full rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-orange-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-orange-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-orange-600 px-2 py-0.5 text-xs text-white hover:bg-orange-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mb-2 flex gap-2">
|
<div className="mb-2 flex gap-2">
|
||||||
<select
|
<select
|
||||||
|
|
@ -80,8 +198,8 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
|
||||||
className="flex-1 rounded border border-orange-300 bg-white px-2 py-1 text-sm focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 dark:border-orange-600 dark:bg-zinc-800 dark:text-white"
|
className="flex-1 rounded border border-orange-300 bg-white px-2 py-1 text-sm focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 dark:border-orange-600 dark:bg-zinc-800 dark:text-white"
|
||||||
>
|
>
|
||||||
<option value="set">set</option>
|
<option value="set">set</option>
|
||||||
<option value="add">add</option>
|
{isNumeric && <option value="add">add</option>}
|
||||||
<option value="subtract">subtract</option>
|
{isNumeric && <option value="subtract">subtract</option>}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue