Compare commits
No commits in common. "815940a011d35dd732165f751ee64f227c29ac6e" and "e02d736f2e7d0cbbc2d962b09afaa0d8ff5f1c76" have entirely different histories.
815940a011
...
e02d736f2e
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": true,
|
"passes": false,
|
||||||
"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": true,
|
"passes": false,
|
||||||
"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": true,
|
"passes": false,
|
||||||
"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": true,
|
"passes": false,
|
||||||
"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": true,
|
"passes": false,
|
||||||
"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": true,
|
"passes": false,
|
||||||
"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": true,
|
"passes": false,
|
||||||
"notes": ""
|
"notes": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -658,7 +658,7 @@
|
||||||
"Typecheck passes"
|
"Typecheck passes"
|
||||||
],
|
],
|
||||||
"priority": 37,
|
"priority": 37,
|
||||||
"passes": true,
|
"passes": false,
|
||||||
"notes": ""
|
"notes": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
119
progress.txt
119
progress.txt
|
|
@ -433,122 +433,3 @@
|
||||||
- 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, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
import ReactFlow, {
|
import ReactFlow, {
|
||||||
Background,
|
Background,
|
||||||
BackgroundVariant,
|
BackgroundVariant,
|
||||||
|
|
@ -14,46 +14,18 @@ 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 ConditionalEdge from '@/components/editor/edges/ConditionalEdge'
|
import type { FlowchartData, FlowchartNode, FlowchartEdge } from '@/types/flowchart'
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,319 +48,15 @@ function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] {
|
||||||
target: edge.target,
|
target: edge.target,
|
||||||
targetHandle: edge.targetHandle,
|
targetHandle: edge.targetHandle,
|
||||||
data: edge.data,
|
data: edge.data,
|
||||||
type: 'conditional',
|
type: 'smoothstep',
|
||||||
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({ projectId, projectName, initialData }: FlowchartEditorProps) {
|
function FlowchartEditorInner({ 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(
|
||||||
() => ({
|
() => ({
|
||||||
|
|
@ -399,42 +67,7 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Define custom edge types - memoized to prevent re-renders
|
const { getViewport } = useReactFlow()
|
||||||
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)
|
||||||
|
|
@ -443,51 +76,6 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
|
||||||
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
|
||||||
|
|
@ -497,7 +85,7 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
|
||||||
target: params.target,
|
target: params.target,
|
||||||
sourceHandle: params.sourceHandle,
|
sourceHandle: params.sourceHandle,
|
||||||
targetHandle: params.targetHandle,
|
targetHandle: params.targetHandle,
|
||||||
type: 'conditional',
|
type: 'smoothstep',
|
||||||
markerEnd: {
|
markerEnd: {
|
||||||
type: MarkerType.ArrowClosed,
|
type: MarkerType.ArrowClosed,
|
||||||
},
|
},
|
||||||
|
|
@ -561,195 +149,16 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
|
||||||
setNodes((nodes) => [...nodes, newNode])
|
setNodes((nodes) => [...nodes, newNode])
|
||||||
}, [getViewportCenter, setNodes])
|
}, [getViewportCenter, setNodes])
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(() => {
|
||||||
if (isSaving) return
|
// TODO: Implement in US-034
|
||||||
|
|
||||||
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(() => {
|
|
||||||
// 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()
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Confirm import (discard unsaved changes)
|
const handleExport = useCallback(() => {
|
||||||
const handleConfirmImport = useCallback(() => {
|
// TODO: Implement in US-035
|
||||||
if (importConfirmDialog?.pendingData) {
|
}, [])
|
||||||
loadImportedData(importConfirmDialog.pendingData)
|
|
||||||
}
|
|
||||||
setImportConfirmDialog(null)
|
|
||||||
}, [importConfirmDialog, loadImportedData])
|
|
||||||
|
|
||||||
// Cancel import
|
const handleImport = useCallback(() => {
|
||||||
const handleCancelImport = useCallback(() => {
|
// TODO: Implement in US-036
|
||||||
setImportConfirmDialog(null)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Handle edge deletion via keyboard (Delete/Backspace)
|
// Handle edge deletion via keyboard (Delete/Backspace)
|
||||||
|
|
@ -759,179 +168,6 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
|
||||||
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
|
||||||
|
|
@ -940,24 +176,17 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
|
||||||
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
|
||||||
>
|
>
|
||||||
|
|
@ -965,108 +194,6 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
|
||||||
<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,7 +66,6 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
'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,9 +6,7 @@ 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({
|
||||||
|
|
@ -17,9 +15,7 @@ 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">
|
||||||
|
|
@ -50,32 +46,9 @@ export default function Toolbar({
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
disabled={isSaving}
|
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="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"
|
|
||||||
>
|
>
|
||||||
{isSaving && (
|
Save
|
||||||
<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}
|
||||||
|
|
@ -83,12 +56,6 @@ 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"
|
||||||
|
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
'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