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:
ignacionelson
2026-10-03 12:07:26 -03:00
parent ac1093dc8c
commit bbd424a8d1
4 changed files with 198 additions and 5 deletions
+10
View File
@@ -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",
+1
View File
@@ -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>
);
}
+38 -5
View File
@@ -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">