609 lines
19 KiB
TypeScript
609 lines
19 KiB
TypeScript
'use client'
|
|
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import ReactFlow, {
|
|
Background,
|
|
BackgroundVariant,
|
|
Controls,
|
|
useNodesState,
|
|
useEdgesState,
|
|
useReactFlow,
|
|
ReactFlowProvider,
|
|
addEdge,
|
|
Connection,
|
|
Node,
|
|
Edge,
|
|
NodeTypes,
|
|
MarkerType,
|
|
} from 'reactflow'
|
|
import { nanoid } from 'nanoid'
|
|
import 'reactflow/dist/style.css'
|
|
import Toolbar from '@/components/editor/Toolbar'
|
|
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 ExportValidationModal, { type ValidationIssue } from '@/components/editor/ExportValidationModal'
|
|
import { EditorProvider } from '@/components/editor/EditorContext'
|
|
import Toast from '@/components/Toast'
|
|
import { RealtimeConnection, type ConnectionState } from '@/lib/collaboration/realtime'
|
|
import ShareModal from '@/components/editor/ShareModal'
|
|
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart'
|
|
|
|
type FlowchartEditorProps = {
|
|
projectId: string
|
|
userId: string
|
|
isOwner: boolean
|
|
initialData: FlowchartData
|
|
needsMigration?: boolean
|
|
}
|
|
|
|
// Convert our FlowchartNode type to React Flow Node type
|
|
function toReactFlowNodes(nodes: FlowchartNode[]): Node[] {
|
|
return nodes.map((node) => ({
|
|
id: node.id,
|
|
type: node.type,
|
|
position: node.position,
|
|
data: node.data,
|
|
}))
|
|
}
|
|
|
|
// Convert our FlowchartEdge type to React Flow Edge type
|
|
function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] {
|
|
return edges.map((edge) => ({
|
|
id: edge.id,
|
|
source: edge.source,
|
|
sourceHandle: edge.sourceHandle,
|
|
target: edge.target,
|
|
targetHandle: edge.targetHandle,
|
|
data: edge.data,
|
|
type: 'smoothstep',
|
|
markerEnd: {
|
|
type: MarkerType.ArrowClosed,
|
|
},
|
|
}))
|
|
}
|
|
|
|
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
|
|
function FlowchartEditorInner({ projectId, userId, isOwner, initialData, needsMigration }: FlowchartEditorProps) {
|
|
// Define custom node types - memoized to prevent re-renders
|
|
const nodeTypes: NodeTypes = useMemo(
|
|
() => ({
|
|
dialogue: DialogueNode,
|
|
choice: ChoiceNode,
|
|
variable: VariableNode,
|
|
}),
|
|
[]
|
|
)
|
|
|
|
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(
|
|
toReactFlowNodes(migratedData.nodes)
|
|
)
|
|
const [edges, setEdges, onEdgesChange] = useEdgesState(
|
|
toReactFlowEdges(migratedData.edges)
|
|
)
|
|
|
|
const [characters, setCharacters] = useState<Character[]>(migratedData.characters)
|
|
const [variables, setVariables] = useState<Variable[]>(migratedData.variables)
|
|
const [showSettings, setShowSettings] = useState(false)
|
|
const [showShare, setShowShare] = useState(false)
|
|
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
|
|
const [toastMessage, setToastMessage] = useState<string | null>(migratedData.toastMessage)
|
|
const [validationIssues, setValidationIssues] = useState<ValidationIssue[] | null>(null)
|
|
const [warningNodeIds, setWarningNodeIds] = useState<Set<string>>(new Set())
|
|
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
|
|
const realtimeRef = useRef<RealtimeConnection | null>(null)
|
|
|
|
// Connect to Supabase Realtime channel on mount, disconnect on unmount
|
|
useEffect(() => {
|
|
const connection = new RealtimeConnection(projectId, userId, {
|
|
onConnectionStateChange: setConnectionState,
|
|
})
|
|
realtimeRef.current = connection
|
|
connection.connect()
|
|
|
|
return () => {
|
|
connection.disconnect()
|
|
realtimeRef.current = null
|
|
}
|
|
}, [projectId, userId])
|
|
|
|
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
|
|
},
|
|
[nodes]
|
|
)
|
|
|
|
const getVariableUsageCount = useCallback(
|
|
(variableId: string) => {
|
|
const nodeCount = nodes.filter(
|
|
(n) => n.type === 'variable' && n.data?.variableId === variableId
|
|
).length
|
|
const edgeCount = edges.filter(
|
|
(e) => e.data?.condition?.variableId === variableId
|
|
).length
|
|
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]
|
|
)
|
|
|
|
const onConnect = useCallback(
|
|
(params: Connection) => {
|
|
if (!params.source || !params.target) return
|
|
const newEdge: Edge = {
|
|
id: nanoid(),
|
|
source: params.source,
|
|
target: params.target,
|
|
sourceHandle: params.sourceHandle,
|
|
targetHandle: params.targetHandle,
|
|
type: 'smoothstep',
|
|
markerEnd: {
|
|
type: MarkerType.ArrowClosed,
|
|
},
|
|
}
|
|
setEdges((eds) => addEdge(newEdge, eds))
|
|
},
|
|
[setEdges]
|
|
)
|
|
|
|
// Get center position of current viewport for placing new nodes
|
|
const getViewportCenter = useCallback(() => {
|
|
const viewport = getViewport()
|
|
// Calculate center based on viewport dimensions (assume ~800x600 visible area)
|
|
// Adjust based on zoom level
|
|
const centerX = (-viewport.x + 400) / viewport.zoom
|
|
const centerY = (-viewport.y + 300) / viewport.zoom
|
|
return { x: centerX, y: centerY }
|
|
}, [getViewport])
|
|
|
|
// Add dialogue node at viewport center
|
|
const handleAddDialogue = useCallback(() => {
|
|
const position = getViewportCenter()
|
|
const newNode: Node = {
|
|
id: nanoid(),
|
|
type: 'dialogue',
|
|
position,
|
|
data: { speaker: '', text: '' },
|
|
}
|
|
setNodes((nodes) => [...nodes, newNode])
|
|
}, [getViewportCenter, setNodes])
|
|
|
|
const handleAddChoice = useCallback(() => {
|
|
const position = getViewportCenter()
|
|
const newNode: Node = {
|
|
id: nanoid(),
|
|
type: 'choice',
|
|
position,
|
|
data: {
|
|
prompt: '',
|
|
options: [
|
|
{ id: nanoid(), label: '' },
|
|
{ id: nanoid(), label: '' },
|
|
],
|
|
},
|
|
}
|
|
setNodes((nodes) => [...nodes, newNode])
|
|
}, [getViewportCenter, setNodes])
|
|
|
|
const handleAddVariable = useCallback(() => {
|
|
const position = getViewportCenter()
|
|
const newNode: Node = {
|
|
id: nanoid(),
|
|
type: 'variable',
|
|
position,
|
|
data: {
|
|
variableName: '',
|
|
operation: 'set',
|
|
value: 0,
|
|
},
|
|
}
|
|
setNodes((nodes) => [...nodes, newNode])
|
|
}, [getViewportCenter, setNodes])
|
|
|
|
const handleSave = useCallback(() => {
|
|
// TODO: Implement in US-034
|
|
}, [])
|
|
|
|
const performExport = useCallback(() => {
|
|
// TODO: Actual export logic in US-035
|
|
setValidationIssues(null)
|
|
setWarningNodeIds(new Set())
|
|
}, [])
|
|
|
|
const handleExport = useCallback(() => {
|
|
const issues: ValidationIssue[] = []
|
|
const characterIds = new Set(characters.map((c) => c.id))
|
|
const variableIds = new Set(variables.map((v) => v.id))
|
|
|
|
// Scan nodes for undefined references
|
|
nodes.forEach((node) => {
|
|
if (node.type === 'dialogue' && node.data?.characterId) {
|
|
if (!characterIds.has(node.data.characterId)) {
|
|
issues.push({
|
|
nodeId: node.id,
|
|
nodeType: 'dialogue',
|
|
contentSnippet: node.data.text
|
|
? `"${node.data.text.slice(0, 40)}${node.data.text.length > 40 ? '...' : ''}"`
|
|
: 'Empty dialogue',
|
|
undefinedReference: node.data.characterId,
|
|
referenceType: 'character',
|
|
})
|
|
}
|
|
}
|
|
if (node.type === 'variable' && node.data?.variableId) {
|
|
if (!variableIds.has(node.data.variableId)) {
|
|
issues.push({
|
|
nodeId: node.id,
|
|
nodeType: 'variable',
|
|
contentSnippet: node.data.variableName
|
|
? `Variable: ${node.data.variableName}`
|
|
: 'Variable node',
|
|
undefinedReference: node.data.variableId,
|
|
referenceType: 'variable',
|
|
})
|
|
}
|
|
}
|
|
if (node.type === 'choice' && node.data?.options) {
|
|
node.data.options.forEach((opt: { id: string; label: string; condition?: { variableId?: string; variableName?: string } }) => {
|
|
if (opt.condition?.variableId && !variableIds.has(opt.condition.variableId)) {
|
|
issues.push({
|
|
nodeId: node.id,
|
|
nodeType: 'choice',
|
|
contentSnippet: opt.label
|
|
? `Option: "${opt.label.slice(0, 30)}${opt.label.length > 30 ? '...' : ''}"`
|
|
: 'Choice option',
|
|
undefinedReference: opt.condition.variableId,
|
|
referenceType: 'variable',
|
|
})
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
// Scan edges for undefined variable references in conditions
|
|
edges.forEach((edge) => {
|
|
if (edge.data?.condition?.variableId && !variableIds.has(edge.data.condition.variableId)) {
|
|
issues.push({
|
|
nodeId: edge.id,
|
|
nodeType: 'edge',
|
|
contentSnippet: edge.data.condition.variableName
|
|
? `Condition on: ${edge.data.condition.variableName}`
|
|
: 'Edge condition',
|
|
undefinedReference: edge.data.condition.variableId,
|
|
referenceType: 'variable',
|
|
})
|
|
}
|
|
})
|
|
|
|
if (issues.length === 0) {
|
|
performExport()
|
|
} else {
|
|
setValidationIssues(issues)
|
|
setWarningNodeIds(new Set(issues.map((i) => i.nodeId)))
|
|
}
|
|
}, [nodes, edges, characters, variables, performExport])
|
|
|
|
const handleExportAnyway = useCallback(() => {
|
|
performExport()
|
|
}, [performExport])
|
|
|
|
const handleExportCancel = useCallback(() => {
|
|
setValidationIssues(null)
|
|
setWarningNodeIds(new Set())
|
|
}, [])
|
|
|
|
const handleImport = useCallback(() => {
|
|
// TODO: Implement in US-036
|
|
}, [])
|
|
|
|
// Handle edge deletion via keyboard (Delete/Backspace)
|
|
const onEdgesDelete = useCallback((deletedEdges: Edge[]) => {
|
|
// Edges are already removed from state by onEdgesChange
|
|
// This callback can be used for additional logic like logging or dirty state
|
|
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]
|
|
)
|
|
|
|
// Apply warning styles to nodes with undefined references
|
|
const styledNodes = useMemo(
|
|
() =>
|
|
warningNodeIds.size === 0
|
|
? nodes
|
|
: nodes.map((node) =>
|
|
warningNodeIds.has(node.id)
|
|
? { ...node, className: 'export-warning-node' }
|
|
: node
|
|
),
|
|
[nodes, warningNodeIds]
|
|
)
|
|
|
|
// 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">
|
|
<Toolbar
|
|
onAddDialogue={handleAddDialogue}
|
|
onAddChoice={handleAddChoice}
|
|
onAddVariable={handleAddVariable}
|
|
onSave={handleSave}
|
|
onExport={handleExport}
|
|
onImport={handleImport}
|
|
onProjectSettings={() => setShowSettings(true)}
|
|
onShare={() => setShowShare(true)}
|
|
connectionState={connectionState}
|
|
/>
|
|
<div className="flex-1">
|
|
<ReactFlow
|
|
nodes={styledNodes}
|
|
edges={edges}
|
|
nodeTypes={nodeTypes}
|
|
onNodesChange={onNodesChange}
|
|
onEdgesChange={onEdgesChange}
|
|
onEdgesDelete={onEdgesDelete}
|
|
onEdgeClick={onEdgeClick}
|
|
onConnect={onConnect}
|
|
deleteKeyCode={['Delete', 'Backspace']}
|
|
fitView
|
|
>
|
|
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
|
<Controls position="bottom-right" />
|
|
</ReactFlow>
|
|
</div>
|
|
{showSettings && (
|
|
<ProjectSettingsModal
|
|
projectId={projectId}
|
|
characters={characters}
|
|
variables={variables}
|
|
onCharactersChange={setCharacters}
|
|
onVariablesChange={setVariables}
|
|
onClose={() => setShowSettings(false)}
|
|
getCharacterUsageCount={getCharacterUsageCount}
|
|
getVariableUsageCount={getVariableUsageCount}
|
|
/>
|
|
)}
|
|
{showShare && (
|
|
<ShareModal
|
|
projectId={projectId}
|
|
isOwner={isOwner}
|
|
onClose={() => setShowShare(false)}
|
|
/>
|
|
)}
|
|
{selectedEdge && (
|
|
<ConditionEditor
|
|
edgeId={selectedEdge.id}
|
|
condition={selectedEdge.data?.condition}
|
|
onChange={handleConditionChange}
|
|
onClose={() => setSelectedEdgeId(null)}
|
|
/>
|
|
)}
|
|
{validationIssues && (
|
|
<ExportValidationModal
|
|
issues={validationIssues}
|
|
onExportAnyway={handleExportAnyway}
|
|
onCancel={handleExportCancel}
|
|
/>
|
|
)}
|
|
{toastMessage && (
|
|
<Toast
|
|
message={toastMessage}
|
|
type="success"
|
|
onClose={() => setToastMessage(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</EditorProvider>
|
|
)
|
|
}
|
|
|
|
// Outer wrapper component with ReactFlowProvider
|
|
export default function FlowchartEditor(props: FlowchartEditorProps) {
|
|
return (
|
|
<ReactFlowProvider>
|
|
<FlowchartEditorInner {...props} />
|
|
</ReactFlowProvider>
|
|
)
|
|
}
|