feat: Add visual crop preview widget for ImageCrop node - widget ImageCrop (#7825)

## Summary

Another implementation for image crop node, alternative for
https://github.com/Comfy-Org/ComfyUI_frontend/pull/7014
As discussed with @christian-byrne and @DrJKL we could have single
widget - IMAGECROP with 4 ints and UI preview.

However, this solution requires changing the definition of image crop
node in BE (sent
[here](https://github.com/comfyanonymous/ComfyUI/pull/11594)), which
will break the exsiting workflow, also it would not allow connect
separate int node as input, I am not sure it is a good idea.

So I keep two PRs openned for references

## Screenshots


https://github.com/user-attachments/assets/fde6938c-4395-48f6-ac05-6282c5eb8157

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7825-feat-Add-visual-crop-preview-widget-for-ImageCrop-node-widget-ImageCrop-2dc6d73d3650812bb8a2cdff4615032b)
by [Unito](https://www.unito.io)
This commit is contained in:
Terry Jia
2026-01-17 17:09:16 -05:00
committed by GitHub
parent de2e37ec8e
commit be8916b4ce
17 changed files with 932 additions and 63 deletions

View File

@@ -1,3 +1,5 @@
import type { Bounds } from '@/renderer/core/layout/types'
import type { CanvasColour, Point, RequiredProps, Size } from '../interfaces'
import type { CanvasPointer, LGraphCanvas, LGraphNode } from '../litegraph'
import type { CanvasPointerEvent } from './events'
@@ -88,6 +90,8 @@ export type IWidget =
| ISelectButtonWidget
| ITextareaWidget
| IAssetWidget
| IImageCropWidget
| IBoundingBoxWidget
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
type: 'toggle'
@@ -259,6 +263,18 @@ export interface IAssetWidget extends IBaseWidget<
value: string
}
/** Image crop widget for cropping image */
export interface IImageCropWidget extends IBaseWidget<Bounds, 'imagecrop'> {
type: 'imagecrop'
value: Bounds
}
/** Bounding box widget for defining regions with numeric inputs */
export interface IBoundingBoxWidget extends IBaseWidget<Bounds, 'boundingbox'> {
type: 'boundingbox'
value: Bounds
}
/**
* Valid widget types. TS cannot provide easily extensible type safety for this at present.
* Override linkedWidgets[]

View File

@@ -1,3 +1,4 @@
import { t } from '@/i18n'
import { drawTextInArea } from '@/lib/litegraph/src/draw'
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import type { Point } from '@/lib/litegraph/src/interfaces'
@@ -227,6 +228,41 @@ export abstract class BaseWidget<
if (showText && !this.computedDisabled) ctx.stroke()
}
/**
* Draws a placeholder for widgets that only have a Vue implementation.
* @param ctx The canvas context
* @param options The options for drawing the widget
* @param label The label to display (e.g., "ImageCrop", "BoundingBox")
*/
protected drawVueOnlyWarning(
ctx: CanvasRenderingContext2D,
{ width }: DrawWidgetOptions,
label: string
): void {
const { y, height } = this
ctx.save()
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'
ctx.fillText(
`${label}: ${t('widgets.node2only')}`,
width / 2,
y + height / 2
)
ctx.restore()
}
/**
* A shared routine for drawing a label and value as text, truncated
* if they exceed the available width.

View File

@@ -0,0 +1,22 @@
import type { IBoundingBoxWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
/**
* Widget for defining bounding box regions.
* This widget only has a Vue implementation.
*/
export class BoundingBoxWidget
extends BaseWidget<IBoundingBoxWidget>
implements IBoundingBoxWidget
{
override type = 'boundingbox' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'BoundingBox')
}
onClick(_options: WidgetEventOptions): void {
// This widget only has a Vue implementation
}
}

View File

@@ -0,0 +1,22 @@
import type { IImageCropWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
/**
* Widget for displaying an image crop preview.
* This widget only has a Vue implementation.
*/
export class ImageCropWidget
extends BaseWidget<IImageCropWidget>
implements IImageCropWidget
{
override type = 'imagecrop' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'ImageCrop')
}
onClick(_options: WidgetEventOptions): void {
// This widget only has a Vue implementation
}
}

View File

@@ -1,12 +1,10 @@
import { t } from '@/i18n'
import type { ITextareaWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
/**
* Widget for multi-line text input
* This is a widget that only has a Vue widgets implementation
* Widget for multi-line text input.
* This widget only has a Vue implementation.
*/
export class TextareaWidget
extends BaseWidget<ITextareaWidget>
@@ -15,35 +13,10 @@ export class TextareaWidget
override type = 'textarea' 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 = `Textarea: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
this.drawVueOnlyWarning(ctx, options, 'Textarea')
}
onClick(_options: WidgetEventOptions): void {
// This is a widget that only has a Vue widgets implementation
// This widget only has a Vue implementation
}
}

View File

@@ -1,12 +1,10 @@
import { t } from '@/i18n'
import type { ITreeSelectWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
/**
* Widget for hierarchical tree selection
* This is a widget that only has a Vue widgets implementation
* Widget for hierarchical tree selection.
* This widget only has a Vue implementation.
*/
export class TreeSelectWidget
extends BaseWidget<ITreeSelectWidget>
@@ -15,35 +13,10 @@ export class TreeSelectWidget
override type = 'treeselect' 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 = `TreeSelect: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
this.drawVueOnlyWarning(ctx, options, 'TreeSelect')
}
onClick(_options: WidgetEventOptions): void {
// This is a widget that only has a Vue widgets implementation
// This widget only has a Vue implementation
}
}

View File

@@ -11,6 +11,7 @@ import { toClass } from '@/lib/litegraph/src/utils/type'
import { AssetWidget } from './AssetWidget'
import { BaseWidget } from './BaseWidget'
import { BooleanWidget } from './BooleanWidget'
import { BoundingBoxWidget } from './BoundingBoxWidget'
import { ButtonWidget } from './ButtonWidget'
import { ChartWidget } from './ChartWidget'
import { ColorWidget } from './ColorWidget'
@@ -18,6 +19,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 +52,8 @@ export type WidgetTypeMap = {
selectbutton: SelectButtonWidget
textarea: TextareaWidget
asset: AssetWidget
imagecrop: ImageCropWidget
boundingbox: BoundingBoxWidget
[key: string]: BaseWidget
}
@@ -120,6 +124,10 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
return toClass(TextareaWidget, narrowedWidget, node)
case 'asset':
return toClass(AssetWidget, narrowedWidget, node)
case 'imagecrop':
return toClass(ImageCropWidget, narrowedWidget, node)
case 'boundingbox':
return toClass(BoundingBoxWidget, narrowedWidget, node)
default: {
if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node)
}