'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import ReactFlow, { Background, BackgroundVariant, Controls, useNodesState, useEdgesState, useReactFlow, ReactFlowProvider, addEdge, Connection, Node, Edge, NodeTypes, EdgeTypes, MarkerType, NodeMouseHandler, EdgeMouseHandler, } 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 ConditionalEdge from '@/components/editor/edges/ConditionalEdge' import ContextMenu, { ContextMenuType } from '@/components/editor/ContextMenu' import ConditionEditor from '@/components/editor/ConditionEditor' import type { FlowchartData, FlowchartNode, FlowchartEdge, Condition } from '@/types/flowchart' // LocalStorage key prefix for draft saves const DRAFT_KEY_PREFIX = 'vnwrite-draft-' // Debounce delay in ms const AUTOSAVE_DEBOUNCE_MS = 1000 type ContextMenuState = { x: number y: number type: ContextMenuType nodeId?: string edgeId?: string } | null type ConditionEditorState = { edgeId: string condition?: Condition } | null type FlowchartEditorProps = { projectId: string initialData: FlowchartData } // 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: 'conditional', markerEnd: { type: MarkerType.ArrowClosed, }, })) } // Convert React Flow Node type back to our FlowchartNode type function fromReactFlowNodes(nodes: Node[]): FlowchartNode[] { return nodes.map((node) => ({ id: node.id, type: node.type as 'dialogue' | 'choice' | 'variable', position: node.position, data: node.data, })) as FlowchartNode[] } // Convert React Flow Edge type back to our FlowchartEdge type function fromReactFlowEdges(edges: Edge[]): FlowchartEdge[] { return edges.map((edge) => ({ id: edge.id, source: edge.source, sourceHandle: edge.sourceHandle ?? undefined, target: edge.target, targetHandle: edge.targetHandle ?? undefined, data: edge.data, })) } // Get LocalStorage key for a project function getDraftKey(projectId: string): string { return `${DRAFT_KEY_PREFIX}${projectId}` } // Save draft to LocalStorage function saveDraft(projectId: string, data: FlowchartData): void { try { localStorage.setItem(getDraftKey(projectId), JSON.stringify(data)) } catch (error) { console.error('Failed to save draft to LocalStorage:', error) } } // Load draft from LocalStorage function loadDraft(projectId: string): FlowchartData | null { try { const draft = localStorage.getItem(getDraftKey(projectId)) if (!draft) return null return JSON.parse(draft) as FlowchartData } catch (error) { console.error('Failed to load draft from LocalStorage:', error) return null } } // Clear draft from LocalStorage export function clearDraft(projectId: string): void { try { localStorage.removeItem(getDraftKey(projectId)) } catch (error) { console.error('Failed to clear draft from LocalStorage:', error) } } // Compare two FlowchartData objects for equality function flowchartDataEquals(a: FlowchartData, b: FlowchartData): boolean { return JSON.stringify(a) === JSON.stringify(b) } // Inner component that uses useReactFlow hook function FlowchartEditorInner({ projectId, initialData }: FlowchartEditorProps) { // Define custom node types - memoized to prevent re-renders const nodeTypes: NodeTypes = useMemo( () => ({ dialogue: DialogueNode, choice: ChoiceNode, variable: VariableNode, }), [] ) // Define custom edge types - memoized to prevent re-renders const edgeTypes: EdgeTypes = useMemo( () => ({ conditional: ConditionalEdge, }), [] ) const { getViewport, screenToFlowPosition } = useReactFlow() const [contextMenu, setContextMenu] = useState(null) const [conditionEditor, setConditionEditor] = useState(null) // Check for saved draft on initial render (lazy initialization) const [draftState, setDraftState] = useState<{ showPrompt: boolean savedDraft: FlowchartData | null }>(() => { // This runs only once on initial render (client-side) if (typeof window === 'undefined') { return { showPrompt: false, savedDraft: null } } const draft = loadDraft(projectId) if (draft && !flowchartDataEquals(draft, initialData)) { return { showPrompt: true, savedDraft: draft } } return { showPrompt: false, savedDraft: null } }) const [nodes, setNodes, onNodesChange] = useNodesState( toReactFlowNodes(initialData.nodes) ) const [edges, setEdges, onEdgesChange] = useEdgesState( toReactFlowEdges(initialData.edges) ) // Track debounce timer const saveTimerRef = useRef(null) // Debounced auto-save to LocalStorage useEffect(() => { // Don't save while draft prompt is showing if (draftState.showPrompt) return // Clear existing timer if (saveTimerRef.current) { clearTimeout(saveTimerRef.current) } // Set new timer saveTimerRef.current = setTimeout(() => { const currentData: FlowchartData = { nodes: fromReactFlowNodes(nodes), edges: fromReactFlowEdges(edges), } saveDraft(projectId, currentData) }, AUTOSAVE_DEBOUNCE_MS) // Cleanup on unmount return () => { if (saveTimerRef.current) { clearTimeout(saveTimerRef.current) } } }, [nodes, edges, projectId, draftState.showPrompt]) // Handle restoring draft const handleRestoreDraft = useCallback(() => { if (draftState.savedDraft) { setNodes(toReactFlowNodes(draftState.savedDraft.nodes)) setEdges(toReactFlowEdges(draftState.savedDraft.edges)) } setDraftState({ showPrompt: false, savedDraft: null }) }, [draftState.savedDraft, setNodes, setEdges]) // Handle discarding draft const handleDiscardDraft = useCallback(() => { clearDraft(projectId) setDraftState({ showPrompt: false, savedDraft: null }) }, [projectId]) 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: 'conditional', 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 handleExport = useCallback(() => { // TODO: Implement in US-035 }, []) 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)) }, []) // Context menu handlers const closeContextMenu = useCallback(() => { setContextMenu(null) }, []) // Handle right-click on canvas (pane) const onPaneContextMenu = useCallback( (event: React.MouseEvent) => { event.preventDefault() setContextMenu({ x: event.clientX, y: event.clientY, type: 'canvas', }) }, [] ) // Handle right-click on node const onNodeContextMenu: NodeMouseHandler = useCallback( (event, node) => { event.preventDefault() setContextMenu({ x: event.clientX, y: event.clientY, type: 'node', nodeId: node.id, }) }, [] ) // Handle right-click on edge const onEdgeContextMenu: EdgeMouseHandler = useCallback( (event, edge) => { event.preventDefault() setContextMenu({ x: event.clientX, y: event.clientY, type: 'edge', edgeId: edge.id, }) }, [] ) // Add node at specific position (for context menu) const handleAddNodeAtPosition = useCallback( (type: 'dialogue' | 'choice' | 'variable') => { if (!contextMenu) return // Convert screen position to flow position const position = screenToFlowPosition({ x: contextMenu.x, y: contextMenu.y, }) let newNode: Node if (type === 'dialogue') { newNode = { id: nanoid(), type: 'dialogue', position, data: { speaker: '', text: '' }, } } else if (type === 'choice') { newNode = { id: nanoid(), type: 'choice', position, data: { prompt: '', options: [ { id: nanoid(), label: '' }, { id: nanoid(), label: '' }, ], }, } } else { newNode = { id: nanoid(), type: 'variable', position, data: { variableName: '', operation: 'set', value: 0, }, } } setNodes((nodes) => [...nodes, newNode]) }, [contextMenu, screenToFlowPosition, setNodes] ) // Delete selected node from context menu const handleDeleteNode = useCallback(() => { if (!contextMenu?.nodeId) return setNodes((nodes) => nodes.filter((n) => n.id !== contextMenu.nodeId)) }, [contextMenu, setNodes]) // Delete selected edge from context menu const handleDeleteEdge = useCallback(() => { if (!contextMenu?.edgeId) return setEdges((edges) => edges.filter((e) => e.id !== contextMenu.edgeId)) }, [contextMenu, setEdges]) // Open condition editor for an edge const openConditionEditor = useCallback( (edgeId: string) => { const edge = edges.find((e) => e.id === edgeId) if (!edge) return setConditionEditor({ edgeId, condition: edge.data?.condition, }) }, [edges] ) // Add condition to edge (opens ConditionEditor modal) const handleAddCondition = useCallback(() => { if (!contextMenu?.edgeId) return openConditionEditor(contextMenu.edgeId) }, [contextMenu, openConditionEditor]) // Handle double-click on edge to open condition editor const onEdgeDoubleClick = useCallback( (_event: React.MouseEvent, edge: Edge) => { openConditionEditor(edge.id) }, [openConditionEditor] ) // Save condition to edge const handleSaveCondition = useCallback( (edgeId: string, condition: Condition) => { setEdges((eds) => eds.map((edge) => edge.id === edgeId ? { ...edge, data: { ...edge.data, condition } } : edge ) ) setConditionEditor(null) }, [setEdges] ) // Remove condition from edge const handleRemoveCondition = useCallback( (edgeId: string) => { setEdges((eds) => eds.map((edge) => { if (edge.id !== edgeId) return edge // Remove condition from data const newData = { ...edge.data } delete newData.condition return { ...edge, data: newData } }) ) setConditionEditor(null) }, [setEdges] ) // Close condition editor const closeConditionEditor = useCallback(() => { setConditionEditor(null) }, []) return (
{contextMenu && ( handleAddNodeAtPosition('dialogue')} onAddChoice={() => handleAddNodeAtPosition('choice')} onAddVariable={() => handleAddNodeAtPosition('variable')} onDelete={ contextMenu.type === 'node' ? handleDeleteNode : handleDeleteEdge } onAddCondition={handleAddCondition} /> )} {conditionEditor && ( )} {/* Draft restoration prompt */} {draftState.showPrompt && (

Unsaved Draft Found

A local draft was found that differs from the saved version. Would you like to restore it or discard it?

)}
) } // Outer wrapper component with ReactFlowProvider export default function FlowchartEditor(props: FlowchartEditorProps) { return ( ) }