From 9d26ac23160b93f7d363fbd7b6a5fac03739015c Mon Sep 17 00:00:00 2001 From: Harshit Vijay <93333205+Coder-Harshit@users.noreply.github.com> Date: Fri, 19 Dec 2025 00:55:46 +0530 Subject: [PATCH 1/8] [+] Target node handles limited to one input --- frontend/src/App.tsx | 27 ++++++++++++++----- .../handle/LimitedConnectionHandle.tsx | 18 ------------- frontend/src/components/handle/handleTypes.ts | 5 ---- 3 files changed, 20 insertions(+), 30 deletions(-) delete mode 100644 frontend/src/components/handle/LimitedConnectionHandle.tsx delete mode 100644 frontend/src/components/handle/handleTypes.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 675fcc8..b614075 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -70,22 +70,27 @@ function App() { const [searchSettings, setSearchSettings] = useState(() => { const saved = localStorage.getItem(settingsKey); if (!saved) return defaultSearchSettings; - + try { const parsed = JSON.parse(saved); // Validate parsed object has the expected structure if ( - typeof parsed === 'object' && + typeof parsed === "object" && parsed !== null && - typeof parsed.fuzzy === 'boolean' && - typeof parsed.delay === 'number' + typeof parsed.fuzzy === "boolean" && + typeof parsed.delay === "number" ) { return parsed as SearchSettings; } - console.warn('Invalid search settings structure in localStorage, using defaults'); + console.warn( + "Invalid search settings structure in localStorage, using defaults", + ); return defaultSearchSettings; } catch (error) { - console.error('Failed to parse search settings from localStorage:', error); + console.error( + "Failed to parse search settings from localStorage:", + error, + ); return defaultSearchSettings; } }); @@ -248,6 +253,7 @@ function App() { const onConnect = useCallback( (connection: Connection) => { // Use functional update so we always work with the latest edges + const newEdge = { ...connection, // style: { @@ -256,8 +262,15 @@ function App() { // strokeWidth: 2 // }, }; + setEdges((eds) => { - const newEdges = addEdge(newEdge, eds); + // since target handles are built for accepting only a single input therefore we need to check and remove the prev. connections so that only one connection (edge) is made (at max) + const targetHandleId = connection.targetHandle; + const oldEdges = eds.filter( + (edge) => edge.targetHandle !== targetHandleId, + ); + + const newEdges = addEdge(newEdge, oldEdges); if (connection.target) { // fire-and-forget async inspect using the up-to-date edge list diff --git a/frontend/src/components/handle/LimitedConnectionHandle.tsx b/frontend/src/components/handle/LimitedConnectionHandle.tsx deleted file mode 100644 index efe12cc..0000000 --- a/frontend/src/components/handle/LimitedConnectionHandle.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Handle, useNodeConnections } from "@xyflow/react"; -import type { LimitedConnectionHandleProps } from "./handleTypes"; - -const LimitedConnectionHandle = (props: LimitedConnectionHandleProps) => { - const { connectionCount = 1, ...restProps } = props; - const connections = useNodeConnections({ - handleType: restProps.type, - }); - - return ( - - ); -}; - -export default LimitedConnectionHandle; diff --git a/frontend/src/components/handle/handleTypes.ts b/frontend/src/components/handle/handleTypes.ts deleted file mode 100644 index b69c6ac..0000000 --- a/frontend/src/components/handle/handleTypes.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { HandleProps } from "@xyflow/react"; - -export type LimitedConnectionHandleProps = HandleProps & { - connectionCount?: number; -}; From b618d0dc9a9715a2e2a59067aa5e2d31e303f0b8 Mon Sep 17 00:00:00 2001 From: Harshit Vijay <93333205+Coder-Harshit@users.noreply.github.com> Date: Fri, 19 Dec 2025 00:59:20 +0530 Subject: [PATCH 2/8] [.] bugfix roateImageNode --- .../src/components/nodes/rotateImageNode.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/nodes/rotateImageNode.tsx b/frontend/src/components/nodes/rotateImageNode.tsx index 8498d9d..45a1d16 100644 --- a/frontend/src/components/nodes/rotateImageNode.tsx +++ b/frontend/src/components/nodes/rotateImageNode.tsx @@ -1,7 +1,7 @@ -import { Handle, Position } from "@xyflow/react"; +import { Position } from "@xyflow/react"; import type { ChangeEvent } from "react"; import type { RotateImageNodeProps } from "../../nodeTypes"; -import LimitedConnectionHandle from "../handle/LimitedConnectionHandle"; +import { TypedHandle } from "../ui/TypedHandle"; // Shared class for form elements const formElementClasses = @@ -21,7 +21,10 @@ const RotateIcon = () => ( strokeLinecap="round" strokeLinejoin="round" > - + ); @@ -138,17 +141,20 @@ function RotateImageNode({ id, data }: RotateImageNodeProps) { {/* Handles */} - - ); From df783db1d56d20ef5b665a397e43ad438a8da460 Mon Sep 17 00:00:00 2001 From: Harshit Vijay <93333205+Coder-Harshit@users.noreply.github.com> Date: Fri, 19 Dec 2025 15:33:52 +0530 Subject: [PATCH 3/8] [+] context menu desgin overhaul --- frontend/src/App.tsx | 5 +- frontend/src/components/ui/ContextMenu.tsx | 309 ++++++++++++++++----- frontend/src/types.ts | 1 + 3 files changed, 236 insertions(+), 79 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b614075..89f6917 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,14 +12,11 @@ import { Controls, } from "@xyflow/react"; import { v4 as uuidv4 } from "uuid"; - import { nodeRegistry } from "./components/nodes/nodeRegistry"; import ContextMenu from "./components/ui/ContextMenu"; import PackageManager from "./components/ui/PackageManager"; - import type { AppNode, AppNodeData } from "./nodeTypes"; import type { NodeStatus, SearchSettings } from "./types"; - import "./App.css"; import { ThemeToggle } from "./components/ui/ThemeToggle"; import { @@ -34,6 +31,7 @@ import { HelpIcon, GearIcon } from "./components/ui/icons"; const localKey = "neurocircuit-flow"; const themeKey = "neurocircuit-theme"; const settingsKey = "neurocircuit-settings"; + const defaultSearchSettings: SearchSettings = { fuzzy: true, delay: 50 }; const nodeTypes = nodeRegistry; @@ -688,6 +686,7 @@ function App() { actions={availableNodes.map((node) => ({ label: node.label, category: node.category, + description: node.description, onSelect: () => { addNode(node.nodeType); setMenu(null); diff --git a/frontend/src/components/ui/ContextMenu.tsx b/frontend/src/components/ui/ContextMenu.tsx index cd0cb4e..a65882d 100644 --- a/frontend/src/components/ui/ContextMenu.tsx +++ b/frontend/src/components/ui/ContextMenu.tsx @@ -1,6 +1,7 @@ -import { useState, useMemo, useEffect, useRef } from "react"; -import type { ContextMenuProps, MenuAction } from "../../types"; +import { useState, useMemo, useEffect, useRef, useLayoutEffect } from "react"; +import type { ContextMenuProps } from "../../types"; +// Helper: Fuzzy Search function fuzzysearch(text: string, search: string) { const searchLower = search.toLowerCase(); const textLower = text.toLowerCase(); @@ -22,116 +23,272 @@ export default function ContextMenu({ left, actions, searchSettings, + onClose, }: ContextMenuProps) { const [searchTerm, setSearchTerm] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); const searchInputRef = useRef(null); + const listRef = useRef(null); + const menuRef = useRef(null); + const [adjustedLeft, setAdjustedLeft] = useState(left); - // Focus the search input when the menu opens + // If opening the menu goes off-screen to the right, flip it to open to the left + useLayoutEffect(() => { + if (menuRef.current) { + const width = menuRef.current.offsetWidth; + const windowWidth = window.innerWidth; + if (left + width > windowWidth - 20) { + setAdjustedLeft(left - width); + } + } + }, [left]); + + // Focus search on mount useEffect(() => { - searchInputRef.current?.focus(); + const timer = setTimeout(() => searchInputRef.current?.focus(), 10); + return () => clearTimeout(timer); }, []); + // --- Filter & Sort --- const filteredActions = useMemo(() => { - return actions.filter((action) => { + const filtered = actions.filter((action) => { if (!searchSettings.fuzzy) { + const lowerSearch = searchTerm.toLowerCase(); return ( - action.label.toLowerCase().includes(searchTerm.toLowerCase()) || - (action.category && - action.category.toLowerCase().includes(searchTerm.toLowerCase())) + action.label.toLowerCase().includes(lowerSearch) || + action.category?.toLowerCase().includes(lowerSearch) || + action.description?.toLowerCase().includes(lowerSearch) ); } return ( fuzzysearch(action.label, searchTerm) || - (action.category && fuzzysearch(action.category, searchTerm)) + (action.category && fuzzysearch(action.category, searchTerm)) || + (action.description && + fuzzysearch( + action.description.toLowerCase(), + searchTerm.toLowerCase(), + )) ); }); - }, [actions, searchSettings.fuzzy, searchTerm]); - - const grpdActions = useMemo(() => { - return filteredActions.reduce( - (acc, action) => { - const category = action.category || "General"; - if (!acc[category]) { - acc[category] = []; - } - acc[category].push(action); - return acc; - }, - {} as Record, - ); + + return filtered.sort((a, b) => { + const catA = a.category || "General"; + const catB = b.category || "General"; + if (catA !== catB) return catA.localeCompare(catB); + return a.label.localeCompare(b.label); + }); + }, [actions, searchTerm, searchSettings.fuzzy]); + + // Reset selection on filter change + useEffect(() => { + setSelectedIndex(0); }, [filteredActions]); + // --- Auto-Select --- useEffect(() => { - if (searchSettings.delay <= 0) return; // disabled + if (searchSettings.delay <= 0) return; let timer: number | undefined; - if (filteredActions.length === 1) { + if (filteredActions.length === 1 && searchTerm.length > 0) { timer = setTimeout(() => { - // Execute the action of the single remaining node - const targetAction = filteredActions[0]; - targetAction.onSelect(); - // The menu will be closed by the parent's onSelect handler, - // but we can ensure cleanup or explicit close if needed. + filteredActions[0].onSelect(); }, searchSettings.delay); } + return () => clearTimeout(timer); + }, [filteredActions, searchTerm, searchSettings.delay]); - return () => { - if (timer) clearTimeout(timer); - }; - }, [filteredActions, searchSettings.delay, searchTerm]); + // --- Scroll active into view --- + useEffect(() => { + if (listRef.current) { + const activeItem = listRef.current.querySelector( + `[data-index="${selectedIndex}"]`, + ); + if (activeItem) { + activeItem.scrollIntoView({ block: "nearest" }); + } + } + }, [selectedIndex]); - const categories = Object.keys(grpdActions).sort(); + const handleKeyDown = (e: React.KeyboardEvent) => { + if (["ArrowUp", "ArrowDown", "Enter", "Escape", "Tab"].includes(e.key)) { + e.preventDefault(); + } + switch (e.key) { + case "ArrowDown": + case "Tab": + setSelectedIndex((prev) => (prev + 1) % filteredActions.length); + break; + case "ArrowUp": + setSelectedIndex((prev) => + prev === 0 ? filteredActions.length - 1 : prev - 1, + ); + break; + case "Enter": + if (filteredActions[selectedIndex]) { + filteredActions[selectedIndex].onSelect(); + } + break; + case "Escape": + onClose(); + break; + } + }; + + const selectedAction = filteredActions[selectedIndex]; return (
e.stopPropagation()} > -
- setSearchTerm(e.target.value)} - className="w-full px-2 py-1.5 rounded-md bg-[var(--color-surface-3)] border border-[var(--color-border-2)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)]" - /> + {/* === LEFT COLUMN: Search & List === */} +
+ {/* Search Bar */} +
+
+ setSearchTerm(e.target.value)} + onKeyDown={handleKeyDown} + className="w-full rounded-md bg-[var(--color-surface-3)] border border-[var(--color-border-2)] py-2 pl-9 pr-3 text-sm focus:border-[var(--color-accent)] focus:outline-none focus:ring-1 focus:ring-[var(--color-accent)] text-[var(--color-text-1)] placeholder-[var(--color-text-3)]" + /> + + + + +
+
+ + {/* Node List */} +
+ {filteredActions.length > 0 ? ( + filteredActions.map((action, index) => { + const prevCategory = + index > 0 ? filteredActions[index - 1].category : null; + const currentCategory = action.category || "General"; + const showHeader = + index === 0 || currentCategory !== prevCategory; + const isActive = index === selectedIndex; + + return ( +
+ {showHeader && ( +
+ {currentCategory} +
+ )} + + +
+ ); + }) + ) : ( +
+

No results found

+
+ )} +
-
    - {categories.length > 0 ? ( - categories.map((category) => ( -
  • -

    - {category} -

    -
      - {grpdActions[category].map(({ label, onSelect }) => ( -
    • - -
    • - ))} -
    -
  • - )) + {/* === RIGHT COLUMN: Details & Preview === */} +
    + {selectedAction ? ( +
    + {/* Header Info */} +
    +
    + {selectedAction.category || "General"} +
    +

    + {selectedAction.label} +

    +
    + + {/* Description */} +
    + {selectedAction.description ? ( + selectedAction.description + ) : ( + + No description available. + + )} +
    +
    ) : ( -
  • - No nodes found. -
  • +
    +

    Select a node to view details

    +
    )} -
+ + {/* Shortcuts Footer */} +
+
+
+ Navigate +
+ +
+
+
+ Select + ↵ Enter +
+
+ Close + Esc +
+
+
+
); } + +// helper component for Keyboard keys +function Kbd({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f467e2a..4551c96 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -4,6 +4,7 @@ export type MenuAction = { label: string; onSelect: () => void; category?: string; + description?: string; }; export type SearchSettings = { From 44a9f72908225e18c21afbe55493592f0a3e36da Mon Sep 17 00:00:00 2001 From: Harshit Vijay <93333205+Coder-Harshit@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:51:34 +0530 Subject: [PATCH 4/8] Update frontend/src/components/ui/ContextMenu.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- frontend/src/components/ui/ContextMenu.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ui/ContextMenu.tsx b/frontend/src/components/ui/ContextMenu.tsx index a65882d..e47b428 100644 --- a/frontend/src/components/ui/ContextMenu.tsx +++ b/frontend/src/components/ui/ContextMenu.tsx @@ -65,8 +65,8 @@ export default function ContextMenu({ (action.category && fuzzysearch(action.category, searchTerm)) || (action.description && fuzzysearch( - action.description.toLowerCase(), - searchTerm.toLowerCase(), + action.description, + searchTerm, )) ); }); From f11d4613fa9d246c86e1d13207b00313019944f8 Mon Sep 17 00:00:00 2001 From: Harshit Vijay <93333205+Coder-Harshit@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:55:15 +0530 Subject: [PATCH 5/8] Update frontend/src/components/nodes/rotateImageNode.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- frontend/src/components/nodes/rotateImageNode.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/components/nodes/rotateImageNode.tsx b/frontend/src/components/nodes/rotateImageNode.tsx index 45a1d16..50c0a9b 100644 --- a/frontend/src/components/nodes/rotateImageNode.tsx +++ b/frontend/src/components/nodes/rotateImageNode.tsx @@ -151,7 +151,6 @@ function RotateImageNode({ id, data }: RotateImageNodeProps) { Date: Fri, 19 Dec 2025 17:00:37 +0530 Subject: [PATCH 6/8] [+] copilot suggestions --- frontend/src/components/ui/ContextMenu.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/components/ui/ContextMenu.tsx b/frontend/src/components/ui/ContextMenu.tsx index e47b428..8c6fb0f 100644 --- a/frontend/src/components/ui/ContextMenu.tsx +++ b/frontend/src/components/ui/ContextMenu.tsx @@ -174,6 +174,7 @@ export default function ContextMenu({ strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" + aria-hidden="true" > @@ -206,6 +207,7 @@ export default function ContextMenu({