Files
SnapOtter/apps/web/src/components/common/before-after-slider.tsx
T

167 lines
5.6 KiB
TypeScript

import { type PointerEvent, useCallback, useEffect, useRef, useState } from "react";
interface BeforeAfterSliderProps {
/** URL or data URL of original image. */
beforeSrc: string;
/** URL or data URL of processed image. */
afterSrc: string;
/** Original file size in bytes. */
beforeSize?: number;
/** Processed file size in bytes. */
afterSize?: number;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
/**
* Before/After image comparison slider.
*
* Shows two images overlapping with a draggable vertical divider.
* The "before" image is on the left, "after" on the right.
* Supports mouse and touch interaction via pointer events.
*/
export function BeforeAfterSlider({
beforeSrc,
afterSrc,
beforeSize,
afterSize,
}: BeforeAfterSliderProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState(50); // percentage 0-100
const [isDragging, setIsDragging] = useState(false);
const updatePosition = useCallback((clientX: number) => {
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const x = clientX - rect.left;
const pct = Math.max(0, Math.min(100, (x / rect.width) * 100));
setPosition(pct);
}, []);
const handlePointerDown = useCallback(
(e: PointerEvent) => {
e.preventDefault();
setIsDragging(true);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
updatePosition(e.clientX);
},
[updatePosition],
);
const handlePointerMove = useCallback(
(e: PointerEvent) => {
if (!isDragging) return;
updatePosition(e.clientX);
},
[isDragging, updatePosition],
);
const handlePointerUp = useCallback(() => {
setIsDragging(false);
}, []);
// Prevent default drag behavior on images
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const preventDrag = (e: Event) => e.preventDefault();
container.addEventListener("dragstart", preventDrag);
return () => container.removeEventListener("dragstart", preventDrag);
}, []);
const savingsPercent =
beforeSize && afterSize && beforeSize > 0
? ((1 - afterSize / beforeSize) * 100).toFixed(1)
: null;
return (
<div className="flex flex-col items-center gap-3 w-full max-w-2xl mx-auto">
{/* Slider container */}
<div
ref={containerRef}
className="relative w-full overflow-hidden rounded-lg border border-border select-none touch-none"
style={{ cursor: isDragging ? "ew-resize" : "default" }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{/* Before image (full width, bottom layer) */}
<img src={beforeSrc} alt="Original" className="block w-full h-auto" draggable={false} />
{/* After image (clipped, top layer) */}
<div
className="absolute inset-0 bg-muted/30"
style={{
clipPath: `inset(0 0 0 ${position}%)`,
}}
>
<img
src={afterSrc}
alt="Processed"
className="w-full h-full object-contain"
draggable={false}
/>
</div>
{/* Divider line */}
<div
className="absolute top-0 bottom-0 w-0.5 bg-white/80 pointer-events-none"
style={{ left: `${position}%`, transform: "translateX(-50%)" }}
>
{/* Handle grip */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-white border-2 border-primary shadow-lg flex items-center justify-center pointer-events-none">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="text-primary">
<path
d="M4 3L1 7L4 11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10 3L13 7L10 11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</div>
{/* Labels */}
<div className="absolute top-2 left-2 px-2 py-0.5 rounded bg-black/50 text-white text-xs font-medium pointer-events-none">
Original
</div>
<div className="absolute top-2 right-2 px-2 py-0.5 rounded bg-black/50 text-white text-xs font-medium pointer-events-none">
Processed
</div>
</div>
{/* Size comparison badges */}
{beforeSize != null && afterSize != null && (
<div className="flex items-center gap-4 text-xs">
<span className="px-2 py-1 rounded bg-muted text-muted-foreground">
Original: {formatSize(beforeSize)}
</span>
<span className="px-2 py-1 rounded bg-primary/10 text-primary font-medium">
Processed: {formatSize(afterSize)}
{savingsPercent !== null && Number(savingsPercent) > 0 && (
<span className="ml-1">({savingsPercent}% smaller)</span>
)}
{savingsPercent !== null && Number(savingsPercent) < 0 && (
<span className="ml-1">({Math.abs(Number(savingsPercent))}% larger)</span>
)}
</span>
</div>
)}
</div>
);
}