From 46ad1318e5930a2e999fd0bfadf80e32c6cec7ba Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Fri, 26 Sep 2025 21:32:31 -0700 Subject: [PATCH] Implement fit-to-view for Vue nodes (#5782) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Implemented fit-to-view functionality for Vue nodes with bounds calculation and viewport animation support. https://github.com/user-attachments/assets/2ec221f1-9194-4564-95f9-ad4da80f190a ## Changes - **What**: Added Vue nodes support to fit-to-view command with [bounds calculation](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect) and LiteGraph integration - **Dependencies**: Added dependency on `layoutStore` and `selectionBounds` utility ## Review Focus Bounds calculation accuracy for complex node layouts and animation performance with large node selections. Verify proper fallback to legacy LiteGraph behavior when Vue nodes disabled. ```mermaid graph TD A[Fit to View Command] --> B{Vue Nodes Enabled?} B -->|Yes| C[Get Selected Nodes] B -->|No| D[Legacy LiteGraph Method] C --> E{Nodes Selected?} E -->|Yes| F[Calculate Selected Bounds] E -->|No| G[Calculate All Nodes Bounds] F --> H[Convert to LiteGraph Format] G --> H H --> I[Animate to Bounds] D --> J[Canvas fitViewToSelectionAnimated] style A fill:#f9f9f9,stroke:#333,color:#333 style I fill:#f9f9f9,stroke:#333,color:#333 style J fill:#f9f9f9,stroke:#333,color:#333 ``` ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5782-Implement-fit-to-view-for-Vue-nodes-27a6d73d365081cb822cd93f557e77b2) by [Unito](https://www.unito.io) --------- Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com> --- src/composables/useCoreCommands.ts | 56 ++++++++++++-- src/renderer/core/layout/utils/layoutMath.ts | 34 ++++++++- .../core/layout/utils/layoutMath.test.ts | 73 +++++++++++++++++++ 3 files changed, 154 insertions(+), 9 deletions(-) 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 + }) + }) + }) })