diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ecdce64 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,98 @@ +# Changelog + +All notable changes to the Digital Circuit Simulator project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +--- + +## [Unreleased] - 2026-01-09 + +### 🎉 Added + +#### Undo/Redo Functionality +- **Undo/Redo Support** - Full undo/redo functionality with keyboard shortcuts + - `Ctrl+Z` (or `Cmd+Z` on Mac): Undo last action + - `Ctrl+Y` (or `Cmd+Y` on Mac): Redo action + - `Ctrl+Shift+Z`: Alternative redo shortcut + - Maintains history of up to 50 steps + - Smart history tracking that captures all node and edge changes including: + - Adding/removing nodes and wires + - Moving nodes and repositioning components + - Connecting/disconnecting wires + - All circuit modifications +- **History Panel** - Added popup to see previous 50 steps and go back to desired precise state back. + - Descriptive action names instead of generic step numbers: + - "Added AND", "Deleted OR", "Moved Input A" + - "Created branch node", "Connected wires", "Disconnected wires" + - "Imported Half Adder", "Renamed to Output Y" + +#### Circuit Management Features +- **Dynamic Circuit Routes** - Clean URL structure for sharing and accessing circuits + - `/circuit` - Create new empty circuit + - `/circuit/{id}` - Direct link to specific saved circuit + - Automatic verification and loading of circuit by ID + - Better sharing with clean URLs instead of query parameters +- **Custom labels** - Added option for users to custom rename the inputs,outputs and logic gates +- **Right Click Option** - Added right click circuit option to + - "Rename" + - "Copy" + - "Duplicate" - with 50px offset positioning + - "Delete" - Moved Delete to here +- **Import Circuits** - Users can import their own ciruits back to new circuit in one block. +- **Dynamic Gate Sizing** - Gate components automatically resize vertically based on input/output count +- **Variable Input Count for Logic Gates** - Logic gates (AND, OR, NAND, NOR, XOR, XNOR) can now have 2-8 inputs via right-click context menu +- **Marquee selection** - Holding control for marquee selection for copy paste + +#### Wire Management & Branch Points +- **Branch Point Creation** - Double-click on any wire to create a branch point for signal splitting or create a branch node +- **Wire Selection** - Wires can now be selected by clicking directly on them and deleted with delete key + +#### Dashboard +- **Right click on categories or circuits** + - "Rename" + - "Delete" + - "Duplicate" (only for circuits not categories) +#### View Controls +- **Fullscreen Mode Toggle** - Added fullscreen mode button to maximize workspace area + + +### 🐛 Fixed + +#### UI/UX Fixes +- **Toolbar Dropdown Toggle** - Logic Gates and other category dropdowns can now be closed by clicking the category name again + +#### Technical Fixes +- **TypeScript Interface Extension** - Added `isImported` and `importedCircuitId` properties to `GateType` interface to prevent compilation errors +- **Source Handle DOM Warning** - Fixed "non-boolean attribute `truth`" warning in Source component by destructuring custom props before spreading to Handle + - Matches pattern used in Target component + - Prevents React from passing custom props to DOM +- **Null Safety in Circuit Simulation** - Added safety checks for `node.data.outputs` and `node.data.inputs` to prevent runtime errors when processing branch nodes +- **React Key Warnings in Modals** - Fixed duplicate key errors across all modal components + - SaveCircuitModal: Added proper key and conditional wrapper + - CircuitLibrary: Moved modals outside AnimatePresence, wrapped in fragment + - ImportCircuitModal: Added key and conditional rendering + - RenameModal: Added key and conditional rendering + - InputCountModal: Added key and conditional rendering + - ConfirmationModal: Added key and conditional rendering + +### 🔄 Changed + +#### Interaction Model +- **Panning & Selection Behavior** - Refined canvas interaction model + - Normal left-drag: Pan background + - Control + drag on empty space: Box selection (marquee) + - Control + drag on branch node: Reposition branch point + - Middle/right mouse: Alternative panning methods + +--- + +## 👨‍đŸ’ģ Author + +**Neeraj** ([@NeerajCodz](https://github.com/NeerajCodz)) + +--- + +## 📝 Notes + +This changelog documents all changes made during the January 9, 2026 development session. All features have been tested. \ No newline at end of file diff --git a/app/page.tsx b/app/[[...rest]]/page.tsx similarity index 100% rename from app/page.tsx rename to app/[[...rest]]/page.tsx diff --git a/app/api/circuits/[id]/route.ts b/app/api/circuits/[id]/route.ts index d66326e..14c4736 100644 --- a/app/api/circuits/[id]/route.ts +++ b/app/api/circuits/[id]/route.ts @@ -9,7 +9,7 @@ export async function GET( try { const { id } = await params const user = await getServerUser() - + if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -51,7 +51,7 @@ export async function PUT( try { const { id } = await params const user = await getServerUser() - + if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -74,42 +74,48 @@ export async function PUT( // Update circuit with transaction to handle categories and labels const circuit = await prisma.$transaction(async (tx: any) => { // Update circuit + const updateData: any = { + name, + description, + circuit_data, + is_public, + } + const updatedCircuit = await tx.circuit.update({ where: { id }, - data: { - name, - description, - circuit_data, - is_public - } + data: updateData }) - // Update categories - await tx.circuitCategory.deleteMany({ - where: { circuit_id: id } - }) - - if (category_ids.length > 0) { - await tx.circuitCategory.createMany({ - data: category_ids.map((category_id: string) => ({ - circuit_id: id, - category_id - })) + // Only update categories if provided + if (body.category_ids !== undefined) { + await tx.circuitCategory.deleteMany({ + where: { circuit_id: id } }) - } - // Update labels - await tx.circuitLabel.deleteMany({ - where: { circuit_id: id } - }) + if (body.category_ids.length > 0) { + await tx.circuitCategory.createMany({ + data: body.category_ids.map((category_id: string) => ({ + circuit_id: id, + category_id + })) + }) + } + } - if (label_ids.length > 0) { - await tx.circuitLabel.createMany({ - data: label_ids.map((label_id: string) => ({ - circuit_id: id, - label_id - })) + // Only update labels if provided + if (body.label_ids !== undefined) { + await tx.circuitLabel.deleteMany({ + where: { circuit_id: id } }) + + if (body.label_ids.length > 0) { + await tx.circuitLabel.createMany({ + data: body.label_ids.map((label_id: string) => ({ + circuit_id: id, + label_id + })) + }) + } } return updatedCircuit @@ -129,13 +135,13 @@ export async function PATCH( try { const { id } = await params const user = await getServerUser() - + if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const body = await request.json() - const { category_ids, label_ids } = body + const { category_ids, label_ids, name, description } = body // Check if circuit exists and belongs to user const existingCircuit = await prisma.circuit.findFirst({ @@ -149,8 +155,20 @@ export async function PATCH( return NextResponse.json({ error: 'Circuit not found' }, { status: 404 }) } - // Update only categories and/or labels + // Update circuit fields, categories and/or labels await prisma.$transaction(async (tx: any) => { + // Update name and/or description if provided + if (name !== undefined || description !== undefined) { + const updateData: any = {} + if (name !== undefined) updateData.name = name + if (description !== undefined) updateData.description = description + + await tx.circuit.update({ + where: { id }, + data: updateData + }) + } + if (category_ids !== undefined) { // Update categories await tx.circuitCategory.deleteMany({ @@ -215,7 +233,7 @@ export async function DELETE( try { const { id } = await params const user = await getServerUser() - + if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/circuit/[id]/page.tsx b/app/circuit/[id]/page.tsx new file mode 100644 index 0000000..e09d610 --- /dev/null +++ b/app/circuit/[id]/page.tsx @@ -0,0 +1,24 @@ +"use client"; +import React from 'react'; +import { useParams } from 'next/navigation'; +import { useUser } from '@clerk/nextjs'; +import Loader from '@/components/Loader'; +import CircuitPage from '../page'; + +export default function CircuitWithId() { + const params = useParams(); + const { isLoaded, user } = useUser(); + const circuitId = params.id as string; + + if (!isLoaded) { + return ( +
+ +
+ ); + } + + // If user is not logged in, they'll be handled inside CircuitPage or they can view public circuits if implemented + // For now, we just pass the ID down + return ; +} diff --git a/app/circuit/components/handles/source.tsx b/app/circuit/components/handles/source.tsx index fd92fc5..dcbd8a1 100644 --- a/app/circuit/components/handles/source.tsx +++ b/app/circuit/components/handles/source.tsx @@ -3,14 +3,16 @@ import {Handle, Position} from 'reactflow'; const Source = (props: any) => { + const { truth, style, ...restProps } = props; + return ( - ); diff --git a/app/circuit/components/handles/target.tsx b/app/circuit/components/handles/target.tsx index d423285..ccdcabd 100644 --- a/app/circuit/components/handles/target.tsx +++ b/app/circuit/components/handles/target.tsx @@ -15,14 +15,16 @@ const Target = (props: any) => { }, [edges, props.id]); + const { truth, style, ...restProps } = props; + return ( - ); diff --git a/app/circuit/components/nodes/branch.tsx b/app/circuit/components/nodes/branch.tsx new file mode 100644 index 0000000..977aed8 --- /dev/null +++ b/app/circuit/components/nodes/branch.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { NodeProps } from "reactflow"; +import { Handle, Position } from "reactflow"; + +function Branch(props: NodeProps) { + const { data, id } = props; + + return ( +
data?.onContextMenu?.(e)} + onDoubleClick={(e) => data?.onDoubleClick?.(e)} + title="Branch Point - Double-click edge to create, Delete to remove" + > + {/* Input handle - receives signal */} + + + {/* Output handle - sends signal */} + +
+ ); +} + +export default Branch; diff --git a/app/circuit/components/nodes/gate.tsx b/app/circuit/components/nodes/gate.tsx index 36b792c..fa6ba37 100644 --- a/app/circuit/components/nodes/gate.tsx +++ b/app/circuit/components/nodes/gate.tsx @@ -17,6 +17,11 @@ function Gate(props: NodeProps) { const accentColor: string = data?.color ?? "#42345f"; const isCombinational = data?.isCombinational ?? false; + // Calculate dynamic height based on the number of inputs/outputs + const maxIO = Math.max(inputs.length, outputKeys.length); + // Formula: 100px (header + padding) + 40px per I/O, minimum 180px + const dynamicHeight = Math.max(180, 100 + maxIO * 40); + const getHandleOffset = (index: number, total: number) => { if (total <= 1) return 50; @@ -26,36 +31,74 @@ function Gate(props: NodeProps) { const spacing = (bottomPadding - topPadding) / (total - 1); return topPadding + index * spacing; } else { - const spread = 70; - const start = (100 - spread) / 2; - return start + (spread * index) / (total - 1); + // For regular gates + if (total === 2) { + // Keep original spacing for 2 inputs + const spread = 70; + const start = (100 - spread) / 2; + return start + (spread * index) / (total - 1); + } else { + // For 3+ inputs, use more even spacing + const topPadding = 20; + const bottomPadding = 80; + const spacing = (bottomPadding - topPadding) / (total - 1); + return topPadding + index * spacing; + } } }; if (isCombinational) { return (
data?.onContextMenu?.(e)} + onDoubleClick={(e) => data?.onDoubleClick?.(e)} style={{ background: `linear-gradient(335deg, rgba(8, 6, 12, 0.95) 0%, rgba(19, 14, 25, 0.88) 45%, ${accentColor} 100%)`, borderColor: accentColor, + minHeight: `${dynamicHeight}px`, }} > - {data.name} + { + e.stopPropagation(); + data?.editLabel?.(); + }} + > + {data.name} +
{/* Inputs */} -
- {inputs.map((input: string, idx: number) => ( - - ))} +
+ {inputs.map((input: string, idx: number) => { + const offset = getHandleOffset(idx, inputs.length); + + return ( +
+ {/* Move handle slightly outside to the left */} +
+ +
+ + {/* Label */} + + {input} + +
+ ); + })}
@@ -85,36 +128,34 @@ function Gate(props: NodeProps) { })}
- -
); } else { + // Calculate dynamic height for regular gates + // Base height for 2 inputs, then +40px for each additional input starting from 3 + const regularGateHeight = inputs.length <= 2 ? undefined : `${140 + (inputs.length - 2) * 40}px`; + return (
data?.onContextMenu?.(e)} + onDoubleClick={(e) => data?.onDoubleClick?.(e)} style={{ background: `linear-gradient(335deg, rgba(8, 6, 12, 0.95) 0%, rgba(19, 14, 25, 0.88) 45%, ${accentColor} 100%)`, borderColor: accentColor, + minHeight: regularGateHeight, }} > - Gate + {data?.gateType || data?.name} Gate - + { + e.stopPropagation(); + data?.editLabel?.(); + }} + > {data?.name} @@ -141,20 +182,6 @@ function Gate(props: NodeProps) { }} /> ))} -
); } diff --git a/app/circuit/components/nodes/input.tsx b/app/circuit/components/nodes/input.tsx index 8db2c34..5aa500d 100644 --- a/app/circuit/components/nodes/input.tsx +++ b/app/circuit/components/nodes/input.tsx @@ -14,20 +14,24 @@ function Input(props: NodeProps) { const isOn = Boolean(data?.value); return ( -
- +
data?.onContextMenu?.(e)} + onDoubleClick={(e) => data?.onDoubleClick?.(e)} + >
{isOn Input
- {data?.label} + { + e.stopPropagation(); + data?.editLabel?.(); + }} + > + {data?.label} + Output - {data?.label} + { + e.stopPropagation(); + data?.editLabel?.(); + }} + > + {data?.label} +
([]); + const [editingLabel, setEditingLabel] = useState<{ + nodeId: string; + currentLabel: string; + nodeType: "input" | "output" | "gate"; + } | null>(null); + + const [showRenameModal, setShowRenameModal] = useState(false); + const [currentCircuitName, setCurrentCircuitName] = useState("Untitled Circuit"); + const [currentCircuitDescription, setCurrentCircuitDescription] = useState(""); + const [currentCategoryIds, setCurrentCategoryIds] = useState([]); + const [currentLabelIds, setCurrentLabelIds] = useState([]); + + const [contextMenu, setContextMenu] = useState<{ + nodeId: string; + x: number; + y: number; + } | null>(null); + + const [copiedNodes, setCopiedNodes] = useState([]); + const [copiedEdges, setCopiedEdges] = useState([]); + + const [showImportModal, setShowImportModal] = useState(false); + + const [inputCountModal, setInputCountModal] = useState<{ + nodeId: string; + currentCount: number; + gateName: string; + } | null>(null); + + const [isCtrlPressed, setIsCtrlPressed] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const [showHistoryPanel, setShowHistoryPanel] = useState(false); + + // History state for undo/redo + const [history, setHistory] = useState<{ + nodes: Node[]; + edges: Edge[]; + action: string; + }[]>([{ nodes: [], edges: [], action: 'Initial state' }]); + const [currentHistoryIndex, setCurrentHistoryIndex] = useState(0); + const isUndoRedoAction = useRef(false); + const addCombinationalCircuit = (gate: GateType) => { setCombinationalGates((prev) => { // avoid duplicates by name (or use id) @@ -181,16 +235,605 @@ function CircuitMaker() { toast.success(`Removed ${circuitName} from toolbar`); }; + const generateInputLabels = (count: number, gateType: string): string[] => { + const labels = []; + for (let i = 0; i < count; i++) { + labels.push(String.fromCharCode(97 + i)); // a, b, c, d, e, f, g, h + } + return labels; + }; + + const handleChangeInputCount = useCallback((nodeId: string, newCount: number) => { + setNodes((nds) => + nds.map((node) => { + if (node.id === nodeId && node.type === "gate") { + const gateType = node.data.gateType || node.data.name; + const newInputs = generateInputLabels(newCount, gateType); + + // Update the gate's logic expression based on gate type and input count + let outputExpression = ""; + switch (gateType.toUpperCase()) { + case "AND": + outputExpression = newInputs.join(" && "); + break; + case "OR": + outputExpression = newInputs.join(" || "); + break; + case "NAND": + outputExpression = `!(${newInputs.join(" && ")})`; + break; + case "NOR": + outputExpression = `!(${newInputs.join(" || ")})`; + break; + case "XOR": + // XOR for multiple inputs: odd number of true inputs + if (newCount === 2) { + outputExpression = `(${newInputs[0]} && !${newInputs[1]}) || (!${newInputs[0]} && ${newInputs[1]})`; + } else { + outputExpression = `(${newInputs.join(" + ")}) % 2 === 1`; + } + break; + case "XNOR": + // XNOR for multiple inputs: even number of true inputs + if (newCount === 2) { + outputExpression = `!((${newInputs[0]} && !${newInputs[1]}) || (!${newInputs[0]} && ${newInputs[1]}))`; + } else { + outputExpression = `(${newInputs.join(" + ")}) % 2 === 0`; + } + break; + default: + outputExpression = node.data.outputs.out; + } + + return { + ...node, + data: { + ...node.data, + inputs: newInputs, + outputs: { out: outputExpression }, + }, + }; + } + return node; + }) + ); + toast.success(`Input count changed to ${newCount}`); + }, [setNodes]); + + const handleImportCircuitAsBlock = useCallback((circuit: any) => { + // Extract input and output nodes from the saved circuit + const circuitNodes = circuit.circuit_data?.nodes || []; + const inputNodes = circuitNodes.filter((n: any) => n.type === 'ip'); + const outputNodes = circuitNodes.filter((n: any) => n.type === 'op'); + + // Create inputs and outputs arrays with labels + const inputs = inputNodes.map((node: any, index: number) => + node.data?.label || `in${index + 1}` + ); + const outputs = outputNodes.map((node: any, index: number) => + node.data?.label || `out${index + 1}` + ); + + // Create output object mapping + const outputsObj: { [key: string]: string } = {}; + outputs.forEach((output: string) => { + // Placeholder expression - actual simulation will be handled separately + outputsObj[output] = 'false'; + }); + + // Create the imported circuit gate + const importedGate: GateType = { + id: v4(), + name: circuit.name, + color: '#7C3AED', // Purple color for imported circuits + inputs: inputs, + outputs: outputsObj, + circuit: circuit.circuit_data, // Store the full circuit data + isCombinational: true, + isImported: true, + importedCircuitId: circuit.id, + }; + + // Set as pending node so user can place it on canvas + setPendingNode({ type: 'gate', gate: importedGate }); + toast.success(`Place "${circuit.name}" on the canvas`); + }, []); + + const handleEditLabel = useCallback((nodeId: string) => { + const node = nodes.find((n) => n.id === nodeId); + if (!node) return; + + let currentLabel = ""; + let nodeType: "input" | "output" | "gate" = "gate"; + + if (node.type === "ip") { + currentLabel = node.data.label || ""; + nodeType = "input"; + } else if (node.type === "op") { + currentLabel = node.data.label || ""; + nodeType = "output"; + } else if (node.type === "gate") { + currentLabel = node.data.name || ""; + nodeType = "gate"; + } + + setEditingLabel({ nodeId, currentLabel, nodeType }); + }, [nodes]); + + const handleSaveLabel = useCallback((nodeId: string, newLabel: string) => { + setNodes((nds) => + nds.map((node) => { + if (node.id === nodeId) { + if (node.type === "ip" || node.type === "op") { + return { + ...node, + data: { + ...node.data, + label: newLabel, + }, + }; + } else if (node.type === "gate") { + return { + ...node, + data: { + ...node.data, + name: newLabel, + }, + }; + } + } + return node; + }) + ); + toast.success("Label updated!"); + }, [setNodes]); + + const handleDuplicateNode = useCallback((nodeId: string) => { + const nodeToDuplicate = nodes.find((n) => n.id === nodeId); + if (!nodeToDuplicate) return; + + const newNode = { + ...nodeToDuplicate, + id: v4(), + position: { + x: nodeToDuplicate.position.x + 50, + y: nodeToDuplicate.position.y + 50, + }, + data: { + ...nodeToDuplicate.data, + }, + }; + + setNodes((nds) => nds.concat(newNode)); + toast.success("Node duplicated!"); + }, [nodes, setNodes]); + + const handleContextMenu = useCallback((nodeId: string, event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setContextMenu({ + nodeId, + x: event.clientX, + y: event.clientY, + }); + }, []); + + // Copy selected nodes + const handleCopy = useCallback(() => { + const selectedNodes = nodes.filter((node) => node.selected); + if (selectedNodes.length === 0) return; + + const selectedNodeIds = selectedNodes.map((n) => n.id); + const relatedEdges = edges.filter( + (edge) => + selectedNodeIds.includes(edge.source) && + selectedNodeIds.includes(edge.target) + ); + + setCopiedNodes(selectedNodes); + setCopiedEdges(relatedEdges); + toast.success(`Copied ${selectedNodes.length} node(s)`); + }, [nodes, edges]); + + // Paste copied nodes + const handlePaste = useCallback(() => { + if (copiedNodes.length === 0) return; + + const nodeIdMap = new Map(); + const newNodes = copiedNodes.map((node) => { + const newId = v4(); + nodeIdMap.set(node.id, newId); + return { + ...node, + id: newId, + position: { + x: node.position.x + 50, + y: node.position.y + 50, + }, + selected: false, + }; + }); + + const newEdges = copiedEdges.map((edge) => ({ + ...edge, + id: v4(), + source: nodeIdMap.get(edge.source) || edge.source, + target: nodeIdMap.get(edge.target) || edge.target, + })); + + setNodes((nds) => [...nds.map((n) => ({ ...n, selected: false })), ...newNodes]); + setEdges((eds) => [...eds, ...newEdges]); + toast.success(`Pasted ${newNodes.length} node(s)`); + }, [copiedNodes, copiedEdges, setNodes, setEdges]); + + // Handle keyboard shortcuts + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + // Ctrl+Z: Undo + if ((event.ctrlKey || event.metaKey) && event.key === 'z' && !event.shiftKey) { + event.preventDefault(); + if (currentHistoryIndex > 0) { + isUndoRedoAction.current = true; + const previousState = history[currentHistoryIndex - 1]; + setNodes(previousState.nodes); + setEdges(previousState.edges); + setCurrentHistoryIndex(currentHistoryIndex - 1); + toast.success('Undo'); + } else { + toast('Nothing to undo', { icon: 'â„šī¸' }); + } + return; + } + // Ctrl+Y or Ctrl+Shift+Z: Redo + if ((event.ctrlKey || event.metaKey) && (event.key === 'y' || (event.key === 'z' && event.shiftKey))) { + event.preventDefault(); + if (currentHistoryIndex < history.length - 1) { + isUndoRedoAction.current = true; + const nextState = history[currentHistoryIndex + 1]; + setNodes(nextState.nodes); + setEdges(nextState.edges); + setCurrentHistoryIndex(currentHistoryIndex + 1); + toast.success('Redo'); + } else { + toast('Nothing to redo', { icon: 'â„šī¸' }); + } + return; + } + // Ctrl+C: Copy + if ((event.ctrlKey || event.metaKey) && event.key === 'c') { + const selectedNodes = nodes.filter((node) => node.selected); + if (selectedNodes.length > 0) { + event.preventDefault(); + handleCopy(); + } + } + // Ctrl+V: Paste + if ((event.ctrlKey || event.metaKey) && event.key === 'v') { + if (copiedNodes.length > 0) { + event.preventDefault(); + handlePaste(); + } + } + // Delete: Remove selected nodes and edges + if (event.key === 'Delete' || event.key === 'Backspace') { + const selectedNodes = nodes.filter((node) => node.selected); + const selectedEdges = edges.filter((edge) => edge.selected); + + if (selectedNodes.length > 0 || selectedEdges.length > 0) { + event.preventDefault(); + + const selectedNodeIds = selectedNodes.map((n) => n.id); + + // Remove selected nodes + if (selectedNodes.length > 0) { + setNodes((nds) => nds.filter((n) => !selectedNodeIds.includes(n.id))); + } + + // Remove edges connected to deleted nodes and selected edges + setEdges((eds) => + eds.filter( + (edge) => + !selectedNodeIds.includes(edge.source) && + !selectedNodeIds.includes(edge.target) && + !edge.selected + ) + ); + + const deletedCount = []; + if (selectedNodes.length > 0) deletedCount.push(`${selectedNodes.length} node(s)`); + if (selectedEdges.length > 0) deletedCount.push(`${selectedEdges.length} wire(s)`); + toast.success(`Deleted ${deletedCount.join(' and ')}`); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [nodes, edges, copiedNodes, currentHistoryIndex, history, handleCopy, handlePaste, setNodes, setEdges]); + + // Track Control key for enabling branch node dragging + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Control' || e.key === 'Meta') { + setIsCtrlPressed(true); + // Enable dragging for branch nodes + setNodes((nds) => + nds.map((node) => + node.type === 'branch' ? { ...node, draggable: true } : node + ) + ); + } + }; + + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Control' || e.key === 'Meta') { + setIsCtrlPressed(false); + // Disable dragging for branch nodes + setNodes((nds) => + nds.map((node) => + node.type === 'branch' ? { ...node, draggable: false } : node + ) + ); + } + }; + + document.addEventListener('keydown', handleKeyDown); + document.addEventListener('keyup', handleKeyUp); + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('keyup', handleKeyUp); + }; + }, [setNodes]); + + // Capture state changes for undo/redo history + useEffect(() => { + // Skip if this is an undo/redo action + if (isUndoRedoAction.current) { + isUndoRedoAction.current = false; + return; + } + + // Only add to history if nodes or edges actually changed + const lastHistory = history[currentHistoryIndex]; + const nodesChanged = JSON.stringify(nodes) !== JSON.stringify(lastHistory?.nodes || []); + const edgesChanged = JSON.stringify(edges) !== JSON.stringify(lastHistory?.edges || []); + + if (nodesChanged || edgesChanged) { + // Detect what action was performed + let action = 'Modified circuit'; + const lastNodes = lastHistory?.nodes || []; + const lastEdges = lastHistory?.edges || []; + let shouldReplaceLastHistory = false; + + // Check for node changes + if (nodes.length > lastNodes.length) { + const newNode = nodes.find(n => !lastNodes.some(ln => ln.id === n.id)); + if (newNode) { + const nodeType = newNode.type === 'gate' ? (newNode.data.name || newNode.data.gateType) : newNode.type; + if (newNode.type === 'branch') { + action = 'Created branch node'; + } else if (newNode.data.isImported) { + action = `Imported ${nodeType}`; + } else { + action = `Added ${nodeType}`; + } + } + } else if (nodes.length < lastNodes.length) { + const deletedNode = lastNodes.find(n => !nodes.some(ln => ln.id === n.id)); + if (deletedNode) { + const nodeLabel = deletedNode.data.label || deletedNode.data.name || deletedNode.data.gateType || 'node'; + action = `Deleted ${nodeLabel}`; + } + } else { + // Check if node was moved + const movedNode = nodes.find((n, i) => { + const lastNode = lastNodes.find(ln => ln.id === n.id); + return lastNode && (lastNode.position.x !== n.position.x || lastNode.position.y !== n.position.y); + }); + if (movedNode) { + const nodeLabel = movedNode.data.label || movedNode.data.name || movedNode.data.gateType || 'node'; + action = `Moved ${nodeLabel}`; + + // Check if last action was also moving the same node + if (lastHistory?.action?.startsWith(`Moved ${nodeLabel}`)) { + shouldReplaceLastHistory = true; + } + } else { + // Check for label changes + const renamedNode = nodes.find((n, i) => { + const lastNode = lastNodes.find(ln => ln.id === n.id); + return lastNode && lastNode.data.label !== n.data.label; + }); + if (renamedNode) { + action = `Renamed to ${renamedNode.data.label}`; + } + } + } + + // Check for edge changes + if (edges.length > lastEdges.length && action === 'Modified circuit') { + action = 'Connected wires'; + } else if (edges.length < lastEdges.length && action === 'Modified circuit') { + action = 'Disconnected wires'; + } + + // Remove any "future" history if we're not at the end + const newHistory = history.slice(0, currentHistoryIndex + 1); + + if (shouldReplaceLastHistory) { + // Replace the last history entry instead of adding a new one + newHistory[newHistory.length - 1] = { nodes, edges, action }; + setHistory(newHistory); + } else { + // Add new state + newHistory.push({ nodes, edges, action }); + + // Limit history size to 50 steps + if (newHistory.length > 50) { + newHistory.shift(); + setHistory(newHistory); + setCurrentHistoryIndex(newHistory.length - 1); + } else { + setHistory(newHistory); + setCurrentHistoryIndex(newHistory.length - 1); + } + } + } + }, [nodes, edges]); + + // Handle delete for selected nodes + const handleDeleteSelected = useCallback(() => { + const selectedNodes = nodes.filter((node) => node.selected); + if (selectedNodes.length === 0) return; + + const selectedNodeIds = selectedNodes.map((n) => n.id); + setNodes((nds) => nds.filter((n) => !selectedNodeIds.includes(n.id))); + setEdges((eds) => + eds.filter( + (edge) => + !selectedNodeIds.includes(edge.source) && + !selectedNodeIds.includes(edge.target) + ) + ); + toast.success(`Deleted ${selectedNodes.length} node(s)`); + }, [nodes, setNodes, setEdges]); + + // Handle duplicate for selected nodes + const handleDuplicateSelected = useCallback(() => { + const selectedNodes = nodes.filter((node) => node.selected); + if (selectedNodes.length === 0) return; + + const nodeIdMap = new Map(); + const newNodes = selectedNodes.map((node) => { + const newId = v4(); + nodeIdMap.set(node.id, newId); + return { + ...node, + id: newId, + position: { + x: node.position.x + 50, + y: node.position.y + 50, + }, + selected: false, + }; + }); + + const selectedNodeIds = selectedNodes.map((n) => n.id); + const relatedEdges = edges.filter( + (edge) => + selectedNodeIds.includes(edge.source) && + selectedNodeIds.includes(edge.target) + ); + + const newEdges = relatedEdges.map((edge) => ({ + ...edge, + id: v4(), + source: nodeIdMap.get(edge.source) || edge.source, + target: nodeIdMap.get(edge.target) || edge.target, + })); + + setNodes((nds) => [...nds, ...newNodes]); + setEdges((eds) => [...eds, ...newEdges]); + toast.success(`Duplicated ${newNodes.length} node(s)`); + }, [nodes, edges, setNodes, setEdges]); + useEffect(() => { - const urlParams = new URLSearchParams(window.location.search); - const circuitId = urlParams.get("load"); - if (circuitId && user) { - loadCircuitFromUrl(circuitId); + if (initialCircuitId && user) { + loadCircuitFromUrl(initialCircuitId); + } else { + const urlParams = new URLSearchParams(window.location.search); + const circuitId = urlParams.get("load"); + if (circuitId && user) { + loadCircuitFromUrl(circuitId); + } } - }, [user]); + }, [user, initialCircuitId]); + + // Auto-save debounced effect + useEffect(() => { + if (!currentCircuitId || !user) return; + + const timer = setTimeout(() => { + autoSaveCircuit(); + }, 3000); // 3 seconds debounced auto-save + + return () => clearTimeout(timer); + }, [nodes, edges, currentCircuitName, currentCircuitDescription]); + + const autoSaveCircuit = async () => { + if (!currentCircuitId || saving) return; + + try { + const nodesWithCurrentValues = nodes.map((node) => ({ + ...node, + data: { + ...node.data, + value: + node.type === "ip" + ? inputValues[node.id] + : node.type === "op" + ? outputValues[node.id] + : node.data.value, + }, + })); + + const circuitData = { + nodes: nodesWithCurrentValues, + edges, + viewport: reactFlowInstance?.getViewport() || { x: 0, y: 0, zoom: 1 }, + inputValues, + outputValues, + }; + + await fetch(`/api/circuits/${currentCircuitId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: currentCircuitName, + description: currentCircuitDescription, + circuit_data: circuitData, + }), + }); + console.log("Auto-save successful"); + } catch (error) { + console.error("Auto-save failed:", error); + } + }; + + const handleRenameCircuit = async (newName: string, newDescription?: string) => { + if (currentCircuitId) { + try { + const response = await fetch(`/api/circuits/${currentCircuitId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: newName, + description: newDescription, + }), + }); + + if (!response.ok) throw new Error("Failed to rename circuit"); + + const updated = await response.json(); + setCurrentCircuitName(updated.name); + setCurrentCircuitDescription(updated.description || ""); + toast.success("Circuit renamed"); + } catch (error) { + toast.error("Failed to rename circuit"); + } + } else { + setCurrentCircuitName(newName); + setCurrentCircuitDescription(newDescription || ""); + toast.success("Name updated (will be saved with circuit)"); + } + }; const updateUrlWithCircuitId = (circuitId: string) => { - const newUrl = `/circuit?load=${circuitId}`; + const newUrl = `/circuit/${circuitId}`; window.history.pushState({}, "", newUrl); }; @@ -251,6 +894,7 @@ function CircuitMaker() { ip: Input, op: Output, gate: Gate, + branch: Branch, }; }, []); @@ -282,6 +926,68 @@ function CircuitMaker() { [setEdges] ); + const onEdgeClick = useCallback( + (event: React.MouseEvent, edge: Edge) => { + event.stopPropagation(); + + if (!reactFlowInstance) return; + + // Get click position in flow coordinates + const position = reactFlowInstance.screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + + // Create a new branch node + const branchId = v4(); + const newBranchNode = { + id: branchId, + type: "branch", + position, + draggable: false, + selectable: true, + data: { + onContextMenu: (e: React.MouseEvent) => handleContextMenu(branchId, e), + remove: () => { + setNodes((prev) => prev.filter((n) => n.id !== branchId)); + setEdges((prev) => + prev.filter( + (e) => e.source !== branchId && e.target !== branchId + ) + ); + }, + }, + }; + + // Remove the original edge + setEdges((eds) => eds.filter((e) => e.id !== edge.id)); + + // Create two new edges: source -> branch and branch -> target + const edge1 = { + id: v4(), + source: edge.source, + sourceHandle: edge.sourceHandle, + target: branchId, + targetHandle: `${branchId}-i`, + }; + + const edge2 = { + id: v4(), + source: branchId, + sourceHandle: `${branchId}-o`, + target: edge.target, + targetHandle: edge.targetHandle, + }; + + // Add the branch node and new edges + setNodes((nds) => [...nds, newBranchNode]); + setEdges((eds) => [...eds, edge1, edge2]); + + toast.success("Branch point created"); + }, + [reactFlowInstance, setNodes, setEdges, handleContextMenu] + ); + const handlePaletteSelect = useCallback((type: string, gate?: GateType) => { let nodeType = "gate"; @@ -322,7 +1028,10 @@ function CircuitMaker() { if (!pendingNode.gate) { return; } - nodeData = pendingNode.gate; + nodeData = { + ...pendingNode.gate, + gateType: pendingNode.gate.name, // Store original gate type + }; } else { const generatedLabel = indexToLabel(nextLabelIndex); setNextLabelIndex((prev) => prev + 1); @@ -369,6 +1078,11 @@ function CircuitMaker() { prevOutputValues[node.id + "-o-" + output] ?? false; nodeStates.set(node.id + "-o-" + output, prevValue); }); + } else if (node.type === "branch") { + // Initialize branch node states + const prevValue = prevOutputValues[node.id + "-i"] ?? false; + nodeStates.set(node.id + "-i", prevValue); + nodeStates.set(node.id + "-o", prevValue); } }); @@ -434,6 +1148,21 @@ function CircuitMaker() { hasChanges = true; } } + } else if (node.type === "branch") { + // Branch nodes simply pass through the signal + const source = edges.find((edge) => edge.target === node.id); + if (source) { + const sourceValue = nodeStates.get(source.sourceHandle!) ?? false; + const inputHandle = node.id + "-i"; + const outputHandle = node.id + "-o"; + const currentValue = nodeStates.get(inputHandle); + + if (currentValue !== sourceValue) { + nodeStates.set(inputHandle, sourceValue); + nodeStates.set(outputHandle, sourceValue); + hasChanges = true; + } + } } }); } @@ -488,8 +1217,8 @@ function CircuitMaker() { node.type === "ip" ? inputValues[node.id] : node.type === "op" - ? outputValues[node.id] - : node.data.value, + ? outputValues[node.id] + : node.data.value, }, })); @@ -523,6 +1252,10 @@ function CircuitMaker() { // If it's a new circuit, set the current circuit ID and update URL if (!currentCircuitId && savedCircuit.id) { setCurrentCircuitId(savedCircuit.id); + setCurrentCircuitName(savedCircuit.name); + setCurrentCircuitDescription(savedCircuit.description || ""); + setCurrentCategoryIds(savedCircuit.categories?.map((c: any) => c.category_id || c.category?.id) || []); + setCurrentLabelIds(savedCircuit.labels?.map((l: any) => l.label_id || l.label?.id) || []); updateUrlWithCircuitId(savedCircuit.id); } @@ -552,8 +1285,8 @@ function CircuitMaker() { node.type === "ip" ? inputValues[node.id] : node.type === "op" - ? outputValues[node.id] - : node.data.value, + ? outputValues[node.id] + : node.data.value, }, })); @@ -600,10 +1333,16 @@ function CircuitMaker() { updateUrlWithCircuitId(circuit.id); setCurrentCircuitId(circuit.id); + setCurrentCircuitName(circuit.name); + setCurrentCircuitDescription(circuit.description || ""); + setCurrentCategoryIds(circuit.categories?.map((c: any) => c.category_id || c.category?.id) || []); + setCurrentLabelIds(circuit.labels?.map((l: any) => l.label_id || l.label?.id) || []); const transformedNodes = circuitData.nodes?.map((node: any) => ({ ...node, + // Set draggable false for branch nodes, true for others by default + draggable: node.type === 'branch' ? false : (node.draggable !== undefined ? node.draggable : true), data: { ...node.data, id: node.id, @@ -672,29 +1411,34 @@ function CircuitMaker() { {loadingPage && } {/* */} -
-
setShowLogin(true)} - onRegisterClick={() => setShowRegister(true)} - /> -
-
-

Circuit Workspace

-

Build & test with the same sleek experience

-

- Drag, drop, and simulate your circuits in a focused canvas that mirrors the landing page styling. -

-
- +
+ {!isFullscreen && ( + <> +
setShowLogin(true)} + onRegisterClick={() => setShowRegister(true)} + /> +
+
+

Circuit Workspace

+

Build & test with the same sleek experience

+

+ Drag, drop, and simulate your circuits in a focused canvas that mirrors the landing page styling. +

+
+
+ + )} +