mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
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:
@@ -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
|
||||
}
|
||||
20
browser_tests/tests/viewport.spec.ts
Normal file
20
browser_tests/tests/viewport.spec.ts
Normal 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 |
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user