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 { useCallback, useState, type DragEvent } from "react";
|
||||||
import { Upload } from "lucide-react";
|
import { Upload, FileImage } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface DropzoneProps {
|
interface DropzoneProps {
|
||||||
onFiles?: (files: File[]) => void;
|
onFiles?: (files: File[]) => void;
|
||||||
accept?: string;
|
accept?: string;
|
||||||
multiple?: boolean;
|
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 [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
const handleDrag = useCallback((e: DragEvent) => {
|
const handleDrag = useCallback((e: DragEvent) => {
|
||||||
@@ -41,6 +43,8 @@ export function Dropzone({ onFiles, accept, multiple = true }: DropzoneProps) {
|
|||||||
input.click();
|
input.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasMultipleFiles = currentFiles.length > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onDragEnter={handleDrag}
|
onDragEnter={handleDrag}
|
||||||
@@ -66,6 +70,27 @@ export function Dropzone({ onFiles, accept, multiple = true }: DropzoneProps) {
|
|||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Drop files here or click the upload button
|
Drop files here or click the upload button
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useMemo, useCallback } from "react";
|
|||||||
import { TOOLS } from "@stirling-image/shared";
|
import { TOOLS } from "@stirling-image/shared";
|
||||||
import { AppLayout } from "@/components/layout/app-layout";
|
import { AppLayout } from "@/components/layout/app-layout";
|
||||||
import { Dropzone } from "@/components/common/dropzone";
|
import { Dropzone } from "@/components/common/dropzone";
|
||||||
|
import { BeforeAfterSlider } from "@/components/common/before-after-slider";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
import { ResizeSettings } from "@/components/tools/resize-settings";
|
import { ResizeSettings } from "@/components/tools/resize-settings";
|
||||||
import { CropSettings } from "@/components/tools/crop-settings";
|
import { CropSettings } from "@/components/tools/crop-settings";
|
||||||
@@ -39,7 +40,7 @@ function ToolSettingsPanel({ toolId }: { toolId: string }) {
|
|||||||
export function ToolPage() {
|
export function ToolPage() {
|
||||||
const { toolId } = useParams<{ toolId: string }>();
|
const { toolId } = useParams<{ toolId: string }>();
|
||||||
const tool = useMemo(() => TOOLS.find((t) => t.id === toolId), [toolId]);
|
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(
|
const handleFiles = useCallback(
|
||||||
(newFiles: File[]) => {
|
(newFiles: File[]) => {
|
||||||
@@ -128,11 +129,21 @@ export function ToolPage() {
|
|||||||
|
|
||||||
{/* Dropzone / Preview */}
|
{/* Dropzone / Preview */}
|
||||||
<div className="flex-1 flex items-center justify-center p-6">
|
<div className="flex-1 flex items-center justify-center p-6">
|
||||||
<Dropzone
|
{processedUrl && originalBlobUrl ? (
|
||||||
onFiles={handleFiles}
|
<BeforeAfterSlider
|
||||||
accept="image/*"
|
beforeSrc={originalBlobUrl}
|
||||||
multiple={false}
|
afterSrc={processedUrl}
|
||||||
/>
|
beforeSize={originalSize ?? undefined}
|
||||||
|
afterSize={processedSize ?? undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Dropzone
|
||||||
|
onFiles={handleFiles}
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
currentFiles={files}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ interface FileState {
|
|||||||
files: File[];
|
files: File[];
|
||||||
jobId: string | null;
|
jobId: string | null;
|
||||||
processedUrl: string | null;
|
processedUrl: string | null;
|
||||||
|
/** Blob URL for the original image (for before/after comparison). */
|
||||||
|
originalBlobUrl: string | null;
|
||||||
processing: boolean;
|
processing: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
originalSize: number | null;
|
originalSize: number | null;
|
||||||
@@ -11,35 +13,49 @@ interface FileState {
|
|||||||
setFiles: (files: File[]) => void;
|
setFiles: (files: File[]) => void;
|
||||||
setJobId: (id: string) => void;
|
setJobId: (id: string) => void;
|
||||||
setProcessedUrl: (url: string | null) => void;
|
setProcessedUrl: (url: string | null) => void;
|
||||||
|
setOriginalBlobUrl: (url: string | null) => void;
|
||||||
setProcessing: (v: boolean) => void;
|
setProcessing: (v: boolean) => void;
|
||||||
setError: (e: string | null) => void;
|
setError: (e: string | null) => void;
|
||||||
setSizes: (original: number, processed: number) => void;
|
setSizes: (original: number, processed: number) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useFileStore = create<FileState>((set) => ({
|
export const useFileStore = create<FileState>((set, get) => ({
|
||||||
files: [],
|
files: [],
|
||||||
jobId: null,
|
jobId: null,
|
||||||
processedUrl: null,
|
processedUrl: null,
|
||||||
|
originalBlobUrl: null,
|
||||||
processing: false,
|
processing: false,
|
||||||
error: null,
|
error: null,
|
||||||
originalSize: null,
|
originalSize: null,
|
||||||
processedSize: 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 }),
|
setJobId: (id) => set({ jobId: id }),
|
||||||
setProcessedUrl: (url) => set({ processedUrl: url }),
|
setProcessedUrl: (url) => set({ processedUrl: url }),
|
||||||
|
setOriginalBlobUrl: (url) => set({ originalBlobUrl: url }),
|
||||||
setProcessing: (v) => set({ processing: v }),
|
setProcessing: (v) => set({ processing: v }),
|
||||||
setError: (e) => set({ error: e, processing: false }),
|
setError: (e) => set({ error: e, processing: false }),
|
||||||
setSizes: (original, processed) =>
|
setSizes: (original, processed) =>
|
||||||
set({ originalSize: original, processedSize: processed }),
|
set({ originalSize: original, processedSize: processed }),
|
||||||
reset: () =>
|
reset: () => {
|
||||||
|
const old = get().originalBlobUrl;
|
||||||
|
if (old) URL.revokeObjectURL(old);
|
||||||
set({
|
set({
|
||||||
files: [],
|
files: [],
|
||||||
jobId: null,
|
jobId: null,
|
||||||
processedUrl: null,
|
processedUrl: null,
|
||||||
|
originalBlobUrl: null,
|
||||||
processing: false,
|
processing: false,
|
||||||
error: null,
|
error: null,
|
||||||
originalSize: null,
|
originalSize: null,
|
||||||
processedSize: null,
|
processedSize: null,
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user