mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-27 07:43:41 +00:00
fix: allow double click on state nodes and improve their design
This commit is contained in:
parent
89117415ed
commit
3ab90e5dd3
3 changed files with 129 additions and 69 deletions
17
src/App.tsx
17
src/App.tsx
|
|
@ -49,10 +49,11 @@ function AppContent() {
|
|||
const leftPanelRef = useRef<LeftPanelRef>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<Actor | null>(null);
|
||||
const [selectedEdge, setSelectedEdge] = useState<Relation | null>(null);
|
||||
const [addNodeCallback, setAddNodeCallback] = useState<
|
||||
// Use refs for callbacks to avoid triggering re-renders
|
||||
const addNodeCallbackRef = useRef<
|
||||
((nodeTypeId: string, position?: { x: number; y: number }) => void) | null
|
||||
>(null);
|
||||
const [exportCallback, setExportCallback] = useState<
|
||||
const exportCallbackRef = useRef<
|
||||
((format: "png" | "svg", options?: ExportOptions) => Promise<void>) | null
|
||||
>(null);
|
||||
const { fitView } = useReactFlow();
|
||||
|
|
@ -133,7 +134,7 @@ function AppContent() {
|
|||
onOpenHelp={() => setShowKeyboardHelp(true)}
|
||||
onFitView={handleFitView}
|
||||
onSelectAll={handleSelectAll}
|
||||
onExport={exportCallback || undefined}
|
||||
onExport={exportCallbackRef.current || undefined}
|
||||
/>
|
||||
|
||||
{/* Document Tabs */}
|
||||
|
|
@ -151,7 +152,7 @@ function AppContent() {
|
|||
setSelectedNode(null);
|
||||
setSelectedEdge(null);
|
||||
}}
|
||||
onAddNode={addNodeCallback || undefined}
|
||||
onAddNode={addNodeCallbackRef.current || undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -179,13 +180,17 @@ function AppContent() {
|
|||
nodeTypeId: string,
|
||||
position?: { x: number; y: number },
|
||||
) => void,
|
||||
) => setAddNodeCallback(() => callback)}
|
||||
) => {
|
||||
addNodeCallbackRef.current = callback;
|
||||
}}
|
||||
onExportRequest={(
|
||||
callback: (
|
||||
format: "png" | "svg",
|
||||
options?: ExportOptions,
|
||||
) => Promise<void>,
|
||||
) => setExportCallback(() => callback)}
|
||||
) => {
|
||||
exportCallbackRef.current = callback;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import React from 'react';
|
||||
import { Handle, Position, NodeProps } from 'reactflow';
|
||||
import type { ConstellationState } from '../../types/timeline';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import React from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import type { ConstellationState } from "../../types/timeline";
|
||||
|
||||
interface StateNodeData {
|
||||
state: ConstellationState;
|
||||
isCurrent: boolean;
|
||||
onRename?: (stateId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -16,29 +16,29 @@ const StateNode: React.FC<NodeProps<StateNodeData>> = ({ data, selected }) => {
|
|||
|
||||
// Format date if present
|
||||
const dateStr = state.metadata?.date
|
||||
? new Date(state.metadata.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
? new Date(state.metadata.date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
: null;
|
||||
|
||||
// Get custom color or default
|
||||
const color = state.metadata?.color || '#3b82f6';
|
||||
const color = state.metadata?.color || "#3b82f6";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
px-3 py-2 rounded-lg border-2 bg-white shadow-sm
|
||||
px-2 py-1.5 rounded-lg border-2 bg-white shadow-sm
|
||||
transition-all cursor-pointer
|
||||
${selected ? 'border-blue-500 shadow-md' : 'border-gray-300'}
|
||||
${isCurrent ? 'ring-2 ring-green-400' : ''}
|
||||
${selected ? "border-blue-500 shadow-md" : "border-gray-300"}
|
||||
${isCurrent ? "ring-2 ring-green-400" : ""}
|
||||
hover:shadow-lg
|
||||
`}
|
||||
style={{
|
||||
minWidth: '120px',
|
||||
maxWidth: '200px',
|
||||
borderColor: selected ? '#3b82f6' : isCurrent ? '#10b981' : '#d1d5db',
|
||||
minWidth: "100px",
|
||||
maxWidth: "180px",
|
||||
borderColor: selected ? "#3b82f6" : isCurrent ? "#10b981" : "#d1d5db",
|
||||
}}
|
||||
>
|
||||
{/* Handles for connections */}
|
||||
|
|
@ -54,37 +54,34 @@ const StateNode: React.FC<NodeProps<StateNodeData>> = ({ data, selected }) => {
|
|||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex items-start gap-2">
|
||||
{isCurrent && (
|
||||
<CheckCircleIcon
|
||||
className="text-green-500 flex-shrink-0"
|
||||
style={{ fontSize: '16px' }}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-sm truncate" title={state.label}>
|
||||
<div className="font-semibold text-xs truncate" title={state.label}>
|
||||
{state.label}
|
||||
</div>
|
||||
{dateStr && (
|
||||
<div className="text-xs text-gray-500">{dateStr}</div>
|
||||
<div className="text-[10px] text-gray-500">{dateStr}</div>
|
||||
)}
|
||||
{state.description && (
|
||||
<div className="text-xs text-gray-600 truncate mt-1" title={state.description}>
|
||||
<div
|
||||
className="text-[10px] text-gray-600 truncate mt-0.5"
|
||||
title={state.description}
|
||||
>
|
||||
{state.description}
|
||||
</div>
|
||||
)}
|
||||
{state.metadata?.tags && state.metadata.tags.length > 0 && (
|
||||
<div className="flex gap-1 mt-1 flex-wrap">
|
||||
<div className="flex gap-1 mt-0.5 flex-wrap">
|
||||
{state.metadata.tags.slice(0, 2).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-xs bg-blue-100 text-blue-700 px-1 rounded"
|
||||
className="text-[10px] bg-blue-100 text-blue-700 px-1 rounded"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{state.metadata.tags.length > 2 && (
|
||||
<span className="text-xs text-gray-500">
|
||||
<span className="text-[10px] text-gray-500">
|
||||
+{state.metadata.tags.length - 2}
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useMemo, useCallback, useState } from 'react';
|
||||
import React, { useMemo, useCallback, useState } from "react";
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
Controls,
|
||||
|
|
@ -9,18 +9,18 @@ import ReactFlow, {
|
|||
useEdgesState,
|
||||
BackgroundVariant,
|
||||
ReactFlowProvider,
|
||||
} from 'reactflow';
|
||||
import 'reactflow/dist/style.css';
|
||||
import { useTimelineStore } from '../../stores/timelineStore';
|
||||
import { useWorkspaceStore } from '../../stores/workspaceStore';
|
||||
import StateNode from './StateNode';
|
||||
import ContextMenu from '../Editor/ContextMenu';
|
||||
import RenameStateDialog from './RenameStateDialog';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import FileCopyIcon from '@mui/icons-material/FileCopy';
|
||||
import CallSplitIcon from '@mui/icons-material/CallSplit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import type { ConstellationState, StateId } from '../../types/timeline';
|
||||
} from "reactflow";
|
||||
import "reactflow/dist/style.css";
|
||||
import { useTimelineStore } from "../../stores/timelineStore";
|
||||
import { useWorkspaceStore } from "../../stores/workspaceStore";
|
||||
import StateNode from "./StateNode";
|
||||
import ContextMenu from "../Editor/ContextMenu";
|
||||
import RenameStateDialog from "./RenameStateDialog";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import FileCopyIcon from "@mui/icons-material/FileCopy";
|
||||
import CallSplitIcon from "@mui/icons-material/CallSplit";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import type { ConstellationState, StateId } from "../../types/timeline";
|
||||
|
||||
/**
|
||||
* Layout states in a horizontal timeline with branches
|
||||
|
|
@ -28,7 +28,7 @@ import type { ConstellationState, StateId } from '../../types/timeline';
|
|||
function layoutStates(
|
||||
states: ConstellationState[],
|
||||
currentStateId: StateId,
|
||||
rootStateId: StateId
|
||||
rootStateId: StateId,
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
const horizontalSpacing = 200;
|
||||
const verticalSpacing = 100;
|
||||
|
|
@ -91,7 +91,7 @@ function layoutStates(
|
|||
|
||||
return {
|
||||
id: state.id,
|
||||
type: 'stateNode',
|
||||
type: "stateNode",
|
||||
position: {
|
||||
x: level * horizontalSpacing,
|
||||
y: lane * verticalSpacing,
|
||||
|
|
@ -111,11 +111,11 @@ function layoutStates(
|
|||
id: `${state.parentStateId}-${state.id}`,
|
||||
source: state.parentStateId,
|
||||
target: state.id,
|
||||
type: 'smoothstep',
|
||||
type: "smoothstep",
|
||||
animated: state.id === currentStateId,
|
||||
style: {
|
||||
strokeWidth: state.id === currentStateId ? 3 : 2,
|
||||
stroke: state.id === currentStateId ? '#10b981' : '#9ca3af',
|
||||
stroke: state.id === currentStateId ? "#10b981" : "#9ca3af",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -129,7 +129,14 @@ function layoutStates(
|
|||
*/
|
||||
const TimelineViewInner: React.FC = () => {
|
||||
const activeDocumentId = useWorkspaceStore((state) => state.activeDocumentId);
|
||||
const { timelines, switchToState, updateState, duplicateState, duplicateStateAsChild, deleteState } = useTimelineStore();
|
||||
const {
|
||||
timelines,
|
||||
switchToState,
|
||||
updateState,
|
||||
duplicateState,
|
||||
duplicateStateAsChild,
|
||||
deleteState,
|
||||
} = useTimelineStore();
|
||||
|
||||
const timeline = activeDocumentId ? timelines.get(activeDocumentId) : null;
|
||||
|
||||
|
|
@ -152,14 +159,44 @@ const TimelineViewInner: React.FC = () => {
|
|||
return Array.from(timeline.states.values());
|
||||
}, [timeline]);
|
||||
|
||||
// Handle rename request from node
|
||||
const handleRenameRequest = useCallback(
|
||||
(stateId: string) => {
|
||||
console.log("Rename requested for state:", stateId);
|
||||
const state = timeline?.states.get(stateId);
|
||||
if (state) {
|
||||
setRenameDialog({
|
||||
stateId: stateId,
|
||||
currentLabel: state.label,
|
||||
});
|
||||
}
|
||||
},
|
||||
[timeline],
|
||||
);
|
||||
|
||||
// Layout nodes and edges
|
||||
const { nodes: layoutNodes, edges: layoutEdges } = useMemo(() => {
|
||||
if (!timeline || states.length === 0) {
|
||||
return { nodes: [], edges: [] };
|
||||
}
|
||||
|
||||
return layoutStates(states, timeline.currentStateId, timeline.rootStateId);
|
||||
}, [states, timeline]);
|
||||
const { nodes, edges } = layoutStates(
|
||||
states,
|
||||
timeline.currentStateId,
|
||||
timeline.rootStateId,
|
||||
);
|
||||
|
||||
// Add rename handler to each node's data
|
||||
const nodesWithRename = nodes.map((node) => ({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
onRename: handleRenameRequest,
|
||||
},
|
||||
}));
|
||||
|
||||
return { nodes: nodesWithRename, edges };
|
||||
}, [states, timeline, handleRenameRequest]);
|
||||
|
||||
// React Flow state
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(layoutNodes);
|
||||
|
|
@ -176,22 +213,34 @@ const TimelineViewInner: React.FC = () => {
|
|||
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') {
|
||||
if (customEvent.detail?.source !== "contextmenu") {
|
||||
setContextMenu(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('closeAllMenus', handleCloseAllMenus);
|
||||
return () => window.removeEventListener('closeAllMenus', handleCloseAllMenus);
|
||||
window.addEventListener("closeAllMenus", handleCloseAllMenus);
|
||||
return () =>
|
||||
window.removeEventListener("closeAllMenus", handleCloseAllMenus);
|
||||
}, []);
|
||||
|
||||
// Handle node click - switch to state
|
||||
const handleNodeClick = useCallback(
|
||||
(_event: React.MouseEvent, node: Node) => {
|
||||
console.log("Single click on node:", node.id);
|
||||
switchToState(node.id);
|
||||
setContextMenu(null); // Close context menu if open
|
||||
},
|
||||
[switchToState]
|
||||
[switchToState],
|
||||
);
|
||||
|
||||
// Handle node click - switch to state
|
||||
const handleNodeDoubleClick = useCallback(
|
||||
(_event: React.MouseEvent, node: Node) => {
|
||||
console.log("Double click on node:", node.id);
|
||||
handleRenameRequest(node.id);
|
||||
setContextMenu(null); // Close context menu if open
|
||||
},
|
||||
[handleRenameRequest],
|
||||
);
|
||||
|
||||
// Handle pane click - close context menu
|
||||
|
|
@ -200,7 +249,7 @@ const TimelineViewInner: React.FC = () => {
|
|||
setContextMenu(null);
|
||||
}
|
||||
// Close all menus (menu bar dropdowns and context menus) when clicking on the timeline canvas
|
||||
window.dispatchEvent(new Event('closeAllMenus'));
|
||||
window.dispatchEvent(new Event("closeAllMenus"));
|
||||
}, [contextMenu]);
|
||||
|
||||
// Handle node context menu
|
||||
|
|
@ -214,10 +263,14 @@ const TimelineViewInner: React.FC = () => {
|
|||
});
|
||||
// Close other menus when opening context menu (after state update)
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent('closeAllMenus', { detail: { source: 'contextmenu' } }));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("closeAllMenus", {
|
||||
detail: { source: "contextmenu" },
|
||||
}),
|
||||
);
|
||||
}, 0);
|
||||
},
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
// Context menu actions
|
||||
|
|
@ -260,7 +313,7 @@ const TimelineViewInner: React.FC = () => {
|
|||
updateState(renameDialog.stateId, { label: newLabel });
|
||||
}
|
||||
},
|
||||
[renameDialog, updateState]
|
||||
[renameDialog, updateState],
|
||||
);
|
||||
|
||||
// Custom node types
|
||||
|
|
@ -268,7 +321,7 @@ const TimelineViewInner: React.FC = () => {
|
|||
() => ({
|
||||
stateNode: StateNode,
|
||||
}),
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
if (!timeline) {
|
||||
|
|
@ -276,7 +329,9 @@ const TimelineViewInner: React.FC = () => {
|
|||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
<div className="text-center">
|
||||
<p>No timeline for this document.</p>
|
||||
<p className="text-sm mt-1">Create a timeline to manage multiple states.</p>
|
||||
<p className="text-sm mt-1">
|
||||
Create a timeline to manage multiple states.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -298,6 +353,7 @@ const TimelineViewInner: React.FC = () => {
|
|||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onNodeContextMenu={handleNodeContextMenu}
|
||||
onPaneClick={handlePaneClick}
|
||||
nodeTypes={nodeTypes}
|
||||
|
|
@ -310,10 +366,12 @@ const TimelineViewInner: React.FC = () => {
|
|||
panOnDrag={true}
|
||||
zoomOnScroll={true}
|
||||
zoomOnPinch={true}
|
||||
zoomOnDoubleClick={false}
|
||||
preventScrolling={false}
|
||||
panOnScroll={false}
|
||||
selectionOnDrag={false}
|
||||
selectNodesOnDrag={false}
|
||||
nodesFocusable={false}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||
|
|
@ -329,22 +387,22 @@ const TimelineViewInner: React.FC = () => {
|
|||
{
|
||||
actions: [
|
||||
{
|
||||
label: 'Rename',
|
||||
label: "Rename",
|
||||
icon: <EditIcon fontSize="small" />,
|
||||
onClick: handleRenameFromMenu,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Duplicate',
|
||||
title: "Duplicate",
|
||||
actions: [
|
||||
{
|
||||
label: 'Duplicate (Parallel)',
|
||||
label: "Duplicate (Parallel)",
|
||||
icon: <FileCopyIcon fontSize="small" />,
|
||||
onClick: handleDuplicateParallelFromMenu,
|
||||
},
|
||||
{
|
||||
label: 'Duplicate (Series)',
|
||||
label: "Duplicate (Series)",
|
||||
icon: <CallSplitIcon fontSize="small" />,
|
||||
onClick: handleDuplicateSeriesFromMenu,
|
||||
},
|
||||
|
|
@ -353,7 +411,7 @@ const TimelineViewInner: React.FC = () => {
|
|||
{
|
||||
actions: [
|
||||
{
|
||||
label: 'Delete',
|
||||
label: "Delete",
|
||||
icon: <DeleteIcon fontSize="small" />,
|
||||
onClick: handleDeleteFromMenu,
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue