From f9af2cc4bd35d88edfad865ca7ee48f6ff1f77c3 Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Tue, 3 Feb 2026 04:24:33 -0500 Subject: [PATCH] feat: add aspect ratio lock for ImageCrop widget (#8533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add ratio preset dropdown (1:1, 3:4, 4:3, 16:9, 9:16, custom) and lock toggle to the ImageCrop widget. When locked, only corner handles are shown and resizing maintains the selected aspect ratio using diagonal projection for smooth, jump-free interaction. Locking with custom selected captures the current crop's ratio. ## Screenshots (if applicable) https://github.com/user-attachments/assets/a7b5c0a0-c18c-4785-940f-59793702e892 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8533-feat-add-aspect-ratio-lock-for-ImageCrop-widget-2fa6d73d365081a98546e43ed0d7d4fe) by [Unito](https://www.unito.io) ## Summary by CodeRabbit * **New Features** * Image crop widget adds a ratio selector with preset and custom options, plus a lock/unlock toggle to preserve aspect while cropping. * Constrained corner-resize behavior enforces locked aspect ratios during resizing. * **Documentation** * Added UI text entries for ratio controls (labels for ratio, lock/unlock, custom). --- src/components/imagecrop/WidgetImageCrop.vue | 48 ++++++- src/composables/useImageCrop.ts | 143 ++++++++++++++++++- src/locales/en/main.json | 6 +- 3 files changed, 194 insertions(+), 3 deletions(-) diff --git a/src/components/imagecrop/WidgetImageCrop.vue b/src/components/imagecrop/WidgetImageCrop.vue index 4a1c39ef6..d093ddad2 100644 --- a/src/components/imagecrop/WidgetImageCrop.vue +++ b/src/components/imagecrop/WidgetImageCrop.vue @@ -57,6 +57,41 @@ /> +
+ + + +
+ @@ -65,7 +100,13 @@ import { useTemplateRef } from 'vue' import WidgetBoundingBox from '@/components/boundingbox/WidgetBoundingBox.vue' -import { useImageCrop } from '@/composables/useImageCrop' +import Button from '@/components/ui/button/Button.vue' +import Select from '@/components/ui/select/Select.vue' +import SelectContent from '@/components/ui/select/SelectContent.vue' +import SelectItem from '@/components/ui/select/SelectItem.vue' +import SelectTrigger from '@/components/ui/select/SelectTrigger.vue' +import SelectValue from '@/components/ui/select/SelectValue.vue' +import { ASPECT_RATIOS, useImageCrop } from '@/composables/useImageCrop' import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema' import type { Bounds } from '@/renderer/core/layout/types' @@ -80,10 +121,15 @@ const modelValue = defineModel({ const imageEl = useTemplateRef('imageEl') const containerEl = useTemplateRef('containerEl') +const ratioKeys = Object.keys(ASPECT_RATIOS) + const { imageUrl, isLoading, + selectedRatio, + isLockEnabled, + cropBoxStyle, cropImageStyle, resizeHandles, diff --git a/src/composables/useImageCrop.ts b/src/composables/useImageCrop.ts index da637ba0a..8a09d3564 100644 --- a/src/composables/useImageCrop.ts +++ b/src/composables/useImageCrop.ts @@ -22,6 +22,15 @@ const CORNER_SIZE = 10 const MIN_CROP_SIZE = 16 const CROP_BOX_BORDER = 2 +export const ASPECT_RATIOS = { + '1:1': 1, + '3:4': 3 / 4, + '4:3': 4 / 3, + '16:9': 16 / 9, + '9:16': 9 / 16, + custom: null +} as const + interface UseImageCropOptions { imageEl: Ref containerEl: Ref @@ -88,6 +97,55 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) { const resizeStartCropWidth = ref(0) const resizeStartCropHeight = ref(0) + const lockedRatio = ref(null) + + const selectedRatio = computed({ + get: () => { + if (lockedRatio.value == null) return 'custom' + const entry = Object.entries(ASPECT_RATIOS).find( + ([, v]) => v === lockedRatio.value + ) + return entry ? entry[0] : 'custom' + }, + set: (key: string) => { + if (key === 'custom') { + lockedRatio.value = null + return + } + lockedRatio.value = + ASPECT_RATIOS[key as keyof typeof ASPECT_RATIOS] ?? null + applyLockedRatio() + } + }) + + const isLockEnabled = computed({ + get: () => lockedRatio.value != null, + set: (locked: boolean) => { + if (locked && lockedRatio.value == null) { + lockedRatio.value = cropWidth.value / cropHeight.value + } + if (!locked) { + lockedRatio.value = null + } + } + }) + + function applyLockedRatio() { + if (lockedRatio.value == null) return + + const ratio = lockedRatio.value + const w = cropWidth.value + let newHeight = Math.round(w / ratio) + + if (cropY.value + newHeight > naturalHeight.value) { + newHeight = naturalHeight.value - cropY.value + const newWidth = Math.round(newHeight * ratio) + cropWidth.value = Math.max(MIN_CROP_SIZE, newWidth) + } + + cropHeight.value = Math.max(MIN_CROP_SIZE, newHeight) + } + useResizeObserver(containerEl, () => { if (imageEl.value && imageUrl.value) { updateDisplayedDimensions() @@ -200,7 +258,9 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) { } } - const resizeHandles = computed(() => { + const CORNER_DIRECTIONS = new Set(['nw', 'ne', 'sw', 'se']) + + const allResizeHandles = computed(() => { const x = imageOffsetX.value + cropX.value * scaleFactor.value const y = imageOffsetY.value + cropY.value * scaleFactor.value const w = cropWidth.value * scaleFactor.value @@ -286,6 +346,13 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) { ] }) + const resizeHandles = computed(() => { + if (!isLockEnabled.value) return allResizeHandles.value + return allResizeHandles.value.filter((h) => + CORNER_DIRECTIONS.has(h.direction) + ) + }) + const handleImageLoad = () => { isLoading.value = false updateDisplayedDimensions() @@ -366,6 +433,13 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) { const deltaX = (e.clientX - resizeStartX.value) / effectiveScale const deltaY = (e.clientY - resizeStartY.value) / effectiveScale + const ratioValue = isLockEnabled.value ? lockedRatio.value : null + + if (ratioValue != null && CORNER_DIRECTIONS.has(dir)) { + handleConstrainedResize(dir, deltaX, deltaY, ratioValue) + return + } + const affectsLeft = dir === 'left' || dir === 'nw' || dir === 'sw' const affectsRight = dir === 'right' || dir === 'ne' || dir === 'se' const affectsTop = dir === 'top' || dir === 'nw' || dir === 'ne' @@ -414,6 +488,70 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) { } } + function handleConstrainedResize( + dir: ResizeDirection, + deltaX: number, + deltaY: number, + ratio: number + ) { + const affectsLeft = dir === 'nw' || dir === 'sw' + const affectsTop = dir === 'nw' || dir === 'ne' + + const sx = affectsLeft ? -1 : 1 + const sy = affectsTop ? -1 : 1 + + const invRatio = 1 / ratio + const dot = deltaX * sx + deltaY * sy * invRatio + const lenSq = 1 + invRatio * invRatio + const widthDelta = dot / lenSq + + let newWidth = Math.round(resizeStartCropWidth.value + widthDelta) + let newHeight = Math.round(newWidth / ratio) + + if (newWidth < MIN_CROP_SIZE) { + newWidth = MIN_CROP_SIZE + newHeight = Math.round(newWidth / ratio) + } + if (newHeight < MIN_CROP_SIZE) { + newHeight = MIN_CROP_SIZE + newWidth = Math.round(newHeight * ratio) + } + + let newX = resizeStartCropX.value + let newY = resizeStartCropY.value + + if (affectsLeft) { + newX = resizeStartCropX.value + resizeStartCropWidth.value - newWidth + } + if (affectsTop) { + newY = resizeStartCropY.value + resizeStartCropHeight.value - newHeight + } + + if (newX < 0) { + newWidth += newX + newX = 0 + newHeight = Math.round(newWidth / ratio) + } + if (newY < 0) { + newHeight += newY + newY = 0 + newWidth = Math.round(newHeight * ratio) + } + if (newX + newWidth > naturalWidth.value) { + newWidth = naturalWidth.value - newX + newHeight = Math.round(newWidth / ratio) + } + if (newY + newHeight > naturalHeight.value) { + newHeight = naturalHeight.value - newY + newWidth = Math.round(newHeight * ratio) + } + + cropX.value = Math.round(newX) + cropY.value = Math.round(newY) + cropWidth.value = Math.max(MIN_CROP_SIZE, newWidth) + cropHeight.value = Math.max(MIN_CROP_SIZE, newHeight) + } + const handleResizeEnd = (e: PointerEvent) => { if (!isResizing.value) return @@ -453,6 +591,9 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) { cropWidth, cropHeight, + selectedRatio, + isLockEnabled, + cropBoxStyle, cropImageStyle, resizeHandles, diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 5add6a63f..c35e65fc9 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1785,7 +1785,11 @@ "imageCrop": { "loading": "Loading...", "noInputImage": "No input image connected", - "cropPreviewAlt": "Crop preview" + "cropPreviewAlt": "Crop preview", + "ratio": "Ratio", + "lockRatio": "Lock aspect ratio", + "unlockRatio": "Unlock aspect ratio", + "custom": "Custom" }, "boundingBox": { "x": "X",