refactor: move undo/redo to Edit menu and implement unified menu system

Removes the upper toolbar and consolidates undo/redo controls into the
Edit menu for a cleaner interface with more screen space.

Changes:
- Move undo/redo buttons from Toolbar to Edit menu with descriptions
- Remove Toolbar component from App layout
- Implement closeAllMenus event system for coordinated menu management
- Add event listeners to close menus when clicking on graph/timeline canvas
- Add cross-menu closing: context menus close menu bar and vice versa
- Fix React warning by deferring event dispatch with setTimeout

Benefits:
- Cleaner UI with more vertical space for graph editor
- Unified menu system prevents multiple menus open simultaneously
- Context menus and menu bar dropdowns coordinate properly
- Consistent UX: clicking anywhere closes open menus

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-10-12 12:35:30 +02:00
parent fab5c035a5
commit 5275b52f0a
4 changed files with 111 additions and 7 deletions

View file

@ -5,7 +5,6 @@ import LeftPanel, { type LeftPanelRef } from "./components/Panels/LeftPanel";
import RightPanel from "./components/Panels/RightPanel";
import BottomPanel from "./components/Timeline/BottomPanel";
import DocumentTabs from "./components/Workspace/DocumentTabs";
import Toolbar from "./components/Toolbar/Toolbar";
import MenuBar from "./components/Menu/MenuBar";
import DocumentManager from "./components/Workspace/DocumentManager";
import KeyboardShortcutsHelp from "./components/Common/KeyboardShortcutsHelp";
@ -24,9 +23,8 @@ import type { ExportOptions } from "./utils/graphExport";
*
* Layout:
* - Header with title
* - Menu bar (File, Edit, View)
* - Menu bar (File, Edit, View) with undo/redo controls
* - Document tabs for multi-file support
* - Toolbar for graph editing controls
* - Main graph editor canvas
*
* Features:
@ -141,9 +139,6 @@ function AppContent() {
{/* Document Tabs */}
<DocumentTabs />
{/* Toolbar */}
{activeDocumentId && <Toolbar />}
{/* Main content area with side panels and bottom panel */}
<main className="flex-1 overflow-hidden flex flex-col">
{/* Top section: Left panel, graph editor, right panel */}

View file

@ -223,6 +223,20 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
getCurrentViewport,
]);
// Listen for custom event to close all menus (including context menus)
useEffect(() => {
const handleCloseAllMenus = (event: Event) => {
const customEvent = event as CustomEvent;
// Don't close if the event came from context menu itself (source: 'contextmenu')
if (customEvent.detail?.source !== 'contextmenu') {
setContextMenu(null);
}
};
window.addEventListener('closeAllMenus', handleCloseAllMenus);
return () => window.removeEventListener('closeAllMenus', handleCloseAllMenus);
}, []);
// Save viewport periodically (debounced)
const handleViewportChange = useCallback(
(_event: MouseEvent | TouchEvent | null, viewport: Viewport) => {
@ -455,6 +469,10 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
y: event.clientY,
type: "pane",
});
// Close other menus when opening context menu (after state update)
setTimeout(() => {
window.dispatchEvent(new CustomEvent('closeAllMenus', { detail: { source: 'contextmenu' } }));
}, 0);
}, []);
// Handle right-click on node
@ -467,6 +485,10 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
type: "node",
target: node,
});
// Close other menus when opening context menu (after state update)
setTimeout(() => {
window.dispatchEvent(new CustomEvent('closeAllMenus', { detail: { source: 'contextmenu' } }));
}, 0);
},
[],
);
@ -481,6 +503,10 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
type: "edge",
target: edge,
});
// Close other menus when opening context menu (after state update)
setTimeout(() => {
window.dispatchEvent(new CustomEvent('closeAllMenus', { detail: { source: 'contextmenu' } }));
}, 0);
},
[],
);
@ -490,6 +516,8 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
if (contextMenu) {
setContextMenu(null);
}
// Close all menus (menu bar dropdowns and context menus) when clicking on the graph canvas
window.dispatchEvent(new Event('closeAllMenus'));
}, [contextMenu]);
// Shared node creation logic (used by context menu and left panel)

View file

@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useEffect } from 'react';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { useWorkspaceStore } from '../../stores/workspaceStore';
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
import { useDocumentHistory } from '../../hooks/useDocumentHistory';
import DocumentManager from '../Workspace/DocumentManager';
import NodeTypeConfigModal from '../Config/NodeTypeConfig';
import EdgeTypeConfigModal from '../Config/EdgeTypeConfig';
@ -51,6 +52,21 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onSelectAll, o
} = useWorkspaceStore();
const { clearGraph } = useGraphWithHistory();
const { undo, redo, canUndo, canRedo, undoDescription, redoDescription } = useDocumentHistory();
// Listen for custom event to close all menus (e.g., from graph canvas clicks, context menu opens)
useEffect(() => {
const handleCloseMenuEvent = (event: Event) => {
const customEvent = event as CustomEvent;
// Don't close if the event came from MenuBar itself (source: 'menubar')
if (customEvent.detail?.source !== 'menubar') {
setActiveMenu(null);
}
};
window.addEventListener('closeAllMenus', handleCloseMenuEvent);
return () => window.removeEventListener('closeAllMenus', handleCloseMenuEvent);
}, []);
// Close menu when clicking outside
useEffect(() => {
@ -67,7 +83,17 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onSelectAll, o
}, [activeMenu]);
const toggleMenu = useCallback((menuName: string) => {
setActiveMenu((current) => (current === menuName ? null : menuName));
setActiveMenu((current) => {
const newMenu = current === menuName ? null : menuName;
// When opening a menu (not closing), dispatch event to close context menus after state updates
if (newMenu !== null && current !== menuName) {
// Use setTimeout to dispatch after the render phase completes
setTimeout(() => {
window.dispatchEvent(new CustomEvent('closeAllMenus', { detail: { source: 'menubar' } }));
}, 0);
}
return newMenu;
});
}, []);
const closeMenu = useCallback(() => {
@ -148,6 +174,16 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onSelectAll, o
closeMenu();
}, [closeMenu]);
const handleUndo = useCallback(() => {
undo();
closeMenu();
}, [undo, closeMenu]);
const handleRedo = useCallback(() => {
redo();
closeMenu();
}, [redo, closeMenu]);
const handleClearGraph = useCallback(async () => {
const confirmed = await confirm({
title: 'Clear Current Graph',
@ -301,6 +337,31 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onSelectAll, o
{activeMenu === 'edit' && (
<div className="absolute top-full left-0 mt-1 w-56 bg-white border border-gray-200 rounded-md shadow-lg py-1 z-50">
<button
onClick={handleUndo}
disabled={!canUndo}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center justify-between disabled:opacity-40 disabled:cursor-not-allowed"
title={undoDescription ? `Undo: ${undoDescription}` : 'Undo'}
>
<span>Undo{undoDescription ? `: ${undoDescription}` : ''}</span>
{getShortcutLabel('undo') && (
<span className="text-xs text-gray-400">{getShortcutLabel('undo')}</span>
)}
</button>
<button
onClick={handleRedo}
disabled={!canRedo}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center justify-between disabled:opacity-40 disabled:cursor-not-allowed"
title={redoDescription ? `Redo: ${redoDescription}` : 'Redo'}
>
<span>Redo{redoDescription ? `: ${redoDescription}` : ''}</span>
{getShortcutLabel('redo') && (
<span className="text-xs text-gray-400">{getShortcutLabel('redo')}</span>
)}
</button>
<hr className="my-1 border-gray-200" />
<button
onClick={handleConfigureActors}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"

View file

@ -171,6 +171,20 @@ const TimelineViewInner: React.FC = () => {
setEdges(layoutEdges);
}, [layoutNodes, layoutEdges, setNodes, setEdges]);
// Listen for custom event to close all menus (including context menus)
React.useEffect(() => {
const handleCloseAllMenus = (event: Event) => {
const customEvent = event as CustomEvent;
// Don't close if the event came from context menu itself (source: 'contextmenu')
if (customEvent.detail?.source !== 'contextmenu') {
setContextMenu(null);
}
};
window.addEventListener('closeAllMenus', handleCloseAllMenus);
return () => window.removeEventListener('closeAllMenus', handleCloseAllMenus);
}, []);
// Handle node click - switch to state
const handleNodeClick = useCallback(
(_event: React.MouseEvent, node: Node) => {
@ -185,6 +199,8 @@ const TimelineViewInner: React.FC = () => {
if (contextMenu) {
setContextMenu(null);
}
// Close all menus (menu bar dropdowns and context menus) when clicking on the timeline canvas
window.dispatchEvent(new Event('closeAllMenus'));
}, [contextMenu]);
// Handle node context menu
@ -196,6 +212,10 @@ const TimelineViewInner: React.FC = () => {
y: event.clientY,
stateId: node.id,
});
// Close other menus when opening context menu (after state update)
setTimeout(() => {
window.dispatchEvent(new CustomEvent('closeAllMenus', { detail: { source: 'contextmenu' } }));
}, 0);
},
[]
);