210 lines
5.1 KiB
TypeScript
210 lines
5.1 KiB
TypeScript
'use client';
|
|
|
|
import { GripVerticalIcon } from 'lucide-react';
|
|
import {
|
|
type MotionValue,
|
|
motion,
|
|
useMotionValue,
|
|
useSpring,
|
|
useTransform,
|
|
} from 'motion/react';
|
|
import {
|
|
type ComponentProps,
|
|
createContext,
|
|
type HTMLAttributes,
|
|
type MouseEventHandler,
|
|
type ReactNode,
|
|
type TouchEventHandler,
|
|
useContext,
|
|
useState,
|
|
} from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
type ImageComparisonContextType = {
|
|
sliderPosition: number;
|
|
setSliderPosition: (pos: number) => void;
|
|
motionSliderPosition: MotionValue<number>;
|
|
mode: 'hover' | 'drag';
|
|
};
|
|
|
|
const ImageComparisonContext = createContext<
|
|
ImageComparisonContextType | undefined
|
|
>(undefined);
|
|
|
|
const useImageComparisonContext = () => {
|
|
const context = useContext(ImageComparisonContext);
|
|
|
|
if (!context) {
|
|
throw new Error(
|
|
'useImageComparisonContext must be used within a ImageComparison'
|
|
);
|
|
}
|
|
|
|
return context;
|
|
};
|
|
|
|
export type ComparisonProps = HTMLAttributes<HTMLDivElement> & {
|
|
mode?: 'hover' | 'drag';
|
|
onDragStart?: () => void;
|
|
onDragEnd?: () => void;
|
|
};
|
|
|
|
export const Comparison = ({
|
|
className,
|
|
mode = 'drag',
|
|
onDragStart,
|
|
onDragEnd,
|
|
...props
|
|
}: ComparisonProps) => {
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const motionValue = useMotionValue(50);
|
|
const motionSliderPosition = useSpring(motionValue, {
|
|
bounce: 0,
|
|
duration: 0,
|
|
});
|
|
const [sliderPosition, setSliderPosition] = useState(50);
|
|
|
|
const handleDrag = (domRect: DOMRect, clientX: number) => {
|
|
if (!isDragging && mode === 'drag') {
|
|
return;
|
|
}
|
|
|
|
const x = clientX - domRect.left;
|
|
const percentage = Math.min(Math.max((x / domRect.width) * 100, 0), 100);
|
|
motionValue.set(percentage);
|
|
setSliderPosition(percentage);
|
|
};
|
|
|
|
const handleMouseDrag: MouseEventHandler<HTMLDivElement> = (event) => {
|
|
if (!event) {
|
|
return;
|
|
}
|
|
|
|
const containerRect = event.currentTarget.getBoundingClientRect();
|
|
|
|
handleDrag(containerRect, event.clientX);
|
|
};
|
|
|
|
const handleTouchDrag: TouchEventHandler<HTMLDivElement> = (event) => {
|
|
if (!event) {
|
|
return;
|
|
}
|
|
|
|
const containerRect = event.currentTarget.getBoundingClientRect();
|
|
const touches = Array.from(event.touches);
|
|
|
|
handleDrag(containerRect, touches.at(0)?.clientX ?? 0);
|
|
};
|
|
|
|
const handleDragStart = () => {
|
|
if (mode === 'drag') {
|
|
setIsDragging(true);
|
|
onDragStart?.();
|
|
}
|
|
};
|
|
|
|
const handleDragEnd = () => {
|
|
if (mode === 'drag') {
|
|
setIsDragging(false);
|
|
onDragEnd?.();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<ImageComparisonContext.Provider
|
|
value={{ sliderPosition, setSliderPosition, motionSliderPosition, mode }}
|
|
>
|
|
<div
|
|
aria-label="Comparison slider"
|
|
aria-valuemax={100}
|
|
aria-valuemin={0}
|
|
aria-valuenow={sliderPosition}
|
|
className={cn(
|
|
'relative isolate w-full select-none overflow-hidden',
|
|
className
|
|
)}
|
|
onMouseDown={handleDragStart}
|
|
onMouseLeave={handleDragEnd}
|
|
onMouseMove={handleMouseDrag}
|
|
onMouseUp={handleDragEnd}
|
|
onTouchEnd={handleDragEnd}
|
|
onTouchMove={handleTouchDrag}
|
|
onTouchStart={handleDragStart}
|
|
role="slider"
|
|
tabIndex={0}
|
|
{...props}
|
|
/>
|
|
</ImageComparisonContext.Provider>
|
|
);
|
|
};
|
|
|
|
export type ComparisonItemProps = ComponentProps<typeof motion.div> & {
|
|
position: 'left' | 'right';
|
|
};
|
|
|
|
export const ComparisonItem = ({
|
|
className,
|
|
position,
|
|
...props
|
|
}: ComparisonItemProps) => {
|
|
const { motionSliderPosition } = useImageComparisonContext();
|
|
const leftClipPath = useTransform(
|
|
motionSliderPosition,
|
|
(value) => `inset(0 0 0 ${value}%)`
|
|
);
|
|
const rightClipPath = useTransform(
|
|
motionSliderPosition,
|
|
(value) => `inset(0 ${100 - value}% 0 0)`
|
|
);
|
|
|
|
return (
|
|
<motion.div
|
|
aria-hidden="true"
|
|
className={cn('absolute inset-0 h-full w-full object-cover', className)}
|
|
role="img"
|
|
style={{
|
|
clipPath: position === 'left' ? leftClipPath : rightClipPath,
|
|
}}
|
|
{...props}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export type ComparisonHandleProps = ComponentProps<typeof motion.div> & {
|
|
children?: ReactNode;
|
|
};
|
|
|
|
export const ComparisonHandle = ({
|
|
className,
|
|
children,
|
|
...props
|
|
}: ComparisonHandleProps) => {
|
|
const { motionSliderPosition, mode } = useImageComparisonContext();
|
|
const left = useTransform(motionSliderPosition, (value) => `${value}%`);
|
|
|
|
return (
|
|
<motion.div
|
|
aria-hidden="true"
|
|
className={cn(
|
|
'-translate-x-1/2 absolute top-0 z-50 flex h-full w-10 items-center justify-center',
|
|
mode === 'drag' && 'cursor-grab active:cursor-grabbing',
|
|
className
|
|
)}
|
|
role="presentation"
|
|
style={{ left }}
|
|
{...props}
|
|
>
|
|
{children ?? (
|
|
<>
|
|
<div className="-translate-x-1/2 absolute left-1/2 h-full w-1 bg-background" />
|
|
{mode === 'drag' && (
|
|
<div className="z-50 flex items-center justify-center rounded-sm bg-background px-0.5 py-1">
|
|
<GripVerticalIcon className="h-4 w-4 select-none text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</motion.div>
|
|
);
|
|
};
|