mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-19 22:34:15 +00:00
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:
Binary file not shown.
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
135
src/renderer/extensions/vueNodes/composables/useNodeResize.ts
Normal file
135
src/renderer/extensions/vueNodes/composables/useNodeResize.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user