1304 lines
40 KiB
TypeScript
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>
|
|
)
|
|
}
|