developing #10
|
|
@ -23,6 +23,7 @@ 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 { EditorProvider } from '@/components/editor/EditorContext'
|
||||
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable } from '@/types/flowchart'
|
||||
|
||||
type FlowchartEditorProps = {
|
||||
|
|
@ -81,6 +82,31 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
|||
const [variables, setVariables] = useState<Variable[]>(initialData.variables)
|
||||
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(
|
||||
(characterId: string) => {
|
||||
return nodes.filter((n) => n.type === 'dialogue' && n.data?.characterId === characterId).length
|
||||
|
|
@ -194,44 +220,46 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
|||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<Toolbar
|
||||
onAddDialogue={handleAddDialogue}
|
||||
onAddChoice={handleAddChoice}
|
||||
onAddVariable={handleAddVariable}
|
||||
onSave={handleSave}
|
||||
onExport={handleExport}
|
||||
onImport={handleImport}
|
||||
onProjectSettings={() => setShowSettings(true)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onEdgesDelete={onEdgesDelete}
|
||||
onConnect={onConnect}
|
||||
deleteKeyCode={['Delete', 'Backspace']}
|
||||
fitView
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||
<Controls position="bottom-right" />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
{showSettings && (
|
||||
<ProjectSettingsModal
|
||||
characters={characters}
|
||||
variables={variables}
|
||||
onCharactersChange={setCharacters}
|
||||
onVariablesChange={setVariables}
|
||||
onClose={() => setShowSettings(false)}
|
||||
getCharacterUsageCount={getCharacterUsageCount}
|
||||
getVariableUsageCount={getVariableUsageCount}
|
||||
<EditorProvider value={editorContextValue}>
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<Toolbar
|
||||
onAddDialogue={handleAddDialogue}
|
||||
onAddChoice={handleAddChoice}
|
||||
onAddVariable={handleAddVariable}
|
||||
onSave={handleSave}
|
||||
onExport={handleExport}
|
||||
onImport={handleImport}
|
||||
onProjectSettings={() => setShowSettings(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onEdgesDelete={onEdgesDelete}
|
||||
onConnect={onConnect}
|
||||
deleteKeyCode={['Delete', 'Backspace']}
|
||||
fitView
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||
<Controls position="bottom-right" />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
{showSettings && (
|
||||
<ProjectSettingsModal
|
||||
characters={characters}
|
||||
variables={variables}
|
||||
onCharactersChange={setCharacters}
|
||||
onVariablesChange={setVariables}
|
||||
onClose={() => setShowSettings(false)}
|
||||
getCharacterUsageCount={getCharacterUsageCount}
|
||||
getVariableUsageCount={getVariableUsageCount}
|
||||
/>
|
||||
)}
|
||||
</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'
|
||||
|
||||
import { useCallback, ChangeEvent } from 'react'
|
||||
import { useCallback, useMemo, useState, ChangeEvent } from 'react'
|
||||
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 = {
|
||||
variableName: string
|
||||
variableId?: string
|
||||
operation: 'set' | 'add' | 'subtract'
|
||||
value: number
|
||||
}
|
||||
|
||||
export default function VariableNode({ id, data }: NodeProps<VariableNodeData>) {
|
||||
const { setNodes } = useReactFlow()
|
||||
const { variables, onAddVariable } = useEditorContext()
|
||||
|
||||
const updateVariableName = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
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 (!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) =>
|
||||
nodes.map((node) =>
|
||||
node.id === id
|
||||
? { ...node, data: { ...node.data, variableName: e.target.value } }
|
||||
? { ...node, data: { ...node.data, ...updates } }
|
||||
: node
|
||||
)
|
||||
)
|
||||
|
|
@ -25,35 +54,69 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
|
|||
[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(
|
||||
(e: ChangeEvent<HTMLSelectElement>) => {
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) =>
|
||||
node.id === id
|
||||
? { ...node, data: { ...node.data, operation: e.target.value as 'set' | 'add' | 'subtract' } }
|
||||
: node
|
||||
)
|
||||
)
|
||||
updateNodeData({ operation: e.target.value as 'set' | 'add' | 'subtract' })
|
||||
},
|
||||
[id, setNodes]
|
||||
[updateNodeData]
|
||||
)
|
||||
|
||||
const updateValue = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseFloat(e.target.value) || 0
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) =>
|
||||
node.id === id
|
||||
? { ...node, data: { ...node.data, value } }
|
||||
: node
|
||||
)
|
||||
)
|
||||
updateNodeData({ value })
|
||||
},
|
||||
[id, setNodes]
|
||||
[updateNodeData]
|
||||
)
|
||||
|
||||
// Filter operations based on selected variable type
|
||||
const isNumeric = !selectedVariable || selectedVariable.type === 'numeric'
|
||||
|
||||
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
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
|
|
@ -65,13 +128,68 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
|
|||
Variable
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={data.variableName || ''}
|
||||
onChange={updateVariableName}
|
||||
placeholder="variableName"
|
||||
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"
|
||||
/>
|
||||
<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
|
||||
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-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">
|
||||
<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"
|
||||
>
|
||||
<option value="set">set</option>
|
||||
<option value="add">add</option>
|
||||
<option value="subtract">subtract</option>
|
||||
{isNumeric && <option value="add">add</option>}
|
||||
{isNumeric && <option value="subtract">subtract</option>}
|
||||
</select>
|
||||
|
||||
<input
|
||||
|
|
|
|||
Loading…
Reference in New Issue