diff --git a/prd.json b/prd.json index 29d01a7..45673e1 100644 --- a/prd.json +++ b/prd.json @@ -533,7 +533,7 @@ "Verify in browser using dev-browser skill" ], "priority": 30, - "passes": false, + "passes": true, "notes": "" }, { @@ -552,7 +552,7 @@ "Verify in browser using dev-browser skill" ], "priority": 31, - "passes": false, + "passes": true, "notes": "" }, { @@ -568,7 +568,7 @@ "Verify in browser using dev-browser skill" ], "priority": 32, - "passes": false, + "passes": true, "notes": "" }, { @@ -585,7 +585,7 @@ "Verify in browser using dev-browser skill" ], "priority": 33, - "passes": false, + "passes": true, "notes": "" }, { @@ -603,7 +603,7 @@ "Verify in browser using dev-browser skill" ], "priority": 34, - "passes": false, + "passes": true, "notes": "" }, { @@ -620,7 +620,7 @@ "Verify in browser using dev-browser skill" ], "priority": 35, - "passes": false, + "passes": true, "notes": "" }, { @@ -638,7 +638,7 @@ "Verify in browser using dev-browser skill" ], "priority": 36, - "passes": false, + "passes": true, "notes": "" }, { @@ -658,7 +658,7 @@ "Typecheck passes" ], "priority": 37, - "passes": false, + "passes": true, "notes": "" }, { diff --git a/progress.txt b/progress.txt index dab9d29..769e1e2 100644 --- a/progress.txt +++ b/progress.txt @@ -433,3 +433,122 @@ - onEdgesDelete is useful for additional logic like logging, dirty state tracking, or undo/redo - Edge selection shows visual highlight via React Flow's built-in styling --- + +## 2026-01-22 - US-030 +- What was implemented: Right-click context menu for canvas, nodes, and edges +- Files changed: + - src/components/editor/ContextMenu.tsx - new component with menu items for different contexts (canvas/node/edge) + - src/app/editor/[projectId]/FlowchartEditor.tsx - integrated context menu with handlers for all actions +- **Learnings for future iterations:** + - Use `onPaneContextMenu`, `onNodeContextMenu`, and `onEdgeContextMenu` React Flow callbacks for context menus + - `screenToFlowPosition()` converts screen coordinates to flow coordinates for placing nodes at click position + - Context menu state includes type ('canvas'|'node'|'edge') and optional nodeId/edgeId for targeted actions + - Use `document.addEventListener('click', handler)` and `e.stopPropagation()` on menu to close on outside click + - Escape key listener via `document.addEventListener('keydown', handler)` for menu close + - NodeMouseHandler and EdgeMouseHandler types from reactflow provide proper typing for context menu callbacks +--- + +## 2026-01-22 - US-031 +- What was implemented: Condition editor modal for adding/editing/removing conditions on edges +- Files changed: + - src/components/editor/ConditionEditor.tsx - new modal component with form for variable name, operator, and value + - src/app/editor/[projectId]/FlowchartEditor.tsx - integrated condition editor with double-click and context menu triggers +- **Learnings for future iterations:** + - Use `onEdgeDoubleClick` React Flow callback for double-click on edges + - Store condition editor state separately from context menu state (`conditionEditor` vs `contextMenu`) + - Use `edge.data.condition` to access condition object on edges + - When removing properties from edge data, use `delete` operator instead of destructuring to avoid lint warnings about unused variables + - Condition type has operators: '>' | '<' | '==' | '>=' | '<=' | '!=' + - Preview condition in modal using template string: `${variableName} ${operator} ${value}` +--- + +## 2026-01-22 - US-032 +- What was implemented: Display conditions on edges with dashed styling and labels +- Files changed: + - src/components/editor/edges/ConditionalEdge.tsx - new custom edge component with condition display + - src/app/editor/[projectId]/FlowchartEditor.tsx - integrated custom edge type, added EdgeTypes import and edgeTypes definition +- **Learnings for future iterations:** + - Custom React Flow edges use EdgeProps typing where T is the data shape + - Use `BaseEdge` component for rendering the edge path, and `EdgeLabelRenderer` for positioning labels + - `getSmoothStepPath` returns [edgePath, labelX, labelY] - labelX/labelY are center coordinates for labels + - Custom edge types are registered in edgeTypes object (similar to nodeTypes) and passed to ReactFlow + - Style edges with conditions using strokeDasharray: '5 5' for dashed lines + - Custom edges go in `src/components/editor/edges/` directory + - Use amber color scheme for conditional edges to distinguish from regular edges +--- + +## 2026-01-22 - US-033 +- What was implemented: Auto-save to LocalStorage with debounced saves and draft restoration prompt +- Files changed: + - src/app/editor/[projectId]/FlowchartEditor.tsx - added LocalStorage auto-save functionality, draft check on load, and restoration prompt UI +- **Learnings for future iterations:** + - Use lazy useState initializer for draft check to avoid ESLint "setState in effect" warning + - LocalStorage key format: `vnwrite-draft-{projectId}` for project-specific drafts + - Debounce saves with 1 second delay using useRef for timer tracking + - Convert React Flow Node/Edge types back to app types using helper functions (fromReactFlowNodes, fromReactFlowEdges) + - React Flow Edge has `sourceHandle: string | null | undefined` but app types use `string | undefined` - use nullish coalescing (`?? undefined`) + - Check `typeof window === 'undefined'` in lazy initializer for SSR safety + - clearDraft is exported for use in save functionality (US-034) to clear draft after successful database save + - JSON.stringify comparison works for flowchart data equality check +--- + +## 2026-01-22 - US-034 +- What was implemented: Save project to database functionality +- Files changed: + - src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleSave with Supabase update, added isSaving state and Toast notifications + - src/components/editor/Toolbar.tsx - added isSaving prop with loading spinner indicator +- **Learnings for future iterations:** + - Use createClient() from lib/supabase/client.ts for browser-side database operations + - Supabase update returns { error } object for error handling + - Use async/await with try/catch for async save operations + - Set updated_at manually with new Date().toISOString() for Supabase JSONB updates + - Clear LocalStorage draft after successful save to avoid stale drafts + - Toast state uses object with message and type for flexibility + - Loading spinner SVG with animate-spin class for visual feedback during save +--- + +## 2026-01-22 - US-035 +- What was implemented: Export project as .vnflow file functionality +- Files changed: + - src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleExport with blob creation and download trigger, added projectName prop + - src/app/editor/[projectId]/page.tsx - passed projectName prop to FlowchartEditor +- **Learnings for future iterations:** + - Use Blob with type 'application/json' for JSON file downloads + - JSON.stringify(data, null, 2) creates pretty-printed JSON with 2-space indentation + - URL.createObjectURL creates a temporary URL for the blob + - Create temporary anchor element with download attribute to trigger file download + - Remember to cleanup: remove the anchor from DOM and revoke the object URL + - Props needed for export: pass data down from server components (e.g., projectName) to client components that need them +--- + +## 2026-01-22 - US-036 +- What was implemented: Import project from .vnflow file functionality +- Files changed: + - src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleImport, handleFileSelect, validation, and confirmation dialog for unsaved changes +- **Learnings for future iterations:** + - Use hidden `` element with ref to trigger file picker programmatically via `ref.current?.click()` + - Accept multiple file extensions using comma-separated values in `accept` attribute: `accept=".vnflow,.json"` + - Reset file input value after selection (`event.target.value = ''`) to allow re-selecting the same file + - Use FileReader API with `readAsText()` for reading file contents, handle onload and onerror callbacks + - Type guard function `isValidFlowchartData()` validates imported JSON structure before loading + - Track unsaved changes by comparing current state to initialData using JSON.stringify comparison + - Show confirmation dialog before import if there are unsaved changes to prevent accidental data loss +--- + +## 2026-01-22 - US-037 +- What was implemented: Export to Ren'Py JSON format functionality +- Files changed: + - src/components/editor/Toolbar.tsx - added 'Export to Ren'Py' button with purple styling + - src/app/editor/[projectId]/FlowchartEditor.tsx - implemented Ren'Py export types, conversion functions, and handleExportRenpy callback +- **Learnings for future iterations:** + - Ren'Py export uses typed interfaces for different node types: RenpyDialogueNode, RenpyMenuNode, RenpyVariableNode + - Find first node by identifying nodes with no incoming edges (not in any edge's target set) + - Use graph traversal (DFS) to organize nodes into labeled sections based on flow + - Choice nodes create branching sections - save current section before processing each branch + - Track visited nodes to detect cycles and create proper labels for jump references + - Labels are generated based on speaker name or incremental counter for uniqueness + - Replace node IDs with proper labels in a second pass after traversal completes + - Include metadata (projectName, exportedAt) at the top level of the export + - Validate JSON output with JSON.parse before download to ensure validity + - Use purple color scheme for Ren'Py-specific button to distinguish from generic export +--- diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index a37e8fc..47f1ccd 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useMemo } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import ReactFlow, { Background, BackgroundVariant, @@ -14,18 +14,46 @@ import ReactFlow, { 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 type { FlowchartData, FlowchartNode, FlowchartEdge } from '@/types/flowchart' +import ConditionalEdge from '@/components/editor/edges/ConditionalEdge' +import ContextMenu, { ContextMenuType } from '@/components/editor/ContextMenu' +import ConditionEditor from '@/components/editor/ConditionEditor' +import type { FlowchartData, FlowchartNode, FlowchartEdge, 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 initialData: FlowchartData } @@ -48,15 +76,319 @@ function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] { target: edge.target, targetHandle: edge.targetHandle, data: edge.data, - type: 'smoothstep', + type: 'conditional', markerEnd: { type: MarkerType.ArrowClosed, }, })) } +// Convert React Flow Node type back to our FlowchartNode type +function fromReactFlowNodes(nodes: Node[]): FlowchartNode[] { + return nodes.map((node) => ({ + id: node.id, + type: node.type as 'dialogue' | 'choice' | 'variable', + position: node.position, + data: node.data, + })) as FlowchartNode[] +} + +// Convert React Flow Edge type back to our FlowchartEdge type +function fromReactFlowEdges(edges: Edge[]): FlowchartEdge[] { + return edges.map((edge) => ({ + id: edge.id, + source: edge.source, + sourceHandle: edge.sourceHandle ?? undefined, + target: edge.target, + targetHandle: edge.targetHandle ?? undefined, + data: edge.data, + })) +} + +// Get LocalStorage key for a project +function getDraftKey(projectId: string): string { + return `${DRAFT_KEY_PREFIX}${projectId}` +} + +// Save draft to LocalStorage +function saveDraft(projectId: string, data: FlowchartData): void { + try { + localStorage.setItem(getDraftKey(projectId), JSON.stringify(data)) + } catch (error) { + console.error('Failed to save draft to LocalStorage:', error) + } +} + +// Load draft from LocalStorage +function loadDraft(projectId: string): FlowchartData | null { + try { + const draft = localStorage.getItem(getDraftKey(projectId)) + if (!draft) return null + return JSON.parse(draft) as FlowchartData + } catch (error) { + console.error('Failed to load draft from LocalStorage:', error) + return null + } +} + +// Clear draft from LocalStorage +export function clearDraft(projectId: string): void { + try { + localStorage.removeItem(getDraftKey(projectId)) + } catch (error) { + console.error('Failed to clear draft from LocalStorage:', error) + } +} + +// Compare two FlowchartData objects for equality +function flowchartDataEquals(a: FlowchartData, b: FlowchartData): boolean { + return JSON.stringify(a) === JSON.stringify(b) +} + +// Validate imported flowchart data structure +function isValidFlowchartData(data: unknown): data is FlowchartData { + if (!data || typeof data !== 'object') return false + const obj = data as Record + if (!Array.isArray(obj.nodes)) return false + if (!Array.isArray(obj.edges)) return false + return true +} + +// 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({ initialData }: FlowchartEditorProps) { +function FlowchartEditorInner({ projectId, projectName, initialData }: FlowchartEditorProps) { // Define custom node types - memoized to prevent re-renders const nodeTypes: NodeTypes = useMemo( () => ({ @@ -67,7 +399,42 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { [] ) - const { getViewport } = useReactFlow() + // Define custom edge types - memoized to prevent re-renders + const edgeTypes: EdgeTypes = useMemo( + () => ({ + conditional: ConditionalEdge, + }), + [] + ) + + const { getViewport, screenToFlowPosition } = useReactFlow() + + const [contextMenu, setContextMenu] = useState(null) + const [conditionEditor, setConditionEditor] = useState(null) + const [isSaving, setIsSaving] = useState(false) + const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null) + const [importConfirmDialog, setImportConfirmDialog] = useState<{ + pendingData: FlowchartData + } | null>(null) + + // Ref for hidden file input + const fileInputRef = useRef(null) + + // 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 } + }) const [nodes, setNodes, onNodesChange] = useNodesState( toReactFlowNodes(initialData.nodes) @@ -76,6 +443,51 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { toReactFlowEdges(initialData.edges) ) + // Track debounce timer + const saveTimerRef = useRef(null) + + // Debounced auto-save to LocalStorage + useEffect(() => { + // Don't save while draft prompt is showing + if (draftState.showPrompt) return + + // Clear existing timer + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current) + } + + // Set new timer + saveTimerRef.current = setTimeout(() => { + const currentData: FlowchartData = { + nodes: fromReactFlowNodes(nodes), + edges: fromReactFlowEdges(edges), + } + saveDraft(projectId, currentData) + }, AUTOSAVE_DEBOUNCE_MS) + + // Cleanup on unmount + return () => { + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current) + } + } + }, [nodes, edges, projectId, draftState.showPrompt]) + + // 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 @@ -85,7 +497,7 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { target: params.target, sourceHandle: params.sourceHandle, targetHandle: params.targetHandle, - type: 'smoothstep', + type: 'conditional', markerEnd: { type: MarkerType.ArrowClosed, }, @@ -149,16 +561,195 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { setNodes((nodes) => [...nodes, newNode]) }, [getViewportCenter, setNodes]) - const handleSave = useCallback(() => { - // TODO: Implement in US-034 - }, []) + 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), + } + + 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) + + setToast({ message: 'Project saved successfully', type: 'success' }) + } catch (error) { + console.error('Failed to save project:', error) + setToast({ message: 'Failed to save project. Please try again.', type: 'error' }) + } finally { + setIsSaving(false) + } + }, [isSaving, nodes, edges, projectId]) const handleExport = useCallback(() => { - // TODO: Implement in US-035 + // Convert React Flow state to FlowchartData + const flowchartData: FlowchartData = { + nodes: fromReactFlowNodes(nodes), + edges: fromReactFlowEdges(edges), + } + + // 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, 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 = { + nodes: fromReactFlowNodes(nodes), + edges: fromReactFlowEdges(edges), + } + return !flowchartDataEquals(currentData, initialData) + }, [nodes, edges, initialData]) + + // Load imported data into React Flow + const loadImportedData = useCallback( + (data: FlowchartData) => { + setNodes(toReactFlowNodes(data.nodes)) + setEdges(toReactFlowEdges(data.edges)) + setToast({ message: 'Project imported successfully', type: 'success' }) + }, + [setNodes, setEdges] + ) + + // Handle file selection from file picker + const handleFileSelect = useCallback( + (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + + // Reset file input so same file can be selected again + event.target.value = '' + + const reader = new FileReader() + reader.onload = (e) => { + try { + const content = e.target?.result as string + const parsedData = JSON.parse(content) + + // Validate the imported data + if (!isValidFlowchartData(parsedData)) { + setToast({ + message: 'Invalid file format. File must contain nodes and edges arrays.', + type: 'error', + }) + return + } + + // Check if current project has unsaved changes + if (hasUnsavedChanges()) { + // Show confirmation dialog + setImportConfirmDialog({ pendingData: parsedData }) + } else { + // Load data directly + loadImportedData(parsedData) + } + } catch { + setToast({ + message: 'Failed to parse file. Please ensure it is valid JSON.', + type: 'error', + }) + } + } + + reader.onerror = () => { + setToast({ message: 'Failed to read file.', type: 'error' }) + } + + reader.readAsText(file) + }, + [hasUnsavedChanges, loadImportedData] + ) + + // Handle import button click - opens file picker + const handleImport = useCallback(() => { + fileInputRef.current?.click() }, []) - const handleImport = useCallback(() => { - // TODO: Implement in US-036 + // 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) @@ -168,6 +759,179 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { console.log('Deleted edges:', deletedEdges.map((e) => e.id)) }, []) + // Context menu handlers + const closeContextMenu = useCallback(() => { + setContextMenu(null) + }, []) + + // Handle right-click on canvas (pane) + const onPaneContextMenu = useCallback( + (event: React.MouseEvent) => { + event.preventDefault() + setContextMenu({ + x: event.clientX, + y: event.clientY, + type: 'canvas', + }) + }, + [] + ) + + // Handle right-click on node + const onNodeContextMenu: NodeMouseHandler = useCallback( + (event, node) => { + event.preventDefault() + setContextMenu({ + x: event.clientX, + y: event.clientY, + type: 'node', + nodeId: node.id, + }) + }, + [] + ) + + // Handle right-click on edge + const onEdgeContextMenu: EdgeMouseHandler = useCallback( + (event, edge) => { + event.preventDefault() + setContextMenu({ + x: event.clientX, + y: event.clientY, + type: 'edge', + edgeId: edge.id, + }) + }, + [] + ) + + // Add node at specific position (for context menu) + const handleAddNodeAtPosition = useCallback( + (type: 'dialogue' | 'choice' | 'variable') => { + if (!contextMenu) return + + // Convert screen position to flow position + 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]) + }, + [contextMenu, screenToFlowPosition, setNodes] + ) + + // Delete selected node from context menu + const handleDeleteNode = useCallback(() => { + if (!contextMenu?.nodeId) return + setNodes((nodes) => nodes.filter((n) => n.id !== contextMenu.nodeId)) + }, [contextMenu, setNodes]) + + // Delete selected edge from context menu + const handleDeleteEdge = useCallback(() => { + if (!contextMenu?.edgeId) return + setEdges((edges) => edges.filter((e) => e.id !== contextMenu.edgeId)) + }, [contextMenu, setEdges]) + + // Open condition editor for an edge + const openConditionEditor = useCallback( + (edgeId: string) => { + const edge = edges.find((e) => e.id === edgeId) + if (!edge) return + setConditionEditor({ + edgeId, + condition: edge.data?.condition, + }) + }, + [edges] + ) + + // Add condition to edge (opens ConditionEditor modal) + const handleAddCondition = useCallback(() => { + if (!contextMenu?.edgeId) return + openConditionEditor(contextMenu.edgeId) + }, [contextMenu, openConditionEditor]) + + // Handle double-click on edge to open condition editor + const onEdgeDoubleClick = useCallback( + (_event: React.MouseEvent, edge: Edge) => { + openConditionEditor(edge.id) + }, + [openConditionEditor] + ) + + // Save condition to edge + const handleSaveCondition = useCallback( + (edgeId: string, condition: Condition) => { + setEdges((eds) => + eds.map((edge) => + edge.id === edgeId + ? { ...edge, data: { ...edge.data, condition } } + : edge + ) + ) + setConditionEditor(null) + }, + [setEdges] + ) + + // Remove condition from edge + const handleRemoveCondition = useCallback( + (edgeId: string) => { + setEdges((eds) => + eds.map((edge) => { + if (edge.id !== edgeId) return edge + // Remove condition from data + const newData = { ...edge.data } + delete newData.condition + return { ...edge, data: newData } + }) + ) + setConditionEditor(null) + }, + [setEdges] + ) + + // Close condition editor + const closeConditionEditor = useCallback(() => { + setConditionEditor(null) + }, []) + return (
@@ -194,6 +965,108 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
+ + {contextMenu && ( + handleAddNodeAtPosition('dialogue')} + onAddChoice={() => handleAddNodeAtPosition('choice')} + onAddVariable={() => handleAddNodeAtPosition('variable')} + onDelete={ + contextMenu.type === 'node' ? handleDeleteNode : handleDeleteEdge + } + onAddCondition={handleAddCondition} + /> + )} + + {conditionEditor && ( + + )} + + {/* Draft restoration prompt */} + {draftState.showPrompt && ( +
+
+

+ Unsaved Draft Found +

+

+ A local draft was found that differs from the saved version. Would + you like to restore it or discard it? +

+
+ + +
+
+
+ )} + + {/* Import confirmation dialog */} + {importConfirmDialog && ( +
+
+

+ Unsaved Changes +

+

+ You have unsaved changes. Importing a new file will discard your + current work. Are you sure you want to continue? +

+
+ + +
+
+
+ )} + + {/* Hidden file input for import */} + + + {/* Toast notification */} + {toast && ( + setToast(null)} + /> + )}
) } diff --git a/src/app/editor/[projectId]/page.tsx b/src/app/editor/[projectId]/page.tsx index 8117932..953274e 100644 --- a/src/app/editor/[projectId]/page.tsx +++ b/src/app/editor/[projectId]/page.tsx @@ -66,6 +66,7 @@ export default async function EditorPage({ params }: PageProps) {
diff --git a/src/components/editor/ConditionEditor.tsx b/src/components/editor/ConditionEditor.tsx new file mode 100644 index 0000000..045af92 --- /dev/null +++ b/src/components/editor/ConditionEditor.tsx @@ -0,0 +1,166 @@ +'use client' + +import { useState, useCallback, useEffect } from 'react' +import type { Condition } from '@/types/flowchart' + +type ConditionEditorProps = { + edgeId: string + condition?: Condition + onSave: (edgeId: string, condition: Condition) => void + onRemove: (edgeId: string) => void + onCancel: () => void +} + +const OPERATORS: Condition['operator'][] = ['>', '<', '==', '>=', '<=', '!='] + +export default function ConditionEditor({ + edgeId, + condition, + onSave, + onRemove, + onCancel, +}: ConditionEditorProps) { + const [variableName, setVariableName] = useState(condition?.variableName ?? '') + const [operator, setOperator] = useState(condition?.operator ?? '==') + const [value, setValue] = useState(condition?.value ?? 0) + + // Close on Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onCancel() + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [onCancel]) + + const handleSave = useCallback(() => { + if (!variableName.trim()) return + onSave(edgeId, { + variableName: variableName.trim(), + operator, + value, + }) + }, [edgeId, variableName, operator, value, onSave]) + + const handleRemove = useCallback(() => { + onRemove(edgeId) + }, [edgeId, onRemove]) + + const hasExistingCondition = !!condition + + return ( +
+
e.stopPropagation()} + > +

+ {hasExistingCondition ? 'Edit Condition' : 'Add Condition'} +

+ +
+ {/* Variable Name Input */} +
+ + setVariableName(e.target.value)} + placeholder="e.g., score, health, affection" + autoFocus + className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100 dark:placeholder-zinc-500" + /> +
+ + {/* Operator Dropdown */} +
+ + +
+ + {/* Value Number Input */} +
+ + setValue(parseFloat(e.target.value) || 0)} + className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100" + /> +
+ + {/* Preview */} + {variableName.trim() && ( +
+ + Condition: {variableName.trim()} {operator} {value} + +
+ )} +
+ + {/* Action Buttons */} +
+
+ {hasExistingCondition && ( + + )} +
+
+ + +
+
+
+
+ ) +} diff --git a/src/components/editor/ContextMenu.tsx b/src/components/editor/ContextMenu.tsx new file mode 100644 index 0000000..cb9ba53 --- /dev/null +++ b/src/components/editor/ContextMenu.tsx @@ -0,0 +1,131 @@ +'use client' + +import { useCallback, useEffect } from 'react' + +export type ContextMenuType = 'canvas' | 'node' | 'edge' + +type ContextMenuProps = { + x: number + y: number + type: ContextMenuType + onClose: () => void + onAddDialogue?: () => void + onAddChoice?: () => void + onAddVariable?: () => void + onDelete?: () => void + onAddCondition?: () => void +} + +export default function ContextMenu({ + x, + y, + type, + onClose, + onAddDialogue, + onAddChoice, + onAddVariable, + onDelete, + onAddCondition, +}: ContextMenuProps) { + // Close menu on Escape key + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose() + } + }, + [onClose] + ) + + // Close menu on click outside + const handleClickOutside = useCallback(() => { + onClose() + }, [onClose]) + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown) + document.addEventListener('click', handleClickOutside) + return () => { + document.removeEventListener('keydown', handleKeyDown) + document.removeEventListener('click', handleClickOutside) + } + }, [handleKeyDown, handleClickOutside]) + + const menuItemClass = + 'w-full px-4 py-2 text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-700 cursor-pointer' + + return ( +
e.stopPropagation()} + > + {type === 'canvas' && ( + <> + + + + + )} + + {type === 'node' && ( + + )} + + {type === 'edge' && ( + <> + + + + )} +
+ ) +} diff --git a/src/components/editor/Toolbar.tsx b/src/components/editor/Toolbar.tsx index 08f0243..c017111 100644 --- a/src/components/editor/Toolbar.tsx +++ b/src/components/editor/Toolbar.tsx @@ -6,7 +6,9 @@ type ToolbarProps = { onAddVariable: () => void onSave: () => void onExport: () => void + onExportRenpy: () => void onImport: () => void + isSaving?: boolean } export default function Toolbar({ @@ -15,7 +17,9 @@ export default function Toolbar({ onAddVariable, onSave, onExport, + onExportRenpy, onImport, + isSaving = false, }: ToolbarProps) { return (
@@ -46,9 +50,32 @@ export default function Toolbar({
+