Skip to content

Commit

Permalink
Support deleting edges & improve action bar design
Browse files Browse the repository at this point in the history
  • Loading branch information
alhassanalbadri committed Nov 30, 2024
1 parent 4344be3 commit 0db8857
Show file tree
Hide file tree
Showing 7 changed files with 621 additions and 69 deletions.
85 changes: 61 additions & 24 deletions components/CustomEdge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ interface CustomEdgeProps {
targetY: number;
};
nodes?: Node[];
onDeleteEdge?: (id: string) => void;
setSelectedEdgeId: (id: string | null) => void;
selectedEdgeId: string | null;
}

interface Node {
Expand All @@ -43,10 +46,19 @@ interface Node {
height: number;
}

const CustomEdge: React.FC<CustomEdgeProps> = ({ edge, path, nodes }) => {
const CustomEdge: React.FC<CustomEdgeProps> = ({
edge,
path,
nodes,
onDeleteEdge,
setSelectedEdgeId,
selectedEdgeId,
}) => {
const [isEditing, setIsEditing] = useState(false);
const label = edge?.data?.label || "";

const isSelected = edge?.id === selectedEdgeId;

const textRef = useRef<SVGTextElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [labelDimensions, setLabelDimensions] = useState<{ width: number; height: number }>({
Expand Down Expand Up @@ -75,7 +87,7 @@ const CustomEdge: React.FC<CustomEdgeProps> = ({ edge, path, nodes }) => {
"http://www.w3.org/2000/svg",
"path"
);
pathElement.setAttribute("d", "M0,0 L6,3 L0,6 Z"); // Triangle shape
pathElement.setAttribute("d", "M0,0 L6,3 L0,6 Z");
pathElement.setAttribute("fill", "#b1b1b7");
pathElement.setAttribute("stroke", "none");

Expand Down Expand Up @@ -145,18 +157,38 @@ const CustomEdge: React.FC<CustomEdgeProps> = ({ edge, path, nodes }) => {
[]
);

const handleEdgeClick = useCallback(
(event: React.MouseEvent) => {
event.stopPropagation();
if (edge?.id !== selectedEdgeId) {
setSelectedEdgeId(edge?.id || null);
} else if(edge?.id === selectedEdgeId) {
setSelectedEdgeId(null);
}
},
[edge, selectedEdgeId, setSelectedEdgeId]
);

useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (isSelected && (event.key === "Delete" || event.key === "Backspace") && edge && !isEditing) {
onDeleteEdge?.(edge.id);
setSelectedEdgeId(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isSelected, edge, onDeleteEdge, setSelectedEdgeId]);

let sourceX, sourceY, targetX, targetY;

if (edge && nodes) {
const sourceNode = nodes.find((n) => n.id === edge.source);
const targetNode = nodes.find((n) => n.id === edge.target);
if (!sourceNode || !targetNode) return null;

// Source port (bottom-center)
sourceX = sourceNode.position.x + sourceNode.width / 2;
sourceY = sourceNode.position.y + sourceNode.height;

// Target port (top-center)
targetX = targetNode.position.x + targetNode.width / 2;
targetY = targetNode.position.y;
} else if (path) {
Expand All @@ -169,31 +201,28 @@ const CustomEdge: React.FC<CustomEdgeProps> = ({ edge, path, nodes }) => {
}

const isSelfConnection = edge ? edge.source === edge.target : false;

const calculatePathData = () => {
if (isSelfConnection && edge) {
const node = nodes?.find((n) => n.id === edge.source);
if (!node) return { pathData: "", labelX: 0, labelY: 0 };

// Dynamically calculate radii based on node dimensions with scaling factors
const RADIUS_SCALE_X = 0.4; // Horizontal scaling factor
const RADIUS_SCALE_Y = 0.9; // Vertical scaling factor
const PADDING = 15; // Extra padding for aesthetics
const RADIUS_SCALE_X = 0.4;
const RADIUS_SCALE_Y = 0.9;
const PADDING = 15;

const radiusX = node.width * RADIUS_SCALE_X + PADDING;
const radiusY = node.height * RADIUS_SCALE_Y + PADDING;


const pathData = `
M ${sourceX} ${sourceY}
A ${radiusX} ${radiusY} 0 1 1 ${targetX} ${targetY}`;

// Position the label above the loop, offset by radii
const labelX = sourceX - radiusX - 10; // Adjust for visual centering
const labelX = sourceX - radiusX - 10;
const labelY = sourceY - radiusY - 20;

return { pathData, labelX, labelY };
} else {
// Normal edge handling with Bezier curve
const deltaY = targetY - sourceY;
const controlPointOffset = Math.min(100, Math.abs(deltaY) / 2);

Expand All @@ -205,7 +234,6 @@ const CustomEdge: React.FC<CustomEdgeProps> = ({ edge, path, nodes }) => {

const pathData = `M ${sourceX} ${sourceY} C ${controlX1} ${controlY1}, ${controlX2} ${controlY2}, ${targetX} ${targetY}`;

// Label position: midpoint of the curve
const labelX = (sourceX + targetX) / 2;
const labelY = (sourceY + targetY) / 2 - 20;

Expand All @@ -222,35 +250,45 @@ const CustomEdge: React.FC<CustomEdgeProps> = ({ edge, path, nodes }) => {
width: "100%",
height: "100%",
pointerEvents: "none",
zIndex: isSelfConnection ? 2 : 1, // Higher z-index for self-connections
zIndex: isSelfConnection ? 2 : 1,
};

return (
<svg style={svgStyle} xmlns="http://www.w3.org/2000/svg" className="edge-container">
{/* Edge Path */}
{/* Invisible hitbox for selection */}
<path
d={pathData}
stroke="transparent"
strokeWidth={10}
fill="none"
style={{ pointerEvents: "stroke", cursor: "pointer" }}
onClick={handleEdgeClick}
/>

{/* Highlight edge when selected */}
<path
d={pathData}
stroke="#b1b1b7"
stroke={isSelected ? "#ff5c5c" : "#b1b1b7"}
strokeWidth={2}
fill="none"
markerEnd="url(#arrowhead-marker)"
strokeLinecap="round"
strokeLinejoin="round"
style={{ cursor: isSelected ? "pointer" : "default" }}
/>

{/* Edge Label */}
{label && (
<g
style={{ pointerEvents: "auto" }}
transform={`translate(${labelX}, ${labelY})`}
onClick={handleLabelClick}
onKeyDown={(e) => {
if (e.key === "Enter") handleLabelClick(e);
onClick={(e) => {
handleEdgeClick(e);
handleLabelClick(e);
}}
role="button"
tabIndex={0}
>
{/* Background Rectangle */}
<rect
x={-labelDimensions.width / 2 - LABEL_PADDING}
y={-labelDimensions.height / 2 - LABEL_PADDING}
Expand All @@ -259,10 +297,9 @@ const CustomEdge: React.FC<CustomEdgeProps> = ({ edge, path, nodes }) => {
fill="rgba(255, 255, 255, 0.9)"
rx="6"
ry="6"
stroke="#b1b1b7"
stroke={isSelected ? "#ff5c5c" : "#b1b1b7"}
strokeWidth="1"
/>
{/* Label Content */}
{!isEditing && (
<text
ref={textRef}
Expand All @@ -280,7 +317,7 @@ const CustomEdge: React.FC<CustomEdgeProps> = ({ edge, path, nodes }) => {
whiteSpace: "nowrap",
}}
>
{label || "Add label"}
{label}
</text>
)}
{isEditing && (
Expand Down
4 changes: 2 additions & 2 deletions components/CustomNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,9 +194,9 @@ const CustomNode: React.FC<CustomNodeProps> = React.memo(
className={`node-container px-4 py-2 rounded-lg relative flex flex-col justify-center items-center ${isEditing
? "border-2 border-green-500 shadow-lg"
: selected
? "border-2 border-blue-500 shadow-lg"
? "border-2 border-blue-500 shadow-lg animate-borderPulse"
: "border-2 border-gray-300"
} bg-white shadow-md transition-all duration-200 ease-in-out hover:shadow-xl cursor-pointer`}
} bg-white shadow-md transition-all duration-700 ease-in-out hover:shadow-xl cursor-pointer`}
style={{
width: dimensions.width,
height: dimensions.height,
Expand Down
66 changes: 23 additions & 43 deletions components/FSMHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { toPng, toSvg } from "html-to-image";
import { calculatePortPosition } from "../app/utils/calculatePortPosition";
import { Grammar } from "./parser";
import CustomEdge from "./CustomEdge";
import FlowControl from "./FlowControl";

interface Node {
id: string;
Expand Down Expand Up @@ -57,11 +58,8 @@ const initialNodes: Node[] = [
},
];

console.log(initialNodes[0].position)

const initialEdges: Edge[] = [];


const FlowEditor = () => {
const [nodes, setNodes] = useState<Node[]>(initialNodes);
const [edges, setEdges] = useState<Edge[]>(initialEdges);
Expand All @@ -84,7 +82,6 @@ const FlowEditor = () => {
const [parsingResult, setParsingResult] = useState<string>("");

const exportRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const { toast } = useToast();

const selectedNodeRef = useRef<string | null>(selectedNode);
Expand All @@ -93,7 +90,9 @@ const FlowEditor = () => {
const isDraggingRef = useRef<boolean>(false);
const dragRAFRef = useRef<number | null>(null);

// TODO: Maybe use this as an indicator somewhere in the UI, not just in the beforeunload event.
const [isSaved, setIsSaved] = useState<boolean>(true);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);

// Update refs when state changes
useEffect(() => {
Expand Down Expand Up @@ -396,24 +395,12 @@ const FlowEditor = () => {
setIsSaved(true);
}, [nodes, edges, toast]);

const restoreFlow = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;

const reader = new FileReader();
reader.onload = (e) => {
try {
const flow = JSON.parse(e.target?.result as string);
setNodes(flow.nodes || []);
setEdges(flow.edges || []);
toast({ title: "Flow Restored", description: "Your flow has been restored." });
setIsSaved(true);
} catch {
toast({ title: "Error", description: "Invalid file format.", variant: "destructive" });
}
};
reader.readAsText(file);
const onRestore = useCallback(
(flow: { nodes: Node[]; edges: Edge[] }) => {
setNodes(flow.nodes);
setEdges(flow.edges);
toast({ title: "Flow Restored", description: "Your flow has been restored." });
setIsSaved(true);
},
[toast]
);
Expand Down Expand Up @@ -654,6 +641,11 @@ const FlowEditor = () => {
}
}, [grammar, onEdgeLabelChange, toast]);

const onDeleteEdge = useCallback((id: string) => {
setEdges((prevEdges) => prevEdges.filter((edge) => edge.id !== id));
setIsSaved(false);
}, []);

// Recompute all edges whenever nodes change (positions or dimensions)
useEffect(() => {
const updatedEdges = edges.map((edge) => {
Expand Down Expand Up @@ -745,7 +737,7 @@ const FlowEditor = () => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (!isSaved && (nodes.length > 0 || edges.length > 0)) {
e.preventDefault();
e.returnValue = ""; // Chrome requires returnValue to be set
return "You have unsaved changes. Are you sure you want to leave?";
}
};

Expand All @@ -756,10 +748,6 @@ const FlowEditor = () => {
};
}, [isSaved, nodes, edges]);

const handleRestoreClick = () => {
fileInputRef.current?.click();
};

return (
<div className="flex h-screen">
<Sidebar
Expand Down Expand Up @@ -816,11 +804,11 @@ const FlowEditor = () => {

{/* Render Existing Edges */}
{edges.map((edge) => (
<CustomEdge key={edge.id} edge={edge} nodes={nodes} />
<CustomEdge key={edge.id} edge={edge} nodes={nodes} selectedEdgeId={selectedEdgeId} setSelectedEdgeId={setSelectedEdgeId} onDeleteEdge={onDeleteEdge} />
))}

{/* Render Dragged Edge */}
{draggedEdge && <CustomEdge path={draggedEdge} />}
{draggedEdge && <CustomEdge path={draggedEdge} selectedEdgeId={selectedEdgeId} setSelectedEdgeId={setSelectedEdgeId} onDeleteEdge={onDeleteEdge} />}
</svg>

{/* Render Nodes */}
Expand Down Expand Up @@ -856,20 +844,12 @@ const FlowEditor = () => {
</div>

{/* Controls */}
<div className="absolute top-4 right-4 flex gap-2">
<Button onClick={saveFlow}>Save</Button>
<Button onClick={handleRestoreClick}>Restore</Button>
<input
type="file"
ref={fileInputRef}
onChange={restoreFlow}
className="hidden"
accept="application/json"
/>
<Button onClick={clearCanvas}>Clear</Button>
<Button onClick={() => exportAsImage("png")}>Export as PNG</Button>
<Button onClick={() => exportAsImage("svg")}>Export as SVG</Button>
</div>
<FlowControl
saveFlow={saveFlow}
clearCanvas={clearCanvas}
exportAsImage={exportAsImage}
onRestore={onRestore}
/>
</div>
</div>
);
Expand Down
Loading

0 comments on commit 0db8857

Please sign in to comment.