From 5b404cbe92666c43e55ae453e25a85c01b31e4a2 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 18:04:54 -0300 Subject: [PATCH 01/14] feat: [US-030] - Right-click context menu Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 151 +++++++++++++++++- src/components/editor/ContextMenu.tsx | 131 +++++++++++++++ 2 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 src/components/editor/ContextMenu.tsx diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index a37e8fc..6a7abd7 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, useMemo, useState } from 'react' import ReactFlow, { Background, BackgroundVariant, @@ -15,6 +15,8 @@ import ReactFlow, { Edge, NodeTypes, MarkerType, + NodeMouseHandler, + EdgeMouseHandler, } from 'reactflow' import { nanoid } from 'nanoid' import 'reactflow/dist/style.css' @@ -22,8 +24,17 @@ import Toolbar from '@/components/editor/Toolbar' import DialogueNode from '@/components/editor/nodes/DialogueNode' import ChoiceNode from '@/components/editor/nodes/ChoiceNode' import VariableNode from '@/components/editor/nodes/VariableNode' +import ContextMenu, { ContextMenuType } from '@/components/editor/ContextMenu' import type { FlowchartData, FlowchartNode, FlowchartEdge } from '@/types/flowchart' +type ContextMenuState = { + x: number + y: number + type: ContextMenuType + nodeId?: string + edgeId?: string +} | null + type FlowchartEditorProps = { projectId: string initialData: FlowchartData @@ -67,7 +78,9 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { [] ) - const { getViewport } = useReactFlow() + const { getViewport, screenToFlowPosition } = useReactFlow() + + const [contextMenu, setContextMenu] = useState(null) const [nodes, setNodes, onNodesChange] = useNodesState( toReactFlowNodes(initialData.nodes) @@ -168,6 +181,121 @@ 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]) + + // Add condition to edge (will be implemented in US-031) + const handleAddCondition = useCallback(() => { + // TODO: Implement in US-031 - will open ConditionEditor modal + console.log('Add condition to edge:', contextMenu?.edgeId) + }, [contextMenu]) + return (
@@ -194,6 +325,22 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
+ + {contextMenu && ( + handleAddNodeAtPosition('dialogue')} + onAddChoice={() => handleAddNodeAtPosition('choice')} + onAddVariable={() => handleAddNodeAtPosition('variable')} + onDelete={ + contextMenu.type === 'node' ? handleDeleteNode : handleDeleteEdge + } + onAddCondition={handleAddCondition} + /> + )} ) } 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' && ( + <> + + + + )} +
+ ) +} From 0d8a4059bc18e7fc9d9c4791d3e29749501784b0 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 18:05:29 -0300 Subject: [PATCH 02/14] chore: mark US-030 as complete and update progress log Co-Authored-By: Claude Opus 4.5 --- prd.json | 2 +- progress.txt | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/prd.json b/prd.json index 29d01a7..c2f770d 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": "" }, { diff --git a/progress.txt b/progress.txt index dab9d29..012a474 100644 --- a/progress.txt +++ b/progress.txt @@ -433,3 +433,17 @@ - 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 +--- From fda09038722721c1a004c6e476a7cbb9789af50d Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 18:08:37 -0300 Subject: [PATCH 03/14] feat: [US-031] - Condition editor modal Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 86 ++++++++- src/components/editor/ConditionEditor.tsx | 166 ++++++++++++++++++ 2 files changed, 247 insertions(+), 5 deletions(-) create mode 100644 src/components/editor/ConditionEditor.tsx diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 6a7abd7..3ecefad 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -25,7 +25,8 @@ import DialogueNode from '@/components/editor/nodes/DialogueNode' import ChoiceNode from '@/components/editor/nodes/ChoiceNode' import VariableNode from '@/components/editor/nodes/VariableNode' import ContextMenu, { ContextMenuType } from '@/components/editor/ContextMenu' -import type { FlowchartData, FlowchartNode, FlowchartEdge } from '@/types/flowchart' +import ConditionEditor from '@/components/editor/ConditionEditor' +import type { FlowchartData, FlowchartNode, FlowchartEdge, Condition } from '@/types/flowchart' type ContextMenuState = { x: number @@ -35,6 +36,11 @@ type ContextMenuState = { edgeId?: string } | null +type ConditionEditorState = { + edgeId: string + condition?: Condition +} | null + type FlowchartEditorProps = { projectId: string initialData: FlowchartData @@ -81,6 +87,7 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { const { getViewport, screenToFlowPosition } = useReactFlow() const [contextMenu, setContextMenu] = useState(null) + const [conditionEditor, setConditionEditor] = useState(null) const [nodes, setNodes, onNodesChange] = useNodesState( toReactFlowNodes(initialData.nodes) @@ -290,11 +297,69 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { setEdges((edges) => edges.filter((e) => e.id !== contextMenu.edgeId)) }, [contextMenu, setEdges]) - // Add condition to edge (will be implemented in US-031) + // 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(() => { - // TODO: Implement in US-031 - will open ConditionEditor modal - console.log('Add condition to edge:', contextMenu?.edgeId) - }, [contextMenu]) + 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 (
@@ -318,6 +383,7 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { onPaneContextMenu={onPaneContextMenu} onNodeContextMenu={onNodeContextMenu} onEdgeContextMenu={onEdgeContextMenu} + onEdgeDoubleClick={onEdgeDoubleClick} deleteKeyCode={['Delete', 'Backspace']} fitView > @@ -341,6 +407,16 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { onAddCondition={handleAddCondition} /> )} + + {conditionEditor && ( + + )}
) } 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 && ( + + )} +
+
+ + +
+
+
+
+ ) +} From e686719f29e8952c6a2e3b18d2bcd88e20b4a448 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 18:09:04 -0300 Subject: [PATCH 04/14] chore: mark US-031 as complete and update progress log Co-Authored-By: Claude Opus 4.5 --- prd.json | 2 +- progress.txt | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/prd.json b/prd.json index c2f770d..cae515f 100644 --- a/prd.json +++ b/prd.json @@ -552,7 +552,7 @@ "Verify in browser using dev-browser skill" ], "priority": 31, - "passes": false, + "passes": true, "notes": "" }, { diff --git a/progress.txt b/progress.txt index 012a474..963615c 100644 --- a/progress.txt +++ b/progress.txt @@ -447,3 +447,17 @@ - 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}` +--- From c431b212ac0bc8923aee1fd0d8a0c345895bdac3 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 18:11:28 -0300 Subject: [PATCH 05/14] feat: [US-032] - Display conditions on edges Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 15 +++- .../editor/edges/ConditionalEdge.tsx | 71 +++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 src/components/editor/edges/ConditionalEdge.tsx diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 3ecefad..0df0156 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -14,6 +14,7 @@ import ReactFlow, { Node, Edge, NodeTypes, + EdgeTypes, MarkerType, NodeMouseHandler, EdgeMouseHandler, @@ -24,6 +25,7 @@ import Toolbar from '@/components/editor/Toolbar' 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 ConditionEditor from '@/components/editor/ConditionEditor' import type { FlowchartData, FlowchartNode, FlowchartEdge, Condition } from '@/types/flowchart' @@ -65,7 +67,7 @@ function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] { target: edge.target, targetHandle: edge.targetHandle, data: edge.data, - type: 'smoothstep', + type: 'conditional', markerEnd: { type: MarkerType.ArrowClosed, }, @@ -84,6 +86,14 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { [] ) + // Define custom edge types - memoized to prevent re-renders + const edgeTypes: EdgeTypes = useMemo( + () => ({ + conditional: ConditionalEdge, + }), + [] + ) + const { getViewport, screenToFlowPosition } = useReactFlow() const [contextMenu, setContextMenu] = useState(null) @@ -105,7 +115,7 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { target: params.target, sourceHandle: params.sourceHandle, targetHandle: params.targetHandle, - type: 'smoothstep', + type: 'conditional', markerEnd: { type: MarkerType.ArrowClosed, }, @@ -376,6 +386,7 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { nodes={nodes} edges={edges} nodeTypes={nodeTypes} + edgeTypes={edgeTypes} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onEdgesDelete={onEdgesDelete} diff --git a/src/components/editor/edges/ConditionalEdge.tsx b/src/components/editor/edges/ConditionalEdge.tsx new file mode 100644 index 0000000..c49a99d --- /dev/null +++ b/src/components/editor/edges/ConditionalEdge.tsx @@ -0,0 +1,71 @@ +'use client' + +import { + BaseEdge, + EdgeLabelRenderer, + EdgeProps, + getSmoothStepPath, +} from 'reactflow' +import type { Condition } from '@/types/flowchart' + +type ConditionalEdgeData = { + condition?: Condition +} + +export default function ConditionalEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, + markerEnd, + selected, +}: EdgeProps) { + const [edgePath, labelX, labelY] = getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }) + + const hasCondition = !!data?.condition + + // Format condition as readable label + const conditionLabel = hasCondition + ? `${data.condition!.variableName} ${data.condition!.operator} ${data.condition!.value}` + : null + + return ( + <> + + {conditionLabel && ( + +
+ {conditionLabel} +
+
+ )} + + ) +} From c3975dd91a4b4c605e1cc21866298c2776e4940b Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 18:11:53 -0300 Subject: [PATCH 06/14] chore: mark US-032 as complete and update progress log Co-Authored-By: Claude Opus 4.5 --- prd.json | 2 +- progress.txt | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/prd.json b/prd.json index cae515f..6b6440c 100644 --- a/prd.json +++ b/prd.json @@ -568,7 +568,7 @@ "Verify in browser using dev-browser skill" ], "priority": 32, - "passes": false, + "passes": true, "notes": "" }, { diff --git a/progress.txt b/progress.txt index 963615c..f09ade7 100644 --- a/progress.txt +++ b/progress.txt @@ -461,3 +461,18 @@ - 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 +--- From f6ab24c5b35f6bd436cde068288f2d90cc5f0b92 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 18:14:49 -0300 Subject: [PATCH 07/14] feat: [US-033] - Auto-save to LocalStorage Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 162 +++++++++++++++++- 1 file changed, 160 insertions(+), 2 deletions(-) diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 0df0156..3efa2c1 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import ReactFlow, { Background, BackgroundVariant, @@ -30,6 +30,12 @@ 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 @@ -74,8 +80,70 @@ 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) +} + // Inner component that uses useReactFlow hook -function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { +function FlowchartEditorInner({ projectId, initialData }: FlowchartEditorProps) { // Define custom node types - memoized to prevent re-renders const nodeTypes: NodeTypes = useMemo( () => ({ @@ -99,6 +167,22 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { const [contextMenu, setContextMenu] = useState(null) const [conditionEditor, setConditionEditor] = useState(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) ) @@ -106,6 +190,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 @@ -428,6 +557,35 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { onCancel={closeConditionEditor} /> )} + + {/* 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? +

+
+ + +
+
+
+ )} ) } From e8dbd00d4cf972885f099e65d38e553ca9b149f6 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 18:15:21 -0300 Subject: [PATCH 08/14] chore: mark US-033 as complete and update progress log Co-Authored-By: Claude Opus 4.5 --- prd.json | 2 +- progress.txt | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/prd.json b/prd.json index 6b6440c..5c0649f 100644 --- a/prd.json +++ b/prd.json @@ -585,7 +585,7 @@ "Verify in browser using dev-browser skill" ], "priority": 33, - "passes": false, + "passes": true, "notes": "" }, { diff --git a/progress.txt b/progress.txt index f09ade7..6bb111b 100644 --- a/progress.txt +++ b/progress.txt @@ -476,3 +476,18 @@ - 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 +--- From 01f5428dd9d1d1c9fccc10c83957fc8932b1b3a9 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 18:17:33 -0300 Subject: [PATCH 09/14] feat: [US-034] - Save project to database Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 54 +++++++++++++++++-- src/components/editor/Toolbar.tsx | 29 +++++++++- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 3efa2c1..6462ae4 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -22,6 +22,8 @@ import 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' @@ -166,6 +168,8 @@ function FlowchartEditorInner({ projectId, initialData }: FlowchartEditorProps) 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) // Check for saved draft on initial render (lazy initialization) const [draftState, setDraftState] = useState<{ @@ -308,9 +312,43 @@ function FlowchartEditorInner({ projectId, 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 @@ -509,6 +547,7 @@ function FlowchartEditorInner({ projectId, initialData }: FlowchartEditorProps) onSave={handleSave} onExport={handleExport} onImport={handleImport} + isSaving={isSaving} />
)} + + {/* Toast notification */} + {toast && ( + setToast(null)} + /> + )} ) } diff --git a/src/components/editor/Toolbar.tsx b/src/components/editor/Toolbar.tsx index 08f0243..0b26706 100644 --- a/src/components/editor/Toolbar.tsx +++ b/src/components/editor/Toolbar.tsx @@ -7,6 +7,7 @@ type ToolbarProps = { onSave: () => void onExport: () => void onImport: () => void + isSaving?: boolean } export default function Toolbar({ @@ -16,6 +17,7 @@ export default function Toolbar({ onSave, onExport, onImport, + isSaving = false, }: ToolbarProps) { return (
@@ -46,9 +48,32 @@ export default function Toolbar({
)} + {/* 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 && ( Date: Thu, 22 Jan 2026 18:26:25 -0300 Subject: [PATCH 13/14] feat: [US-037] - Export to Ren'Py JSON format Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 273 ++++++++++++++++++ src/components/editor/Toolbar.tsx | 8 + 2 files changed, 281 insertions(+) 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 +