From 893409dfc83510408146184ae822d746344957a4 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Fri, 19 Sep 2025 13:51:47 -0700 Subject: [PATCH 1/3] Add playwright tests for links and slots in vue nodes mode (#5668) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests added - Should show a link dragging out from a slot when dragging on a slot - Should create a link when dropping on a compatible slot - Should not create a link when dropping on an incompatible slot(s) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5668-Add-playwright-tests-for-links-and-slots-in-vue-nodes-mode-2736d73d36508188a47dceee5d1a11e5) by [Unito](https://www.unito.io) --------- Co-authored-by: github-actions --- .../assets/vueNodes/simple-triple.json | 1 + browser_tests/helpers/fitToView.ts | 104 +++++++++ .../tests/vueNodes/NodeHeader.spec.ts | 2 +- .../tests/vueNodes/linkInteraction.spec.ts | 221 ++++++++++++++++++ .../vue-node-dragging-link-chromium-linux.png | Bin 0 -> 54042 bytes 5 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 browser_tests/assets/vueNodes/simple-triple.json create mode 100644 browser_tests/helpers/fitToView.ts create mode 100644 browser_tests/tests/vueNodes/linkInteraction.spec.ts create mode 100644 browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png diff --git a/browser_tests/assets/vueNodes/simple-triple.json b/browser_tests/assets/vueNodes/simple-triple.json new file mode 100644 index 0000000000..9b665191db --- /dev/null +++ b/browser_tests/assets/vueNodes/simple-triple.json @@ -0,0 +1 @@ +{"id":"4412323e-2509-4258-8abc-68ddeea8f9e1","revision":0,"last_node_id":39,"last_link_id":29,"nodes":[{"id":37,"type":"KSampler","pos":[3635.923095703125,870.237548828125],"size":[428,437],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"model","name":"model","type":"MODEL","link":null},{"localized_name":"positive","name":"positive","type":"CONDITIONING","link":null},{"localized_name":"negative","name":"negative","type":"CONDITIONING","link":null},{"localized_name":"latent_image","name":"latent_image","type":"LATENT","link":null},{"localized_name":"seed","name":"seed","type":"INT","widget":{"name":"seed"},"link":null},{"localized_name":"steps","name":"steps","type":"INT","widget":{"name":"steps"},"link":null},{"localized_name":"cfg","name":"cfg","type":"FLOAT","widget":{"name":"cfg"},"link":null},{"localized_name":"sampler_name","name":"sampler_name","type":"COMBO","widget":{"name":"sampler_name"},"link":null},{"localized_name":"scheduler","name":"scheduler","type":"COMBO","widget":{"name":"scheduler"},"link":null},{"localized_name":"denoise","name":"denoise","type":"FLOAT","widget":{"name":"denoise"},"link":null}],"outputs":[{"localized_name":"LATENT","name":"LATENT","type":"LATENT","links":null}],"properties":{"Node name for S&R":"KSampler"},"widgets_values":[0,"randomize",20,8,"euler","simple",1]},{"id":38,"type":"VAEDecode","pos":[4164.01611328125,925.5230712890625],"size":[193.25,107],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"samples","name":"samples","type":"LATENT","link":null},{"localized_name":"vae","name":"vae","type":"VAE","link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":null}],"properties":{"Node name for S&R":"VAEDecode"}},{"id":39,"type":"CLIPTextEncode","pos":[3259.289794921875,927.2508544921875],"size":[239.9375,155],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"clip","name":"clip","type":"CLIP","link":null},{"localized_name":"text","name":"text","type":"STRING","widget":{"name":"text"},"link":null}],"outputs":[{"localized_name":"CONDITIONING","name":"CONDITIONING","type":"CONDITIONING","links":null}],"properties":{"Node name for S&R":"CLIPTextEncode"},"widgets_values":[""]}],"links":[],"groups":[],"config":{},"extra":{"ds":{"scale":1.1576250000000001,"offset":[-2808.366467322067,-478.34316506594797]}},"version":0.4} \ No newline at end of file diff --git a/browser_tests/helpers/fitToView.ts b/browser_tests/helpers/fitToView.ts new file mode 100644 index 0000000000..af6c10e9d3 --- /dev/null +++ b/browser_tests/helpers/fitToView.ts @@ -0,0 +1,104 @@ +import type { ReadOnlyRect } from '../../src/lib/litegraph/src/interfaces' +import type { ComfyPage } from '../fixtures/ComfyPage' + +interface FitToViewOptions { + selectionOnly?: boolean + zoom?: number + padding?: number +} + +/** + * Instantly fits the canvas view to graph content without waiting for UI animation. + * + * Lives outside the shared fixture to keep the default ComfyPage interactions user-oriented. + */ +export async function fitToViewInstant( + comfyPage: ComfyPage, + options: FitToViewOptions = {} +) { + const { selectionOnly = false, zoom = 0.75, padding = 10 } = options + + const rectangles = await comfyPage.page.evaluate< + ReadOnlyRect[] | null, + { selectionOnly: boolean } + >( + ({ selectionOnly }) => { + const app = window['app'] + if (!app?.canvas) return null + + const canvas = app.canvas + const items = (() => { + if (selectionOnly && canvas.selectedItems?.size) { + return Array.from(canvas.selectedItems) + } + try { + return Array.from(canvas.positionableItems ?? []) + } catch { + return [] + } + })() + + if (!items.length) return null + + const rects: ReadOnlyRect[] = [] + + for (const item of items) { + const rect = item?.boundingRect + if (!rect) continue + + const x = Number(rect[0]) + const y = Number(rect[1]) + const width = Number(rect[2]) + const height = Number(rect[3]) + + rects.push([x, y, width, height] as const) + } + + return rects.length ? rects : null + }, + { selectionOnly } + ) + + if (!rectangles || rectangles.length === 0) return + + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + + for (const [x, y, width, height] of rectangles) { + minX = Math.min(minX, Number(x)) + minY = Math.min(minY, Number(y)) + maxX = Math.max(maxX, Number(x) + Number(width)) + maxY = Math.max(maxY, Number(y) + Number(height)) + } + + const hasFiniteBounds = + Number.isFinite(minX) && + Number.isFinite(minY) && + Number.isFinite(maxX) && + Number.isFinite(maxY) + + if (!hasFiniteBounds) return + + const bounds: ReadOnlyRect = [ + minX - padding, + minY - padding, + maxX - minX + 2 * padding, + maxY - minY + 2 * padding + ] + + await comfyPage.page.evaluate( + ({ bounds, zoom }) => { + const app = window['app'] + if (!app?.canvas) return + + const canvas = app.canvas + canvas.ds.fitToBounds(bounds, { zoom }) + canvas.setDirty(true, true) + }, + { bounds, zoom } + ) + + await comfyPage.nextFrame() +} diff --git a/browser_tests/tests/vueNodes/NodeHeader.spec.ts b/browser_tests/tests/vueNodes/NodeHeader.spec.ts index 7a8ae5dd2b..336e2672de 100644 --- a/browser_tests/tests/vueNodes/NodeHeader.spec.ts +++ b/browser_tests/tests/vueNodes/NodeHeader.spec.ts @@ -6,7 +6,7 @@ import { VueNodeFixture } from '../../fixtures/utils/vueNodeFixtures' test.describe('NodeHeader', () => { test.beforeEach(async ({ comfyPage }) => { - await comfyPage.setSetting('Comfy.UseNewMenu', 'Enabled') + await comfyPage.setSetting('Comfy.UseNewMenu', 'Top') await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false) await comfyPage.setSetting('Comfy.EnableTooltips', true) await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) diff --git a/browser_tests/tests/vueNodes/linkInteraction.spec.ts b/browser_tests/tests/vueNodes/linkInteraction.spec.ts new file mode 100644 index 0000000000..d6b1bccc18 --- /dev/null +++ b/browser_tests/tests/vueNodes/linkInteraction.spec.ts @@ -0,0 +1,221 @@ +import type { Locator } from '@playwright/test' + +import { getSlotKey } from '../../../src/renderer/core/layout/slots/slotIdentifier' +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../fixtures/ComfyPage' +import { fitToViewInstant } from '../../helpers/fitToView' + +async function getCenter(locator: Locator): Promise<{ x: number; y: number }> { + const box = await locator.boundingBox() + if (!box) throw new Error('Slot bounding box not available') + return { + x: box.x + box.width / 2, + y: box.y + box.height / 2 + } +} + +test.describe('Vue Node Link Interaction', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Top') + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.setup() + await comfyPage.loadWorkflow('vueNodes/simple-triple') + await comfyPage.vueNodes.waitForNodes() + await fitToViewInstant(comfyPage) + }) + + test('should show a link dragging out from a slot when dragging on a slot', async ({ + comfyPage, + comfyMouse + }) => { + const samplerNodes = await comfyPage.getNodeRefsByType('KSampler') + expect(samplerNodes.length).toBeGreaterThan(0) + + const samplerNode = samplerNodes[0] + const outputSlot = await samplerNode.getOutput(0) + await outputSlot.removeLinks() + await comfyPage.nextFrame() + + const slotKey = getSlotKey(String(samplerNode.id), 0, false) + const slotLocator = comfyPage.page.locator(`[data-slot-key="${slotKey}"]`) + await expect(slotLocator).toBeVisible() + + const start = await getCenter(slotLocator) + const canvasBox = await comfyPage.canvas.boundingBox() + if (!canvasBox) throw new Error('Canvas bounding box not available') + + // Arbitrary value + const dragTarget = { + x: start.x + 180, + y: start.y - 140 + } + + await comfyMouse.move(start) + await comfyMouse.drag(dragTarget) + await comfyPage.nextFrame() + + try { + await expect(comfyPage.canvas).toHaveScreenshot( + 'vue-node-dragging-link.png' + ) + } finally { + await comfyMouse.drop() + } + }) + + test('should create a link when dropping on a compatible slot', async ({ + comfyPage + }) => { + const samplerNodes = await comfyPage.getNodeRefsByType('KSampler') + expect(samplerNodes.length).toBeGreaterThan(0) + const samplerNode = samplerNodes[0] + + const vaeNodes = await comfyPage.getNodeRefsByType('VAEDecode') + expect(vaeNodes.length).toBeGreaterThan(0) + const vaeNode = vaeNodes[0] + + const samplerOutput = await samplerNode.getOutput(0) + const vaeInput = await vaeNode.getInput(0) + + const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false) + const inputSlotKey = getSlotKey(String(vaeNode.id), 0, true) + + const outputSlot = comfyPage.page.locator( + `[data-slot-key="${outputSlotKey}"]` + ) + const inputSlot = comfyPage.page.locator( + `[data-slot-key="${inputSlotKey}"]` + ) + + await expect(outputSlot).toBeVisible() + await expect(inputSlot).toBeVisible() + + await outputSlot.dragTo(inputSlot) + await comfyPage.nextFrame() + + expect(await samplerOutput.getLinkCount()).toBe(1) + expect(await vaeInput.getLinkCount()).toBe(1) + + const linkDetails = await comfyPage.page.evaluate((sourceId) => { + const app = window['app'] + const graph = app?.canvas?.graph ?? app?.graph + if (!graph) return null + + const source = graph.getNodeById(sourceId) + if (!source) return null + + const linkId = source.outputs[0]?.links?.[0] + if (linkId == null) return null + + const link = graph.links[linkId] + if (!link) return null + + return { + originId: link.origin_id, + originSlot: link.origin_slot, + targetId: link.target_id, + targetSlot: link.target_slot + } + }, samplerNode.id) + + expect(linkDetails).not.toBeNull() + expect(linkDetails).toMatchObject({ + originId: samplerNode.id, + originSlot: 0, + targetId: vaeNode.id, + targetSlot: 0 + }) + }) + + test('should not create a link when slot types are incompatible', async ({ + comfyPage + }) => { + const samplerNodes = await comfyPage.getNodeRefsByType('KSampler') + expect(samplerNodes.length).toBeGreaterThan(0) + const samplerNode = samplerNodes[0] + + const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode') + expect(clipNodes.length).toBeGreaterThan(0) + const clipNode = clipNodes[0] + + const samplerOutput = await samplerNode.getOutput(0) + const clipInput = await clipNode.getInput(0) + + const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false) + const inputSlotKey = getSlotKey(String(clipNode.id), 0, true) + + const outputSlot = comfyPage.page.locator( + `[data-slot-key="${outputSlotKey}"]` + ) + const inputSlot = comfyPage.page.locator( + `[data-slot-key="${inputSlotKey}"]` + ) + + await expect(outputSlot).toBeVisible() + await expect(inputSlot).toBeVisible() + + await outputSlot.dragTo(inputSlot) + await comfyPage.nextFrame() + + expect(await samplerOutput.getLinkCount()).toBe(0) + expect(await clipInput.getLinkCount()).toBe(0) + + const graphLinkCount = await comfyPage.page.evaluate((sourceId) => { + const app = window['app'] + const graph = app?.canvas?.graph ?? app?.graph + if (!graph) return 0 + + const source = graph.getNodeById(sourceId) + if (!source) return 0 + + return source.outputs[0]?.links?.length ?? 0 + }, samplerNode.id) + + expect(graphLinkCount).toBe(0) + }) + + test('should not create a link when dropping onto a slot on the same node', async ({ + comfyPage + }) => { + const samplerNodes = await comfyPage.getNodeRefsByType('KSampler') + expect(samplerNodes.length).toBeGreaterThan(0) + const samplerNode = samplerNodes[0] + + const samplerOutput = await samplerNode.getOutput(0) + const samplerInput = await samplerNode.getInput(3) + + const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false) + const inputSlotKey = getSlotKey(String(samplerNode.id), 3, true) + + const outputSlot = comfyPage.page.locator( + `[data-slot-key="${outputSlotKey}"]` + ) + const inputSlot = comfyPage.page.locator( + `[data-slot-key="${inputSlotKey}"]` + ) + + await expect(outputSlot).toBeVisible() + await expect(inputSlot).toBeVisible() + + await outputSlot.dragTo(inputSlot) + await comfyPage.nextFrame() + + expect(await samplerOutput.getLinkCount()).toBe(0) + expect(await samplerInput.getLinkCount()).toBe(0) + + const graphLinkCount = await comfyPage.page.evaluate((sourceId) => { + const app = window['app'] + const graph = app?.canvas?.graph ?? app?.graph + if (!graph) return 0 + + const source = graph.getNodeById(sourceId) + if (!source) return 0 + + return source.outputs[0]?.links?.length ?? 0 + }, samplerNode.id) + + expect(graphLinkCount).toBe(0) + }) +}) diff --git a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..d4c32b4ea2f5e6f13e9409d41e82454e8db6f8d0 GIT binary patch literal 54042 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 Qp$8)+CNG)?eevf10X%eTMF0Q* literal 0 HcmV?d00001 From 8ffe63f54eb3369e0d05679e30afb68418925341 Mon Sep 17 00:00:00 2001 From: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com> Date: Fri, 19 Sep 2025 21:52:57 +0100 Subject: [PATCH 2/3] Layoutstore Minimap calculation (#5547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This pull request refactors the minimap rendering system to use a unified, extensible data source abstraction for all minimap operations. By introducing a data source interface and factory, the minimap can now seamlessly support multiple sources of node layout (such as the `LayoutStore` or the underlying `LiteGraph`), improving maintainability and future extensibility. Rendering logic and change detection throughout the minimap have been updated to use this new abstraction, resulting in cleaner code and easier support for new data models. **Core architecture improvements:** * Introduced a new `IMinimapDataSource` interface and related data types (`MinimapNodeData`, `MinimapLinkData`, `MinimapGroupData`) to standardize node, link, and group data for minimap rendering. * Added an abstract base class `AbstractMinimapDataSource` that provides shared logic for bounds and group/link extraction, and implemented two concrete data sources: `LiteGraphDataSource` (for classic graph data) and `LayoutStoreDataSource` (for layout store data). [[1]](diffhunk://#diff-ea46218fc9ffced84168a5ff975e4a30e43f7bf134ee8f02ed2eae66efbb729dR1-R95) [[2]](diffhunk://#diff-9a6b7c6be25b4dbeb358fea18f3a21e78797058ccc86c818ed1e5f69c7355273R1-R30) [[3]](diffhunk://#diff-f200ba9495a03157198abff808ed6c3761746071404a52adbad98f6a9d01249bR1-R42) * Created a `MinimapDataSourceFactory` that selects the appropriate data source based on the presence of layout store data, enabling seamless switching between data models. **Minimap rendering and logic refactoring:** * Updated all minimap rendering functions (`renderGroups`, `renderNodes`, `renderConnections`) and the main `renderMinimapToCanvas` entry point to use the unified data source interface, significantly simplifying the rendering code and decoupling it from the underlying graph structure. [[1]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L1-R11) [[2]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121R33-R75) [[3]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L66-R124) [[4]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L134-R161) [[5]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L153-R187) [[6]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L187-L188) [[7]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121R227-R231) [[8]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L230-R248) * Refactored minimap viewport and graph change detection logic to use the data source abstraction for bounds, node, and link change detection, and to respond to layout store version changes. [[1]](diffhunk://#diff-d92e448dee5e30782a66b9e66d8c8b05626dffd0b2ff1032f2612b9a9b9c51f6L2-R10) [[2]](diffhunk://#diff-d92e448dee5e30782a66b9e66d8c8b05626dffd0b2ff1032f2612b9a9b9c51f6R33-R35) [[3]](diffhunk://#diff-d92e448dee5e30782a66b9e66d8c8b05626dffd0b2ff1032f2612b9a9b9c51f6L99-R141) [[4]](diffhunk://#diff-d92e448dee5e30782a66b9e66d8c8b05626dffd0b2ff1032f2612b9a9b9c51f6R157-R160) [[5]](diffhunk://#diff-338d14c67dabffaf6f68fbf09b16e8d67bead2b9df340e46601b2fbd57331521L8-R11) [[6]](diffhunk://#diff-338d14c67dabffaf6f68fbf09b16e8d67bead2b9df340e46601b2fbd57331521L56-R64) These changes make the minimap codebase more modular and robust, and lay the groundwork for supporting additional node layout strategies in the future. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5547-Layoutstore-Minimap-calculation-26e6d73d3650813e9457c051dff41ca1) by [Unito](https://www.unito.io) --- .../minimap/composables/useMinimapGraph.ts | 48 +++-- .../minimap/composables/useMinimapViewport.ts | 16 +- .../minimap/data/AbstractMinimapDataSource.ts | 95 ++++++++++ .../minimap/data/LayoutStoreDataSource.ts | 42 +++++ .../minimap/data/LiteGraphDataSource.ts | 30 +++ .../minimap/data/MinimapDataSourceFactory.ts | 22 +++ .../minimap/minimapCanvasRenderer.ts | 146 ++++++++------- src/renderer/extensions/minimap/types.ts | 48 +++++ .../tests/minimap/MinimapDataSource.test.ts | 174 ++++++++++++++++++ 9 files changed, 529 insertions(+), 92 deletions(-) create mode 100644 src/renderer/extensions/minimap/data/AbstractMinimapDataSource.ts create mode 100644 src/renderer/extensions/minimap/data/LayoutStoreDataSource.ts create mode 100644 src/renderer/extensions/minimap/data/LiteGraphDataSource.ts create mode 100644 src/renderer/extensions/minimap/data/MinimapDataSourceFactory.ts create mode 100644 tests-ui/tests/minimap/MinimapDataSource.test.ts diff --git a/src/renderer/extensions/minimap/composables/useMinimapGraph.ts b/src/renderer/extensions/minimap/composables/useMinimapGraph.ts index 4f1c0dd8ed..c5bf9aa6c9 100644 --- a/src/renderer/extensions/minimap/composables/useMinimapGraph.ts +++ b/src/renderer/extensions/minimap/composables/useMinimapGraph.ts @@ -1,11 +1,13 @@ import { useThrottleFn } from '@vueuse/core' -import { ref } from 'vue' +import { ref, watch } from 'vue' import type { Ref } from 'vue' import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph' import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema' +import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import { api } from '@/scripts/api' +import { MinimapDataSourceFactory } from '../data/MinimapDataSourceFactory' import type { UpdateFlags } from '../types' interface GraphCallbacks { @@ -28,6 +30,9 @@ export function useMinimapGraph( viewport: false }) + // Track LayoutStore version for change detection + const layoutStoreVersion = layoutStore.getVersion() + // Map to store original callbacks per graph ID const originalCallbacksMap = new Map() @@ -96,28 +101,30 @@ export function useMinimapGraph( let positionChanged = false let connectionChanged = false - if (g._nodes.length !== lastNodeCount.value) { + // Use unified data source for change detection + const dataSource = MinimapDataSourceFactory.create(g) + + // Check for node count changes + const currentNodeCount = dataSource.getNodeCount() + if (currentNodeCount !== lastNodeCount.value) { structureChanged = true - lastNodeCount.value = g._nodes.length + lastNodeCount.value = currentNodeCount } - for (const node of g._nodes) { - const key = node.id - const currentState = `${node.pos[0]},${node.pos[1]},${node.size[0]},${node.size[1]}` + // Check for node position/size changes + const nodes = dataSource.getNodes() + for (const node of nodes) { + const nodeId = node.id + const currentState = `${node.x},${node.y},${node.width},${node.height}` - if (nodeStatesCache.get(key) !== currentState) { + if (nodeStatesCache.get(nodeId) !== currentState) { positionChanged = true - nodeStatesCache.set(key, currentState) + nodeStatesCache.set(nodeId, currentState) } } - const currentLinks = JSON.stringify(g.links || {}) - if (currentLinks !== linksCache.value) { - connectionChanged = true - linksCache.value = currentLinks - } - - const currentNodeIds = new Set(g._nodes.map((n: LGraphNode) => n.id)) + // Clean up removed nodes from cache + const currentNodeIds = new Set(nodes.map((n) => n.id)) for (const [nodeId] of nodeStatesCache) { if (!currentNodeIds.has(nodeId)) { nodeStatesCache.delete(nodeId) @@ -125,6 +132,13 @@ export function useMinimapGraph( } } + // TODO: update when Layoutstore tracks links + const currentLinks = JSON.stringify(g.links || {}) + if (currentLinks !== linksCache.value) { + connectionChanged = true + linksCache.value = currentLinks + } + if (structureChanged || positionChanged) { updateFlags.value.bounds = true updateFlags.value.nodes = true @@ -140,6 +154,10 @@ export function useMinimapGraph( const init = () => { setupEventListeners() api.addEventListener('graphChanged', handleGraphChangedThrottled) + + watch(layoutStoreVersion, () => { + void handleGraphChangedThrottled() + }) } const destroy = () => { diff --git a/src/renderer/extensions/minimap/composables/useMinimapViewport.ts b/src/renderer/extensions/minimap/composables/useMinimapViewport.ts index da67e5a7ca..6f947a3077 100644 --- a/src/renderer/extensions/minimap/composables/useMinimapViewport.ts +++ b/src/renderer/extensions/minimap/composables/useMinimapViewport.ts @@ -5,9 +5,9 @@ import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformS import type { LGraph } from '@/lib/litegraph/src/litegraph' import { calculateMinimapScale, - calculateNodeBounds, enforceMinimumBounds } from '@/renderer/core/spatial/boundsCalculator' +import { MinimapDataSourceFactory } from '@/renderer/extensions/minimap/data/MinimapDataSourceFactory' import type { MinimapBounds, MinimapCanvas, ViewportTransform } from '../types' @@ -53,17 +53,15 @@ export function useMinimapViewport( } const calculateGraphBounds = (): MinimapBounds => { - const g = graph.value - if (!g || !g._nodes || g._nodes.length === 0) { + // Use unified data source + const dataSource = MinimapDataSourceFactory.create(graph.value) + + if (!dataSource.hasData()) { return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 } } - const bounds = calculateNodeBounds(g._nodes) - if (!bounds) { - return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 } - } - - return enforceMinimumBounds(bounds) + const sourceBounds = dataSource.getBounds() + return enforceMinimumBounds(sourceBounds) } const calculateScale = () => { diff --git a/src/renderer/extensions/minimap/data/AbstractMinimapDataSource.ts b/src/renderer/extensions/minimap/data/AbstractMinimapDataSource.ts new file mode 100644 index 0000000000..4aae340b4e --- /dev/null +++ b/src/renderer/extensions/minimap/data/AbstractMinimapDataSource.ts @@ -0,0 +1,95 @@ +import type { LGraph } from '@/lib/litegraph/src/litegraph' +import { calculateNodeBounds } from '@/renderer/core/spatial/boundsCalculator' + +import type { + IMinimapDataSource, + MinimapBounds, + MinimapGroupData, + MinimapLinkData, + MinimapNodeData +} from '../types' + +/** + * Abstract base class for minimap data sources + * Provides common functionality and shared implementation + */ +export abstract class AbstractMinimapDataSource implements IMinimapDataSource { + constructor(protected graph: LGraph | null) {} + + // Abstract methods that must be implemented by subclasses + abstract getNodes(): MinimapNodeData[] + abstract getNodeCount(): number + abstract hasData(): boolean + + // Shared implementation using calculateNodeBounds + getBounds(): MinimapBounds { + const nodes = this.getNodes() + if (nodes.length === 0) { + return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 } + } + + // Convert MinimapNodeData to the format expected by calculateNodeBounds + const compatibleNodes = nodes.map((node) => ({ + pos: [node.x, node.y], + size: [node.width, node.height] + })) + + const bounds = calculateNodeBounds(compatibleNodes) + if (!bounds) { + return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 } + } + + return bounds + } + + // Shared implementation for groups + getGroups(): MinimapGroupData[] { + if (!this.graph?._groups) return [] + return this.graph._groups.map((group) => ({ + x: group.pos[0], + y: group.pos[1], + width: group.size[0], + height: group.size[1], + color: group.color + })) + } + + // TODO: update when Layoutstore supports links + getLinks(): MinimapLinkData[] { + if (!this.graph) return [] + return this.extractLinksFromGraph(this.graph) + } + + protected extractLinksFromGraph(graph: LGraph): MinimapLinkData[] { + const links: MinimapLinkData[] = [] + const nodeMap = new Map(this.getNodes().map((n) => [n.id, n])) + + for (const node of graph._nodes) { + if (!node.outputs) continue + + const sourceNodeData = nodeMap.get(String(node.id)) + if (!sourceNodeData) continue + + for (const output of node.outputs) { + if (!output.links) continue + + for (const linkId of output.links) { + const link = graph.links[linkId] + if (!link) continue + + const targetNodeData = nodeMap.get(String(link.target_id)) + if (!targetNodeData) continue + + links.push({ + sourceNode: sourceNodeData, + targetNode: targetNodeData, + sourceSlot: link.origin_slot, + targetSlot: link.target_slot + }) + } + } + } + + return links + } +} diff --git a/src/renderer/extensions/minimap/data/LayoutStoreDataSource.ts b/src/renderer/extensions/minimap/data/LayoutStoreDataSource.ts new file mode 100644 index 0000000000..c0daf7030f --- /dev/null +++ b/src/renderer/extensions/minimap/data/LayoutStoreDataSource.ts @@ -0,0 +1,42 @@ +import { layoutStore } from '@/renderer/core/layout/store/layoutStore' + +import type { MinimapNodeData } from '../types' +import { AbstractMinimapDataSource } from './AbstractMinimapDataSource' + +/** + * Layout Store data source implementation + */ +export class LayoutStoreDataSource extends AbstractMinimapDataSource { + getNodes(): MinimapNodeData[] { + const allNodes = layoutStore.getAllNodes().value + if (allNodes.size === 0) return [] + + const nodes: MinimapNodeData[] = [] + + for (const [nodeId, layout] of allNodes) { + // Find corresponding LiteGraph node for additional properties + const graphNode = this.graph?._nodes?.find((n) => String(n.id) === nodeId) + + nodes.push({ + id: nodeId, + x: layout.position.x, + y: layout.position.y, + width: layout.size.width, + height: layout.size.height, + bgcolor: graphNode?.bgcolor, + mode: graphNode?.mode, + hasErrors: graphNode?.has_errors + }) + } + + return nodes + } + + getNodeCount(): number { + return layoutStore.getAllNodes().value.size + } + + hasData(): boolean { + return this.getNodeCount() > 0 + } +} diff --git a/src/renderer/extensions/minimap/data/LiteGraphDataSource.ts b/src/renderer/extensions/minimap/data/LiteGraphDataSource.ts new file mode 100644 index 0000000000..8e1048e750 --- /dev/null +++ b/src/renderer/extensions/minimap/data/LiteGraphDataSource.ts @@ -0,0 +1,30 @@ +import type { MinimapNodeData } from '../types' +import { AbstractMinimapDataSource } from './AbstractMinimapDataSource' + +/** + * LiteGraph data source implementation + */ +export class LiteGraphDataSource extends AbstractMinimapDataSource { + getNodes(): MinimapNodeData[] { + if (!this.graph?._nodes) return [] + + return this.graph._nodes.map((node) => ({ + id: String(node.id), + x: node.pos[0], + y: node.pos[1], + width: node.size[0], + height: node.size[1], + bgcolor: node.bgcolor, + mode: node.mode, + hasErrors: node.has_errors + })) + } + + getNodeCount(): number { + return this.graph?._nodes?.length ?? 0 + } + + hasData(): boolean { + return this.getNodeCount() > 0 + } +} diff --git a/src/renderer/extensions/minimap/data/MinimapDataSourceFactory.ts b/src/renderer/extensions/minimap/data/MinimapDataSourceFactory.ts new file mode 100644 index 0000000000..49b15ed9e3 --- /dev/null +++ b/src/renderer/extensions/minimap/data/MinimapDataSourceFactory.ts @@ -0,0 +1,22 @@ +import type { LGraph } from '@/lib/litegraph/src/litegraph' +import { layoutStore } from '@/renderer/core/layout/store/layoutStore' + +import type { IMinimapDataSource } from '../types' +import { LayoutStoreDataSource } from './LayoutStoreDataSource' +import { LiteGraphDataSource } from './LiteGraphDataSource' + +/** + * Factory for creating the appropriate data source + */ +export class MinimapDataSourceFactory { + static create(graph: LGraph | null): IMinimapDataSource { + // Check if LayoutStore has data + const layoutStoreHasData = layoutStore.getAllNodes().value.size > 0 + + if (layoutStoreHasData) { + return new LayoutStoreDataSource(graph) + } + + return new LiteGraphDataSource(graph) + } +} diff --git a/src/renderer/extensions/minimap/minimapCanvasRenderer.ts b/src/renderer/extensions/minimap/minimapCanvasRenderer.ts index 2e0790ca96..3e547ce689 100644 --- a/src/renderer/extensions/minimap/minimapCanvasRenderer.ts +++ b/src/renderer/extensions/minimap/minimapCanvasRenderer.ts @@ -3,7 +3,12 @@ import { LGraphEventMode } from '@/lib/litegraph/src/litegraph' import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore' import { adjustColor } from '@/utils/colorUtil' -import type { MinimapRenderContext } from './types' +import { MinimapDataSourceFactory } from './data/MinimapDataSourceFactory' +import type { + IMinimapDataSource, + MinimapNodeData, + MinimapRenderContext +} from './types' /** * Get theme-aware colors for the minimap @@ -25,24 +30,49 @@ function getMinimapColors() { } } +/** + * Get node color based on settings and node properties (Single Responsibility) + */ +function getNodeColor( + node: MinimapNodeData, + settings: MinimapRenderContext['settings'], + colors: ReturnType +): string { + if (settings.renderBypass && node.mode === LGraphEventMode.BYPASS) { + return colors.bypassColor + } + + if (settings.nodeColors) { + if (node.bgcolor) { + return colors.isLightTheme + ? adjustColor(node.bgcolor, { lightness: 0.5 }) + : node.bgcolor + } + return colors.nodeColorDefault + } + + return colors.nodeColor +} + /** * Render groups on the minimap */ function renderGroups( ctx: CanvasRenderingContext2D, - graph: LGraph, + dataSource: IMinimapDataSource, offsetX: number, offsetY: number, context: MinimapRenderContext, colors: ReturnType ) { - if (!graph._groups || graph._groups.length === 0) return + const groups = dataSource.getGroups() + if (groups.length === 0) return - for (const group of graph._groups) { - const x = (group.pos[0] - context.bounds.minX) * context.scale + offsetX - const y = (group.pos[1] - context.bounds.minY) * context.scale + offsetY - const w = group.size[0] * context.scale - const h = group.size[1] * context.scale + for (const group of groups) { + const x = (group.x - context.bounds.minX) * context.scale + offsetX + const y = (group.y - context.bounds.minY) * context.scale + offsetY + const w = group.width * context.scale + const h = group.height * context.scale let color = colors.groupColor @@ -64,45 +94,34 @@ function renderGroups( */ function renderNodes( ctx: CanvasRenderingContext2D, - graph: LGraph, + dataSource: IMinimapDataSource, offsetX: number, offsetY: number, context: MinimapRenderContext, colors: ReturnType ) { - if (!graph._nodes || graph._nodes.length === 0) return + const nodes = dataSource.getNodes() + if (nodes.length === 0) return - // Group nodes by color for batch rendering + // Group nodes by color for batch rendering (performance optimization) const nodesByColor = new Map< string, Array<{ x: number; y: number; w: number; h: number; hasErrors?: boolean }> >() - for (const node of graph._nodes) { - const x = (node.pos[0] - context.bounds.minX) * context.scale + offsetX - const y = (node.pos[1] - context.bounds.minY) * context.scale + offsetY - const w = node.size[0] * context.scale - const h = node.size[1] * context.scale + for (const node of nodes) { + const x = (node.x - context.bounds.minX) * context.scale + offsetX + const y = (node.y - context.bounds.minY) * context.scale + offsetY + const w = node.width * context.scale + const h = node.height * context.scale - let color = colors.nodeColor - - if (context.settings.renderBypass && node.mode === LGraphEventMode.BYPASS) { - color = colors.bypassColor - } else if (context.settings.nodeColors) { - color = colors.nodeColorDefault - - if (node.bgcolor) { - color = colors.isLightTheme - ? adjustColor(node.bgcolor, { lightness: 0.5 }) - : node.bgcolor - } - } + const color = getNodeColor(node, context.settings, colors) if (!nodesByColor.has(color)) { nodesByColor.set(color, []) } - nodesByColor.get(color)!.push({ x, y, w, h, hasErrors: node.has_errors }) + nodesByColor.get(color)!.push({ x, y, w, h, hasErrors: node.hasErrors }) } // Batch render nodes by color @@ -132,13 +151,14 @@ function renderNodes( */ function renderConnections( ctx: CanvasRenderingContext2D, - graph: LGraph, + dataSource: IMinimapDataSource, offsetX: number, offsetY: number, context: MinimapRenderContext, colors: ReturnType ) { - if (!graph || !graph._nodes) return + const links = dataSource.getLinks() + if (links.length === 0) return ctx.strokeStyle = colors.linkColor ctx.lineWidth = 0.3 @@ -151,41 +171,28 @@ function renderConnections( y2: number }> = [] - for (const node of graph._nodes) { - if (!node.outputs) continue + for (const link of links) { + const x1 = + (link.sourceNode.x - context.bounds.minX) * context.scale + offsetX + const y1 = + (link.sourceNode.y - context.bounds.minY) * context.scale + offsetY + const x2 = + (link.targetNode.x - context.bounds.minX) * context.scale + offsetX + const y2 = + (link.targetNode.y - context.bounds.minY) * context.scale + offsetY - const x1 = (node.pos[0] - context.bounds.minX) * context.scale + offsetX - const y1 = (node.pos[1] - context.bounds.minY) * context.scale + offsetY + const outputX = x1 + link.sourceNode.width * context.scale + const outputY = y1 + link.sourceNode.height * context.scale * 0.2 + const inputX = x2 + const inputY = y2 + link.targetNode.height * context.scale * 0.2 - for (const output of node.outputs) { - if (!output.links) continue + // Draw connection line + ctx.beginPath() + ctx.moveTo(outputX, outputY) + ctx.lineTo(inputX, inputY) + ctx.stroke() - for (const linkId of output.links) { - const link = graph.links[linkId] - if (!link) continue - - const targetNode = graph.getNodeById(link.target_id) - if (!targetNode) continue - - const x2 = - (targetNode.pos[0] - context.bounds.minX) * context.scale + offsetX - const y2 = - (targetNode.pos[1] - context.bounds.minY) * context.scale + offsetY - - const outputX = x1 + node.size[0] * context.scale - const outputY = y1 + node.size[1] * context.scale * 0.2 - const inputX = x2 - const inputY = y2 + targetNode.size[1] * context.scale * 0.2 - - // Draw connection line - ctx.beginPath() - ctx.moveTo(outputX, outputY) - ctx.lineTo(inputX, inputY) - ctx.stroke() - - connections.push({ x1: outputX, y1: outputY, x2: inputX, y2: inputY }) - } - } + connections.push({ x1: outputX, y1: outputY, x2: inputX, y2: inputY }) } // Render connection slots on top @@ -217,8 +224,11 @@ export function renderMinimapToCanvas( // Clear canvas ctx.clearRect(0, 0, context.width, context.height) + // Create unified data source (Dependency Inversion) + const dataSource = MinimapDataSourceFactory.create(graph) + // Fast path for empty graph - if (!graph || !graph._nodes || graph._nodes.length === 0) { + if (!dataSource.hasData()) { return } @@ -228,12 +238,12 @@ export function renderMinimapToCanvas( // Render in correct order: groups -> links -> nodes if (context.settings.showGroups) { - renderGroups(ctx, graph, offsetX, offsetY, context, colors) + renderGroups(ctx, dataSource, offsetX, offsetY, context, colors) } if (context.settings.showLinks) { - renderConnections(ctx, graph, offsetX, offsetY, context, colors) + renderConnections(ctx, dataSource, offsetX, offsetY, context, colors) } - renderNodes(ctx, graph, offsetX, offsetY, context, colors) + renderNodes(ctx, dataSource, offsetX, offsetY, context, colors) } diff --git a/src/renderer/extensions/minimap/types.ts b/src/renderer/extensions/minimap/types.ts index fbea21c83e..b458718ea8 100644 --- a/src/renderer/extensions/minimap/types.ts +++ b/src/renderer/extensions/minimap/types.ts @@ -2,6 +2,7 @@ * Minimap-specific type definitions */ import type { LGraph } from '@/lib/litegraph/src/litegraph' +import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema' /** * Minimal interface for what the minimap needs from the canvas @@ -66,3 +67,50 @@ export type MinimapSettingsKey = | 'Comfy.Minimap.ShowGroups' | 'Comfy.Minimap.RenderBypassState' | 'Comfy.Minimap.RenderErrorState' + +/** + * Node data required for minimap rendering + */ +export interface MinimapNodeData { + id: NodeId + x: number + y: number + width: number + height: number + bgcolor?: string + mode?: number + hasErrors?: boolean +} + +/** + * Link data required for minimap rendering + */ +export interface MinimapLinkData { + sourceNode: MinimapNodeData + targetNode: MinimapNodeData + sourceSlot: number + targetSlot: number +} + +/** + * Group data required for minimap rendering + */ +export interface MinimapGroupData { + x: number + y: number + width: number + height: number + color?: string +} + +/** + * Interface for minimap data sources (Dependency Inversion Principle) + */ +export interface IMinimapDataSource { + getNodes(): MinimapNodeData[] + getLinks(): MinimapLinkData[] + getGroups(): MinimapGroupData[] + getBounds(): MinimapBounds + getNodeCount(): number + hasData(): boolean +} diff --git a/tests-ui/tests/minimap/MinimapDataSource.test.ts b/tests-ui/tests/minimap/MinimapDataSource.test.ts new file mode 100644 index 0000000000..eff6ba771b --- /dev/null +++ b/tests-ui/tests/minimap/MinimapDataSource.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it, vi } from 'vitest' +import { type ComputedRef, computed } from 'vue' + +import type { NodeId } from '@/lib/litegraph/src/LGraphNode' +import type { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph' +import { layoutStore } from '@/renderer/core/layout/store/layoutStore' +import type { NodeLayout } from '@/renderer/core/layout/types' +import { MinimapDataSourceFactory } from '@/renderer/extensions/minimap/data/MinimapDataSourceFactory' + +// Mock layoutStore +vi.mock('@/renderer/core/layout/store/layoutStore', () => ({ + layoutStore: { + getAllNodes: vi.fn() + } +})) + +// Helper to create mock links that satisfy LGraph['links'] type +function createMockLinks(): LGraph['links'] { + const map = new Map() + return Object.assign(map, {}) as LGraph['links'] +} + +describe('MinimapDataSource', () => { + describe('MinimapDataSourceFactory', () => { + it('should create LayoutStoreDataSource when LayoutStore has data', () => { + // Arrange + const mockNodes = new Map([ + [ + 'node1', + { + id: 'node1', + position: { x: 0, y: 0 }, + size: { width: 100, height: 50 }, + zIndex: 0, + visible: true, + bounds: { x: 0, y: 0, width: 100, height: 50 } + } + ] + ]) + + // Create a computed ref that returns the map + const computedNodes: ComputedRef> = + computed(() => mockNodes) + vi.mocked(layoutStore.getAllNodes).mockReturnValue(computedNodes) + + const mockGraph: Pick = { + _nodes: [], + _groups: [], + links: createMockLinks() + } + + // Act + const dataSource = MinimapDataSourceFactory.create(mockGraph as LGraph) + + // Assert + expect(dataSource).toBeDefined() + expect(dataSource.hasData()).toBe(true) + expect(dataSource.getNodeCount()).toBe(1) + }) + + it('should create LiteGraphDataSource when LayoutStore is empty', () => { + // Arrange + const emptyMap = new Map() + const computedEmpty: ComputedRef> = + computed(() => emptyMap) + vi.mocked(layoutStore.getAllNodes).mockReturnValue(computedEmpty) + + const mockNode: Pick< + LGraphNode, + 'id' | 'pos' | 'size' | 'bgcolor' | 'mode' | 'has_errors' | 'outputs' + > = { + id: 'node1' as NodeId, + pos: [0, 0], + size: [100, 50], + bgcolor: '#fff', + mode: 0, + has_errors: false, + outputs: [] + } + + const mockGraph: Pick = { + _nodes: [mockNode as LGraphNode], + _groups: [], + links: createMockLinks() + } + + // Act + const dataSource = MinimapDataSourceFactory.create(mockGraph as LGraph) + + // Assert + expect(dataSource).toBeDefined() + expect(dataSource.hasData()).toBe(true) + expect(dataSource.getNodeCount()).toBe(1) + + const nodes = dataSource.getNodes() + expect(nodes).toHaveLength(1) + expect(nodes[0]).toMatchObject({ + id: 'node1', + x: 0, + y: 0, + width: 100, + height: 50 + }) + }) + + it('should handle empty graph correctly', () => { + // Arrange + const emptyMap = new Map() + const computedEmpty: ComputedRef> = + computed(() => emptyMap) + vi.mocked(layoutStore.getAllNodes).mockReturnValue(computedEmpty) + + const mockGraph: Pick = { + _nodes: [], + _groups: [], + links: createMockLinks() + } + + // Act + const dataSource = MinimapDataSourceFactory.create(mockGraph as LGraph) + + // Assert + expect(dataSource.hasData()).toBe(false) + expect(dataSource.getNodeCount()).toBe(0) + expect(dataSource.getNodes()).toEqual([]) + expect(dataSource.getLinks()).toEqual([]) + expect(dataSource.getGroups()).toEqual([]) + }) + }) + + describe('Bounds calculation', () => { + it('should calculate correct bounds from nodes', () => { + // Arrange + const emptyMap = new Map() + const computedEmpty: ComputedRef> = + computed(() => emptyMap) + vi.mocked(layoutStore.getAllNodes).mockReturnValue(computedEmpty) + + const mockNode1: Pick = { + id: 'node1' as NodeId, + pos: [0, 0], + size: [100, 50], + outputs: [] + } + + const mockNode2: Pick = { + id: 'node2' as NodeId, + pos: [200, 100], + size: [150, 75], + outputs: [] + } + + const mockGraph: Pick = { + _nodes: [mockNode1 as LGraphNode, mockNode2 as LGraphNode], + _groups: [], + links: createMockLinks() + } + + // Act + const dataSource = MinimapDataSourceFactory.create(mockGraph as LGraph) + const bounds = dataSource.getBounds() + + // Assert + expect(bounds).toEqual({ + minX: 0, + minY: 0, + maxX: 350, + maxY: 175, + width: 350, + height: 175 + }) + }) + }) +}) From 0801778f6047654e3185de6a970898c519124929 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Fri, 19 Sep 2025 14:19:06 -0700 Subject: [PATCH 3/3] feat: Add Vue node subgraph title button and fix subgraph navigation with vue nodes (#5572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds subgraph title button to Vue node headers (matching LiteGraph behavior) - Fixes Vue node lifecycle issues during subgraph navigation and tab switching - Extracts reusable `useSubgraphNavigation` composable with callback-based API - Adds comprehensive tests for subgraph functionality - Ensures proper graph context restoration during tab switches https://github.com/user-attachments/assets/fd4ff16a-4071-4da6-903f-b2be8dd6e672 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5572-feat-Add-Vue-node-subgraph-title-button-with-lifecycle-management-26f6d73d365081bfbd9cfd7d2775e1ef) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude Co-authored-by: DrJKL --- src/components/graph/GraphCanvas.vue | 21 ++ src/composables/auth/useCurrentUser.ts | 10 +- src/composables/graph/useVueNodeLifecycle.ts | 18 +- src/renderer/core/canvas/canvasStore.ts | 29 ++- .../vueNodes/components/LGraphNode.vue | 36 ++- .../vueNodes/components/NodeHeader.vue | 45 +++- src/utils/graphTraversalUtil.ts | 17 ++ .../components/NodeHeader.subgraph.test.ts | 223 ++++++++++++++++++ 8 files changed, 382 insertions(+), 17 deletions(-) create mode 100644 tests-ui/tests/renderer/extensions/vueNodes/components/NodeHeader.subgraph.test.ts diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index 3abf47813b..6ded0e3f82 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -76,6 +76,7 @@ import { useEventListener, whenever } from '@vueuse/core' import { computed, + nextTick, onMounted, onUnmounted, provide, @@ -182,6 +183,26 @@ const viewportCulling = useViewportCulling( ) const nodeEventHandlers = useNodeEventHandlers(vueNodeLifecycle.nodeManager) +const handleVueNodeLifecycleReset = async () => { + if (isVueNodesEnabled.value) { + vueNodeLifecycle.disposeNodeManagerAndSyncs() + await nextTick() + vueNodeLifecycle.initializeNodeManager() + } +} + +watch(() => canvasStore.currentGraph, handleVueNodeLifecycleReset) + +watch( + () => canvasStore.isInSubgraph, + async (newValue, oldValue) => { + if (oldValue && !newValue) { + useWorkflowStore().updateActiveGraph() + } + await handleVueNodeLifecycleReset() + } +) + const nodePositions = vueNodeLifecycle.nodePositions const nodeSizes = vueNodeLifecycle.nodeSizes const allNodes = viewportCulling.allNodes diff --git a/src/composables/auth/useCurrentUser.ts b/src/composables/auth/useCurrentUser.ts index cf89c1069b..2c70be227a 100644 --- a/src/composables/auth/useCurrentUser.ts +++ b/src/composables/auth/useCurrentUser.ts @@ -1,5 +1,4 @@ -import { whenever } from '@vueuse/core' -import { computed } from 'vue' +import { computed, watch } from 'vue' import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions' import { t } from '@/i18n' @@ -39,7 +38,12 @@ export const useCurrentUser = () => { callback(resolvedUserInfo.value) } - const stop = whenever(resolvedUserInfo, callback) + const stop = watch(resolvedUserInfo, (value) => { + if (value) { + callback(value) + } + }) + return () => stop() } diff --git a/src/composables/graph/useVueNodeLifecycle.ts b/src/composables/graph/useVueNodeLifecycle.ts index 54296b900a..b481439dda 100644 --- a/src/composables/graph/useVueNodeLifecycle.ts +++ b/src/composables/graph/useVueNodeLifecycle.ts @@ -57,10 +57,12 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref) { const isNodeManagerReady = computed(() => nodeManager.value !== null) const initializeNodeManager = () => { - if (!comfyApp.graph || nodeManager.value) return + // Use canvas graph if available (handles subgraph contexts), fallback to app graph + const activeGraph = comfyApp.canvas?.graph || comfyApp.graph + if (!activeGraph || nodeManager.value) return // Initialize the core node manager - const manager = useGraphNodeManager(comfyApp.graph) + const manager = useGraphNodeManager(activeGraph) nodeManager.value = manager cleanupNodeManager.value = manager.cleanup @@ -71,8 +73,8 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref) { nodeSizes.value = manager.nodeSizes detectChangesInRAF.value = manager.detectChangesInRAF - // Initialize layout system with existing nodes - const nodes = comfyApp.graph._nodes.map((node: LGraphNode) => ({ + // Initialize layout system with existing nodes from active graph + const nodes = activeGraph._nodes.map((node: LGraphNode) => ({ id: node.id.toString(), pos: [node.pos[0], node.pos[1]] as [number, number], size: [node.size[0], node.size[1]] as [number, number] @@ -80,7 +82,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref) { layoutStore.initializeFromLiteGraph(nodes) // Seed reroutes into the Layout Store so hit-testing uses the new path - for (const reroute of comfyApp.graph.reroutes.values()) { + for (const reroute of activeGraph.reroutes.values()) { const [x, y] = reroute.pos const parent = reroute.parentId ?? undefined const linkIds = Array.from(reroute.linkIds) @@ -88,7 +90,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref) { } // Seed existing links into the Layout Store (topology only) - for (const link of comfyApp.graph._links.values()) { + for (const link of activeGraph._links.values()) { layoutMutations.createLink( link.id, link.origin_id, @@ -142,7 +144,9 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref) { // Watch for Vue nodes enabled state changes watch( - () => isVueNodesEnabled.value && Boolean(comfyApp.graph), + () => + isVueNodesEnabled.value && + Boolean(comfyApp.canvas?.graph || comfyApp.graph), (enabled) => { if (enabled) { initializeNodeManager() diff --git a/src/renderer/core/canvas/canvasStore.ts b/src/renderer/core/canvas/canvasStore.ts index 6e09d95a3a..371035c08c 100644 --- a/src/renderer/core/canvas/canvasStore.ts +++ b/src/renderer/core/canvas/canvasStore.ts @@ -1,8 +1,10 @@ +import { useEventListener, whenever } from '@vueuse/core' import { defineStore } from 'pinia' import { type Raw, computed, markRaw, ref, shallowRef } from 'vue' import type { Point, Positionable } from '@/lib/litegraph/src/interfaces' import type { + LGraph, LGraphCanvas, LGraphGroup, LGraphNode @@ -94,6 +96,29 @@ export const useCanvasStore = defineStore('canvas', () => { appScalePercentage.value = Math.round(newScale * 100) } + const currentGraph = shallowRef(null) + const isInSubgraph = ref(false) + + whenever( + () => canvas.value, + (newCanvas) => { + useEventListener( + newCanvas.canvas, + 'litegraph:set-graph', + (event: CustomEvent<{ newGraph: LGraph; oldGraph: LGraph }>) => { + const newGraph = event.detail?.newGraph || app.canvas?.graph + currentGraph.value = newGraph + isInSubgraph.value = Boolean(app.canvas?.subgraph) + } + ) + + useEventListener(newCanvas.canvas, 'subgraph-opened', () => { + isInSubgraph.value = true + }) + }, + { immediate: true } + ) + return { canvas, selectedItems, @@ -105,6 +130,8 @@ export const useCanvasStore = defineStore('canvas', () => { getCanvas, setAppZoomFromPercentage, initScaleSync, - cleanupScaleSync + cleanupScaleSync, + currentGraph, + isInSubgraph } }) diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue index 478326c83b..bd6480c38a 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNode.vue +++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue @@ -55,6 +55,7 @@ :collapsed="isCollapsed" @collapse="handleCollapse" @update:title="handleTitleUpdate" + @enter-subgraph="handleEnterSubgraph" /> @@ -164,7 +165,10 @@ import type { ExecutedWsMessage } from '@/schemas/apiSchema' import { app } from '@/scripts/app' import { useExecutionStore } from '@/stores/executionStore' import { useNodeOutputStore } from '@/stores/imagePreviewStore' -import { getNodeByLocatorId } from '@/utils/graphTraversalUtil' +import { + getLocatorIdFromNodeData, + getNodeByLocatorId +} from '@/utils/graphTraversalUtil' import { cn } from '@/utils/tailwindUtil' import { useVueElementTracking } from '../composables/useVueNodeResizeTracking' @@ -453,14 +457,36 @@ const handleTitleUpdate = (newTitle: string) => { emit('update:title', nodeData.id, newTitle) } +const handleEnterSubgraph = () => { + const graph = app.graph?.rootGraph || app.graph + if (!graph) { + console.warn('LGraphNode: No graph available for subgraph navigation') + return + } + + const locatorId = getLocatorIdFromNodeData(nodeData) + + const litegraphNode = getNodeByLocatorId(graph, locatorId) + + if (!litegraphNode?.isSubgraphNode() || !('subgraph' in litegraphNode)) { + console.warn('LGraphNode: Node is not a valid subgraph node', litegraphNode) + return + } + + const canvas = app.canvas + if (!canvas || typeof canvas.openSubgraph !== 'function') { + console.warn('LGraphNode: Canvas or openSubgraph method not available') + return + } + + canvas.openSubgraph(litegraphNode.subgraph) +} + const nodeOutputs = useNodeOutputStore() const nodeImageUrls = ref([]) const onNodeOutputsUpdate = (newOutputs: ExecutedWsMessage['output']) => { - // Construct proper locator ID using subgraph ID from VueNodeData - const locatorId = nodeData.subgraphId - ? `${nodeData.subgraphId}:${nodeData.id}` - : nodeData.id + const locatorId = getLocatorIdFromNodeData(nodeData) // Use root graph for getNodeByLocatorId since it needs to traverse from root const rootGraph = app.graph?.rootGraph || app.graph diff --git a/src/renderer/extensions/vueNodes/components/NodeHeader.vue b/src/renderer/extensions/vueNodes/components/NodeHeader.vue index 0c6ebfc207..3bd75024c6 100644 --- a/src/renderer/extensions/vueNodes/components/NodeHeader.vue +++ b/src/renderer/extensions/vueNodes/components/NodeHeader.vue @@ -4,7 +4,7 @@
@@ -36,17 +36,39 @@ @cancel="handleTitleCancel" />
+ + +
+ + + +
diff --git a/src/utils/graphTraversalUtil.ts b/src/utils/graphTraversalUtil.ts index 72f6f37333..4a573ed24c 100644 --- a/src/utils/graphTraversalUtil.ts +++ b/src/utils/graphTraversalUtil.ts @@ -8,6 +8,23 @@ import { parseNodeLocatorId } from '@/types/nodeIdentification' import { isSubgraphIoNode } from './typeGuardUtil' +interface NodeWithId { + id: string | number + subgraphId?: string | null +} + +/** + * Constructs a locator ID from node data with optional subgraph context. + * + * @param nodeData - Node data containing id and optional subgraphId + * @returns The locator ID string + */ +export function getLocatorIdFromNodeData(nodeData: NodeWithId): string { + return nodeData.subgraphId + ? `${nodeData.subgraphId}:${String(nodeData.id)}` + : String(nodeData.id) +} + /** * Parses an execution ID into its component parts. * diff --git a/tests-ui/tests/renderer/extensions/vueNodes/components/NodeHeader.subgraph.test.ts b/tests-ui/tests/renderer/extensions/vueNodes/components/NodeHeader.subgraph.test.ts new file mode 100644 index 0000000000..cca01bce8d --- /dev/null +++ b/tests-ui/tests/renderer/extensions/vueNodes/components/NodeHeader.subgraph.test.ts @@ -0,0 +1,223 @@ +/** + * Tests for NodeHeader subgraph functionality + */ +import { createTestingPinia } from '@pinia/testing' +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' +import NodeHeader from '@/renderer/extensions/vueNodes/components/NodeHeader.vue' +import { getNodeByLocatorId } from '@/utils/graphTraversalUtil' + +// Mock dependencies +vi.mock('@/scripts/app', () => ({ + app: { + graph: null as any + } +})) + +vi.mock('@/utils/graphTraversalUtil', () => ({ + getNodeByLocatorId: vi.fn(), + getLocatorIdFromNodeData: vi.fn((nodeData) => + nodeData.subgraphId + ? `${nodeData.subgraphId}:${String(nodeData.id)}` + : String(nodeData.id) + ) +})) + +vi.mock('@/composables/useErrorHandling', () => ({ + useErrorHandling: () => ({ + toastErrorHandler: vi.fn() + }) +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: vi.fn((key) => key) + }), + createI18n: vi.fn(() => ({ + global: { + t: vi.fn((key) => key) + } + })) +})) + +vi.mock('@/i18n', () => ({ + st: vi.fn((key) => key), + t: vi.fn((key) => key), + i18n: { + global: { + t: vi.fn((key) => key) + } + } +})) + +describe('NodeHeader - Subgraph Functionality', () => { + // Helper to setup common mocks + const setupMocks = async (isSubgraph = true, hasGraph = true) => { + const { app } = await import('@/scripts/app') + + if (hasGraph) { + ;(app as any).graph = { rootGraph: {} } + } else { + ;(app as any).graph = null + } + + vi.mocked(getNodeByLocatorId).mockReturnValue({ + isSubgraphNode: () => isSubgraph + } as any) + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + const createMockNodeData = ( + id: string, + subgraphId?: string + ): VueNodeData => ({ + id, + title: 'Test Node', + type: 'TestNode', + mode: 0, + selected: false, + executing: false, + subgraphId, + widgets: [], + inputs: [], + outputs: [], + hasErrors: false, + flags: {} + }) + + const createWrapper = (props = {}) => { + return mount(NodeHeader, { + props, + global: { + plugins: [createTestingPinia({ createSpy: vi.fn })], + mocks: { + $t: vi.fn((key: string) => key), + $primevue: { config: {} } + } + } + }) + } + + it('should show subgraph button for subgraph nodes', async () => { + await setupMocks(true) // isSubgraph = true + + const wrapper = createWrapper({ + nodeData: createMockNodeData('test-node-1'), + readonly: false + }) + + await wrapper.vm.$nextTick() + + const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]') + expect(subgraphButton.exists()).toBe(true) + }) + + it('should not show subgraph button for regular nodes', async () => { + await setupMocks(false) // isSubgraph = false + + const wrapper = createWrapper({ + nodeData: createMockNodeData('test-node-1'), + readonly: false + }) + + await wrapper.vm.$nextTick() + + const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]') + expect(subgraphButton.exists()).toBe(false) + }) + + it('should not show subgraph button in readonly mode', async () => { + await setupMocks(true) // isSubgraph = true + + const wrapper = createWrapper({ + nodeData: createMockNodeData('test-node-1'), + readonly: true + }) + + await wrapper.vm.$nextTick() + + const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]') + expect(subgraphButton.exists()).toBe(false) + }) + + it('should emit enter-subgraph event when button is clicked', async () => { + await setupMocks(true) // isSubgraph = true + + const wrapper = createWrapper({ + nodeData: createMockNodeData('test-node-1'), + readonly: false + }) + + await wrapper.vm.$nextTick() + + const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]') + await subgraphButton.trigger('click') + + expect(wrapper.emitted('enter-subgraph')).toBeTruthy() + expect(wrapper.emitted('enter-subgraph')).toHaveLength(1) + }) + + it('should handle subgraph context correctly', async () => { + await setupMocks(true) // isSubgraph = true + + const wrapper = createWrapper({ + nodeData: createMockNodeData('test-node-1', 'subgraph-id'), + readonly: false + }) + + await wrapper.vm.$nextTick() + + // Should call getNodeByLocatorId with correct locator ID + expect(vi.mocked(getNodeByLocatorId)).toHaveBeenCalledWith( + expect.anything(), + 'subgraph-id:test-node-1' + ) + + const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]') + expect(subgraphButton.exists()).toBe(true) + }) + + it('should handle missing graph gracefully', async () => { + await setupMocks(true, false) // isSubgraph = true, hasGraph = false + + const wrapper = createWrapper({ + nodeData: createMockNodeData('test-node-1'), + readonly: false + }) + + await wrapper.vm.$nextTick() + + const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]') + expect(subgraphButton.exists()).toBe(false) + }) + + it('should prevent event propagation on double click', async () => { + await setupMocks(true) // isSubgraph = true + + const wrapper = createWrapper({ + nodeData: createMockNodeData('test-node-1'), + readonly: false + }) + + await wrapper.vm.$nextTick() + + const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]') + + // Mock event object + const mockEvent = { + stopPropagation: vi.fn() + } + + // Trigger dblclick event + await subgraphButton.trigger('dblclick', mockEvent) + + // Should prevent propagation (handled by @dblclick.stop directive) + // This is tested by ensuring the component doesn't error and renders correctly + expect(subgraphButton.exists()).toBe(true) + }) +})