WebVNWrite/src/app/editor/[projectId]/FlowchartEditor.tsx

1304 lines
40 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,
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 } from '@/lib/collaboration/realtime'
import { CRDTManager } from '@/lib/collaboration/crdt'
import ShareModal from '@/components/editor/ShareModal'
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<string, unknown>
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<string, unknown[]> } {
const nodeMap = new Map<string, FlowchartNode>(nodes.map((n) => [n.id, n]))
const visited = new Set<string>()
const sections: Record<string, unknown[]> = {}
let currentSectionName = 'start'
let currentSection: unknown[] = []
const nodeLabels = new Map<string, string>()
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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)]
}
// 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, 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<ContextMenuState>(null)
const [conditionEditor, setConditionEditor] = useState<ConditionEditorState>(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<FlowchartData>(initialData)
// Ref for hidden file input
const fileInputRef = useRef<HTMLInputElement>(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<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 [presenceUsers, setPresenceUsers] = useState<PresenceUser[]>([])
const realtimeRef = useRef<RealtimeConnection | null>(null)
const crdtRef = useRef<CRDTManager | null>(null)
const isRemoteUpdateRef = useRef(false)
// Initialize CRDT manager and connect to Supabase Realtime channel on mount
useEffect(() => {
const supabase = createClient()
const crdtManager = new CRDTManager({
onNodesChange: (crdtNodes: FlowchartNode[]) => {
isRemoteUpdateRef.current = true
setNodes(toReactFlowNodes(crdtNodes))
isRemoteUpdateRef.current = false
},
onEdgesChange: (crdtEdges: FlowchartEdge[]) => {
isRemoteUpdateRef.current = true
setEdges(toReactFlowEdges(crdtEdges))
isRemoteUpdateRef.current = false
},
onPersist: async (persistNodes: FlowchartNode[], persistEdges: FlowchartEdge[]) => {
try {
await supabase
.from('projects')
.update({
flowchart_data: {
nodes: persistNodes,
edges: persistEdges,
characters,
variables,
},
})
.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
const connection = new RealtimeConnection(projectId, userId, userDisplayName, {
onConnectionStateChange: setConnectionState,
onPresenceSync: setPresenceUsers,
onChannelSubscribed: (channel) => {
crdtManager.connectChannel(channel)
},
})
realtimeRef.current = connection
connection.connect()
return () => {
connection.disconnect()
realtimeRef.current = null
crdtManager.destroy()
crdtRef.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId, userId, userDisplayName])
// 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(() => {
if (isRemoteUpdateRef.current) return
crdtRef.current?.updateNodes(nodesForCRDT)
}, [nodesForCRDT])
useEffect(() => {
if (isRemoteUpdateRef.current) return
crdtRef.current?.updateEdges(edgesForCRDT)
}, [edgesForCRDT])
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]
)
// Track debounce timer
const saveTimerRef = useRef<NodeJS.Timeout | null>(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
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<HTMLInputElement>) => {
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 (
<EditorProvider value={editorContextValue}>
<div className="flex h-screen w-full flex-col">
<Toolbar
onAddDialogue={handleAddDialogue}
onAddChoice={handleAddChoice}
onAddVariable={handleAddVariable}
onSave={handleSave}
isSaving={isSaving}
onExport={handleExport}
onExportRenpy={handleExportRenpy}
onImport={handleImport}
onProjectSettings={() => setShowSettings(true)}
onShare={() => setShowShare(true)}
connectionState={connectionState}
presenceUsers={presenceUsers}
/>
<div className="flex-1">
<ReactFlow
nodes={styledNodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onEdgesDelete={onEdgesDelete}
onEdgeClick={onEdgeClick}
onConnect={onConnect}
onPaneContextMenu={onPaneContextMenu}
onNodeContextMenu={onNodeContextMenu}
onEdgeContextMenu={onEdgeContextMenu}
onEdgeDoubleClick={onEdgeDoubleClick}
deleteKeyCode={['Delete', 'Backspace']}
fitView
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<Controls position="bottom-right" />
</ReactFlow>
</div>
{contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
type={contextMenu.type}
onClose={closeContextMenu}
onAddDialogue={() => handleAddNodeAtPosition('dialogue')}
onAddChoice={() => handleAddNodeAtPosition('choice')}
onAddVariable={() => handleAddNodeAtPosition('variable')}
onDelete={
contextMenu.type === 'node' ? handleDeleteNode : handleDeleteEdge
}
onAddCondition={handleAddCondition}
/>
)}
{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>
)
}