mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-20 06:44:32 +00:00
## 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>
223 lines
5.9 KiB
TypeScript
223 lines
5.9 KiB
TypeScript
import { createTestingPinia } from '@pinia/testing'
|
|
import { mount } from '@vue/test-utils'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { computed, toValue } from 'vue'
|
|
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'
|
|
|
|
const mockData = vi.hoisted(() => ({
|
|
mockNodeIds: new Set<string>(),
|
|
mockExecuting: false
|
|
}))
|
|
|
|
vi.mock('@/renderer/core/canvas/canvasStore', () => {
|
|
const getCanvas = vi.fn()
|
|
const useCanvasStore = () => ({
|
|
getCanvas,
|
|
selectedNodeIds: computed(() => mockData.mockNodeIds)
|
|
})
|
|
return {
|
|
useCanvasStore
|
|
}
|
|
})
|
|
|
|
vi.mock(
|
|
'@/renderer/extensions/vueNodes/composables/useNodeEventHandlers',
|
|
() => {
|
|
const handleNodeSelect = vi.fn()
|
|
return { useNodeEventHandlers: () => ({ handleNodeSelect }) }
|
|
}
|
|
)
|
|
|
|
vi.mock(
|
|
'@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking',
|
|
() => ({
|
|
useVueElementTracking: vi.fn()
|
|
})
|
|
)
|
|
|
|
vi.mock('@/composables/useErrorHandling', () => ({
|
|
useErrorHandling: () => ({
|
|
toastErrorHandler: vi.fn()
|
|
})
|
|
}))
|
|
|
|
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({
|
|
useNodeLayout: () => ({
|
|
position: { x: 100, y: 50 },
|
|
size: { width: 200, height: 100 },
|
|
startDrag: vi.fn(),
|
|
handleDrag: vi.fn(),
|
|
endDrag: vi.fn()
|
|
})
|
|
}))
|
|
|
|
vi.mock(
|
|
'@/renderer/extensions/vueNodes/execution/useNodeExecutionState',
|
|
() => ({
|
|
useNodeExecutionState: vi.fn(() => ({
|
|
executing: computed(() => mockData.mockExecuting),
|
|
progress: computed(() => undefined),
|
|
progressPercentage: computed(() => undefined),
|
|
progressState: computed(() => undefined as any),
|
|
executionState: computed(() => 'idle' as const)
|
|
}))
|
|
})
|
|
)
|
|
|
|
vi.mock('@/renderer/extensions/vueNodes/preview/useNodePreviewState', () => ({
|
|
useNodePreviewState: vi.fn(() => ({
|
|
latestPreviewUrl: computed(() => ''),
|
|
shouldShowPreviewImg: computed(() => false)
|
|
}))
|
|
}))
|
|
|
|
vi.mock('../composables/useNodeResize', () => ({
|
|
useNodeResize: vi.fn(() => ({
|
|
startResize: vi.fn(),
|
|
isResizing: computed(() => false)
|
|
}))
|
|
}))
|
|
|
|
const i18n = createI18n({
|
|
legacy: false,
|
|
locale: 'en',
|
|
messages: {
|
|
en: {
|
|
'Node Render Error': 'Node Render Error'
|
|
}
|
|
}
|
|
})
|
|
function mountLGraphNode(props: ComponentProps<typeof LGraphNode>) {
|
|
return mount(LGraphNode, {
|
|
props,
|
|
global: {
|
|
plugins: [
|
|
createTestingPinia({
|
|
createSpy: vi.fn
|
|
}),
|
|
i18n
|
|
],
|
|
provide: {
|
|
[TransformStateKey as symbol]: {
|
|
screenToCanvas: vi.fn(),
|
|
canvasToScreen: vi.fn(),
|
|
camera: { z: 1 },
|
|
isNodeInViewport: vi.fn()
|
|
}
|
|
},
|
|
stubs: {
|
|
NodeHeader: true,
|
|
NodeSlots: true,
|
|
NodeWidgets: true,
|
|
NodeContent: true,
|
|
SlotConnectionDot: true
|
|
}
|
|
}
|
|
})
|
|
}
|
|
const mockNodeData: VueNodeData = {
|
|
id: 'test-node-123',
|
|
title: 'Test Node',
|
|
type: 'TestNode',
|
|
mode: 0,
|
|
flags: {},
|
|
inputs: [],
|
|
outputs: [],
|
|
widgets: [],
|
|
selected: false,
|
|
executing: false
|
|
}
|
|
|
|
describe('LGraphNode', () => {
|
|
beforeEach(() => {
|
|
vi.resetAllMocks()
|
|
mockData.mockNodeIds = new Set()
|
|
mockData.mockExecuting = false
|
|
})
|
|
|
|
it('should call resize tracking composable with node ID', () => {
|
|
mountLGraphNode({ nodeData: mockNodeData })
|
|
|
|
expect(useVueElementTracking).toHaveBeenCalledWith(
|
|
expect.any(Function),
|
|
'node'
|
|
)
|
|
const idArg = vi.mocked(useVueElementTracking).mock.calls[0]?.[0]
|
|
const id = toValue(idArg)
|
|
expect(id).toEqual('test-node-123')
|
|
})
|
|
|
|
it('should render with data-node-id attribute', () => {
|
|
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
|
|
|
|
expect(wrapper.attributes('data-node-id')).toBe('test-node-123')
|
|
})
|
|
|
|
it('should render node title', () => {
|
|
// Don't stub NodeHeader for this test so we can see the title
|
|
const wrapper = mount(LGraphNode, {
|
|
props: { nodeData: mockNodeData },
|
|
global: {
|
|
plugins: [
|
|
createTestingPinia({
|
|
createSpy: vi.fn
|
|
}),
|
|
i18n
|
|
],
|
|
provide: {
|
|
[TransformStateKey as symbol]: {
|
|
screenToCanvas: vi.fn(),
|
|
canvasToScreen: vi.fn(),
|
|
camera: { z: 1 },
|
|
isNodeInViewport: vi.fn()
|
|
}
|
|
},
|
|
stubs: {
|
|
NodeSlots: true,
|
|
NodeWidgets: true,
|
|
NodeContent: true,
|
|
SlotConnectionDot: true
|
|
}
|
|
}
|
|
})
|
|
|
|
expect(wrapper.text()).toContain('Test Node')
|
|
})
|
|
|
|
it('should apply selected styling when selected prop is true', () => {
|
|
mockData.mockNodeIds = new Set(['test-node-123'])
|
|
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
|
|
expect(wrapper.classes()).toContain('outline-2')
|
|
expect(wrapper.classes()).toContain('outline-node-component-outline')
|
|
})
|
|
|
|
it('should apply executing animation when executing prop is true', () => {
|
|
mockData.mockExecuting = true
|
|
|
|
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
|
|
|
|
expect(wrapper.classes()).toContain('animate-pulse')
|
|
})
|
|
|
|
it('should emit node-click event on pointer up', async () => {
|
|
const { handleNodeSelect } = useNodeEventHandlers()
|
|
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
|
|
|
|
await wrapper.trigger('pointerup')
|
|
|
|
expect(handleNodeSelect).toHaveBeenCalledOnce()
|
|
expect(handleNodeSelect).toHaveBeenCalledWith(
|
|
expect.any(PointerEvent),
|
|
mockNodeData,
|
|
expect.any(Boolean)
|
|
)
|
|
})
|
|
})
|