mono/packages/ui/src/components/ImageEditor.tsx

501 lines
22 KiB
TypeScript

import React, { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { RotateCw, RotateCcw, Crop as CropIcon, Download, X, Check, Save, Image as ImageIcon, Sliders } from 'lucide-react';
import { updatePicture } from '@/modules/posts/client-pictures';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/components/ui/use-toast';
import { uploadImage } from '@/lib/uploadUtils';
import { Slider } from '@/components/ui/slider';
interface ImageEditorProps {
imageUrl: string;
pictureId: string;
onSave?: (newUrl: string) => void;
onClose?: () => void;
}
export const ImageEditor = ({ imageUrl, pictureId, onSave, onClose }: ImageEditorProps) => {
const [previewUrl, setPreviewUrl] = useState<string | null>(imageUrl);
const [loading, setLoading] = useState(false);
const { toast } = useToast();
const { user } = useAuth();
const imageRef = useRef<HTMLImageElement>(null);
// Edit State
const [mode, setMode] = useState<'view' | 'crop' | 'adjust'>('view');
const [brightness, setBrightness] = useState(1);
const [contrast, setContrast] = useState(1);
// Reset adjustments when image changes or mode resets (optional, depends on UX)
// For now, these are "pending" adjustments locally until applied.
useEffect(() => {
setPreviewUrl(imageUrl);
// Reset local edits on new image
setBrightness(1);
setContrast(1);
setMode('view');
}, [imageUrl]);
const executeOperation = async (ops: any[]) => {
if (!previewUrl) return;
setLoading(true);
try {
const response = await fetch(previewUrl);
const blob = await response.blob();
const file = new File([blob], "source.jpg", { type: blob.type });
const formData = new FormData();
formData.append('file', file);
formData.append('operations', JSON.stringify(ops));
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL;
const res = await fetch(`${serverUrl}/api/images/transform`, {
method: 'POST',
body: formData
});
if (!res.ok) throw new Error(await res.text());
const resultBlob = await res.blob();
const resultUrl = URL.createObjectURL(resultBlob);
setPreviewUrl(resultUrl);
toast({ title: "Applied" });
} catch (err: any) {
toast({
title: "Operation failed",
description: err.message,
variant: "destructive"
});
} finally {
setLoading(false);
}
};
const handleRotate = (angle: number) => {
executeOperation([{ type: 'rotate', angle }]);
};
const handleSave = async () => {
if (!previewUrl || !pictureId || !user) return;
setLoading(true);
try {
const response = await fetch(previewUrl);
const blob = await response.blob();
const file = new File([blob], "edited.jpg", { type: "image/jpeg" });
const { publicUrl } = await uploadImage(file, user.id);
await updatePicture(pictureId, {
image_url: publicUrl,
updated_at: new Date().toISOString()
});
toast({ title: "Image Saved", description: "Source image updated successfully." });
onSave?.(publicUrl);
} catch (err: any) {
console.error(err);
toast({
title: "Save failed",
description: err.message,
variant: "destructive"
});
} finally {
setLoading(false);
}
};
// --- Visual Cropper State ---
const [cropRect, setCropRect] = useState<{ x: number, y: number, w: number, h: number } | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState<{ x: number, y: number } | null>(null);
const [dragAction, setDragAction] = useState<'move' | 'nw' | 'ne' | 'sw' | 'se' | 'create' | null>(null);
const [aspectRatio, setAspectRatio] = useState<number | null>(null);
const PRESETS = [
{ label: 'Free', value: null },
{ label: 'Original', value: 'original' },
{ label: 'Square', value: 1 },
{ label: '9:16', value: 9 / 16 },
{ label: '16:9', value: 16 / 9 },
{ label: '4:5', value: 4 / 5 },
{ label: '5:4', value: 5 / 4 },
{ label: '3:4', value: 3 / 4 },
{ label: '4:3', value: 4 / 3 },
];
useEffect(() => {
if (mode === 'crop' && imageRef.current) {
const { width, height } = imageRef.current.getBoundingClientRect();
const w = width * 0.8;
const h = height * 0.8;
setCropRect({
x: (width - w) / 2,
y: (height - h) / 2,
w, h
});
}
}, [mode]);
const getPointerPos = (e: React.PointerEvent | PointerEvent) => {
if (!imageRef.current) return { x: 0, y: 0 };
const rect = imageRef.current.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
};
const handlePointerDown = (e: React.PointerEvent) => {
if (mode !== 'crop' || !cropRect) return;
e.preventDefault();
e.stopPropagation();
// Capture pointer to ensure we get move/up events even if we leave the div
(e.target as Element).setPointerCapture(e.pointerId);
const { x, y } = getPointerPos(e);
const handleSize = 40; // Increased hit area for easier touch
const r = cropRect;
if (Math.abs(x - r.x) < handleSize && Math.abs(y - r.y) < handleSize) setDragAction('nw');
else if (Math.abs(x - (r.x + r.w)) < handleSize && Math.abs(y - r.y) < handleSize) setDragAction('ne');
else if (Math.abs(x - r.x) < handleSize && Math.abs(y - (r.y + r.h)) < handleSize) setDragAction('sw');
else if (Math.abs(x - (r.x + r.w)) < handleSize && Math.abs(y - (r.y + r.h)) < handleSize) setDragAction('se');
else if (x > r.x && x < r.x + r.w && y > r.y && y < r.y + r.h) setDragAction('move');
else setDragAction('create');
setIsDragging(true);
setDragStart({ x, y });
};
const handlePointerMove = (e: React.PointerEvent) => {
if (!isDragging || !dragStart || !cropRect || !imageRef.current) return;
const { x, y } = getPointerPos(e);
const dx = x - dragStart.x;
const dy = y - dragStart.y;
const imgW = imageRef.current.offsetWidth;
const imgH = imageRef.current.offsetHeight;
setCropRect(prev => {
if (!prev) return null;
let next = { ...prev };
if (dragAction === 'move') {
next.x = Math.max(0, Math.min(imgW - next.w, prev.x + dx));
next.y = Math.max(0, Math.min(imgH - next.h, prev.y + dy));
} else {
let newW = next.w;
let newH = next.h;
let newX = next.x;
let newY = next.y;
if (dragAction === 'se') {
newW = Math.max(20, prev.w + dx);
newH = Math.max(20, prev.h + dy);
} else if (dragAction === 'sw') {
newW = prev.w - dx;
newX = Math.min(prev.x + prev.w - 20, prev.x + dx);
newH = Math.max(20, prev.h + dy);
} else if (dragAction === 'ne') {
newW = Math.max(20, prev.w + dx);
newH = prev.h - dy;
newY = Math.min(prev.y + prev.h - 20, prev.y + dy);
} else if (dragAction === 'nw') {
newW = prev.w - dx;
newX = Math.min(prev.x + prev.w - 20, prev.x + dx);
newH = prev.h - dy;
newY = Math.min(prev.y + prev.h - 20, prev.y + dy);
}
if (aspectRatio !== null) {
if (dragAction === 'se' || dragAction === 'sw') {
newH = newW / aspectRatio;
} else {
newW = newH * aspectRatio;
if (dragAction === 'nw') newX = prev.x + prev.w - newW;
}
newH = newW / aspectRatio;
if (dragAction === 'nw') {
newY = prev.y + prev.h - newH;
newX = prev.x + prev.w - newW;
} else if (dragAction === 'ne') {
newY = prev.y + prev.h - newH;
}
}
next.w = Math.max(20, newW);
next.h = Math.max(20, newH);
next.x = newX;
next.y = newY;
}
return next;
});
setDragStart({ x, y });
};
const handlePointerUp = (e: React.PointerEvent) => {
setIsDragging(false);
setDragAction(null);
(e.target as Element).releasePointerCapture(e.pointerId);
};
const applyCrop = () => {
if (!cropRect || !imageRef.current) return;
const displayRect = imageRef.current.getBoundingClientRect();
const scaleX = imageRef.current.naturalWidth / displayRect.width;
const scaleY = imageRef.current.naturalHeight / displayRect.height;
let realX = Math.round(cropRect.x * scaleX);
let realY = Math.round(cropRect.y * scaleY);
let realW = Math.round(cropRect.w * scaleX);
let realH = Math.round(cropRect.h * scaleY);
realX = Math.max(0, realX);
realY = Math.max(0, realY);
realW = Math.max(1, Math.min(realW, imageRef.current.naturalWidth - realX));
realH = Math.max(1, Math.min(realH, imageRef.current.naturalHeight - realY));
const realCrop = {
x: realX,
y: realY,
width: realW,
height: realH
};
executeOperation([{ type: 'crop', ...realCrop }]);
setMode('view');
};
const applyAdjust = () => {
// Send adjust op
executeOperation([{
type: 'adjust',
brightness: brightness !== 1 ? brightness : undefined,
contrast: contrast !== 1 ? contrast : undefined
}]);
setMode('view');
// Reset sliders as they are applied to the "base" image now
setBrightness(1);
setContrast(1);
};
const cancelAdjust = () => {
setBrightness(1);
setContrast(1);
setMode('view');
};
const downloadImage = () => {
if (previewUrl) {
const a = document.createElement('a');
a.href = previewUrl;
a.download = 'edited-image.jpg';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
};
return (
<div className="flex flex-col h-full w-full bg-background relative overflow-hidden">
{/* Close Button if provided */}
{onClose && (
<div className="absolute top-4 right-4 z-20">
<Button variant="ghost" size="icon" onClick={onClose} className="rounded-full bg-background/50 hover:bg-background/80">
<X className="w-6 h-6" />
</Button>
</div>
)}
<div
className="flex-1 flex items-center justify-center p-8 bg-zinc-950/50 relative select-none touch-none"
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
>
{previewUrl ? (
<div className="relative group">
<img
ref={imageRef}
src={previewUrl}
alt="Work"
className="max-h-[80vh] max-w-full object-contain pointer-events-none transition-all duration-200"
style={{
filter: mode === 'adjust' ? `brightness(${brightness}) contrast(${contrast})` : undefined
}}
draggable={false}
onDragStart={(e) => e.preventDefault()}
/>
{mode === 'crop' && cropRect && (
<div
className="absolute border-2 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.5)] cursor-move"
style={{
left: cropRect.x,
top: cropRect.y,
width: cropRect.w,
height: cropRect.h,
touchAction: 'none'
}}
onPointerDown={handlePointerDown}
>
<div className="absolute -top-3 -left-3 w-6 h-6 bg-white border border-black cursor-nw-resize rounded-full shadow-sm" />
<div className="absolute -top-3 -right-3 w-6 h-6 bg-white border border-black cursor-ne-resize rounded-full shadow-sm" />
<div className="absolute -bottom-3 -left-3 w-6 h-6 bg-white border border-black cursor-sw-resize rounded-full shadow-sm" />
<div className="absolute -bottom-3 -right-3 w-6 h-6 bg-white border border-black cursor-se-resize rounded-full shadow-sm" />
<div className="absolute top-1/3 left-0 right-0 h-px bg-white/30 pointer-events-none" />
<div className="absolute top-2/3 left-0 right-0 h-px bg-white/30 pointer-events-none" />
<div className="absolute left-1/3 top-0 bottom-0 w-px bg-white/30 pointer-events-none" />
<div className="absolute left-2/3 top-0 bottom-0 w-px bg-white/30 pointer-events-none" />
</div>
)}
</div>
) : (
<div className="text-muted-foreground flex flex-col items-center">
<ImageIcon className="w-16 h-16 mb-4 opacity-20" />
<p>No image loaded</p>
</div>
)}
</div>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-20 flex flex-col gap-2 items-center w-full max-w-2xl px-4 pointer-events-none">
{/* Controls Container - enable pointer events for children */}
<div className="pointer-events-auto flex flex-col gap-2 items-center w-full">
{mode === 'crop' && (
<div className="bg-background/80 backdrop-blur-md border rounded-full shadow-xl px-2 py-1 flex items-center gap-1 overflow-x-auto max-w-full scrollbar-hide">
{PRESETS.map(p => (
<Button
key={p.label}
variant={aspectRatio === (p.value === 'original' ? (imageRef.current ? imageRef.current.width / imageRef.current.height : null) : p.value) ? "secondary" : "ghost"}
size="sm"
onClick={() => {
if (p.value === 'original' && imageRef.current) {
setAspectRatio(imageRef.current.width / imageRef.current.height);
} else {
setAspectRatio(p.value as number | null);
}
if (cropRect && imageRef.current) {
const ratio = p.value === 'original' ? (imageRef.current.width / imageRef.current.height) : p.value as number;
if (ratio) {
const newH = cropRect.w / ratio;
setCropRect({ ...cropRect, h: newH });
}
}
}}
className="text-xs h-8 px-2 whitespace-nowrap"
>
{p.label}
</Button>
))}
</div>
)}
{mode === 'adjust' && (
<div className="bg-background/80 backdrop-blur-md border rounded-xl shadow-xl px-4 py-4 w-full max-w-sm space-y-4">
<div className="space-y-2">
<div className="flex justify-between text-xs font-medium">
<span>Brightness</span>
<span>{Math.round(brightness * 100)}%</span>
</div>
<Slider
value={[brightness]}
min={0.5}
max={1.5}
step={0.05}
onValueChange={([v]) => setBrightness(v)}
/>
</div>
<div className="space-y-2">
<div className="flex justify-between text-xs font-medium">
<span>Contrast</span>
<span>{Math.round(contrast * 100)}%</span>
</div>
<Slider
value={[contrast]}
min={0.5}
max={1.5}
step={0.05}
onValueChange={([v]) => setContrast(v)}
/>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button variant="ghost" size="sm" onClick={cancelAdjust}>Cancel</Button>
<Button size="sm" onClick={applyAdjust}>Apply</Button>
</div>
</div>
)}
<div className="bg-background/80 backdrop-blur-md border rounded-full shadow-xl px-4 py-2 flex items-center gap-2">
{mode === 'view' ? (
<>
<Button variant="ghost" size="icon" onClick={() => setMode('crop')} title="Crop">
<CropIcon className="w-5 h-5" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setMode('adjust')} title="Adjust">
<Sliders className="w-5 h-5" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleRotate(-90)} title="Rotate CCW">
<RotateCcw className="w-5 h-5" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleRotate(90)} title="Rotate CW">
<RotateCw className="w-5 h-5" />
</Button>
<div className="w-px h-6 bg-border mx-1" />
<Button variant="ghost" size="icon" onClick={handleSave} disabled={!pictureId} title="Save (Overwrite)">
<Save className="w-5 h-5" />
</Button>
<Button variant="ghost" size="icon" onClick={downloadImage} title="Download">
<Download className="w-5 h-5" />
</Button>
</>
) : mode === 'crop' ? (
<>
<Button variant="ghost" size="icon" onClick={() => setMode('view')} className="text-muted-foreground">
<X className="w-5 h-5" />
</Button>
<span className="text-sm font-medium px-2">Crop Mode</span>
<Button variant="default" size="icon" className="rounded-full" onClick={applyCrop}>
<Check className="w-5 h-5" />
</Button>
</>
) : (
// Adjust Mode Toolbar
<>
<Button variant="ghost" size="icon" onClick={cancelAdjust} className="text-muted-foreground">
<X className="w-5 h-5" />
</Button>
<span className="text-sm font-medium px-2">Adjust Mode</span>
<Button variant="default" size="icon" className="rounded-full" onClick={applyAdjust}>
<Check className="w-5 h-5" />
</Button>
</>
)}
</div>
</div>
</div>
{
loading && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center z-50 backdrop-blur-sm">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
)
}
</div >
);
};