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:
Siddharth Kumar Sah
2026-03-22 04:03:30 +08:00
parent 8964bc5cd8
commit 1a22522349
4 changed files with 242 additions and 12 deletions
@@ -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>
);
}
+27 -2
View File
@@ -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>
);
+17 -6
View File
@@ -3,6 +3,7 @@ import { useMemo, useCallback } from "react";
import { TOOLS } from "@stirling-image/shared";
import { AppLayout } from "@/components/layout/app-layout";
import { Dropzone } from "@/components/common/dropzone";
import { BeforeAfterSlider } from "@/components/common/before-after-slider";
import { useFileStore } from "@/stores/file-store";
import { ResizeSettings } from "@/components/tools/resize-settings";
import { CropSettings } from "@/components/tools/crop-settings";
@@ -39,7 +40,7 @@ function ToolSettingsPanel({ toolId }: { toolId: string }) {
export function ToolPage() {
const { toolId } = useParams<{ toolId: string }>();
const tool = useMemo(() => TOOLS.find((t) => t.id === toolId), [toolId]);
const { files, setFiles, reset } = useFileStore();
const { files, setFiles, reset, processedUrl, originalBlobUrl, originalSize, processedSize } = useFileStore();
const handleFiles = useCallback(
(newFiles: File[]) => {
@@ -128,11 +129,21 @@ export function ToolPage() {
{/* Dropzone / Preview */}
<div className="flex-1 flex items-center justify-center p-6">
<Dropzone
onFiles={handleFiles}
accept="image/*"
multiple={false}
/>
{processedUrl && originalBlobUrl ? (
<BeforeAfterSlider
beforeSrc={originalBlobUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
) : (
<Dropzone
onFiles={handleFiles}
accept="image/*"
multiple
currentFiles={files}
/>
)}
</div>
</div>
</AppLayout>
+20 -4
View File
@@ -4,6 +4,8 @@ interface FileState {
files: File[];
jobId: string | null;
processedUrl: string | null;
/** Blob URL for the original image (for before/after comparison). */
originalBlobUrl: string | null;
processing: boolean;
error: string | null;
originalSize: number | null;
@@ -11,35 +13,49 @@ interface FileState {
setFiles: (files: File[]) => void;
setJobId: (id: string) => void;
setProcessedUrl: (url: string | null) => void;
setOriginalBlobUrl: (url: string | null) => void;
setProcessing: (v: boolean) => void;
setError: (e: string | null) => void;
setSizes: (original: number, processed: number) => void;
reset: () => void;
}
export const useFileStore = create<FileState>((set) => ({
export const useFileStore = create<FileState>((set, get) => ({
files: [],
jobId: null,
processedUrl: null,
originalBlobUrl: null,
processing: false,
error: null,
originalSize: null,
processedSize: null,
setFiles: (files) => set({ files, error: null }),
setFiles: (files) => {
// Revoke old blob URL if any
const old = get().originalBlobUrl;
if (old) URL.revokeObjectURL(old);
// Create a blob URL for the first file for before/after preview
const blobUrl = files.length > 0 ? URL.createObjectURL(files[0]) : null;
set({ files, error: null, originalBlobUrl: blobUrl });
},
setJobId: (id) => set({ jobId: id }),
setProcessedUrl: (url) => set({ processedUrl: url }),
setOriginalBlobUrl: (url) => set({ originalBlobUrl: url }),
setProcessing: (v) => set({ processing: v }),
setError: (e) => set({ error: e, processing: false }),
setSizes: (original, processed) =>
set({ originalSize: original, processedSize: processed }),
reset: () =>
reset: () => {
const old = get().originalBlobUrl;
if (old) URL.revokeObjectURL(old);
set({
files: [],
jobId: null,
processedUrl: null,
originalBlobUrl: null,
processing: false,
error: null,
originalSize: null,
processedSize: null,
}),
});
},
}));