diff --git a/src/composables/useCoreCommands.ts b/src/composables/useCoreCommands.ts index 00d97ef88..e729667b1 100644 --- a/src/composables/useCoreCommands.ts +++ b/src/composables/useCoreCommands.ts @@ -25,6 +25,8 @@ import { useCanvasStore, useTitleEditorStore } from '@/renderer/core/canvas/canvasStore' +import { layoutStore } from '@/renderer/core/layout/store/layoutStore' +import { selectionBounds } from '@/renderer/core/layout/utils/layoutMath' import { api } from '@/scripts/api' import { app } from '@/scripts/app' import { useDialogService } from '@/services/dialogService' @@ -316,15 +318,53 @@ export function useCoreCommands(): ComfyCommand[] { menubarLabel: 'Zoom to fit', category: 'view-controls' as const, function: () => { - if (app.canvas.empty) { - toastStore.add({ - severity: 'error', - summary: t('toastMessages.emptyCanvas'), - life: 3000 - }) - return + const vueNodesEnabled = useSettingStore().get('Comfy.VueNodes.Enabled') + + if (vueNodesEnabled) { + // Get nodes from Vue stores + const canvasStore = useCanvasStore() + const selectedNodeIds = canvasStore.selectedNodeIds + const allNodes = layoutStore.getAllNodes().value + + // Get nodes to fit - selected if any, otherwise all + const nodesToFit = + selectedNodeIds.size > 0 + ? Array.from(selectedNodeIds) + .map((id) => allNodes.get(id)) + .filter((node) => node != null) + : Array.from(allNodes.values()) + + // Use Vue nodes bounds calculation + const bounds = selectionBounds(nodesToFit) + if (!bounds) { + toastStore.add({ + severity: 'error', + summary: t('toastMessages.emptyCanvas'), + life: 3000 + }) + return + } + + // Convert to LiteGraph format and animate + const lgBounds = [ + bounds.x, + bounds.y, + bounds.width, + bounds.height + ] as const + const setDirty = () => app.canvas.setDirty(true, true) + app.canvas.ds.animateToBounds(lgBounds, setDirty) + } else { + if (app.canvas.empty) { + toastStore.add({ + severity: 'error', + summary: t('toastMessages.emptyCanvas'), + life: 3000 + }) + return + } + app.canvas.fitViewToSelectionAnimated() } - app.canvas.fitViewToSelectionAnimated() } }, { diff --git a/src/renderer/core/layout/utils/layoutMath.ts b/src/renderer/core/layout/utils/layoutMath.ts index 786e261dd..adb73aa3d 100644 --- a/src/renderer/core/layout/utils/layoutMath.ts +++ b/src/renderer/core/layout/utils/layoutMath.ts @@ -1,4 +1,4 @@ -import type { Bounds, Point } from '@/renderer/core/layout/types' +import type { Bounds, NodeLayout, Point } from '@/renderer/core/layout/types' export const REROUTE_RADIUS = 8 @@ -19,3 +19,35 @@ export function boundsIntersect(a: Bounds, b: Bounds): boolean { b.y + b.height < a.y ) } + +export function calculateBounds(nodes: NodeLayout[]): Bounds { + let minX = Infinity, + minY = Infinity + let maxX = -Infinity, + maxY = -Infinity + + for (const node of nodes) { + const bounds = node.bounds + minX = Math.min(minX, bounds.x) + minY = Math.min(minY, bounds.y) + maxX = Math.max(maxX, bounds.x + bounds.width) + maxY = Math.max(maxY, bounds.y + bounds.height) + } + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY + } +} + +/** + * Calculate combined bounds for Vue nodes selection + * @param nodes Array of NodeLayout objects to calculate bounds for + * @returns Bounds of the nodes or null if no nodes provided + */ +export function selectionBounds(nodes: NodeLayout[]): Bounds | null { + if (nodes.length === 0) return null + return calculateBounds(nodes) +} diff --git a/tests-ui/tests/renderer/core/layout/utils/layoutMath.test.ts b/tests-ui/tests/renderer/core/layout/utils/layoutMath.test.ts index 3d768740e..9c1fe1b46 100644 --- a/tests-ui/tests/renderer/core/layout/utils/layoutMath.test.ts +++ b/tests-ui/tests/renderer/core/layout/utils/layoutMath.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest' +import type { NodeLayout } from '@/renderer/core/layout/types' import { REROUTE_RADIUS, boundsIntersect, + calculateBounds, pointInBounds } from '@/renderer/core/layout/utils/layoutMath' @@ -46,4 +48,75 @@ describe('layoutMath utils', () => { expect(REROUTE_RADIUS).toBeGreaterThan(0) }) }) + + describe('calculateBounds', () => { + const createTestNode = ( + id: string, + x: number, + y: number, + width: number, + height: number + ): NodeLayout => ({ + id, + position: { x, y }, + size: { width, height }, + zIndex: 0, + visible: true, + bounds: { x, y, width, height } + }) + + it('calculates bounds for single node', () => { + const nodes = [createTestNode('1', 10, 20, 100, 50)] + const bounds = calculateBounds(nodes) + + expect(bounds).toEqual({ + x: 10, + y: 20, + width: 100, + height: 50 + }) + }) + + it('calculates combined bounds for multiple nodes', () => { + const nodes = [ + createTestNode('1', 0, 0, 50, 50), // Top-left: (0,0) to (50,50) + createTestNode('2', 100, 100, 30, 40), // Bottom-right: (100,100) to (130,140) + createTestNode('3', 25, 75, 20, 10) // Middle: (25,75) to (45,85) + ] + const bounds = calculateBounds(nodes) + + expect(bounds).toEqual({ + x: 0, // leftmost + y: 0, // topmost + width: 130, // rightmost (130) - leftmost (0) + height: 140 // bottommost (140) - topmost (0) + }) + }) + + it('handles nodes with negative positions', () => { + const nodes = [ + createTestNode('1', -50, -30, 40, 20), // (-50,-30) to (-10,-10) + createTestNode('2', 10, 15, 25, 35) // (10,15) to (35,50) + ] + const bounds = calculateBounds(nodes) + + expect(bounds).toEqual({ + x: -50, + y: -30, + width: 85, // 35 - (-50) + height: 80 // 50 - (-30) + }) + }) + + it('handles empty array', () => { + const bounds = calculateBounds([]) + + expect(bounds).toEqual({ + x: Infinity, + y: Infinity, + width: -Infinity, + height: -Infinity + }) + }) + }) })