mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-02 11:40:00 +00:00
## Summary ### Problem: After [vue node compacting PR](https://github.com/Comfy-Org/ComfyUI_frontend/pull/6687) the white space within the node has been greatly reduced, lowering the min intrinsic size, thus allowing us to reduce the amount we need to scale up via ensureCorrectLayoutScale(), therefore increasing readability of nodes. Great! However, a side effect of reducing the scale factor means nodes with larger min content will not be scaled up enough causing nodes to be too large in many cases. For example, if the min intrinsic width is very long due to input length: <img width="807" height="519" alt="image" src="https://github.com/user-attachments/assets/a6ea3852-bed5-49b2-b10e-c2e65c6450b2" /> ### Solution: Allow for nodes to be resized less than their intrinsic min width. And truncate widget inputs like many other node UIs do. IMPORTANT: when a node is added via search or other, it will still get a min size based on its intrinsic content it just wont be the min width! So best of both worlds. <img width="670" height="551" alt="image" src="https://github.com/user-attachments/assets/f4f5ec8c-037e-472f-a5a1-d8a59a87c0b0" /> this means we choose a default min width and clamp resize to it. This also means we have to remove the arbitrary min width values that were sprinkled around the vue node widgets. They are not needed because instead of min width, they can take up full width and inherit the sizing from the node min width! This makes nodes like little browser windows and widgets are just responsive elements with in. Much more natural imo. ### Bonus - Set ensureCorrectLayouScale() to scale factor of 1.2 which means vue nodes are now only being set 20% bigger than LG. That covers for the height difference we cant change! - Fix ensureCorrectLayouScale() to offset y position for groups / better alignment - Get rid of arbitrary inflexible min width like min-[417px] which shouldnt have been used the first place - Make Select and Input overlay portals width set to their content ## Changes **What**: - Node resizing behavior - Node widget min width - Widget input and slot truncation - Misc arbitrary styling that should have been fluid ## Screenshots (if applicable) https://github.com/user-attachments/assets/3ea4b8fe-565a-47f7-b3ab-6cef56cecde5 https://github.com/user-attachments/assets/2fe1e1a0-a9dc-4000-b865-ce2d8c7f3606 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6731-fix-arbitrary-styles-min-size-content-ensure-layout-calc-trunc-2af6d73d365081eab507c2f1638a4194) by [Unito](https://www.unito.io) --------- Co-authored-by: github-actions <github-actions@github.com>
530 lines
17 KiB
Vue
530 lines
17 KiB
Vue
<template>
|
|
<div v-if="renderError" class="node-error p-2 text-sm text-red-500">
|
|
{{ st('nodeErrors.render', 'Node Render Error') }}
|
|
</div>
|
|
<div
|
|
v-else
|
|
ref="nodeContainerRef"
|
|
:data-node-id="nodeData.id"
|
|
:class="
|
|
cn(
|
|
'bg-component-node-background lg-node absolute',
|
|
|
|
'contain-style contain-layout min-w-[225px] min-h-(--node-height) w-(--node-width)',
|
|
'rounded-2xl touch-none flex flex-col',
|
|
'border-1 border-solid border-component-node-border',
|
|
// hover (only when node should handle events)
|
|
shouldHandleNodePointerEvents &&
|
|
'hover:ring-7 ring-node-component-ring',
|
|
'outline-transparent outline-2',
|
|
borderClass,
|
|
outlineClass,
|
|
{
|
|
'before:rounded-2xl before:pointer-events-none before:absolute before:bg-bypass/60 before:inset-0':
|
|
bypassed,
|
|
'before:rounded-2xl before:pointer-events-none before:absolute before:inset-0':
|
|
muted,
|
|
'will-change-transform': isDragging,
|
|
'ring-4 ring-primary-500 bg-primary-500/10': isDraggingOver
|
|
},
|
|
|
|
shouldHandleNodePointerEvents
|
|
? 'pointer-events-auto'
|
|
: 'pointer-events-none'
|
|
)
|
|
"
|
|
:style="[
|
|
{
|
|
transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
|
|
zIndex: zIndex,
|
|
opacity: nodeOpacity,
|
|
'--component-node-background': nodeBodyBackgroundColor
|
|
},
|
|
dragStyle
|
|
]"
|
|
v-bind="pointerHandlers"
|
|
@wheel="handleWheel"
|
|
@contextmenu="handleContextMenu"
|
|
@dragover.prevent="handleDragOver"
|
|
@dragleave="handleDragLeave"
|
|
@drop.stop.prevent="handleDrop"
|
|
>
|
|
<div class="flex flex-col justify-center items-center relative">
|
|
<template v-if="isCollapsed">
|
|
<SlotConnectionDot
|
|
v-if="hasInputs"
|
|
multi
|
|
class="absolute left-0 -translate-x-1/2"
|
|
/>
|
|
<SlotConnectionDot
|
|
v-if="hasOutputs"
|
|
multi
|
|
class="absolute right-0 translate-x-1/2"
|
|
/>
|
|
<NodeSlots :node-data="nodeData" unified />
|
|
</template>
|
|
<NodeHeader
|
|
:node-data="nodeData"
|
|
:collapsed="isCollapsed"
|
|
@collapse="handleCollapse"
|
|
@update:title="handleHeaderTitleUpdate"
|
|
@enter-subgraph="handleEnterSubgraph"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
v-if="isCollapsed && executing && progress !== undefined"
|
|
:class="
|
|
cn(
|
|
'absolute inset-x-4 -bottom-[1px] translate-y-1/2 rounded-full',
|
|
progressClasses
|
|
)
|
|
"
|
|
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
|
|
/>
|
|
|
|
<template v-if="!isCollapsed">
|
|
<div class="relative mb-1">
|
|
<!-- Progress bar for executing state -->
|
|
<div
|
|
v-if="executing && progress !== undefined"
|
|
:class="
|
|
cn(
|
|
'absolute inset-x-0 top-1/2 -translate-y-1/2',
|
|
!!(progress < 1) && 'rounded-r-full',
|
|
progressClasses
|
|
)
|
|
"
|
|
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Node Body - rendered based on LOD level and collapsed state -->
|
|
<div
|
|
class="flex flex-1 flex-col gap-1 pb-2"
|
|
:data-testid="`node-body-${nodeData.id}`"
|
|
>
|
|
<!-- Slots only rendered at full detail -->
|
|
<NodeSlots :node-data="nodeData" />
|
|
|
|
<!-- Widgets rendered at reduced+ detail -->
|
|
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
|
|
|
|
<!-- Custom content at reduced+ detail -->
|
|
<div v-if="hasCustomContent" class="min-h-0 flex-1 flex">
|
|
<NodeContent :node-data="nodeData" :media="nodeMedia" />
|
|
</div>
|
|
<!-- Live mid-execution preview images -->
|
|
<div v-if="shouldShowPreviewImg" class="min-h-0 flex-1 px-4">
|
|
<LivePreview :image-url="latestPreviewUrl || null" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Resize handles -->
|
|
<template v-if="!isCollapsed">
|
|
<div
|
|
v-for="handle in cornerResizeHandles"
|
|
:key="handle.id"
|
|
role="button"
|
|
:aria-label="handle.ariaLabel"
|
|
:class="cn(baseResizeHandleClasses, handle.classes)"
|
|
@pointerdown.stop="handleResizePointerDown(handle.direction)($event)"
|
|
/>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { storeToRefs } from 'pinia'
|
|
import { computed, inject, onErrorCaptured, onMounted, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
|
import { toggleNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
|
|
import { useErrorHandling } from '@/composables/useErrorHandling'
|
|
import { st } from '@/i18n'
|
|
import { LGraphEventMode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
|
import { useTelemetry } from '@/platform/telemetry'
|
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
|
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
|
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
|
|
import SlotConnectionDot from '@/renderer/extensions/vueNodes/components/SlotConnectionDot.vue'
|
|
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
|
|
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
|
|
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
|
|
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
|
|
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
|
|
import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState'
|
|
import { nonWidgetedInputs } from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
|
|
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
|
|
import { app } from '@/scripts/app'
|
|
import { useExecutionStore } from '@/stores/executionStore'
|
|
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
|
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
|
import {
|
|
getLocatorIdFromNodeData,
|
|
getNodeByLocatorId
|
|
} from '@/utils/graphTraversalUtil'
|
|
import { cn } from '@/utils/tailwindUtil'
|
|
|
|
import type { ResizeHandleDirection } from '../interactions/resize/resizeMath'
|
|
import { useNodeResize } from '../interactions/resize/useNodeResize'
|
|
import LivePreview from './LivePreview.vue'
|
|
import NodeContent from './NodeContent.vue'
|
|
import NodeHeader from './NodeHeader.vue'
|
|
import NodeSlots from './NodeSlots.vue'
|
|
import NodeWidgets from './NodeWidgets.vue'
|
|
|
|
// Extended props for main node component
|
|
interface LGraphNodeProps {
|
|
nodeData: VueNodeData
|
|
error?: string | null
|
|
zoomLevel?: number
|
|
}
|
|
|
|
const { nodeData, error = null } = defineProps<LGraphNodeProps>()
|
|
|
|
const { t } = useI18n()
|
|
|
|
const {
|
|
handleNodeCollapse,
|
|
handleNodeTitleUpdate,
|
|
handleNodeSelect,
|
|
handleNodeRightClick
|
|
} = useNodeEventHandlers()
|
|
|
|
useVueElementTracking(() => nodeData.id, 'node')
|
|
|
|
const transformState = inject(TransformStateKey)
|
|
if (!transformState) {
|
|
throw new Error(
|
|
'TransformState must be provided for node resize functionality'
|
|
)
|
|
}
|
|
|
|
const { selectedNodeIds } = storeToRefs(useCanvasStore())
|
|
const isSelected = computed(() => {
|
|
return selectedNodeIds.value.has(nodeData.id)
|
|
})
|
|
|
|
const nodeLocatorId = computed(() => getLocatorIdFromNodeData(nodeData))
|
|
const { executing, progress } = useNodeExecutionState(nodeLocatorId)
|
|
const executionStore = useExecutionStore()
|
|
const hasExecutionError = computed(
|
|
() => executionStore.lastExecutionErrorNodeId === nodeData.id
|
|
)
|
|
|
|
const hasAnyError = computed((): boolean => {
|
|
return !!(
|
|
hasExecutionError.value ||
|
|
nodeData.hasErrors ||
|
|
error ||
|
|
(executionStore.lastNodeErrors?.[nodeData.id]?.errors.length ?? 0) > 0
|
|
)
|
|
})
|
|
|
|
const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)
|
|
const bypassed = computed(
|
|
(): boolean => nodeData.mode === LGraphEventMode.BYPASS
|
|
)
|
|
const muted = computed((): boolean => nodeData.mode === LGraphEventMode.NEVER)
|
|
|
|
const nodeBodyBackgroundColor = computed(() => {
|
|
const colorPaletteStore = useColorPaletteStore()
|
|
|
|
if (!nodeData.bgcolor) {
|
|
return ''
|
|
}
|
|
|
|
return applyLightThemeColor(
|
|
nodeData.bgcolor,
|
|
Boolean(colorPaletteStore.completedActivePalette.light_theme)
|
|
)
|
|
})
|
|
|
|
const nodeOpacity = computed(() => {
|
|
const globalOpacity = useSettingStore().get('Comfy.Node.Opacity') ?? 1
|
|
|
|
// For muted/bypassed nodes, apply the 0.5 multiplier on top of global opacity
|
|
if (bypassed.value || muted.value) {
|
|
return globalOpacity * 0.5
|
|
}
|
|
|
|
return globalOpacity
|
|
})
|
|
|
|
const hasInputs = computed(() => nonWidgetedInputs(nodeData).length > 0)
|
|
const hasOutputs = computed((): boolean => !!nodeData.outputs?.length)
|
|
|
|
// Use canvas interactions for proper wheel event handling and pointer event capture control
|
|
const { handleWheel, shouldHandleNodePointerEvents } = useCanvasInteractions()
|
|
|
|
// Error boundary implementation
|
|
const renderError = ref<string | null>(null)
|
|
const { toastErrorHandler } = useErrorHandling()
|
|
|
|
onErrorCaptured((error) => {
|
|
renderError.value = error.message
|
|
toastErrorHandler(error)
|
|
return false // Prevent error propagation
|
|
})
|
|
|
|
const { position, size, zIndex, moveNodeTo } = useNodeLayout(() => nodeData.id)
|
|
const { pointerHandlers, isDragging, dragStyle } = useNodePointerInteractions(
|
|
() => nodeData,
|
|
handleNodeSelect
|
|
)
|
|
|
|
// Handle right-click context menu
|
|
const handleContextMenu = (event: MouseEvent) => {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
|
|
// First handle the standard right-click behavior (selection)
|
|
handleNodeRightClick(event as PointerEvent, nodeData)
|
|
|
|
// Show the node options menu at the cursor position
|
|
const targetElement = event.currentTarget as HTMLElement
|
|
if (targetElement) {
|
|
toggleNodeOptions(event, targetElement, false)
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
// Set initial DOM size from layout store, but respect intrinsic content minimum
|
|
if (size.value && nodeContainerRef.value) {
|
|
nodeContainerRef.value.style.setProperty(
|
|
'--node-width',
|
|
`${size.value.width}px`
|
|
)
|
|
nodeContainerRef.value.style.setProperty(
|
|
'--node-height',
|
|
`${size.value.height}px`
|
|
)
|
|
}
|
|
})
|
|
|
|
const baseResizeHandleClasses =
|
|
'absolute h-3 w-3 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
|
|
const POSITION_EPSILON = 0.01
|
|
|
|
type CornerResizeHandle = {
|
|
id: string
|
|
direction: ResizeHandleDirection
|
|
classes: string
|
|
ariaLabel: string
|
|
}
|
|
|
|
const cornerResizeHandles: CornerResizeHandle[] = [
|
|
{
|
|
id: 'se',
|
|
direction: { horizontal: 'right', vertical: 'bottom' },
|
|
classes: 'right-0 bottom-0 cursor-se-resize',
|
|
ariaLabel: t('g.resizeFromBottomRight')
|
|
},
|
|
{
|
|
id: 'ne',
|
|
direction: { horizontal: 'right', vertical: 'top' },
|
|
classes: 'right-0 top-0 cursor-ne-resize',
|
|
ariaLabel: t('g.resizeFromTopRight')
|
|
},
|
|
{
|
|
id: 'sw',
|
|
direction: { horizontal: 'left', vertical: 'bottom' },
|
|
classes: 'left-0 bottom-0 cursor-sw-resize',
|
|
ariaLabel: t('g.resizeFromBottomLeft')
|
|
},
|
|
{
|
|
id: 'nw',
|
|
direction: { horizontal: 'left', vertical: 'top' },
|
|
classes: 'left-0 top-0 cursor-nw-resize',
|
|
ariaLabel: t('g.resizeFromTopLeft')
|
|
}
|
|
]
|
|
|
|
const MIN_NODE_WIDTH = 225
|
|
|
|
const { startResize } = useNodeResize(
|
|
(result, element) => {
|
|
if (isCollapsed.value) return
|
|
|
|
// Clamp width to minimum to avoid conflicts with CSS min-width
|
|
const clampedWidth = Math.max(result.size.width, MIN_NODE_WIDTH)
|
|
|
|
// Apply size directly to DOM element - ResizeObserver will pick this up
|
|
element.style.setProperty('--node-width', `${clampedWidth}px`)
|
|
element.style.setProperty('--node-height', `${result.size.height}px`)
|
|
|
|
const currentPosition = position.value
|
|
const deltaX = Math.abs(result.position.x - currentPosition.x)
|
|
const deltaY = Math.abs(result.position.y - currentPosition.y)
|
|
|
|
if (deltaX > POSITION_EPSILON || deltaY > POSITION_EPSILON) {
|
|
moveNodeTo(result.position)
|
|
}
|
|
},
|
|
{
|
|
transformState
|
|
}
|
|
)
|
|
|
|
const handleResizePointerDown = (direction: ResizeHandleDirection) => {
|
|
return (event: PointerEvent) => {
|
|
if (nodeData.flags?.pinned) return
|
|
|
|
startResize(event, direction, { ...position.value })
|
|
}
|
|
}
|
|
|
|
watch(isCollapsed, (collapsed) => {
|
|
const element = nodeContainerRef.value
|
|
if (!element) return
|
|
const [from, to] = collapsed ? ['', '-x'] : ['-x', '']
|
|
const currentWidth = element.style.getPropertyValue(`--node-width${from}`)
|
|
element.style.setProperty(`--node-width${to}`, currentWidth)
|
|
element.style.setProperty(`--node-width${from}`, '')
|
|
|
|
const currentHeight = element.style.getPropertyValue(`--node-height${from}`)
|
|
element.style.setProperty(`--node-height${to}`, currentHeight)
|
|
element.style.setProperty(`--node-height${from}`, '')
|
|
})
|
|
|
|
// Check if node has custom content (like image/video outputs)
|
|
const hasCustomContent = computed(() => {
|
|
// Show custom content if node has media outputs
|
|
return !!nodeMedia.value && nodeMedia.value.urls.length > 0
|
|
})
|
|
|
|
// Computed classes and conditions for better reusability
|
|
const progressClasses = 'h-2 bg-primary-500 transition-all duration-300'
|
|
|
|
const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
|
|
() => nodeData.id,
|
|
{
|
|
isCollapsed
|
|
}
|
|
)
|
|
|
|
const borderClass = computed(() => {
|
|
if (hasAnyError.value) return 'border-node-stroke-error'
|
|
if (executing.value) return 'border-node-stroke-executing'
|
|
return 'border-node-stroke'
|
|
})
|
|
|
|
const outlineClass = computed(() => {
|
|
return cn(
|
|
isSelected.value &&
|
|
((hasAnyError.value && 'outline-error ') ||
|
|
(executing.value && 'outline-node-executing') ||
|
|
'outline-node-component-outline')
|
|
)
|
|
})
|
|
|
|
// Event handlers
|
|
const handleCollapse = () => {
|
|
handleNodeCollapse(nodeData.id, !isCollapsed.value)
|
|
}
|
|
|
|
const handleHeaderTitleUpdate = (newTitle: string) => {
|
|
handleNodeTitleUpdate(nodeData.id, newTitle)
|
|
}
|
|
|
|
const handleEnterSubgraph = () => {
|
|
useTelemetry()?.trackUiButtonClicked({
|
|
button_id: 'graph_node_open_subgraph_clicked'
|
|
})
|
|
const graph = app.graph?.rootGraph || app.graph
|
|
if (!graph) {
|
|
console.warn('LGraphNode: No graph available for subgraph navigation')
|
|
return
|
|
}
|
|
|
|
const locatorId = getLocatorIdFromNodeData(nodeData)
|
|
|
|
const litegraphNode = getNodeByLocatorId(graph, locatorId)
|
|
|
|
if (!litegraphNode?.isSubgraphNode() || !('subgraph' in litegraphNode)) {
|
|
console.warn('LGraphNode: Node is not a valid subgraph node', litegraphNode)
|
|
return
|
|
}
|
|
|
|
const canvas = app.canvas
|
|
if (!canvas || typeof canvas.openSubgraph !== 'function') {
|
|
console.warn('LGraphNode: Canvas or openSubgraph method not available')
|
|
return
|
|
}
|
|
|
|
canvas.openSubgraph(litegraphNode.subgraph, litegraphNode)
|
|
}
|
|
|
|
const nodeOutputs = useNodeOutputStore()
|
|
|
|
const nodeOutputLocatorId = computed(() =>
|
|
nodeData.subgraphId ? `${nodeData.subgraphId}:${nodeData.id}` : nodeData.id
|
|
)
|
|
|
|
const lgraphNode = computed(() => {
|
|
const locatorId = getLocatorIdFromNodeData(nodeData)
|
|
const rootGraph = app.graph?.rootGraph || app.graph
|
|
if (!rootGraph) return null
|
|
return getNodeByLocatorId(rootGraph, locatorId)
|
|
})
|
|
|
|
const nodeMedia = computed(() => {
|
|
const newOutputs = nodeOutputs.nodeOutputs[nodeOutputLocatorId.value]
|
|
const node = lgraphNode.value
|
|
|
|
if (!node || !newOutputs?.images?.length) return undefined
|
|
|
|
const urls = nodeOutputs.getNodeImageUrls(node)
|
|
if (!urls?.length) return undefined
|
|
|
|
// Determine media type from previewMediaType or fallback to input slot types
|
|
// Note: Despite the field name "images", videos are also included in outputs
|
|
// TODO: fix the backend to return videos using the videos key instead of the images key
|
|
const hasVideoInput = node.inputs?.some((input) => input.type === 'VIDEO')
|
|
const type =
|
|
node.previewMediaType === 'video' ||
|
|
(!node.previewMediaType && hasVideoInput)
|
|
? 'video'
|
|
: 'image'
|
|
|
|
return { type, urls } as const
|
|
})
|
|
|
|
const nodeContainerRef = ref<HTMLDivElement>()
|
|
|
|
// Drag and drop support
|
|
const isDraggingOver = ref(false)
|
|
|
|
function handleDragOver(event: DragEvent) {
|
|
const node = lgraphNode.value
|
|
if (!node || !node.onDragOver) {
|
|
isDraggingOver.value = false
|
|
return
|
|
}
|
|
|
|
// Call the litegraph node's onDragOver callback to check if files are valid
|
|
const canDrop = node.onDragOver(event)
|
|
isDraggingOver.value = canDrop
|
|
}
|
|
|
|
function handleDragLeave() {
|
|
isDraggingOver.value = false
|
|
}
|
|
|
|
async function handleDrop(event: DragEvent) {
|
|
isDraggingOver.value = false
|
|
|
|
const node = lgraphNode.value
|
|
if (!node || !node.onDragDrop) {
|
|
return
|
|
}
|
|
|
|
// Forward the drop event to the litegraph node's onDragDrop callback
|
|
await node.onDragDrop(event)
|
|
}
|
|
</script>
|