Merge pull request 'ralph/vn-flowchart-editor' (#1) from ralph/vn-flowchart-editor into master

Reviewed-on: #1
This commit is contained in:
GHMiranda 2026-01-22 21:53:45 +00:00
commit 8e37e1433f
8 changed files with 1416 additions and 22 deletions

View File

@ -533,7 +533,7 @@
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
"priority": 30, "priority": 30,
"passes": false, "passes": true,
"notes": "" "notes": ""
}, },
{ {
@ -552,7 +552,7 @@
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
"priority": 31, "priority": 31,
"passes": false, "passes": true,
"notes": "" "notes": ""
}, },
{ {
@ -568,7 +568,7 @@
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
"priority": 32, "priority": 32,
"passes": false, "passes": true,
"notes": "" "notes": ""
}, },
{ {
@ -585,7 +585,7 @@
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
"priority": 33, "priority": 33,
"passes": false, "passes": true,
"notes": "" "notes": ""
}, },
{ {
@ -603,7 +603,7 @@
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
"priority": 34, "priority": 34,
"passes": false, "passes": true,
"notes": "" "notes": ""
}, },
{ {
@ -620,7 +620,7 @@
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
"priority": 35, "priority": 35,
"passes": false, "passes": true,
"notes": "" "notes": ""
}, },
{ {
@ -638,7 +638,7 @@
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
"priority": 36, "priority": 36,
"passes": false, "passes": true,
"notes": "" "notes": ""
}, },
{ {
@ -658,7 +658,7 @@
"Typecheck passes" "Typecheck passes"
], ],
"priority": 37, "priority": 37,
"passes": false, "passes": true,
"notes": "" "notes": ""
}, },
{ {

View File

@ -433,3 +433,122 @@
- onEdgesDelete is useful for additional logic like logging, dirty state tracking, or undo/redo - 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 - 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<T> 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 `<input type="file">` 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
---

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { useCallback, useMemo } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import ReactFlow, { import ReactFlow, {
Background, Background,
BackgroundVariant, BackgroundVariant,
@ -14,18 +14,46 @@ import ReactFlow, {
Node, Node,
Edge, Edge,
NodeTypes, NodeTypes,
EdgeTypes,
MarkerType, MarkerType,
NodeMouseHandler,
EdgeMouseHandler,
} from 'reactflow' } from 'reactflow'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import 'reactflow/dist/style.css' import 'reactflow/dist/style.css'
import Toolbar from '@/components/editor/Toolbar' 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 DialogueNode from '@/components/editor/nodes/DialogueNode'
import ChoiceNode from '@/components/editor/nodes/ChoiceNode' import ChoiceNode from '@/components/editor/nodes/ChoiceNode'
import VariableNode from '@/components/editor/nodes/VariableNode' 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 = { type FlowchartEditorProps = {
projectId: string projectId: string
projectName: string
initialData: FlowchartData initialData: FlowchartData
} }
@ -48,15 +76,319 @@ function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] {
target: edge.target, target: edge.target,
targetHandle: edge.targetHandle, targetHandle: edge.targetHandle,
data: edge.data, data: edge.data,
type: 'smoothstep', type: 'conditional',
markerEnd: { markerEnd: {
type: MarkerType.ArrowClosed, type: MarkerType.ArrowClosed,
}, },
})) }))
} }
// Convert React Flow Node type back to our FlowchartNode type
function fromReactFlowNodes(nodes: Node[]): FlowchartNode[] {
return nodes.map((node) => ({
id: node.id,
type: node.type as 'dialogue' | 'choice' | 'variable',
position: node.position,
data: node.data,
})) as FlowchartNode[]
}
// Convert React Flow Edge type back to our FlowchartEdge type
function fromReactFlowEdges(edges: Edge[]): FlowchartEdge[] {
return edges.map((edge) => ({
id: edge.id,
source: edge.source,
sourceHandle: edge.sourceHandle ?? undefined,
target: edge.target,
targetHandle: edge.targetHandle ?? undefined,
data: edge.data,
}))
}
// Get LocalStorage key for a project
function getDraftKey(projectId: string): string {
return `${DRAFT_KEY_PREFIX}${projectId}`
}
// Save draft to LocalStorage
function saveDraft(projectId: string, data: FlowchartData): void {
try {
localStorage.setItem(getDraftKey(projectId), JSON.stringify(data))
} catch (error) {
console.error('Failed to save draft to LocalStorage:', error)
}
}
// Load draft from LocalStorage
function loadDraft(projectId: string): FlowchartData | null {
try {
const draft = localStorage.getItem(getDraftKey(projectId))
if (!draft) return null
return JSON.parse(draft) as FlowchartData
} catch (error) {
console.error('Failed to load draft from LocalStorage:', error)
return null
}
}
// Clear draft from LocalStorage
export function clearDraft(projectId: string): void {
try {
localStorage.removeItem(getDraftKey(projectId))
} catch (error) {
console.error('Failed to clear draft from LocalStorage:', error)
}
}
// Compare two FlowchartData objects for equality
function flowchartDataEquals(a: FlowchartData, b: FlowchartData): boolean {
return JSON.stringify(a) === JSON.stringify(b)
}
// Validate imported flowchart data structure
function isValidFlowchartData(data: unknown): data is FlowchartData {
if (!data || typeof data !== 'object') return false
const obj = data as Record<string, unknown>
if (!Array.isArray(obj.nodes)) return false
if (!Array.isArray(obj.edges)) return false
return true
}
// 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<string, RenpyNode[]>
}
// 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<string, FlowchartNode>(nodes.map((n) => [n.id, n]))
const visited = new Set<string>()
const sections: Record<string, RenpyNode[]> = {}
let currentSectionName = 'start'
let currentSection: RenpyNode[] = []
// Helper to get or create a label for a node
const nodeLabels = new Map<string, string>()
let labelCounter = 0
function getNodeLabel(nodeId: string): string {
if (!nodeLabels.has(nodeId)) {
const node = nodeMap.get(nodeId)
if (node?.type === 'dialogue' && node.data.speaker) {
// 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 // 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 // Define custom node types - memoized to prevent re-renders
const nodeTypes: NodeTypes = useMemo( 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<ContextMenuState>(null)
const [conditionEditor, setConditionEditor] = useState<ConditionEditorState>(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<HTMLInputElement>(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( const [nodes, setNodes, onNodesChange] = useNodesState(
toReactFlowNodes(initialData.nodes) toReactFlowNodes(initialData.nodes)
@ -76,6 +443,51 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
toReactFlowEdges(initialData.edges) toReactFlowEdges(initialData.edges)
) )
// Track debounce timer
const saveTimerRef = useRef<NodeJS.Timeout | null>(null)
// Debounced auto-save to LocalStorage
useEffect(() => {
// Don't save while draft prompt is showing
if (draftState.showPrompt) return
// Clear existing timer
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
}
// Set new timer
saveTimerRef.current = setTimeout(() => {
const currentData: FlowchartData = {
nodes: fromReactFlowNodes(nodes),
edges: fromReactFlowEdges(edges),
}
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( const onConnect = useCallback(
(params: Connection) => { (params: Connection) => {
if (!params.source || !params.target) return if (!params.source || !params.target) return
@ -85,7 +497,7 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
target: params.target, target: params.target,
sourceHandle: params.sourceHandle, sourceHandle: params.sourceHandle,
targetHandle: params.targetHandle, targetHandle: params.targetHandle,
type: 'smoothstep', type: 'conditional',
markerEnd: { markerEnd: {
type: MarkerType.ArrowClosed, type: MarkerType.ArrowClosed,
}, },
@ -149,16 +561,195 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
setNodes((nodes) => [...nodes, newNode]) setNodes((nodes) => [...nodes, newNode])
}, [getViewportCenter, setNodes]) }, [getViewportCenter, setNodes])
const handleSave = useCallback(() => { const handleSave = useCallback(async () => {
// TODO: Implement in US-034 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(() => { 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<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
// Reset file input so same file can be selected again
event.target.value = ''
const reader = new FileReader()
reader.onload = (e) => {
try {
const content = e.target?.result as string
const parsedData = JSON.parse(content)
// Validate the imported data
if (!isValidFlowchartData(parsedData)) {
setToast({
message: 'Invalid file format. File must contain nodes and edges arrays.',
type: 'error',
})
return
}
// Check if current project has unsaved changes
if (hasUnsavedChanges()) {
// Show confirmation dialog
setImportConfirmDialog({ pendingData: parsedData })
} else {
// Load data directly
loadImportedData(parsedData)
}
} catch {
setToast({
message: 'Failed to parse file. Please ensure it is valid JSON.',
type: 'error',
})
}
}
reader.onerror = () => {
setToast({ message: 'Failed to read file.', type: 'error' })
}
reader.readAsText(file)
},
[hasUnsavedChanges, loadImportedData]
)
// Handle import button click - opens file picker
const handleImport = useCallback(() => {
fileInputRef.current?.click()
}, []) }, [])
const handleImport = useCallback(() => { // Confirm import (discard unsaved changes)
// TODO: Implement in US-036 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) // Handle edge deletion via keyboard (Delete/Backspace)
@ -168,6 +759,179 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
console.log('Deleted edges:', deletedEdges.map((e) => e.id)) 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 ( return (
<div className="flex h-full w-full flex-col"> <div className="flex h-full w-full flex-col">
<Toolbar <Toolbar
@ -176,17 +940,24 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
onAddVariable={handleAddVariable} onAddVariable={handleAddVariable}
onSave={handleSave} onSave={handleSave}
onExport={handleExport} onExport={handleExport}
onExportRenpy={handleExportRenpy}
onImport={handleImport} onImport={handleImport}
isSaving={isSaving}
/> />
<div className="flex-1"> <div className="flex-1">
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodesChange={onNodesChange} onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
onEdgesDelete={onEdgesDelete} onEdgesDelete={onEdgesDelete}
onConnect={onConnect} onConnect={onConnect}
onPaneContextMenu={onPaneContextMenu}
onNodeContextMenu={onNodeContextMenu}
onEdgeContextMenu={onEdgeContextMenu}
onEdgeDoubleClick={onEdgeDoubleClick}
deleteKeyCode={['Delete', 'Backspace']} deleteKeyCode={['Delete', 'Backspace']}
fitView fitView
> >
@ -194,6 +965,108 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
<Controls position="bottom-right" /> <Controls position="bottom-right" />
</ReactFlow> </ReactFlow>
</div> </div>
{contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
type={contextMenu.type}
onClose={closeContextMenu}
onAddDialogue={() => handleAddNodeAtPosition('dialogue')}
onAddChoice={() => handleAddNodeAtPosition('choice')}
onAddVariable={() => handleAddNodeAtPosition('variable')}
onDelete={
contextMenu.type === 'node' ? handleDeleteNode : handleDeleteEdge
}
onAddCondition={handleAddCondition}
/>
)}
{conditionEditor && (
<ConditionEditor
edgeId={conditionEditor.edgeId}
condition={conditionEditor.condition}
onSave={handleSaveCondition}
onRemove={handleRemoveCondition}
onCancel={closeConditionEditor}
/>
)}
{/* Draft restoration prompt */}
{draftState.showPrompt && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-900">
<h2 className="mb-2 text-lg font-semibold text-zinc-900 dark:text-white">
Unsaved Draft Found
</h2>
<p className="mb-4 text-sm text-zinc-600 dark:text-zinc-400">
A local draft was found that differs from the saved version. Would
you like to restore it or discard it?
</p>
<div className="flex gap-3">
<button
onClick={handleRestoreDraft}
className="flex-1 rounded-md bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600"
>
Restore Draft
</button>
<button
onClick={handleDiscardDraft}
className="flex-1 rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700"
>
Discard
</button>
</div>
</div>
</div>
)}
{/* Import confirmation dialog */}
{importConfirmDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-900">
<h2 className="mb-2 text-lg font-semibold text-zinc-900 dark:text-white">
Unsaved Changes
</h2>
<p className="mb-4 text-sm text-zinc-600 dark:text-zinc-400">
You have unsaved changes. Importing a new file will discard your
current work. Are you sure you want to continue?
</p>
<div className="flex gap-3">
<button
onClick={handleConfirmImport}
className="flex-1 rounded-md bg-red-500 px-4 py-2 text-sm font-medium text-white hover:bg-red-600"
>
Discard &amp; Import
</button>
<button
onClick={handleCancelImport}
className="flex-1 rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700"
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* Hidden file input for import */}
<input
ref={fileInputRef}
type="file"
accept=".vnflow,.json"
onChange={handleFileSelect}
className="hidden"
/>
{/* Toast notification */}
{toast && (
<Toast
message={toast.message}
type={toast.type}
onClose={() => setToast(null)}
/>
)}
</div> </div>
) )
} }

View File

@ -66,6 +66,7 @@ export default async function EditorPage({ params }: PageProps) {
<div className="flex-1"> <div className="flex-1">
<FlowchartEditor <FlowchartEditor
projectId={project.id} projectId={project.id}
projectName={project.name}
initialData={flowchartData} initialData={flowchartData}
/> />
</div> </div>

View File

@ -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']>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
className="w-full max-w-md rounded-lg border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-800"
onClick={(e) => e.stopPropagation()}
>
<h3 className="mb-4 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
{hasExistingCondition ? 'Edit Condition' : 'Add Condition'}
</h3>
<div className="space-y-4">
{/* Variable Name Input */}
<div>
<label
htmlFor="variableName"
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Variable Name
</label>
<input
type="text"
id="variableName"
value={variableName}
onChange={(e) => 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"
/>
</div>
{/* Operator Dropdown */}
<div>
<label
htmlFor="operator"
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Operator
</label>
<select
id="operator"
value={operator}
onChange={(e) => setOperator(e.target.value as Condition['operator'])}
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 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"
>
{OPERATORS.map((op) => (
<option key={op} value={op}>
{op === '==' ? '== (equals)' : op === '!=' ? '!= (not equals)' : op === '>' ? '> (greater than)' : op === '<' ? '< (less than)' : op === '>=' ? '>= (greater or equal)' : '<= (less or equal)'}
</option>
))}
</select>
</div>
{/* Value Number Input */}
<div>
<label
htmlFor="value"
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Value
</label>
<input
type="number"
id="value"
value={value}
onChange={(e) => 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"
/>
</div>
{/* Preview */}
{variableName.trim() && (
<div className="rounded-md border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-600 dark:bg-zinc-700">
<span className="text-sm text-zinc-600 dark:text-zinc-300">
Condition: <code className="font-mono text-blue-600 dark:text-blue-400">{variableName.trim()} {operator} {value}</code>
</span>
</div>
)}
</div>
{/* Action Buttons */}
<div className="mt-6 flex justify-between">
<div>
{hasExistingCondition && (
<button
type="button"
onClick={handleRemove}
className="rounded-md border border-red-300 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
>
Remove
</button>
)}
</div>
<div className="flex gap-2">
<button
type="button"
onClick={onCancel}
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={!variableName.trim()}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Save
</button>
</div>
</div>
</div>
</div>
)
}

View File

@ -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 (
<div
className="fixed z-50 min-w-40 rounded-md border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-800"
style={{ left: x, top: y }}
onClick={(e) => e.stopPropagation()}
>
{type === 'canvas' && (
<>
<button
className={`${menuItemClass} text-blue-600 dark:text-blue-400`}
onClick={() => {
onAddDialogue?.()
onClose()
}}
>
Add Dialogue
</button>
<button
className={`${menuItemClass} text-green-600 dark:text-green-400`}
onClick={() => {
onAddChoice?.()
onClose()
}}
>
Add Choice
</button>
<button
className={`${menuItemClass} text-orange-600 dark:text-orange-400`}
onClick={() => {
onAddVariable?.()
onClose()
}}
>
Add Variable
</button>
</>
)}
{type === 'node' && (
<button
className={`${menuItemClass} text-red-600 dark:text-red-400`}
onClick={() => {
onDelete?.()
onClose()
}}
>
Delete
</button>
)}
{type === 'edge' && (
<>
<button
className={`${menuItemClass} text-red-600 dark:text-red-400`}
onClick={() => {
onDelete?.()
onClose()
}}
>
Delete
</button>
<button
className={menuItemClass}
onClick={() => {
onAddCondition?.()
onClose()
}}
>
Add Condition
</button>
</>
)}
</div>
)
}

View File

@ -6,7 +6,9 @@ type ToolbarProps = {
onAddVariable: () => void onAddVariable: () => void
onSave: () => void onSave: () => void
onExport: () => void onExport: () => void
onExportRenpy: () => void
onImport: () => void onImport: () => void
isSaving?: boolean
} }
export default function Toolbar({ export default function Toolbar({
@ -15,7 +17,9 @@ export default function Toolbar({
onAddVariable, onAddVariable,
onSave, onSave,
onExport, onExport,
onExportRenpy,
onImport, onImport,
isSaving = false,
}: ToolbarProps) { }: ToolbarProps) {
return ( return (
<div className="flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-4 py-2 dark:border-zinc-700 dark:bg-zinc-800"> <div className="flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-4 py-2 dark:border-zinc-700 dark:bg-zinc-800">
@ -46,9 +50,32 @@ export default function Toolbar({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={onSave} onClick={onSave}
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800" disabled={isSaving}
className="flex items-center gap-1.5 rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
> >
Save {isSaving && (
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)}
{isSaving ? 'Saving...' : 'Save'}
</button> </button>
<button <button
onClick={onExport} onClick={onExport}
@ -56,6 +83,12 @@ export default function Toolbar({
> >
Export Export
</button> </button>
<button
onClick={onExportRenpy}
className="rounded border border-purple-400 bg-purple-50 px-3 py-1.5 text-sm font-medium text-purple-700 hover:bg-purple-100 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:border-purple-500 dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50 dark:focus:ring-offset-zinc-800"
>
Export to Ren&apos;Py
</button>
<button <button
onClick={onImport} onClick={onImport}
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800" className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"

View File

@ -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<ConditionalEdgeData>) {
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 (
<>
<BaseEdge
id={id}
path={edgePath}
markerEnd={markerEnd}
style={{
strokeDasharray: hasCondition ? '5 5' : undefined,
stroke: selected ? '#3b82f6' : hasCondition ? '#f59e0b' : '#64748b',
strokeWidth: selected ? 2 : 1.5,
}}
/>
{conditionLabel && (
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'all',
}}
className="rounded border border-amber-300 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800 dark:border-amber-600 dark:bg-amber-900 dark:text-amber-200"
>
{conditionLabel}
</div>
</EdgeLabelRenderer>
)}
</>
)
}