mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-06 05:30:08 +00:00
Merge branch 'main' into 5658-bug-focus-mode
This commit is contained in:
1
browser_tests/assets/vueNodes/simple-triple.json
Normal file
1
browser_tests/assets/vueNodes/simple-triple.json
Normal file
@@ -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}
|
||||
104
browser_tests/helpers/fitToView.ts
Normal file
104
browser_tests/helpers/fitToView.ts
Normal file
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
221
browser_tests/tests/vueNodes/linkInteraction.spec.ts
Normal file
221
browser_tests/tests/vueNodes/linkInteraction.spec.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -57,10 +57,12 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
|
||||
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<boolean>) {
|
||||
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<boolean>) {
|
||||
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<boolean>) {
|
||||
}
|
||||
|
||||
// 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<boolean>) {
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -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<LGraph | null>(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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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<string, GraphCallbacks>()
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
30
src/renderer/extensions/minimap/data/LiteGraphDataSource.ts
Normal file
30
src/renderer/extensions/minimap/data/LiteGraphDataSource.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<typeof getMinimapColors>
|
||||
): 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<typeof getMinimapColors>
|
||||
) {
|
||||
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<typeof getMinimapColors>
|
||||
) {
|
||||
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<typeof getMinimapColors>
|
||||
) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
:collapsed="isCollapsed"
|
||||
@collapse="handleCollapse"
|
||||
@update:title="handleTitleUpdate"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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<string[]>([])
|
||||
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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="lg-node-header flex items-center justify-between p-4 rounded-t-2xl cursor-move"
|
||||
class="lg-node-header flex items-center justify-between p-4 rounded-t-2xl cursor-move w-full"
|
||||
:data-testid="`node-header-${nodeData?.id || ''}`"
|
||||
@dblclick="handleDoubleClick"
|
||||
>
|
||||
@@ -36,17 +36,39 @@
|
||||
@cancel="handleTitleCancel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Title Buttons -->
|
||||
<div v-if="!readonly" class="flex items-center">
|
||||
<IconButton
|
||||
v-if="isSubgraphNode"
|
||||
size="sm"
|
||||
type="transparent"
|
||||
class="text-stone-200 dark-theme:text-slate-300"
|
||||
data-testid="subgraph-enter-button"
|
||||
title="Enter Subgraph"
|
||||
@click.stop="handleEnterSubgraph"
|
||||
@dblclick.stop
|
||||
>
|
||||
<i class="pi pi-external-link"></i>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type Ref, computed, inject, onErrorCaptured, ref, watch } from 'vue'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
import { app } from '@/scripts/app'
|
||||
import {
|
||||
getLocatorIdFromNodeData,
|
||||
getNodeByLocatorId
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
interface NodeHeaderProps {
|
||||
nodeData?: VueNodeData
|
||||
@@ -60,6 +82,7 @@ const { nodeData, readonly, collapsed } = defineProps<NodeHeaderProps>()
|
||||
const emit = defineEmits<{
|
||||
collapse: []
|
||||
'update:title': [newTitle: string]
|
||||
'enter-subgraph': []
|
||||
}>()
|
||||
|
||||
// Error boundary implementation
|
||||
@@ -111,6 +134,22 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
// Subgraph detection
|
||||
const isSubgraphNode = computed(() => {
|
||||
if (!nodeData?.id) return false
|
||||
|
||||
// Get the underlying LiteGraph node
|
||||
const graph = app.graph?.rootGraph || app.graph
|
||||
if (!graph) return false
|
||||
|
||||
const locatorId = getLocatorIdFromNodeData(nodeData)
|
||||
|
||||
const litegraphNode = getNodeByLocatorId(graph, locatorId)
|
||||
|
||||
// Use the official type guard method
|
||||
return litegraphNode?.isSubgraphNode() ?? false
|
||||
})
|
||||
|
||||
// Event handlers
|
||||
const handleCollapse = () => {
|
||||
emit('collapse')
|
||||
@@ -134,4 +173,8 @@ const handleTitleEdit = (newTitle: string) => {
|
||||
const handleTitleCancel = () => {
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
const handleEnterSubgraph = () => {
|
||||
emit('enter-subgraph')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
174
tests-ui/tests/minimap/MinimapDataSource.test.ts
Normal file
174
tests-ui/tests/minimap/MinimapDataSource.test.ts
Normal file
@@ -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<number, LLink>()
|
||||
return Object.assign(map, {}) as LGraph['links']
|
||||
}
|
||||
|
||||
describe('MinimapDataSource', () => {
|
||||
describe('MinimapDataSourceFactory', () => {
|
||||
it('should create LayoutStoreDataSource when LayoutStore has data', () => {
|
||||
// Arrange
|
||||
const mockNodes = new Map<string, NodeLayout>([
|
||||
[
|
||||
'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<ReadonlyMap<string, NodeLayout>> =
|
||||
computed(() => mockNodes)
|
||||
vi.mocked(layoutStore.getAllNodes).mockReturnValue(computedNodes)
|
||||
|
||||
const mockGraph: Pick<LGraph, '_nodes' | '_groups' | 'links'> = {
|
||||
_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<string, NodeLayout>()
|
||||
const computedEmpty: ComputedRef<ReadonlyMap<string, NodeLayout>> =
|
||||
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<LGraph, '_nodes' | '_groups' | 'links'> = {
|
||||
_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<string, NodeLayout>()
|
||||
const computedEmpty: ComputedRef<ReadonlyMap<string, NodeLayout>> =
|
||||
computed(() => emptyMap)
|
||||
vi.mocked(layoutStore.getAllNodes).mockReturnValue(computedEmpty)
|
||||
|
||||
const mockGraph: Pick<LGraph, '_nodes' | '_groups' | 'links'> = {
|
||||
_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<string, NodeLayout>()
|
||||
const computedEmpty: ComputedRef<ReadonlyMap<string, NodeLayout>> =
|
||||
computed(() => emptyMap)
|
||||
vi.mocked(layoutStore.getAllNodes).mockReturnValue(computedEmpty)
|
||||
|
||||
const mockNode1: Pick<LGraphNode, 'id' | 'pos' | 'size' | 'outputs'> = {
|
||||
id: 'node1' as NodeId,
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
outputs: []
|
||||
}
|
||||
|
||||
const mockNode2: Pick<LGraphNode, 'id' | 'pos' | 'size' | 'outputs'> = {
|
||||
id: 'node2' as NodeId,
|
||||
pos: [200, 100],
|
||||
size: [150, 75],
|
||||
outputs: []
|
||||
}
|
||||
|
||||
const mockGraph: Pick<LGraph, '_nodes' | '_groups' | 'links'> = {
|
||||
_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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user