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 0000000000..d4c32b4ea2 Binary files /dev/null and b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png differ diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index cf3fa3d752..2bbb45a878 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -72,6 +72,7 @@ import { useEventListener, whenever } from '@vueuse/core' import { computed, + nextTick, onMounted, onUnmounted, provide, @@ -178,6 +179,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/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/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/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 + }) + }) + }) +}) 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) + }) +})