diff --git a/package.json b/package.json index 5c1a17cb3..b441e421f 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,11 @@ "format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}' --cache", "format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --list-different", "format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'", + "test:all": "nx run test", "test:browser": "npx nx e2e", - "test:unit": "nx run test tests-ui/tests", "test:component": "nx run test src/components/", "test:litegraph": "vitest run --config vitest.litegraph.config.ts", + "test:unit": "nx run test tests-ui/tests", "preinstall": "npx only-allow pnpm", "prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true", "preview": "nx preview", diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index e40466a3e..f13b78d7f 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -165,7 +165,7 @@ const { shouldRenderVueNodes } = useVueFeatureFlags() // Vue node system const vueNodeLifecycle = useVueNodeLifecycle() -const viewportCulling = useViewportCulling() +const { handleTransformUpdate } = useViewportCulling() const handleVueNodeLifecycleReset = async () => { if (shouldRenderVueNodes.value) { @@ -187,8 +187,9 @@ watch( } ) -const allNodes = viewportCulling.allNodes -const handleTransformUpdate = viewportCulling.handleTransformUpdate +const allNodes = computed(() => + Array.from(vueNodeLifecycle.vueNodeData.value.values()) +) watchEffect(() => { nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated') diff --git a/src/composables/graph/useViewportCulling.ts b/src/composables/graph/useViewportCulling.ts index 6ebebeba3..30cf82e56 100644 --- a/src/composables/graph/useViewportCulling.ts +++ b/src/composables/graph/useViewportCulling.ts @@ -6,78 +6,98 @@ * 2. Set display none on element to avoid cascade resolution overhead * 3. Only run when transform changes (event driven) */ -import { useThrottleFn } from '@vueuse/core' +import { createSharedComposable, useThrottleFn } from '@vueuse/core' import { computed } from 'vue' import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle' +import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' -import { app as comfyApp } from '@/scripts/app' +import { app } from '@/scripts/app' -export function useViewportCulling() { +type Bounds = [left: number, right: number, top: number, bottom: number] + +function getNodeBounds(node: LGraphNode): Bounds { + const [nodeLeft, nodeTop] = node.pos + const nodeRight = nodeLeft + node.size[0] + const nodeBottom = nodeTop + node.size[1] + return [nodeLeft, nodeRight, nodeTop, nodeBottom] +} + +function viewportEdges( + canvas: ReturnType['canvas'] +): Bounds | undefined { + if (!canvas) { + return + } + const ds = canvas.ds + const viewport_width = canvas.canvas.width + const viewport_height = canvas.canvas.height + const margin = 500 * ds.scale + + const [xOffset, yOffset] = ds.offset + + const leftEdge = -margin / ds.scale - xOffset + const rightEdge = (viewport_width + margin) / ds.scale - xOffset + const topEdge = -margin / ds.scale - yOffset + const bottomEdge = (viewport_height + margin) / ds.scale - yOffset + return [leftEdge, rightEdge, topEdge, bottomEdge] +} + +function boundsIntersect(boxA: Bounds, boxB: Bounds): boolean { + const [aLeft, aRight, aTop, aBottom] = boxA + const [bLeft, bRight, bTop, bBottom] = boxB + + const leftOf = aRight < bLeft + const rightOf = aLeft > bRight + const above = aBottom < bTop + const below = aTop > bBottom + return !(leftOf || rightOf || above || below) +} + +function useViewportCullingIndividual() { const canvasStore = useCanvasStore() - const { vueNodeData, nodeManager } = useVueNodeLifecycle() + const { nodeManager } = useVueNodeLifecycle() - const allNodes = computed(() => { - return Array.from(vueNodeData.value.values()) - }) + const viewport = computed(() => viewportEdges(canvasStore.canvas)) + + function inViewport(node: LGraphNode | undefined): boolean { + if (!viewport.value || !node) { + return true + } + const nodeBounds = getNodeBounds(node) + return boundsIntersect(nodeBounds, viewport.value) + } /** * Update visibility of all nodes based on viewport * Queries DOM directly - no cache maintenance needed */ function updateVisibility() { - if (!nodeManager.value || !canvasStore.canvas || !comfyApp.canvas) return + if (!nodeManager.value || !app.canvas) return // load bearing app.canvas check for workflows being loaded. - const canvas = canvasStore.canvas - const manager = nodeManager.value - const ds = canvas.ds - - // Viewport bounds - const viewport_width = canvas.canvas.width - const viewport_height = canvas.canvas.height - const margin = 500 * ds.scale - - // Get all node elements at once const nodeElements = document.querySelectorAll('[data-node-id]') - - // Update each element's visibility for (const element of nodeElements) { const nodeId = element.getAttribute('data-node-id') if (!nodeId) continue - const node = manager.getNode(nodeId) + const node = nodeManager.value.getNode(nodeId) if (!node) continue - // Calculate if node is outside viewport - const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale - const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale - const screen_width = node.size[0] * ds.scale - const screen_height = node.size[1] * ds.scale - - const isNodeOutsideViewport = - screen_x + screen_width < -margin || - screen_x > viewport_width + margin || - screen_y + screen_height < -margin || - screen_y > viewport_height + margin - - // Setting display none directly avoid potential cascade resolution - if (element instanceof HTMLElement) { - element.style.display = isNodeOutsideViewport ? 'none' : '' + const displayValue = inViewport(node) ? '' : 'none' + if ( + element instanceof HTMLElement && + element.style.display !== displayValue + ) { + element.style.display = displayValue } } } - const updateVisibilityDebounced = useThrottleFn(updateVisibility, 20) + const handleTransformUpdate = useThrottleFn(() => updateVisibility, 100, true) - // RAF throttling for smooth updates during continuous panning - function handleTransformUpdate() { - requestAnimationFrame(async () => { - await updateVisibilityDebounced() - }) - } - - return { - allNodes, - handleTransformUpdate - } + return { handleTransformUpdate } } + +export const useViewportCulling = createSharedComposable( + useViewportCullingIndividual +) diff --git a/src/renderer/core/layout/__tests__/TransformPane.spec.ts b/src/renderer/core/layout/__tests__/TransformPane.spec.ts index e3519afb5..f6b4f463e 100644 --- a/src/renderer/core/layout/__tests__/TransformPane.spec.ts +++ b/src/renderer/core/layout/__tests__/TransformPane.spec.ts @@ -1,97 +1,96 @@ import { mount } from '@vue/test-utils' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { nextTick, ref } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { computed, nextTick } from 'vue' + +import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas' +import { useTransformState } from '@/renderer/core/layout/transform/useTransformState' import TransformPane from '../transform/TransformPane.vue' -// Mock the transform state composable -const mockTransformState = { - camera: ref({ x: 0, y: 0, z: 1 }), - transformStyle: ref({ +const mockData = vi.hoisted(() => ({ + mockTransformStyle: { transform: 'scale(1) translate(0px, 0px)', transformOrigin: '0 0' - }), - syncWithCanvas: vi.fn(), - canvasToScreen: vi.fn(), - screenToCanvas: vi.fn(), - isNodeInViewport: vi.fn() -} - -vi.mock('@/renderer/core/spatial/useTransformState', () => ({ - useTransformState: () => mockTransformState + }, + mockCamera: { x: 0, y: 0, z: 1 } })) -// Mock requestAnimationFrame/cancelAnimationFrame -global.requestAnimationFrame = vi.fn((cb) => { - setTimeout(cb, 16) - return 1 +vi.mock('@/renderer/core/layout/transform/useTransformState', () => { + const syncWithCanvas = vi.fn() + return { + useTransformState: () => ({ + camera: computed(() => mockData.mockCamera), + transformStyle: computed(() => mockData.mockTransformStyle), + canvasToScreen: vi.fn(), + screenToCanvas: vi.fn(), + isNodeInViewport: vi.fn(), + syncWithCanvas + }) + } }) -global.cancelAnimationFrame = vi.fn() + +vi.mock('@/renderer/extensions/vueNodes/lod/useLOD', () => ({ + useLOD: vi.fn(() => ({ + isLOD: false + })) +})) + +function createMockCanvas(): LGraphCanvas { + return { + canvas: { + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }, + ds: { + offset: [0, 0], + scale: 1 + } + } as unknown as LGraphCanvas +} describe('TransformPane', () => { - let wrapper: ReturnType - let mockCanvas: any - beforeEach(() => { - vi.clearAllMocks() + vi.useFakeTimers() + vi.resetAllMocks() // Create mock canvas with LiteGraph interface - mockCanvas = { - canvas: { - addEventListener: vi.fn(), - removeEventListener: vi.fn() - }, - ds: { - offset: [0, 0], - scale: 1 - } - } - - // Reset mock transform state - mockTransformState.camera.value = { x: 0, y: 0, z: 1 } - mockTransformState.transformStyle.value = { - transform: 'scale(1) translate(0px, 0px)', - transformOrigin: '0 0' - } - }) - - afterEach(() => { - if (wrapper) { - wrapper.unmount() - } }) describe('component mounting', () => { it('should mount successfully with minimal props', () => { - wrapper = mount(TransformPane, { + const mockCanvas = createMockCanvas() + const wrapper = mount(TransformPane, { props: { canvas: mockCanvas } }) expect(wrapper.exists()).toBe(true) - expect(wrapper.find('.transform-pane').exists()).toBe(true) + expect(wrapper.find('[data-testid="transform-pane"]').exists()).toBe(true) }) - it('should apply transform style from composable', () => { - mockTransformState.transformStyle.value = { + it('should apply transform style from composable', async () => { + mockData.mockTransformStyle = { transform: 'scale(2) translate(100px, 50px)', transformOrigin: '0 0' } - wrapper = mount(TransformPane, { + const mockCanvas = createMockCanvas() + const wrapper = mount(TransformPane, { props: { canvas: mockCanvas } }) + await nextTick() - const transformPane = wrapper.find('.transform-pane') + const transformPane = wrapper.find('[data-testid="transform-pane"]') const style = transformPane.attributes('style') expect(style).toContain('transform: scale(2) translate(100px, 50px)') }) it('should render slot content', () => { - wrapper = mount(TransformPane, { + const mockCanvas = createMockCanvas() + const wrapper = mount(TransformPane, { props: { canvas: mockCanvas }, @@ -106,22 +105,9 @@ describe('TransformPane', () => { }) describe('RAF synchronization', () => { - it('should start RAF sync on mount', async () => { - wrapper = mount(TransformPane, { - props: { - canvas: mockCanvas - } - }) - - await nextTick() - - // Should emit RAF status change to true - expect(wrapper.emitted('rafStatusChange')).toBeTruthy() - expect(wrapper.emitted('rafStatusChange')?.[0]).toEqual([true]) - }) - it('should call syncWithCanvas during RAF updates', async () => { - wrapper = mount(TransformPane, { + const mockCanvas = createMockCanvas() + mount(TransformPane, { props: { canvas: mockCanvas } @@ -130,13 +116,15 @@ describe('TransformPane', () => { await nextTick() // Allow RAF to execute - await new Promise((resolve) => setTimeout(resolve, 20)) + vi.advanceTimersToNextFrame() - expect(mockTransformState.syncWithCanvas).toHaveBeenCalledWith(mockCanvas) + const transformState = useTransformState() + expect(transformState.syncWithCanvas).toHaveBeenCalledWith(mockCanvas) }) it('should emit transform update timing', async () => { - wrapper = mount(TransformPane, { + const mockCanvas = createMockCanvas() + const wrapper = mount(TransformPane, { props: { canvas: mockCanvas } @@ -145,34 +133,16 @@ describe('TransformPane', () => { await nextTick() // Allow RAF to execute - await new Promise((resolve) => setTimeout(resolve, 20)) + vi.advanceTimersToNextFrame() expect(wrapper.emitted('transformUpdate')).toBeTruthy() - const updateEvent = wrapper.emitted('transformUpdate')?.[0] - expect(typeof updateEvent?.[0]).toBe('number') - expect(updateEvent?.[0]).toBeGreaterThanOrEqual(0) - }) - - it('should stop RAF sync on unmount', async () => { - wrapper = mount(TransformPane, { - props: { - canvas: mockCanvas - } - }) - - await nextTick() - wrapper.unmount() - - expect(wrapper.emitted('rafStatusChange')).toBeTruthy() - const events = wrapper.emitted('rafStatusChange') as any[] - expect(events[events.length - 1]).toEqual([false]) - expect(global.cancelAnimationFrame).toHaveBeenCalled() }) }) describe('canvas event listeners', () => { it('should add event listeners to canvas on mount', async () => { - wrapper = mount(TransformPane, { + const mockCanvas = createMockCanvas() + mount(TransformPane, { props: { canvas: mockCanvas } @@ -203,7 +173,8 @@ describe('TransformPane', () => { }) it('should remove event listeners on unmount', async () => { - wrapper = mount(TransformPane, { + const mockCanvas = createMockCanvas() + const wrapper = mount(TransformPane, { props: { canvas: mockCanvas } @@ -237,7 +208,8 @@ describe('TransformPane', () => { describe('interaction state management', () => { it('should apply interacting class during interactions', async () => { - wrapper = mount(TransformPane, { + const mockCanvas = createMockCanvas() + const wrapper = mount(TransformPane, { props: { canvas: mockCanvas } @@ -245,7 +217,7 @@ describe('TransformPane', () => { // Simulate interaction start by checking internal state // Note: This tests the CSS class application logic - const transformPane = wrapper.find('.transform-pane') + const transformPane = wrapper.find('[data-testid="transform-pane"]') // Initially should not have interacting class expect(transformPane.classes()).not.toContain( @@ -254,13 +226,14 @@ describe('TransformPane', () => { }) it('should handle pointer events for node delegation', async () => { - wrapper = mount(TransformPane, { + const mockCanvas = createMockCanvas() + const wrapper = mount(TransformPane, { props: { canvas: mockCanvas } }) - const transformPane = wrapper.find('.transform-pane') + const transformPane = wrapper.find('[data-testid="transform-pane"]') // Simulate pointer down - we can't test the exact delegation logic // in unit tests due to vue-test-utils limitations, but we can verify @@ -274,77 +247,32 @@ describe('TransformPane', () => { describe('transform state integration', () => { it('should provide transform utilities to child components', () => { - wrapper = mount(TransformPane, { + const mockCanvas = createMockCanvas() + mount(TransformPane, { props: { canvas: mockCanvas } }) + const transformState = useTransformState() // The component should provide transform state via Vue's provide/inject // This is tested indirectly through the composable integration - expect(mockTransformState.syncWithCanvas).toBeDefined() - expect(mockTransformState.canvasToScreen).toBeDefined() - expect(mockTransformState.screenToCanvas).toBeDefined() + expect(transformState.syncWithCanvas).toBeDefined() + expect(transformState.canvasToScreen).toBeDefined() + expect(transformState.screenToCanvas).toBeDefined() }) }) describe('error handling', () => { it('should handle null canvas gracefully', () => { - wrapper = mount(TransformPane, { + const wrapper = mount(TransformPane, { props: { canvas: undefined } }) expect(wrapper.exists()).toBe(true) - expect(wrapper.find('.transform-pane').exists()).toBe(true) - }) - - it('should handle missing canvas properties', () => { - const incompleteCanvas = {} as any - - wrapper = mount(TransformPane, { - props: { - canvas: incompleteCanvas - } - }) - - expect(wrapper.exists()).toBe(true) - // Should not throw errors during mount - }) - }) - - describe('performance optimizations', () => { - it('should use contain CSS property for layout optimization', () => { - wrapper = mount(TransformPane, { - props: { - canvas: mockCanvas - } - }) - - const transformPane = wrapper.find('.transform-pane') - - // This test verifies the CSS contains the performance optimization - // Note: In JSDOM, computed styles might not reflect all CSS properties - expect(transformPane.element.className).toContain('transform-pane') - }) - - it('should disable pointer events on container but allow on children', () => { - wrapper = mount(TransformPane, { - props: { - canvas: mockCanvas - }, - slots: { - default: '
Test Node
' - } - }) - - const transformPane = wrapper.find('.transform-pane') - - // The CSS should handle pointer events optimization - // This is primarily a CSS concern, but we verify the structure - expect(transformPane.exists()).toBe(true) - expect(wrapper.find('[data-node-id="test"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="transform-pane"]').exists()).toBe(true) }) }) }) diff --git a/src/renderer/core/layout/transform/TransformPane.vue b/src/renderer/core/layout/transform/TransformPane.vue index 67bf8f8ad..0ed23a90c 100644 --- a/src/renderer/core/layout/transform/TransformPane.vue +++ b/src/renderer/core/layout/transform/TransformPane.vue @@ -1,14 +1,14 @@