feat: add aspect ratio lock for ImageCrop widget (#8533)

## 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)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Terry Jia
2026-02-03 04:24:33 -05:00
committed by GitHub
parent a3fba58c79
commit f9af2cc4bd
3 changed files with 194 additions and 3 deletions

View File

@@ -57,6 +57,41 @@
/>
</div>
<div class="flex shrink-0 items-center gap-2">
<label class="text-xs text-muted-foreground">
{{ $t('imageCrop.ratio') }}
</label>
<Select v-model="selectedRatio">
<SelectTrigger class="h-7 w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="key in ratioKeys" :key="key" :value="key">
{{ key === 'custom' ? $t('imageCrop.custom') : key }}
</SelectItem>
</SelectContent>
</Select>
<Button
size="icon"
:variant="isLockEnabled ? 'primary' : 'secondary'"
class="size-7"
:aria-label="
isLockEnabled
? $t('imageCrop.unlockRatio')
: $t('imageCrop.lockRatio')
"
@click="isLockEnabled = !isLockEnabled"
>
<i
:class="
isLockEnabled
? 'icon-[lucide--lock] size-3.5'
: 'icon-[lucide--lock-open] size-3.5'
"
/>
</Button>
</div>
<WidgetBoundingBox v-model="modelValue" class="shrink-0" />
</div>
</template>
@@ -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<Bounds>({
const imageEl = useTemplateRef<HTMLImageElement>('imageEl')
const containerEl = useTemplateRef<HTMLDivElement>('containerEl')
const ratioKeys = Object.keys(ASPECT_RATIOS)
const {
imageUrl,
isLoading,
selectedRatio,
isLockEnabled,
cropBoxStyle,
cropImageStyle,
resizeHandles,

View File

@@ -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<HTMLImageElement | null>
containerEl: Ref<HTMLDivElement | null>
@@ -88,6 +97,55 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
const resizeStartCropWidth = ref(0)
const resizeStartCropHeight = ref(0)
const lockedRatio = ref<number | null>(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<ResizeHandle[]>(() => {
const CORNER_DIRECTIONS = new Set<ResizeDirection>(['nw', 'ne', 'sw', 'se'])
const allResizeHandles = computed<ResizeHandle[]>(() => {
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<ResizeHandle[]>(() => {
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,

View File

@@ -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",