mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 18:52:19 +00:00
Implement fit-to-view for Vue nodes (#5782)
## 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>
This commit is contained in:
@@ -25,6 +25,8 @@ import {
|
|||||||
useCanvasStore,
|
useCanvasStore,
|
||||||
useTitleEditorStore
|
useTitleEditorStore
|
||||||
} from '@/renderer/core/canvas/canvasStore'
|
} 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 { api } from '@/scripts/api'
|
||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
import { useDialogService } from '@/services/dialogService'
|
import { useDialogService } from '@/services/dialogService'
|
||||||
@@ -316,15 +318,53 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
menubarLabel: 'Zoom to fit',
|
menubarLabel: 'Zoom to fit',
|
||||||
category: 'view-controls' as const,
|
category: 'view-controls' as const,
|
||||||
function: () => {
|
function: () => {
|
||||||
if (app.canvas.empty) {
|
const vueNodesEnabled = useSettingStore().get('Comfy.VueNodes.Enabled')
|
||||||
toastStore.add({
|
|
||||||
severity: 'error',
|
if (vueNodesEnabled) {
|
||||||
summary: t('toastMessages.emptyCanvas'),
|
// Get nodes from Vue stores
|
||||||
life: 3000
|
const canvasStore = useCanvasStore()
|
||||||
})
|
const selectedNodeIds = canvasStore.selectedNodeIds
|
||||||
return
|
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()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
export const REROUTE_RADIUS = 8
|
||||||
|
|
||||||
@@ -19,3 +19,35 @@ export function boundsIntersect(a: Bounds, b: Bounds): boolean {
|
|||||||
b.y + b.height < a.y
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { NodeLayout } from '@/renderer/core/layout/types'
|
||||||
import {
|
import {
|
||||||
REROUTE_RADIUS,
|
REROUTE_RADIUS,
|
||||||
boundsIntersect,
|
boundsIntersect,
|
||||||
|
calculateBounds,
|
||||||
pointInBounds
|
pointInBounds
|
||||||
} from '@/renderer/core/layout/utils/layoutMath'
|
} from '@/renderer/core/layout/utils/layoutMath'
|
||||||
|
|
||||||
@@ -46,4 +48,75 @@ describe('layoutMath utils', () => {
|
|||||||
expect(REROUTE_RADIUS).toBeGreaterThan(0)
|
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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user