feat: [US-030] - Right-click context menu
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e02d736f2e
commit
5b404cbe92
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useMemo } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import ReactFlow, {
|
import ReactFlow, {
|
||||||
Background,
|
Background,
|
||||||
BackgroundVariant,
|
BackgroundVariant,
|
||||||
|
|
@ -15,6 +15,8 @@ import ReactFlow, {
|
||||||
Edge,
|
Edge,
|
||||||
NodeTypes,
|
NodeTypes,
|
||||||
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'
|
||||||
|
|
@ -22,8 +24,17 @@ import Toolbar from '@/components/editor/Toolbar'
|
||||||
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 ContextMenu, { ContextMenuType } from '@/components/editor/ContextMenu'
|
||||||
import type { FlowchartData, FlowchartNode, FlowchartEdge } from '@/types/flowchart'
|
import type { FlowchartData, FlowchartNode, FlowchartEdge } from '@/types/flowchart'
|
||||||
|
|
||||||
|
type ContextMenuState = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
type: ContextMenuType
|
||||||
|
nodeId?: string
|
||||||
|
edgeId?: string
|
||||||
|
} | null
|
||||||
|
|
||||||
type FlowchartEditorProps = {
|
type FlowchartEditorProps = {
|
||||||
projectId: string
|
projectId: string
|
||||||
initialData: FlowchartData
|
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(
|
const [nodes, setNodes, onNodesChange] = useNodesState(
|
||||||
toReactFlowNodes(initialData.nodes)
|
toReactFlowNodes(initialData.nodes)
|
||||||
|
|
@ -168,6 +181,121 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
||||||
console.log('Deleted edges:', deletedEdges.map((e) => e.id))
|
console.log('Deleted edges:', deletedEdges.map((e) => e.id))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Context menu handlers
|
||||||
|
const closeContextMenu = useCallback(() => {
|
||||||
|
setContextMenu(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Handle right-click on canvas (pane)
|
||||||
|
const onPaneContextMenu = useCallback(
|
||||||
|
(event: React.MouseEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setContextMenu({
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
type: 'canvas',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle right-click on node
|
||||||
|
const onNodeContextMenu: NodeMouseHandler = useCallback(
|
||||||
|
(event, node) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setContextMenu({
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
type: 'node',
|
||||||
|
nodeId: node.id,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle right-click on edge
|
||||||
|
const onEdgeContextMenu: EdgeMouseHandler = useCallback(
|
||||||
|
(event, edge) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setContextMenu({
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
type: 'edge',
|
||||||
|
edgeId: edge.id,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add node at specific position (for context menu)
|
||||||
|
const handleAddNodeAtPosition = useCallback(
|
||||||
|
(type: 'dialogue' | 'choice' | 'variable') => {
|
||||||
|
if (!contextMenu) return
|
||||||
|
|
||||||
|
// Convert screen position to flow position
|
||||||
|
const position = screenToFlowPosition({
|
||||||
|
x: contextMenu.x,
|
||||||
|
y: contextMenu.y,
|
||||||
|
})
|
||||||
|
|
||||||
|
let newNode: Node
|
||||||
|
|
||||||
|
if (type === 'dialogue') {
|
||||||
|
newNode = {
|
||||||
|
id: nanoid(),
|
||||||
|
type: 'dialogue',
|
||||||
|
position,
|
||||||
|
data: { speaker: '', text: '' },
|
||||||
|
}
|
||||||
|
} else if (type === 'choice') {
|
||||||
|
newNode = {
|
||||||
|
id: nanoid(),
|
||||||
|
type: 'choice',
|
||||||
|
position,
|
||||||
|
data: {
|
||||||
|
prompt: '',
|
||||||
|
options: [
|
||||||
|
{ id: nanoid(), label: '' },
|
||||||
|
{ id: nanoid(), label: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newNode = {
|
||||||
|
id: nanoid(),
|
||||||
|
type: 'variable',
|
||||||
|
position,
|
||||||
|
data: {
|
||||||
|
variableName: '',
|
||||||
|
operation: 'set',
|
||||||
|
value: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setNodes((nodes) => [...nodes, newNode])
|
||||||
|
},
|
||||||
|
[contextMenu, screenToFlowPosition, setNodes]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Delete selected node from context menu
|
||||||
|
const handleDeleteNode = useCallback(() => {
|
||||||
|
if (!contextMenu?.nodeId) return
|
||||||
|
setNodes((nodes) => nodes.filter((n) => n.id !== contextMenu.nodeId))
|
||||||
|
}, [contextMenu, setNodes])
|
||||||
|
|
||||||
|
// Delete selected edge from context menu
|
||||||
|
const handleDeleteEdge = useCallback(() => {
|
||||||
|
if (!contextMenu?.edgeId) return
|
||||||
|
setEdges((edges) => edges.filter((e) => e.id !== contextMenu.edgeId))
|
||||||
|
}, [contextMenu, setEdges])
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div className="flex h-full w-full flex-col">
|
<div className="flex h-full w-full flex-col">
|
||||||
<Toolbar
|
<Toolbar
|
||||||
|
|
@ -187,6 +315,9 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onEdgesDelete={onEdgesDelete}
|
onEdgesDelete={onEdgesDelete}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
|
onPaneContextMenu={onPaneContextMenu}
|
||||||
|
onNodeContextMenu={onNodeContextMenu}
|
||||||
|
onEdgeContextMenu={onEdgeContextMenu}
|
||||||
deleteKeyCode={['Delete', 'Backspace']}
|
deleteKeyCode={['Delete', 'Backspace']}
|
||||||
fitView
|
fitView
|
||||||
>
|
>
|
||||||
|
|
@ -194,6 +325,22 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
||||||
<Controls position="bottom-right" />
|
<Controls position="bottom-right" />
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{contextMenu && (
|
||||||
|
<ContextMenu
|
||||||
|
x={contextMenu.x}
|
||||||
|
y={contextMenu.y}
|
||||||
|
type={contextMenu.type}
|
||||||
|
onClose={closeContextMenu}
|
||||||
|
onAddDialogue={() => handleAddNodeAtPosition('dialogue')}
|
||||||
|
onAddChoice={() => handleAddNodeAtPosition('choice')}
|
||||||
|
onAddVariable={() => handleAddNodeAtPosition('variable')}
|
||||||
|
onDelete={
|
||||||
|
contextMenu.type === 'node' ? handleDeleteNode : handleDeleteEdge
|
||||||
|
}
|
||||||
|
onAddCondition={handleAddCondition}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue