make Vue nodes resizable (#5936)

## Summary

Implemented node resizing functionality for Vue nodes.


https://github.com/user-attachments/assets/a7536045-1fa5-401b-8d18-7c26b4dfbfc3

Resolves https://github.com/Comfy-Org/ComfyUI_frontend/issues/5675.

## Review Focus

ResizeObserver as single source of truth pattern eliminates feedback
loops between manual resize and reactive layout updates. Intrinsic
content sizing calculation temporarily resets DOM styles to measure
natural content dimensions.

```mermaid
graph TD
    A[User Drags Handle] --> B[Direct DOM Style Update]
    B --> C[ResizeObserver Detects Change]
    C --> D[Layout Store Update]
    D --> E[Slot Position Sync]
    
    style A fill:#f9f9f9,stroke:#333,color:#000
    style E fill:#f9f9f9,stroke:#333,color:#000
```

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5936-make-Vue-nodes-resizable-2846d73d36508160b3b9db49ad8b273e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Christian Byrne
2025-10-07 15:53:10 -07:00
committed by GitHub
parent 89f4452488
commit d9157925f5
6 changed files with 190 additions and 1 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -21,7 +21,7 @@ import type { useTransformState } from '@/renderer/core/layout/transform/useTran
* const state = inject(TransformStateKey)!
* const screen = state.canvasToScreen({ x: 100, y: 50 })
*/
interface TransformState
export interface TransformState
extends Pick<
ReturnType<typeof useTransformState>,
'screenToCanvas' | 'canvasToScreen' | 'camera' | 'isNodeInViewport'

View File

@@ -113,6 +113,13 @@
</div>
</div>
</template>
<!-- Resize handle -->
<div
v-if="!isCollapsed"
class="absolute bottom-0 right-0 w-3 h-3 cursor-se-resize opacity-0 hover:opacity-20 hover:bg-white transition-opacity duration-200"
@pointerdown.stop="startResize"
/>
</div>
</template>
@@ -145,6 +152,7 @@ import {
} from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil'
import { useNodeResize } from '../composables/useNodeResize'
import NodeContent from './NodeContent.vue'
import NodeHeader from './NodeHeader.vue'
import NodeSlots from './NodeSlots.vue'
@@ -173,6 +181,11 @@ const { selectedNodeIds } = storeToRefs(useCanvasStore())
// Inject transform state for coordinate conversion
const transformState = inject(TransformStateKey)
if (!transformState) {
throw new Error(
'TransformState must be provided for node resize functionality'
)
}
// Computed selection state - only this node re-evaluates when its selection changes
const isSelected = computed(() => {
@@ -264,6 +277,19 @@ onMounted(() => {
}
})
const { startResize } = useNodeResize(
(newSize, element) => {
// Apply size directly to DOM element - ResizeObserver will pick this up
if (isCollapsed.value) return
element.style.width = `${newSize.width}px`
element.style.height = `${newSize.height}px`
},
{
transformState
}
)
// Track collapsed state
const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)

View File

@@ -0,0 +1,135 @@
import { useEventListener } from '@vueuse/core'
import { ref } from 'vue'
import type { TransformState } from '@/renderer/core/layout/injectionKeys'
interface Size {
width: number
height: number
}
interface Position {
x: number
y: number
}
interface UseNodeResizeOptions {
/** Transform state for coordinate conversion */
transformState: TransformState
}
/**
* Composable for node resizing functionality
*
* Provides resize handle interaction that integrates with the layout system.
* Handles pointer capture, coordinate calculations, and size constraints.
*/
export function useNodeResize(
resizeCallback: (size: Size, element: HTMLElement) => void,
options: UseNodeResizeOptions
) {
const { transformState } = options
const isResizing = ref(false)
const resizeStartPos = ref<Position | null>(null)
const resizeStartSize = ref<Size | null>(null)
const intrinsicMinSize = ref<Size | null>(null)
const startResize = (event: PointerEvent) => {
event.preventDefault()
event.stopPropagation()
const target = event.currentTarget
if (!(target instanceof HTMLElement)) return
// Capture pointer to ensure we get all move/up events
target.setPointerCapture(event.pointerId)
isResizing.value = true
resizeStartPos.value = { x: event.clientX, y: event.clientY }
// Get current node size from the DOM and calculate intrinsic min size
const nodeElement = target.closest('[data-node-id]')
if (!(nodeElement instanceof HTMLElement)) return
const rect = nodeElement.getBoundingClientRect()
// Calculate intrinsic content size once at start
const originalWidth = nodeElement.style.width
const originalHeight = nodeElement.style.height
nodeElement.style.width = 'auto'
nodeElement.style.height = 'auto'
const intrinsicRect = nodeElement.getBoundingClientRect()
// Restore original size
nodeElement.style.width = originalWidth
nodeElement.style.height = originalHeight
// Convert to canvas coordinates using transform state
const scale = transformState.camera.z
resizeStartSize.value = {
width: rect.width / scale,
height: rect.height / scale
}
intrinsicMinSize.value = {
width: intrinsicRect.width / scale,
height: intrinsicRect.height / scale
}
const handlePointerMove = (moveEvent: PointerEvent) => {
if (
!isResizing.value ||
!resizeStartPos.value ||
!resizeStartSize.value ||
!intrinsicMinSize.value
)
return
const dx = moveEvent.clientX - resizeStartPos.value.x
const dy = moveEvent.clientY - resizeStartPos.value.y
// Apply scale factor from transform state
const scale = transformState.camera.z
const scaledDx = dx / scale
const scaledDy = dy / scale
// Apply constraints: only minimum size based on content, no maximum
const newWidth = Math.max(
intrinsicMinSize.value.width,
resizeStartSize.value.width + scaledDx
)
const newHeight = Math.max(
intrinsicMinSize.value.height,
resizeStartSize.value.height + scaledDy
)
// Get the node element to apply size directly
const nodeElement = target.closest('[data-node-id]')
if (nodeElement instanceof HTMLElement) {
resizeCallback({ width: newWidth, height: newHeight }, nodeElement)
}
}
const handlePointerUp = (upEvent: PointerEvent) => {
if (isResizing.value) {
isResizing.value = false
resizeStartPos.value = null
resizeStartSize.value = null
intrinsicMinSize.value = null
target.releasePointerCapture(upEvent.pointerId)
stopMoveListen()
stopUpListen()
}
}
const stopMoveListen = useEventListener('pointermove', handlePointerMove)
const stopUpListen = useEventListener('pointerup', handlePointerUp)
}
return {
startResize,
isResizing
}
}

View File

@@ -20,6 +20,7 @@ import { useSharedCanvasPositionConversion } from '@/composables/element/useCanv
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
import { LayoutSource } from '@/renderer/core/layout/types'
import { syncNodeSlotLayoutsFromDOM } from './useSlotElementTracking'
@@ -124,6 +125,9 @@ const resizeObserver = new ResizeObserver((entries) => {
}
}
// Set source to Vue before processing DOM-driven updates
layoutStore.setSource(LayoutSource.Vue)
// Flush per-type
for (const [type, updates] of updatesByType) {
const config = trackingConfigs.get(type)

View File

@@ -6,6 +6,7 @@ import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
@@ -77,6 +78,13 @@ vi.mock('@/renderer/extensions/vueNodes/preview/useNodePreviewState', () => ({
}))
}))
vi.mock('../composables/useNodeResize', () => ({
useNodeResize: vi.fn(() => ({
startResize: vi.fn(),
isResizing: computed(() => false)
}))
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -96,6 +104,14 @@ function mountLGraphNode(props: ComponentProps<typeof LGraphNode>) {
}),
i18n
],
provide: {
[TransformStateKey as symbol]: {
screenToCanvas: vi.fn(),
canvasToScreen: vi.fn(),
camera: { z: 1 },
isNodeInViewport: vi.fn()
}
},
stubs: {
NodeHeader: true,
NodeSlots: true,
@@ -155,6 +171,14 @@ describe('LGraphNode', () => {
}),
i18n
],
provide: {
[TransformStateKey as symbol]: {
screenToCanvas: vi.fn(),
canvasToScreen: vi.fn(),
camera: { z: 1 },
isNodeInViewport: vi.fn()
}
},
stubs: {
NodeSlots: true,
NodeWidgets: true,