diff --git a/.github/workflows/nextjs.yml b/.github/workflows/nextjs.yml index ed74736..fc02714 100644 --- a/.github/workflows/nextjs.yml +++ b/.github/workflows/nextjs.yml @@ -8,6 +8,9 @@ on: # Runs on pushes targeting the default branch push: branches: ["main"] + paths-ignore: + - '**/*.md' + # Allows you to run this workflow manually from the Actions tab workflow_dispatch: diff --git a/README.md b/README.md index e3a9bf4..7010e53 100644 --- a/README.md +++ b/README.md @@ -1,114 +1,126 @@ -# FSM Visualizer +# 🌟 **FSM Visualizer** 🌟 -Welcome to **FSM Visualizer**, a versatile tool for creating and visualizing finite state machines (FSMs). Whether you're designing complex parsers or simple state diagrams, this tool is built to enhance your workflow with ease and efficiency. +Welcome to **FSM Visualizer**, your ultimate tool for crafting and visualizing finite state machines (FSMs). Whether you're designing a simple state diagram or tackling complex workflows, **FSM Visualizer** empowers you with intuitive and efficient tools. πŸš€ --- -## 🎯 Features +## 🎯 **Features at a Glance** -- **No Registration Required**: Get started instantlyβ€”no signups or logins needed. -- **Flexible Customization**: Add detailed text to states and transitions for clarity. -- **Interactive Design**: Drag and drop states, connect transitions, and edit effortlessly. -- **Practical for Developers**: Built with ReactFlow, ensuring performance and scalability. +- **πŸšͺ No Signups Needed**: Start building instantlyβ€”no barriers or distractions. +- **✨ Fully Customizable**: Add detailed labels to states and transitions to bring clarity to your diagrams. +- **⚑ Interactive Interface**: Create, edit, and connect states seamlessly with drag-and-drop functionality. +- **πŸ›  Built from Scratch**: Powered by a custom implementation designed for maximum flexibility and performance. +- **πŸ”§ Tools Section**: Transform **BNF grammar into FSM** representations in **LR(1) parser form**, making it perfect for advanced grammar analysis and parsing tasks. --- -## πŸš€ Live Demo +## 🌐 **Live Demo** -Try the tool live here: [Live Tool Link](https://alhassanalbadri.github.io/fsm-visualizer/) +Explore the magic now: πŸ‘‰ [**FSM Visualizer Live**](https://alhassanalbadri.github.io/fsm-visualizer/) πŸŽ‰ --- -## πŸ›  Installation & Setup +## πŸ›  **Getting Started** -Follow these steps to run the project locally: +Ready to run **FSM Visualizer** locally? Just follow these simple steps: 1. **Clone the Repository**: - ```bash - git clone https://github.com/yourusername/FSM-Visualizer.git - cd FSM-Visualizer - ``` - + ```bash + git clone https://github.com/alhassanalbadri/fsm-visualizer.git + cd FSM-Visualizer + ``` 2. **Install Dependencies**: - ```bash - npm install - ``` + ```bash + npm install + ``` +3. **Start the Development Server**: + ```bash + npm run dev + ``` +4. Open your browser and navigate to `http://localhost:3000`. πŸŽ‰ + +--- -3. **Run the Development Server**: - ```bash - npm run dev - ``` +## πŸ“‚ **Project Structure** -4. Open your browser and navigate to `http://localhost:3000`. +Here's an overview of the key components: + +- **🎨 Components**: + - `CustomNode`: The visual representation of FSM states with editable labels. + - `CustomEdge`: Flexible transitions connecting the states. + - `FSMHandler`: The core logic for managing states and transitions. + - `Sidebar`: The sidebar for adding new states, and accessing our builtin tools. +- **πŸ”§ State Management**: + - Built from scratch to ensure efficient and intuitive interaction. + - Handles all nodes, edges, and user interactions dynamically. --- -## πŸ“‚ Project Structure +## πŸ€” **How to Use** -- **Components**: - - `CustomNode`: Customizable nodes for FSM states. - - `CustomEdge`: Custom edges for transitions between states. - - `Sidebar`: UI for adding and managing states and transitions. -- **State Management**: - - Managed using ReactFlow's `useNodesState` and `useEdgesState`. +1. **🎨 Add States**: Drag and drop new states from the sidebar onto the canvas. +2. **πŸ”— Connect Transitions**: Click and drag from one state to another. +3. **πŸ–Š Edit Labels**: Double-click on states or transitions to update their labels. +4. **πŸ“· Save Your Work**: Export your FSM as JSON, PNG, or SVG to share or reuse. +5. **πŸ”§ Tools Section**: Use the **BNF grammar to FSM** tool to visualize LR(1) parser states from your grammar inputs. --- -## πŸ€” How to Use +## πŸ—Ί **Development Roadmap** -1. **Add States**: Drag and drop new states from the sidebar onto the canvas. -2. **Connect Transitions**: Drag from one state to another to create transitions. -3. **Edit Labels**: Click on states or transitions to edit their labels. -4. **Save Your Work**: Take screenshots or copy the FSM structure for your needs. +### πŸ”Ή Stage 1: Core Features +- [x] Custom-built state and edge management +- [x] JSON import/export functionality +- [x] PNG/SVG export support +- [ ] Multiple node types (e.g., start/end states) ---- +### πŸ”Ή Stage 2: Grammar Integration +- [x] **BNF grammar to FSM** in LR(1) parser form +- [x] Visualization of LR(1) parser states -## πŸ—Ί Development Roadmap +### πŸ”Ή Stage 3: Styling & Customization +- [ ] Customizable colors for states and edges +- [ ] Enhanced edge styles and animations +- [ ] Customizable end markers for transitions -### Stage 1: Basic Functionality -- [x] Implement basic pathfinding algorithm for FSMs -- [x] Add JSON import/export functionality -- [x] Add PNG/SVG export functionality -- [x] Implement clearing the canvas -- [ ] Add support for multiple node types (e.g., start/end states). +### πŸ”Ή Stage 4: Collaboration +- [ ] User account creation and authentication +- [ ] Shared FSM editing and real-time collaboration +- [ ] Personal dashboards for managing FSMs -### Stage 2: LR(1) Grammar Integration -- [ ] Implement LR(1) grammar parsing -- [ ] Visualize LR(1) parser states and transitions -- [ ] Add support for grammar rule editing +### πŸ”Ή Stage 5: Advanced Features +- [ ] Support for diverse FSM types +- [ ] Undo/redo functionality +- [ ] Composite nodes for advanced workflows -### Stage 3: Customization & Styling -- [ ] Allow custom colors for states -... and more? +--- -### Stage 4: User Accounts and Basic Collaboration -- [ ] Implement user account creation and authentication -- [ ] Add basic collaboration features (e.g., sharing FSMs) -- [ ] Implement user-specific FSM storage +## ⚠️ **Known Issues** -### Stage 5: Advanced Collaboration & Extensibility -- [ ] Add support for different FSM types -- [ ] Implement undo/redo functionality -... and more! +### Current Issues +- **PNG/SVG Export Flicker**: A flicker occurs during canvas rendering (Low priority). +- **Oversized Node Boxes**: Some nodes initially have unnecessary padding (Low priority). --- -## πŸ›‘ License +## πŸ›‘ **License** -This project is licensed under the [MIT License](LICENSE). +This project is open-source and available under the [MIT License](LICENSE). πŸ“ --- -## 🀝 Contributing +## 🀝 **Contributing** -Contributions are welcome! If you have ideas for improvements or find bugs, please open an issue or submit a pull request. +Let’s make **FSM Visualizer** even better! πŸ’‘ +If you have ideas or find bugs, feel free to open an issue or submit a pull request. Contributions are always welcome! πŸ™Œ --- -## πŸ“« Feedback & Support +## πŸ“’ **Feedback & Support** -If you try out **FSM Visualizer**, let me know your thoughts! I'm always open to feedback and suggestions for improvement. +Tried **FSM Visualizer**? Share your thoughts or suggestions! +Your feedback helps us grow and improve. πŸ’¬ --- -Happy visualizing! πŸŽ‰ +πŸŽ‰ **Happy Visualizing!** πŸŽ‰ \ No newline at end of file diff --git a/app/globals.css b/app/globals.css index 4ef8810..c2d4fa2 100644 --- a/app/globals.css +++ b/app/globals.css @@ -78,3 +78,17 @@ body { @apply bg-background text-foreground; } } + + +.canvas-background { + background-color: #f3f4f6; + /* Light gray background */ + background-image: + linear-gradient(to right, #d1d5db 1px, transparent 1px), + linear-gradient(to bottom, #d1d5db 1px, transparent 1px); + /* Grid size */ + background-size: 20px 20px; + /* Dark gray border */ + border: 0.5px solid #9ca3af; + position: relative; +} \ No newline at end of file diff --git a/app/utils/calculatePortPosition.ts b/app/utils/calculatePortPosition.ts new file mode 100644 index 0000000..61f46d0 --- /dev/null +++ b/app/utils/calculatePortPosition.ts @@ -0,0 +1,24 @@ +// calculatePortPosition.ts +interface NodePosition { + x: number; + y: number; + width: number; + height: number; + } + + export const calculatePortPosition = (node: NodePosition, isSource: boolean) => { + if (isSource) { + // Output port at bottom-center + return { + x: node.x + node.width / 2, + y: node.y + node.height, + }; + } else { + // Input port at top-center + return { + x: node.x + node.width / 2, + y: node.y, + }; + } + }; + \ No newline at end of file diff --git a/app/utils/useResizeObserver.ts b/app/utils/useResizeObserver.ts new file mode 100644 index 0000000..ff9669e --- /dev/null +++ b/app/utils/useResizeObserver.ts @@ -0,0 +1,27 @@ +// useResizeObserver.ts +import { useEffect, RefObject } from "react"; + +const useResizeObserver = ( + ref: RefObject, + callback: (width: number, height: number) => void, + options?: ResizeObserverOptions +) => { + useEffect(() => { + if (!ref.current) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + callback(width, height); + } + }); + + observer.observe(ref.current, options); + + return () => { + observer.disconnect(); + }; + }, [ref, callback, options]); +}; + +export default useResizeObserver; diff --git a/components/CustomEdge.tsx b/components/CustomEdge.tsx index 1d4b550..4db0be4 100644 --- a/components/CustomEdge.tsx +++ b/components/CustomEdge.tsx @@ -1,110 +1,328 @@ "use client"; -import React, { useState, useCallback } from 'react'; -import { EdgeProps, EdgeLabelRenderer, getBezierPath } from '@xyflow/react'; - -export default function CustomEdge({ - id, - source, - target, - sourceX, - sourceY, - targetX, - targetY, - sourcePosition, - targetPosition, - style = {}, - markerEnd, -}: EdgeProps) { +import React, { + useState, + useCallback, + useEffect, + useRef, + useLayoutEffect, +} from "react"; + +interface Edge { + id: string; + source: string; + target: string; + sourceX: number; + sourceY: number; + targetX: number; + targetY: number; + type: string; + data: { + label: string; + onLabelChange: (id: string, newLabel: string) => void; + }; +} + +interface CustomEdgeProps { + edge?: Edge; + path?: { + sourceX: number; + sourceY: number; + targetX: number; + targetY: number; + }; + nodes?: Node[]; +} + +interface Node { + id: string; + type: string; + position: { x: number; y: number }; + data: { label: string; conflictType?: string; conflictToken?: string }; + width: number; + height: number; +} + +const CustomEdge: React.FC = ({ edge, path, nodes }) => { const [isEditing, setIsEditing] = useState(false); - const [label, setLabel] = useState(''); - - const isSelfLoop = source === target; - - let edgePath, labelX, labelY; - if (isSelfLoop) { - const radiusX = 80; - const radiusY = 50; - - edgePath = `M ${sourceX} ${sourceY} A ${radiusX} ${radiusY} 0 1 1 ${targetX} ${targetY}`; - - labelX = sourceX - radiusX - 75; - labelY = sourceY + radiusY - 80; - - } else { - [edgePath, labelX, labelY] = getBezierPath({ - sourceX, - sourceY, - sourcePosition, - targetX, - targetY, - targetPosition, - }); - } + const label = edge?.data?.label || ""; - const handleLabelClick = useCallback((event: React.MouseEvent | React.KeyboardEvent) => { - event.stopPropagation(); - setIsEditing(true); - }, []); + const textRef = useRef(null); + const textareaRef = useRef(null); + const [labelDimensions, setLabelDimensions] = useState<{ width: number; height: number }>({ + width: 0, + height: 0, + }); + + const LABEL_PADDING = 8; + const labelWidthRef = useRef(0); + + useEffect(() => { + const markerId = "arrowhead-marker"; + if (!document.getElementById(markerId)) { + const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); + const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker"); + marker.setAttribute("id", markerId); + marker.setAttribute("markerWidth", "6"); + marker.setAttribute("markerHeight", "6"); + marker.setAttribute("viewBox", "0 0 6 6"); + marker.setAttribute("refX", "5"); + marker.setAttribute("refY", "3"); + marker.setAttribute("orient", "auto"); + marker.setAttribute("markerUnits", "strokeWidth"); + + const pathElement = document.createElementNS( + "http://www.w3.org/2000/svg", + "path" + ); + pathElement.setAttribute("d", "M0,0 L6,3 L0,6 Z"); // Triangle shape + pathElement.setAttribute("fill", "#b1b1b7"); + pathElement.setAttribute("stroke", "none"); - const handleLabelChange = useCallback((event: React.ChangeEvent) => { - setLabel(event.target.value); + marker.appendChild(pathElement); + defs.appendChild(marker); + + let svg = document.querySelector("svg"); + if (!svg) { + svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("style", "position:absolute; width:0; height:0;"); + document.body.appendChild(svg); + } + svg.appendChild(defs); + } }, []); + useLayoutEffect(() => { + if (textRef.current && !isEditing) { + const bbox = textRef.current.getBBox(); + labelWidthRef.current = bbox.width; + setLabelDimensions({ width: bbox.width, height: bbox.height }); + } + }, [label, isEditing]); + + useLayoutEffect(() => { + if (isEditing && textareaRef.current) { + const textarea = textareaRef.current; + textarea.style.height = "auto"; + textarea.style.height = `${textarea.scrollHeight}px`; + + setLabelDimensions(() => ({ + width: labelWidthRef.current, + height: textarea.scrollHeight, + })); + } + }, [isEditing, label]); + + const handleLabelClick = useCallback( + (event: React.MouseEvent | React.KeyboardEvent) => { + event.stopPropagation(); + setIsEditing(true); + }, + [] + ); + + const handleLabelChange = useCallback( + (event: React.ChangeEvent) => { + const newLabel = event.target.value; + if (edge?.data?.onLabelChange) { + edge.data.onLabelChange(edge.id, newLabel); + } + }, + [edge] + ); + const handleBlur = useCallback(() => { setIsEditing(false); }, []); + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + setIsEditing(false); + } + }, + [] + ); + + 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) { + sourceX = path.sourceX; + sourceY = path.sourceY; + targetX = path.targetX; + targetY = path.targetY; + } else { + return null; + } + + 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 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 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); + + const controlX1 = sourceX; + const controlY1 = sourceY + controlPointOffset; + + const controlX2 = targetX; + const controlY2 = targetY - controlPointOffset; + + 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; + + return { pathData, labelX, labelY }; + } + }; + + const { pathData, labelX, labelY } = calculatePathData(); + + const svgStyle: React.CSSProperties = { + position: "absolute", + left: 0, + top: 0, + width: "100%", + height: "100%", + pointerEvents: "none", + zIndex: isSelfConnection ? 2 : 1, // Higher z-index for self-connections + }; + return ( - <> - + + {/* Edge Path */} - -
{ + if (e.key === "Enter") handleLabelClick(e); }} - className="nodrag nopan" + role="button" + tabIndex={0} > - {isEditing ? ( - - ) : ( -
{ - if (e.key === 'Enter') handleLabelClick(e as unknown as React.MouseEvent); + {/* Background Rectangle */} + + {/* Label Content */} + {!isEditing && ( + - {label || 'Add label'} -
+ {label || "Add label"} + + )} + {isEditing && ( + +