mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 10:59:53 +00:00
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:
82
src/components/boundingbox/WidgetBoundingBox.vue
Normal file
82
src/components/boundingbox/WidgetBoundingBox.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1">
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.x') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="x"
|
||||
type="number"
|
||||
:min="0"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.y') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="y"
|
||||
type="number"
|
||||
:min="0"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.width') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="width"
|
||||
type="number"
|
||||
:min="1"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.height') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="height"
|
||||
type="number"
|
||||
:min="1"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
|
||||
const modelValue = defineModel<Bounds>({
|
||||
default: () => ({ x: 0, y: 0, width: 512, height: 512 })
|
||||
})
|
||||
|
||||
const x = computed({
|
||||
get: () => modelValue.value.x,
|
||||
set: (x) => {
|
||||
modelValue.value = { ...modelValue.value, x }
|
||||
}
|
||||
})
|
||||
|
||||
const y = computed({
|
||||
get: () => modelValue.value.y,
|
||||
set: (y) => {
|
||||
modelValue.value = { ...modelValue.value, y }
|
||||
}
|
||||
})
|
||||
|
||||
const width = computed({
|
||||
get: () => modelValue.value.width,
|
||||
set: (width) => {
|
||||
modelValue.value = { ...modelValue.value, width }
|
||||
}
|
||||
})
|
||||
|
||||
const height = computed({
|
||||
get: () => modelValue.value.height,
|
||||
set: (height) => {
|
||||
modelValue.value = { ...modelValue.value, height }
|
||||
}
|
||||
})
|
||||
</script>
|
||||
100
src/components/imagecrop/WidgetImageCrop.vue
Normal file
100
src/components/imagecrop/WidgetImageCrop.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div
|
||||
class="widget-expands relative flex h-full w-full flex-col gap-1"
|
||||
@pointerdown.stop
|
||||
@pointermove.stop
|
||||
@pointerup.stop
|
||||
>
|
||||
<!-- Image preview container -->
|
||||
<div
|
||||
ref="containerEl"
|
||||
class="relative min-h-0 flex-1 overflow-hidden rounded-[5px] bg-node-component-surface"
|
||||
>
|
||||
<div v-if="isLoading" class="flex size-full items-center justify-center">
|
||||
<span class="text-sm">{{ $t('imageCrop.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!imageUrl"
|
||||
class="flex size-full flex-col items-center justify-center text-center"
|
||||
>
|
||||
<i class="mb-2 icon-[lucide--image] h-12 w-12" />
|
||||
<p class="text-sm">{{ $t('imageCrop.noInputImage') }}</p>
|
||||
</div>
|
||||
|
||||
<img
|
||||
v-else
|
||||
ref="imageEl"
|
||||
:src="imageUrl"
|
||||
:alt="$t('imageCrop.cropPreviewAlt')"
|
||||
draggable="false"
|
||||
class="block size-full object-contain select-none brightness-50"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
@dragstart.prevent
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="imageUrl && !isLoading"
|
||||
class="absolute box-content cursor-move overflow-hidden border-2 border-white"
|
||||
:style="cropBoxStyle"
|
||||
@pointerdown="handleDragStart"
|
||||
@pointermove="handleDragMove"
|
||||
@pointerup="handleDragEnd"
|
||||
>
|
||||
<div class="pointer-events-none size-full" :style="cropImageStyle" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="handle in resizeHandles"
|
||||
v-show="imageUrl && !isLoading"
|
||||
:key="handle.direction"
|
||||
:class="['absolute', handle.class]"
|
||||
:style="handle.style"
|
||||
@pointerdown="(e) => handleResizeStart(e, handle.direction)"
|
||||
@pointermove="handleResizeMove"
|
||||
@pointerup="handleResizeEnd"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<WidgetBoundingBox v-model="modelValue" class="shrink-0" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import WidgetBoundingBox from '@/components/boundingbox/WidgetBoundingBox.vue'
|
||||
import { useImageCrop } from '@/composables/useImageCrop'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
|
||||
const props = defineProps<{
|
||||
nodeId: NodeId
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<Bounds>({
|
||||
default: () => ({ x: 0, y: 0, width: 512, height: 512 })
|
||||
})
|
||||
|
||||
const imageEl = useTemplateRef<HTMLImageElement>('imageEl')
|
||||
const containerEl = useTemplateRef<HTMLDivElement>('containerEl')
|
||||
|
||||
const {
|
||||
imageUrl,
|
||||
isLoading,
|
||||
|
||||
cropBoxStyle,
|
||||
cropImageStyle,
|
||||
resizeHandles,
|
||||
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
handleDragStart,
|
||||
handleDragMove,
|
||||
handleDragEnd,
|
||||
handleResizeStart,
|
||||
handleResizeMove,
|
||||
handleResizeEnd
|
||||
} = useImageCrop(props.nodeId, { imageEl, containerEl, modelValue })
|
||||
</script>
|
||||
469
src/composables/useImageCrop.ts
Normal file
469
src/composables/useImageCrop.ts
Normal file
@@ -0,0 +1,469 @@
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
|
||||
type ResizeDirection =
|
||||
| 'top'
|
||||
| 'bottom'
|
||||
| 'left'
|
||||
| 'right'
|
||||
| 'nw'
|
||||
| 'ne'
|
||||
| 'sw'
|
||||
| 'se'
|
||||
|
||||
const HANDLE_SIZE = 8
|
||||
const CORNER_SIZE = 10
|
||||
const MIN_CROP_SIZE = 16
|
||||
const CROP_BOX_BORDER = 2
|
||||
|
||||
interface UseImageCropOptions {
|
||||
imageEl: Ref<HTMLImageElement | null>
|
||||
containerEl: Ref<HTMLDivElement | null>
|
||||
modelValue: Ref<Bounds>
|
||||
}
|
||||
|
||||
export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
||||
const { imageEl, containerEl, modelValue } = options
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
const node = ref<LGraphNode | null>(null)
|
||||
|
||||
const imageUrl = ref<string | null>(null)
|
||||
const isLoading = ref(false)
|
||||
|
||||
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 = computed({
|
||||
get: () => modelValue.value.x,
|
||||
set: (v: number) => {
|
||||
modelValue.value.x = v
|
||||
}
|
||||
})
|
||||
|
||||
const cropY = computed({
|
||||
get: () => modelValue.value.y,
|
||||
set: (v: number) => {
|
||||
modelValue.value.y = v
|
||||
}
|
||||
})
|
||||
|
||||
const cropWidth = computed({
|
||||
get: () => modelValue.value.width || 512,
|
||||
set: (v: number) => {
|
||||
modelValue.value.width = v
|
||||
}
|
||||
})
|
||||
|
||||
const cropHeight = computed({
|
||||
get: () => modelValue.value.height || 512,
|
||||
set: (v: number) => {
|
||||
modelValue.value.height = v
|
||||
}
|
||||
})
|
||||
|
||||
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<ResizeDirection | null>(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)
|
||||
|
||||
useResizeObserver(containerEl, () => {
|
||||
if (imageEl.value && imageUrl.value) {
|
||||
updateDisplayedDimensions()
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
if (naturalWidth.value <= 0 || naturalHeight.value <= 0) {
|
||||
scaleFactor.value = 1
|
||||
return
|
||||
}
|
||||
|
||||
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 = 1
|
||||
} else {
|
||||
scaleFactor.value = displayedWidth.value / naturalWidth.value
|
||||
}
|
||||
}
|
||||
|
||||
const getEffectiveScale = (): number => {
|
||||
const container = containerEl.value
|
||||
|
||||
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 / clientWidth) * rect.width
|
||||
|
||||
return renderedDisplayedWidth / naturalWidth.value
|
||||
}
|
||||
|
||||
const cropBoxStyle = computed(() => ({
|
||||
left: `${imageOffsetX.value + cropX.value * scaleFactor.value - CROP_BOX_BORDER}px`,
|
||||
top: `${imageOffsetY.value + cropY.value * scaleFactor.value - CROP_BOX_BORDER}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<ResizeHandle[]>(() => {
|
||||
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 [
|
||||
{
|
||||
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`
|
||||
}
|
||||
},
|
||||
{
|
||||
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
|
||||
|
||||
cropX.value = Math.round(
|
||||
Math.max(0, Math.min(maxX, dragStartCropX.value + deltaX))
|
||||
)
|
||||
cropY.value = Math.round(
|
||||
Math.max(0, Math.min(maxY, dragStartCropY.value + deltaY))
|
||||
)
|
||||
}
|
||||
|
||||
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) {
|
||||
cropX.value = Math.round(newX)
|
||||
cropWidth.value = Math.round(newWidth)
|
||||
}
|
||||
if (affectsTop || affectsBottom) {
|
||||
cropY.value = Math.round(newY)
|
||||
cropHeight.value = Math.round(newHeight)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResizeEnd = (e: PointerEvent) => {
|
||||
if (!isResizing.value) return
|
||||
|
||||
isResizing.value = false
|
||||
resizeDirection.value = null
|
||||
releasePointer(e)
|
||||
}
|
||||
|
||||
const initialize = () => {
|
||||
if (nodeId != null) {
|
||||
node.value = app.rootGraph?.getNodeById(nodeId) || null
|
||||
}
|
||||
|
||||
updateImageUrl()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => nodeOutputStore.nodeOutputs,
|
||||
() => updateImageUrl(),
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => nodeOutputStore.nodePreviewImages,
|
||||
() => updateImageUrl(),
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(initialize)
|
||||
|
||||
return {
|
||||
imageUrl,
|
||||
isLoading,
|
||||
|
||||
cropX,
|
||||
cropY,
|
||||
cropWidth,
|
||||
cropHeight,
|
||||
|
||||
cropBoxStyle,
|
||||
cropImageStyle,
|
||||
resizeHandles,
|
||||
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
handleDragStart,
|
||||
handleDragMove,
|
||||
handleDragEnd,
|
||||
handleResizeStart,
|
||||
handleResizeMove,
|
||||
handleResizeEnd
|
||||
}
|
||||
}
|
||||
12
src/extensions/core/imageCrop.ts
Normal file
12
src/extensions/core/imageCrop.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.ImageCrop',
|
||||
|
||||
async nodeCreated(node) {
|
||||
if (node.constructor.comfyClass !== 'ImageCrop') return
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 450)])
|
||||
}
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import './groupNode'
|
||||
import './groupNodeManage'
|
||||
import './groupOptions'
|
||||
import './imageCompare'
|
||||
import './imageCrop'
|
||||
import './load3d'
|
||||
import './maskeditor'
|
||||
import './nodeTemplates'
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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.
|
||||
|
||||
22
src/lib/litegraph/src/widgets/BoundingBoxWidget.ts
Normal file
22
src/lib/litegraph/src/widgets/BoundingBoxWidget.ts
Normal 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
|
||||
}
|
||||
}
|
||||
22
src/lib/litegraph/src/widgets/ImageCropWidget.ts
Normal file
22
src/lib/litegraph/src/widgets/ImageCropWidget.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1718,6 +1718,17 @@
|
||||
"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"
|
||||
},
|
||||
"boundingBox": {
|
||||
"x": "X",
|
||||
"y": "Y",
|
||||
"width": "Width",
|
||||
"height": "Height"
|
||||
},
|
||||
"toastMessages": {
|
||||
"nothingToQueue": "Nothing to queue",
|
||||
"pleaseSelectOutputNodes": "Please select output nodes",
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IBoundingBoxWidget,
|
||||
IImageCropWidget,
|
||||
INumericWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import type {
|
||||
BoundingBoxInputSpec,
|
||||
InputSpec as InputSpecV2
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
function isBoundingBoxLikeWidget(
|
||||
widget: IBaseWidget
|
||||
): widget is IBoundingBoxWidget | IImageCropWidget {
|
||||
return widget.type === 'boundingbox' || widget.type === 'imagecrop'
|
||||
}
|
||||
|
||||
function isNumericWidget(widget: IBaseWidget): widget is INumericWidget {
|
||||
return widget.type === 'number'
|
||||
}
|
||||
|
||||
export const useBoundingBoxWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpecV2
|
||||
): IBoundingBoxWidget | IImageCropWidget => {
|
||||
const spec = inputSpec as BoundingBoxInputSpec
|
||||
const { name, component } = spec
|
||||
const defaultValue: Bounds = spec.default ?? {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 512,
|
||||
height: 512
|
||||
}
|
||||
|
||||
const widgetType = component === 'ImageCrop' ? 'imagecrop' : 'boundingbox'
|
||||
|
||||
const fields: (keyof Bounds)[] = ['x', 'y', 'width', 'height']
|
||||
const subWidgets: INumericWidget[] = []
|
||||
|
||||
const rawWidget = node.addWidget(
|
||||
widgetType,
|
||||
name,
|
||||
{ ...defaultValue },
|
||||
null,
|
||||
{
|
||||
serialize: true,
|
||||
canvasOnly: false
|
||||
}
|
||||
)
|
||||
|
||||
if (!isBoundingBoxLikeWidget(rawWidget)) {
|
||||
throw new Error(`Unexpected widget type: ${rawWidget.type}`)
|
||||
}
|
||||
|
||||
const widget = rawWidget
|
||||
|
||||
widget.callback = () => {
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
const field = fields[i]
|
||||
const subWidget = subWidgets[i]
|
||||
if (subWidget) {
|
||||
subWidget.value = widget.value[field]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of fields) {
|
||||
const subWidget = node.addWidget(
|
||||
'number',
|
||||
field,
|
||||
defaultValue[field],
|
||||
function (this: INumericWidget, v: number) {
|
||||
this.value = Math.round(v)
|
||||
widget.value[field] = this.value
|
||||
widget.callback?.(widget.value)
|
||||
},
|
||||
{
|
||||
min: field === 'width' || field === 'height' ? 1 : 0,
|
||||
max: 8192,
|
||||
step: 10,
|
||||
step2: 1,
|
||||
precision: 0,
|
||||
serialize: false,
|
||||
canvasOnly: true
|
||||
}
|
||||
)
|
||||
|
||||
if (!isNumericWidget(subWidget)) {
|
||||
throw new Error(`Unexpected widget type: ${subWidget.type}`)
|
||||
}
|
||||
|
||||
subWidgets.push(subWidget)
|
||||
}
|
||||
|
||||
widget.linkedWidgets = subWidgets
|
||||
|
||||
return widget
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,12 @@ const WidgetAudioUI = defineAsyncComponent(
|
||||
const Load3D = defineAsyncComponent(
|
||||
() => import('@/components/load3d/Load3D.vue')
|
||||
)
|
||||
const WidgetImageCrop = defineAsyncComponent(
|
||||
() => import('@/components/imagecrop/WidgetImageCrop.vue')
|
||||
)
|
||||
const WidgetBoundingBox = defineAsyncComponent(
|
||||
() => import('@/components/boundingbox/WidgetBoundingBox.vue')
|
||||
)
|
||||
|
||||
export const FOR_TESTING = {
|
||||
WidgetAudioUI,
|
||||
@@ -157,7 +163,23 @@ 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: ['IMAGECROP'],
|
||||
essential: false
|
||||
}
|
||||
],
|
||||
[
|
||||
'boundingbox',
|
||||
{
|
||||
component: WidgetBoundingBox,
|
||||
aliases: ['BOUNDINGBOX'],
|
||||
essential: false
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
const getComboWidgetAdditions = (): Map<string, Component> => {
|
||||
|
||||
@@ -64,6 +64,21 @@ const zImageCompareInputSpec = zBaseInputOptions.extend({
|
||||
options: z.record(z.unknown()).optional()
|
||||
})
|
||||
|
||||
const zBoundingBoxInputSpec = zBaseInputOptions.extend({
|
||||
type: z.literal('BOUNDINGBOX'),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional(),
|
||||
component: z.enum(['ImageCrop']).optional(),
|
||||
default: z
|
||||
.object({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
width: z.number(),
|
||||
height: z.number()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
const zMarkdownInputSpec = zBaseInputOptions.extend({
|
||||
type: z.literal('MARKDOWN'),
|
||||
name: z.string(),
|
||||
@@ -126,6 +141,7 @@ const zInputSpec = z.union([
|
||||
zColorInputSpec,
|
||||
zImageInputSpec,
|
||||
zImageCompareInputSpec,
|
||||
zBoundingBoxInputSpec,
|
||||
zMarkdownInputSpec,
|
||||
zChartInputSpec,
|
||||
zGalleriaInputSpec,
|
||||
@@ -169,6 +185,7 @@ type StringInputSpec = z.infer<typeof zStringInputSpec>
|
||||
export type ComboInputSpec = z.infer<typeof zComboInputSpec>
|
||||
export type ColorInputSpec = z.infer<typeof zColorInputSpec>
|
||||
export type ImageCompareInputSpec = z.infer<typeof zImageCompareInputSpec>
|
||||
export type BoundingBoxInputSpec = z.infer<typeof zBoundingBoxInputSpec>
|
||||
export type ChartInputSpec = z.infer<typeof zChartInputSpec>
|
||||
export type GalleriaInputSpec = z.infer<typeof zGalleriaInputSpec>
|
||||
export type TextareaInputSpec = z.infer<typeof zTextareaInputSpec>
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { dynamicWidgets } from '@/core/graph/widgets/dynamicWidgets'
|
||||
import { useBooleanWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useBooleanWidget'
|
||||
import { useBoundingBoxWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useBoundingBoxWidget'
|
||||
import { useChartWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useChartWidget'
|
||||
import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget'
|
||||
import { useComboWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useComboWidget'
|
||||
@@ -301,6 +302,7 @@ export const ComfyWidgets = {
|
||||
IMAGEUPLOAD: useImageUploadWidget(),
|
||||
COLOR: transformWidgetConstructorV2ToV1(useColorWidget()),
|
||||
IMAGECOMPARE: transformWidgetConstructorV2ToV1(useImageCompareWidget()),
|
||||
BOUNDINGBOX: transformWidgetConstructorV2ToV1(useBoundingBoxWidget()),
|
||||
CHART: transformWidgetConstructorV2ToV1(useChartWidget()),
|
||||
GALLERIA: transformWidgetConstructorV2ToV1(useGalleriaWidget()),
|
||||
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()),
|
||||
|
||||
Reference in New Issue
Block a user