Compare commits
14 Commits
e02d736f2e
...
815940a011
| Author | SHA1 | Date |
|---|---|---|
|
|
815940a011 | |
|
|
f8f8048e53 | |
|
|
ee0a6f9424 | |
|
|
78479f3234 | |
|
|
59cda43987 | |
|
|
01f5428dd9 | |
|
|
e8dbd00d4c | |
|
|
f6ab24c5b3 | |
|
|
c3975dd91a | |
|
|
c431b212ac | |
|
|
e686719f29 | |
|
|
fda0903872 | |
|
|
0d8a4059bc | |
|
|
5b404cbe92 |
16
prd.json
16
prd.json
|
|
@ -533,7 +533,7 @@
|
|||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 30,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
|
|
@ -552,7 +552,7 @@
|
|||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 31,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
|
|
@ -568,7 +568,7 @@
|
|||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 32,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
|
|
@ -585,7 +585,7 @@
|
|||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 33,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
|
|
@ -603,7 +603,7 @@
|
|||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 34,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
|
|
@ -620,7 +620,7 @@
|
|||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 35,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
|
|
@ -638,7 +638,7 @@
|
|||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 36,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
|
|
@ -658,7 +658,7 @@
|
|||
"Typecheck passes"
|
||||
],
|
||||
"priority": 37,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
|
|
|
|||
119
progress.txt
119
progress.txt
|
|
@ -433,3 +433,122 @@
|
|||
- onEdgesDelete is useful for additional logic like logging, dirty state tracking, or undo/redo
|
||||
- Edge selection shows visual highlight via React Flow's built-in styling
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-030
|
||||
- What was implemented: Right-click context menu for canvas, nodes, and edges
|
||||
- Files changed:
|
||||
- src/components/editor/ContextMenu.tsx - new component with menu items for different contexts (canvas/node/edge)
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated context menu with handlers for all actions
|
||||
- **Learnings for future iterations:**
|
||||
- Use `onPaneContextMenu`, `onNodeContextMenu`, and `onEdgeContextMenu` React Flow callbacks for context menus
|
||||
- `screenToFlowPosition()` converts screen coordinates to flow coordinates for placing nodes at click position
|
||||
- Context menu state includes type ('canvas'|'node'|'edge') and optional nodeId/edgeId for targeted actions
|
||||
- Use `document.addEventListener('click', handler)` and `e.stopPropagation()` on menu to close on outside click
|
||||
- Escape key listener via `document.addEventListener('keydown', handler)` for menu close
|
||||
- NodeMouseHandler and EdgeMouseHandler types from reactflow provide proper typing for context menu callbacks
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-031
|
||||
- What was implemented: Condition editor modal for adding/editing/removing conditions on edges
|
||||
- Files changed:
|
||||
- src/components/editor/ConditionEditor.tsx - new modal component with form for variable name, operator, and value
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated condition editor with double-click and context menu triggers
|
||||
- **Learnings for future iterations:**
|
||||
- Use `onEdgeDoubleClick` React Flow callback for double-click on edges
|
||||
- Store condition editor state separately from context menu state (`conditionEditor` vs `contextMenu`)
|
||||
- Use `edge.data.condition` to access condition object on edges
|
||||
- When removing properties from edge data, use `delete` operator instead of destructuring to avoid lint warnings about unused variables
|
||||
- Condition type has operators: '>' | '<' | '==' | '>=' | '<=' | '!='
|
||||
- Preview condition in modal using template string: `${variableName} ${operator} ${value}`
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-032
|
||||
- What was implemented: Display conditions on edges with dashed styling and labels
|
||||
- Files changed:
|
||||
- src/components/editor/edges/ConditionalEdge.tsx - new custom edge component with condition display
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated custom edge type, added EdgeTypes import and edgeTypes definition
|
||||
- **Learnings for future iterations:**
|
||||
- Custom React Flow edges use EdgeProps<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'
|
||||
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
|
|
@ -14,18 +14,46 @@ import ReactFlow, {
|
|||
Node,
|
||||
Edge,
|
||||
NodeTypes,
|
||||
EdgeTypes,
|
||||
MarkerType,
|
||||
NodeMouseHandler,
|
||||
EdgeMouseHandler,
|
||||
} from 'reactflow'
|
||||
import { nanoid } from 'nanoid'
|
||||
import 'reactflow/dist/style.css'
|
||||
import Toolbar from '@/components/editor/Toolbar'
|
||||
import Toast from '@/components/Toast'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import DialogueNode from '@/components/editor/nodes/DialogueNode'
|
||||
import ChoiceNode from '@/components/editor/nodes/ChoiceNode'
|
||||
import VariableNode from '@/components/editor/nodes/VariableNode'
|
||||
import type { FlowchartData, FlowchartNode, FlowchartEdge } from '@/types/flowchart'
|
||||
import ConditionalEdge from '@/components/editor/edges/ConditionalEdge'
|
||||
import ContextMenu, { ContextMenuType } from '@/components/editor/ContextMenu'
|
||||
import ConditionEditor from '@/components/editor/ConditionEditor'
|
||||
import type { FlowchartData, FlowchartNode, FlowchartEdge, Condition } from '@/types/flowchart'
|
||||
|
||||
// LocalStorage key prefix for draft saves
|
||||
const DRAFT_KEY_PREFIX = 'vnwrite-draft-'
|
||||
|
||||
// Debounce delay in ms
|
||||
const AUTOSAVE_DEBOUNCE_MS = 1000
|
||||
|
||||
type ContextMenuState = {
|
||||
x: number
|
||||
y: number
|
||||
type: ContextMenuType
|
||||
nodeId?: string
|
||||
edgeId?: string
|
||||
} | null
|
||||
|
||||
type ConditionEditorState = {
|
||||
edgeId: string
|
||||
condition?: Condition
|
||||
} | null
|
||||
|
||||
type FlowchartEditorProps = {
|
||||
projectId: string
|
||||
projectName: string
|
||||
initialData: FlowchartData
|
||||
}
|
||||
|
||||
|
|
@ -48,15 +76,319 @@ function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] {
|
|||
target: edge.target,
|
||||
targetHandle: edge.targetHandle,
|
||||
data: edge.data,
|
||||
type: 'smoothstep',
|
||||
type: 'conditional',
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
// Convert React Flow Node type back to our FlowchartNode type
|
||||
function fromReactFlowNodes(nodes: Node[]): FlowchartNode[] {
|
||||
return nodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: node.type as 'dialogue' | 'choice' | 'variable',
|
||||
position: node.position,
|
||||
data: node.data,
|
||||
})) as FlowchartNode[]
|
||||
}
|
||||
|
||||
// Convert React Flow Edge type back to our FlowchartEdge type
|
||||
function fromReactFlowEdges(edges: Edge[]): FlowchartEdge[] {
|
||||
return edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
sourceHandle: edge.sourceHandle ?? undefined,
|
||||
target: edge.target,
|
||||
targetHandle: edge.targetHandle ?? undefined,
|
||||
data: edge.data,
|
||||
}))
|
||||
}
|
||||
|
||||
// Get LocalStorage key for a project
|
||||
function getDraftKey(projectId: string): string {
|
||||
return `${DRAFT_KEY_PREFIX}${projectId}`
|
||||
}
|
||||
|
||||
// Save draft to LocalStorage
|
||||
function saveDraft(projectId: string, data: FlowchartData): void {
|
||||
try {
|
||||
localStorage.setItem(getDraftKey(projectId), JSON.stringify(data))
|
||||
} catch (error) {
|
||||
console.error('Failed to save draft to LocalStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Load draft from LocalStorage
|
||||
function loadDraft(projectId: string): FlowchartData | null {
|
||||
try {
|
||||
const draft = localStorage.getItem(getDraftKey(projectId))
|
||||
if (!draft) return null
|
||||
return JSON.parse(draft) as FlowchartData
|
||||
} catch (error) {
|
||||
console.error('Failed to load draft from LocalStorage:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Clear draft from LocalStorage
|
||||
export function clearDraft(projectId: string): void {
|
||||
try {
|
||||
localStorage.removeItem(getDraftKey(projectId))
|
||||
} catch (error) {
|
||||
console.error('Failed to clear draft from LocalStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Compare two FlowchartData objects for equality
|
||||
function flowchartDataEquals(a: FlowchartData, b: FlowchartData): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b)
|
||||
}
|
||||
|
||||
// Validate imported flowchart data structure
|
||||
function isValidFlowchartData(data: unknown): data is FlowchartData {
|
||||
if (!data || typeof data !== 'object') return false
|
||||
const obj = data as Record<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
|
||||
function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
||||
function FlowchartEditorInner({ projectId, projectName, initialData }: FlowchartEditorProps) {
|
||||
// Define custom node types - memoized to prevent re-renders
|
||||
const nodeTypes: NodeTypes = useMemo(
|
||||
() => ({
|
||||
|
|
@ -67,7 +399,42 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
|||
[]
|
||||
)
|
||||
|
||||
const { getViewport } = useReactFlow()
|
||||
// Define custom edge types - memoized to prevent re-renders
|
||||
const edgeTypes: EdgeTypes = useMemo(
|
||||
() => ({
|
||||
conditional: ConditionalEdge,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const { getViewport, screenToFlowPosition } = useReactFlow()
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<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(
|
||||
toReactFlowNodes(initialData.nodes)
|
||||
|
|
@ -76,6 +443,51 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
|||
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(
|
||||
(params: Connection) => {
|
||||
if (!params.source || !params.target) return
|
||||
|
|
@ -85,7 +497,7 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
|||
target: params.target,
|
||||
sourceHandle: params.sourceHandle,
|
||||
targetHandle: params.targetHandle,
|
||||
type: 'smoothstep',
|
||||
type: 'conditional',
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
},
|
||||
|
|
@ -149,16 +561,195 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
|||
setNodes((nodes) => [...nodes, newNode])
|
||||
}, [getViewportCenter, setNodes])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
// TODO: Implement in US-034
|
||||
}, [])
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isSaving) return
|
||||
|
||||
setIsSaving(true)
|
||||
|
||||
try {
|
||||
const supabase = createClient()
|
||||
|
||||
// Convert React Flow state to FlowchartData
|
||||
const flowchartData: FlowchartData = {
|
||||
nodes: fromReactFlowNodes(nodes),
|
||||
edges: fromReactFlowEdges(edges),
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('projects')
|
||||
.update({
|
||||
flowchart_data: flowchartData,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', projectId)
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Clear LocalStorage draft after successful save
|
||||
clearDraft(projectId)
|
||||
|
||||
setToast({ message: 'Project saved successfully', type: 'success' })
|
||||
} catch (error) {
|
||||
console.error('Failed to save project:', error)
|
||||
setToast({ message: 'Failed to save project. Please try again.', type: 'error' })
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}, [isSaving, nodes, edges, projectId])
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
// TODO: Implement in US-035
|
||||
// Convert React Flow state to FlowchartData
|
||||
const flowchartData: FlowchartData = {
|
||||
nodes: fromReactFlowNodes(nodes),
|
||||
edges: fromReactFlowEdges(edges),
|
||||
}
|
||||
|
||||
// Create pretty-printed JSON
|
||||
const jsonContent = JSON.stringify(flowchartData, null, 2)
|
||||
|
||||
// Create blob with JSON content
|
||||
const blob = new Blob([jsonContent], { type: 'application/json' })
|
||||
|
||||
// Create download URL
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
// Create temporary link element and trigger download
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${projectName}.vnflow`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
|
||||
// Cleanup
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}, [nodes, edges, projectName])
|
||||
|
||||
const handleExportRenpy = useCallback(() => {
|
||||
// Convert React Flow state to our flowchart types
|
||||
const flowchartNodes = fromReactFlowNodes(nodes)
|
||||
const flowchartEdges = fromReactFlowEdges(edges)
|
||||
|
||||
// Convert to Ren'Py format
|
||||
const renpyExport = convertToRenpyFormat(flowchartNodes, flowchartEdges, projectName)
|
||||
|
||||
// Create pretty-printed JSON
|
||||
const jsonContent = JSON.stringify(renpyExport, null, 2)
|
||||
|
||||
// Verify JSON is valid
|
||||
try {
|
||||
JSON.parse(jsonContent)
|
||||
} catch {
|
||||
setToast({ message: 'Failed to generate valid JSON', type: 'error' })
|
||||
return
|
||||
}
|
||||
|
||||
// Create blob with JSON content
|
||||
const blob = new Blob([jsonContent], { type: 'application/json' })
|
||||
|
||||
// Create download URL
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
// Create temporary link element and trigger download
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${projectName}-renpy.json`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
|
||||
// Cleanup
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
setToast({ message: 'Exported to Ren\'Py format successfully', type: 'success' })
|
||||
}, [nodes, edges, projectName])
|
||||
|
||||
// Check if current flowchart has unsaved changes
|
||||
const hasUnsavedChanges = useCallback(() => {
|
||||
const currentData: FlowchartData = {
|
||||
nodes: fromReactFlowNodes(nodes),
|
||||
edges: fromReactFlowEdges(edges),
|
||||
}
|
||||
return !flowchartDataEquals(currentData, initialData)
|
||||
}, [nodes, edges, initialData])
|
||||
|
||||
// Load imported data into React Flow
|
||||
const loadImportedData = useCallback(
|
||||
(data: FlowchartData) => {
|
||||
setNodes(toReactFlowNodes(data.nodes))
|
||||
setEdges(toReactFlowEdges(data.edges))
|
||||
setToast({ message: 'Project imported successfully', type: 'success' })
|
||||
},
|
||||
[setNodes, setEdges]
|
||||
)
|
||||
|
||||
// Handle file selection from file picker
|
||||
const handleFileSelect = useCallback(
|
||||
(event: React.ChangeEvent<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(() => {
|
||||
// TODO: Implement in US-036
|
||||
// Confirm import (discard unsaved changes)
|
||||
const handleConfirmImport = useCallback(() => {
|
||||
if (importConfirmDialog?.pendingData) {
|
||||
loadImportedData(importConfirmDialog.pendingData)
|
||||
}
|
||||
setImportConfirmDialog(null)
|
||||
}, [importConfirmDialog, loadImportedData])
|
||||
|
||||
// Cancel import
|
||||
const handleCancelImport = useCallback(() => {
|
||||
setImportConfirmDialog(null)
|
||||
}, [])
|
||||
|
||||
// Handle edge deletion via keyboard (Delete/Backspace)
|
||||
|
|
@ -168,6 +759,179 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
|||
console.log('Deleted edges:', deletedEdges.map((e) => e.id))
|
||||
}, [])
|
||||
|
||||
// Context menu handlers
|
||||
const closeContextMenu = useCallback(() => {
|
||||
setContextMenu(null)
|
||||
}, [])
|
||||
|
||||
// Handle right-click on canvas (pane)
|
||||
const onPaneContextMenu = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.preventDefault()
|
||||
setContextMenu({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
type: 'canvas',
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Handle right-click on node
|
||||
const onNodeContextMenu: NodeMouseHandler = useCallback(
|
||||
(event, node) => {
|
||||
event.preventDefault()
|
||||
setContextMenu({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
type: 'node',
|
||||
nodeId: node.id,
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Handle right-click on edge
|
||||
const onEdgeContextMenu: EdgeMouseHandler = useCallback(
|
||||
(event, edge) => {
|
||||
event.preventDefault()
|
||||
setContextMenu({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
type: 'edge',
|
||||
edgeId: edge.id,
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Add node at specific position (for context menu)
|
||||
const handleAddNodeAtPosition = useCallback(
|
||||
(type: 'dialogue' | 'choice' | 'variable') => {
|
||||
if (!contextMenu) return
|
||||
|
||||
// Convert screen position to flow position
|
||||
const position = screenToFlowPosition({
|
||||
x: contextMenu.x,
|
||||
y: contextMenu.y,
|
||||
})
|
||||
|
||||
let newNode: Node
|
||||
|
||||
if (type === 'dialogue') {
|
||||
newNode = {
|
||||
id: nanoid(),
|
||||
type: 'dialogue',
|
||||
position,
|
||||
data: { speaker: '', text: '' },
|
||||
}
|
||||
} else if (type === 'choice') {
|
||||
newNode = {
|
||||
id: nanoid(),
|
||||
type: 'choice',
|
||||
position,
|
||||
data: {
|
||||
prompt: '',
|
||||
options: [
|
||||
{ id: nanoid(), label: '' },
|
||||
{ id: nanoid(), label: '' },
|
||||
],
|
||||
},
|
||||
}
|
||||
} else {
|
||||
newNode = {
|
||||
id: nanoid(),
|
||||
type: 'variable',
|
||||
position,
|
||||
data: {
|
||||
variableName: '',
|
||||
operation: 'set',
|
||||
value: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
setNodes((nodes) => [...nodes, newNode])
|
||||
},
|
||||
[contextMenu, screenToFlowPosition, setNodes]
|
||||
)
|
||||
|
||||
// Delete selected node from context menu
|
||||
const handleDeleteNode = useCallback(() => {
|
||||
if (!contextMenu?.nodeId) return
|
||||
setNodes((nodes) => nodes.filter((n) => n.id !== contextMenu.nodeId))
|
||||
}, [contextMenu, setNodes])
|
||||
|
||||
// Delete selected edge from context menu
|
||||
const handleDeleteEdge = useCallback(() => {
|
||||
if (!contextMenu?.edgeId) return
|
||||
setEdges((edges) => edges.filter((e) => e.id !== contextMenu.edgeId))
|
||||
}, [contextMenu, setEdges])
|
||||
|
||||
// Open condition editor for an edge
|
||||
const openConditionEditor = useCallback(
|
||||
(edgeId: string) => {
|
||||
const edge = edges.find((e) => e.id === edgeId)
|
||||
if (!edge) return
|
||||
setConditionEditor({
|
||||
edgeId,
|
||||
condition: edge.data?.condition,
|
||||
})
|
||||
},
|
||||
[edges]
|
||||
)
|
||||
|
||||
// Add condition to edge (opens ConditionEditor modal)
|
||||
const handleAddCondition = useCallback(() => {
|
||||
if (!contextMenu?.edgeId) return
|
||||
openConditionEditor(contextMenu.edgeId)
|
||||
}, [contextMenu, openConditionEditor])
|
||||
|
||||
// Handle double-click on edge to open condition editor
|
||||
const onEdgeDoubleClick = useCallback(
|
||||
(_event: React.MouseEvent, edge: Edge) => {
|
||||
openConditionEditor(edge.id)
|
||||
},
|
||||
[openConditionEditor]
|
||||
)
|
||||
|
||||
// Save condition to edge
|
||||
const handleSaveCondition = useCallback(
|
||||
(edgeId: string, condition: Condition) => {
|
||||
setEdges((eds) =>
|
||||
eds.map((edge) =>
|
||||
edge.id === edgeId
|
||||
? { ...edge, data: { ...edge.data, condition } }
|
||||
: edge
|
||||
)
|
||||
)
|
||||
setConditionEditor(null)
|
||||
},
|
||||
[setEdges]
|
||||
)
|
||||
|
||||
// Remove condition from edge
|
||||
const handleRemoveCondition = useCallback(
|
||||
(edgeId: string) => {
|
||||
setEdges((eds) =>
|
||||
eds.map((edge) => {
|
||||
if (edge.id !== edgeId) return edge
|
||||
// Remove condition from data
|
||||
const newData = { ...edge.data }
|
||||
delete newData.condition
|
||||
return { ...edge, data: newData }
|
||||
})
|
||||
)
|
||||
setConditionEditor(null)
|
||||
},
|
||||
[setEdges]
|
||||
)
|
||||
|
||||
// Close condition editor
|
||||
const closeConditionEditor = useCallback(() => {
|
||||
setConditionEditor(null)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<Toolbar
|
||||
|
|
@ -176,17 +940,24 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
|||
onAddVariable={handleAddVariable}
|
||||
onSave={handleSave}
|
||||
onExport={handleExport}
|
||||
onExportRenpy={handleExportRenpy}
|
||||
onImport={handleImport}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onEdgesDelete={onEdgesDelete}
|
||||
onConnect={onConnect}
|
||||
onPaneContextMenu={onPaneContextMenu}
|
||||
onNodeContextMenu={onNodeContextMenu}
|
||||
onEdgeContextMenu={onEdgeContextMenu}
|
||||
onEdgeDoubleClick={onEdgeDoubleClick}
|
||||
deleteKeyCode={['Delete', 'Backspace']}
|
||||
fitView
|
||||
>
|
||||
|
|
@ -194,6 +965,108 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
|||
<Controls position="bottom-right" />
|
||||
</ReactFlow>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ export default async function EditorPage({ params }: PageProps) {
|
|||
<div className="flex-1">
|
||||
<FlowchartEditor
|
||||
projectId={project.id}
|
||||
projectName={project.name}
|
||||
initialData={flowchartData}
|
||||
/>
|
||||
</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
|
||||
onSave: () => void
|
||||
onExport: () => void
|
||||
onExportRenpy: () => void
|
||||
onImport: () => void
|
||||
isSaving?: boolean
|
||||
}
|
||||
|
||||
export default function Toolbar({
|
||||
|
|
@ -15,7 +17,9 @@ export default function Toolbar({
|
|||
onAddVariable,
|
||||
onSave,
|
||||
onExport,
|
||||
onExportRenpy,
|
||||
onImport,
|
||||
isSaving = false,
|
||||
}: ToolbarProps) {
|
||||
return (
|
||||
<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">
|
||||
<button
|
||||
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
|
||||
onClick={onExport}
|
||||
|
|
@ -56,6 +83,12 @@ export default function Toolbar({
|
|||
>
|
||||
Export
|
||||
</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
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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