mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-27 07:43:41 +00:00
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:
parent
fab5c035a5
commit
5275b52f0a
4 changed files with 111 additions and 7 deletions
|
|
@ -5,7 +5,6 @@ import LeftPanel, { type LeftPanelRef } from "./components/Panels/LeftPanel";
|
||||||
import RightPanel from "./components/Panels/RightPanel";
|
import RightPanel from "./components/Panels/RightPanel";
|
||||||
import BottomPanel from "./components/Timeline/BottomPanel";
|
import BottomPanel from "./components/Timeline/BottomPanel";
|
||||||
import DocumentTabs from "./components/Workspace/DocumentTabs";
|
import DocumentTabs from "./components/Workspace/DocumentTabs";
|
||||||
import Toolbar from "./components/Toolbar/Toolbar";
|
|
||||||
import MenuBar from "./components/Menu/MenuBar";
|
import MenuBar from "./components/Menu/MenuBar";
|
||||||
import DocumentManager from "./components/Workspace/DocumentManager";
|
import DocumentManager from "./components/Workspace/DocumentManager";
|
||||||
import KeyboardShortcutsHelp from "./components/Common/KeyboardShortcutsHelp";
|
import KeyboardShortcutsHelp from "./components/Common/KeyboardShortcutsHelp";
|
||||||
|
|
@ -24,9 +23,8 @@ import type { ExportOptions } from "./utils/graphExport";
|
||||||
*
|
*
|
||||||
* Layout:
|
* Layout:
|
||||||
* - Header with title
|
* - Header with title
|
||||||
* - Menu bar (File, Edit, View)
|
* - Menu bar (File, Edit, View) with undo/redo controls
|
||||||
* - Document tabs for multi-file support
|
* - Document tabs for multi-file support
|
||||||
* - Toolbar for graph editing controls
|
|
||||||
* - Main graph editor canvas
|
* - Main graph editor canvas
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
|
|
@ -141,9 +139,6 @@ function AppContent() {
|
||||||
{/* Document Tabs */}
|
{/* Document Tabs */}
|
||||||
<DocumentTabs />
|
<DocumentTabs />
|
||||||
|
|
||||||
{/* Toolbar */}
|
|
||||||
{activeDocumentId && <Toolbar />}
|
|
||||||
|
|
||||||
{/* Main content area with side panels and bottom panel */}
|
{/* Main content area with side panels and bottom panel */}
|
||||||
<main className="flex-1 overflow-hidden flex flex-col">
|
<main className="flex-1 overflow-hidden flex flex-col">
|
||||||
{/* Top section: Left panel, graph editor, right panel */}
|
{/* Top section: Left panel, graph editor, right panel */}
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,20 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
|
||||||
getCurrentViewport,
|
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)
|
// Save viewport periodically (debounced)
|
||||||
const handleViewportChange = useCallback(
|
const handleViewportChange = useCallback(
|
||||||
(_event: MouseEvent | TouchEvent | null, viewport: Viewport) => {
|
(_event: MouseEvent | TouchEvent | null, viewport: Viewport) => {
|
||||||
|
|
@ -455,6 +469,10 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
|
||||||
y: event.clientY,
|
y: event.clientY,
|
||||||
type: "pane",
|
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
|
// Handle right-click on node
|
||||||
|
|
@ -467,6 +485,10 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
|
||||||
type: "node",
|
type: "node",
|
||||||
target: 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",
|
type: "edge",
|
||||||
target: 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) {
|
if (contextMenu) {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
}
|
}
|
||||||
|
// Close all menus (menu bar dropdowns and context menus) when clicking on the graph canvas
|
||||||
|
window.dispatchEvent(new Event('closeAllMenus'));
|
||||||
}, [contextMenu]);
|
}, [contextMenu]);
|
||||||
|
|
||||||
// Shared node creation logic (used by context menu and left panel)
|
// Shared node creation logic (used by context menu and left panel)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
import { useWorkspaceStore } from '../../stores/workspaceStore';
|
import { useWorkspaceStore } from '../../stores/workspaceStore';
|
||||||
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
|
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
|
||||||
|
import { useDocumentHistory } from '../../hooks/useDocumentHistory';
|
||||||
import DocumentManager from '../Workspace/DocumentManager';
|
import DocumentManager from '../Workspace/DocumentManager';
|
||||||
import NodeTypeConfigModal from '../Config/NodeTypeConfig';
|
import NodeTypeConfigModal from '../Config/NodeTypeConfig';
|
||||||
import EdgeTypeConfigModal from '../Config/EdgeTypeConfig';
|
import EdgeTypeConfigModal from '../Config/EdgeTypeConfig';
|
||||||
|
|
@ -51,6 +52,21 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onSelectAll, o
|
||||||
} = useWorkspaceStore();
|
} = useWorkspaceStore();
|
||||||
|
|
||||||
const { clearGraph } = useGraphWithHistory();
|
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
|
// Close menu when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -67,7 +83,17 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onSelectAll, o
|
||||||
}, [activeMenu]);
|
}, [activeMenu]);
|
||||||
|
|
||||||
const toggleMenu = useCallback((menuName: string) => {
|
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(() => {
|
const closeMenu = useCallback(() => {
|
||||||
|
|
@ -148,6 +174,16 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onSelectAll, o
|
||||||
closeMenu();
|
closeMenu();
|
||||||
}, [closeMenu]);
|
}, [closeMenu]);
|
||||||
|
|
||||||
|
const handleUndo = useCallback(() => {
|
||||||
|
undo();
|
||||||
|
closeMenu();
|
||||||
|
}, [undo, closeMenu]);
|
||||||
|
|
||||||
|
const handleRedo = useCallback(() => {
|
||||||
|
redo();
|
||||||
|
closeMenu();
|
||||||
|
}, [redo, closeMenu]);
|
||||||
|
|
||||||
const handleClearGraph = useCallback(async () => {
|
const handleClearGraph = useCallback(async () => {
|
||||||
const confirmed = await confirm({
|
const confirmed = await confirm({
|
||||||
title: 'Clear Current Graph',
|
title: 'Clear Current Graph',
|
||||||
|
|
@ -301,6 +337,31 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onSelectAll, o
|
||||||
|
|
||||||
{activeMenu === 'edit' && (
|
{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">
|
<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
|
<button
|
||||||
onClick={handleConfigureActors}
|
onClick={handleConfigureActors}
|
||||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,20 @@ const TimelineViewInner: React.FC = () => {
|
||||||
setEdges(layoutEdges);
|
setEdges(layoutEdges);
|
||||||
}, [layoutNodes, layoutEdges, setNodes, setEdges]);
|
}, [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
|
// Handle node click - switch to state
|
||||||
const handleNodeClick = useCallback(
|
const handleNodeClick = useCallback(
|
||||||
(_event: React.MouseEvent, node: Node) => {
|
(_event: React.MouseEvent, node: Node) => {
|
||||||
|
|
@ -185,6 +199,8 @@ const TimelineViewInner: React.FC = () => {
|
||||||
if (contextMenu) {
|
if (contextMenu) {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
}
|
}
|
||||||
|
// Close all menus (menu bar dropdowns and context menus) when clicking on the timeline canvas
|
||||||
|
window.dispatchEvent(new Event('closeAllMenus'));
|
||||||
}, [contextMenu]);
|
}, [contextMenu]);
|
||||||
|
|
||||||
// Handle node context menu
|
// Handle node context menu
|
||||||
|
|
@ -196,6 +212,10 @@ const TimelineViewInner: React.FC = () => {
|
||||||
y: event.clientY,
|
y: event.clientY,
|
||||||
stateId: node.id,
|
stateId: node.id,
|
||||||
});
|
});
|
||||||
|
// Close other menus when opening context menu (after state update)
|
||||||
|
setTimeout(() => {
|
||||||
|
window.dispatchEvent(new CustomEvent('closeAllMenus', { detail: { source: 'contextmenu' } }));
|
||||||
|
}, 0);
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue