fix: allow double click on state nodes and improve their design

This commit is contained in:
Jan-Henrik Bruhn 2025-10-12 14:49:15 +02:00
parent 89117415ed
commit 3ab90e5dd3
3 changed files with 129 additions and 69 deletions

View file

@ -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>

View file

@ -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>
)}

View file

@ -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,
},