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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user