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'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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