import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; import { Button, Modal, Segmented, Tooltip } from "antd"; import { Check, X, ZoomIn, ZoomOut } from "lucide-react"; import { useImageEditorViewport } from "@/components/canvas/use-image-editor-viewport"; import { readImageMeta } from "@/lib/image-utils"; export type CanvasImageCropRect = { x: number; y: number; width: number; height: number; }; type DragMode = "move" | "resize"; type ResizeHandle = "n" | "e" | "s" | "w" | "ne" | "nw" | "se" | "sw"; const handles: ResizeHandle[] = ["nw", "n", "ne", "e", "se", "s", "sw", "w"]; const minSize = 0.06; const defaultCrop = { x: 0.12, y: 0.12, width: 0.76, height: 0.76 }; const ratioOptions = [ { label: "自由", value: "free" }, { label: "固定", value: "fixed" }, { label: "原图", value: "original" }, { label: "1:1", value: "1:1" }, { label: "4:3", value: "4:3" }, { label: "16:9", value: "16:9" }, { label: "9:16", value: "9:16" }, ]; export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (crop: CanvasImageCropRect) => void }) { const [crop, setCrop] = useState(defaultCrop); const [ratioPreset, setRatioPreset] = useState("free"); const [fixedRatio, setFixedRatio] = useState(null); const [image, setImage] = useState<{ width: number; height: number } | null>(null); const dragAbortRef = useRef(null); const viewport = useImageEditorViewport(image, open); const boxRef = viewport.stageRef; const cropSize = image ? { width: Math.max(1, Math.round(crop.width * image.width)), height: Math.max(1, Math.round(crop.height * image.height)) } : null; useEffect(() => { if (open) { setCrop(defaultCrop); setRatioPreset("free"); setFixedRatio(null); } }, [dataUrl, open]); useEffect(() => { if (!open) return; void readImageMeta(dataUrl).then(setImage); }, [dataUrl, open]); useEffect(() => { if (!open) dragAbortRef.current?.abort(); return () => dragAbortRef.current?.abort(); }, [open]); const startDrag = (mode: DragMode, event: ReactPointerEvent, handle?: ResizeHandle) => { const box = boxRef.current?.getBoundingClientRect(); if (!box) return; event.preventDefault(); event.stopPropagation(); dragAbortRef.current?.abort(); const controller = new AbortController(); dragAbortRef.current = controller; const start = { x: event.clientX, y: event.clientY, crop }; const move = (event: PointerEvent) => { const dx = (event.clientX - start.x) / box.width; const dy = (event.clientY - start.y) / box.height; setCrop(mode === "move" ? moveCrop(start.crop, dx, dy) : resizeCrop(start.crop, dx, dy, handle || "se", resolveRatio(ratioPreset, image, fixedRatio), box)); }; const stop = () => controller.abort(); document.addEventListener("pointermove", move, { signal: controller.signal }); document.addEventListener("pointerup", stop, { signal: controller.signal }); document.addEventListener("pointercancel", stop, { signal: controller.signal }); }; return (
startDrag("move", event)}>
{handles.map((handle) => (
裁剪尺寸 {cropSize ? `${cropSize.width} x ${cropSize.height}` : "未知"} 比例 {cropSize ? formatRatio(cropSize.width, cropSize.height) : "未知"} {image ? ( 原图 {image.width} x {image.height} ) : null}
{ const preset = String(value); setRatioPreset(preset); const currentRatio = image ? (crop.width * image.width) / Math.max(1, crop.height * image.height) : null; const nextFixedRatio = preset === "fixed" ? currentRatio : null; setFixedRatio(nextFixedRatio); const ratio = resolveRatio(preset, image, nextFixedRatio); if (ratio && image) setCrop((current) => fitCropToRatio(current, ratio, image)); }} />
); } function CropMask({ crop }: { crop: CanvasImageCropRect }) { return ( <>
); } function moveCrop(crop: CanvasImageCropRect, dx: number, dy: number): CanvasImageCropRect { return { ...crop, x: clamp(crop.x + dx, 0, 1 - crop.width), y: clamp(crop.y + dy, 0, 1 - crop.height) }; } function resizeCrop(crop: CanvasImageCropRect, dx: number, dy: number, handle: ResizeHandle, aspectRatio: number | null, box: DOMRect): CanvasImageCropRect { let next = { ...crop }; if (handle.includes("e")) next.width = crop.width + dx; if (handle.includes("s")) next.height = crop.height + dy; if (handle.includes("w")) { next.x = crop.x + dx; next.width = crop.width - dx; } if (handle.includes("n")) { next.y = crop.y + dy; next.height = crop.height - dy; } if (aspectRatio) { const normalizedRatio = aspectRatio * (box.height / box.width); const horizontalOnly = (handle.includes("e") || handle.includes("w")) && !handle.includes("n") && !handle.includes("s"); const useWidth = horizontalOnly || (handle.length > 1 && Math.abs(dx * box.width) >= Math.abs(dy * box.height)); if (useWidth) next.height = next.width / normalizedRatio; else next.width = next.height * normalizedRatio; if (handle.includes("w")) next.x = crop.x + crop.width - next.width; if (handle.includes("n")) next.y = crop.y + crop.height - next.height; } if (aspectRatio) { const normalizedRatio = aspectRatio * (box.height / box.width); const scaleDown = Math.min(1, 1 / Math.max(next.width, 0.001), 1 / Math.max(next.height, 0.001)); next.width *= scaleDown; next.height *= scaleDown; if (next.width < minSize || next.height < minSize) { const minimumScale = Math.max(minSize / Math.max(next.width, 0.001), minSize / Math.max(next.height, 0.001)); next.width *= minimumScale; next.height *= minimumScale; } next.width = Math.min(next.width, next.height * normalizedRatio); next.height = next.width / normalizedRatio; } else { next.width = clamp(next.width, minSize, 1); next.height = clamp(next.height, minSize, 1); } next.x = clamp(next.x, 0, 1 - next.width); next.y = clamp(next.y, 0, 1 - next.height); return next; } function resolveRatio(preset: string, image: { width: number; height: number } | null, fixedRatio: number | null) { if (preset === "free" || !image) return null; if (preset === "fixed") return fixedRatio; if (preset === "original") return image.width / image.height; const [width, height] = preset.split(":").map(Number); return width > 0 && height > 0 ? width / height : null; } function fitCropToRatio(crop: CanvasImageCropRect, ratio: number, image: { width: number; height: number }): CanvasImageCropRect { const normalizedRatio = ratio * (image.height / image.width); let width = crop.width; let height = width / normalizedRatio; if (height > crop.height) { height = crop.height; width = height * normalizedRatio; } if (width > 1) { width = 1; height = width / normalizedRatio; } if (height > 1) { height = 1; width = height * normalizedRatio; } return { x: clamp(crop.x + (crop.width - width) / 2, 0, 1 - width), y: clamp(crop.y + (crop.height - height) / 2, 0, 1 - height), width, height, }; } function cropStyle(crop: CanvasImageCropRect) { return { left: `${crop.x * 100}%`, top: `${crop.y * 100}%`, width: `${crop.width * 100}%`, height: `${crop.height * 100}%` }; } function handleStyle(handle: ResizeHandle) { const top = handle.includes("n") ? "-6px" : handle.includes("s") ? "calc(100% - 6px)" : "calc(50% - 6px)"; const left = handle.includes("w") ? "-6px" : handle.includes("e") ? "calc(100% - 6px)" : "calc(50% - 6px)"; return { top, left, cursor: `${handle}-resize` }; } function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } function formatRatio(width: number, height: number) { const divisor = gcd(width, height); return `${Math.round(width / divisor)}:${Math.round(height / divisor)}`; } function gcd(a: number, b: number): number { return b ? gcd(b, a % b) : Math.max(1, a); }