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)}
+ >
Input
-
{data?.label}
+
{
+ e.stopPropagation();
+ data?.editLabel?.();
+ }}
+ >
+ {data?.label}
+