ralph/vn-flowchart-editor #1

Merged
GHMiranda merged 14 commits from ralph/vn-flowchart-editor into master 2026-01-22 21:53:46 +00:00
2 changed files with 280 additions and 2 deletions
Showing only changes of commit 5b404cbe92 - Show all commits

View File

@ -1,6 +1,6 @@
'use client'
import { useCallback, useMemo } from 'react'
import { useCallback, useMemo, useState } from 'react'
import ReactFlow, {
Background,
BackgroundVariant,
@ -15,6 +15,8 @@ import ReactFlow, {
Edge,
NodeTypes,
MarkerType,
NodeMouseHandler,
EdgeMouseHandler,
} from 'reactflow'
import { nanoid } from 'nanoid'
import 'reactflow/dist/style.css'
@ -22,8 +24,17 @@ import Toolbar from '@/components/editor/Toolbar'
import DialogueNode from '@/components/editor/nodes/DialogueNode'
import ChoiceNode from '@/components/editor/nodes/ChoiceNode'
import VariableNode from '@/components/editor/nodes/VariableNode'
import ContextMenu, { ContextMenuType } from '@/components/editor/ContextMenu'
import type { FlowchartData, FlowchartNode, FlowchartEdge } from '@/types/flowchart'
type ContextMenuState = {
x: number
y: number
type: ContextMenuType
nodeId?: string
edgeId?: string
} | null
type FlowchartEditorProps = {
projectId: string
initialData: FlowchartData
@ -67,7 +78,9 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
[]
)
const { getViewport } = useReactFlow()
const { getViewport, screenToFlowPosition } = useReactFlow()
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null)
const [nodes, setNodes, onNodesChange] = useNodesState(
toReactFlowNodes(initialData.nodes)
@ -168,6 +181,121 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
console.log('Deleted edges:', deletedEdges.map((e) => e.id))
}, [])
// Context menu handlers
const closeContextMenu = useCallback(() => {
setContextMenu(null)
}, [])
// Handle right-click on canvas (pane)
const onPaneContextMenu = useCallback(
(event: React.MouseEvent) => {
event.preventDefault()
setContextMenu({
x: event.clientX,
y: event.clientY,
type: 'canvas',
})
},
[]
)
// Handle right-click on node
const onNodeContextMenu: NodeMouseHandler = useCallback(
(event, node) => {
event.preventDefault()
setContextMenu({
x: event.clientX,
y: event.clientY,
type: 'node',
nodeId: node.id,
})
},
[]
)
// Handle right-click on edge
const onEdgeContextMenu: EdgeMouseHandler = useCallback(
(event, edge) => {
event.preventDefault()
setContextMenu({
x: event.clientX,
y: event.clientY,
type: 'edge',
edgeId: edge.id,
})
},
[]
)
// Add node at specific position (for context menu)
const handleAddNodeAtPosition = useCallback(
(type: 'dialogue' | 'choice' | 'variable') => {
if (!contextMenu) return
// Convert screen position to flow position
const position = screenToFlowPosition({
x: contextMenu.x,
y: contextMenu.y,
})
let newNode: Node
if (type === 'dialogue') {
newNode = {
id: nanoid(),
type: 'dialogue',
position,
data: { speaker: '', text: '' },
}
} else if (type === 'choice') {
newNode = {
id: nanoid(),
type: 'choice',
position,
data: {
prompt: '',
options: [
{ id: nanoid(), label: '' },
{ id: nanoid(), label: '' },
],
},
}
} else {
newNode = {
id: nanoid(),
type: 'variable',
position,
data: {
variableName: '',
operation: 'set',
value: 0,
},
}
}
setNodes((nodes) => [...nodes, newNode])
},
[contextMenu, screenToFlowPosition, setNodes]
)
// Delete selected node from context menu
const handleDeleteNode = useCallback(() => {
if (!contextMenu?.nodeId) return
setNodes((nodes) => nodes.filter((n) => n.id !== contextMenu.nodeId))
}, [contextMenu, setNodes])
// Delete selected edge from context menu
const handleDeleteEdge = useCallback(() => {
if (!contextMenu?.edgeId) return
setEdges((edges) => edges.filter((e) => e.id !== contextMenu.edgeId))
}, [contextMenu, setEdges])
// Add condition to edge (will be implemented in US-031)
const handleAddCondition = useCallback(() => {
// TODO: Implement in US-031 - will open ConditionEditor modal
console.log('Add condition to edge:', contextMenu?.edgeId)
}, [contextMenu])
return (
<div className="flex h-full w-full flex-col">
<Toolbar
@ -187,6 +315,9 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
onEdgesChange={onEdgesChange}
onEdgesDelete={onEdgesDelete}
onConnect={onConnect}
onPaneContextMenu={onPaneContextMenu}
onNodeContextMenu={onNodeContextMenu}
onEdgeContextMenu={onEdgeContextMenu}
deleteKeyCode={['Delete', 'Backspace']}
fitView
>
@ -194,6 +325,22 @@ 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}
/>
)}
</div>
)
}

View File

@ -0,0 +1,131 @@
'use client'
import { useCallback, useEffect } from 'react'
export type ContextMenuType = 'canvas' | 'node' | 'edge'
type ContextMenuProps = {
x: number
y: number
type: ContextMenuType
onClose: () => void
onAddDialogue?: () => void
onAddChoice?: () => void
onAddVariable?: () => void
onDelete?: () => void
onAddCondition?: () => void
}
export default function ContextMenu({
x,
y,
type,
onClose,
onAddDialogue,
onAddChoice,
onAddVariable,
onDelete,
onAddCondition,
}: ContextMenuProps) {
// Close menu on Escape key
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
},
[onClose]
)
// Close menu on click outside
const handleClickOutside = useCallback(() => {
onClose()
}, [onClose])
useEffect(() => {
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('click', handleClickOutside)
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('click', handleClickOutside)
}
}, [handleKeyDown, handleClickOutside])
const menuItemClass =
'w-full px-4 py-2 text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-700 cursor-pointer'
return (
<div
className="fixed z-50 min-w-40 rounded-md border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-800"
style={{ left: x, top: y }}
onClick={(e) => e.stopPropagation()}
>
{type === 'canvas' && (
<>
<button
className={`${menuItemClass} text-blue-600 dark:text-blue-400`}
onClick={() => {
onAddDialogue?.()
onClose()
}}
>
Add Dialogue
</button>
<button
className={`${menuItemClass} text-green-600 dark:text-green-400`}
onClick={() => {
onAddChoice?.()
onClose()
}}
>
Add Choice
</button>
<button
className={`${menuItemClass} text-orange-600 dark:text-orange-400`}
onClick={() => {
onAddVariable?.()
onClose()
}}
>
Add Variable
</button>
</>
)}
{type === 'node' && (
<button
className={`${menuItemClass} text-red-600 dark:text-red-400`}
onClick={() => {
onDelete?.()
onClose()
}}
>
Delete
</button>
)}
{type === 'edge' && (
<>
<button
className={`${menuItemClass} text-red-600 dark:text-red-400`}
onClick={() => {
onDelete?.()
onClose()
}}
>
Delete
</button>
<button
className={menuItemClass}
onClick={() => {
onAddCondition?.()
onClose()
}}
>
Add Condition
</button>
</>
)}
</div>
)
}