mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-09 22:09:58 +00:00
Compare commits
14 Commits
dev/remote
...
fix/promot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
278a1eddb6 | ||
|
|
2330807b06 | ||
|
|
894768a4b6 | ||
|
|
210384bbf7 | ||
|
|
288f52d033 | ||
|
|
8402c2ae96 | ||
|
|
7f0ab180b9 | ||
|
|
6cd3b59d5f | ||
|
|
0b83926c3e | ||
|
|
735d639d64 | ||
|
|
71a4098aa9 | ||
|
|
c601aab2c3 | ||
|
|
fd9e732b7f | ||
|
|
8a923a2094 |
197
browser_tests/assets/subgraphs/basic-subgraph-zero-uuid.json
Normal file
197
browser_tests/assets/subgraphs/basic-subgraph-zero-uuid.json
Normal file
@@ -0,0 +1,197 @@
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"pos": [627.5973510742188, 423.0972900390625],
|
||||
"size": [144.15234375, 46],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 2,
|
||||
"lastLinkId": 4,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [347.90441582814213, 417.3822440655296, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [892.5973510742188, 416.0972900390625, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [1],
|
||||
"pos": {
|
||||
"0": 447.9044189453125,
|
||||
"1": 437.3822326660156
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [2],
|
||||
"pos": {
|
||||
"0": 912.5973510742188,
|
||||
"1": 436.0972900390625
|
||||
}
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [554.8743286132812, 100.95539093017578],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "VAEEncode",
|
||||
"pos": [685.1265869140625, 439.1734619140625],
|
||||
"size": [140, 46],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "pixels",
|
||||
"name": "pixels",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [4]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEEncode"
|
||||
}
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.8894351682943402,
|
||||
"offset": [58.7671207025881, 137.7124650620126]
|
||||
},
|
||||
"frontendVersion": "1.24.1"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -55,7 +55,8 @@
|
||||
["3", "string_a"],
|
||||
["4", "value"],
|
||||
["6", "value"],
|
||||
["6", "value_1"]
|
||||
["6", "value_1"],
|
||||
["6", "string_a"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
@@ -272,7 +273,7 @@
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["5", "string_a"],
|
||||
["9", "string_a"],
|
||||
["11", "value"],
|
||||
["9", "value"],
|
||||
["10", "string_a"]
|
||||
|
||||
@@ -458,4 +458,71 @@ test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Regression test for #10612: promoted widget indicator ring missing on
|
||||
* nested subgraph nodes.
|
||||
*
|
||||
* Uses the 3-level nested fixture (subgraph-nested-promotion):
|
||||
* Root → Sub 0 (node 5) → Sub 1 (node 6) → Sub 2 (node 9)
|
||||
*
|
||||
* Node 6 (Sub 1) has proxyWidgets promoting widgets from inner nodes,
|
||||
* and those promotions are also promoted up to node 5 (Sub 0). When
|
||||
* navigating into Sub 0, node 6 should show the promoted ring on its
|
||||
* widgets.
|
||||
*/
|
||||
test.describe(
|
||||
'Promoted indicator on 3-level nested subgraphs (#10612)',
|
||||
{ tag: ['@widget'] },
|
||||
() => {
|
||||
const WORKFLOW = 'subgraphs/subgraph-nested-promotion'
|
||||
const PROMOTED_BORDER_CLASS = 'ring-component-node-widget-promoted'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Intermediate SubgraphNode shows promoted ring inside parent subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// At root level, node 5 (Sub 0) is the outermost SubgraphNode.
|
||||
// Its widgets are not promoted further, so no ring expected.
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await comfyExpect(outerNode).toBeVisible()
|
||||
|
||||
const outerRings = outerNode.locator(`.${PROMOTED_BORDER_CLASS}`)
|
||||
await comfyExpect(outerRings).toHaveCount(0)
|
||||
|
||||
// Navigate programmatically — the enter-subgraph button on
|
||||
// node 5 is obscured by the canvas z-999 overlay at root level.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph!.getNodeById('5')
|
||||
if (node?.isSubgraphNode()) {
|
||||
window.app!.canvas.setGraph(node.subgraph)
|
||||
}
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Node 6 (Sub 1) has 4 proxyWidgets promoted up to Sub 0
|
||||
// (node 5). Each should carry the promoted ring.
|
||||
const intermediateNode = comfyPage.vueNodes.getNodeLocator('6')
|
||||
await comfyExpect(intermediateNode).toBeVisible()
|
||||
|
||||
const intermediateRings = intermediateNode.locator(
|
||||
`.${PROMOTED_BORDER_CLASS}`
|
||||
)
|
||||
await expect
|
||||
.poll(() => intermediateRings.count(), {
|
||||
timeout: 5000,
|
||||
message:
|
||||
'Node 6 (Sub 1) should show promoted rings for all 4 proxyWidgets'
|
||||
})
|
||||
.toBe(4)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
49
browser_tests/tests/subgraph/subgraphZeroUuid.spec.ts
Normal file
49
browser_tests/tests/subgraph/subgraphZeroUuid.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe(
|
||||
'Zero UUID workflow: subgraph undo rendering',
|
||||
{ tag: ['@workflow', '@subgraph'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
test.setTimeout(30000) // Extend timeout as we need to reload the page an additional time
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.page.reload() // Reload page as we need to enter in Vue mode
|
||||
await comfyPage.page.waitForFunction(() => !!window.app?.graph)
|
||||
})
|
||||
|
||||
test('Undo after subgraph enter/exit renders all nodes when workflow starts with zero UUID', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/basic-subgraph-zero-uuid'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const assertInSubgraph = async (inSubgraph: boolean) => {
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.isInSubgraph())
|
||||
.toBe(inSubgraph)
|
||||
}
|
||||
|
||||
// Root graph has 1 subgraph node, rendered in the DOM
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect.poll(() => comfyPage.vueNodes.getNodeCount()).toBe(1)
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
await assertInSubgraph(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await assertInSubgraph(false)
|
||||
|
||||
await comfyPage.canvas.focus()
|
||||
await comfyPage.keyboard.undo()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// After undo, the subgraph node is still visible and rendered
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect.poll(() => comfyPage.vueNodes.getNodeCount()).toBe(1)
|
||||
})
|
||||
}
|
||||
)
|
||||
133
src/core/graph/subgraph/promotedWidgetIndicator.test.ts
Normal file
133
src/core/graph/subgraph/promotedWidgetIndicator.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
SafeWidgetData,
|
||||
VueNodeData
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: {
|
||||
graph: {
|
||||
rootGraph: {
|
||||
id: 'graph-test'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const PROMOTED_CLASS = 'ring-component-node-widget-promoted'
|
||||
|
||||
const WidgetStub = defineComponent({
|
||||
props: { widget: { type: Object, default: undefined } },
|
||||
setup(props) {
|
||||
return () =>
|
||||
h('div', {
|
||||
'data-testid': 'widget-stub',
|
||||
class: props.widget?.borderStyle
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry',
|
||||
() => ({
|
||||
getComponent: () => WidgetStub,
|
||||
shouldExpand: () => false,
|
||||
shouldRenderAsVue: () => true
|
||||
})
|
||||
)
|
||||
|
||||
function createMockWidget(
|
||||
overrides: Partial<SafeWidgetData> = {}
|
||||
): SafeWidgetData {
|
||||
return {
|
||||
nodeId: 'test_node',
|
||||
name: 'test_widget',
|
||||
type: 'combo',
|
||||
options: undefined,
|
||||
callback: undefined,
|
||||
spec: undefined,
|
||||
isDOMWidget: false,
|
||||
slotMetadata: undefined,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createMockNodeData(
|
||||
nodeType: string,
|
||||
widgets: SafeWidgetData[],
|
||||
id: string
|
||||
): VueNodeData {
|
||||
return {
|
||||
id,
|
||||
type: nodeType,
|
||||
widgets,
|
||||
title: 'Test Node',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
}
|
||||
}
|
||||
|
||||
function renderComponent(nodeData: VueNodeData, setupStores?: () => void) {
|
||||
const pinia = createTestingPinia({ stubActions: false })
|
||||
setActivePinia(pinia)
|
||||
setupStores?.()
|
||||
|
||||
return render(NodeWidgets, {
|
||||
props: { nodeData },
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
stubs: { InputSlot: true },
|
||||
mocks: { $t: (key: string) => key }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('promoted widget indicator on nested subgraphs', () => {
|
||||
test('shows promoted ring when promotion was stored without disambiguatingSourceNodeId', async () => {
|
||||
// Scenario: SubBNode (id=3) inside SubA promotes a widget from
|
||||
// ConcreteNode (id=1). The promotion at the SubA level is stored
|
||||
// WITHOUT disambiguatingSourceNodeId because ConcreteNode is not
|
||||
// itself a SubgraphNode.
|
||||
//
|
||||
// The widget rendered on SubBNode has storeName and storeNodeId set
|
||||
// (because it's a promoted widget), so NodeWidgets.vue would normally
|
||||
// compute a disambiguatingSourceNodeId from the storeNodeId.
|
||||
// This causes a key mismatch: lookup key "3:text:1" vs stored "3:text".
|
||||
const promotedWidget = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: 'inner-subgraph:1',
|
||||
storeNodeId: 'inner-subgraph:1',
|
||||
storeName: 'text',
|
||||
slotName: 'text'
|
||||
})
|
||||
const nodeData = createMockNodeData('SubgraphNode', [promotedWidget], '3')
|
||||
renderComponent(nodeData, () => {
|
||||
// Store promotion WITHOUT disambiguatingSourceNodeId, as would
|
||||
// happen for a first-level nested promotion where the inner node
|
||||
// is not itself a SubgraphNode.
|
||||
usePromotionStore().promote('graph-test', '4', {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text'
|
||||
})
|
||||
})
|
||||
|
||||
const widgets = screen.getAllByTestId('widget-stub')
|
||||
const hasPromotedRing = widgets.some((el) =>
|
||||
el.classList.contains(PROMOTED_CLASS)
|
||||
)
|
||||
expect(hasPromotedRing).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { zeroUuid } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import {
|
||||
@@ -1005,3 +1006,25 @@ describe('deduplicateSubgraphNodeIds (via configure)', () => {
|
||||
expect(nodeIdSet(graph, SUBGRAPH_B)).toEqual(new Set([20, 21, 22]))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Zero UUID handling in configure', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia())
|
||||
})
|
||||
|
||||
it('rejects zeroUuid for root graphs and assigns a new ID', () => {
|
||||
const graph = new LGraph()
|
||||
const data = graph.serialize()
|
||||
data.id = zeroUuid
|
||||
graph.configure(data)
|
||||
expect(graph.id).not.toBe(zeroUuid)
|
||||
})
|
||||
|
||||
it('preserves zeroUuid for subgraphs', () => {
|
||||
const graph = new LGraph()
|
||||
const subgraphData = { ...createTestSubgraphData(), id: zeroUuid }
|
||||
const subgraph = graph.createSubgraph(subgraphData)
|
||||
subgraph.configure(subgraphData)
|
||||
expect(subgraph.id).toBe(zeroUuid)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2480,8 +2480,8 @@ export class LGraph
|
||||
protected _configureBase(data: ISerialisedGraph | SerialisableGraph): void {
|
||||
const { id, extra } = data
|
||||
|
||||
// Create a new graph ID if none is provided
|
||||
if (id) {
|
||||
// Create a new graph ID if none is provided or the zero UUID is used on the root graph
|
||||
if (id && !(this.isRootGraph && id === zeroUuid)) {
|
||||
this.id = id
|
||||
} else if (this.id === zeroUuid) {
|
||||
this.id = createUuidv4()
|
||||
|
||||
@@ -211,10 +211,11 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
if (
|
||||
graphId &&
|
||||
!suppressPromotedOutline &&
|
||||
usePromotionStore().isPromotedByAny(graphId, {
|
||||
sourceNodeId: String(this.node.id),
|
||||
sourceWidgetName: this.name
|
||||
})
|
||||
usePromotionStore().isWidgetPromoted(
|
||||
graphId,
|
||||
String(this.node.id),
|
||||
this.name
|
||||
)
|
||||
)
|
||||
return LiteGraph.WIDGET_PROMOTED_OUTLINE_COLOR
|
||||
return this.advanced
|
||||
|
||||
@@ -390,17 +390,20 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
? { ...mergedOptions, disabled: true }
|
||||
: mergedOptions
|
||||
|
||||
const borderStyle =
|
||||
const sourceWidgetName = widget.storeName ?? widget.name
|
||||
const isPromoted =
|
||||
graphId &&
|
||||
promotionStore.isPromotedByAny(graphId, {
|
||||
sourceNodeId: hostNodeId,
|
||||
sourceWidgetName: widget.storeName ?? widget.name,
|
||||
disambiguatingSourceNodeId: promotionSourceNodeId
|
||||
})
|
||||
? 'ring ring-component-node-widget-promoted'
|
||||
: mergedOptions.advanced
|
||||
? 'ring ring-component-node-widget-advanced'
|
||||
: undefined
|
||||
promotionStore.isWidgetPromoted(
|
||||
graphId,
|
||||
hostNodeId,
|
||||
sourceWidgetName,
|
||||
promotionSourceNodeId
|
||||
)
|
||||
const borderStyle = isPromoted
|
||||
? 'ring ring-component-node-widget-promoted'
|
||||
: mergedOptions.advanced
|
||||
? 'ring ring-component-node-widget-advanced'
|
||||
: undefined
|
||||
|
||||
const linkedUpstream: LinkedUpstreamInfo | undefined =
|
||||
slotMetadata?.linked && slotMetadata.originNodeId
|
||||
|
||||
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { ComponentWidgetImpl, DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
|
||||
const isPromotedByAnyMock = vi.hoisted(() => vi.fn())
|
||||
const isWidgetPromotedMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/stores/domWidgetStore', () => ({
|
||||
@@ -14,7 +14,7 @@ vi.mock('@/stores/domWidgetStore', () => ({
|
||||
|
||||
vi.mock('@/stores/promotionStore', () => ({
|
||||
usePromotionStore: () => ({
|
||||
isPromotedByAny: isPromotedByAnyMock
|
||||
isWidgetPromoted: isWidgetPromotedMock
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -120,7 +120,7 @@ describe('DOMWidget draw promotion behavior', () => {
|
||||
})
|
||||
|
||||
test('draws promoted outline for visible promoted widgets', () => {
|
||||
isPromotedByAnyMock.mockReturnValue(true)
|
||||
isWidgetPromotedMock.mockReturnValue(true)
|
||||
|
||||
const node = new LGraphNode('test-node')
|
||||
const rootGraph = { id: 'root-graph-id' }
|
||||
@@ -138,16 +138,17 @@ describe('DOMWidget draw promotion behavior', () => {
|
||||
|
||||
widget.draw(ctx as CanvasRenderingContext2D, node, 200, 30, 40)
|
||||
|
||||
expect(isPromotedByAnyMock).toHaveBeenCalledWith('root-graph-id', {
|
||||
sourceNodeId: '-1',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(isWidgetPromotedMock).toHaveBeenCalledWith(
|
||||
'root-graph-id',
|
||||
'-1',
|
||||
'seed'
|
||||
)
|
||||
expect(ctx.strokeRect).toHaveBeenCalledOnce()
|
||||
expect(onDraw).toHaveBeenCalledWith(widget)
|
||||
})
|
||||
|
||||
test('does not draw promoted outline when widget is not promoted', () => {
|
||||
isPromotedByAnyMock.mockReturnValue(false)
|
||||
isWidgetPromotedMock.mockReturnValue(false)
|
||||
|
||||
const node = new LGraphNode('test-node')
|
||||
const rootGraph = { id: 'root-graph-id' }
|
||||
@@ -187,7 +188,7 @@ describe('DOMWidget draw promotion behavior', () => {
|
||||
|
||||
widget.draw(ctx as CanvasRenderingContext2D, node, 200, 30, 40)
|
||||
|
||||
expect(isPromotedByAnyMock).not.toHaveBeenCalled()
|
||||
expect(isWidgetPromotedMock).not.toHaveBeenCalled()
|
||||
expect(ctx.strokeRect).not.toHaveBeenCalled()
|
||||
expect(onDraw).toHaveBeenCalledWith(widget)
|
||||
})
|
||||
|
||||
@@ -125,8 +125,6 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
|
||||
declare readonly name: string
|
||||
declare readonly options: DOMWidgetOptions<V>
|
||||
declare callback?: (value: V) => void
|
||||
readonly promotionStore = usePromotionStore()
|
||||
|
||||
readonly id: string
|
||||
|
||||
constructor(obj: {
|
||||
@@ -190,10 +188,11 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
|
||||
const graphId = this.node.graph?.rootGraph.id
|
||||
const isPromoted =
|
||||
graphId &&
|
||||
this.promotionStore.isPromotedByAny(graphId, {
|
||||
sourceNodeId: String(this.node.id),
|
||||
sourceWidgetName: this.name
|
||||
})
|
||||
usePromotionStore().isWidgetPromoted(
|
||||
graphId,
|
||||
String(this.node.id),
|
||||
this.name
|
||||
)
|
||||
if (!isPromoted) {
|
||||
this.options.onDraw?.(this)
|
||||
return
|
||||
|
||||
@@ -190,11 +190,35 @@ export const usePromotionStore = defineStore('promotion', () => {
|
||||
graphRefCounts.value.delete(graphId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a widget is promoted by any subgraph node in the given
|
||||
* graph. Handles nested subgraph promotions where the stored key may omit
|
||||
* the disambiguatingSourceNodeId — checks both key shapes (#10612).
|
||||
*/
|
||||
function isWidgetPromoted(
|
||||
graphId: UUID,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): boolean {
|
||||
if (
|
||||
disambiguatingSourceNodeId &&
|
||||
isPromotedByAny(graphId, {
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
})
|
||||
)
|
||||
return true
|
||||
return isPromotedByAny(graphId, { sourceNodeId, sourceWidgetName })
|
||||
}
|
||||
|
||||
return {
|
||||
getPromotionsRef,
|
||||
getPromotions,
|
||||
isPromoted,
|
||||
isPromotedByAny,
|
||||
isWidgetPromoted,
|
||||
setPromotions,
|
||||
promote,
|
||||
demote,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { app } from '@/scripts/app'
|
||||
import { findSubgraphPathById } from '@/utils/graphTraversalUtil'
|
||||
import { anyItemOverlapsRect } from '@/utils/mathUtil'
|
||||
import { isNonNullish, isSubgraph } from '@/utils/typeGuardUtil'
|
||||
|
||||
export const VIEWPORT_CACHE_MAX_SIZE = 32
|
||||
@@ -138,11 +139,19 @@ export const useSubgraphNavigationStore = defineStore(
|
||||
return
|
||||
}
|
||||
|
||||
// Cache miss — fit to content after the canvas has the new graph.
|
||||
// rAF fires after layout + paint, when nodes are positioned.
|
||||
const expectedGraphId = graphId
|
||||
// Cache miss — fit to content only if no nodes are currently visible.
|
||||
// loadGraphData may have already restored extra.ds or called fitView
|
||||
// for templates, so only intervene when the viewport is truly empty.
|
||||
requestAnimationFrame(() => {
|
||||
if (getActiveGraphId() !== expectedGraphId) return
|
||||
if (getActiveGraphId() !== graphId) return
|
||||
if (!canvas.graph) return
|
||||
|
||||
const nodes = canvas.graph.nodes
|
||||
if (!nodes?.length) return
|
||||
|
||||
canvas.ds.computeVisibleArea(canvas.viewport)
|
||||
if (anyItemOverlapsRect(nodes, canvas.ds.visible_area)) return
|
||||
|
||||
useLitegraphService().fitView()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,8 +24,11 @@ vi.mock('@/scripts/app', () => {
|
||||
scale: 1,
|
||||
offset: [0, 0],
|
||||
state: { scale: 1, offset: [0, 0] },
|
||||
fitToBounds: vi.fn()
|
||||
fitToBounds: vi.fn(),
|
||||
visible_area: [0, 0, 1000, 1000],
|
||||
computeVisibleArea: vi.fn()
|
||||
},
|
||||
viewport: [0, 0, 1000, 1000],
|
||||
setDirty: mockSetDirty,
|
||||
get empty() {
|
||||
return true
|
||||
@@ -184,6 +187,11 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
// Ensure no cached entry
|
||||
store.viewportCache.delete(':root')
|
||||
|
||||
// Add a node outside the visible area so anyItemOverlapsRect returns false
|
||||
const mockGraph = app.graph as { nodes: unknown[]; _nodes: unknown[] }
|
||||
mockGraph.nodes = [{ pos: [9999, 9999], size: [100, 100] }]
|
||||
mockGraph._nodes = mockGraph.nodes
|
||||
|
||||
// Use the root graph ID so the stale-guard passes
|
||||
store.restoreViewport('root')
|
||||
|
||||
@@ -194,6 +202,10 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
rafCallbacks[0](performance.now())
|
||||
|
||||
expect(mockFitView).toHaveBeenCalledOnce()
|
||||
|
||||
// Cleanup
|
||||
mockGraph.nodes = []
|
||||
mockGraph._nodes = []
|
||||
})
|
||||
|
||||
it('skips fitView if active graph changed before rAF fires', () => {
|
||||
|
||||
Reference in New Issue
Block a user