diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 78a4d89..9f938d5 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -1,6 +1,6 @@ 'use client' -import React, { useCallback, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import ReactFlow, { Background, BackgroundVariant, @@ -27,20 +27,42 @@ 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 Toast from '@/components/Toast' import { RealtimeConnection, type ConnectionState, type PresenceUser } from '@/lib/collaboration/realtime' import { CRDTManager } from '@/lib/collaboration/crdt' -import { createClient } from '@/lib/supabase/client' 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 } @@ -71,6 +93,205 @@ function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] { })) } +// 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', @@ -212,7 +433,7 @@ function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) { } // Inner component that uses useReactFlow hook -function FlowchartEditorInner({ projectId, initialData, needsMigration }: FlowchartEditorProps) { +function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, isOwner, initialData, needsMigration }: FlowchartEditorProps) { // Define custom node types - memoized to prevent re-renders const nodeTypes: NodeTypes = useMemo( () => ({ @@ -450,6 +671,8 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch const currentData: FlowchartData = { nodes: fromReactFlowNodes(nodes), edges: fromReactFlowEdges(edges), + characters, + variables, } saveDraft(projectId, currentData) }, AUTOSAVE_DEBOUNCE_MS) @@ -460,16 +683,18 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch clearTimeout(saveTimerRef.current) } } - }, [nodes, edges, projectId, draftState.showPrompt]) + }, [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]) + }, [nodes, edges, characters, variables]) // Browser beforeunload warning when dirty useEffect(() => { @@ -589,6 +814,8 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch const flowchartData: FlowchartData = { nodes: fromReactFlowNodes(nodes), edges: fromReactFlowEdges(edges), + characters, + variables, } const { error } = await supabase @@ -620,7 +847,7 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch } finally { setIsSaving(false) } - }, [isSaving, nodes, edges, projectId]) + }, [isSaving, nodes, edges, characters, variables, projectId]) // Keep ref updated with latest handleSave handleSaveRef.current = handleSave @@ -630,6 +857,8 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch const flowchartData: FlowchartData = { nodes: fromReactFlowNodes(nodes), edges: fromReactFlowEdges(edges), + characters, + variables, } // Create pretty-printed JSON @@ -651,7 +880,7 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch // Cleanup document.body.removeChild(link) URL.revokeObjectURL(url) - }, [nodes, edges, projectName]) + }, [nodes, edges, characters, variables, projectName]) const handleExportRenpy = useCallback(() => { // Convert React Flow state to our flowchart types @@ -692,14 +921,27 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch 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, initialData]) + }, [nodes, edges, characters, variables, initialData]) // Load imported data into React Flow const loadImportedData = useCallback( @@ -804,6 +1046,134 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch [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( () => @@ -825,13 +1195,15 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch return ( -
+
setShowSettings(true)} onShare={() => setShowShare(true)} @@ -843,11 +1215,16 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch 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 > @@ -855,6 +1232,21 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
+ {contextMenu && ( + handleAddNodeAtPosition('dialogue')} + onAddChoice={() => handleAddNodeAtPosition('choice')} + onAddVariable={() => handleAddNodeAtPosition('variable')} + onDelete={ + contextMenu.type === 'node' ? handleDeleteNode : handleDeleteEdge + } + onAddCondition={handleAddCondition} + /> + )} {showSettings && (