'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() 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() 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() 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() 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(migratedData.characters) const [variables, setVariables] = useState(migratedData.variables) const [showSettings, setShowSettings] = useState(false) const [showShare, setShowShare] = useState(false) const [selectedEdgeId, setSelectedEdgeId] = useState(null) const [toastMessage, setToastMessage] = useState(migratedData.toastMessage) const [validationIssues, setValidationIssues] = useState(null) const [warningNodeIds, setWarningNodeIds] = useState>(new Set()) const [connectionState, setConnectionState] = useState('disconnected') const realtimeRef = useRef(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 (
setShowSettings(true)} onShare={() => setShowShare(true)} connectionState={connectionState} />
{showSettings && ( setShowSettings(false)} getCharacterUsageCount={getCharacterUsageCount} getVariableUsageCount={getVariableUsageCount} /> )} {showShare && ( setShowShare(false)} /> )} {selectedEdge && ( setSelectedEdgeId(null)} /> )} {validationIssues && ( )} {toastMessage && ( setToastMessage(null)} /> )}
) } // Outer wrapper component with ReactFlowProvider export default function FlowchartEditor(props: FlowchartEditorProps) { return ( ) }