mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
Merge branch 'main' into deepme987/feat/missing-node-telemetry
This commit is contained in:
@@ -29,7 +29,8 @@ export const webSocketFixture = base.extend<{
|
||||
function ([data, url]) {
|
||||
if (!url) {
|
||||
// If no URL specified, use page URL
|
||||
const u = new URL(window.location.toString())
|
||||
const u = new URL(window.location.href)
|
||||
u.hash = ''
|
||||
u.protocol = 'ws:'
|
||||
u.pathname = '/'
|
||||
url = u.toString() + 'ws'
|
||||
|
||||
@@ -767,11 +767,9 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
})
|
||||
|
||||
// Click breadcrumb to navigate back to parent graph
|
||||
const homeBreadcrumb = comfyPage.page.getByRole('link', {
|
||||
// In the subgraph navigation breadcrumbs, the home/top level
|
||||
// breadcrumb is just the workflow name without the folder path
|
||||
name: 'subgraph-with-promoted-text-widget'
|
||||
})
|
||||
const homeBreadcrumb = comfyPage.page.locator(
|
||||
'.p-breadcrumb-list > :first-child'
|
||||
)
|
||||
await homeBreadcrumb.waitFor({ state: 'visible' })
|
||||
await homeBreadcrumb.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
@@ -743,15 +743,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back via breadcrumb
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
.waitFor({ state: 'visible', timeout: 5000 })
|
||||
const homeBreadcrumb = comfyPage.page.getByRole('link', {
|
||||
name: 'subgraph-with-promoted-text-widget'
|
||||
})
|
||||
await homeBreadcrumb.waitFor({ state: 'visible' })
|
||||
await homeBreadcrumb.click()
|
||||
await comfyPage.nextFrame()
|
||||
await exitSubgraphToParent(comfyPage)
|
||||
|
||||
// Widget count should be reduced
|
||||
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
|
||||
@@ -2,9 +2,116 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../../../fixtures/ComfyPage'
|
||||
|
||||
const CREATE_GROUP_HOTKEY = 'Control+g'
|
||||
|
||||
type NodeGroupCenteringError = {
|
||||
horizontal: number
|
||||
vertical: number
|
||||
}
|
||||
|
||||
type NodeGroupCenteringErrors = {
|
||||
innerGroup: NodeGroupCenteringError
|
||||
outerGroup: NodeGroupCenteringError
|
||||
}
|
||||
|
||||
const LEGACY_VUE_CENTERING_BASELINE: NodeGroupCenteringErrors = {
|
||||
innerGroup: {
|
||||
horizontal: 16.308832840862777,
|
||||
vertical: 17.390899314547084
|
||||
},
|
||||
outerGroup: {
|
||||
horizontal: 20.30164329441476,
|
||||
vertical: 42.196324096481476
|
||||
}
|
||||
} as const
|
||||
|
||||
const CENTERING_TOLERANCE = {
|
||||
innerGroup: 6,
|
||||
outerGroup: 12
|
||||
} as const
|
||||
|
||||
function expectWithinBaseline(
|
||||
actual: number,
|
||||
baseline: number,
|
||||
tolerance: number
|
||||
) {
|
||||
expect(Math.abs(actual - baseline)).toBeLessThan(tolerance)
|
||||
}
|
||||
|
||||
async function getNodeGroupCenteringErrors(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<NodeGroupCenteringErrors> {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
type GraphNode = {
|
||||
id: number | string
|
||||
pos: ReadonlyArray<number>
|
||||
}
|
||||
type GraphGroup = {
|
||||
title: string
|
||||
pos: ReadonlyArray<number>
|
||||
size: ReadonlyArray<number>
|
||||
}
|
||||
|
||||
const app = window.app!
|
||||
const node = app.graph.nodes[0] as GraphNode | undefined
|
||||
|
||||
if (!node) {
|
||||
throw new Error('Expected a node in the loaded workflow')
|
||||
}
|
||||
|
||||
const nodeElement = document.querySelector<HTMLElement>(
|
||||
`[data-node-id="${node.id}"]`
|
||||
)
|
||||
|
||||
if (!nodeElement) {
|
||||
throw new Error(`Vue node element not found for node ${node.id}`)
|
||||
}
|
||||
|
||||
const groups = app.graph.groups as GraphGroup[]
|
||||
const innerGroup = groups.find((group) => group.title === 'Inner Group')
|
||||
const outerGroup = groups.find((group) => group.title === 'Outer Group')
|
||||
|
||||
if (!innerGroup || !outerGroup) {
|
||||
throw new Error('Expected both Inner Group and Outer Group in graph')
|
||||
}
|
||||
|
||||
const nodeRect = nodeElement.getBoundingClientRect()
|
||||
|
||||
const getCenteringError = (group: GraphGroup): NodeGroupCenteringError => {
|
||||
const [groupStartX, groupStartY] = app.canvasPosToClientPos([
|
||||
group.pos[0],
|
||||
group.pos[1]
|
||||
])
|
||||
const [groupEndX, groupEndY] = app.canvasPosToClientPos([
|
||||
group.pos[0] + group.size[0],
|
||||
group.pos[1] + group.size[1]
|
||||
])
|
||||
|
||||
const groupLeft = Math.min(groupStartX, groupEndX)
|
||||
const groupRight = Math.max(groupStartX, groupEndX)
|
||||
const groupTop = Math.min(groupStartY, groupEndY)
|
||||
const groupBottom = Math.max(groupStartY, groupEndY)
|
||||
|
||||
const leftGap = nodeRect.left - groupLeft
|
||||
const rightGap = groupRight - nodeRect.right
|
||||
const topGap = nodeRect.top - groupTop
|
||||
const bottomGap = groupBottom - nodeRect.bottom
|
||||
|
||||
return {
|
||||
horizontal: Math.abs(leftGap - rightGap),
|
||||
vertical: Math.abs(topGap - bottomGap)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
innerGroup: getCenteringError(innerGroup),
|
||||
outerGroup: getCenteringError(outerGroup)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Vue Node Groups', { tag: '@screenshot' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
@@ -74,4 +181,45 @@ test.describe('Vue Node Groups', { tag: '@screenshot' }, () => {
|
||||
expect(finalOffsetY).toBeCloseTo(initialOffsetY, 0)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('should keep groups aligned after loading legacy Vue workflows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
|
||||
const workflowRendererVersion = await comfyPage.page.evaluate(() => {
|
||||
const extra = window.app!.graph.extra as
|
||||
| { workflowRendererVersion?: string }
|
||||
| undefined
|
||||
return extra?.workflowRendererVersion
|
||||
})
|
||||
|
||||
expect(workflowRendererVersion).toMatch(/^Vue/)
|
||||
|
||||
await expect(async () => {
|
||||
const centeringErrors = await getNodeGroupCenteringErrors(comfyPage)
|
||||
|
||||
expectWithinBaseline(
|
||||
centeringErrors.innerGroup.horizontal,
|
||||
LEGACY_VUE_CENTERING_BASELINE.innerGroup.horizontal,
|
||||
CENTERING_TOLERANCE.innerGroup
|
||||
)
|
||||
expectWithinBaseline(
|
||||
centeringErrors.innerGroup.vertical,
|
||||
LEGACY_VUE_CENTERING_BASELINE.innerGroup.vertical,
|
||||
CENTERING_TOLERANCE.innerGroup
|
||||
)
|
||||
expectWithinBaseline(
|
||||
centeringErrors.outerGroup.horizontal,
|
||||
LEGACY_VUE_CENTERING_BASELINE.outerGroup.horizontal,
|
||||
CENTERING_TOLERANCE.outerGroup
|
||||
)
|
||||
expectWithinBaseline(
|
||||
centeringErrors.outerGroup.vertical,
|
||||
LEGACY_VUE_CENTERING_BASELINE.outerGroup.vertical,
|
||||
CENTERING_TOLERANCE.outerGroup
|
||||
)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"@tiptap/starter-kit": "catalog:",
|
||||
"@vueuse/core": "catalog:",
|
||||
"@vueuse/integrations": "catalog:",
|
||||
"@vueuse/router": "^14.2.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-serialize": "^0.13.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
|
||||
24
pnpm-lock.yaml
generated
24
pnpm-lock.yaml
generated
@@ -461,6 +461,9 @@ importers:
|
||||
'@vueuse/integrations':
|
||||
specifier: 'catalog:'
|
||||
version: 14.2.0(axios@1.13.5)(fuse.js@7.0.0)(vue@3.5.13(typescript@5.9.3))
|
||||
'@vueuse/router':
|
||||
specifier: ^14.2.0
|
||||
version: 14.2.1(vue-router@4.4.3(vue@3.5.13(typescript@5.9.3)))(vue@3.5.13(typescript@5.9.3))
|
||||
'@xterm/addon-fit':
|
||||
specifier: ^0.10.0
|
||||
version: 0.10.0(@xterm/xterm@5.5.0)
|
||||
@@ -4427,6 +4430,12 @@ packages:
|
||||
'@vueuse/metadata@14.2.0':
|
||||
resolution: {integrity: sha512-i3axTGjU8b13FtyR4Keeama+43iD+BwX9C2TmzBVKqjSHArF03hjkp2SBZ1m72Jk2UtrX0aYCugBq2R1fhkuAQ==}
|
||||
|
||||
'@vueuse/router@14.2.1':
|
||||
resolution: {integrity: sha512-SbZfJe+qn5bj78zNOXT4nYbnp8OIFMyAsdcJb4Y0y9vXi1TsOfglF+YIazi5DPO2lk6/ZukpN5DEQe6KrNOjMw==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
vue-router: ^4.0.0 || ^5.0.0
|
||||
|
||||
'@vueuse/shared@12.8.2':
|
||||
resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
|
||||
|
||||
@@ -4435,6 +4444,11 @@ packages:
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@vueuse/shared@14.2.1':
|
||||
resolution: {integrity: sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@webgpu/types@0.1.66':
|
||||
resolution: {integrity: sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA==}
|
||||
|
||||
@@ -12795,6 +12809,12 @@ snapshots:
|
||||
|
||||
'@vueuse/metadata@14.2.0': {}
|
||||
|
||||
'@vueuse/router@14.2.1(vue-router@4.4.3(vue@3.5.13(typescript@5.9.3)))(vue@3.5.13(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@vueuse/shared': 14.2.1(vue@3.5.13(typescript@5.9.3))
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
vue-router: 4.4.3(vue@3.5.13(typescript@5.9.3))
|
||||
|
||||
'@vueuse/shared@12.8.2(typescript@5.9.3)':
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
@@ -12805,6 +12825,10 @@ snapshots:
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
|
||||
'@vueuse/shared@14.2.1(vue@3.5.13(typescript@5.9.3))':
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
|
||||
'@webgpu/types@0.1.66': {}
|
||||
|
||||
'@xstate/fsm@1.6.5': {}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<template>
|
||||
<a
|
||||
<div
|
||||
ref="wrapperRef"
|
||||
v-tooltip.bottom="{
|
||||
value: tooltipText,
|
||||
showDelay: 512
|
||||
}"
|
||||
draggable="false"
|
||||
href="#"
|
||||
class="p-breadcrumb-item-link h-8 cursor-pointer px-2"
|
||||
:class="{
|
||||
'flex items-center gap-1': isActive,
|
||||
@@ -23,7 +22,7 @@
|
||||
<span class="p-breadcrumb-item-label px-2">{{ item.label }}</span>
|
||||
<Tag v-if="item.isBlueprint" value="Blueprint" severity="primary" />
|
||||
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
|
||||
</a>
|
||||
</div>
|
||||
<Menu
|
||||
v-if="isActive || isRoot"
|
||||
ref="menu"
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
|
||||
import { syncLayoutStoreNodeBoundsFromGraph } from './syncLayoutStoreFromGraph'
|
||||
|
||||
function createGraph(nodes: LGraphNode[]): LGraph {
|
||||
return {
|
||||
nodes
|
||||
} as LGraph
|
||||
}
|
||||
|
||||
function createNode(
|
||||
id: number,
|
||||
pos: [number, number],
|
||||
size: [number, number]
|
||||
): LGraphNode {
|
||||
return {
|
||||
id,
|
||||
pos,
|
||||
size
|
||||
} as LGraphNode
|
||||
}
|
||||
|
||||
describe('syncLayoutStoreNodeBoundsFromGraph', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
LiteGraph.vueNodesMode = false
|
||||
})
|
||||
|
||||
it('syncs node bounds to layout store when Vue nodes mode is enabled', () => {
|
||||
LiteGraph.vueNodesMode = true
|
||||
|
||||
const batchUpdateNodeBounds = vi
|
||||
.spyOn(layoutStore, 'batchUpdateNodeBounds')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const graph = createGraph([
|
||||
createNode(1, [100, 200], [320, 140]),
|
||||
createNode(2, [450, 300], [225, 96])
|
||||
])
|
||||
|
||||
syncLayoutStoreNodeBoundsFromGraph(graph)
|
||||
|
||||
expect(batchUpdateNodeBounds).toHaveBeenCalledWith([
|
||||
{
|
||||
nodeId: '1',
|
||||
bounds: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 320,
|
||||
height: 140
|
||||
}
|
||||
},
|
||||
{
|
||||
nodeId: '2',
|
||||
bounds: {
|
||||
x: 450,
|
||||
y: 300,
|
||||
width: 225,
|
||||
height: 96
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('does nothing when Vue nodes mode is disabled', () => {
|
||||
const batchUpdateNodeBounds = vi
|
||||
.spyOn(layoutStore, 'batchUpdateNodeBounds')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const graph = createGraph([createNode(1, [100, 200], [320, 140])])
|
||||
|
||||
syncLayoutStoreNodeBoundsFromGraph(graph)
|
||||
|
||||
expect(batchUpdateNodeBounds).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when graph has no nodes', () => {
|
||||
LiteGraph.vueNodesMode = true
|
||||
|
||||
const batchUpdateNodeBounds = vi
|
||||
.spyOn(layoutStore, 'batchUpdateNodeBounds')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const graph = createGraph([])
|
||||
|
||||
syncLayoutStoreNodeBoundsFromGraph(graph)
|
||||
|
||||
expect(batchUpdateNodeBounds).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
23
src/renderer/core/layout/sync/syncLayoutStoreFromGraph.ts
Normal file
23
src/renderer/core/layout/sync/syncLayoutStoreFromGraph.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { NodeBoundsUpdate } from '@/renderer/core/layout/types'
|
||||
|
||||
export function syncLayoutStoreNodeBoundsFromGraph(graph: LGraph): void {
|
||||
if (!LiteGraph.vueNodesMode) return
|
||||
|
||||
const nodes = graph.nodes ?? []
|
||||
if (nodes.length === 0) return
|
||||
|
||||
const updates: NodeBoundsUpdate[] = nodes.map((node) => ({
|
||||
nodeId: String(node.id),
|
||||
bounds: {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size[0],
|
||||
height: node.size[1]
|
||||
}
|
||||
}))
|
||||
|
||||
layoutStore.batchUpdateNodeBounds(updates)
|
||||
}
|
||||
@@ -204,11 +204,7 @@ useResizeObserver(outputsRef, () => {
|
||||
lastScrollWidth = outputsRef.value?.scrollWidth ?? 0
|
||||
})
|
||||
watch(
|
||||
[
|
||||
() => store.activeWorkflowInProgressItems.length,
|
||||
() => visibleHistory.value[0]?.id,
|
||||
queueCount
|
||||
],
|
||||
() => visibleHistory.value[0]?.id,
|
||||
() => {
|
||||
const el = outputsRef.value
|
||||
if (!el || el.scrollLeft === 0) {
|
||||
@@ -302,59 +298,57 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div role="group" class="min-w-0 px-4 pb-4">
|
||||
<div
|
||||
role="group"
|
||||
class="flex h-21 min-w-0 items-start justify-center px-4 py-3 pb-4"
|
||||
>
|
||||
<div
|
||||
v-if="queueCount > 0 || hasActiveContent"
|
||||
class="flex h-15 shrink-0 items-start gap-0.5"
|
||||
>
|
||||
<OutputHistoryActiveQueueItem
|
||||
v-if="queueCount > 1"
|
||||
class="mr-3"
|
||||
:queue-count="queueCount"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="mayBeActiveWorkflowPending"
|
||||
:ref="selectedRef('slot:pending')"
|
||||
v-bind="itemAttrs('slot:pending')"
|
||||
:class="itemClass"
|
||||
@click="store.select('slot:pending')"
|
||||
>
|
||||
<OutputPreviewItem />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="item in store.activeWorkflowInProgressItems"
|
||||
:key="`${item.id}-${item.state}`"
|
||||
:ref="selectedRef(`slot:${item.id}`)"
|
||||
v-bind="itemAttrs(`slot:${item.id}`)"
|
||||
:class="itemClass"
|
||||
@click="store.select(`slot:${item.id}`)"
|
||||
>
|
||||
<OutputPreviewItem
|
||||
v-if="item.state !== 'image' || !item.output"
|
||||
:latent-preview="item.latentPreviewUrl"
|
||||
/>
|
||||
<OutputHistoryItem v-else :output="item.output" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasActiveContent && visibleHistory.length > 0"
|
||||
class="mx-4 h-12 shrink-0 border-l border-border-default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<article
|
||||
ref="outputsRef"
|
||||
data-testid="linear-outputs"
|
||||
class="min-w-0 overflow-x-auto overflow-y-clip py-3"
|
||||
class="min-w-0 overflow-x-auto overflow-y-clip"
|
||||
>
|
||||
<div class="mx-auto flex h-21 w-fit items-start gap-0.5">
|
||||
<div
|
||||
v-if="queueCount > 0 || hasActiveContent"
|
||||
:class="
|
||||
cn(
|
||||
'sticky left-0 z-10 flex shrink-0 items-start gap-0.5',
|
||||
'md:bg-comfy-menu-secondary-bg bg-comfy-menu-bg'
|
||||
)
|
||||
"
|
||||
>
|
||||
<OutputHistoryActiveQueueItem
|
||||
v-if="queueCount > 1"
|
||||
class="mr-3"
|
||||
:queue-count="queueCount"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="mayBeActiveWorkflowPending"
|
||||
:ref="selectedRef('slot:pending')"
|
||||
v-bind="itemAttrs('slot:pending')"
|
||||
:class="itemClass"
|
||||
@click="store.select('slot:pending')"
|
||||
>
|
||||
<OutputPreviewItem />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="item in store.activeWorkflowInProgressItems"
|
||||
:key="`${item.id}-${item.state}`"
|
||||
:ref="selectedRef(`slot:${item.id}`)"
|
||||
v-bind="itemAttrs(`slot:${item.id}`)"
|
||||
:class="itemClass"
|
||||
@click="store.select(`slot:${item.id}`)"
|
||||
>
|
||||
<OutputPreviewItem
|
||||
v-if="item.state !== 'image' || !item.output"
|
||||
:latent-preview="item.latentPreviewUrl"
|
||||
/>
|
||||
<OutputHistoryItem v-else :output="item.output" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasActiveContent && visibleHistory.length > 0"
|
||||
class="mx-4 h-12 shrink-0 border-l border-border-default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex h-15 w-fit items-start gap-0.5">
|
||||
<template v-for="(asset, aIdx) in visibleHistory" :key="asset.id">
|
||||
<div
|
||||
v-if="aIdx > 0"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { shallowRef } from 'vue'
|
||||
|
||||
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { syncLayoutStoreNodeBoundsFromGraph } from '@/renderer/core/layout/sync/syncLayoutStoreFromGraph'
|
||||
import { flushScheduledSlotLayoutSync } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
|
||||
import { st, t } from '@/i18n'
|
||||
@@ -74,6 +75,7 @@ import { useModelStore } from '@/stores/modelStore'
|
||||
import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
|
||||
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
@@ -1274,6 +1276,7 @@ export class ComfyApp {
|
||||
|
||||
ChangeTracker.isLoadingGraph = true
|
||||
try {
|
||||
let normalizedMainGraph = false
|
||||
try {
|
||||
// @ts-expect-error Discrepancies between zod and litegraph - in progress
|
||||
this.rootGraph.configure(graphData)
|
||||
@@ -1283,7 +1286,10 @@ export class ComfyApp {
|
||||
this.rootGraph.extra.workflowRendererVersion
|
||||
|
||||
// Scale main graph
|
||||
ensureCorrectLayoutScale(originalMainGraphRenderer, this.rootGraph)
|
||||
normalizedMainGraph = ensureCorrectLayoutScale(
|
||||
originalMainGraphRenderer,
|
||||
this.rootGraph
|
||||
)
|
||||
|
||||
// Scale all subgraphs that were loaded with the workflow
|
||||
// Use original main graph renderer as fallback (not the modified one)
|
||||
@@ -1361,6 +1367,10 @@ export class ComfyApp {
|
||||
useExtensionService().invokeExtensions('loadedGraphNode', node)
|
||||
})
|
||||
|
||||
if (normalizedMainGraph) {
|
||||
syncLayoutStoreNodeBoundsFromGraph(this.rootGraph)
|
||||
}
|
||||
|
||||
await useExtensionService().invokeExtensionsAsync(
|
||||
'afterConfigureGraph',
|
||||
missingNodeTypes
|
||||
@@ -1398,6 +1408,7 @@ export class ComfyApp {
|
||||
useWorkflowService().showPendingWarnings()
|
||||
}
|
||||
|
||||
void useSubgraphNavigationStore().updateHash()
|
||||
requestAnimationFrame(() => {
|
||||
this.canvas.setDirty(true, true)
|
||||
})
|
||||
|
||||
@@ -47,6 +47,7 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
findSubgraphPathById: vi.fn()
|
||||
}))
|
||||
vi.mock('@vueuse/router', () => ({ useRouteHash: vi.fn() }))
|
||||
|
||||
describe('useSubgraphNavigationStore', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import QuickLRU from '@alloc/quick-lru'
|
||||
import { useRouteHash } from '@vueuse/router'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import type { DragAndScaleState } from '@/lib/litegraph/src/DragAndScale'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { findSubgraphPathById } from '@/utils/graphTraversalUtil'
|
||||
import { isNonNullish } from '@/utils/typeGuardUtil'
|
||||
import { isNonNullish, isSubgraph } from '@/utils/typeGuardUtil'
|
||||
|
||||
/**
|
||||
* Stores the current subgraph navigation state; a stack representing subgraph
|
||||
@@ -20,6 +23,8 @@ export const useSubgraphNavigationStore = defineStore(
|
||||
() => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const router = useRouter()
|
||||
const routeHash = useRouteHash()
|
||||
|
||||
/** The currently opened subgraph. */
|
||||
const activeSubgraph = shallowRef<Subgraph>()
|
||||
@@ -38,8 +43,6 @@ export const useSubgraphNavigationStore = defineStore(
|
||||
*/
|
||||
const getCurrentRootGraphId = () => {
|
||||
const canvas = canvasStore.getCanvas()
|
||||
if (!canvas) return 'root'
|
||||
|
||||
return canvas.graph?.rootGraph?.id ?? 'root'
|
||||
}
|
||||
|
||||
@@ -158,6 +161,72 @@ export const useSubgraphNavigationStore = defineStore(
|
||||
}
|
||||
)
|
||||
|
||||
//Allow navigation with forward/back buttons
|
||||
let blockHashUpdate = false
|
||||
let initialLoad = true
|
||||
|
||||
async function navigateToHash(newHash: string) {
|
||||
const root = app.rootGraph
|
||||
const locatorId = newHash?.slice(1) || root.id
|
||||
const canvas = canvasStore.getCanvas()
|
||||
if (canvas.graph?.id === locatorId) return
|
||||
const targetGraph =
|
||||
(locatorId || root.id) !== root.id
|
||||
? root.subgraphs.get(locatorId)
|
||||
: root
|
||||
if (targetGraph) return canvas.setGraph(targetGraph)
|
||||
|
||||
//Search all open workflows
|
||||
for (const workflow of workflowStore.openWorkflows) {
|
||||
const { activeState } = workflow
|
||||
if (!activeState) continue
|
||||
const subgraphs = activeState.definitions?.subgraphs ?? []
|
||||
for (const graph of [activeState, ...subgraphs]) {
|
||||
if (graph.id !== locatorId) continue
|
||||
//This will trigger a navigation, which can break forward history
|
||||
try {
|
||||
blockHashUpdate = true
|
||||
await useWorkflowService().openWorkflow(workflow)
|
||||
} finally {
|
||||
blockHashUpdate = false
|
||||
}
|
||||
const targetGraph =
|
||||
app.rootGraph.id === locatorId
|
||||
? app.rootGraph
|
||||
: app.rootGraph.subgraphs.get(locatorId)
|
||||
if (!targetGraph) {
|
||||
console.error('subgraph poofed after load?')
|
||||
return
|
||||
}
|
||||
|
||||
return canvas.setGraph(targetGraph)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateHash() {
|
||||
if (blockHashUpdate) return
|
||||
if (initialLoad) {
|
||||
initialLoad = false
|
||||
if (!routeHash.value) return
|
||||
await navigateToHash(routeHash.value)
|
||||
const graph = canvasStore.getCanvas().graph
|
||||
if (isSubgraph(graph)) workflowStore.activeSubgraph = graph
|
||||
return
|
||||
}
|
||||
|
||||
const newId = canvasStore.getCanvas().graph?.id ?? ''
|
||||
if (!routeHash.value) await router.replace('#' + app.rootGraph.id)
|
||||
const currentId = routeHash.value?.slice(1)
|
||||
if (!newId || newId === currentId) return
|
||||
|
||||
await router.push('#' + newId)
|
||||
}
|
||||
//update navigation hash
|
||||
//NOTE: Doesn't apply on workflow load
|
||||
watch(() => canvasStore.currentGraph, updateHash)
|
||||
watch(routeHash, () => navigateToHash(String(routeHash.value)))
|
||||
|
||||
return {
|
||||
activeSubgraph,
|
||||
navigationStack,
|
||||
@@ -165,6 +234,7 @@ export const useSubgraphNavigationStore = defineStore(
|
||||
exportState,
|
||||
saveViewport,
|
||||
restoreViewport,
|
||||
updateHash,
|
||||
viewportCache
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
getCanvas: () => app.canvas
|
||||
})
|
||||
}))
|
||||
vi.mock('@vueuse/router', () => ({ useRouteHash: vi.fn() }))
|
||||
|
||||
// Get reference to mock canvas
|
||||
const mockCanvas = app.canvas
|
||||
|
||||
@@ -7,10 +7,11 @@ import type { ExportedSubgraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
|
||||
Reference in New Issue
Block a user