diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index ad7d5717..6d28cd68 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -49,7 +49,7 @@ import { // Lazy-load GLBViewerNode to avoid bundling three.js for users who don't use 3D nodes const GLBViewerNode = dynamic(() => import("./nodes/GLBViewerNode").then(mod => ({ default: mod.GLBViewerNode })), { ssr: false }); -import { EditableEdge, ReferenceEdge } from "./edges"; +import { EditableEdge, ReferenceEdge, SharedEdgeGradients } from "./edges"; import { ConnectionDropMenu, MenuAction } from "./ConnectionDropMenu"; import { MultiSelectToolbar } from "./MultiSelectToolbar"; import { EdgeToolbar } from "./EdgeToolbar"; @@ -2008,6 +2008,7 @@ export function WorkflowCanvas() { animated: false, }} > + diff --git a/src/components/__tests__/EditableEdge.test.tsx b/src/components/__tests__/EditableEdge.test.tsx index 6344933c..8c12a45e 100644 --- a/src/components/__tests__/EditableEdge.test.tsx +++ b/src/components/__tests__/EditableEdge.test.tsx @@ -133,9 +133,10 @@ describe("EditableEdge", () => { ); - // Check that gradient is defined - const gradient = container.querySelector("linearGradient"); - expect(gradient).toBeInTheDocument(); + // Edges now reference shared gradient IDs instead of per-edge defs + const basePath = container.querySelector(".react-flow__edge-path"); + const stroke = basePath?.getAttribute("style") ?? ""; + expect(stroke).toContain("edge-grad-image-"); }); it("should use blue color for prompt handle type", () => { @@ -145,8 +146,9 @@ describe("EditableEdge", () => { ); - const gradient = container.querySelector("linearGradient"); - expect(gradient).toBeInTheDocument(); + const basePath = container.querySelector(".react-flow__edge-path"); + const stroke = basePath?.getAttribute("style") ?? ""; + expect(stroke).toContain("edge-grad-prompt-"); }); it("should use orange color when edge is paused", () => { @@ -160,8 +162,9 @@ describe("EditableEdge", () => { ); - const gradient = container.querySelector("linearGradient"); - expect(gradient).toBeInTheDocument(); + const basePath = container.querySelector(".react-flow__edge-path"); + const stroke = basePath?.getAttribute("style") ?? ""; + expect(stroke).toContain("edge-grad-pause-"); }); }); @@ -278,13 +281,10 @@ describe("EditableEdge", () => { ); - // Gradient should exist with active opacity values - const gradient = container.querySelector("linearGradient"); - expect(gradient).toBeInTheDocument(); - - // Check first stop has opacity 1 (active state) - const stops = gradient?.querySelectorAll("stop"); - expect(stops?.[0]?.getAttribute("stop-opacity")).toBe("1"); + // Should reference the "active" shared gradient + const basePath = container.querySelector(".react-flow__edge-path"); + const stroke = basePath?.getAttribute("style") ?? ""; + expect(stroke).toContain("-active"); }); it("should have dimmed opacity when not connected to selected node", () => { @@ -300,13 +300,10 @@ describe("EditableEdge", () => { ); - // Gradient should exist with dimmed opacity values - const gradient = container.querySelector("linearGradient"); - expect(gradient).toBeInTheDocument(); - - // Check first stop has lower opacity (dimmed state) - const stops = gradient?.querySelectorAll("stop"); - expect(stops?.[0]?.getAttribute("stop-opacity")).toBe("0.25"); + // Should reference the "dimmed" shared gradient + const basePath = container.querySelector(".react-flow__edge-path"); + const stroke = basePath?.getAttribute("style") ?? ""; + expect(stroke).toContain("-dimmed"); }); }); diff --git a/src/components/__tests__/ReferenceEdge.test.tsx b/src/components/__tests__/ReferenceEdge.test.tsx index 633c38e7..c2db0824 100644 --- a/src/components/__tests__/ReferenceEdge.test.tsx +++ b/src/components/__tests__/ReferenceEdge.test.tsx @@ -115,13 +115,10 @@ describe("ReferenceEdge", () => { ); - // Check that gradient is defined with reference color - const gradient = container.querySelector("linearGradient"); - expect(gradient).toBeInTheDocument(); - - // Gradient ID should contain "reference" - const gradientId = gradient?.getAttribute("id"); - expect(gradientId).toContain("reference"); + // Edges now reference shared gradient IDs instead of per-edge defs + const basePath = container.querySelector(".react-flow__edge-path"); + const stroke = basePath?.getAttribute("style") ?? ""; + expect(stroke).toContain("edge-grad-reference-"); }); }); @@ -169,12 +166,10 @@ describe("ReferenceEdge", () => { ); - const gradient = container.querySelector("linearGradient"); - expect(gradient).toBeInTheDocument(); - - // Check that gradient ID contains "active" - const gradientId = gradient?.getAttribute("id"); - expect(gradientId).toContain("active"); + // Should reference the "active" shared gradient + const basePath = container.querySelector(".react-flow__edge-path"); + const stroke = basePath?.getAttribute("style") ?? ""; + expect(stroke).toContain("-active"); }); it("should have dimmed opacity when not connected to selected node", () => { @@ -190,12 +185,10 @@ describe("ReferenceEdge", () => { ); - const gradient = container.querySelector("linearGradient"); - expect(gradient).toBeInTheDocument(); - - // Check that gradient ID contains "dimmed" - const gradientId = gradient?.getAttribute("id"); - expect(gradientId).toContain("dimmed"); + // Should reference the "dimmed" shared gradient + const basePath = container.querySelector(".react-flow__edge-path"); + const stroke = basePath?.getAttribute("style") ?? ""; + expect(stroke).toContain("-dimmed"); }); it("should be dimmed when no nodes are selected", () => { @@ -211,12 +204,10 @@ describe("ReferenceEdge", () => { ); - const gradient = container.querySelector("linearGradient"); - expect(gradient).toBeInTheDocument(); - - // When no nodes selected, edge should be dimmed - const gradientId = gradient?.getAttribute("id"); - expect(gradientId).toContain("dimmed"); + // Should reference the "dimmed" shared gradient + const basePath = container.querySelector(".react-flow__edge-path"); + const stroke = basePath?.getAttribute("style") ?? ""; + expect(stroke).toContain("-dimmed"); }); }); @@ -249,9 +240,9 @@ describe("ReferenceEdge", () => { ); - const gradient = container.querySelector("linearGradient"); - const gradientId = gradient?.getAttribute("id"); - expect(gradientId).toContain("active"); + const basePath = container.querySelector(".react-flow__edge-path"); + const stroke = basePath?.getAttribute("style") ?? ""; + expect(stroke).toContain("-active"); }); it("should highlight when target node is selected", () => { @@ -267,9 +258,9 @@ describe("ReferenceEdge", () => { ); - const gradient = container.querySelector("linearGradient"); - const gradientId = gradient?.getAttribute("id"); - expect(gradientId).toContain("active"); + const basePath = container.querySelector(".react-flow__edge-path"); + const stroke = basePath?.getAttribute("style") ?? ""; + expect(stroke).toContain("-active"); }); }); }); diff --git a/src/components/edges/EditableEdge.tsx b/src/components/edges/EditableEdge.tsx index 8e7d0477..d93ad92f 100644 --- a/src/components/edges/EditableEdge.tsx +++ b/src/components/edges/EditableEdge.tsx @@ -10,6 +10,7 @@ import { } from "@xyflow/react"; import { useWorkflowStore } from "@/store/workflowStore"; import { NanoBananaNodeData, WorkflowEdgeData } from "@/types"; +import { getSharedGradientId } from "./SharedEdgeGradients"; interface EdgeData extends WorkflowEdgeData { offsetX?: number; @@ -43,33 +44,26 @@ export function EditableEdge({ }: EdgeProps) { const { setEdges } = useReactFlow(); const edgeStyle = useWorkflowStore((state) => state.edgeStyle); - // Subscribe to nodes array to get updates when node data changes - const nodes = useWorkflowStore((state) => state.nodes); const [isDragging, setIsDragging] = useState(false); - // Check if any node is selected and if this edge is connected to it - const isConnectedToSelection = useMemo(() => { - const selectedNodes = nodes.filter((n) => n.selected); - if (selectedNodes.length === 0) return false; // No selection, show all dimmed - - // Check if this edge connects to any selected node - return selectedNodes.some((n) => n.id === source || n.id === target); - }, [nodes, source, target]); + // Narrow selector: returns boolean, only re-renders when selection relevance changes + const isConnectedToSelection = useWorkflowStore((state) => { + const selected = state.nodes.filter((n) => n.selected); + if (selected.length === 0) return false; + return selected.some((n) => n.id === source || n.id === target); + }); const edgeData = data as EdgeData | undefined; const offsetX = edgeData?.offsetX ?? 0; const offsetY = edgeData?.offsetY ?? 0; const hasPause = edgeData?.hasPause ?? false; - // Check if target node is a Generate node that's currently loading - const isTargetLoading = useMemo(() => { - const targetNode = nodes.find((n) => n.id === target); - if (targetNode?.type === "nanoBanana") { - const nodeData = targetNode.data as NanoBananaNodeData; - return nodeData.status === "loading"; - } - return false; - }, [target, nodes]); + // Narrow selector: only re-renders when target loading status changes + const isTargetLoading = useWorkflowStore((state) => { + const targetNode = state.nodes.find((n) => n.id === target); + if (targetNode?.type !== "nanoBanana") return false; + return (targetNode.data as NanoBananaNodeData).status === "loading"; + }); // Determine edge color based on handle type (orange if paused) const edgeColor = useMemo(() => { @@ -81,12 +75,12 @@ export function EditableEdge({ return EDGE_COLORS.default; }, [hasPause, sourceHandleId, targetHandleId]); - // Generate a unique gradient ID based on edge color and selection state + // Reference shared gradient by color key + selection state const gradientId = useMemo(() => { const colorKey = hasPause ? "pause" : (sourceHandleId || targetHandleId || "default"); const selectionKey = isConnectedToSelection ? "active" : "dimmed"; - return `edge-gradient-${colorKey}-${selectionKey}-${id}`; - }, [hasPause, sourceHandleId, targetHandleId, isConnectedToSelection, id]); + return getSharedGradientId(colorKey, selectionKey); + }, [hasPause, sourceHandleId, targetHandleId, isConnectedToSelection]); // Calculate the path based on edge style const [edgePath, labelX, labelY] = useMemo(() => { @@ -181,15 +175,6 @@ export function EditableEdge({ return ( <> - {/* SVG gradient definition for bright-dim-bright effect */} - - - - - - - - - {/* Glow effect behind the pulse */} + {/* Outer glow — replaces blur(6px) filter for better perf on Windows */} + {/* Inner glow */} + {/* Animated flowing pulse using stroke-dasharray */} state.edgeStyle); - const nodes = useWorkflowStore((state) => state.nodes); - - // Check if any node is selected and if this edge is connected to it - const isConnectedToSelection = useMemo(() => { - const selectedNodes = nodes.filter((n) => n.selected); - if (selectedNodes.length === 0) return false; // No selection, show all dimmed - // Check if this edge connects to any selected node - return selectedNodes.some((n) => n.id === source || n.id === target); - }, [nodes, source, target]); + // Narrow selector: returns boolean, only re-renders when selection relevance changes + const isConnectedToSelection = useWorkflowStore((state) => { + const selected = state.nodes.filter((n) => n.selected); + if (selected.length === 0) return false; + return selected.some((n) => n.id === source || n.id === target); + }); // Calculate the path - always use curved for reference edges for softer look const [edgePath] = useMemo(() => { @@ -49,23 +44,14 @@ export function ReferenceEdge({ }); }, [sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition]); - // Generate a unique gradient ID based on selection state + // Reference shared gradient by selection state const gradientId = useMemo(() => { const selectionKey = isConnectedToSelection ? "active" : "dimmed"; - return `reference-gradient-${selectionKey}-${id}`; - }, [isConnectedToSelection, id]); + return getSharedGradientId("reference", selectionKey); + }, [isConnectedToSelection]); return ( <> - {/* SVG gradient definition for bright-dim-bright effect */} - - - - - - - - / in every edge component. + +const EDGE_COLORS: Record = { + image: "#0d9668", + prompt: "#2563eb", + default: "#64748b", + pause: "#ea580c", + reference: "#52525b", +}; + +const SELECTION_STATES = ["active", "dimmed"] as const; + +function gradientStops(color: string, active: boolean) { + return ( + <> + + + + + ); +} + +export function getSharedGradientId(colorKey: string, selectionKey: "active" | "dimmed") { + return `edge-grad-${colorKey}-${selectionKey}`; +} + +export function SharedEdgeGradients() { + return ( + + + {Object.entries(EDGE_COLORS).flatMap(([colorKey, color]) => + SELECTION_STATES.map((sel) => ( + + {gradientStops(color, sel === "active")} + + )) + )} + + + ); +} diff --git a/src/components/edges/index.ts b/src/components/edges/index.ts index d5a5471a..b782c0d5 100644 --- a/src/components/edges/index.ts +++ b/src/components/edges/index.ts @@ -1,2 +1,3 @@ export { EditableEdge } from "./EditableEdge"; export { ReferenceEdge } from "./ReferenceEdge"; +export { SharedEdgeGradients } from "./SharedEdgeGradients";