feat: when restored position has no nodes in viewport, automatically fit to view (#7435)

## Summary

Sometimes the saved position is super far away from any of the nodes,
which causes general confusion. This PR changes the `loadGraphData`
logic to fit-to-view in those scenarios.

Fixes https://github.com/Comfy-Org/ComfyUI_frontend/issues/7425

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7435-feat-when-restored-position-has-no-nodes-in-viewport-automatically-fit-to-view-2c86d73d36508119bf2ed9d361ec868f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Christian Byrne
2025-12-17 15:38:05 -08:00
committed by GitHub
parent db929220af
commit 1d014c0dbe
5 changed files with 473 additions and 6 deletions

View File

@@ -0,0 +1,412 @@
{
"id": "2ba0b800-2f13-4f21-b8d6-c6cdb0152cae",
"revision": 0,
"last_node_id": 16,
"last_link_id": 9,
"nodes": [
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [
60,
200
],
"size": [
315,
98
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [
1
]
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [
3,
5
]
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [
8
]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple",
"cnr_id": "comfy-core",
"ver": "0.3.65",
"models": [
{
"name": "v1-5-pruned-emaonly-fp16.safetensors",
"url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true",
"directory": "checkpoints"
}
]
},
"widgets_values": [
"v1-5-pruned-emaonly-fp16.safetensors"
]
},
{
"id": 3,
"type": "KSampler",
"pos": [
870,
170
],
"size": [
315,
474
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 1
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
7
]
}
],
"properties": {
"Node name for S&R": "KSampler",
"cnr_id": "comfy-core",
"ver": "0.3.65"
},
"widgets_values": [
685468484323813,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [
975,
700
],
"size": [
210,
46
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 7
},
{
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": []
}
],
"properties": {
"Node name for S&R": "VAEDecode",
"cnr_id": "comfy-core",
"ver": "0.3.65"
},
"widgets_values": []
},
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [
410,
410
],
"size": [
425.27801513671875,
180.6060791015625
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 5
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
6
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode",
"cnr_id": "comfy-core",
"ver": "0.3.65"
},
"widgets_values": [
"text, watermark"
],
"color": "#223",
"bgcolor": "#335"
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [
520,
690
],
"size": [
315,
106
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
2
]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage",
"cnr_id": "comfy-core",
"ver": "0.3.65"
},
"widgets_values": [
512,
512,
1
]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [
411.21649169921875,
203.68695068359375
],
"size": [
422.84503173828125,
164.31304931640625
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
4
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode",
"cnr_id": "comfy-core",
"ver": "0.3.65"
},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, purple galaxy bottle,"
],
"color": "#232",
"bgcolor": "#353"
}
],
"links": [
[
1,
4,
0,
3,
0,
"MODEL"
],
[
2,
5,
0,
3,
3,
"LATENT"
],
[
3,
4,
1,
6,
0,
"CLIP"
],
[
4,
6,
0,
3,
1,
"CONDITIONING"
],
[
5,
4,
1,
7,
0,
"CLIP"
],
[
6,
7,
0,
3,
2,
"CONDITIONING"
],
[
7,
3,
0,
8,
0,
"LATENT"
],
[
8,
4,
2,
8,
1,
"VAE"
]
],
"groups": [
{
"id": 1,
"title": "Step 1 - Load model",
"bounding": [
50,
130,
335,
181.60000610351562
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 2,
"title": "Step 3 - Image size",
"bounding": [
510,
620,
335,
189.60000610351562
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 3,
"title": "Step 2 - Prompt",
"bounding": [
400,
130,
445.27801513671875,
467.2060852050781
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.44218252181616574,
"offset": [
-666.5670907104311,
-2227.894644048147
]
},
"frontendVersion": "1.35.3",
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true,
"workflowRendererVersion": "LG"
},
"version": 0.4
}

View File

@@ -0,0 +1,20 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Viewport', () => {
test('Fits view to nodes when saved viewport position is offscreen', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('viewport/default-viewport-saved-offscreen')
// Wait a few frames for rendering to stabilize
for (let i = 0; i < 5; i++) {
await comfyPage.nextFrame()
}
await expect(comfyPage.canvas).toHaveScreenshot(
'viewport-fits-when-saved-offscreen.png'
)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -64,6 +64,7 @@ import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
import { type ExtensionManager } from '@/types/extensionTypes'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { graphToPrompt } from '@/utils/executionUtil'
import { anyItemOverlapsRect } from '@/utils/mathUtil'
import { collectAllNodes, forEachNode } from '@/utils/graphTraversalUtil'
import {
getNodeByExecutionId,
@@ -1222,13 +1223,19 @@ export class ComfyApp {
if (graphData.extra?.ds) {
this.canvas.ds.offset = graphData.extra.ds.offset
this.canvas.ds.scale = graphData.extra.ds.scale
// Fit view if no nodes visible in restored viewport
this.canvas.ds.computeVisibleArea(this.canvas.viewport)
if (
!anyItemOverlapsRect(
this.rootGraph._nodes,
this.canvas.visible_area
)
) {
requestAnimationFrame(() => useLitegraphService().fitView())
}
} else {
// @note: Set view after the graph has been rendered once. fitView uses
// boundingRect on nodes to calculate the view bounds, which only become
// available after the first render.
requestAnimationFrame(() => {
useLitegraphService().fitView()
})
requestAnimationFrame(() => useLitegraphService().fitView())
}
}
} catch (error) {

View File

@@ -1,6 +1,9 @@
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import type { Bounds } from '@/renderer/core/layout/types'
/** Simple 2D point or size as [x, y] or [width, height] */
type Vec2 = readonly [number, number]
/**
* Finds the greatest common divisor (GCD) for two numbers using iterative
* Euclidean algorithm. Uses iteration instead of recursion to avoid stack
@@ -91,3 +94,28 @@ export function computeUnionBounds(
height: maxY - minY
}
}
/**
* Checks if any item with pos/size overlaps a rectangle (AABB test).
* @param items Items with pos [x, y] and size [width, height]
* @param rect Rectangle as [x, y, width, height]
* @returns `true` if any item overlaps the rect
*/
export function anyItemOverlapsRect(
items: Iterable<{ pos: Vec2; size: Vec2 }>,
rect: ReadOnlyRect
): boolean {
const rectRight = rect[0] + rect[2]
const rectBottom = rect[1] + rect[3]
for (const item of items) {
const overlaps =
item.pos[0] < rectRight &&
item.pos[0] + item.size[0] > rect[0] &&
item.pos[1] < rectBottom &&
item.pos[1] + item.size[1] > rect[1]
if (overlaps) return true
}
return false
}