From acbb28856bba8709746637c8aefaf0c38af756c2 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Thu, 6 Aug 2026 08:51:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20=E5=9B=BE=E7=89=87=E8=A3=81?= =?UTF-8?q?=E5=89=AA=20-=20=E5=A2=9E=E5=8A=A0=E5=B8=B8=E7=94=A8=E6=AF=94?= =?UTF-8?q?=E4=BE=8B=E4=B8=8E=E5=87=86=E7=A1=AE=E6=AF=94=E4=BE=8B=E9=94=81?= =?UTF-8?q?=E5=AE=9A=20(#153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #153 --- .../canvas/canvas-node-crop-dialog.tsx | 135 ++++++++++++++++-- 1 file changed, 120 insertions(+), 15 deletions(-) diff --git a/web/src/components/canvas/canvas-node-crop-dialog.tsx b/web/src/components/canvas/canvas-node-crop-dialog.tsx index 8f428348a..c9c52847b 100644 --- a/web/src/components/canvas/canvas-node-crop-dialog.tsx +++ b/web/src/components/canvas/canvas-node-crop-dialog.tsx @@ -13,25 +13,47 @@ export type CanvasImageCropRect = { type DragMode = "move" | "resize"; type ResizeHandle = "n" | "e" | "s" | "w" | "ne" | "nw" | "se" | "sw"; +type CropAspectPreset = "original" | "1:1" | "3:2" | "2:3" | "4:3" | "3:4" | "16:9" | "9:16"; 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 cropAspectPresets: Array<{ value: CropAspectPreset; label: string; ratio?: number }> = [ + { value: "original", label: "原图" }, + { value: "1:1", label: "1:1", ratio: 1 }, + { value: "3:2", label: "3:2", ratio: 3 / 2 }, + { value: "2:3", label: "2:3", ratio: 2 / 3 }, + { value: "4:3", label: "4:3", ratio: 4 / 3 }, + { value: "3:4", label: "3:4", ratio: 3 / 4 }, + { value: "16:9", label: "16:9", ratio: 16 / 9 }, + { value: "9:16", label: "9:16", ratio: 9 / 16 }, +]; export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (crop: CanvasImageCropRect) => void }) { const boxRef = useRef(null); const [crop, setCrop] = useState(defaultCrop); - const [locked, setLocked] = useState(false); + const [lockedRatio, setLockedRatio] = useState(null); + const [activePreset, setActivePreset] = useState(null); const [image, setImage] = useState<{ width: number; height: number } | null>(null); 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); + if (!open) return; + setCrop(defaultCrop); + setLockedRatio(null); + setActivePreset(null); }, [dataUrl, open]); useEffect(() => { if (!open) return; - void readImageMeta(dataUrl).then(setImage); + let cancelled = false; + setImage(null); + void readImageMeta(dataUrl).then((meta) => { + if (!cancelled) setImage(meta); + }); + return () => { + cancelled = true; + }; }, [dataUrl, open]); const startDrag = (mode: DragMode, event: ReactPointerEvent, handle?: ResizeHandle) => { @@ -43,7 +65,7 @@ export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { da 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", locked, box)); + setCrop(mode === "move" ? moveCrop(start.crop, dx, dy) : resizeCrop(start.crop, dx, dy, handle || "se", lockedRatio, box)); }; const up = () => { document.removeEventListener("pointermove", move); @@ -53,6 +75,31 @@ export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { da document.addEventListener("pointerup", up); }; + const selectAspectPreset = (preset: (typeof cropAspectPresets)[number]) => { + if (!image) return; + const ratio = preset.ratio || image.width / image.height; + setCrop((current) => fitCropToAspect(current, ratio, image)); + setLockedRatio(ratio); + setActivePreset(preset.value); + }; + + const toggleAspectLock = () => { + if (lockedRatio !== null) { + setLockedRatio(null); + setActivePreset(null); + return; + } + if (!image) return; + setLockedRatio((crop.width * image.width) / (crop.height * image.height)); + setActivePreset("custom"); + }; + + const resetCrop = () => { + setCrop(defaultCrop); + setLockedRatio(null); + setActivePreset(null); + }; + return (
@@ -72,6 +119,17 @@ export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { da
+
+
常用比例
+
+ {cropAspectPresets.map((preset) => ( + + ))} +
+
+
裁剪尺寸 {cropSize ? `${cropSize.width} x ${cropSize.height}` : "未知"} @@ -82,13 +140,13 @@ export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { da ) : null}
-
- + @@ -116,7 +174,8 @@ function moveCrop(crop: CanvasImageCropRect, dx: number, dy: number): CanvasImag 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, locked: boolean, box: DOMRect): CanvasImageCropRect { +function resizeCrop(crop: CanvasImageCropRect, dx: number, dy: number, handle: ResizeHandle, lockedRatio: number | null, box: DOMRect): CanvasImageCropRect { + if (lockedRatio !== null) return resizeLockedCrop(crop, dx, dy, handle, lockedRatio, box); let next = { ...crop }; if (handle.includes("e")) next.width = crop.width + dx; if (handle.includes("s")) next.height = crop.height + dy; @@ -128,13 +187,6 @@ function resizeCrop(crop: CanvasImageCropRect, dx: number, dy: number, handle: R next.y = crop.y + dy; next.height = crop.height - dy; } - if (locked) { - const size = Math.max(next.width * box.width, next.height * box.height); - next.width = size / box.width; - next.height = size / box.height; - if (handle.includes("w")) next.x = crop.x + crop.width - next.width; - if (handle.includes("n")) next.y = crop.y + crop.height - next.height; - } next.width = clamp(next.width, minSize, 1); next.height = clamp(next.height, minSize, 1); next.x = clamp(next.x, 0, 1 - next.width); @@ -142,6 +194,59 @@ function resizeCrop(crop: CanvasImageCropRect, dx: number, dy: number, handle: R return next; } +function resizeLockedCrop(crop: CanvasImageCropRect, dx: number, dy: number, handle: ResizeHandle, ratio: number, box: DOMRect): CanvasImageCropRect { + // 裁剪坐标按显示框归一化,像素宽高比需先换算到归一化坐标系。 + const normalizedRatio = (ratio * box.height) / box.width; + if (!Number.isFinite(normalizedRatio) || normalizedRatio <= 0) return crop; + + const horizontal = handle.includes("e") || handle.includes("w"); + const vertical = handle.includes("n") || handle.includes("s"); + const candidateWidth = handle.includes("w") ? crop.width - dx : handle.includes("e") ? crop.width + dx : crop.width; + const candidateHeight = handle.includes("n") ? crop.height - dy : handle.includes("s") ? crop.height + dy : crop.height; + let width = candidateWidth; + if (!horizontal || (vertical && Math.abs((candidateHeight - crop.height) * box.height) > Math.abs((candidateWidth - crop.width) * box.width))) { + width = candidateHeight * normalizedRatio; + } + + // 角点固定对角,单边固定对边并以另一轴中心为锚点,避免锁定比例时裁剪框漂移。 + const anchorX = handle.includes("w") ? crop.x + crop.width : handle.includes("e") ? crop.x : crop.x + crop.width / 2; + const anchorY = handle.includes("n") ? crop.y + crop.height : handle.includes("s") ? crop.y : crop.y + crop.height / 2; + const maxWidthByX = handle.includes("w") ? anchorX : handle.includes("e") ? 1 - anchorX : 2 * Math.min(anchorX, 1 - anchorX); + const maxHeightByY = handle.includes("n") ? anchorY : handle.includes("s") ? 1 - anchorY : 2 * Math.min(anchorY, 1 - anchorY); + const maxWidth = Math.min(maxWidthByX, maxHeightByY * normalizedRatio); + if (maxWidth <= 0) return crop; + const minWidth = Math.min(maxWidth, Math.max(minSize, minSize * normalizedRatio)); + width = clamp(width, minWidth, maxWidth); + const height = width / normalizedRatio; + + const x = handle.includes("w") ? anchorX - width : handle.includes("e") ? anchorX : anchorX - width / 2; + const y = handle.includes("n") ? anchorY - height : handle.includes("s") ? anchorY : anchorY - height / 2; + return { x: clamp(x, 0, 1 - width), y: clamp(y, 0, 1 - height), width, height }; +} + +function fitCropToAspect(crop: CanvasImageCropRect, ratio: number, image: { width: number; height: number }): CanvasImageCropRect { + const normalizedRatio = (ratio * image.height) / image.width; + if (!Number.isFinite(normalizedRatio) || normalizedRatio <= 0) return crop; + // 切换快捷比例时尽量保持当前裁剪面积和中心位置,超出边界时再整体缩小。 + const area = crop.width * crop.height; + let width = Math.sqrt(area * normalizedRatio); + let height = width / normalizedRatio; + const minimumScale = Math.max(1, minSize / width, minSize / height); + width *= minimumScale; + height *= minimumScale; + const maximumScale = Math.min(1, 1 / width, 1 / height); + width *= maximumScale; + height *= maximumScale; + const centerX = crop.x + crop.width / 2; + const centerY = crop.y + crop.height / 2; + return { + x: clamp(centerX - width / 2, 0, 1 - width), + y: clamp(centerY - 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}%` }; }