constellation-analyzer/src/App.tsx
Jan-Henrik Bruhn f5adbc8ead feat: add resizable actor grouping with full undo/redo support
Implements visual grouping of actors with context menu operations,
resizable containers, and complete history tracking integration.

Features:
- Create groups from multiple selected actors via context menu
- Groups visualized as resizable containers with child nodes
- Ungroup actors (non-destructive) or delete group with actors
- Right-click context menu with group-specific operations
- Dedicated GroupEditorPanel for group properties
- Smart minimum size constraint based on child node positions
- Full undo/redo support for group operations and resizes

Technical Implementation:
- GroupNode component with React Flow NodeResizer integration
- Atomic createGroupWithActors operation for consistent history
- Parent-child relationship using React Flow v11 parentId pattern
- Groups stored separately from actors in graphStore
- Fixed history tracking to sync graphStore before snapshots
- Resize tracking to prevent state sync loops during interaction
- Dynamic minimum dimensions to keep children inside bounds
- Sanitization of orphaned parentId references on state load

History Fixes:
- pushToHistory now syncs timeline with graphStore before snapshot
- Prevents missing groups/nodes in history states
- Ensures undo/redo correctly restores all graph elements
- Atomic state updates to avoid React Flow processing stale state

Storage & Persistence:
- Groups saved in timeline states and document structure
- Safe JSON serialization to prevent prototype pollution
- Cleanup utilities for removing __proto__ from localStorage

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 20:06:59 +02:00

255 lines
8.7 KiB
TypeScript

import { useState, useCallback, useEffect, useRef } from "react";
import { ReactFlowProvider, useReactFlow } from "reactflow";
import GraphEditor from "./components/Editor/GraphEditor";
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 MenuBar from "./components/Menu/MenuBar";
import DocumentManager from "./components/Workspace/DocumentManager";
import KeyboardShortcutsHelp from "./components/Common/KeyboardShortcutsHelp";
import ToastContainer from "./components/Common/ToastContainer";
import { KeyboardShortcutProvider } from "./contexts/KeyboardShortcutContext";
import { useGlobalShortcuts } from "./hooks/useGlobalShortcuts";
import { useDocumentHistory } from "./hooks/useDocumentHistory";
import { useWorkspaceStore } from "./stores/workspaceStore";
import { usePanelStore } from "./stores/panelStore";
import { useCreateDocument } from "./hooks/useCreateDocument";
import type { Actor, Relation, Group } from "./types";
import type { ExportOptions } from "./utils/graphExport";
/**
* App - Root application component
*
* Layout:
* - Header with title
* - Menu bar (File, Edit, View) with undo/redo controls
* - Document tabs for multi-file support
* - Main graph editor canvas
*
* Features:
* - Responsive layout
* - ReactFlowProvider wrapper for graph functionality
* - Multi-document workspace with tabs
* - Organized menu system for file and editing operations
* - Per-document undo/redo with keyboard shortcuts
* - Centralized keyboard shortcut management system
*/
/** Inner component that has access to ReactFlow context */
function AppContent() {
const { undo, redo } = useDocumentHistory();
const { activeDocumentId } = useWorkspaceStore();
const { leftPanelVisible, rightPanelVisible, bottomPanelVisible } = usePanelStore();
const { handleNewDocument, NewDocumentDialog } = useCreateDocument();
const [showDocumentManager, setShowDocumentManager] = useState(false);
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false);
// Ref for LeftPanel to call focusSearch
const leftPanelRef = useRef<LeftPanelRef>(null);
const [selectedNode, setSelectedNode] = useState<Actor | null>(null);
const [selectedEdge, setSelectedEdge] = useState<Relation | null>(null);
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
// Use refs for callbacks to avoid triggering re-renders
const addNodeCallbackRef = useRef<
((nodeTypeId: string, position?: { x: number; y: number }) => void) | null
>(null);
const exportCallbackRef = useRef<
((format: "png" | "svg", options?: ExportOptions) => Promise<void>) | null
>(null);
const { fitView } = useReactFlow();
// Listen for document manager open event from EmptyState
useEffect(() => {
const handleOpenDocumentManager = () => {
setShowDocumentManager(true);
};
window.addEventListener("openDocumentManager", handleOpenDocumentManager);
return () =>
window.removeEventListener(
"openDocumentManager",
handleOpenDocumentManager,
);
}, []);
const handleFitView = useCallback(() => {
fitView({ padding: 0.2, duration: 300 });
}, [fitView]);
// Setup global keyboard shortcuts
useGlobalShortcuts({
onUndo: undo,
onRedo: redo,
onNewDocument: handleNewDocument,
onOpenDocumentManager: () => setShowDocumentManager(true),
onOpenHelp: () => setShowKeyboardHelp(true),
onFitView: handleFitView,
onFocusSearch: () => leftPanelRef.current?.focusSearch(),
});
// Escape key to close property panels
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Escape: Close property panels
if (e.key === "Escape") {
if (selectedNode || selectedEdge || selectedGroup) {
e.preventDefault();
setSelectedNode(null);
setSelectedEdge(null);
setSelectedGroup(null);
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedNode, selectedEdge, selectedGroup]);
return (
<div className="flex flex-col h-screen bg-gray-100">
{/* Header */}
<header className="bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg">
<div className="px-6 py-3">
<div className="flex items-center gap-3">
<img
src="favicon.svg"
alt="Constellation Analyzer Logo"
className="w-8 h-8"
/>
<h1 className="text-xl font-bold">Constellation Analyzer</h1>
<span className="text-blue-100 text-sm border-l border-blue-400 pl-3">
Visual editor for analyzing actors and their relationships
</span>
</div>
</div>
</header>
{/* Menu Bar */}
<MenuBar
onOpenHelp={() => setShowKeyboardHelp(true)}
onFitView={handleFitView}
onExport={exportCallbackRef.current || undefined}
/>
{/* Document Tabs */}
<DocumentTabs />
{/* 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 */}
<div className="flex-1 overflow-hidden flex">
{/* Left Panel */}
{leftPanelVisible && activeDocumentId && (
<LeftPanel
ref={leftPanelRef}
onDeselectAll={() => {
setSelectedNode(null);
setSelectedEdge(null);
setSelectedGroup(null);
}}
onAddNode={addNodeCallbackRef.current || undefined}
/>
)}
{/* Center: Graph Editor */}
<div className="flex-1 overflow-hidden">
<GraphEditor
selectedNode={selectedNode}
selectedEdge={selectedEdge}
selectedGroup={selectedGroup}
onNodeSelect={(node) => {
setSelectedNode(node);
// Only clear others if we're setting a node (not clearing)
if (node) {
setSelectedEdge(null);
setSelectedGroup(null);
}
}}
onEdgeSelect={(edge) => {
setSelectedEdge(edge);
// Only clear others if we're setting an edge (not clearing)
if (edge) {
setSelectedNode(null);
setSelectedGroup(null);
}
}}
onGroupSelect={(group) => {
setSelectedGroup(group);
// Only clear others if we're setting a group (not clearing)
if (group) {
setSelectedNode(null);
setSelectedEdge(null);
}
}}
onAddNodeRequest={(
callback: (
nodeTypeId: string,
position?: { x: number; y: number },
) => void,
) => {
addNodeCallbackRef.current = callback;
}}
onExportRequest={(
callback: (
format: "png" | "svg",
options?: ExportOptions,
) => Promise<void>,
) => {
exportCallbackRef.current = callback;
}}
/>
</div>
{/* Right Panel */}
{rightPanelVisible && activeDocumentId && (
<RightPanel
selectedNode={selectedNode}
selectedEdge={selectedEdge}
selectedGroup={selectedGroup}
onClose={() => {
setSelectedNode(null);
setSelectedEdge(null);
setSelectedGroup(null);
}}
/>
)}
</div>
{/* Bottom Panel (Timeline) - show when bottomPanelVisible and there's an active document */}
{bottomPanelVisible && activeDocumentId && (
<BottomPanel />
)}
</main>
{/* Document Manager Modal */}
<DocumentManager
isOpen={showDocumentManager}
onClose={() => setShowDocumentManager(false)}
/>
{/* Keyboard Shortcuts Help Modal */}
<KeyboardShortcutsHelp
isOpen={showKeyboardHelp}
onClose={() => setShowKeyboardHelp(false)}
/>
{/* Toast Notifications */}
<ToastContainer />
{/* New Document Dialog */}
{NewDocumentDialog}
</div>
);
}
function App() {
return (
<KeyboardShortcutProvider>
<ReactFlowProvider>
<AppContent />
</ReactFlowProvider>
</KeyboardShortcutProvider>
);
}
export default App;