developing #10
|
|
@ -25,11 +25,13 @@ import VariableNode from '@/components/editor/nodes/VariableNode'
|
||||||
import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal'
|
import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal'
|
||||||
import ConditionEditor from '@/components/editor/ConditionEditor'
|
import ConditionEditor from '@/components/editor/ConditionEditor'
|
||||||
import { EditorProvider } from '@/components/editor/EditorContext'
|
import { EditorProvider } from '@/components/editor/EditorContext'
|
||||||
|
import Toast from '@/components/Toast'
|
||||||
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart'
|
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart'
|
||||||
|
|
||||||
type FlowchartEditorProps = {
|
type FlowchartEditorProps = {
|
||||||
projectId: string
|
projectId: string
|
||||||
initialData: FlowchartData
|
initialData: FlowchartData
|
||||||
|
needsMigration?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert our FlowchartNode type to React Flow Node type
|
// Convert our FlowchartNode type to React Flow Node type
|
||||||
|
|
@ -58,8 +60,148 @@ function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RANDOM_COLORS = [
|
||||||
|
'#EF4444', '#F97316', '#F59E0B', '#10B981',
|
||||||
|
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
|
||||||
|
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
|
||||||
|
]
|
||||||
|
|
||||||
|
function randomHexColor(): string {
|
||||||
|
return RANDOM_COLORS[Math.floor(Math.random() * RANDOM_COLORS.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute auto-migration of existing free-text values to character/variable definitions
|
||||||
|
function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) {
|
||||||
|
if (!shouldMigrate) {
|
||||||
|
return {
|
||||||
|
characters: initialData.characters,
|
||||||
|
variables: initialData.variables,
|
||||||
|
nodes: initialData.nodes,
|
||||||
|
edges: initialData.edges,
|
||||||
|
toastMessage: null as string | null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect unique speaker names from dialogue nodes
|
||||||
|
const speakerNames = new Set<string>()
|
||||||
|
initialData.nodes.forEach((node) => {
|
||||||
|
if (node.type === 'dialogue' && node.data?.speaker) {
|
||||||
|
speakerNames.add(node.data.speaker)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create character definitions from unique speaker names
|
||||||
|
const newCharacters: Character[] = []
|
||||||
|
const speakerToCharacterId = new Map<string, string>()
|
||||||
|
speakerNames.forEach((name) => {
|
||||||
|
const id = nanoid()
|
||||||
|
newCharacters.push({ id, name, color: randomHexColor() })
|
||||||
|
speakerToCharacterId.set(name, id)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Collect unique variable names from variable nodes, edge conditions, and choice option conditions
|
||||||
|
const variableNames = new Set<string>()
|
||||||
|
initialData.nodes.forEach((node) => {
|
||||||
|
if (node.type === 'variable' && node.data.variableName) {
|
||||||
|
variableNames.add(node.data.variableName)
|
||||||
|
}
|
||||||
|
if (node.type === 'choice' && node.data.options) {
|
||||||
|
node.data.options.forEach((opt) => {
|
||||||
|
if (opt.condition?.variableName) {
|
||||||
|
variableNames.add(opt.condition.variableName)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
initialData.edges.forEach((edge) => {
|
||||||
|
if (edge.data?.condition?.variableName) {
|
||||||
|
variableNames.add(edge.data.condition.variableName)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create variable definitions from unique variable names
|
||||||
|
const newVariables: Variable[] = []
|
||||||
|
const varNameToId = new Map<string, string>()
|
||||||
|
variableNames.forEach((name) => {
|
||||||
|
const id = nanoid()
|
||||||
|
newVariables.push({ id, name, type: 'numeric', initialValue: 0 })
|
||||||
|
varNameToId.set(name, id)
|
||||||
|
})
|
||||||
|
|
||||||
|
// If nothing to migrate, return original data
|
||||||
|
if (newCharacters.length === 0 && newVariables.length === 0) {
|
||||||
|
return {
|
||||||
|
characters: initialData.characters,
|
||||||
|
variables: initialData.variables,
|
||||||
|
nodes: initialData.nodes,
|
||||||
|
edges: initialData.edges,
|
||||||
|
toastMessage: null as string | null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update nodes with characterId/variableId references
|
||||||
|
const migratedNodes = initialData.nodes.map((node) => {
|
||||||
|
if (node.type === 'dialogue' && node.data.speaker) {
|
||||||
|
const characterId = speakerToCharacterId.get(node.data.speaker)
|
||||||
|
if (characterId) {
|
||||||
|
return { ...node, data: { ...node.data, characterId } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.type === 'variable' && node.data.variableName) {
|
||||||
|
const variableId = varNameToId.get(node.data.variableName)
|
||||||
|
if (variableId) {
|
||||||
|
return { ...node, data: { ...node.data, variableId } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.type === 'choice' && node.data.options) {
|
||||||
|
const updatedOptions = node.data.options.map((opt) => {
|
||||||
|
if (opt.condition?.variableName) {
|
||||||
|
const variableId = varNameToId.get(opt.condition.variableName)
|
||||||
|
if (variableId) {
|
||||||
|
return { ...opt, condition: { ...opt.condition, variableId } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return opt
|
||||||
|
})
|
||||||
|
return { ...node, data: { ...node.data, options: updatedOptions } }
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
}) as typeof initialData.nodes
|
||||||
|
|
||||||
|
// Update edges with variableId references
|
||||||
|
const migratedEdges = initialData.edges.map((edge) => {
|
||||||
|
if (edge.data?.condition?.variableName) {
|
||||||
|
const variableId = varNameToId.get(edge.data.condition.variableName)
|
||||||
|
if (variableId) {
|
||||||
|
return {
|
||||||
|
...edge,
|
||||||
|
data: { ...edge.data, condition: { ...edge.data.condition, variableId } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return edge
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build toast message
|
||||||
|
const parts: string[] = []
|
||||||
|
if (newCharacters.length > 0) {
|
||||||
|
parts.push(`${newCharacters.length} character${newCharacters.length > 1 ? 's' : ''}`)
|
||||||
|
}
|
||||||
|
if (newVariables.length > 0) {
|
||||||
|
parts.push(`${newVariables.length} variable${newVariables.length > 1 ? 's' : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
characters: newCharacters,
|
||||||
|
variables: newVariables,
|
||||||
|
nodes: migratedNodes,
|
||||||
|
edges: migratedEdges,
|
||||||
|
toastMessage: `Auto-imported ${parts.join(' and ')} from existing data`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Inner component that uses useReactFlow hook
|
// Inner component that uses useReactFlow hook
|
||||||
function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
function FlowchartEditorInner({ initialData, needsMigration }: FlowchartEditorProps) {
|
||||||
// Define custom node types - memoized to prevent re-renders
|
// Define custom node types - memoized to prevent re-renders
|
||||||
const nodeTypes: NodeTypes = useMemo(
|
const nodeTypes: NodeTypes = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|
@ -72,17 +214,21 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
||||||
|
|
||||||
const { getViewport } = useReactFlow()
|
const { getViewport } = useReactFlow()
|
||||||
|
|
||||||
|
// Compute migrated data once on first render using a lazy state initializer
|
||||||
|
const [migratedData] = useState(() => computeMigration(initialData, !!needsMigration))
|
||||||
|
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState(
|
const [nodes, setNodes, onNodesChange] = useNodesState(
|
||||||
toReactFlowNodes(initialData.nodes)
|
toReactFlowNodes(migratedData.nodes)
|
||||||
)
|
)
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState(
|
const [edges, setEdges, onEdgesChange] = useEdgesState(
|
||||||
toReactFlowEdges(initialData.edges)
|
toReactFlowEdges(migratedData.edges)
|
||||||
)
|
)
|
||||||
|
|
||||||
const [characters, setCharacters] = useState<Character[]>(initialData.characters)
|
const [characters, setCharacters] = useState<Character[]>(migratedData.characters)
|
||||||
const [variables, setVariables] = useState<Variable[]>(initialData.variables)
|
const [variables, setVariables] = useState<Variable[]>(migratedData.variables)
|
||||||
const [showSettings, setShowSettings] = useState(false)
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
|
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
|
||||||
|
const [toastMessage, setToastMessage] = useState<string | null>(migratedData.toastMessage)
|
||||||
|
|
||||||
const handleAddCharacter = useCallback(
|
const handleAddCharacter = useCallback(
|
||||||
(name: string, color: string): string => {
|
(name: string, color: string): string => {
|
||||||
|
|
@ -302,6 +448,13 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
||||||
onClose={() => setSelectedEdgeId(null)}
|
onClose={() => setSelectedEdgeId(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{toastMessage && (
|
||||||
|
<Toast
|
||||||
|
message={toastMessage}
|
||||||
|
type="success"
|
||||||
|
onClose={() => setToastMessage(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</EditorProvider>
|
</EditorProvider>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,10 @@ export default async function EditorPage({ params }: PageProps) {
|
||||||
variables: rawData.variables || [],
|
variables: rawData.variables || [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration flag: if the raw data doesn't have characters/variables arrays,
|
||||||
|
// the project was created before these features existed and may need auto-migration
|
||||||
|
const needsMigration = !rawData.characters && !rawData.variables
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-screen flex-col">
|
||||||
<header className="flex items-center justify-between border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
|
<header className="flex items-center justify-between border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
|
||||||
|
|
@ -70,6 +74,7 @@ export default async function EditorPage({ params }: PageProps) {
|
||||||
<FlowchartEditor
|
<FlowchartEditor
|
||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
initialData={flowchartData}
|
initialData={flowchartData}
|
||||||
|
needsMigration={needsMigration}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,56 @@
|
||||||
'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 DialogueNodeData = {
|
type DialogueNodeData = {
|
||||||
speaker?: string
|
speaker?: string
|
||||||
|
characterId?: string
|
||||||
text: string
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RANDOM_COLORS = [
|
||||||
|
'#EF4444', '#F97316', '#F59E0B', '#10B981',
|
||||||
|
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
|
||||||
|
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
|
||||||
|
]
|
||||||
|
|
||||||
|
function randomColor(): string {
|
||||||
|
return RANDOM_COLORS[Math.floor(Math.random() * RANDOM_COLORS.length)]
|
||||||
|
}
|
||||||
|
|
||||||
export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>) {
|
export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>) {
|
||||||
const { setNodes } = useReactFlow()
|
const { setNodes } = useReactFlow()
|
||||||
|
const { characters, onAddCharacter } = useEditorContext()
|
||||||
|
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
|
const [newName, setNewName] = useState('')
|
||||||
|
const [newColor, setNewColor] = useState(randomColor)
|
||||||
|
|
||||||
|
const characterItems: ComboboxItem[] = useMemo(
|
||||||
|
() =>
|
||||||
|
characters.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
label: c.name,
|
||||||
|
color: c.color,
|
||||||
|
})),
|
||||||
|
[characters]
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasInvalidReference = useMemo(() => {
|
||||||
|
if (!data.characterId) return false
|
||||||
|
return !characters.some((c) => c.id === data.characterId)
|
||||||
|
}, [data.characterId, characters])
|
||||||
|
|
||||||
const updateNodeData = useCallback(
|
const updateNodeData = useCallback(
|
||||||
(field: keyof DialogueNodeData, value: string) => {
|
(updates: Partial<DialogueNodeData>) => {
|
||||||
setNodes((nodes) =>
|
setNodes((nodes) =>
|
||||||
nodes.map((node) =>
|
nodes.map((node) =>
|
||||||
node.id === id
|
node.id === id
|
||||||
? { ...node, data: { ...node.data, [field]: value } }
|
? { ...node, data: { ...node.data, ...updates } }
|
||||||
: node
|
: node
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -24,22 +58,52 @@ export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>)
|
||||||
[id, setNodes]
|
[id, setNodes]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleSpeakerChange = useCallback(
|
const handleCharacterSelect = useCallback(
|
||||||
(e: ChangeEvent<HTMLInputElement>) => {
|
(characterId: string) => {
|
||||||
updateNodeData('speaker', e.target.value)
|
const character = characters.find((c) => c.id === characterId)
|
||||||
|
updateNodeData({
|
||||||
|
characterId,
|
||||||
|
speaker: character?.name || '',
|
||||||
|
})
|
||||||
},
|
},
|
||||||
[updateNodeData]
|
[characters, updateNodeData]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleAddNew = useCallback(() => {
|
||||||
|
setShowAddForm(true)
|
||||||
|
setNewName('')
|
||||||
|
setNewColor(randomColor())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSubmitNew = useCallback(() => {
|
||||||
|
if (!newName.trim()) return
|
||||||
|
const newId = onAddCharacter(newName.trim(), newColor)
|
||||||
|
updateNodeData({
|
||||||
|
characterId: newId,
|
||||||
|
speaker: newName.trim(),
|
||||||
|
})
|
||||||
|
setShowAddForm(false)
|
||||||
|
}, [newName, newColor, onAddCharacter, updateNodeData])
|
||||||
|
|
||||||
|
const handleCancelNew = useCallback(() => {
|
||||||
|
setShowAddForm(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleTextChange = useCallback(
|
const handleTextChange = useCallback(
|
||||||
(e: ChangeEvent<HTMLTextAreaElement>) => {
|
(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
updateNodeData('text', e.target.value)
|
updateNodeData({ text: e.target.value })
|
||||||
},
|
},
|
||||||
[updateNodeData]
|
[updateNodeData]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-[200px] rounded-lg border-2 border-blue-500 bg-blue-50 p-3 shadow-md dark:border-blue-400 dark:bg-blue-950">
|
<div
|
||||||
|
className={`min-w-[200px] rounded-lg border-2 ${
|
||||||
|
hasInvalidReference
|
||||||
|
? 'border-orange-500 dark:border-orange-400'
|
||||||
|
: 'border-blue-500 dark:border-blue-400'
|
||||||
|
} bg-blue-50 p-3 shadow-md dark:bg-blue-950`}
|
||||||
|
>
|
||||||
<Handle
|
<Handle
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Top}
|
position={Position.Top}
|
||||||
|
|
@ -51,13 +115,63 @@ export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>)
|
||||||
Dialogue
|
Dialogue
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<div className="mb-2">
|
||||||
type="text"
|
<Combobox
|
||||||
value={data.speaker || ''}
|
items={characterItems}
|
||||||
onChange={handleSpeakerChange}
|
value={data.characterId}
|
||||||
placeholder="Speaker"
|
onChange={handleCharacterSelect}
|
||||||
className="mb-2 w-full rounded border border-blue-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-blue-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
placeholder="Select speaker..."
|
||||||
/>
|
onAddNew={handleAddNew}
|
||||||
|
/>
|
||||||
|
{hasInvalidReference && (
|
||||||
|
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
|
||||||
|
Character not found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAddForm && (
|
||||||
|
<div className="mb-2 rounded border border-blue-300 bg-white p-2 dark:border-blue-600 dark:bg-zinc-800">
|
||||||
|
<div className="mb-1 text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||||
|
New character
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={newColor}
|
||||||
|
onChange={(e) => setNewColor(e.target.value)}
|
||||||
|
className="h-6 w-6 shrink-0 cursor-pointer rounded border border-zinc-300 dark:border-zinc-600"
|
||||||
|
/>
|
||||||
|
<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 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>
|
||||||
|
)}
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
value={data.text || ''}
|
value={data.text || ''}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue