From 5b404cbe92666c43e55ae453e25a85c01b31e4a2 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 18:04:54 -0300 Subject: [PATCH] feat: [US-030] - Right-click context menu Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 151 +++++++++++++++++- src/components/editor/ContextMenu.tsx | 131 +++++++++++++++ 2 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 src/components/editor/ContextMenu.tsx diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index a37e8fc..6a7abd7 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -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(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 (
@@ -194,6 +325,22 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
+ + {contextMenu && ( + handleAddNodeAtPosition('dialogue')} + onAddChoice={() => handleAddNodeAtPosition('choice')} + onAddVariable={() => handleAddNodeAtPosition('variable')} + onDelete={ + contextMenu.type === 'node' ? handleDeleteNode : handleDeleteEdge + } + onAddCondition={handleAddCondition} + /> + )} ) } diff --git a/src/components/editor/ContextMenu.tsx b/src/components/editor/ContextMenu.tsx new file mode 100644 index 0000000..cb9ba53 --- /dev/null +++ b/src/components/editor/ContextMenu.tsx @@ -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 ( +
e.stopPropagation()} + > + {type === 'canvas' && ( + <> + + + + + )} + + {type === 'node' && ( + + )} + + {type === 'edge' && ( + <> + + + + )} +
+ ) +}