mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(web): add before/after image comparison slider component
Draggable split-view slider with clip-path reveals original vs processed image. Shows file size badges with savings percentage. Supports pointer events for both mouse and touch. Integrated into tool-page to replace dropzone after processing completes. Updated file-store with originalBlobUrl for comparison. Enhanced dropzone with multi-file count badge and file list display.
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
import { useRef, useState, useCallback, useEffect, type PointerEvent } 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) */}
|
||||
<img
|
||||
src={afterSrc}
|
||||
alt="Processed"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
draggable={false}
|
||||
style={{
|
||||
clipPath: `inset(0 0 0 ${position}%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useCallback, useState, type DragEvent } from "react";
|
||||
import { Upload } from "lucide-react";
|
||||
import { Upload, FileImage } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DropzoneProps {
|
||||
onFiles?: (files: File[]) => void;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
/** Files that have already been dropped (for showing count & list). */
|
||||
currentFiles?: File[];
|
||||
}
|
||||
|
||||
export function Dropzone({ onFiles, accept, multiple = true }: DropzoneProps) {
|
||||
export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] }: DropzoneProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const handleDrag = useCallback((e: DragEvent) => {
|
||||
@@ -41,6 +43,8 @@ export function Dropzone({ onFiles, accept, multiple = true }: DropzoneProps) {
|
||||
input.click();
|
||||
};
|
||||
|
||||
const hasMultipleFiles = currentFiles.length > 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
onDragEnter={handleDrag}
|
||||
@@ -66,6 +70,27 @@ export function Dropzone({ onFiles, accept, multiple = true }: DropzoneProps) {
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Drop files here or click the upload button
|
||||
</p>
|
||||
|
||||
{/* Show file count badge and list when multiple files are dropped */}
|
||||
{hasMultipleFiles && (
|
||||
<div className="flex flex-col items-center gap-2 mt-2">
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium">
|
||||
<FileImage className="h-3.5 w-3.5" />
|
||||
{currentFiles.length} files selected
|
||||
</span>
|
||||
<div className="max-h-32 overflow-y-auto w-full max-w-xs">
|
||||
{currentFiles.map((f, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between text-xs text-muted-foreground px-2 py-0.5"
|
||||
>
|
||||
<span className="truncate">{f.name}</span>
|
||||
<span className="shrink-0 ml-2">{(f.size / 1024).toFixed(0)} KB</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user