diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 0c5b024..47f1ccd 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -154,6 +154,239 @@ function isValidFlowchartData(data: unknown): data is FlowchartData { return true } +// Ren'Py export types +type RenpyDialogueNode = { + type: 'dialogue' + speaker: string + text: string + next?: string + condition?: Condition +} + +type RenpyMenuChoice = { + label: string + next?: string + condition?: Condition +} + +type RenpyMenuNode = { + type: 'menu' + prompt: string + choices: RenpyMenuChoice[] +} + +type RenpyVariableNode = { + type: 'variable' + name: string + operation: 'set' | 'add' | 'subtract' + value: number + next?: string + condition?: Condition +} + +type RenpyNode = RenpyDialogueNode | RenpyMenuNode | RenpyVariableNode + +type RenpyExport = { + projectName: string + exportedAt: string + sections: Record +} + +// 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 the first start node, or the first node if all have incoming edges + return startNodes[0] || nodes[0] || null +} + +// Get outgoing edge from a node (for dialogue and variable nodes) +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 (for choice nodes) +function getOutgoingEdges(nodeId: string, edges: FlowchartEdge[]): FlowchartEdge[] { + return edges.filter((e) => e.source === nodeId) +} + +// Convert flowchart to Ren'Py format using graph traversal +function convertToRenpyFormat( + nodes: FlowchartNode[], + edges: FlowchartEdge[], + projectName: string +): RenpyExport { + const nodeMap = new Map(nodes.map((n) => [n.id, n])) + const visited = new Set() + const sections: Record = {} + let currentSectionName = 'start' + let currentSection: RenpyNode[] = [] + + // Helper to get or create a label for a node + 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) { + // Use speaker name as part of label if available + nodeLabels.set(nodeId, `section_${node.data.speaker.toLowerCase().replace(/\s+/g, '_')}_${labelCounter++}`) + } else { + nodeLabels.set(nodeId, `section_${labelCounter++}`) + } + } + return nodeLabels.get(nodeId)! + } + + // Process a node and its successors + 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: RenpyDialogueNode = { + type: 'dialogue', + speaker: node.data.speaker || '', + text: node.data.text, + } + + if (outgoingEdge) { + // Check if target node is already visited (creates a jump) + if (visited.has(outgoingEdge.target)) { + renpyNode.next = getNodeLabel(outgoingEdge.target) + } else { + renpyNode.next = outgoingEdge.target + } + if (outgoingEdge.data?.condition) { + renpyNode.condition = outgoingEdge.data.condition + } + } + + currentSection.push(renpyNode) + + // Process next node if not visited + if (outgoingEdge && !visited.has(outgoingEdge.target)) { + processNode(outgoingEdge.target) + } + } else if (node.type === 'choice') { + const outgoingEdges = getOutgoingEdges(nodeId, edges) + + // Map options to their corresponding edges + const choices: RenpyMenuChoice[] = node.data.options.map((option, index) => { + // Find edge for this option handle + const optionEdge = outgoingEdges.find((e) => e.sourceHandle === `option-${index}`) + const choice: RenpyMenuChoice = { + label: option.label || `Option ${index + 1}`, + } + + if (optionEdge) { + // If target is visited, use label; otherwise use target id + if (visited.has(optionEdge.target)) { + choice.next = getNodeLabel(optionEdge.target) + } else { + choice.next = optionEdge.target + } + if (optionEdge.data?.condition) { + choice.condition = optionEdge.data.condition + } + } + + return choice + }) + + const renpyNode: RenpyMenuNode = { + type: 'menu', + prompt: node.data.prompt || '', + choices, + } + + currentSection.push(renpyNode) + + // Save current section before processing branches + sections[currentSectionName] = currentSection + + // Process each branch in a new section + for (const choice of choices) { + if (choice.next && !visited.has(choice.next)) { + const targetNode = nodeMap.get(choice.next) + if (targetNode) { + // Start new section for this branch + currentSectionName = getNodeLabel(choice.next) + currentSection = [] + processNode(choice.next) + if (currentSection.length > 0) { + sections[currentSectionName] = currentSection + } + } + } + } + } else if (node.type === 'variable') { + const outgoingEdge = getOutgoingEdge(nodeId, edges) + const renpyNode: RenpyVariableNode = { + type: 'variable', + name: node.data.variableName, + operation: node.data.operation, + value: node.data.value, + } + + if (outgoingEdge) { + if (visited.has(outgoingEdge.target)) { + renpyNode.next = getNodeLabel(outgoingEdge.target) + } else { + renpyNode.next = outgoingEdge.target + } + if (outgoingEdge.data?.condition) { + renpyNode.condition = outgoingEdge.data.condition + } + } + + currentSection.push(renpyNode) + + if (outgoingEdge && !visited.has(outgoingEdge.target)) { + processNode(outgoingEdge.target) + } + } + } + + // Find and process starting from the first node + const firstNode = findFirstNode(nodes, edges) + if (firstNode) { + processNode(firstNode.id) + // Save the final section if it has content + if (currentSection.length > 0 && !sections[currentSectionName]) { + sections[currentSectionName] = currentSection + } + } + + // Replace node IDs in next fields with proper labels + for (const sectionNodes of Object.values(sections)) { + for (const renpyNode of sectionNodes) { + if (renpyNode.type === 'dialogue' || renpyNode.type === 'variable') { + if (renpyNode.next && nodeLabels.has(renpyNode.next)) { + renpyNode.next = nodeLabels.get(renpyNode.next) + } + } else if (renpyNode.type === 'menu') { + for (const choice of renpyNode.choices) { + if (choice.next && nodeLabels.has(choice.next)) { + choice.next = nodeLabels.get(choice.next) + } + } + } + } + } + + return { + projectName, + exportedAt: new Date().toISOString(), + sections, + } +} + // Inner component that uses useReactFlow hook function FlowchartEditorInner({ projectId, projectName, initialData }: FlowchartEditorProps) { // Define custom node types - memoized to prevent re-renders @@ -394,6 +627,45 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart URL.revokeObjectURL(url) }, [nodes, edges, 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]) + // Check if current flowchart has unsaved changes const hasUnsavedChanges = useCallback(() => { const currentData: FlowchartData = { @@ -668,6 +940,7 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart onAddVariable={handleAddVariable} onSave={handleSave} onExport={handleExport} + onExportRenpy={handleExportRenpy} onImport={handleImport} isSaving={isSaving} /> diff --git a/src/components/editor/Toolbar.tsx b/src/components/editor/Toolbar.tsx index 0b26706..c017111 100644 --- a/src/components/editor/Toolbar.tsx +++ b/src/components/editor/Toolbar.tsx @@ -6,6 +6,7 @@ type ToolbarProps = { onAddVariable: () => void onSave: () => void onExport: () => void + onExportRenpy: () => void onImport: () => void isSaving?: boolean } @@ -16,6 +17,7 @@ export default function Toolbar({ onAddVariable, onSave, onExport, + onExportRenpy, onImport, isSaving = false, }: ToolbarProps) { @@ -81,6 +83,12 @@ export default function Toolbar({ > Export +