mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-04 13:33:22 +00:00
Crop the logo from the Branding screen, and restore the original
A Crop button opens the uploaded image with a free-shape box, starting from the last crop; Restore original appears once there is one. The box is sent in the upload's own pixels and the server cuts the file. The image is shown with image-orientation: none, the pixels as the server reads them, since no image here has the exif extension to rotate by an orientation tag. Adds react-image-crop 11.1.2: no dependencies, no install scripts, and its lockfile integrity matches the registry.
This commit is contained in:
Generated
+10
@@ -47,6 +47,7 @@
|
||||
"lucide-react": "^0.475.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-image-crop": "^11.1.2",
|
||||
"recharts": "^3.10.1",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
@@ -7278,6 +7279,15 @@
|
||||
"react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-image-crop": {
|
||||
"version": "11.1.2",
|
||||
"resolved": "https://registry.npmjs.org/react-image-crop/-/react-image-crop-11.1.2.tgz",
|
||||
"integrity": "sha512-+0Pc2fxpwKL4u4oLmdKBw8XSwUceFbXbKEHvFOlsl/MGB1OVNic4uBlAPmEHGXYgoJIq+b63xHbc/aJMG0AVkA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"lucide-react": "^0.475.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-image-crop": "^11.1.2",
|
||||
"recharts": "^3.10.1",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { router } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import ReactCrop, { type PercentCrop } from 'react-image-crop';
|
||||
import 'react-image-crop/dist/ReactCrop.css';
|
||||
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
|
||||
export interface LogoCropBox {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface LogoCropDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The uploaded image, never a previous crop of it. */
|
||||
sourceUrl: string;
|
||||
/** The last crop, in the upload's pixels, or null to start from the whole image. */
|
||||
savedCrop: LogoCropBox | null;
|
||||
}
|
||||
|
||||
const WHOLE: PercentCrop = { unit: '%', x: 0, y: 0, width: 100, height: 100 };
|
||||
|
||||
/**
|
||||
* Draw a box on the uploaded logo and keep only that part of it.
|
||||
*
|
||||
* The box is held in percent while it is being drawn, so it survives the
|
||||
* dialog resizing, and turned into the upload's own pixels only when it is
|
||||
* saved. The server cuts the new file from the upload: nothing is cropped
|
||||
* in the browser.
|
||||
*
|
||||
* `image-orientation: none` shows the stored pixels exactly as the server
|
||||
* reads them. A browser would otherwise rotate a phone photo by its
|
||||
* orientation tag, the server cannot, and the box would land on the wrong
|
||||
* part of the picture.
|
||||
*/
|
||||
export default function LogoCropDialog({ open, onOpenChange, sourceUrl, savedCrop }: LogoCropDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [crop, setCrop] = useState<PercentCrop>(WHOLE);
|
||||
const [natural, setNatural] = useState<{ width: number; height: number } | null>(null);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const onImageLoad = (image: HTMLImageElement) => {
|
||||
const width = image.naturalWidth;
|
||||
const height = image.naturalHeight;
|
||||
|
||||
setNatural({ width, height });
|
||||
setError(undefined);
|
||||
setCrop(
|
||||
savedCrop === null
|
||||
? WHOLE
|
||||
: {
|
||||
unit: '%',
|
||||
x: (savedCrop.x / width) * 100,
|
||||
y: (savedCrop.y / height) * 100,
|
||||
width: (savedCrop.width / width) * 100,
|
||||
height: (savedCrop.height / height) * 100,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const toPixels = (box: PercentCrop, size: { width: number; height: number }): LogoCropBox => {
|
||||
const x = Math.max(0, Math.round((box.x / 100) * size.width));
|
||||
const y = Math.max(0, Math.round((box.y / 100) * size.height));
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width: Math.max(1, Math.min(size.width - x, Math.round((box.width / 100) * size.width))),
|
||||
height: Math.max(1, Math.min(size.height - y, Math.round((box.height / 100) * size.height))),
|
||||
};
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
if (natural === null || crop.width === 0 || crop.height === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
router.patch(
|
||||
route('branding.logo.crop'),
|
||||
{ ...toPixels(crop, natural) },
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => onOpenChange(false),
|
||||
onError: (errors) => setError(errors.logo ?? errors.width ?? errors.x ?? Object.values(errors)[0]),
|
||||
onFinish: () => setSaving(false),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Crop logo')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
'Drag the box and its corners to choose the part of the image to show. The uploaded image is kept, so you can change this later.',
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="bg-muted/40 flex max-h-[60vh] justify-center overflow-auto rounded border p-2">
|
||||
<ReactCrop crop={crop} onChange={(_, percent) => setCrop(percent)} keepSelection minWidth={8} minHeight={8}>
|
||||
<img
|
||||
src={sourceUrl}
|
||||
alt=""
|
||||
onLoad={(e) => onImageLoad(e.currentTarget)}
|
||||
style={{ imageOrientation: 'none' }}
|
||||
className="max-h-[56vh] max-w-full"
|
||||
/>
|
||||
</ReactCrop>
|
||||
</div>
|
||||
|
||||
{natural !== null && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{(() => {
|
||||
const box = toPixels(crop, natural);
|
||||
|
||||
return t(':width × :height pixels', { width: String(box.width), height: String(box.height) });
|
||||
})()}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<InputError message={error} />
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCrop(WHOLE)} disabled={saving}>
|
||||
{t('Select all')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={save} disabled={saving || natural === null}>
|
||||
{t('Save crop')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { type BreadcrumbItem, type SharedData } from '@/types';
|
||||
import { Head, useForm, usePage } from '@inertiajs/react';
|
||||
import { FormEventHandler, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import LogoCropDialog, { type LogoCropBox } from '@/components/branding/logo-crop-dialog';
|
||||
import Heading from '@/components/heading';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -22,6 +23,9 @@ interface Watermark {
|
||||
|
||||
interface BrandingEditProps {
|
||||
logo_url: string | null;
|
||||
logo_source_url: string | null;
|
||||
logo_crop: LogoCropBox | null;
|
||||
logo_cropped: boolean;
|
||||
hide_attribution: boolean;
|
||||
show_site_name: boolean;
|
||||
watermark: Watermark;
|
||||
@@ -30,7 +34,7 @@ interface BrandingEditProps {
|
||||
|
||||
type Tab = 'logo' | 'watermark' | 'attribution';
|
||||
|
||||
export default function BrandingEdit({ logo_url, hide_attribution, show_site_name, watermark, watermark_positions }: BrandingEditProps) {
|
||||
export default function BrandingEdit({ logo_url, logo_source_url, logo_crop, logo_cropped, hide_attribution, show_site_name, watermark, watermark_positions }: BrandingEditProps) {
|
||||
const { t } = useTranslation();
|
||||
const { capabilities } = usePage<SharedData>().props;
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -135,6 +139,14 @@ export default function BrandingEdit({ logo_url, hide_attribution, show_site_nam
|
||||
removeForm.delete(route('branding.destroy'), { preserveScroll: true, preserveState: true });
|
||||
};
|
||||
|
||||
// Cropping is optional: an upload is used whole until somebody crops it.
|
||||
const [cropping, setCropping] = useState(false);
|
||||
const restoreForm = useForm({});
|
||||
|
||||
const restore = () => {
|
||||
restoreForm.delete(route('branding.logo.restore'), { preserveScroll: true, preserveState: true });
|
||||
};
|
||||
|
||||
const submitWatermark: FormEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
watermarkForm.post(route('branding.watermark.update'), {
|
||||
@@ -212,10 +224,31 @@ export default function BrandingEdit({ logo_url, hide_attribution, show_site_nam
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{logo_url && (
|
||||
<Button variant="outline" onClick={remove} disabled={removeForm.processing}>
|
||||
{t('Remove logo')}
|
||||
</Button>
|
||||
{logo_url && logo_source_url && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" onClick={() => setCropping(true)}>
|
||||
{t('Crop')}
|
||||
</Button>
|
||||
{logo_cropped && (
|
||||
<Button variant="outline" onClick={restore} disabled={restoreForm.processing}>
|
||||
{t('Restore original')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={remove} disabled={removeForm.processing}>
|
||||
{t('Remove logo')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logo_source_url && (
|
||||
// Keyed by the upload so a new logo opens on a fresh box.
|
||||
<LogoCropDialog
|
||||
key={logo_source_url}
|
||||
open={cropping}
|
||||
onOpenChange={setCropping}
|
||||
sourceUrl={logo_source_url}
|
||||
savedCrop={logo_crop}
|
||||
/>
|
||||
)}
|
||||
|
||||
<form onSubmit={submitSiteName} className="space-y-4 border-t pt-6">
|
||||
|
||||
Reference in New Issue
Block a user