From 14bf95d013c1115a5f84334ddbf25c6425ccb68a Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Thu, 27 Nov 2025 19:50:07 -0500 Subject: [PATCH 1/3] crop box --- src/components/imagecrop/WidgetImageCrop.vue | 89 ++++ src/composables/useImageCrop.ts | 498 ++++++++++++++++++ src/extensions/core/imageCrop.ts | 29 + src/extensions/core/index.ts | 1 + src/lib/litegraph/src/types/widgets.ts | 7 + .../litegraph/src/widgets/ImageCropWidget.ts | 47 ++ src/lib/litegraph/src/widgets/widgetMap.ts | 4 + src/locales/en/main.json | 5 + .../widgets/registry/widgetRegistry.ts | 13 +- 9 files changed, 692 insertions(+), 1 deletion(-) create mode 100644 src/components/imagecrop/WidgetImageCrop.vue create mode 100644 src/composables/useImageCrop.ts create mode 100644 src/extensions/core/imageCrop.ts create mode 100644 src/lib/litegraph/src/widgets/ImageCropWidget.ts diff --git a/src/components/imagecrop/WidgetImageCrop.vue b/src/components/imagecrop/WidgetImageCrop.vue new file mode 100644 index 0000000000..66e29168d3 --- /dev/null +++ b/src/components/imagecrop/WidgetImageCrop.vue @@ -0,0 +1,89 @@ + + + diff --git a/src/composables/useImageCrop.ts b/src/composables/useImageCrop.ts new file mode 100644 index 0000000000..9abfbea7a3 --- /dev/null +++ b/src/composables/useImageCrop.ts @@ -0,0 +1,498 @@ +import { computed, onMounted, onUnmounted, ref, watch } from 'vue' + +import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode' +import { app } from '@/scripts/app' +import { useNodeOutputStore } from '@/stores/imagePreviewStore' + +type CropWidgetChangeHandler = ( + name: 'x' | 'y' | 'width' | 'height', + value: number +) => void + +// Global map to store handlers by nodeId, accessed by imageCrop.ts +// Key is always String(nodeId) to ensure consistent lookup +export const cropWidgetChangeHandlers = new Map< + string, + CropWidgetChangeHandler +>() + +type ResizeDirection = + | 'top' + | 'bottom' + | 'left' + | 'right' + | 'nw' + | 'ne' + | 'sw' + | 'se' + +const HANDLE_SIZE = 8 +const CORNER_SIZE = 10 +const MIN_CROP_SIZE = 16 + +export const useImageCrop = (nodeId: NodeId) => { + const nodeOutputStore = useNodeOutputStore() + + const node = ref(null) + + const imageUrl = ref(null) + const isLoading = ref(false) + const imageEl = ref() + const containerEl = ref() + + const naturalWidth = ref(0) + const naturalHeight = ref(0) + const displayedWidth = ref(0) + const displayedHeight = ref(0) + const scaleFactor = ref(1) + const imageOffsetX = ref(0) + const imageOffsetY = ref(0) + + const cropX = ref(0) + const cropY = ref(0) + const cropWidth = ref(512) + const cropHeight = ref(512) + + const isDragging = ref(false) + const dragStartX = ref(0) + const dragStartY = ref(0) + const dragStartCropX = ref(0) + const dragStartCropY = ref(0) + + const isResizing = ref(false) + const resizeDirection = ref(null) + const resizeStartX = ref(0) + const resizeStartY = ref(0) + const resizeStartCropX = ref(0) + const resizeStartCropY = ref(0) + const resizeStartCropWidth = ref(0) + const resizeStartCropHeight = ref(0) + + let resizeObserver: ResizeObserver | null = null + + const getWidgetValue = (name: string): number => { + if (!node.value) return 0 + + const widget = node.value.widgets?.find((w) => w.name === name) + + return (widget?.value as number) ?? 0 + } + + const setWidgetValue = (name: string, value: number) => { + if (!node.value) return + + const widget = node.value.widgets?.find((w) => w.name === name) + + if (widget) { + widget.value = value + widget.callback?.(value) + } + } + + const syncCropFromWidgets = () => { + cropX.value = getWidgetValue('x') + cropY.value = getWidgetValue('y') + cropWidth.value = getWidgetValue('width') || 512 + cropHeight.value = getWidgetValue('height') || 512 + } + + const registerWidgetChangeHandler = () => { + if (nodeId == null) return + + // Use String(id) to ensure consistent key type (node.id can be number or string) + cropWidgetChangeHandlers.set(String(nodeId), (name, value) => { + if (name === 'x') cropX.value = value + else if (name === 'y') cropY.value = value + else if (name === 'width') cropWidth.value = value + else if (name === 'height') cropHeight.value = value + }) + } + + const unregisterWidgetChangeHandler = () => { + if (nodeId == null) return + + cropWidgetChangeHandlers.delete(String(nodeId)) + } + + const getInputImageUrl = (): string | null => { + if (!node.value) return null + + const inputNode = node.value.getInputNode(0) + + if (!inputNode) return null + + const urls = nodeOutputStore.getNodeImageUrls(inputNode) + + if (urls?.length) { + return urls[0] + } + + return null + } + + const updateImageUrl = () => { + imageUrl.value = getInputImageUrl() + } + + const updateDisplayedDimensions = () => { + if (!imageEl.value || !containerEl.value) return + + const img = imageEl.value + const container = containerEl.value + + naturalWidth.value = img.naturalWidth + naturalHeight.value = img.naturalHeight + + const containerWidth = container.clientWidth + const containerHeight = container.clientHeight + + const imageAspect = naturalWidth.value / naturalHeight.value + const containerAspect = containerWidth / containerHeight + + if (imageAspect > containerAspect) { + displayedWidth.value = containerWidth + displayedHeight.value = containerWidth / imageAspect + imageOffsetX.value = 0 + imageOffsetY.value = (containerHeight - displayedHeight.value) / 2 + } else { + displayedHeight.value = containerHeight + displayedWidth.value = containerHeight * imageAspect + imageOffsetX.value = (containerWidth - displayedWidth.value) / 2 + imageOffsetY.value = 0 + } + + if (naturalWidth.value > 0 && displayedWidth.value > 0) { + scaleFactor.value = displayedWidth.value / naturalWidth.value + } else { + scaleFactor.value = 1 + } + } + + const getEffectiveScale = (): number => { + if (!containerEl.value || naturalWidth.value === 0) return 1 + + const rect = containerEl.value.getBoundingClientRect() + + const renderedDisplayedWidth = + (displayedWidth.value / containerEl.value.clientWidth) * rect.width + + return renderedDisplayedWidth / naturalWidth.value + } + + const cropBoxStyle = computed(() => ({ + left: `${imageOffsetX.value + cropX.value * scaleFactor.value}px`, + top: `${imageOffsetY.value + cropY.value * scaleFactor.value}px`, + width: `${cropWidth.value * scaleFactor.value}px`, + height: `${cropHeight.value * scaleFactor.value}px` + })) + + const cropImageStyle = computed(() => { + if (!imageUrl.value) return {} + + return { + backgroundImage: `url(${imageUrl.value})`, + backgroundSize: `${displayedWidth.value}px ${displayedHeight.value}px`, + backgroundPosition: `-${cropX.value * scaleFactor.value}px -${cropY.value * scaleFactor.value}px`, + backgroundRepeat: 'no-repeat' + } + }) + + interface ResizeHandle { + direction: ResizeDirection + class: string + style: { + left: string + top: string + width?: string + height?: string + } + } + + const resizeHandles = computed(() => { + const x = imageOffsetX.value + cropX.value * scaleFactor.value + const y = imageOffsetY.value + cropY.value * scaleFactor.value + const w = cropWidth.value * scaleFactor.value + const h = cropHeight.value * scaleFactor.value + + return [ + // Edge handles + { + direction: 'top', + class: 'h-2 cursor-ns-resize', + style: { + left: `${x + HANDLE_SIZE}px`, + top: `${y - HANDLE_SIZE / 2}px`, + width: `${Math.max(0, w - HANDLE_SIZE * 2)}px` + } + }, + { + direction: 'bottom', + class: 'h-2 cursor-ns-resize', + style: { + left: `${x + HANDLE_SIZE}px`, + top: `${y + h - HANDLE_SIZE / 2}px`, + width: `${Math.max(0, w - HANDLE_SIZE * 2)}px` + } + }, + { + direction: 'left', + class: 'w-2 cursor-ew-resize', + style: { + left: `${x - HANDLE_SIZE / 2}px`, + top: `${y + HANDLE_SIZE}px`, + height: `${Math.max(0, h - HANDLE_SIZE * 2)}px` + } + }, + { + direction: 'right', + class: 'w-2 cursor-ew-resize', + style: { + left: `${x + w - HANDLE_SIZE / 2}px`, + top: `${y + HANDLE_SIZE}px`, + height: `${Math.max(0, h - HANDLE_SIZE * 2)}px` + } + }, + // Corner handles + { + direction: 'nw', + class: 'cursor-nwse-resize rounded-sm bg-white/80', + style: { + left: `${x - CORNER_SIZE / 2}px`, + top: `${y - CORNER_SIZE / 2}px`, + width: `${CORNER_SIZE}px`, + height: `${CORNER_SIZE}px` + } + }, + { + direction: 'ne', + class: 'cursor-nesw-resize rounded-sm bg-white/80', + style: { + left: `${x + w - CORNER_SIZE / 2}px`, + top: `${y - CORNER_SIZE / 2}px`, + width: `${CORNER_SIZE}px`, + height: `${CORNER_SIZE}px` + } + }, + { + direction: 'sw', + class: 'cursor-nesw-resize rounded-sm bg-white/80', + style: { + left: `${x - CORNER_SIZE / 2}px`, + top: `${y + h - CORNER_SIZE / 2}px`, + width: `${CORNER_SIZE}px`, + height: `${CORNER_SIZE}px` + } + }, + { + direction: 'se', + class: 'cursor-nwse-resize rounded-sm bg-white/80', + style: { + left: `${x + w - CORNER_SIZE / 2}px`, + top: `${y + h - CORNER_SIZE / 2}px`, + width: `${CORNER_SIZE}px`, + height: `${CORNER_SIZE}px` + } + } + ] + }) + + const handleImageLoad = () => { + isLoading.value = false + updateDisplayedDimensions() + } + + const handleImageError = () => { + isLoading.value = false + imageUrl.value = null + } + + const capturePointer = (e: PointerEvent) => + (e.target as HTMLElement).setPointerCapture(e.pointerId) + + const releasePointer = (e: PointerEvent) => + (e.target as HTMLElement).releasePointerCapture(e.pointerId) + + const handleDragStart = (e: PointerEvent) => { + if (!imageUrl.value) return + + isDragging.value = true + dragStartX.value = e.clientX + dragStartY.value = e.clientY + dragStartCropX.value = cropX.value + dragStartCropY.value = cropY.value + capturePointer(e) + } + + const handleDragMove = (e: PointerEvent) => { + if (!isDragging.value) return + + const effectiveScale = getEffectiveScale() + if (effectiveScale === 0) return + + const deltaX = (e.clientX - dragStartX.value) / effectiveScale + const deltaY = (e.clientY - dragStartY.value) / effectiveScale + + const maxX = naturalWidth.value - cropWidth.value + const maxY = naturalHeight.value - cropHeight.value + + const newX = Math.round( + Math.max(0, Math.min(maxX, dragStartCropX.value + deltaX)) + ) + const newY = Math.round( + Math.max(0, Math.min(maxY, dragStartCropY.value + deltaY)) + ) + + setWidgetValue('x', newX) + setWidgetValue('y', newY) + } + + const handleDragEnd = (e: PointerEvent) => { + if (!isDragging.value) return + + isDragging.value = false + releasePointer(e) + } + + const handleResizeStart = (e: PointerEvent, direction: ResizeDirection) => { + if (!imageUrl.value) return + + e.stopPropagation() + isResizing.value = true + resizeDirection.value = direction + + resizeStartX.value = e.clientX + resizeStartY.value = e.clientY + resizeStartCropX.value = cropX.value + resizeStartCropY.value = cropY.value + resizeStartCropWidth.value = cropWidth.value + resizeStartCropHeight.value = cropHeight.value + capturePointer(e) + } + + const handleResizeMove = (e: PointerEvent) => { + if (!isResizing.value || !resizeDirection.value) return + + const effectiveScale = getEffectiveScale() + if (effectiveScale === 0) return + + const dir = resizeDirection.value + const deltaX = (e.clientX - resizeStartX.value) / effectiveScale + const deltaY = (e.clientY - resizeStartY.value) / effectiveScale + + const affectsLeft = dir === 'left' || dir === 'nw' || dir === 'sw' + const affectsRight = dir === 'right' || dir === 'ne' || dir === 'se' + const affectsTop = dir === 'top' || dir === 'nw' || dir === 'ne' + const affectsBottom = dir === 'bottom' || dir === 'sw' || dir === 'se' + + let newX = resizeStartCropX.value + let newY = resizeStartCropY.value + let newWidth = resizeStartCropWidth.value + let newHeight = resizeStartCropHeight.value + + if (affectsLeft) { + const maxDeltaX = resizeStartCropWidth.value - MIN_CROP_SIZE + const minDeltaX = -resizeStartCropX.value + const clampedDeltaX = Math.max(minDeltaX, Math.min(maxDeltaX, deltaX)) + newX = resizeStartCropX.value + clampedDeltaX + newWidth = resizeStartCropWidth.value - clampedDeltaX + } else if (affectsRight) { + const maxWidth = naturalWidth.value - resizeStartCropX.value + newWidth = Math.max( + MIN_CROP_SIZE, + Math.min(maxWidth, resizeStartCropWidth.value + deltaX) + ) + } + + if (affectsTop) { + const maxDeltaY = resizeStartCropHeight.value - MIN_CROP_SIZE + const minDeltaY = -resizeStartCropY.value + const clampedDeltaY = Math.max(minDeltaY, Math.min(maxDeltaY, deltaY)) + newY = resizeStartCropY.value + clampedDeltaY + newHeight = resizeStartCropHeight.value - clampedDeltaY + } else if (affectsBottom) { + const maxHeight = naturalHeight.value - resizeStartCropY.value + newHeight = Math.max( + MIN_CROP_SIZE, + Math.min(maxHeight, resizeStartCropHeight.value + deltaY) + ) + } + + if (affectsLeft || affectsRight) { + setWidgetValue('x', Math.round(newX)) + setWidgetValue('width', Math.round(newWidth)) + } + if (affectsTop || affectsBottom) { + setWidgetValue('y', Math.round(newY)) + setWidgetValue('height', Math.round(newHeight)) + } + } + + const handleResizeEnd = (e: PointerEvent) => { + if (!isResizing.value) return + + isResizing.value = false + resizeDirection.value = null + releasePointer(e) + } + + const initialize = () => { + if (nodeId) { + node.value = app.rootGraph?.getNodeById(nodeId) || null + } + + updateImageUrl() + registerWidgetChangeHandler() + syncCropFromWidgets() + + resizeObserver = new ResizeObserver(() => { + if (imageEl.value && imageUrl.value) { + updateDisplayedDimensions() + } + }) + + if (containerEl.value) { + resizeObserver.observe(containerEl.value) + } + } + + const cleanup = () => { + unregisterWidgetChangeHandler() + resizeObserver?.disconnect() + } + + watch( + () => nodeOutputStore.nodeOutputs, + () => updateImageUrl(), + { deep: true } + ) + + watch( + () => nodeOutputStore.nodePreviewImages, + () => updateImageUrl(), + { deep: true } + ) + + onMounted(initialize) + onUnmounted(cleanup) + + return { + imageEl, + containerEl, + + imageUrl, + isLoading, + + cropBoxStyle, + cropImageStyle, + resizeHandles, + + handleImageLoad, + handleImageError, + handleDragStart, + handleDragMove, + handleDragEnd, + handleResizeStart, + handleResizeMove, + handleResizeEnd + } +} diff --git a/src/extensions/core/imageCrop.ts b/src/extensions/core/imageCrop.ts new file mode 100644 index 0000000000..4f89c87429 --- /dev/null +++ b/src/extensions/core/imageCrop.ts @@ -0,0 +1,29 @@ +import { cropWidgetChangeHandlers } from '@/composables/useImageCrop' +import { useExtensionService } from '@/services/extensionService' + +useExtensionService().registerExtension({ + name: 'Comfy.ImageCrop', + + async nodeCreated(node) { + if (node.constructor.comfyClass !== 'ImageCrop') return + + node.addWidget('imagecrop', 'crop_preview', [], () => {}, { + serialize: false + }) + + const paramNames = ['x', 'y', 'width', 'height'] as const + + for (const paramName of paramNames) { + const widget = node.widgets?.find((w) => w.name === paramName) + if (widget) { + widget.callback = (value: number) => { + const handler = cropWidgetChangeHandlers.get(String(node.id)) + handler?.(paramName, value) + } + } + } + + const [oldWidth, oldHeight] = node.size + node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 400)]) + } +}) diff --git a/src/extensions/core/index.ts b/src/extensions/core/index.ts index 4171dce89d..6eb14d4961 100644 --- a/src/extensions/core/index.ts +++ b/src/extensions/core/index.ts @@ -8,6 +8,7 @@ import './electronAdapter' import './groupNode' import './groupNodeManage' import './groupOptions' +import './imageCrop' import './load3d' import './maskeditor' import './matchType' diff --git a/src/lib/litegraph/src/types/widgets.ts b/src/lib/litegraph/src/types/widgets.ts index 5f0d1b9a35..ab5967b7e5 100644 --- a/src/lib/litegraph/src/types/widgets.ts +++ b/src/lib/litegraph/src/types/widgets.ts @@ -82,6 +82,7 @@ export type IWidget = | ISelectButtonWidget | ITextareaWidget | IAssetWidget + | IImageCropWidget export interface IBooleanWidget extends IBaseWidget { type: 'toggle' @@ -236,6 +237,12 @@ export interface IAssetWidget value: string } +/** Image crop widget for cropping image */ +export interface IImageCropWidget extends IBaseWidget { + type: 'imagecrop' + value: string[] +} + /** * Valid widget types. TS cannot provide easily extensible type safety for this at present. * Override linkedWidgets[] diff --git a/src/lib/litegraph/src/widgets/ImageCropWidget.ts b/src/lib/litegraph/src/widgets/ImageCropWidget.ts new file mode 100644 index 0000000000..2415a81681 --- /dev/null +++ b/src/lib/litegraph/src/widgets/ImageCropWidget.ts @@ -0,0 +1,47 @@ +import type { IImageCropWidget } from '../types/widgets' +import { BaseWidget } from './BaseWidget' +import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget' + +/** + * Widget for displaying an image crop preview + * This is a widget that only has a Vue widgets implementation + */ +export class ImageCropWidget + extends BaseWidget + implements IImageCropWidget +{ + override type = 'imagecrop' as const + + drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void { + const { width } = options + const { y, height } = this + + const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx + + ctx.fillStyle = this.background_color + ctx.fillRect(15, y, width - 30, height) + + ctx.strokeStyle = this.outline_color + ctx.strokeRect(15, y, width - 30, height) + + ctx.fillStyle = this.text_color + ctx.font = '11px monospace' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + const text = 'ImageCrop: Vue-only' + ctx.fillText(text, width / 2, y + height / 2) + + Object.assign(ctx, { + fillStyle, + strokeStyle, + textAlign, + textBaseline, + font + }) + } + + onClick(_options: WidgetEventOptions): void { + // This is a widget that only has a Vue widgets implementation + } +} diff --git a/src/lib/litegraph/src/widgets/widgetMap.ts b/src/lib/litegraph/src/widgets/widgetMap.ts index 0e6a34fe51..30a019357f 100644 --- a/src/lib/litegraph/src/widgets/widgetMap.ts +++ b/src/lib/litegraph/src/widgets/widgetMap.ts @@ -18,6 +18,7 @@ import { ComboWidget } from './ComboWidget' import { FileUploadWidget } from './FileUploadWidget' import { GalleriaWidget } from './GalleriaWidget' import { ImageCompareWidget } from './ImageCompareWidget' +import { ImageCropWidget } from './ImageCropWidget' import { KnobWidget } from './KnobWidget' import { LegacyWidget } from './LegacyWidget' import { MarkdownWidget } from './MarkdownWidget' @@ -50,6 +51,7 @@ export type WidgetTypeMap = { selectbutton: SelectButtonWidget textarea: TextareaWidget asset: AssetWidget + imagecrop: ImageCropWidget [key: string]: BaseWidget } @@ -120,6 +122,8 @@ export function toConcreteWidget( return toClass(TextareaWidget, narrowedWidget, node) case 'asset': return toClass(AssetWidget, narrowedWidget, node) + case 'imagecrop': + return toClass(ImageCropWidget, narrowedWidget, node) default: { if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node) } diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 3c27184290..d514b92309 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1621,6 +1621,11 @@ "unsupportedFileType": "Unsupported file type (supports .gltf, .glb, .obj, .fbx, .stl)", "uploadingModel": "Uploading 3D model..." }, + "imageCrop": { + "loading": "Loading...", + "noInputImage": "No input image connected", + "cropPreviewAlt": "Crop preview" + }, "toastMessages": { "nothingToQueue": "Nothing to queue", "pleaseSelectOutputNodes": "Please select output nodes", diff --git a/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts b/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts index f093f88b9c..d4bccc61ef 100644 --- a/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts +++ b/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts @@ -54,6 +54,9 @@ const WidgetAudioUI = defineAsyncComponent( const Load3D = defineAsyncComponent( () => import('@/components/load3d/Load3D.vue') ) +const WidgetImageCrop = defineAsyncComponent( + () => import('@/components/imagecrop/WidgetImageCrop.vue') +) export const FOR_TESTING = { WidgetAudioUI, @@ -157,7 +160,15 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [ essential: false } ], - ['load3D', { component: Load3D, aliases: ['LOAD_3D'], essential: false }] + ['load3D', { component: Load3D, aliases: ['LOAD_3D'], essential: false }], + [ + 'imagecrop', + { + component: WidgetImageCrop, + aliases: ['IMAGE_CROP', 'imageCrop'], + essential: false + } + ] ] const getComboWidgetAdditions = (): Map => { From 30666c8f2e3e50e3b832ebe20116cc3917efafc1 Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Sat, 29 Nov 2025 22:23:44 -0500 Subject: [PATCH 2/3] code improve --- src/composables/useImageCrop.ts | 73 ++++++++++++++++++++------------- 1 file changed, 44 insertions(+), 29 deletions(-) diff --git a/src/composables/useImageCrop.ts b/src/composables/useImageCrop.ts index 9abfbea7a3..d5606c02ab 100644 --- a/src/composables/useImageCrop.ts +++ b/src/composables/useImageCrop.ts @@ -1,3 +1,4 @@ +import { useResizeObserver } from '@vueuse/core' import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode' @@ -68,7 +69,11 @@ export const useImageCrop = (nodeId: NodeId) => { const resizeStartCropWidth = ref(0) const resizeStartCropHeight = ref(0) - let resizeObserver: ResizeObserver | null = null + useResizeObserver(containerEl, () => { + if (imageEl.value && imageUrl.value) { + updateDisplayedDimensions() + } + }) const getWidgetValue = (name: string): number => { if (!node.value) return 0 @@ -131,7 +136,11 @@ export const useImageCrop = (nodeId: NodeId) => { } const updateImageUrl = () => { - imageUrl.value = getInputImageUrl() + const nextUrl = getInputImageUrl() + if (nextUrl !== imageUrl.value) { + imageUrl.value = nextUrl + isLoading.value = !!nextUrl + } } const updateDisplayedDimensions = () => { @@ -143,6 +152,11 @@ export const useImageCrop = (nodeId: NodeId) => { naturalWidth.value = img.naturalWidth naturalHeight.value = img.naturalHeight + if (naturalWidth.value <= 0 || naturalHeight.value <= 0) { + scaleFactor.value = 1 + return + } + const containerWidth = container.clientWidth const containerHeight = container.clientHeight @@ -161,20 +175,27 @@ export const useImageCrop = (nodeId: NodeId) => { imageOffsetY.value = 0 } - if (naturalWidth.value > 0 && displayedWidth.value > 0) { - scaleFactor.value = displayedWidth.value / naturalWidth.value - } else { + if (naturalWidth.value <= 0 || displayedWidth.value <= 0) { scaleFactor.value = 1 + } else { + scaleFactor.value = displayedWidth.value / naturalWidth.value } } const getEffectiveScale = (): number => { - if (!containerEl.value || naturalWidth.value === 0) return 1 + const container = containerEl.value - const rect = containerEl.value.getBoundingClientRect() + if (!container || naturalWidth.value <= 0 || displayedWidth.value <= 0) { + return 1 + } + + const rect = container.getBoundingClientRect() + const clientWidth = container.clientWidth + + if (!clientWidth || !rect.width) return 1 const renderedDisplayedWidth = - (displayedWidth.value / containerEl.value.clientWidth) * rect.width + (displayedWidth.value / clientWidth) * rect.width return renderedDisplayedWidth / naturalWidth.value } @@ -335,15 +356,12 @@ export const useImageCrop = (nodeId: NodeId) => { const maxX = naturalWidth.value - cropWidth.value const maxY = naturalHeight.value - cropHeight.value - const newX = Math.round( + cropX.value = Math.round( Math.max(0, Math.min(maxX, dragStartCropX.value + deltaX)) ) - const newY = Math.round( + cropY.value = Math.round( Math.max(0, Math.min(maxY, dragStartCropY.value + deltaY)) ) - - setWidgetValue('x', newX) - setWidgetValue('y', newY) } const handleDragEnd = (e: PointerEvent) => { @@ -351,6 +369,9 @@ export const useImageCrop = (nodeId: NodeId) => { isDragging.value = false releasePointer(e) + + setWidgetValue('x', cropX.value) + setWidgetValue('y', cropY.value) } const handleResizeStart = (e: PointerEvent, direction: ResizeDirection) => { @@ -418,12 +439,12 @@ export const useImageCrop = (nodeId: NodeId) => { } if (affectsLeft || affectsRight) { - setWidgetValue('x', Math.round(newX)) - setWidgetValue('width', Math.round(newWidth)) + cropX.value = Math.round(newX) + cropWidth.value = Math.round(newWidth) } if (affectsTop || affectsBottom) { - setWidgetValue('y', Math.round(newY)) - setWidgetValue('height', Math.round(newHeight)) + cropY.value = Math.round(newY) + cropHeight.value = Math.round(newHeight) } } @@ -433,31 +454,25 @@ export const useImageCrop = (nodeId: NodeId) => { isResizing.value = false resizeDirection.value = null releasePointer(e) + + setWidgetValue('x', cropX.value) + setWidgetValue('y', cropY.value) + setWidgetValue('width', cropWidth.value) + setWidgetValue('height', cropHeight.value) } const initialize = () => { - if (nodeId) { + if (nodeId != null) { node.value = app.rootGraph?.getNodeById(nodeId) || null } updateImageUrl() registerWidgetChangeHandler() syncCropFromWidgets() - - resizeObserver = new ResizeObserver(() => { - if (imageEl.value && imageUrl.value) { - updateDisplayedDimensions() - } - }) - - if (containerEl.value) { - resizeObserver.observe(containerEl.value) - } } const cleanup = () => { unregisterWidgetChangeHandler() - resizeObserver?.disconnect() } watch( From 3b731eb12e9266c8762ed35831d86a60237b6745 Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Mon, 1 Dec 2025 20:20:27 -0500 Subject: [PATCH 3/3] bug fix --- src/composables/useImageCrop.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/composables/useImageCrop.ts b/src/composables/useImageCrop.ts index d5606c02ab..6462510cc9 100644 --- a/src/composables/useImageCrop.ts +++ b/src/composables/useImageCrop.ts @@ -136,11 +136,7 @@ export const useImageCrop = (nodeId: NodeId) => { } const updateImageUrl = () => { - const nextUrl = getInputImageUrl() - if (nextUrl !== imageUrl.value) { - imageUrl.value = nextUrl - isLoading.value = !!nextUrl - } + imageUrl.value = getInputImageUrl() } const updateDisplayedDimensions = () => {