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