'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, EdgeTypes, MarkerType, NodeMouseHandler, EdgeMouseHandler, } from 'reactflow' import { nanoid } from 'nanoid' import 'reactflow/dist/style.css' import Toolbar from '@/components/editor/Toolbar' import Toast from '@/components/Toast' import { createClient } from '@/lib/supabase/client' 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 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 { RealtimeConnection, type ConnectionState, type PresenceUser, type RemoteCursor, type NodeLock } from '@/lib/collaboration/realtime' import type { NodeLockInfo } from '@/components/editor/EditorContext' import RemoteCursors from '@/components/editor/RemoteCursors' import { CRDTManager } from '@/lib/collaboration/crdt' import { AuditTrailRecorder } from '@/lib/collaboration/auditTrail' import ShareModal from '@/components/editor/ShareModal' import ActivityHistorySidebar, { type AuditEntry } from '@/components/editor/ActivityHistorySidebar' import CollaborationToast, { type CollaborationNotification } from '@/components/editor/CollaborationToast' import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, 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 projectName: string userId: string userDisplayName: 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: '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) } // Validate imported flowchart data structure function isValidFlowchartData(data: unknown): data is FlowchartData { if (!data || typeof data !== 'object') return false const obj = data as Record if (!Array.isArray(obj.nodes)) return false if (!Array.isArray(obj.edges)) return false return true } // Find the first node (node with no incoming edges) function findFirstNode(nodes: FlowchartNode[], edges: FlowchartEdge[]): FlowchartNode | null { const targetIds = new Set(edges.map((e) => e.target)) const startNodes = nodes.filter((n) => !targetIds.has(n.id)) return startNodes[0] || nodes[0] || null } // Get outgoing edge from a node function getOutgoingEdge(nodeId: string, edges: FlowchartEdge[], sourceHandle?: string): FlowchartEdge | undefined { return edges.find((e) => e.source === nodeId && (sourceHandle === undefined || e.sourceHandle === sourceHandle)) } // Get all outgoing edges from a node function getOutgoingEdges(nodeId: string, edges: FlowchartEdge[]): FlowchartEdge[] { return edges.filter((e) => e.source === nodeId) } // Convert flowchart to Ren'Py JSON format function convertToRenpyFormat( nodes: FlowchartNode[], edges: FlowchartEdge[], projectName: string ): { projectName: string; exportedAt: string; sections: Record } { const nodeMap = new Map(nodes.map((n) => [n.id, n])) const visited = new Set() const sections: Record = {} let currentSectionName = 'start' let currentSection: unknown[] = [] const nodeLabels = new Map() let labelCounter = 0 function getNodeLabel(nodeId: string): string { if (!nodeLabels.has(nodeId)) { const node = nodeMap.get(nodeId) if (node?.type === 'dialogue' && node.data.speaker) { nodeLabels.set(nodeId, `section_${node.data.speaker.toLowerCase().replace(/\s+/g, '_')}_${labelCounter++}`) } else { nodeLabels.set(nodeId, `section_${labelCounter++}`) } } return nodeLabels.get(nodeId)! } function processNode(nodeId: string): void { if (visited.has(nodeId)) return visited.add(nodeId) const node = nodeMap.get(nodeId) if (!node) return if (node.type === 'dialogue') { const outgoingEdge = getOutgoingEdge(nodeId, edges) const renpyNode: Record = { type: 'dialogue', speaker: node.data.speaker || '', text: node.data.text, } if (outgoingEdge) { renpyNode.next = visited.has(outgoingEdge.target) ? getNodeLabel(outgoingEdge.target) : outgoingEdge.target if (outgoingEdge.data?.condition) { renpyNode.condition = outgoingEdge.data.condition } } currentSection.push(renpyNode) if (outgoingEdge && !visited.has(outgoingEdge.target)) { processNode(outgoingEdge.target) } } else if (node.type === 'choice') { const outEdges = getOutgoingEdges(nodeId, edges) const choices = outEdges.map((edge) => { const choiceData: Record = { label: edge.sourceHandle || 'Choice', next: edge.target, } if (edge.data?.condition) { choiceData.condition = edge.data.condition } return choiceData }) currentSection.push({ type: 'menu', prompt: node.data.prompt || '', choices, }) // Process each choice target as a new section outEdges.forEach((edge) => { if (!visited.has(edge.target)) { sections[currentSectionName] = currentSection currentSectionName = getNodeLabel(edge.target) currentSection = [] processNode(edge.target) } }) } else if (node.type === 'variable') { const outgoingEdge = getOutgoingEdge(nodeId, edges) const renpyNode: Record = { type: 'variable', name: node.data.variableName || '', operation: node.data.operation || 'set', value: node.data.value ?? 0, } if (outgoingEdge) { renpyNode.next = visited.has(outgoingEdge.target) ? getNodeLabel(outgoingEdge.target) : outgoingEdge.target if (outgoingEdge.data?.condition) { renpyNode.condition = outgoingEdge.data.condition } } currentSection.push(renpyNode) if (outgoingEdge && !visited.has(outgoingEdge.target)) { processNode(outgoingEdge.target) } } } const startNode = findFirstNode(nodes, edges) if (startNode) { processNode(startNode.id) } sections[currentSectionName] = currentSection return { projectName, exportedAt: new Date().toISOString(), sections, } } 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)] } // Generate a consistent color from a user ID hash (same logic as PresenceAvatars) function getUserColor(userId: string): string { let hash = 0 for (let i = 0; i < userId.length; i++) { hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0 } return RANDOM_COLORS[Math.abs(hash) % RANDOM_COLORS.length] } // Determine the action type for a revert audit entry function getRevertActionType(originalActionType: string): string { switch (originalActionType) { case 'node_add': return 'node_delete' // reverting an add = deleting case 'node_delete': return 'node_add' // reverting a delete = adding back case 'node_update': return 'node_update' // reverting an update = updating back case 'edge_add': return 'edge_delete' case 'edge_delete': return 'edge_add' case 'edge_update': return 'edge_update' default: return originalActionType } } // 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, projectName, userId, userDisplayName, isOwner, initialData, needsMigration }: 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) const [isSaving, setIsSaving] = useState(false) const [toast, setToast] = useState<{ message: string; type: 'success' | 'error'; action?: { label: string; onClick: () => void } } | null>(null) const [importConfirmDialog, setImportConfirmDialog] = useState<{ pendingData: FlowchartData } | null>(null) const [showNavigationWarning, setShowNavigationWarning] = useState(false) // Track the last saved data to determine dirty state const lastSavedDataRef = useRef(initialData) // Ref for hidden file input const fileInputRef = useRef(null) // Ref for save function to enable retry without circular dependency const handleSaveRef = useRef<() => void>(() => {}) // 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 } }) // 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) // Refs to always have the latest characters/variables for the CRDT persist callback const charactersRef = useRef(characters) const variablesRef = useRef(variables) charactersRef.current = characters variablesRef.current = variables const [showSettings, setShowSettings] = useState(false) const [showShare, setShowShare] = useState(false) const [showHistory, setShowHistory] = 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 [presenceUsers, setPresenceUsers] = useState([]) const [remoteCursors, setRemoteCursors] = useState([]) const realtimeRef = useRef(null) const crdtRef = useRef(null) const auditRef = useRef(null) const cursorThrottleRef = useRef(0) const [collaborationNotifications, setCollaborationNotifications] = useState([]) const [nodeLocks, setNodeLocks] = useState>(new Map()) const localLockRef = useRef(null) // node ID currently locked by this user const lockExpiryTimerRef = useRef | null>(null) const isRevertingRef = useRef(false) // guards against double audit recording during revert // Initialize CRDT manager and connect to Supabase Realtime channel on mount useEffect(() => { const supabase = createClient() const crdtManager = new CRDTManager({ onNodesChange: (crdtNodes: FlowchartNode[]) => { setNodes(toReactFlowNodes(crdtNodes)) }, onEdgesChange: (crdtEdges: FlowchartEdge[]) => { setEdges(toReactFlowEdges(crdtEdges)) }, onPersist: async (persistNodes: FlowchartNode[], persistEdges: FlowchartEdge[]) => { try { // Auto-persist saves the current state for durability. We do NOT // broadcast state-refresh here because CRDT already syncs nodes/edges // via yjs-update broadcasts. Broadcasting here causes ping-pong: // other clients fetch from DB, overwrite their local variables/characters, // then their persist writes stale data back, causing a loop. await supabase .from('projects') .update({ flowchart_data: { nodes: persistNodes, edges: persistEdges, characters: charactersRef.current, variables: variablesRef.current, }, }) .eq('id', projectId) } catch { // Persistence failure is non-critical; will retry on next change } }, }) // Initialize CRDT document from initial data crdtManager.initializeFromData(migratedData.nodes, migratedData.edges) crdtRef.current = crdtManager // Initialize audit trail recorder const auditRecorder = new AuditTrailRecorder(projectId, userId) auditRecorder.initialize(migratedData.nodes, migratedData.edges) auditRef.current = auditRecorder const connection = new RealtimeConnection(projectId, userId, userDisplayName, { onConnectionStateChange: setConnectionState, onPresenceSync: setPresenceUsers, onPresenceJoin: (user) => { setCollaborationNotifications((prev) => [ ...prev, { id: nanoid(), displayName: user.displayName, type: 'join', color: getUserColor(user.userId) }, ]) }, onPresenceLeave: (user) => { setCollaborationNotifications((prev) => [ ...prev, { id: nanoid(), displayName: user.displayName, type: 'leave', color: getUserColor(user.userId) }, ]) }, onCursorUpdate: (cursor) => { setRemoteCursors((prev) => { const existing = prev.findIndex((c) => c.userId === cursor.userId) if (existing >= 0) { const updated = [...prev] updated[existing] = cursor return updated } return [...prev, cursor] }) }, onNodeLockUpdate: (lock: NodeLock | null, lockUserId: string) => { setNodeLocks((prev) => { const next = new Map(prev) if (lock) { next.set(lock.nodeId, { ...lock, color: getUserColor(lock.userId) }) } else { // Remove any lock held by this user for (const [nodeId, info] of next) { if (info.userId === lockUserId) { next.delete(nodeId) } } } return next }) }, onCRDTUpdate: (update: number[]) => { crdtManager.applyRemoteUpdate(update) }, onStateRefresh: async () => { try { const sb = createClient() const { data } = await sb .from('projects') .select('flowchart_data') .eq('id', projectId) .single() if (data?.flowchart_data) { const fd = data.flowchart_data as FlowchartData setNodes(toReactFlowNodes(fd.nodes)) setEdges(toReactFlowEdges(fd.edges)) setCharacters(fd.characters) setVariables(fd.variables) crdtManager.refreshFromData(fd.nodes, fd.edges) } } catch { // Non-critical: user can still manually refresh } }, onChannelSubscribed: (channel) => { crdtManager.connectChannel(channel) }, }) realtimeRef.current = connection connection.connect() return () => { // Release any held lock before disconnecting if (localLockRef.current) { connection.broadcastNodeLock(null) localLockRef.current = null } connection.disconnect() realtimeRef.current = null crdtManager.destroy() crdtRef.current = null auditRecorder.destroy() auditRef.current = null } // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId, userId, userDisplayName]) // Manage connection lifecycle based on visibility and user activity useEffect(() => { const handleVisibilityChange = () => { if (!document.hidden) { realtimeRef.current?.notifyActivity() } } // Throttle activity notifications to avoid excessive calls let activityThrottled = false const throttledActivity = () => { if (activityThrottled) return activityThrottled = true realtimeRef.current?.notifyActivity() setTimeout(() => { activityThrottled = false }, 10_000) } document.addEventListener('visibilitychange', handleVisibilityChange) document.addEventListener('mousedown', throttledActivity) document.addEventListener('keydown', throttledActivity) document.addEventListener('scroll', throttledActivity, true) document.addEventListener('mousemove', throttledActivity) return () => { document.removeEventListener('visibilitychange', handleVisibilityChange) document.removeEventListener('mousedown', throttledActivity) document.removeEventListener('keydown', throttledActivity) document.removeEventListener('scroll', throttledActivity, true) document.removeEventListener('mousemove', throttledActivity) } }, []) // Sync local React Flow state changes to CRDT (skip remote-originated updates) const nodesForCRDT = useMemo(() => { return nodes.map((node) => ({ id: node.id, type: node.type as 'dialogue' | 'choice' | 'variable', position: node.position, data: node.data, })) as FlowchartNode[] }, [nodes]) const edgesForCRDT = useMemo(() => { return edges.map((edge) => ({ id: edge.id, source: edge.source, sourceHandle: edge.sourceHandle, target: edge.target, targetHandle: edge.targetHandle, data: edge.data, })) as FlowchartEdge[] }, [edges]) useEffect(() => { crdtRef.current?.updateNodes(nodesForCRDT) if (!isRevertingRef.current) { auditRef.current?.recordNodeChanges(nodesForCRDT) } }, [nodesForCRDT]) useEffect(() => { crdtRef.current?.updateEdges(edgesForCRDT) if (!isRevertingRef.current) { auditRef.current?.recordEdgeChanges(edgesForCRDT) } }, [edgesForCRDT]) // Broadcast cursor position on mouse move (throttled to 50ms) const handleMouseMove = useCallback( (event: React.MouseEvent) => { const now = Date.now() if (now - cursorThrottleRef.current < 50) return cursorThrottleRef.current = now const connection = realtimeRef.current if (!connection) return // Convert screen position to flow coordinates const bounds = event.currentTarget.getBoundingClientRect() const screenX = event.clientX - bounds.left const screenY = event.clientY - bounds.top const flowPosition = screenToFlowPosition({ x: screenX, y: screenY }) connection.broadcastCursor(flowPosition) }, [screenToFlowPosition] ) // Remove cursors and locks for users who leave useEffect(() => { setRemoteCursors((prev) => prev.filter((cursor) => presenceUsers.some((u) => u.userId === cursor.userId) ) ) setNodeLocks((prev) => { const next = new Map(prev) let changed = false for (const [nodeId, info] of next) { if (!presenceUsers.some((u) => u.userId === info.userId)) { next.delete(nodeId) changed = true } } return changed ? next : prev }) }, [presenceUsers]) // Lock auto-expiry: expire locks after 60 seconds of inactivity useEffect(() => { const LOCK_EXPIRY_MS = 60_000 lockExpiryTimerRef.current = setInterval(() => { const now = Date.now() setNodeLocks((prev) => { const next = new Map(prev) let changed = false for (const [nodeId, info] of next) { if (now - info.lockedAt > LOCK_EXPIRY_MS) { next.delete(nodeId) changed = true } } return changed ? next : prev }) }, 5000) // Check every 5 seconds return () => { if (lockExpiryTimerRef.current) { clearInterval(lockExpiryTimerRef.current) lockExpiryTimerRef.current = null } } }, []) const handleDismissNotification = useCallback((id: string) => { setCollaborationNotifications((prev) => prev.filter((n) => n.id !== id)) }, []) const handleHistorySelectEntity = useCallback((entityId: string, actionType: string) => { if (actionType.startsWith('node_')) { // Select the node on the canvas setNodes((nds) => nds.map((n) => ({ ...n, selected: n.id === entityId })) ) setEdges((eds) => eds.map((e) => ({ ...e, selected: false })) ) } else if (actionType.startsWith('edge_')) { // Select the edge on the canvas setEdges((eds) => eds.map((e) => ({ ...e, selected: e.id === entityId })) ) setNodes((nds) => nds.map((n) => ({ ...n, selected: false })) ) } }, [setNodes, setEdges]) const handleRevertEntry = useCallback((entry: AuditEntry) => { const { action_type, entity_id, previous_state, new_state } = entry // Guard against double audit recording - we write our own audit entry for the revert isRevertingRef.current = true // Reset after React has processed the state updates setTimeout(() => { isRevertingRef.current = false }, 0) if (action_type === 'node_add') { // Revert an addition = delete the node setNodes((nds) => nds.filter((n) => n.id !== entity_id)) // Also remove any edges connected to this node setEdges((eds) => eds.filter((e) => e.source !== entity_id && e.target !== entity_id)) } else if (action_type === 'node_update') { // Revert an update = restore previous state if (previous_state) { const prevNode = previous_state as unknown as FlowchartNode setNodes((nds) => nds.map((n) => n.id === entity_id ? { ...n, position: prevNode.position, data: prevNode.data } : n ) ) } } else if (action_type === 'node_delete') { // Revert a deletion = re-create the node from previous_state if (previous_state) { const prevNode = previous_state as unknown as FlowchartNode const newReactFlowNode: Node = { id: prevNode.id, type: prevNode.type, position: prevNode.position, data: prevNode.data, } setNodes((nds) => [...nds, newReactFlowNode]) } } else if (action_type === 'edge_add') { // Revert an edge addition = delete the edge setEdges((eds) => eds.filter((e) => e.id !== entity_id)) } else if (action_type === 'edge_update') { // Revert an edge update = restore previous state if (previous_state) { const prevEdge = previous_state as unknown as FlowchartEdge setEdges((eds) => eds.map((e) => e.id === entity_id ? { ...e, source: prevEdge.source, sourceHandle: prevEdge.sourceHandle, target: prevEdge.target, targetHandle: prevEdge.targetHandle, data: prevEdge.data, } : e ) ) } } else if (action_type === 'edge_delete') { // Revert an edge deletion = re-create the edge from previous_state if (previous_state) { const prevEdge = previous_state as unknown as FlowchartEdge const newReactFlowEdge: Edge = { id: prevEdge.id, source: prevEdge.source, sourceHandle: prevEdge.sourceHandle, target: prevEdge.target, targetHandle: prevEdge.targetHandle, data: prevEdge.data, type: 'conditional', markerEnd: { type: MarkerType.ArrowClosed }, } setEdges((eds) => [...eds, newReactFlowEdge]) } } // Record the revert as a new audit trail entry (fire-and-forget) const supabase = createClient() const revertActionType = getRevertActionType(action_type) supabase .from('audit_trail') .insert({ project_id: projectId, user_id: userId, action_type: revertActionType, entity_id, previous_state: new_state, // "previous" for the revert is the current state (which was new_state) new_state: previous_state, // "new" for the revert is what we're restoring to }) .then(({ error }) => { if (error) { console.error('[Revert] Failed to write revert audit entry:', error) } }) setToast({ message: 'Change reverted successfully', type: 'success' }) }, [setNodes, setEdges, projectId, userId]) const handleNodeFocus = useCallback((nodeId: string) => { // Broadcast lock for this node localLockRef.current = nodeId realtimeRef.current?.broadcastNodeLock(nodeId) }, []) const handleNodeBlur = useCallback(() => { // Release lock if (localLockRef.current) { localLockRef.current = null realtimeRef.current?.broadcastNodeLock(null) } }, []) 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, nodeLocks, onNodeFocus: handleNodeFocus, onNodeBlur: handleNodeBlur, }), [characters, handleAddCharacter, variables, handleAddVariableDefinition, nodeLocks, handleNodeFocus, handleNodeBlur] ) 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] ) // 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), characters, variables, } saveDraft(projectId, currentData) }, AUTOSAVE_DEBOUNCE_MS) // Cleanup on unmount return () => { if (saveTimerRef.current) { clearTimeout(saveTimerRef.current) } } }, [nodes, edges, characters, variables, projectId, draftState.showPrompt]) // Calculate dirty state by comparing current data with last saved data const isDirty = useMemo(() => { const currentData: FlowchartData = { nodes: fromReactFlowNodes(nodes), edges: fromReactFlowEdges(edges), characters, variables, } return !flowchartDataEquals(currentData, lastSavedDataRef.current) }, [nodes, edges, characters, variables]) // Browser beforeunload warning when dirty useEffect(() => { const handleBeforeUnload = (event: BeforeUnloadEvent) => { if (isDirty) { event.preventDefault() // Modern browsers require returnValue to be set event.returnValue = '' return '' } } window.addEventListener('beforeunload', handleBeforeUnload) return () => { window.removeEventListener('beforeunload', handleBeforeUnload) } }, [isDirty]) // 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(async () => { if (isSaving) return setIsSaving(true) try { const supabase = createClient() // Convert React Flow state to FlowchartData const flowchartData: FlowchartData = { nodes: fromReactFlowNodes(nodes), edges: fromReactFlowEdges(edges), characters, variables, } const { error } = await supabase .from('projects') .update({ flowchart_data: flowchartData, updated_at: new Date().toISOString(), }) .eq('id', projectId) if (error) { throw error } // Clear LocalStorage draft after successful save clearDraft(projectId) // Update last saved data ref to mark as not dirty lastSavedDataRef.current = flowchartData // Notify other connected clients to refresh from the database realtimeRef.current?.broadcastStateRefresh() setToast({ message: 'Project saved successfully', type: 'success' }) } catch (error) { console.error('Failed to save project:', error) setToast({ message: 'Failed to save project.', type: 'error', action: { label: 'Retry', onClick: () => { setToast(null); handleSaveRef.current() } }, }) } finally { setIsSaving(false) } }, [isSaving, nodes, edges, characters, variables, projectId]) // Keep ref updated with latest handleSave handleSaveRef.current = handleSave const handleExport = useCallback(() => { // Convert React Flow state to FlowchartData const flowchartData: FlowchartData = { nodes: fromReactFlowNodes(nodes), edges: fromReactFlowEdges(edges), characters, variables, } // Create pretty-printed JSON const jsonContent = JSON.stringify(flowchartData, null, 2) // Create blob with JSON content const blob = new Blob([jsonContent], { type: 'application/json' }) // Create download URL const url = URL.createObjectURL(blob) // Create temporary link element and trigger download const link = document.createElement('a') link.href = url link.download = `${projectName}.vnflow` document.body.appendChild(link) link.click() // Cleanup document.body.removeChild(link) URL.revokeObjectURL(url) }, [nodes, edges, characters, variables, projectName]) const handleExportRenpy = useCallback(() => { // Convert React Flow state to our flowchart types const flowchartNodes = fromReactFlowNodes(nodes) const flowchartEdges = fromReactFlowEdges(edges) // Convert to Ren'Py format const renpyExport = convertToRenpyFormat(flowchartNodes, flowchartEdges, projectName) // Create pretty-printed JSON const jsonContent = JSON.stringify(renpyExport, null, 2) // Verify JSON is valid try { JSON.parse(jsonContent) } catch { setToast({ message: 'Failed to generate valid JSON', type: 'error' }) return } // Create blob with JSON content const blob = new Blob([jsonContent], { type: 'application/json' }) // Create download URL const url = URL.createObjectURL(blob) // Create temporary link element and trigger download const link = document.createElement('a') link.href = url link.download = `${projectName}-renpy.json` document.body.appendChild(link) link.click() // Cleanup document.body.removeChild(link) URL.revokeObjectURL(url) setToast({ message: 'Exported to Ren\'Py format successfully', type: 'success' }) }, [nodes, edges, projectName]) const handleExportAnyway = useCallback(() => { setValidationIssues(null) setWarningNodeIds(new Set()) handleExportRenpy() }, [handleExportRenpy]) const handleExportCancel = useCallback(() => { setValidationIssues(null) setWarningNodeIds(new Set()) }, []) // Check if current flowchart has unsaved changes const hasUnsavedChanges = useCallback(() => { const currentData: FlowchartData = { nodes: fromReactFlowNodes(nodes), edges: fromReactFlowEdges(edges), characters, variables, } return !flowchartDataEquals(currentData, initialData) }, [nodes, edges, characters, variables, initialData]) // Load imported data into React Flow const loadImportedData = useCallback( (data: FlowchartData) => { setNodes(toReactFlowNodes(data.nodes)) setEdges(toReactFlowEdges(data.edges)) setToast({ message: 'Project imported successfully', type: 'success' }) }, [setNodes, setEdges] ) // Handle file selection from file picker const handleFileSelect = useCallback( (event: React.ChangeEvent) => { const file = event.target.files?.[0] if (!file) return // Reset file input so same file can be selected again event.target.value = '' const reader = new FileReader() reader.onload = (e) => { try { const content = e.target?.result as string const parsedData = JSON.parse(content) // Validate the imported data if (!isValidFlowchartData(parsedData)) { setToast({ message: 'Invalid file format. File must contain nodes and edges arrays.', type: 'error', }) return } // Check if current project has unsaved changes if (hasUnsavedChanges()) { // Show confirmation dialog setImportConfirmDialog({ pendingData: parsedData }) } else { // Load data directly loadImportedData(parsedData) } } catch { setToast({ message: 'Failed to parse file. Please ensure it is valid JSON.', type: 'error', }) } } reader.onerror = () => { setToast({ message: 'Failed to read file.', type: 'error' }) } reader.readAsText(file) }, [hasUnsavedChanges, loadImportedData] ) // Handle import button click - opens file picker const handleImport = useCallback(() => { fileInputRef.current?.click() }, []) // Confirm import (discard unsaved changes) const handleConfirmImport = useCallback(() => { if (importConfirmDialog?.pendingData) { loadImportedData(importConfirmDialog.pendingData) } setImportConfirmDialog(null) }, [importConfirmDialog, loadImportedData]) // Cancel import const handleCancelImport = useCallback(() => { setImportConfirmDialog(null) }, []) // 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] ) // Context menu handlers const closeContextMenu = useCallback(() => { setContextMenu(null) }, []) const onPaneContextMenu = useCallback( (event: React.MouseEvent) => { event.preventDefault() setContextMenu({ x: event.clientX, y: event.clientY, type: 'canvas', }) }, [] ) const onNodeContextMenu: NodeMouseHandler = useCallback( (event, node) => { event.preventDefault() setContextMenu({ x: event.clientX, y: event.clientY, type: 'node', nodeId: node.id, }) }, [] ) const onEdgeContextMenu: EdgeMouseHandler = useCallback( (event, edge) => { event.preventDefault() setContextMenu({ x: event.clientX, y: event.clientY, type: 'edge', edgeId: edge.id, }) }, [] ) const handleAddNodeAtPosition = useCallback( (type: 'dialogue' | 'choice' | 'variable') => { if (!contextMenu) return 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]) setContextMenu(null) }, [contextMenu, screenToFlowPosition, setNodes] ) const handleDeleteNode = useCallback(() => { if (!contextMenu?.nodeId) return setNodes((nodes) => nodes.filter((n) => n.id !== contextMenu.nodeId)) setContextMenu(null) }, [contextMenu, setNodes]) const handleDeleteEdge = useCallback(() => { if (!contextMenu?.edgeId) return setEdges((edges) => edges.filter((e) => e.id !== contextMenu.edgeId)) setContextMenu(null) }, [contextMenu, setEdges]) const openConditionEditor = useCallback( (edgeId: string) => { const edge = edges.find((e) => e.id === edgeId) if (!edge) return setConditionEditor({ edgeId, condition: edge.data?.condition, }) }, [edges] ) const handleAddCondition = useCallback(() => { if (!contextMenu?.edgeId) return openConditionEditor(contextMenu.edgeId) setContextMenu(null) }, [contextMenu, openConditionEditor]) const onEdgeDoubleClick = useCallback( (_event: React.MouseEvent, edge: Edge) => { openConditionEditor(edge.id) }, [openConditionEditor] ) // 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)} onHistory={() => setShowHistory((v) => !v)} connectionState={connectionState} presenceUsers={presenceUsers} />
{showHistory && ( setShowHistory(false)} onSelectEntity={handleHistorySelectEntity} onRevert={handleRevertEntry} /> )}
{contextMenu && ( handleAddNodeAtPosition('dialogue')} onAddChoice={() => handleAddNodeAtPosition('choice')} onAddVariable={() => handleAddNodeAtPosition('variable')} onDelete={ contextMenu.type === 'node' ? handleDeleteNode : handleDeleteEdge } onAddCondition={handleAddCondition} /> )} {showSettings && ( setShowSettings(false)} getCharacterUsageCount={getCharacterUsageCount} getVariableUsageCount={getVariableUsageCount} /> )} {showShare && ( setShowShare(false)} /> )} {selectedEdge && ( setSelectedEdgeId(null)} /> )} {validationIssues && ( )} {toastMessage && ( setToastMessage(null)} /> )} {collaborationNotifications.length > 0 && (
{collaborationNotifications.map((notification) => ( ))}
)}
) } // Outer wrapper component with ReactFlowProvider export default function FlowchartEditor(props: FlowchartEditorProps) { return ( ) }