Skip to content

Instantly share code, notes, and snippets.

@dBianchii
Created December 27, 2024 18:43
Show Gist options
  • Save dBianchii/7809cfb1c53bdcf6462f4068db3e9c23 to your computer and use it in GitHub Desktop.
Save dBianchii/7809cfb1c53bdcf6462f4068db3e9c23 to your computer and use it in GitHub Desktop.
Avatar-dial
import { useAtom } from "jotai/react";
import React, { useState, useCallback, useEffect } from "react";
import { colorValue } from "../atoms";
export default function AnimatedCircularProgressBar({
max = 100,
min = 0,
value,
className = "",
children,
strokeWidth = 7,
onChange,
sensitivity = 0.4,
onClickRing,
}: {
max?: number;
value: number;
min?: number;
className?: string;
children?: React.ReactNode;
strokeWidth?: number;
size?: number;
onChange?: (newValue: number) => void;
sensitivity?: number;
onClickRing?: () => void;
}) {
const [color] = useAtom(colorValue);
const [isDragging, setIsDragging] = useState(false);
const [startY, setStartY] = useState(0);
const [startValue, setStartValue] = useState(value);
const circumference = 2 * Math.PI * 45;
const percentPx = circumference / 100;
const currentPercent = Math.round(((value - min) / (max - min)) * 100);
const handleMouseDown = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
setIsDragging(true);
setStartY("touches" in e ? e.touches[0].clientY : e.clientY);
setStartValue(value);
document.body.style.cursor = "grabbing";
},
[value],
);
const handleMouseMove = useCallback(
(e: MouseEvent | TouchEvent) => {
if (!isDragging) return;
const currentY = "touches" in e ? e.touches[0].clientY : e.clientY;
const deltaY = (startY - currentY) * sensitivity;
const valueRange = max - min;
const newValue = Math.min(
Math.max(startValue + (deltaY / 100) * valueRange, min),
max,
);
onChange?.(Math.round(newValue));
e.preventDefault();
},
[isDragging, startY, startValue, max, min, sensitivity, onChange],
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
document.body.style.cursor = "";
}, []);
React.useEffect(() => {
if (isDragging) {
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("touchmove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
window.addEventListener("touchend", handleMouseUp);
}
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("touchmove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
window.removeEventListener("touchend", handleMouseUp);
};
}, [isDragging, handleMouseMove, handleMouseUp]);
return (
<div
className={`relative size-32 text-2xl font-semibold ${className}`}
style={{
transform: "translateZ(0)",
}}
>
<svg
fill="none"
className="size-full"
strokeWidth="2"
viewBox="0 0 100 100"
>
{currentPercent <= 90 && currentPercent >= 0 && (
<circle
onClick={onClickRing}
cx="50"
cy="50"
r="45"
strokeWidth="10"
strokeDashoffset="0"
strokeLinecap="round"
strokeLinejoin="round"
className="opacity-100 hover:scale-110"
style={{
strokeDasharray: `${(90 - currentPercent) * percentPx}px ${circumference}px`,
transform: "rotate(270deg) scaleY(-1)",
transformOrigin: "50% 50%",
}}
/>
)}
<circle
onClick={onClickRing}
cx="50"
cy="50"
r="45"
strokeWidth={strokeWidth}
strokeDashoffset="0"
strokeLinecap="round"
strokeLinejoin="round"
className="opacity-100"
style={{
stroke: color,
strokeDasharray: `${currentPercent * percentPx}px ${circumference}px`,
transform: "rotate(-90deg)",
transformOrigin: "50% 50%",
}}
/>
</svg>
{children ? (
<span
onMouseDown={handleMouseDown}
onTouchStart={handleMouseDown}
data-current-value={currentPercent}
className="absolute inset-0 m-auto size-fit select-none"
style={{ cursor: isDragging ? "grabbing" : "grab" }}
>
{children}
</span>
) : (
<span
onMouseDown={handleMouseDown}
onTouchStart={handleMouseDown}
data-current-value={currentPercent}
className="absolute inset-0 m-auto size-fit select-none"
style={{ cursor: isDragging ? "grabbing" : "grab" }}
>
{currentPercent}
</span>
)}
</div>
);
}
import { useState } from "react";
import AnimatedCircularProgressBar from "./ui/animated-circular-progress-bar";
import { useAtom } from "jotai/react";
import { colorValue, dialValue } from "./atoms";
import { ColorPicker } from "./ui/color-picker";
export function AvatarDial({ children }: { children?: React.ReactNode }) {
const [value, setValue] = useAtom(dialValue);
const [color, setColor] = useAtom(colorValue);
const [open, setOpen] = useState(false);
return (
<>
<ColorPicker
open={open}
setOpen={(open) => {
setOpen(open);
}}
value={color}
onChange={setColor}
/>
<AnimatedCircularProgressBar
max={100}
min={0}
onClickRing={() => setOpen(true)}
value={value}
onChange={setValue}
>
{children}
</AnimatedCircularProgressBar>
</>
);
}
"use client";
import { forwardRef, useMemo } from "react";
import { HexColorPicker } from "react-colorful";
import { useForwardedRef } from "~/lib/use-forwarded-ref";
import { type ButtonProps } from "./button";
import { Input } from "./input";
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
interface ColorPickerProps {
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
open: boolean;
setOpen: (open: boolean) => void;
}
const ColorPicker = forwardRef<
HTMLInputElement,
Omit<ButtonProps, "value" | "onChange" | "onBlur"> & ColorPickerProps
>(
(
{
disabled,
value,
onChange,
onBlur,
name,
className,
open,
setOpen,
...props
},
forwardedRef,
) => {
const ref = useForwardedRef(forwardedRef);
const parsedValue = useMemo(() => {
return value || "#FFFFFF";
}, [value]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger className="hidden size-32"></PopoverTrigger>
<PopoverContent className="w-full">
<HexColorPicker color={parsedValue} onChange={onChange} />
<Input
maxLength={7}
onChange={(e) => {
onChange(e?.currentTarget?.value);
}}
ref={ref}
value={parsedValue}
/>
</PopoverContent>
</Popover>
);
},
);
ColorPicker.displayName = "ColorPicker";
export { ColorPicker };
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment