mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
170 lines
5.5 KiB
TypeScript
170 lines
5.5 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/src/components/ui/dialog";
|
|
import { Button } from "@/src/components/ui/button";
|
|
import { Slider } from "@/src/components/ui/slider";
|
|
|
|
const CANVAS_SIZE = 320;
|
|
const OUTPUT_SIZE = 512;
|
|
|
|
interface LogoCropDialogProps {
|
|
file: File | null;
|
|
onCancel: () => void;
|
|
onConfirm: (croppedFile: File) => void;
|
|
}
|
|
|
|
/**
|
|
* Einfacher Zoom/Pan-Crop direkt per Canvas, ohne zusätzliche Dependency -
|
|
* analog zum bestehenden Canvas-Ansatz in downsizeImage. Schneidet immer
|
|
* quadratisch zu (Logos werden überall als Quadrat/Kreis dargestellt: Header,
|
|
* Rechnung, öffentliche Seite), damit kein gestrecktes oder schief
|
|
* zugeschnittenes Logo entstehen kann.
|
|
*/
|
|
const LogoCropDialog = ({ file, onCancel, onConfirm }: LogoCropDialogProps) => {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const imageRef = useRef<HTMLImageElement | null>(null);
|
|
const [zoom, setZoom] = useState(1);
|
|
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
|
const dragState = useRef<{
|
|
startX: number;
|
|
startY: number;
|
|
origin: { x: number; y: number };
|
|
} | null>(null);
|
|
|
|
const draw = useCallback(
|
|
(currentZoom: number, currentOffset: { x: number; y: number }, img?: HTMLImageElement) => {
|
|
const canvas = canvasRef.current;
|
|
const image = img ?? imageRef.current;
|
|
if (!canvas || !image) return;
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return;
|
|
|
|
canvas.width = CANVAS_SIZE;
|
|
canvas.height = CANVAS_SIZE;
|
|
ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
|
|
ctx.fillStyle = "#f5f5f5";
|
|
ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
|
|
|
|
const baseScale = Math.max(CANVAS_SIZE / image.width, CANVAS_SIZE / image.height);
|
|
const scale = baseScale * currentZoom;
|
|
const drawWidth = image.width * scale;
|
|
const drawHeight = image.height * scale;
|
|
const x = (CANVAS_SIZE - drawWidth) / 2 + currentOffset.x;
|
|
const y = (CANVAS_SIZE - drawHeight) / 2 + currentOffset.y;
|
|
|
|
ctx.drawImage(image, x, y, drawWidth, drawHeight);
|
|
},
|
|
[]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!file) return;
|
|
const url = URL.createObjectURL(file);
|
|
const img = new window.Image();
|
|
img.onload = () => {
|
|
imageRef.current = img;
|
|
setZoom(1);
|
|
setOffset({ x: 0, y: 0 });
|
|
draw(1, { x: 0, y: 0 }, img);
|
|
};
|
|
img.src = url;
|
|
return () => URL.revokeObjectURL(url);
|
|
}, [file, draw]);
|
|
|
|
const handleZoomChange = ([value]: number[]) => {
|
|
setZoom(value);
|
|
draw(value, offset);
|
|
};
|
|
|
|
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
|
dragState.current = { startX: e.clientX, startY: e.clientY, origin: offset };
|
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
};
|
|
|
|
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
|
if (!dragState.current) return;
|
|
const dx = e.clientX - dragState.current.startX;
|
|
const dy = e.clientY - dragState.current.startY;
|
|
const next = { x: dragState.current.origin.x + dx, y: dragState.current.origin.y + dy };
|
|
setOffset(next);
|
|
draw(zoom, next);
|
|
};
|
|
|
|
const handlePointerUp = () => {
|
|
dragState.current = null;
|
|
};
|
|
|
|
const handleConfirm = () => {
|
|
const image = imageRef.current;
|
|
if (!image) return;
|
|
|
|
const outputCanvas = document.createElement("canvas");
|
|
outputCanvas.width = OUTPUT_SIZE;
|
|
outputCanvas.height = OUTPUT_SIZE;
|
|
const ctx = outputCanvas.getContext("2d");
|
|
if (!ctx) return;
|
|
|
|
const outputScale = OUTPUT_SIZE / CANVAS_SIZE;
|
|
const baseScale = Math.max(CANVAS_SIZE / image.width, CANVAS_SIZE / image.height);
|
|
const scale = baseScale * zoom * outputScale;
|
|
const drawWidth = image.width * scale;
|
|
const drawHeight = image.height * scale;
|
|
const x = (OUTPUT_SIZE - drawWidth) / 2 + offset.x * outputScale;
|
|
const y = (OUTPUT_SIZE - drawHeight) / 2 + offset.y * outputScale;
|
|
|
|
ctx.drawImage(image, x, y, drawWidth, drawHeight);
|
|
|
|
outputCanvas.toBlob((blob) => {
|
|
if (!blob) return;
|
|
onConfirm(new File([blob], "logo.png", { type: "image/png" }));
|
|
}, "image/png");
|
|
};
|
|
|
|
return (
|
|
<Dialog open={!!file} onOpenChange={(open) => !open && onCancel()}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Logo zuschneiden</DialogTitle>
|
|
<DialogDescription>
|
|
Ziehe das Bild, um den Ausschnitt zu verschieben, und nutze den Regler zum Zoomen.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex justify-center">
|
|
<canvas
|
|
ref={canvasRef}
|
|
width={CANVAS_SIZE}
|
|
height={CANVAS_SIZE}
|
|
className="rounded-lg border border-input touch-none cursor-grab active:cursor-grabbing"
|
|
onPointerDown={handlePointerDown}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={handlePointerUp}
|
|
onPointerLeave={handlePointerUp}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 px-1">
|
|
<span className="text-xs text-muted-foreground shrink-0">Zoom</span>
|
|
<Slider min={1} max={3} step={0.05} value={[zoom]} onValueChange={handleZoomChange} />
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={onCancel}>
|
|
Abbrechen
|
|
</Button>
|
|
<Button onClick={handleConfirm}>Übernehmen</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
export default LogoCropDialog;
|