Merge pull request 'ralph/vn-flowchart-editor' (#1) from ralph/vn-flowchart-editor into master
Reviewed-on: #1
This commit is contained in:
commit
8e37e1433f
16
prd.json
16
prd.json
|
|
@ -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": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
119
progress.txt
119
progress.txt
|
|
@ -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
|
||||||
|
---
|
||||||
|
|
|
||||||
|
|
@ -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 & 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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'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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue