mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 01:48:06 +00:00
Compare commits
40 Commits
fix/codera
...
drjkl/he-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00cfb70ad1 | ||
|
|
0b8cc7f3ca | ||
|
|
838b168ea9 | ||
|
|
7b41b03af2 | ||
|
|
2cc3997fc1 | ||
|
|
8a3c647261 | ||
|
|
f0ca965892 | ||
|
|
767f19f0c3 | ||
|
|
d6e05bd11a | ||
|
|
0a940d62b9 | ||
|
|
779694cfc8 | ||
|
|
6464d14fe8 | ||
|
|
2182375e5e | ||
|
|
e0cc4d93da | ||
|
|
e2ad7a58a9 | ||
|
|
1917804190 | ||
|
|
ab59e373fa | ||
|
|
237e210962 | ||
|
|
13040e700d | ||
|
|
bd126698f7 | ||
|
|
de8be5c14a | ||
|
|
9d4ae0ddca | ||
|
|
b9c467577c | ||
|
|
9028ee9cfe | ||
|
|
8026585721 | ||
|
|
9f1376d79e | ||
|
|
2686becdb9 | ||
|
|
3331ab90f2 | ||
|
|
7d737a534d | ||
|
|
0331c1c726 | ||
|
|
025a803ac0 | ||
|
|
34786a16c0 | ||
|
|
6580500206 | ||
|
|
e17a2c669b | ||
|
|
1c4c000745 | ||
|
|
38ed3124a0 | ||
|
|
ae5bfe9428 | ||
|
|
6c94b9faf8 | ||
|
|
bad3fa5f2f | ||
|
|
1efc931e2e |
@@ -3,6 +3,7 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
|
||||
async function beforeChange(comfyPage: ComfyPage) {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
@@ -63,6 +64,99 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
|
||||
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
|
||||
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(2)
|
||||
})
|
||||
|
||||
test('undo/redo restores link topology with reroutes and floating links', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('links/batch_move_links')
|
||||
|
||||
const readTopology = () =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.rootGraph
|
||||
return {
|
||||
links: graph.links.size,
|
||||
floatingLinks: graph.floatingLinks.size,
|
||||
reroutes: graph.reroutes.size,
|
||||
serialised: graph.serialize()
|
||||
}
|
||||
})
|
||||
|
||||
const baseline = await readTopology()
|
||||
|
||||
await beforeChange(comfyPage)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.rootGraph
|
||||
const firstLink = graph.links.values().next().value
|
||||
if (!firstLink) throw new Error('Expected at least one link')
|
||||
|
||||
const reroute = graph.createReroute(
|
||||
[firstLink.id * 5, firstLink.id * 3],
|
||||
firstLink
|
||||
)
|
||||
graph.addFloatingLink(firstLink.toFloating('output', reroute.id))
|
||||
})
|
||||
await afterChange(comfyPage)
|
||||
|
||||
const mutated = await readTopology()
|
||||
expect(mutated.floatingLinks).toBeGreaterThan(baseline.floatingLinks)
|
||||
expect(mutated.reroutes).toBeGreaterThan(baseline.reroutes)
|
||||
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(1)
|
||||
|
||||
await comfyPage.page.evaluate(async () => {
|
||||
await (
|
||||
window.app!.extensionManager as WorkspaceStore
|
||||
).workflow.activeWorkflow?.changeTracker.undo()
|
||||
})
|
||||
const afterUndo = await readTopology()
|
||||
expect(afterUndo.links).toBe(baseline.links)
|
||||
expect(afterUndo.floatingLinks).toBe(baseline.floatingLinks)
|
||||
expect(afterUndo.reroutes).toBe(baseline.reroutes)
|
||||
expect(afterUndo.serialised).toEqual(baseline.serialised)
|
||||
|
||||
await comfyPage.page.evaluate(async () => {
|
||||
await (
|
||||
window.app!.extensionManager as WorkspaceStore
|
||||
).workflow.activeWorkflow?.changeTracker.redo()
|
||||
})
|
||||
const afterRedo = await readTopology()
|
||||
expect(afterRedo.links).toBe(mutated.links)
|
||||
expect(afterRedo.floatingLinks).toBe(mutated.floatingLinks)
|
||||
expect(afterRedo.reroutes).toBe(mutated.reroutes)
|
||||
expect(afterRedo.serialised).toEqual(mutated.serialised)
|
||||
})
|
||||
|
||||
test('read-through accessors stay in sync for links, floating links, and reroutes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('links/batch_move_links')
|
||||
|
||||
const parity = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.rootGraph
|
||||
const firstLink = graph.links.values().next().value
|
||||
if (!firstLink) throw new Error('Expected at least one link')
|
||||
|
||||
const reroute = graph.createReroute(
|
||||
[firstLink.id * 7, firstLink.id * 4],
|
||||
firstLink
|
||||
)
|
||||
const floatingLink = firstLink.toFloating('output', reroute.id)
|
||||
graph.addFloatingLink(floatingLink)
|
||||
|
||||
return {
|
||||
normalLinkMatches:
|
||||
graph.getLink(firstLink.id) === graph.links.get(firstLink.id),
|
||||
floatingLinkRequiresExplicitProjection:
|
||||
graph.getLink(floatingLink.id) === undefined &&
|
||||
graph.floatingLinks.get(floatingLink.id) !== undefined,
|
||||
rerouteMatches:
|
||||
graph.getReroute(reroute.id) === graph.reroutes.get(reroute.id)
|
||||
}
|
||||
})
|
||||
|
||||
expect(parity.normalLinkMatches).toBe(true)
|
||||
expect(parity.floatingLinkRequiresExplicitProjection).toBe(true)
|
||||
expect(parity.rerouteMatches).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test('Can group multiple change actions into a single transaction', async ({
|
||||
|
||||
@@ -87,9 +87,9 @@ test.describe('Subgraph Slot Rename Dialog', { tag: '@subgraph' }, () => {
|
||||
// Get the current value in the prompt dialog
|
||||
const dialogValue = await comfyPage.page.inputValue(SELECTORS.promptDialog)
|
||||
|
||||
// This should show the current label (RENAMED_NAME), not the original name
|
||||
// This should show the current renamed value and stay aligned with slot identity.
|
||||
expect(dialogValue).toBe(RENAMED_NAME)
|
||||
expect(dialogValue).not.toBe(afterFirstRename.name) // Should not show the original slot.name
|
||||
expect(dialogValue).toBe(afterFirstRename.name)
|
||||
|
||||
// Complete the second rename to ensure everything still works
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, '')
|
||||
|
||||
@@ -310,8 +310,8 @@ describe('Nested promoted widget mapping', () => {
|
||||
|
||||
expect(mappedWidget).toBeDefined()
|
||||
expect(mappedWidget?.type).toBe('combo')
|
||||
expect(mappedWidget?.storeName).toBe('picker')
|
||||
expect(mappedWidget?.storeNodeId).toBe(
|
||||
expect(mappedWidget?.name).toBe('picker')
|
||||
expect(mappedWidget?.nodeId).toBe(
|
||||
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -29,7 +29,6 @@ import type {
|
||||
LGraph,
|
||||
LGraphBadge,
|
||||
LGraphNode,
|
||||
LGraphTriggerAction,
|
||||
LGraphTriggerEvent,
|
||||
LGraphTriggerParam
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
@@ -48,9 +47,7 @@ export interface WidgetSlotMetadata {
|
||||
*/
|
||||
export interface SafeWidgetData {
|
||||
nodeId?: NodeId
|
||||
storeNodeId?: NodeId
|
||||
name: string
|
||||
storeName?: string
|
||||
type: string
|
||||
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
|
||||
callback?: ((value: unknown) => void) | undefined
|
||||
@@ -162,33 +159,16 @@ function getSharedWidgetEnhancements(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a value is a valid WidgetValue type
|
||||
*/
|
||||
function normalizeWidgetValue(value: unknown): WidgetValue {
|
||||
if (value === null || value === undefined || value === void 0) {
|
||||
return undefined
|
||||
}
|
||||
if (
|
||||
value == null ||
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean'
|
||||
typeof value === 'boolean' ||
|
||||
typeof value === 'object'
|
||||
) {
|
||||
return value
|
||||
return value as WidgetValue
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
// Check if it's a File array
|
||||
if (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every((item): item is File => item instanceof File)
|
||||
) {
|
||||
return value
|
||||
}
|
||||
// Otherwise it's a generic object
|
||||
return value
|
||||
}
|
||||
// If none of the above, return undefined
|
||||
console.warn(`Invalid widget value type: ${typeof value}`, value)
|
||||
return undefined
|
||||
}
|
||||
@@ -273,56 +253,55 @@ function safeWidgetMapper(
|
||||
node.widgets?.forEach((w) => w.triggerDraw?.())
|
||||
}
|
||||
|
||||
const isPromoted = isPromotedWidgetView(widget)
|
||||
const isPromotedPseudoWidget =
|
||||
isPromotedWidgetView(widget) && widget.sourceWidgetName.startsWith('$$')
|
||||
isPromoted && widget.sourceWidgetName.startsWith('$$')
|
||||
|
||||
// Extract only render-critical options (canvasOnly, advanced, read_only)
|
||||
const options = extractWidgetDisplayOptions(widget)
|
||||
const subgraphId = node.isSubgraphNode() && node.subgraph.id
|
||||
|
||||
const resolvedSourceResult =
|
||||
isPromotedWidgetView(widget) && promotedSource
|
||||
const resolved =
|
||||
isPromoted && promotedSource
|
||||
? resolveConcretePromotedWidget(
|
||||
node,
|
||||
promotedSource.sourceNodeId,
|
||||
promotedSource.sourceWidgetName
|
||||
)
|
||||
: null
|
||||
const resolvedSource =
|
||||
resolvedSourceResult?.status === 'resolved'
|
||||
? resolvedSourceResult.resolved
|
||||
: undefined
|
||||
const sourceWidget = resolvedSource?.widget
|
||||
const sourceNode = resolvedSource?.node
|
||||
const { widget: sourceWidget, node: sourceNode } =
|
||||
resolved?.status === 'resolved'
|
||||
? resolved.resolved
|
||||
: { widget: undefined, node: undefined }
|
||||
|
||||
const effectiveWidget = sourceWidget ?? widget
|
||||
|
||||
const localId = isPromotedWidgetView(widget)
|
||||
const localId = isPromoted
|
||||
? String(sourceNode?.id ?? promotedSource?.sourceNodeId)
|
||||
: undefined
|
||||
const nodeId =
|
||||
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
|
||||
const storeName = isPromotedWidgetView(widget)
|
||||
? (sourceWidget?.name ?? promotedSource?.sourceWidgetName)
|
||||
: undefined
|
||||
const name = storeName ?? displayName
|
||||
const name = isPromoted
|
||||
? (sourceWidget?.name ??
|
||||
promotedSource?.sourceWidgetName ??
|
||||
displayName)
|
||||
: displayName
|
||||
|
||||
const options =
|
||||
effectiveWidget !== widget
|
||||
? (extractWidgetDisplayOptions(effectiveWidget) ??
|
||||
extractWidgetDisplayOptions(widget))
|
||||
: extractWidgetDisplayOptions(widget)
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
storeNodeId: nodeId,
|
||||
name,
|
||||
storeName,
|
||||
type: effectiveWidget.type,
|
||||
...sharedEnhancements,
|
||||
callback,
|
||||
hasLayoutSize: typeof effectiveWidget.computeLayoutSize === 'function',
|
||||
isDOMWidget: isDOMWidget(widget) || isPromotedDOMWidget(widget),
|
||||
options: isPromotedPseudoWidget
|
||||
? {
|
||||
...(extractWidgetDisplayOptions(effectiveWidget) ?? options),
|
||||
canvasOnly: true
|
||||
}
|
||||
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
|
||||
? { ...options, canvasOnly: true }
|
||||
: options,
|
||||
slotMetadata: slotInfo,
|
||||
slotName: name !== widget.name ? widget.name : undefined
|
||||
}
|
||||
@@ -335,6 +314,18 @@ function safeWidgetMapper(
|
||||
}
|
||||
}
|
||||
|
||||
function buildSlotMetadata(
|
||||
inputs: INodeInputSlot[] | undefined
|
||||
): Map<string, WidgetSlotMetadata> {
|
||||
const slotMetadata = new Map<string, WidgetSlotMetadata>()
|
||||
inputs?.forEach((input, index) => {
|
||||
const slotInfo = { index, linked: input.link != null }
|
||||
if (input.name) slotMetadata.set(input.name, slotInfo)
|
||||
if (input.widget?.name) slotMetadata.set(input.widget.name, slotInfo)
|
||||
})
|
||||
return slotMetadata
|
||||
}
|
||||
|
||||
// Extract safe data from LiteGraph node for Vue consumption
|
||||
export function extractVueNodeData(node: LGraphNode): VueNodeData {
|
||||
// Determine subgraph ID - null for root graph, string for subgraphs
|
||||
@@ -342,8 +333,6 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
|
||||
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
|
||||
? String(node.graph.id)
|
||||
: null
|
||||
// Extract safe widget data
|
||||
const slotMetadata = new Map<string, WidgetSlotMetadata>()
|
||||
|
||||
const existingWidgetsDescriptor = Object.getOwnPropertyDescriptor(
|
||||
node,
|
||||
@@ -391,16 +380,7 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
|
||||
|
||||
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
|
||||
const widgetsSnapshot = node.widgets ?? []
|
||||
|
||||
slotMetadata.clear()
|
||||
node.inputs?.forEach((input, index) => {
|
||||
const slotInfo = {
|
||||
index,
|
||||
linked: input.link != null
|
||||
}
|
||||
if (input.name) slotMetadata.set(input.name, slotInfo)
|
||||
if (input.widget?.name) slotMetadata.set(input.widget.name, slotInfo)
|
||||
})
|
||||
const slotMetadata = buildSlotMetadata(node.inputs)
|
||||
return widgetsSnapshot.map(safeWidgetMapper(node, slotMetadata))
|
||||
})
|
||||
|
||||
@@ -453,19 +433,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
|
||||
if (!nodeRef || !currentData) return
|
||||
|
||||
// Only extract slot-related data instead of full node re-extraction
|
||||
const slotMetadata = new Map<string, WidgetSlotMetadata>()
|
||||
const slotMetadata = buildSlotMetadata(nodeRef.inputs)
|
||||
|
||||
nodeRef.inputs?.forEach((input, index) => {
|
||||
const slotInfo = {
|
||||
index,
|
||||
linked: input.link != null
|
||||
}
|
||||
if (input.name) slotMetadata.set(input.name, slotInfo)
|
||||
if (input.widget?.name) slotMetadata.set(input.widget.name, slotInfo)
|
||||
})
|
||||
|
||||
// Update only widgets with new slot metadata, keeping other widget data intact
|
||||
for (const widget of currentData.widgets ?? []) {
|
||||
const slotInfo = slotMetadata.get(widget.slotName ?? widget.name)
|
||||
if (slotInfo) widget.slotMetadata = slotInfo
|
||||
@@ -477,31 +446,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
return nodeRefs.get(id)
|
||||
}
|
||||
|
||||
const syncWithGraph = () => {
|
||||
if (!graph?._nodes) return
|
||||
|
||||
const currentNodes = new Set(graph._nodes.map((n) => String(n.id)))
|
||||
|
||||
// Remove deleted nodes
|
||||
for (const id of Array.from(vueNodeData.keys())) {
|
||||
if (!currentNodes.has(id)) {
|
||||
nodeRefs.delete(id)
|
||||
vueNodeData.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Add/update existing nodes
|
||||
graph._nodes.forEach((node) => {
|
||||
const id = String(node.id)
|
||||
|
||||
// Store non-reactive reference
|
||||
nodeRefs.set(id, node)
|
||||
|
||||
// Extract and store safe data for Vue
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles node addition to the graph - sets up Vue state and spatial indexing
|
||||
* Defers position extraction until after potential configure() calls
|
||||
@@ -626,123 +570,80 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
handleNodeRemoved(node, originalOnNodeRemoved)
|
||||
}
|
||||
|
||||
const triggerHandlers: {
|
||||
[K in LGraphTriggerAction]: (event: LGraphTriggerParam<K>) => void
|
||||
} = {
|
||||
'node:property:changed': (propertyEvent) => {
|
||||
const nodeId = String(propertyEvent.nodeId)
|
||||
const currentData = vueNodeData.get(nodeId)
|
||||
const handlePropertyChanged = (
|
||||
propertyEvent: LGraphTriggerParam<'node:property:changed'>
|
||||
) => {
|
||||
const nodeId = String(propertyEvent.nodeId)
|
||||
const currentData = vueNodeData.get(nodeId)
|
||||
if (!currentData) return
|
||||
|
||||
if (currentData) {
|
||||
switch (propertyEvent.property) {
|
||||
case 'title':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
title: String(propertyEvent.newValue)
|
||||
})
|
||||
break
|
||||
case 'flags.collapsed':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
flags: {
|
||||
...currentData.flags,
|
||||
collapsed: Boolean(propertyEvent.newValue)
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'flags.ghost':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
flags: {
|
||||
...currentData.flags,
|
||||
ghost: Boolean(propertyEvent.newValue)
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'flags.pinned':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
flags: {
|
||||
...currentData.flags,
|
||||
pinned: Boolean(propertyEvent.newValue)
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'mode':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
mode:
|
||||
typeof propertyEvent.newValue === 'number'
|
||||
? propertyEvent.newValue
|
||||
: 0
|
||||
})
|
||||
break
|
||||
case 'color':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
color:
|
||||
typeof propertyEvent.newValue === 'string'
|
||||
? propertyEvent.newValue
|
||||
: undefined
|
||||
})
|
||||
break
|
||||
case 'bgcolor':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
bgcolor:
|
||||
typeof propertyEvent.newValue === 'string'
|
||||
? propertyEvent.newValue
|
||||
: undefined
|
||||
})
|
||||
break
|
||||
case 'shape':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
shape:
|
||||
typeof propertyEvent.newValue === 'number'
|
||||
? propertyEvent.newValue
|
||||
: undefined
|
||||
})
|
||||
break
|
||||
case 'showAdvanced':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
showAdvanced: Boolean(propertyEvent.newValue)
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
'node:slot-errors:changed': (slotErrorsEvent) => {
|
||||
refreshNodeSlots(String(slotErrorsEvent.nodeId))
|
||||
},
|
||||
'node:slot-links:changed': (slotLinksEvent) => {
|
||||
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
|
||||
refreshNodeSlots(String(slotLinksEvent.nodeId))
|
||||
const { property, newValue } = propertyEvent
|
||||
|
||||
switch (property) {
|
||||
case 'title':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
title: String(newValue)
|
||||
})
|
||||
break
|
||||
case 'flags.collapsed':
|
||||
case 'flags.ghost':
|
||||
case 'flags.pinned': {
|
||||
const flagName = property.split('.')[1] as
|
||||
| 'collapsed'
|
||||
| 'ghost'
|
||||
| 'pinned'
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
flags: { ...currentData.flags, [flagName]: Boolean(newValue) }
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'mode':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
mode: typeof newValue === 'number' ? newValue : 0
|
||||
})
|
||||
break
|
||||
case 'color':
|
||||
case 'bgcolor':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
[property]: typeof newValue === 'string' ? newValue : undefined
|
||||
})
|
||||
break
|
||||
case 'shape':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
shape: typeof newValue === 'number' ? newValue : undefined
|
||||
})
|
||||
break
|
||||
case 'showAdvanced':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
showAdvanced: Boolean(newValue)
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
graph.onTrigger = (event: LGraphTriggerEvent) => {
|
||||
switch (event.type) {
|
||||
case 'node:property:changed':
|
||||
triggerHandlers['node:property:changed'](event)
|
||||
handlePropertyChanged(event)
|
||||
break
|
||||
case 'node:slot-errors:changed':
|
||||
triggerHandlers['node:slot-errors:changed'](event)
|
||||
refreshNodeSlots(String(event.nodeId))
|
||||
break
|
||||
case 'node:slot-links:changed':
|
||||
triggerHandlers['node:slot-links:changed'](event)
|
||||
if (event.slotType === NodeSlotType.INPUT) {
|
||||
refreshNodeSlots(String(event.nodeId))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Chain to original handler
|
||||
originalOnTrigger?.(event)
|
||||
}
|
||||
|
||||
// Initialize state
|
||||
syncWithGraph()
|
||||
|
||||
// Return cleanup function
|
||||
return createCleanupFunction(
|
||||
originalOnNodeAdded || undefined,
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { NodeId, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
LLink
|
||||
LLink,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { useLinkStore } from '@/stores/linkStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import {
|
||||
@@ -19,6 +23,39 @@ import {
|
||||
|
||||
import { test } from './__fixtures__/testExtensions'
|
||||
|
||||
vi.mock('@/renderer/core/layout/operations/layoutMutations', () => {
|
||||
const createLink = vi.fn()
|
||||
const deleteLink = vi.fn()
|
||||
const createNode = vi.fn()
|
||||
const deleteNode = vi.fn()
|
||||
const moveNode = vi.fn()
|
||||
const resizeNode = vi.fn()
|
||||
const setNodeZIndex = vi.fn()
|
||||
const createReroute = vi.fn()
|
||||
const deleteReroute = vi.fn()
|
||||
const moveReroute = vi.fn()
|
||||
const bringNodeToFront = vi.fn()
|
||||
const setSource = vi.fn()
|
||||
const setActor = vi.fn()
|
||||
return {
|
||||
useLayoutMutations: () => ({
|
||||
createLink,
|
||||
deleteLink,
|
||||
createNode,
|
||||
deleteNode,
|
||||
moveNode,
|
||||
resizeNode,
|
||||
setNodeZIndex,
|
||||
createReroute,
|
||||
deleteReroute,
|
||||
moveReroute,
|
||||
bringNodeToFront,
|
||||
setSource,
|
||||
setActor
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function swapNodes(nodes: LGraphNode[]) {
|
||||
const firstNode = nodes[0]
|
||||
const lastNode = nodes[nodes.length - 1]
|
||||
@@ -39,6 +76,46 @@ class DummyNode extends LGraphNode {
|
||||
}
|
||||
}
|
||||
|
||||
function createNumberNode(title: string): LGraphNode {
|
||||
const node = new LGraphNode(title)
|
||||
node.addOutput('out', 'number')
|
||||
node.addInput('in', 'number')
|
||||
return node
|
||||
}
|
||||
|
||||
function buildLinkTopology(graph: LGraph): {
|
||||
disconnectedLinkId: number
|
||||
floatingLinkId: number
|
||||
linkedNodeId: NodeId
|
||||
rerouteId: number
|
||||
} {
|
||||
const source = createNumberNode('source')
|
||||
const floatingTarget = createNumberNode('floating-target')
|
||||
const linkedTarget = createNumberNode('linked-target')
|
||||
graph.add(source)
|
||||
graph.add(floatingTarget)
|
||||
graph.add(linkedTarget)
|
||||
|
||||
source.connect(0, floatingTarget, 0)
|
||||
source.connect(0, linkedTarget, 0)
|
||||
|
||||
const linkToDisconnect = graph.getLink(floatingTarget.inputs[0].link)
|
||||
if (!linkToDisconnect) throw new Error('Expected link to disconnect')
|
||||
|
||||
const reroute = graph.createReroute([120, 80], linkToDisconnect)
|
||||
graph.addFloatingLink(linkToDisconnect.toFloating('output', reroute.id))
|
||||
|
||||
const floatingLinkId = [...graph.floatingLinks.keys()][0]
|
||||
if (floatingLinkId == null) throw new Error('Expected floating link')
|
||||
|
||||
return {
|
||||
disconnectedLinkId: linkToDisconnect.id,
|
||||
floatingLinkId,
|
||||
linkedNodeId: linkedTarget.id,
|
||||
rerouteId: reroute.id
|
||||
}
|
||||
}
|
||||
|
||||
describe('LGraph', () => {
|
||||
it('should serialize deterministic node order', async () => {
|
||||
LiteGraph.registerNodeType('dummy', DummyNode)
|
||||
@@ -88,6 +165,39 @@ describe('LGraph', () => {
|
||||
const fromOldSchema = new LGraph(oldSchemaGraph)
|
||||
expect(fromOldSchema).toMatchSnapshot('oldSchemaGraph')
|
||||
})
|
||||
|
||||
it('round-trips v0.4 link parent extensions and reroutes through configure', () => {
|
||||
const source = createNumberNode('source')
|
||||
const target = createNumberNode('target')
|
||||
const graph = createGraph(source, target)
|
||||
|
||||
const link = source.connect(0, target, 0)
|
||||
if (!link) throw new Error('Expected link')
|
||||
const reroute = graph.createReroute([80, 40], link)
|
||||
|
||||
const serialized04 = graph.serialize()
|
||||
const restored = new LGraph(serialized04)
|
||||
const restoredLink = restored.getLink(link.id)
|
||||
|
||||
if (!restoredLink) throw new Error('Expected restored link')
|
||||
expect(restoredLink.parentId).toBe(reroute.id)
|
||||
expect(restored.reroutes.size).toBe(1)
|
||||
expect(restored.reroutes.get(reroute.id)?.linkIds.has(link.id)).toBe(true)
|
||||
})
|
||||
|
||||
it('round-trips v1 serialisable links/floating/reroutes through configure', () => {
|
||||
const graph = new LGraph()
|
||||
const { floatingLinkId, rerouteId, linkedNodeId } = buildLinkTopology(graph)
|
||||
const serialisedV1 = graph.asSerialisable()
|
||||
|
||||
const restored = new LGraph(serialisedV1)
|
||||
const linkedInputLinkId = restored.getNodeById(linkedNodeId)?.inputs[0].link
|
||||
|
||||
expect(linkedInputLinkId).toBeDefined()
|
||||
expect(restored.getLink(linkedInputLinkId)).toBeDefined()
|
||||
expect(restored.getReroute(rerouteId)).toBeDefined()
|
||||
expect(restored.floatingLinks.get(floatingLinkId)).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Floating Links / Reroutes', () => {
|
||||
@@ -184,6 +294,256 @@ describe('Floating Links / Reroutes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('LinkStore Lifecycle Rehydration', () => {
|
||||
it('tracks links, floating links, and reroutes after configure', () => {
|
||||
const graph = new LGraph()
|
||||
const { floatingLinkId, linkedNodeId, rerouteId } = buildLinkTopology(graph)
|
||||
const serialised = graph.asSerialisable()
|
||||
|
||||
const restored = new LGraph(serialised)
|
||||
const linkedInput = restored.getNodeById(linkedNodeId)?.inputs[0]
|
||||
expect(linkedInput?.link).toBeDefined()
|
||||
const linkedInputLink = restored.getLink(linkedInput!.link!)
|
||||
expect(linkedInputLink).toBeDefined()
|
||||
|
||||
const linkStore = useLinkStore()
|
||||
const topology = linkStore.getTopology(restored.linkStoreKey)
|
||||
expect(topology.links.size).toBe(restored.links.size)
|
||||
expect(topology.floatingLinks.size).toBe(restored.floatingLinks.size)
|
||||
expect(topology.reroutes.size).toBe(restored.reroutes.size)
|
||||
expect(
|
||||
linkStore.getFloatingLink(restored.linkStoreKey, floatingLinkId)
|
||||
).toBeDefined()
|
||||
expect(linkStore.getReroute(restored.linkStoreKey, rerouteId)).toBeDefined()
|
||||
expect(linkStore.getLink(restored.linkStoreKey, linkedInput!.link!)).toBe(
|
||||
linkedInputLink
|
||||
)
|
||||
})
|
||||
|
||||
it('clears and rehydrates the store on graph.clear()', () => {
|
||||
const graph = new LGraph()
|
||||
buildLinkTopology(graph)
|
||||
|
||||
const linkStore = useLinkStore()
|
||||
const topologyBefore = linkStore.getTopology(graph.linkStoreKey)
|
||||
expect(topologyBefore.links.size).toBeGreaterThan(0)
|
||||
expect(topologyBefore.floatingLinks.size).toBeGreaterThan(0)
|
||||
expect(topologyBefore.reroutes.size).toBeGreaterThan(0)
|
||||
|
||||
graph.clear()
|
||||
|
||||
const topologyAfter = linkStore.getTopology(graph.linkStoreKey)
|
||||
expect(topologyAfter.links.size).toBe(0)
|
||||
expect(topologyAfter.floatingLinks.size).toBe(0)
|
||||
expect(topologyAfter.reroutes.size).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves root/subgraph store isolation after round-trip', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
const root = new LGraph()
|
||||
const rootTopology = buildLinkTopology(root)
|
||||
|
||||
const subgraph = root.createSubgraph(createTestSubgraphData())
|
||||
const subgraphSource = createNumberNode('subgraph-source')
|
||||
const subgraphTarget = createNumberNode('subgraph-target')
|
||||
subgraph.add(subgraphSource)
|
||||
subgraph.add(subgraphTarget)
|
||||
subgraphSource.connect(0, subgraphTarget, 0)
|
||||
|
||||
root.add(createTestSubgraphNode(subgraph, { pos: [500, 200] }))
|
||||
|
||||
const serialised = root.asSerialisable()
|
||||
const restoredRoot = new LGraph(serialised)
|
||||
const restoredSubgraph = [...restoredRoot.subgraphs.values()][0]
|
||||
|
||||
if (!restoredSubgraph) throw new Error('Expected restored subgraph')
|
||||
|
||||
const subgraphLinkId = [...restoredSubgraph.links.keys()][0]
|
||||
|
||||
const linkStore = useLinkStore()
|
||||
expect(
|
||||
linkStore.getFloatingLink(
|
||||
restoredRoot.linkStoreKey,
|
||||
rootTopology.floatingLinkId
|
||||
)
|
||||
).toBeDefined()
|
||||
expect(
|
||||
linkStore.getReroute(restoredRoot.linkStoreKey, rootTopology.rerouteId)
|
||||
).toBeDefined()
|
||||
expect(
|
||||
linkStore.getLink(restoredRoot.linkStoreKey, subgraphLinkId)
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
linkStore.getLink(restoredSubgraph.linkStoreKey, subgraphLinkId)
|
||||
).toBeDefined()
|
||||
expect(
|
||||
linkStore.getTopology(restoredSubgraph.linkStoreKey).floatingLinks.size
|
||||
).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LinkStore Read-Through Projection', () => {
|
||||
it('reads normal links from the projected store map', () => {
|
||||
const graph = new LGraph()
|
||||
const { linkedNodeId } = buildLinkTopology(graph)
|
||||
const linkId = graph.getNodeById(linkedNodeId)?.inputs[0].link
|
||||
if (linkId == null) throw new Error('Expected linked input link')
|
||||
|
||||
const linkStore = useLinkStore()
|
||||
const projectedLinks = new Map(graph.links)
|
||||
linkStore.rehydrate(graph.linkStoreKey, {
|
||||
links: projectedLinks,
|
||||
floatingLinks: graph.floatingLinks,
|
||||
reroutes: graph.reroutes
|
||||
})
|
||||
graph.links.clear()
|
||||
|
||||
expect(graph.getLink(linkId)).toBe(projectedLinks.get(linkId))
|
||||
expect(graph.links.get(linkId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads reroutes from the projected store map', () => {
|
||||
const graph = new LGraph()
|
||||
const { rerouteId } = buildLinkTopology(graph)
|
||||
|
||||
const linkStore = useLinkStore()
|
||||
const projectedReroutes = new Map(graph.reroutes)
|
||||
linkStore.rehydrate(graph.linkStoreKey, {
|
||||
links: graph.links,
|
||||
floatingLinks: graph.floatingLinks,
|
||||
reroutes: projectedReroutes
|
||||
})
|
||||
graph.reroutes.clear()
|
||||
|
||||
expect(graph.getReroute(rerouteId)).toBe(projectedReroutes.get(rerouteId))
|
||||
expect(graph.reroutes.get(rerouteId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps floating-link reads explicit through floating projection', () => {
|
||||
const graph = new LGraph()
|
||||
const { floatingLinkId } = buildLinkTopology(graph)
|
||||
const linkStore = useLinkStore()
|
||||
const floatingLink = linkStore.getFloatingLink(
|
||||
graph.linkStoreKey,
|
||||
floatingLinkId
|
||||
)
|
||||
if (!floatingLink) throw new Error('Expected floating link projection')
|
||||
|
||||
expect(graph.getLink(floatingLinkId)).not.toBe(floatingLink)
|
||||
expect(linkStore.getFloatingLink(graph.linkStoreKey, floatingLinkId)).toBe(
|
||||
floatingLink
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disconnect/Remove Characterization', () => {
|
||||
it('graph.removeLink preserves disconnect callback ordering parity', () => {
|
||||
const graph = new LGraph()
|
||||
const sourceNode = createNumberNode('source')
|
||||
const targetNode = createNumberNode('target')
|
||||
|
||||
graph.add(sourceNode)
|
||||
graph.add(targetNode)
|
||||
|
||||
const link = sourceNode.connect(0, targetNode, 0)
|
||||
if (!link) throw new Error('Expected link')
|
||||
|
||||
const callbackOrder: string[] = []
|
||||
|
||||
targetNode.onConnectionsChange = (
|
||||
slotType,
|
||||
slotIndex,
|
||||
connected,
|
||||
linkInfo
|
||||
) => {
|
||||
if (!linkInfo) throw new Error('Expected link info')
|
||||
callbackOrder.push(`target:${slotType}:${slotIndex}:${connected}`)
|
||||
expect(slotType).toBe(NodeSlotType.INPUT)
|
||||
expect(slotIndex).toBe(0)
|
||||
expect(connected).toBe(false)
|
||||
expect(linkInfo.id).toBe(link.id)
|
||||
}
|
||||
|
||||
sourceNode.onConnectionsChange = (
|
||||
slotType,
|
||||
slotIndex,
|
||||
connected,
|
||||
linkInfo
|
||||
) => {
|
||||
if (!linkInfo) throw new Error('Expected link info')
|
||||
callbackOrder.push(`source:${slotType}:${slotIndex}:${connected}`)
|
||||
expect(slotType).toBe(NodeSlotType.OUTPUT)
|
||||
expect(slotIndex).toBe(0)
|
||||
expect(connected).toBe(false)
|
||||
expect(linkInfo.id).toBe(link.id)
|
||||
}
|
||||
|
||||
graph.removeLink(link.id)
|
||||
|
||||
expect(callbackOrder).toEqual([
|
||||
`target:${NodeSlotType.INPUT}:0:false`,
|
||||
`source:${NodeSlotType.OUTPUT}:0:false`
|
||||
])
|
||||
expect(graph.getLink(link.id)).toBeUndefined()
|
||||
expect(targetNode.inputs[0].link).toBeNull()
|
||||
expect(sourceNode.outputs[0].links).toEqual([])
|
||||
})
|
||||
|
||||
it('removeLink retains floating/reroute cleanup invariants', () => {
|
||||
const graph = new LGraph()
|
||||
const { disconnectedLinkId, floatingLinkId, rerouteId } =
|
||||
buildLinkTopology(graph)
|
||||
|
||||
graph.removeLink(disconnectedLinkId)
|
||||
|
||||
const linkStore = useLinkStore()
|
||||
expect(graph.getLink(disconnectedLinkId)).toBeUndefined()
|
||||
expect(
|
||||
linkStore.getLink(graph.linkStoreKey, disconnectedLinkId)
|
||||
).toBeUndefined()
|
||||
expect(graph.getReroute(rerouteId)).toBeDefined()
|
||||
expect(linkStore.getReroute(graph.linkStoreKey, rerouteId)).toBeDefined()
|
||||
expect(graph.floatingLinks.has(floatingLinkId)).toBe(true)
|
||||
expect(
|
||||
linkStore.getFloatingLink(graph.linkStoreKey, floatingLinkId)
|
||||
).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Connect Characterization', () => {
|
||||
it('connect with reroute keeps floating cleanup invariants', () => {
|
||||
const graph = new LGraph()
|
||||
const { floatingLinkId, rerouteId } = buildLinkTopology(graph)
|
||||
|
||||
const sourceNode = createNumberNode('new-source')
|
||||
const targetNode = createNumberNode('new-target')
|
||||
graph.add(sourceNode)
|
||||
graph.add(targetNode)
|
||||
|
||||
const rerouteBeforeConnect = graph.getReroute(rerouteId)
|
||||
if (!rerouteBeforeConnect) throw new Error('Expected reroute')
|
||||
expect(rerouteBeforeConnect.floatingLinkIds.has(floatingLinkId)).toBe(true)
|
||||
|
||||
const link = sourceNode.connect(0, targetNode, 0, rerouteId)
|
||||
if (!link) throw new Error('Expected link')
|
||||
|
||||
const rerouteAfterConnect = graph.getReroute(rerouteId)
|
||||
if (!rerouteAfterConnect) throw new Error('Expected reroute after connect')
|
||||
|
||||
const linkStore = useLinkStore()
|
||||
expect(graph.getLink(link.id)).toBe(link)
|
||||
expect(linkStore.getLink(graph.linkStoreKey, link.id)).toBe(link)
|
||||
expect(rerouteAfterConnect.linkIds.has(link.id)).toBe(true)
|
||||
expect(rerouteAfterConnect.floating).toBeUndefined()
|
||||
expect(rerouteAfterConnect.floatingLinkIds.has(floatingLinkId)).toBe(false)
|
||||
expect(graph.floatingLinks.has(floatingLinkId)).toBe(false)
|
||||
expect(
|
||||
linkStore.getFloatingLink(graph.linkStoreKey, floatingLinkId)
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Graph Clearing and Callbacks', () => {
|
||||
test('clear() calls both node.onRemoved() and graph.onNodeRemoved()', ({
|
||||
expect
|
||||
@@ -494,6 +854,33 @@ describe('ensureGlobalIdUniqueness', () => {
|
||||
expect(link.origin_id).not.toBe(rootNode.id)
|
||||
})
|
||||
|
||||
it('patches floating link origin_id and target_id after reassignment', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const subgraph = createSubgraphOnGraph(rootGraph)
|
||||
|
||||
const rootNode = new DummyNode()
|
||||
rootGraph.add(rootNode)
|
||||
|
||||
const subNodeA = new DummyNode()
|
||||
subNodeA.id = rootNode.id
|
||||
subgraph._nodes.push(subNodeA)
|
||||
subgraph._nodes_by_id[subNodeA.id] = subNodeA
|
||||
|
||||
const subNodeB = new DummyNode()
|
||||
subNodeB.id = 777
|
||||
subgraph._nodes.push(subNodeB)
|
||||
subgraph._nodes_by_id[subNodeB.id] = subNodeB
|
||||
|
||||
const floatingLink = new LLink(9, 'number', subNodeA.id, 0, subNodeB.id, 0)
|
||||
subgraph.addFloatingLink(floatingLink)
|
||||
|
||||
rootGraph.ensureGlobalIdUniqueness()
|
||||
|
||||
expect(floatingLink.origin_id).toBe(subNodeA.id)
|
||||
expect(floatingLink.target_id).toBe(subNodeB.id)
|
||||
expect(floatingLink.origin_id).not.toBe(rootNode.id)
|
||||
})
|
||||
|
||||
it('detects collisions with reserved (not-yet-created) node IDs', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const subgraph = createSubgraphOnGraph(rootGraph)
|
||||
@@ -530,6 +917,42 @@ describe('ensureGlobalIdUniqueness', () => {
|
||||
})
|
||||
|
||||
describe('Subgraph Unpacking', () => {
|
||||
function installSubgraphNodeRegistration(rootGraph: LGraph): () => void {
|
||||
const listener = (event: CustomEvent<{ subgraph: Subgraph }>): void => {
|
||||
const { subgraph } = event.detail
|
||||
|
||||
class RuntimeSubgraphNode extends SubgraphNode {
|
||||
constructor(title?: string) {
|
||||
super(rootGraph, subgraph, {
|
||||
id: ++rootGraph.last_node_id,
|
||||
type: subgraph.id,
|
||||
title,
|
||||
pos: [0, 0],
|
||||
size: [140, 80],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: {},
|
||||
flags: {},
|
||||
mode: 0,
|
||||
order: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
LiteGraph.registerNodeType(subgraph.id, RuntimeSubgraphNode)
|
||||
}
|
||||
|
||||
rootGraph.events.addEventListener('subgraph-created', listener)
|
||||
return () =>
|
||||
rootGraph.events.removeEventListener('subgraph-created', listener)
|
||||
}
|
||||
|
||||
function getRequiredNodeByTitle(graph: LGraph, title: string): LGraphNode {
|
||||
const node = graph.nodes.find((candidate) => candidate.title === title)
|
||||
if (!node) throw new Error(`Expected node titled ${title}`)
|
||||
return node
|
||||
}
|
||||
|
||||
class TestNode extends LGraphNode {
|
||||
constructor(title?: string) {
|
||||
super(title ?? 'TestNode')
|
||||
@@ -635,6 +1058,117 @@ describe('Subgraph Unpacking', () => {
|
||||
expect(unpackedTarget.inputs[1].link).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves boundary input reroute parent remap across convert and unpack', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
registerTestNodes()
|
||||
const rootGraph = new LGraph()
|
||||
const cleanupRegistration = installSubgraphNodeRegistration(rootGraph)
|
||||
try {
|
||||
const externalSource = LiteGraph.createNode(
|
||||
'test/TestNode',
|
||||
'external-source'
|
||||
)
|
||||
const boundaryTarget = LiteGraph.createNode(
|
||||
'test/TestNode',
|
||||
'boundary-target'
|
||||
)
|
||||
if (!externalSource || !boundaryTarget)
|
||||
throw new Error('Expected test nodes')
|
||||
rootGraph.add(externalSource)
|
||||
rootGraph.add(boundaryTarget)
|
||||
|
||||
const boundaryLink = externalSource.connect(0, boundaryTarget, 0)
|
||||
if (!boundaryLink) throw new Error('Expected boundary link')
|
||||
|
||||
const reroute = rootGraph.createReroute([120, 40], boundaryLink)
|
||||
expect(boundaryLink.parentId).toBe(reroute.id)
|
||||
|
||||
const { node: subgraphNode } = rootGraph.convertToSubgraph(
|
||||
new Set([boundaryTarget])
|
||||
)
|
||||
const convertedBoundaryLinkId = subgraphNode.inputs[0].link
|
||||
if (convertedBoundaryLinkId == null)
|
||||
throw new Error('Expected converted boundary input link')
|
||||
|
||||
const convertedBoundaryLink = rootGraph.getLink(convertedBoundaryLinkId)
|
||||
if (!convertedBoundaryLink)
|
||||
throw new Error('Expected converted boundary input link instance')
|
||||
expect(convertedBoundaryLink.parentId).toBe(reroute.id)
|
||||
|
||||
rootGraph.unpackSubgraph(subgraphNode)
|
||||
|
||||
const unpackedTarget = getRequiredNodeByTitle(
|
||||
rootGraph,
|
||||
'boundary-target'
|
||||
)
|
||||
const unpackedLink = rootGraph.getLink(unpackedTarget.inputs[0].link)
|
||||
if (!unpackedLink)
|
||||
throw new Error('Expected unpacked boundary input link')
|
||||
|
||||
expect(unpackedLink.origin_id).toBe(externalSource.id)
|
||||
expect(unpackedLink.target_id).toBe(unpackedTarget.id)
|
||||
expect(unpackedLink.parentId).toBe(reroute.id)
|
||||
} finally {
|
||||
cleanupRegistration()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves boundary output reroute parent remap across convert and unpack', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
registerTestNodes()
|
||||
const rootGraph = new LGraph()
|
||||
const cleanupRegistration = installSubgraphNodeRegistration(rootGraph)
|
||||
try {
|
||||
const boundarySource = LiteGraph.createNode(
|
||||
'test/TestNode',
|
||||
'boundary-source'
|
||||
)
|
||||
const externalTarget = LiteGraph.createNode(
|
||||
'test/TestNode',
|
||||
'external-target'
|
||||
)
|
||||
if (!boundarySource || !externalTarget)
|
||||
throw new Error('Expected test nodes')
|
||||
rootGraph.add(boundarySource)
|
||||
rootGraph.add(externalTarget)
|
||||
|
||||
const boundaryLink = boundarySource.connect(0, externalTarget, 0)
|
||||
if (!boundaryLink) throw new Error('Expected boundary link')
|
||||
|
||||
const reroute = rootGraph.createReroute([180, 80], boundaryLink)
|
||||
expect(boundaryLink.parentId).toBe(reroute.id)
|
||||
|
||||
const { node: subgraphNode } = rootGraph.convertToSubgraph(
|
||||
new Set([boundarySource])
|
||||
)
|
||||
const convertedBoundaryLinkId = subgraphNode.outputs[0].links?.[0]
|
||||
if (convertedBoundaryLinkId == null)
|
||||
throw new Error('Expected converted boundary output link')
|
||||
|
||||
const convertedBoundaryLink = rootGraph.getLink(convertedBoundaryLinkId)
|
||||
if (!convertedBoundaryLink)
|
||||
throw new Error('Expected converted boundary output link instance')
|
||||
expect(convertedBoundaryLink.parentId).toBe(reroute.id)
|
||||
|
||||
rootGraph.unpackSubgraph(subgraphNode)
|
||||
|
||||
const unpackedSource = getRequiredNodeByTitle(
|
||||
rootGraph,
|
||||
'boundary-source'
|
||||
)
|
||||
const unpackedLinkId = unpackedSource.outputs[0].links?.[0]
|
||||
const unpackedLink = rootGraph.getLink(unpackedLinkId)
|
||||
if (!unpackedLink)
|
||||
throw new Error('Expected unpacked boundary output link')
|
||||
|
||||
expect(unpackedLink.origin_id).toBe(unpackedSource.id)
|
||||
expect(unpackedLink.target_id).toBe(externalTarget.id)
|
||||
expect(unpackedLink.parentId).toBe(reroute.id)
|
||||
} finally {
|
||||
cleanupRegistration()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps subgraph definition when unpacking one instance while another remains', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const subgraph = createSubgraphOnGraph(rootGraph)
|
||||
@@ -656,3 +1190,61 @@ describe('Subgraph Unpacking', () => {
|
||||
expect(definitionIds).toContain(subgraph.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph Layout Integration', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function createSubgraphWithIO(rootGraph: LGraph) {
|
||||
const subgraph = rootGraph.createSubgraph(createTestSubgraphData())
|
||||
subgraph.addInput('in_0', 'number')
|
||||
subgraph.addOutput('out_0', 'number')
|
||||
|
||||
const innerNode = new LGraphNode('InnerNode')
|
||||
innerNode.addInput('in', 'number')
|
||||
innerNode.addOutput('out', 'number')
|
||||
subgraph.add(innerNode)
|
||||
|
||||
return { subgraph, innerNode }
|
||||
}
|
||||
|
||||
it('calls layoutMutations.createLink when connectSubgraphInputSlot is called', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const { subgraph, innerNode } = createSubgraphWithIO(rootGraph)
|
||||
|
||||
const subgraphInput = subgraph.inputs[0]
|
||||
const link = subgraph.connectSubgraphInputSlot(subgraphInput, innerNode, 0)
|
||||
|
||||
const mutations = useLayoutMutations()
|
||||
expect(mutations.createLink).toHaveBeenCalledWith(
|
||||
link.id,
|
||||
subgraphInput.parent.id,
|
||||
0,
|
||||
innerNode.id,
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
it('calls layoutMutations.createLink when connectSubgraphOutputSlot is called', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const { subgraph, innerNode } = createSubgraphWithIO(rootGraph)
|
||||
|
||||
const subgraphOutput = subgraph.outputs[0]
|
||||
const link = subgraph.connectSubgraphOutputSlot(
|
||||
innerNode,
|
||||
0,
|
||||
subgraphOutput
|
||||
)
|
||||
|
||||
const mutations = useLayoutMutations()
|
||||
expect(mutations.createLink).toHaveBeenCalledWith(
|
||||
link.id,
|
||||
innerNode.id,
|
||||
0,
|
||||
subgraphOutput.parent.id,
|
||||
0
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,11 +4,16 @@ import {
|
||||
SUBGRAPH_INPUT_ID,
|
||||
SUBGRAPH_OUTPUT_ID
|
||||
} from '@/lib/litegraph/src/constants'
|
||||
import { isNodeBindable } from '@/lib/litegraph/src/utils/type'
|
||||
import {
|
||||
normalizeLegacySlotIdentity,
|
||||
resolveCanonicalSlotName
|
||||
} from '@/lib/litegraph/src/utils/slotIdentity'
|
||||
import { commonType, isNodeBindable } from '@/lib/litegraph/src/utils/type'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { useLinkStore } from '@/stores/linkStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
@@ -24,6 +29,8 @@ import { MapProxyHandler } from './MapProxyHandler'
|
||||
import { Reroute } from './Reroute'
|
||||
import type { RerouteId } from './Reroute'
|
||||
import { CustomEventTarget } from './infrastructure/CustomEventTarget'
|
||||
import { graphLifecycleEventDispatcher } from './infrastructure/GraphLifecycleEventDispatcher'
|
||||
import { graphPersistenceAdapter } from './infrastructure/GraphPersistenceAdapter'
|
||||
import type { LGraphEventMap } from './infrastructure/LGraphEventMap'
|
||||
import type { SubgraphEventMap } from './infrastructure/SubgraphEventMap'
|
||||
import type {
|
||||
@@ -33,6 +40,7 @@ import type {
|
||||
IContextMenuValue,
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
ISlotType,
|
||||
LinkNetwork,
|
||||
LinkSegment,
|
||||
MethodNames,
|
||||
@@ -58,9 +66,10 @@ import {
|
||||
mapSubgraphInputsAndLinks,
|
||||
mapSubgraphOutputsAndLinks,
|
||||
multiClone,
|
||||
subgraphBoundaryAdapter,
|
||||
splitPositionables
|
||||
} from './subgraph/subgraphUtils'
|
||||
import { Alignment, LGraphEventMode } from './types/globalEnums'
|
||||
import { Alignment, LGraphEventMode, NodeSlotType } from './types/globalEnums'
|
||||
import type {
|
||||
LGraphTriggerAction,
|
||||
LGraphTriggerEvent,
|
||||
@@ -78,10 +87,7 @@ import type {
|
||||
} from './types/serialisation'
|
||||
import { getAllNestedItems } from './utils/collections'
|
||||
|
||||
export type {
|
||||
LGraphTriggerAction,
|
||||
LGraphTriggerParam
|
||||
} from './types/graphTriggers'
|
||||
export type { LGraphTriggerParam } from './types/graphTriggers'
|
||||
|
||||
export type RendererType = 'LG' | 'Vue'
|
||||
|
||||
@@ -269,7 +275,16 @@ export class LGraph
|
||||
/** Internal only. Not required for serialisation; calculated on deserialise. */
|
||||
private _lastFloatingLinkId: number = 0
|
||||
|
||||
/** Stable instance key for the link store — never changes once created. */
|
||||
readonly linkStoreKey: UUID = createUuidv4()
|
||||
|
||||
private _linkStoreCache?: ReturnType<typeof useLinkStore>
|
||||
private get linkStore() {
|
||||
return (this._linkStoreCache ??= useLinkStore())
|
||||
}
|
||||
|
||||
private readonly floatingLinksInternal: Map<LinkId, LLink> = new Map()
|
||||
|
||||
get floatingLinks(): ReadonlyMap<LinkId, LLink> {
|
||||
return this.floatingLinksInternal
|
||||
}
|
||||
@@ -359,6 +374,7 @@ export class LGraph
|
||||
usePromotionStore().clearGraph(graphId)
|
||||
useWidgetValueStore().clearGraph(graphId)
|
||||
}
|
||||
this.linkStore.clearGraph(this.linkStoreKey)
|
||||
|
||||
this.id = zeroUuid
|
||||
this.revision = 0
|
||||
@@ -393,6 +409,7 @@ export class LGraph
|
||||
this._links.clear()
|
||||
this.reroutes.clear()
|
||||
this.floatingLinksInternal.clear()
|
||||
this.rehydrateLinkStore()
|
||||
|
||||
this._lastFloatingLinkId = 0
|
||||
|
||||
@@ -429,6 +446,14 @@ export class LGraph
|
||||
this.canvasAction((c) => c.clear())
|
||||
}
|
||||
|
||||
private rehydrateLinkStore(): void {
|
||||
this.linkStore.rehydrate(this.linkStoreKey, {
|
||||
links: this._links,
|
||||
floatingLinks: this.floatingLinksInternal,
|
||||
reroutes: this.reroutesInternal
|
||||
})
|
||||
}
|
||||
|
||||
get subgraphs(): Map<UUID, Subgraph> {
|
||||
return this.rootGraph._subgraphs
|
||||
}
|
||||
@@ -1446,7 +1471,7 @@ export class LGraph
|
||||
getLink(id: null | undefined): undefined
|
||||
getLink(id: LinkId | null | undefined): LLink | undefined
|
||||
getLink(id: LinkId | null | undefined): LLink | undefined {
|
||||
return id == null ? undefined : this._links.get(id)
|
||||
return this.linkStore.getLink(this.linkStoreKey, id)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1457,7 +1482,7 @@ export class LGraph
|
||||
getReroute(id: null | undefined): undefined
|
||||
getReroute(id: RerouteId | null | undefined): Reroute | undefined
|
||||
getReroute(id: RerouteId | null | undefined): Reroute | undefined {
|
||||
return id == null ? undefined : this.reroutes.get(id)
|
||||
return this.linkStore.getReroute(this.linkStoreKey, id)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1605,9 +1630,232 @@ export class LGraph
|
||||
if (!link) return
|
||||
|
||||
const node = this.getNodeById(link.target_id)
|
||||
node?.disconnectInput(link.target_slot, false)
|
||||
if (node?.disconnectInput(link.target_slot, false)) {
|
||||
return
|
||||
}
|
||||
|
||||
link.disconnect(this)
|
||||
this.disconnectLink(link)
|
||||
}
|
||||
|
||||
disconnectLink(link: LLink, keepReroutes?: 'input' | 'output'): void {
|
||||
link.disconnect(this, keepReroutes)
|
||||
}
|
||||
|
||||
private finalizeConnectedLink(link: LLink): void {
|
||||
const reroutes = LLink.getReroutes(this, link)
|
||||
for (const reroute of reroutes) {
|
||||
reroute.linkIds.add(link.id)
|
||||
if (reroute.floating) reroute.floating = undefined
|
||||
reroute._dragging = undefined
|
||||
}
|
||||
|
||||
const lastReroute = reroutes.at(-1)
|
||||
if (lastReroute) {
|
||||
for (const floatingLinkId of lastReroute.floatingLinkIds) {
|
||||
const floatingLink = this.floatingLinks.get(floatingLinkId)
|
||||
if (floatingLink?.parentId === lastReroute.id) {
|
||||
this.removeFloatingLink(floatingLink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._version++
|
||||
}
|
||||
|
||||
private _createAndRegisterLink(
|
||||
type: ISlotType,
|
||||
originId: NodeId,
|
||||
originSlot: number,
|
||||
targetId: NodeId,
|
||||
targetSlot: number,
|
||||
afterRerouteId?: RerouteId
|
||||
): LLink {
|
||||
const link = new LLink(
|
||||
++this.state.lastLinkId,
|
||||
type,
|
||||
originId,
|
||||
originSlot,
|
||||
targetId,
|
||||
targetSlot,
|
||||
afterRerouteId
|
||||
)
|
||||
this._links.set(link.id, link)
|
||||
|
||||
const layoutMutations = useLayoutMutations()
|
||||
layoutMutations.setSource(LayoutSource.Canvas)
|
||||
layoutMutations.createLink(
|
||||
link.id,
|
||||
originId,
|
||||
originSlot,
|
||||
targetId,
|
||||
targetSlot
|
||||
)
|
||||
return link
|
||||
}
|
||||
|
||||
/**
|
||||
* Always returns a valid LLink — callers rely on non-nullable return.
|
||||
*
|
||||
* Note: This method does NOT dispatch `dispatchConnectNodePair`.
|
||||
* Callers (e.g. `LGraphNode.connectSlots`) are responsible for dispatching
|
||||
* connection callbacks after this returns.
|
||||
*/
|
||||
connectSlots(
|
||||
sourceNode: LGraphNode,
|
||||
outputIndex: number,
|
||||
targetNode: LGraphNode,
|
||||
inputIndex: number,
|
||||
afterRerouteId?: RerouteId
|
||||
): LLink {
|
||||
const output = sourceNode.outputs[outputIndex]
|
||||
const input = targetNode.inputs[inputIndex]
|
||||
|
||||
const maybeCommonType =
|
||||
input.type && output.type && commonType(input.type, output.type)
|
||||
const link = this._createAndRegisterLink(
|
||||
maybeCommonType || input.type || output.type,
|
||||
sourceNode.id,
|
||||
outputIndex,
|
||||
targetNode.id,
|
||||
inputIndex,
|
||||
afterRerouteId
|
||||
)
|
||||
|
||||
output.links ??= []
|
||||
output.links.push(link.id)
|
||||
input.link = link.id
|
||||
graphLifecycleEventDispatcher.dispatchSlotLinkChanged({
|
||||
graph: this,
|
||||
nodeId: targetNode.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: inputIndex,
|
||||
connected: true,
|
||||
linkId: link.id,
|
||||
hasWidget: !!input.widget
|
||||
})
|
||||
|
||||
this.finalizeConnectedLink(link)
|
||||
return link
|
||||
}
|
||||
|
||||
connectSubgraphInputSlot(
|
||||
subgraphInput: SubgraphInput,
|
||||
targetNode: LGraphNode,
|
||||
targetSlotIndex: number,
|
||||
afterRerouteId?: RerouteId
|
||||
): LLink {
|
||||
const targetInput = targetNode.inputs[targetSlotIndex]
|
||||
const subgraphInputIndex = subgraphInput.parent.slots.indexOf(subgraphInput)
|
||||
const link = this._createAndRegisterLink(
|
||||
targetInput.type,
|
||||
subgraphInput.parent.id,
|
||||
subgraphInputIndex,
|
||||
targetNode.id,
|
||||
targetSlotIndex,
|
||||
afterRerouteId
|
||||
)
|
||||
|
||||
subgraphInput.linkIds.push(link.id)
|
||||
targetInput.link = link.id
|
||||
graphLifecycleEventDispatcher.dispatchSlotLinkChanged({
|
||||
graph: this,
|
||||
nodeId: targetNode.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: targetSlotIndex,
|
||||
connected: true,
|
||||
linkId: link.id,
|
||||
hasWidget: !!targetInput.widget
|
||||
})
|
||||
|
||||
this.finalizeConnectedLink(link)
|
||||
return link
|
||||
}
|
||||
|
||||
connectSubgraphOutputSlot(
|
||||
sourceNode: LGraphNode,
|
||||
sourceSlotIndex: number,
|
||||
subgraphOutput: SubgraphOutput,
|
||||
afterRerouteId?: RerouteId
|
||||
): LLink {
|
||||
const sourceOutput = sourceNode.outputs[sourceSlotIndex]
|
||||
const subgraphOutputIndex =
|
||||
subgraphOutput.parent.slots.indexOf(subgraphOutput)
|
||||
const link = this._createAndRegisterLink(
|
||||
sourceOutput.type,
|
||||
sourceNode.id,
|
||||
sourceSlotIndex,
|
||||
subgraphOutput.parent.id,
|
||||
subgraphOutputIndex,
|
||||
afterRerouteId
|
||||
)
|
||||
|
||||
subgraphOutput.linkIds[0] = link.id
|
||||
sourceOutput.links ??= []
|
||||
sourceOutput.links.push(link.id)
|
||||
graphLifecycleEventDispatcher.dispatchSlotLinkChanged({
|
||||
graph: this,
|
||||
nodeId: sourceNode.id,
|
||||
slotType: NodeSlotType.OUTPUT,
|
||||
slotIndex: sourceSlotIndex,
|
||||
connected: true,
|
||||
linkId: link.id,
|
||||
hasWidget: false
|
||||
})
|
||||
|
||||
this.finalizeConnectedLink(link)
|
||||
return link
|
||||
}
|
||||
|
||||
// Versioning: `disconnectLink` / `link.disconnect()` does not increment
|
||||
// `_version`. Each disconnect method is responsible for its own increment.
|
||||
disconnectSubgraphInputLink(
|
||||
subgraphInput: SubgraphInput,
|
||||
targetNode: LGraphNode,
|
||||
targetSlotIndex: number,
|
||||
link: LLink | undefined
|
||||
): void {
|
||||
const targetInput = targetNode.inputs[targetSlotIndex]
|
||||
if (targetInput._floatingLinks?.size) {
|
||||
for (const floatingLink of targetInput._floatingLinks) {
|
||||
this.removeFloatingLink(floatingLink)
|
||||
}
|
||||
}
|
||||
|
||||
targetInput.link = null
|
||||
if (!link) return
|
||||
|
||||
this.disconnectLink(link, 'output')
|
||||
this._version++
|
||||
|
||||
const index = subgraphInput.linkIds.indexOf(link.id)
|
||||
if (index === -1) {
|
||||
console.warn(
|
||||
'disconnectSubgraphInputLink: link ID not found in subgraph input',
|
||||
link.id
|
||||
)
|
||||
return
|
||||
}
|
||||
subgraphInput.linkIds.splice(index, 1)
|
||||
}
|
||||
|
||||
disconnectSubgraphOutputLink(
|
||||
subgraphOutput: SubgraphOutput,
|
||||
sourceNode: LGraphNode,
|
||||
sourceSlotIndex: number,
|
||||
link: LLink
|
||||
): void {
|
||||
const sourceOutput = sourceNode.outputs[sourceSlotIndex]
|
||||
this.disconnectLink(link, 'input')
|
||||
this._version++
|
||||
|
||||
if (sourceOutput.links) {
|
||||
sourceOutput.links = sourceOutput.links.filter((id) => id !== link.id)
|
||||
}
|
||||
|
||||
const subgraphLinkIndex = subgraphOutput.linkIds.indexOf(link.id)
|
||||
if (subgraphLinkIndex !== -1) {
|
||||
subgraphOutput.linkIds.splice(subgraphLinkIndex, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1798,7 +2046,7 @@ export class LGraph
|
||||
|
||||
// Special handling: Subgraph input node
|
||||
i++
|
||||
if (link.origin_id === SUBGRAPH_INPUT_ID) {
|
||||
if (link.originIsIoNode) {
|
||||
link.target_id = subgraphNode.id
|
||||
link.target_slot = i - 1
|
||||
if (subgraphInput instanceof SubgraphInput) {
|
||||
@@ -1812,7 +2060,7 @@ export class LGraph
|
||||
}
|
||||
|
||||
for (const resolved of others) {
|
||||
resolved.link.disconnect(this)
|
||||
this.disconnectLink(resolved.link)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -1839,7 +2087,7 @@ export class LGraph
|
||||
for (const connection of connections) {
|
||||
const { input, inputNode, link, subgraphOutput } = connection
|
||||
// Special handling: Subgraph output node
|
||||
if (link.target_id === SUBGRAPH_OUTPUT_ID) {
|
||||
if (link.targetIsIoNode) {
|
||||
link.origin_id = subgraphNode.id
|
||||
link.origin_slot = i - 1
|
||||
this.links.set(link.id, link)
|
||||
@@ -1850,7 +2098,7 @@ export class LGraph
|
||||
link.parentId
|
||||
)
|
||||
} else {
|
||||
throw new TypeError('Subgraph input node is not a SubgraphInput')
|
||||
throw new TypeError('Subgraph output node is not a SubgraphOutput')
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -2011,16 +2259,20 @@ export class LGraph
|
||||
}[] = []
|
||||
for (const [, link] of subgraphNode.subgraph._links) {
|
||||
let externalParentId: RerouteId | undefined
|
||||
if (link.origin_id === SUBGRAPH_INPUT_ID) {
|
||||
const outerLinkId = subgraphNode.inputs[link.origin_slot].link
|
||||
if (!outerLinkId) {
|
||||
if (link.originIsIoNode) {
|
||||
const endpoint = subgraphBoundaryAdapter.remapInputBoundaryForUnpack(
|
||||
link,
|
||||
subgraphNode,
|
||||
this.links
|
||||
)
|
||||
if (!endpoint) {
|
||||
console.error('Missing Link ID when unpacking')
|
||||
continue
|
||||
}
|
||||
const outerLink = this.links[outerLinkId]
|
||||
link.origin_id = outerLink.origin_id
|
||||
link.origin_slot = outerLink.origin_slot
|
||||
externalParentId = outerLink.parentId
|
||||
|
||||
link.origin_id = endpoint.originId
|
||||
link.origin_slot = endpoint.originSlot
|
||||
externalParentId = endpoint.externalParentId
|
||||
} else {
|
||||
const origin_id = nodeIdMap.get(link.origin_id)
|
||||
if (!origin_id) {
|
||||
@@ -2029,22 +2281,37 @@ export class LGraph
|
||||
}
|
||||
link.origin_id = origin_id
|
||||
}
|
||||
if (link.target_id === SUBGRAPH_OUTPUT_ID) {
|
||||
for (const linkId of subgraphNode.outputs[link.target_slot].links ??
|
||||
[]) {
|
||||
const sublink = this.links[linkId]
|
||||
if (link.targetIsIoNode) {
|
||||
const outputEndpoints =
|
||||
subgraphBoundaryAdapter.resolveOutputBoundaryForUnpack(
|
||||
link,
|
||||
subgraphNode,
|
||||
this.links
|
||||
)
|
||||
if (outputEndpoints.length === 0) {
|
||||
console.error('Missing Link ID when unpacking')
|
||||
continue
|
||||
}
|
||||
|
||||
for (const endpoint of outputEndpoints) {
|
||||
newLinks.push({
|
||||
oid: link.origin_id,
|
||||
oslot: link.origin_slot,
|
||||
tid: sublink.target_id,
|
||||
tslot: sublink.target_slot,
|
||||
tid: endpoint.targetId,
|
||||
tslot: endpoint.targetSlot,
|
||||
id: link.id,
|
||||
iparent: link.parentId,
|
||||
eparent: sublink.parentId,
|
||||
eparent: endpoint.externalParentId,
|
||||
externalFirst: true
|
||||
})
|
||||
sublink.parentId = undefined
|
||||
}
|
||||
|
||||
for (const linkId of subgraphNode.outputs[link.target_slot].links ??
|
||||
[]) {
|
||||
const sublink = this.links.get(linkId)
|
||||
if (sublink) sublink.parentId = undefined
|
||||
}
|
||||
|
||||
continue
|
||||
} else {
|
||||
const target_id = nodeIdMap.get(link.target_id)
|
||||
@@ -2403,59 +2670,12 @@ export class LGraph
|
||||
|
||||
this._configureBase(data)
|
||||
|
||||
let reroutes: SerialisableReroute[] | undefined
|
||||
|
||||
// TODO: Determine whether this should this fall back to 0.4.
|
||||
if (data.version === 0.4) {
|
||||
const { extra } = data
|
||||
// Deprecated - old schema version, links are arrays
|
||||
if (Array.isArray(data.links)) {
|
||||
for (const linkData of data.links) {
|
||||
const link = LLink.createFromArray(linkData)
|
||||
this._links.set(link.id, link)
|
||||
}
|
||||
}
|
||||
// #region `extra` embeds for v0.4
|
||||
|
||||
// LLink parentIds
|
||||
if (Array.isArray(extra?.linkExtensions)) {
|
||||
for (const linkEx of extra.linkExtensions) {
|
||||
const link = this._links.get(linkEx.id)
|
||||
if (link) link.parentId = linkEx.parentId
|
||||
}
|
||||
}
|
||||
|
||||
// Reroutes
|
||||
reroutes = extra?.reroutes
|
||||
|
||||
// #endregion `extra` embeds for v0.4
|
||||
} else {
|
||||
// New schema - one version so far, no check required.
|
||||
|
||||
// State - use max to prevent ID collisions across root and subgraphs
|
||||
if (data.state) {
|
||||
const { lastGroupId, lastLinkId, lastNodeId, lastRerouteId } =
|
||||
data.state
|
||||
const { state } = this
|
||||
if (lastGroupId != null)
|
||||
state.lastGroupId = Math.max(state.lastGroupId, lastGroupId)
|
||||
if (lastLinkId != null)
|
||||
state.lastLinkId = Math.max(state.lastLinkId, lastLinkId)
|
||||
if (lastNodeId != null)
|
||||
state.lastNodeId = Math.max(state.lastNodeId, lastNodeId)
|
||||
if (lastRerouteId != null)
|
||||
state.lastRerouteId = Math.max(state.lastRerouteId, lastRerouteId)
|
||||
}
|
||||
|
||||
// Links
|
||||
if (Array.isArray(data.links)) {
|
||||
for (const linkData of data.links) {
|
||||
const link = LLink.create(linkData)
|
||||
this._links.set(link.id, link)
|
||||
}
|
||||
}
|
||||
|
||||
reroutes = data.reroutes
|
||||
const { links, reroutes } = graphPersistenceAdapter.toConfiguredTopology(
|
||||
data,
|
||||
this.state
|
||||
)
|
||||
for (const link of links) {
|
||||
this._links.set(link.id, link)
|
||||
}
|
||||
|
||||
// Reroutes
|
||||
@@ -2578,6 +2798,7 @@ export class LGraph
|
||||
this.setDirtyCanvas(true, true)
|
||||
return error
|
||||
} finally {
|
||||
this.rehydrateLinkStore()
|
||||
this.events.dispatch('configured')
|
||||
}
|
||||
}
|
||||
@@ -2620,8 +2841,14 @@ export class LGraph
|
||||
}
|
||||
|
||||
if (remappedIds.size > 0) {
|
||||
patchLinkNodeIds(graph._links, remappedIds)
|
||||
patchLinkNodeIds(graph.floatingLinksInternal, remappedIds)
|
||||
graphPersistenceAdapter.patchLinkNodeIds(
|
||||
graph._links.values(),
|
||||
remappedIds
|
||||
)
|
||||
graphPersistenceAdapter.patchLinkNodeIds(
|
||||
graph.floatingLinksInternal.values(),
|
||||
remappedIds
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2751,8 +2978,11 @@ export class Subgraph
|
||||
for (const input of inputs) {
|
||||
const subgraphInput = new SubgraphInput(input, this.inputNode)
|
||||
this.inputs.push(subgraphInput)
|
||||
this.events.dispatch('input-added', { input: subgraphInput })
|
||||
}
|
||||
|
||||
normalizeLegacySlotIdentity(this.inputs)
|
||||
for (const subgraphInput of this.inputs)
|
||||
this.events.dispatch('input-added', { input: subgraphInput })
|
||||
}
|
||||
|
||||
if (outputs) {
|
||||
@@ -2760,6 +2990,8 @@ export class Subgraph
|
||||
for (const output of outputs) {
|
||||
this.outputs.push(new SubgraphOutput(output, this.outputNode))
|
||||
}
|
||||
|
||||
normalizeLegacySlotIdentity(this.outputs)
|
||||
}
|
||||
|
||||
if (widgets) {
|
||||
@@ -2798,10 +3030,14 @@ export class Subgraph
|
||||
|
||||
this.events.dispatch('adding-input', { name, type })
|
||||
|
||||
const id = createUuidv4()
|
||||
const canonicalName = resolveCanonicalSlotName(this.inputs, name, id)
|
||||
|
||||
const input = new SubgraphInput(
|
||||
{
|
||||
id: createUuidv4(),
|
||||
name,
|
||||
id,
|
||||
name: canonicalName,
|
||||
label: canonicalName === name ? undefined : name,
|
||||
type
|
||||
},
|
||||
this.inputNode
|
||||
@@ -2820,10 +3056,14 @@ export class Subgraph
|
||||
|
||||
this.events.dispatch('adding-output', { name, type })
|
||||
|
||||
const id = createUuidv4()
|
||||
const canonicalName = resolveCanonicalSlotName(this.outputs, name, id)
|
||||
|
||||
const output = new SubgraphOutput(
|
||||
{
|
||||
id: createUuidv4(),
|
||||
name,
|
||||
id,
|
||||
name: canonicalName,
|
||||
label: canonicalName === name ? undefined : name,
|
||||
type
|
||||
},
|
||||
this.outputNode
|
||||
@@ -2845,13 +3085,16 @@ export class Subgraph
|
||||
if (index === -1) throw new Error('Input not found')
|
||||
|
||||
const oldName = input.displayName
|
||||
const canonicalName = resolveCanonicalSlotName(this.inputs, name, input.id)
|
||||
this.events.dispatch('renaming-input', {
|
||||
input,
|
||||
index,
|
||||
oldName,
|
||||
newName: name
|
||||
newName: name,
|
||||
canonicalName
|
||||
})
|
||||
|
||||
input.name = canonicalName
|
||||
input.label = name
|
||||
}
|
||||
|
||||
@@ -2865,13 +3108,20 @@ export class Subgraph
|
||||
if (index === -1) throw new Error('Output not found')
|
||||
|
||||
const oldName = output.displayName
|
||||
const canonicalName = resolveCanonicalSlotName(
|
||||
this.outputs,
|
||||
name,
|
||||
output.id
|
||||
)
|
||||
this.events.dispatch('renaming-output', {
|
||||
output,
|
||||
index,
|
||||
oldName,
|
||||
newName: name
|
||||
newName: name,
|
||||
canonicalName
|
||||
})
|
||||
|
||||
output.name = canonicalName
|
||||
output.label = name
|
||||
}
|
||||
|
||||
@@ -2972,16 +3222,3 @@ export class Subgraph
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function patchLinkNodeIds(
|
||||
links: Map<LinkId, LLink>,
|
||||
remappedIds: Map<NodeId, NodeId>
|
||||
): void {
|
||||
for (const link of links.values()) {
|
||||
const newOrigin = remappedIds.get(link.origin_id)
|
||||
if (newOrigin !== undefined) link.origin_id = newOrigin
|
||||
|
||||
const newTarget = remappedIds.get(link.target_id)
|
||||
if (newTarget !== undefined) link.target_id = newTarget
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,4 +99,90 @@ describe('remapClipboardSubgraphNodeIds', () => {
|
||||
[String(remappedInteriorId), 'seed']
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves non-remapped proxy widget refs and rewrites internal link endpoints', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const existingNodeA = new LGraphNode('existing-a')
|
||||
existingNodeA.id = 1
|
||||
rootGraph.add(existingNodeA)
|
||||
|
||||
const existingNodeB = new LGraphNode('existing-b')
|
||||
existingNodeB.id = 2
|
||||
rootGraph.add(existingNodeB)
|
||||
|
||||
const subgraphId = createUuidv4()
|
||||
const pastedSubgraph: ExportedSubgraph = {
|
||||
id: subgraphId,
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 0,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
config: {},
|
||||
name: 'Pasted Subgraph',
|
||||
inputNode: {
|
||||
id: -10,
|
||||
bounding: [0, 0, 10, 10]
|
||||
},
|
||||
outputNode: {
|
||||
id: -20,
|
||||
bounding: [0, 0, 10, 10]
|
||||
},
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [],
|
||||
nodes: [
|
||||
createSerialisedNode(1, 'test/node'),
|
||||
createSerialisedNode(2, 'test/node')
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
type: '*',
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0
|
||||
}
|
||||
],
|
||||
groups: []
|
||||
}
|
||||
|
||||
const parsed: ClipboardItems = {
|
||||
nodes: [
|
||||
createSerialisedNode(99, subgraphId, [
|
||||
['1', 'seed'],
|
||||
['external', 'label']
|
||||
])
|
||||
],
|
||||
groups: [],
|
||||
reroutes: [],
|
||||
links: [],
|
||||
subgraphs: [pastedSubgraph]
|
||||
}
|
||||
|
||||
remapClipboardSubgraphNodeIds(parsed, rootGraph)
|
||||
|
||||
const remappedSubgraph = parsed.subgraphs?.[0]
|
||||
if (!remappedSubgraph) throw new Error('Expected remapped subgraph')
|
||||
const [firstInterior, secondInterior] = remappedSubgraph.nodes ?? []
|
||||
if (!firstInterior || !secondInterior)
|
||||
throw new Error('Expected remapped interior nodes')
|
||||
|
||||
expect(firstInterior.id).not.toBe(1)
|
||||
expect(secondInterior.id).not.toBe(2)
|
||||
|
||||
const remappedLink = remappedSubgraph.links?.[0]
|
||||
expect(remappedLink?.origin_id).toBe(firstInterior.id)
|
||||
expect(remappedLink?.target_id).toBe(secondInterior.id)
|
||||
|
||||
const remappedNode = parsed.nodes?.[0]
|
||||
expect(remappedNode?.properties?.proxyWidgets).toStrictEqual([
|
||||
[String(firstInterior.id), 'seed'],
|
||||
['external', 'label']
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
CustomEventDispatcher,
|
||||
ICustomEventTarget
|
||||
} from './infrastructure/CustomEventTarget'
|
||||
import { graphPersistenceAdapter } from './infrastructure/GraphPersistenceAdapter'
|
||||
import type { LGraphCanvasEventMap } from './infrastructure/LGraphCanvasEventMap'
|
||||
import { NullGraphError } from './infrastructure/NullGraphError'
|
||||
import { Rectangle } from './infrastructure/Rectangle'
|
||||
@@ -8803,55 +8804,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
}
|
||||
|
||||
function patchLinkNodeIds(
|
||||
links: { origin_id: NodeId; target_id: NodeId }[] | undefined,
|
||||
remappedIds: Map<NodeId, NodeId>
|
||||
) {
|
||||
if (!links?.length) return
|
||||
|
||||
for (const link of links) {
|
||||
const newOriginId = remappedIds.get(link.origin_id)
|
||||
if (newOriginId !== undefined) link.origin_id = newOriginId
|
||||
|
||||
const newTargetId = remappedIds.get(link.target_id)
|
||||
if (newTargetId !== undefined) link.target_id = newTargetId
|
||||
}
|
||||
}
|
||||
|
||||
function remapNodeId(
|
||||
nodeId: string,
|
||||
remappedIds: Map<NodeId, NodeId>
|
||||
): NodeId | undefined {
|
||||
const directMatch = remappedIds.get(nodeId)
|
||||
if (directMatch !== undefined) return directMatch
|
||||
if (!/^-?\d+$/.test(nodeId)) return undefined
|
||||
|
||||
const numericId = Number(nodeId)
|
||||
if (!Number.isSafeInteger(numericId)) return undefined
|
||||
|
||||
return remappedIds.get(numericId)
|
||||
}
|
||||
|
||||
function remapProxyWidgets(
|
||||
info: ISerialisedNode,
|
||||
remappedIds: Map<NodeId, NodeId> | undefined
|
||||
) {
|
||||
if (!remappedIds || remappedIds.size === 0) return
|
||||
|
||||
const proxyWidgets = info.properties?.proxyWidgets
|
||||
if (!Array.isArray(proxyWidgets)) return
|
||||
|
||||
for (const entry of proxyWidgets) {
|
||||
if (!Array.isArray(entry)) continue
|
||||
|
||||
const [nodeId] = entry
|
||||
if (typeof nodeId !== 'string' || nodeId === '-1') continue
|
||||
|
||||
const remappedNodeId = remapNodeId(nodeId, remappedIds)
|
||||
if (remappedNodeId !== undefined) entry[0] = String(remappedNodeId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remaps pasted subgraph interior node IDs that would collide with existing
|
||||
* node IDs in the root graph. Also patches subgraph link node IDs and
|
||||
@@ -8899,7 +8851,7 @@ export function remapClipboardSubgraphNodeIds(
|
||||
}
|
||||
|
||||
if (remappedIds.size > 0) {
|
||||
patchLinkNodeIds(subgraphInfo.links, remappedIds)
|
||||
graphPersistenceAdapter.patchLinkNodeIds(subgraphInfo.links, remappedIds)
|
||||
subgraphNodeIdMap.set(subgraphInfo.id, remappedIds)
|
||||
}
|
||||
}
|
||||
@@ -8911,6 +8863,8 @@ export function remapClipboardSubgraphNodeIds(
|
||||
|
||||
for (const nodeInfo of allNodeInfo) {
|
||||
if (typeof nodeInfo.type !== 'string') continue
|
||||
remapProxyWidgets(nodeInfo, subgraphNodeIdMap.get(nodeInfo.type))
|
||||
const remappedIds = subgraphNodeIdMap.get(nodeInfo.type)
|
||||
if (!remappedIds) continue
|
||||
graphPersistenceAdapter.remapProxyWidgets(nodeInfo, remappedIds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
NodeInputSlot,
|
||||
NodeOutputSlot
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
|
||||
import { test } from './__fixtures__/testExtensions'
|
||||
import { createMockLGraphNodeWithArrayBoundingRect } from '@/utils/__tests__/litegraphTestUtils'
|
||||
@@ -149,6 +150,71 @@ describe('LGraphNode', () => {
|
||||
})
|
||||
|
||||
describe('Disconnect I/O Slots', () => {
|
||||
test('disconnectInput keeps current callback ordering and payload parity', () => {
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
const targetNode = new LGraphNode('TargetNode')
|
||||
sourceNode.addOutput('Output1', 'number')
|
||||
targetNode.addInput('Input1', 'number')
|
||||
|
||||
const graph = new LGraph()
|
||||
graph.add(sourceNode)
|
||||
graph.add(targetNode)
|
||||
|
||||
const link = sourceNode.connect(0, targetNode, 0)
|
||||
if (!link) throw new Error('Expected link')
|
||||
targetNode.inputs[0].widget = { name: 'in-widget' }
|
||||
|
||||
const callbackOrder: string[] = []
|
||||
graph.onTrigger = (event) => {
|
||||
if (event.type !== 'node:slot-links:changed') return
|
||||
|
||||
callbackOrder.push(`trigger:${event.type}:${event.connected}`)
|
||||
expect(event.type).toBe('node:slot-links:changed')
|
||||
expect(event.nodeId).toBe(targetNode.id)
|
||||
expect(event.slotType).toBe(NodeSlotType.INPUT)
|
||||
expect(event.slotIndex).toBe(0)
|
||||
expect(event.connected).toBe(false)
|
||||
expect(event.linkId).toBe(link.id)
|
||||
}
|
||||
|
||||
targetNode.onConnectionsChange = (
|
||||
slotType,
|
||||
slotIndex,
|
||||
connected,
|
||||
linkInfo
|
||||
) => {
|
||||
if (!linkInfo) throw new Error('Expected link info')
|
||||
callbackOrder.push(`target:${slotType}:${slotIndex}:${connected}`)
|
||||
expect(slotType).toBe(NodeSlotType.INPUT)
|
||||
expect(slotIndex).toBe(0)
|
||||
expect(connected).toBe(false)
|
||||
expect(linkInfo.id).toBe(link.id)
|
||||
}
|
||||
|
||||
sourceNode.onConnectionsChange = (
|
||||
slotType,
|
||||
slotIndex,
|
||||
connected,
|
||||
linkInfo
|
||||
) => {
|
||||
if (!linkInfo) throw new Error('Expected link info')
|
||||
callbackOrder.push(`source:${slotType}:${slotIndex}:${connected}`)
|
||||
expect(slotType).toBe(NodeSlotType.OUTPUT)
|
||||
expect(slotIndex).toBe(0)
|
||||
expect(connected).toBe(false)
|
||||
expect(linkInfo.id).toBe(link.id)
|
||||
}
|
||||
|
||||
const disconnected = targetNode.disconnectInput(0)
|
||||
|
||||
expect(disconnected).toBe(true)
|
||||
expect(callbackOrder).toEqual([
|
||||
'trigger:node:slot-links:changed:false',
|
||||
`target:${NodeSlotType.INPUT}:0:false`,
|
||||
`source:${NodeSlotType.OUTPUT}:0:false`
|
||||
])
|
||||
})
|
||||
|
||||
test('should disconnect input correctly', () => {
|
||||
const node1 = new LGraphNode('SourceNode')
|
||||
const node2 = new LGraphNode('TargetNode')
|
||||
@@ -200,6 +266,75 @@ describe('LGraphNode', () => {
|
||||
expect(alreadyDisconnected).toBe(true)
|
||||
})
|
||||
|
||||
test('disconnectOutput(target) keeps callback ordering and payload parity', () => {
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
const targetNode1 = new LGraphNode('TargetNode1')
|
||||
const targetNode2 = new LGraphNode('TargetNode2')
|
||||
sourceNode.addOutput('Output1', 'number')
|
||||
targetNode1.addInput('Input1', 'number')
|
||||
targetNode2.addInput('Input1', 'number')
|
||||
|
||||
const graph = new LGraph()
|
||||
graph.add(sourceNode)
|
||||
graph.add(targetNode1)
|
||||
graph.add(targetNode2)
|
||||
|
||||
const targetLink = sourceNode.connect(0, targetNode1, 0)
|
||||
sourceNode.connect(0, targetNode2, 0)
|
||||
if (!targetLink) throw new Error('Expected target link')
|
||||
targetNode1.inputs[0].widget = { name: 'in-widget' }
|
||||
|
||||
const callbackOrder: string[] = []
|
||||
graph.onTrigger = (event) => {
|
||||
if (event.type !== 'node:slot-links:changed') return
|
||||
|
||||
callbackOrder.push(`trigger:${event.type}:${event.connected}`)
|
||||
expect(event.type).toBe('node:slot-links:changed')
|
||||
expect(event.nodeId).toBe(targetNode1.id)
|
||||
expect(event.slotType).toBe(NodeSlotType.INPUT)
|
||||
expect(event.slotIndex).toBe(0)
|
||||
expect(event.connected).toBe(false)
|
||||
expect(event.linkId).toBe(targetLink.id)
|
||||
}
|
||||
|
||||
targetNode1.onConnectionsChange = (
|
||||
slotType,
|
||||
slotIndex,
|
||||
connected,
|
||||
linkInfo
|
||||
) => {
|
||||
if (!linkInfo) throw new Error('Expected link info')
|
||||
callbackOrder.push(`target:${slotType}:${slotIndex}:${connected}`)
|
||||
expect(slotType).toBe(NodeSlotType.INPUT)
|
||||
expect(slotIndex).toBe(0)
|
||||
expect(connected).toBe(false)
|
||||
expect(linkInfo.id).toBe(targetLink.id)
|
||||
}
|
||||
|
||||
sourceNode.onConnectionsChange = (
|
||||
slotType,
|
||||
slotIndex,
|
||||
connected,
|
||||
linkInfo
|
||||
) => {
|
||||
if (!linkInfo) throw new Error('Expected link info')
|
||||
callbackOrder.push(`source:${slotType}:${slotIndex}:${connected}`)
|
||||
expect(slotType).toBe(NodeSlotType.OUTPUT)
|
||||
expect(slotIndex).toBe(0)
|
||||
expect(connected).toBe(false)
|
||||
expect(linkInfo.id).toBe(targetLink.id)
|
||||
}
|
||||
|
||||
const disconnected = sourceNode.disconnectOutput(0, targetNode1)
|
||||
|
||||
expect(disconnected).toBe(true)
|
||||
expect(callbackOrder).toEqual([
|
||||
'trigger:node:slot-links:changed:false',
|
||||
`target:${NodeSlotType.INPUT}:0:false`,
|
||||
`source:${NodeSlotType.OUTPUT}:0:false`
|
||||
])
|
||||
})
|
||||
|
||||
test('should disconnect output correctly', () => {
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
const targetNode1 = new LGraphNode('TargetNode1')
|
||||
@@ -310,6 +445,78 @@ describe('LGraphNode', () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe('Connect Characterization', () => {
|
||||
test('connect keeps callback ordering and payload parity', () => {
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
const targetNode = new LGraphNode('TargetNode')
|
||||
sourceNode.addOutput('Output1', 'number')
|
||||
targetNode.addInput('Input1', 'number')
|
||||
|
||||
const graph = new LGraph()
|
||||
graph.add(sourceNode)
|
||||
graph.add(targetNode)
|
||||
|
||||
const callbackOrder: string[] = []
|
||||
const sourceOutput = sourceNode.outputs[0]
|
||||
const targetInput = targetNode.inputs[0]
|
||||
targetInput.widget = { name: 'in-widget' }
|
||||
|
||||
graph.onTrigger = (event) => {
|
||||
if (event.type !== 'node:slot-links:changed') return
|
||||
|
||||
callbackOrder.push(`trigger:${event.type}:${event.connected}`)
|
||||
expect(event.type).toBe('node:slot-links:changed')
|
||||
expect(event.nodeId).toBe(targetNode.id)
|
||||
expect(event.slotType).toBe(NodeSlotType.INPUT)
|
||||
expect(event.slotIndex).toBe(0)
|
||||
expect(event.connected).toBe(true)
|
||||
}
|
||||
|
||||
sourceNode.onConnectionsChange = (
|
||||
slotType,
|
||||
slotIndex,
|
||||
connected,
|
||||
linkInfo,
|
||||
ioSlot
|
||||
) => {
|
||||
if (!linkInfo) throw new Error('Expected link info')
|
||||
callbackOrder.push(`source:${slotType}:${slotIndex}:${connected}`)
|
||||
expect(slotType).toBe(NodeSlotType.OUTPUT)
|
||||
expect(slotIndex).toBe(0)
|
||||
expect(connected).toBe(true)
|
||||
expect(linkInfo.origin_id).toBe(sourceNode.id)
|
||||
expect(linkInfo.target_id).toBe(targetNode.id)
|
||||
expect(ioSlot).toBe(sourceOutput)
|
||||
}
|
||||
|
||||
targetNode.onConnectionsChange = (
|
||||
slotType,
|
||||
slotIndex,
|
||||
connected,
|
||||
linkInfo,
|
||||
ioSlot
|
||||
) => {
|
||||
if (!linkInfo) throw new Error('Expected link info')
|
||||
callbackOrder.push(`target:${slotType}:${slotIndex}:${connected}`)
|
||||
expect(slotType).toBe(NodeSlotType.INPUT)
|
||||
expect(slotIndex).toBe(0)
|
||||
expect(connected).toBe(true)
|
||||
expect(linkInfo.origin_id).toBe(sourceNode.id)
|
||||
expect(linkInfo.target_id).toBe(targetNode.id)
|
||||
expect(ioSlot).toBe(targetInput)
|
||||
}
|
||||
|
||||
const link = sourceNode.connect(0, targetNode, 0)
|
||||
|
||||
expect(link).toBeDefined()
|
||||
expect(callbackOrder).toEqual([
|
||||
'trigger:node:slot-links:changed:true',
|
||||
`source:${NodeSlotType.OUTPUT}:0:true`,
|
||||
`target:${NodeSlotType.INPUT}:0:true`
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getInputPos and getOutputPos', () => {
|
||||
test('should handle collapsed nodes correctly', () => {
|
||||
const node = createMockLGraphNodeWithArrayBoundingRect('TestNode')
|
||||
|
||||
@@ -10,11 +10,7 @@ import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMuta
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import type { ColorAdjustOptions } from '@/utils/colorUtil'
|
||||
import {
|
||||
commonType,
|
||||
isNodeBindable,
|
||||
toClass
|
||||
} from '@/lib/litegraph/src/utils/type'
|
||||
import { isNodeBindable, toClass } from '@/lib/litegraph/src/utils/type'
|
||||
|
||||
import { SUBGRAPH_OUTPUT_ID } from '@/lib/litegraph/src/constants'
|
||||
import type { DragAndScale } from './DragAndScale'
|
||||
@@ -27,6 +23,7 @@ import { LLink } from './LLink'
|
||||
import type { Reroute, RerouteId } from './Reroute'
|
||||
import { getNodeInputOnPos, getNodeOutputOnPos } from './canvas/measureSlots'
|
||||
import type { IDrawBoundingOptions } from './draw'
|
||||
import { graphLifecycleEventDispatcher } from './infrastructure/GraphLifecycleEventDispatcher'
|
||||
import { NullGraphError } from './infrastructure/NullGraphError'
|
||||
import type { ReadOnlyRectangle } from './infrastructure/Rectangle'
|
||||
import { Rectangle } from './infrastructure/Rectangle'
|
||||
@@ -2866,8 +2863,6 @@ export class LGraphNode
|
||||
const { graph } = this
|
||||
if (!graph) throw new NullGraphError()
|
||||
|
||||
const layoutMutations = useLayoutMutations()
|
||||
|
||||
const outputIndex = this.outputs.indexOf(output)
|
||||
if (outputIndex === -1) {
|
||||
console.warn('connectSlots: output not found')
|
||||
@@ -2913,84 +2908,23 @@ export class LGraphNode
|
||||
inputNode.disconnectInput(inputIndex, true)
|
||||
}
|
||||
|
||||
const maybeCommonType =
|
||||
input.type && output.type && commonType(input.type, output.type)
|
||||
|
||||
const link = new LLink(
|
||||
++graph.state.lastLinkId,
|
||||
maybeCommonType || input.type || output.type,
|
||||
this.id,
|
||||
const link = graph.connectSlots(
|
||||
this,
|
||||
outputIndex,
|
||||
inputNode.id,
|
||||
inputNode,
|
||||
inputIndex,
|
||||
afterRerouteId
|
||||
)
|
||||
|
||||
// add to graph links list
|
||||
graph._links.set(link.id, link)
|
||||
|
||||
// Register link in Layout Store for spatial tracking
|
||||
layoutMutations.setSource(LayoutSource.Canvas)
|
||||
layoutMutations.createLink(
|
||||
link.id,
|
||||
this.id,
|
||||
outputIndex,
|
||||
inputNode.id,
|
||||
inputIndex
|
||||
)
|
||||
|
||||
// connect in output
|
||||
output.links ??= []
|
||||
output.links.push(link.id)
|
||||
// connect in input
|
||||
const targetInput = inputNode.inputs[inputIndex]
|
||||
targetInput.link = link.id
|
||||
if (targetInput.widget) {
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: inputNode.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: inputIndex,
|
||||
connected: true,
|
||||
linkId: link.id
|
||||
})
|
||||
}
|
||||
|
||||
// Reroutes
|
||||
const reroutes = LLink.getReroutes(graph, link)
|
||||
for (const reroute of reroutes) {
|
||||
reroute.linkIds.add(link.id)
|
||||
if (reroute.floating) reroute.floating = undefined
|
||||
reroute._dragging = undefined
|
||||
}
|
||||
|
||||
// If this is the terminus of a floating link, remove it
|
||||
const lastReroute = reroutes.at(-1)
|
||||
if (lastReroute) {
|
||||
for (const linkId of lastReroute.floatingLinkIds) {
|
||||
const link = graph.floatingLinks.get(linkId)
|
||||
if (link?.parentId === lastReroute.id) {
|
||||
graph.removeFloatingLink(link)
|
||||
}
|
||||
}
|
||||
}
|
||||
graph._version++
|
||||
|
||||
// link has been created now, so its updated
|
||||
this.onConnectionsChange?.(
|
||||
NodeSlotType.OUTPUT,
|
||||
outputIndex,
|
||||
true,
|
||||
link,
|
||||
output
|
||||
)
|
||||
|
||||
inputNode.onConnectionsChange?.(
|
||||
NodeSlotType.INPUT,
|
||||
inputIndex,
|
||||
true,
|
||||
link,
|
||||
input
|
||||
)
|
||||
graphLifecycleEventDispatcher.dispatchConnectNodePair({
|
||||
sourceNode: this,
|
||||
sourceSlotIndex: outputIndex,
|
||||
sourceSlot: output,
|
||||
targetNode: inputNode,
|
||||
targetSlotIndex: inputIndex,
|
||||
targetSlot: input,
|
||||
link
|
||||
})
|
||||
|
||||
this.setDirtyCanvas(false, true)
|
||||
graph.afterChange()
|
||||
@@ -3110,35 +3044,29 @@ export class LGraphNode
|
||||
const input = target.inputs[link_info.target_slot]
|
||||
// remove there
|
||||
input.link = null
|
||||
if (input.widget) {
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: target.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: link_info.target_slot,
|
||||
connected: false,
|
||||
linkId: link_info.id
|
||||
})
|
||||
}
|
||||
graphLifecycleEventDispatcher.dispatchSlotLinkChanged({
|
||||
graph,
|
||||
nodeId: target.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: link_info.target_slot,
|
||||
connected: false,
|
||||
linkId: link_info.id,
|
||||
hasWidget: !!input.widget
|
||||
})
|
||||
|
||||
// remove the link from the links pool
|
||||
link_info.disconnect(graph, 'input')
|
||||
graph.disconnectLink(link_info, 'input')
|
||||
graph._version++
|
||||
|
||||
// link_info hasn't been modified so its ok
|
||||
target.onConnectionsChange?.(
|
||||
NodeSlotType.INPUT,
|
||||
link_info.target_slot,
|
||||
false,
|
||||
link_info,
|
||||
input
|
||||
)
|
||||
this.onConnectionsChange?.(
|
||||
NodeSlotType.OUTPUT,
|
||||
slot,
|
||||
false,
|
||||
link_info,
|
||||
output
|
||||
)
|
||||
graphLifecycleEventDispatcher.dispatchDisconnectNodePair({
|
||||
sourceNode: this,
|
||||
sourceSlotIndex: slot,
|
||||
sourceSlot: output,
|
||||
targetNode: target,
|
||||
targetSlotIndex: link_info.target_slot,
|
||||
targetSlot: input,
|
||||
link: link_info
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
@@ -3153,7 +3081,24 @@ export class LGraphNode
|
||||
) {
|
||||
const targetSlot = graph.outputNode.slots[link_info.target_slot]
|
||||
if (targetSlot) {
|
||||
targetSlot.linkIds.length = 0
|
||||
graph.disconnectSubgraphOutputLink(
|
||||
targetSlot,
|
||||
this,
|
||||
slot,
|
||||
link_info
|
||||
)
|
||||
// Compat: onConnectionsChange now fires for subgraph output
|
||||
// disconnects (previously did not). Extensions should handle
|
||||
// OUTPUT/disconnected callbacks idempotently.
|
||||
graphLifecycleEventDispatcher.dispatchNodeConnectionChange({
|
||||
node: this,
|
||||
slotType: NodeSlotType.OUTPUT,
|
||||
slotIndex: slot,
|
||||
connected: false,
|
||||
link: link_info,
|
||||
slot: output
|
||||
})
|
||||
continue
|
||||
} else {
|
||||
console.error('Missing subgraphOutput slot when disconnecting link')
|
||||
}
|
||||
@@ -3166,35 +3111,39 @@ export class LGraphNode
|
||||
const input = target.inputs[link_info.target_slot]
|
||||
// remove other side link
|
||||
input.link = null
|
||||
if (input.widget) {
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: target.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: link_info.target_slot,
|
||||
connected: false,
|
||||
linkId: link_info.id
|
||||
})
|
||||
}
|
||||
graphLifecycleEventDispatcher.dispatchSlotLinkChanged({
|
||||
graph,
|
||||
nodeId: target.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: link_info.target_slot,
|
||||
connected: false,
|
||||
linkId: link_info.id,
|
||||
hasWidget: !!input.widget
|
||||
})
|
||||
|
||||
// link_info hasn't been modified so its ok
|
||||
target.onConnectionsChange?.(
|
||||
NodeSlotType.INPUT,
|
||||
link_info.target_slot,
|
||||
false,
|
||||
link_info,
|
||||
input
|
||||
)
|
||||
graph.disconnectLink(link_info, 'input')
|
||||
|
||||
graphLifecycleEventDispatcher.dispatchDisconnectNodePair({
|
||||
sourceNode: this,
|
||||
sourceSlotIndex: slot,
|
||||
sourceSlot: output,
|
||||
targetNode: target,
|
||||
targetSlotIndex: link_info.target_slot,
|
||||
targetSlot: input,
|
||||
link: link_info
|
||||
})
|
||||
} else {
|
||||
graph.disconnectLink(link_info, 'input')
|
||||
|
||||
graphLifecycleEventDispatcher.dispatchNodeConnectionChange({
|
||||
node: this,
|
||||
slotType: NodeSlotType.OUTPUT,
|
||||
slotIndex: slot,
|
||||
connected: false,
|
||||
link: link_info,
|
||||
slot: output
|
||||
})
|
||||
}
|
||||
// remove the link from the links pool
|
||||
link_info.disconnect(graph, 'input')
|
||||
|
||||
this.onConnectionsChange?.(
|
||||
NodeSlotType.OUTPUT,
|
||||
slot,
|
||||
false,
|
||||
link_info,
|
||||
output
|
||||
)
|
||||
}
|
||||
output.links = null
|
||||
}
|
||||
@@ -3244,15 +3193,15 @@ export class LGraphNode
|
||||
const link_id = this.inputs[slot].link
|
||||
if (link_id != null) {
|
||||
this.inputs[slot].link = null
|
||||
if (input.widget) {
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: this.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: slot,
|
||||
connected: false,
|
||||
linkId: link_id
|
||||
})
|
||||
}
|
||||
graphLifecycleEventDispatcher.dispatchSlotLinkChanged({
|
||||
graph,
|
||||
nodeId: this.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: slot,
|
||||
connected: false,
|
||||
linkId: link_id,
|
||||
hasWidget: !!input.widget
|
||||
})
|
||||
|
||||
// remove other side
|
||||
const link_info = graph._links.get(link_id)
|
||||
@@ -3287,23 +3236,18 @@ export class LGraphNode
|
||||
}
|
||||
}
|
||||
|
||||
link_info.disconnect(graph, keepReroutes ? 'output' : undefined)
|
||||
graph.disconnectLink(link_info, keepReroutes ? 'output' : undefined)
|
||||
if (graph) graph._version++
|
||||
|
||||
this.onConnectionsChange?.(
|
||||
NodeSlotType.INPUT,
|
||||
slot,
|
||||
false,
|
||||
link_info,
|
||||
input
|
||||
)
|
||||
target_node.onConnectionsChange?.(
|
||||
NodeSlotType.OUTPUT,
|
||||
i,
|
||||
false,
|
||||
link_info,
|
||||
output
|
||||
)
|
||||
graphLifecycleEventDispatcher.dispatchDisconnectNodePair({
|
||||
sourceNode: target_node,
|
||||
sourceSlotIndex: link_info.origin_slot,
|
||||
sourceSlot: output,
|
||||
targetNode: this,
|
||||
targetSlotIndex: slot,
|
||||
targetSlot: input,
|
||||
link: link_info
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect } from 'vitest'
|
||||
|
||||
import { LLink } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
|
||||
import { useLinkStore } from '@/stores/linkStore'
|
||||
|
||||
import { test } from './__fixtures__/testExtensions'
|
||||
|
||||
@@ -14,4 +15,61 @@ describe('LLink', () => {
|
||||
const link = new LLink(1, 'float', 4, 2, 5, 3)
|
||||
expect(link.serialize()).toMatchSnapshot('Basic')
|
||||
})
|
||||
|
||||
test('origin and target lookups resolve via getLink accessor', () => {
|
||||
const graph = new LGraph()
|
||||
const originNode = new LGraphNode('origin')
|
||||
originNode.addOutput('out', 'number')
|
||||
const targetNode = new LGraphNode('target')
|
||||
targetNode.addInput('in', 'number')
|
||||
|
||||
graph.add(originNode)
|
||||
graph.add(targetNode)
|
||||
|
||||
originNode.connect(0, targetNode, 0)
|
||||
const linkId = originNode.outputs[0].links?.[0]
|
||||
if (linkId == null) throw new Error('Expected link ID')
|
||||
|
||||
const linkStore = useLinkStore()
|
||||
const projectedLinks = new Map(graph.links)
|
||||
linkStore.rehydrate(graph.linkStoreKey, {
|
||||
links: projectedLinks,
|
||||
floatingLinks: graph.floatingLinks,
|
||||
reroutes: graph.reroutes
|
||||
})
|
||||
graph.links.clear()
|
||||
|
||||
expect(LLink.getOriginNode(graph, linkId)).toBe(originNode)
|
||||
expect(LLink.getTargetNode(graph, linkId)).toBe(targetNode)
|
||||
})
|
||||
|
||||
test('resolveMany resolves links via projected accessor', () => {
|
||||
const graph = new LGraph()
|
||||
const originNode = new LGraphNode('origin')
|
||||
originNode.addOutput('out', 'number')
|
||||
|
||||
const targetNode = new LGraphNode('target')
|
||||
targetNode.addInput('in', 'number')
|
||||
|
||||
graph.add(originNode)
|
||||
graph.add(targetNode)
|
||||
|
||||
originNode.connect(0, targetNode, 0)
|
||||
const linkId = originNode.outputs[0].links?.[0]
|
||||
if (linkId == null) throw new Error('Expected link ID')
|
||||
|
||||
const linkStore = useLinkStore()
|
||||
const projectedLinks = new Map(graph.links)
|
||||
linkStore.rehydrate(graph.linkStoreKey, {
|
||||
links: projectedLinks,
|
||||
floatingLinks: graph.floatingLinks,
|
||||
reroutes: graph.reroutes
|
||||
})
|
||||
graph.links.clear()
|
||||
|
||||
const [resolved] = LLink.resolveMany([linkId], graph)
|
||||
expect(resolved?.link).toBe(projectedLinks.get(linkId))
|
||||
expect(resolved?.outputNode).toBe(originNode)
|
||||
expect(resolved?.inputNode).toBe(targetNode)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -244,7 +244,7 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
|
||||
network: BasicReadonlyNetwork,
|
||||
linkId: LinkId
|
||||
): LGraphNode | undefined {
|
||||
const id = network.links.get(linkId)?.origin_id
|
||||
const id = network.getLink(linkId)?.origin_id
|
||||
return network.getNodeById(id) ?? undefined
|
||||
}
|
||||
|
||||
@@ -258,7 +258,7 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
|
||||
network: BasicReadonlyNetwork,
|
||||
linkId: LinkId
|
||||
): LGraphNode | undefined {
|
||||
const id = network.links.get(linkId)?.target_id
|
||||
const id = network.getLink(linkId)?.target_id
|
||||
return network.getNodeById(id) ?? undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ export class Reroute
|
||||
const linkId = this.linkIds.values().next().value
|
||||
return linkId === undefined
|
||||
? undefined
|
||||
: this.network.deref()?.links.get(linkId)
|
||||
: this.network.deref()?.getLink(linkId)
|
||||
}
|
||||
|
||||
get firstFloatingLink(): LLink | undefined {
|
||||
|
||||
@@ -38,14 +38,16 @@ interface TestContext {
|
||||
const test = baseTest.extend<TestContext>({
|
||||
network: async ({}, use) => {
|
||||
const graph = new LGraph()
|
||||
const links = new Map<number, LLink>()
|
||||
const floatingLinks = new Map<number, LLink>()
|
||||
const reroutes = new Map<number, Reroute>()
|
||||
|
||||
await use({
|
||||
links: new Map<number, LLink>(),
|
||||
links,
|
||||
reroutes,
|
||||
floatingLinks,
|
||||
getLink: graph.getLink.bind(graph),
|
||||
getLink: ((id: number | null | undefined) =>
|
||||
id == null ? undefined : links.get(id)) as LinkNetwork['getLink'],
|
||||
getNodeById: (id: number) => graph.getNodeById(id),
|
||||
addFloatingLink: (link: LLink) => {
|
||||
floatingLinks.set(link.id, link)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LinkId, LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { SubgraphIO } from '@/lib/litegraph/src/types/serialisation'
|
||||
|
||||
type SlotConnection = INodeInputSlot | INodeOutputSlot | SubgraphIO
|
||||
|
||||
function dispatchSlotLinkChanged(params: {
|
||||
graph: LGraph
|
||||
nodeId: NodeId
|
||||
slotType: NodeSlotType
|
||||
slotIndex: number
|
||||
connected: boolean
|
||||
linkId: LinkId
|
||||
hasWidget: boolean
|
||||
}): void {
|
||||
const { graph, nodeId, slotType, slotIndex, connected, linkId, hasWidget } =
|
||||
params
|
||||
if (!hasWidget) return
|
||||
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId,
|
||||
slotType,
|
||||
slotIndex,
|
||||
connected,
|
||||
linkId
|
||||
})
|
||||
}
|
||||
|
||||
function dispatchNodeConnectionChange(params: {
|
||||
node: LGraphNode | undefined
|
||||
slotType: NodeSlotType
|
||||
slotIndex: number
|
||||
connected: boolean
|
||||
link: LLink | undefined
|
||||
slot: SlotConnection
|
||||
}): void {
|
||||
const { node, slotType, slotIndex, connected, link, slot } = params
|
||||
node?.onConnectionsChange?.(slotType, slotIndex, connected, link, slot)
|
||||
}
|
||||
|
||||
// Dispatch ordering: connect fires OUTPUT→INPUT; disconnect fires INPUT→OUTPUT
|
||||
// (LIFO-style teardown). disconnect accepts SubgraphIO as targetSlot because
|
||||
// subgraph output nodes act as inputs inside the subgraph and are passed
|
||||
// directly from the disconnect callsite.
|
||||
function dispatchConnectNodePair(params: {
|
||||
sourceNode: LGraphNode
|
||||
sourceSlotIndex: number
|
||||
sourceSlot: INodeOutputSlot
|
||||
targetNode: LGraphNode
|
||||
targetSlotIndex: number
|
||||
targetSlot: INodeInputSlot
|
||||
link: LLink
|
||||
}): void {
|
||||
const {
|
||||
sourceNode,
|
||||
sourceSlotIndex,
|
||||
sourceSlot,
|
||||
targetNode,
|
||||
targetSlotIndex,
|
||||
targetSlot,
|
||||
link
|
||||
} = params
|
||||
|
||||
dispatchNodeConnectionChange({
|
||||
node: sourceNode,
|
||||
slotType: NodeSlotType.OUTPUT,
|
||||
slotIndex: sourceSlotIndex,
|
||||
connected: true,
|
||||
link,
|
||||
slot: sourceSlot
|
||||
})
|
||||
dispatchNodeConnectionChange({
|
||||
node: targetNode,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: targetSlotIndex,
|
||||
connected: true,
|
||||
link,
|
||||
slot: targetSlot
|
||||
})
|
||||
}
|
||||
|
||||
function dispatchDisconnectNodePair(params: {
|
||||
sourceNode: LGraphNode
|
||||
sourceSlotIndex: number
|
||||
sourceSlot: INodeOutputSlot
|
||||
targetNode: LGraphNode
|
||||
targetSlotIndex: number
|
||||
targetSlot: INodeInputSlot | SubgraphIO
|
||||
link: LLink
|
||||
}): void {
|
||||
const {
|
||||
sourceNode,
|
||||
sourceSlotIndex,
|
||||
sourceSlot,
|
||||
targetNode,
|
||||
targetSlotIndex,
|
||||
targetSlot,
|
||||
link
|
||||
} = params
|
||||
|
||||
dispatchNodeConnectionChange({
|
||||
node: targetNode,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: targetSlotIndex,
|
||||
connected: false,
|
||||
link,
|
||||
slot: targetSlot
|
||||
})
|
||||
dispatchNodeConnectionChange({
|
||||
node: sourceNode,
|
||||
slotType: NodeSlotType.OUTPUT,
|
||||
slotIndex: sourceSlotIndex,
|
||||
connected: false,
|
||||
link,
|
||||
slot: sourceSlot
|
||||
})
|
||||
}
|
||||
|
||||
export const graphLifecycleEventDispatcher = {
|
||||
dispatchSlotLinkChanged,
|
||||
dispatchNodeConnectionChange,
|
||||
dispatchConnectNodePair,
|
||||
dispatchDisconnectNodePair
|
||||
}
|
||||
128
src/lib/litegraph/src/infrastructure/GraphPersistenceAdapter.ts
Normal file
128
src/lib/litegraph/src/infrastructure/GraphPersistenceAdapter.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type {
|
||||
ISerialisedGraph,
|
||||
ISerialisedNode,
|
||||
SerialisableGraph,
|
||||
SerialisableReroute
|
||||
} from '@/lib/litegraph/src/types/serialisation'
|
||||
|
||||
type GraphStateLike = {
|
||||
lastGroupId: number
|
||||
lastLinkId: number
|
||||
lastNodeId: number
|
||||
lastRerouteId: number
|
||||
}
|
||||
|
||||
type LinkNodeEndpoint = {
|
||||
origin_id: NodeId
|
||||
target_id: NodeId
|
||||
}
|
||||
|
||||
type ConfiguredTopology = {
|
||||
links: LLink[]
|
||||
reroutes: SerialisableReroute[] | undefined
|
||||
}
|
||||
|
||||
function mergeStateByMax(
|
||||
state: GraphStateLike,
|
||||
configuredState: SerialisableGraph['state'] | undefined
|
||||
): void {
|
||||
if (!configuredState) return
|
||||
|
||||
const { lastGroupId, lastLinkId, lastNodeId, lastRerouteId } = configuredState
|
||||
if (lastGroupId != null)
|
||||
state.lastGroupId = Math.max(state.lastGroupId, lastGroupId)
|
||||
if (lastLinkId != null)
|
||||
state.lastLinkId = Math.max(state.lastLinkId, lastLinkId)
|
||||
if (lastNodeId != null)
|
||||
state.lastNodeId = Math.max(state.lastNodeId, lastNodeId)
|
||||
if (lastRerouteId != null)
|
||||
state.lastRerouteId = Math.max(state.lastRerouteId, lastRerouteId)
|
||||
}
|
||||
|
||||
function toConfiguredTopology(
|
||||
data: ISerialisedGraph | SerialisableGraph,
|
||||
state: GraphStateLike
|
||||
): ConfiguredTopology {
|
||||
if (data.version === 0.4) {
|
||||
const links = Array.isArray(data.links)
|
||||
? data.links.map((linkData) => LLink.createFromArray(linkData))
|
||||
: []
|
||||
|
||||
const linkMap = new Map(links.map((link) => [link.id, link]))
|
||||
if (Array.isArray(data.extra?.linkExtensions)) {
|
||||
for (const linkExtension of data.extra.linkExtensions) {
|
||||
const link = linkMap.get(linkExtension.id)
|
||||
if (link) link.parentId = linkExtension.parentId
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
links,
|
||||
reroutes: data.extra?.reroutes
|
||||
}
|
||||
}
|
||||
|
||||
mergeStateByMax(state, data.state)
|
||||
return {
|
||||
links: Array.isArray(data.links)
|
||||
? data.links.map((linkData) => LLink.create(linkData))
|
||||
: [],
|
||||
reroutes: data.reroutes
|
||||
}
|
||||
}
|
||||
|
||||
function patchLinkNodeIds(
|
||||
links: Iterable<LinkNodeEndpoint> | undefined,
|
||||
remappedIds: ReadonlyMap<NodeId, NodeId>
|
||||
): void {
|
||||
if (!links) return
|
||||
|
||||
for (const link of links) {
|
||||
const newOriginId = remappedIds.get(link.origin_id)
|
||||
if (newOriginId !== undefined) link.origin_id = newOriginId
|
||||
|
||||
const newTargetId = remappedIds.get(link.target_id)
|
||||
if (newTargetId !== undefined) link.target_id = newTargetId
|
||||
}
|
||||
}
|
||||
|
||||
function remapNodeId(
|
||||
nodeId: string,
|
||||
remappedIds: ReadonlyMap<NodeId, NodeId>
|
||||
): NodeId | undefined {
|
||||
const directMatch = remappedIds.get(nodeId)
|
||||
if (directMatch !== undefined) return directMatch
|
||||
if (!/^-?\d+$/.test(nodeId)) return undefined
|
||||
|
||||
const numericId = Number(nodeId)
|
||||
if (!Number.isSafeInteger(numericId)) return undefined
|
||||
return remappedIds.get(numericId)
|
||||
}
|
||||
|
||||
function remapProxyWidgets(
|
||||
info: ISerialisedNode,
|
||||
remappedIds: ReadonlyMap<NodeId, NodeId> | undefined
|
||||
): void {
|
||||
if (!remappedIds || remappedIds.size === 0) return
|
||||
|
||||
const proxyWidgets = info.properties?.proxyWidgets
|
||||
if (!Array.isArray(proxyWidgets)) return
|
||||
|
||||
for (const entry of proxyWidgets) {
|
||||
if (!Array.isArray(entry)) continue
|
||||
|
||||
const [nodeId] = entry
|
||||
if (typeof nodeId !== 'string' || nodeId === '-1') continue
|
||||
|
||||
const remappedNodeId = remapNodeId(nodeId, remappedIds)
|
||||
if (remappedNodeId !== undefined) entry[0] = String(remappedNodeId)
|
||||
}
|
||||
}
|
||||
|
||||
export const graphPersistenceAdapter = {
|
||||
toConfiguredTopology,
|
||||
patchLinkNodeIds,
|
||||
remapProxyWidgets
|
||||
}
|
||||
@@ -36,12 +36,14 @@ export interface SubgraphEventMap extends LGraphEventMap {
|
||||
index: number
|
||||
oldName: string
|
||||
newName: string
|
||||
canonicalName: string
|
||||
}
|
||||
'renaming-output': {
|
||||
output: SubgraphOutput
|
||||
index: number
|
||||
oldName: string
|
||||
newName: string
|
||||
canonicalName: string
|
||||
}
|
||||
|
||||
'widget-promoted': {
|
||||
|
||||
@@ -107,7 +107,6 @@ export {
|
||||
LGraph,
|
||||
type GroupNodeConfigEntry,
|
||||
type GroupNodeWorkflowData,
|
||||
type LGraphTriggerAction,
|
||||
type LGraphTriggerParam,
|
||||
type GraphAddOptions
|
||||
} from './LGraph'
|
||||
|
||||
@@ -1,17 +1,241 @@
|
||||
// TODO: Fix these tests after migration
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { ToInputFromIoNodeLink } from '@/lib/litegraph/src/canvas/ToInputFromIoNodeLink'
|
||||
import { LinkDirection } from '@/lib/litegraph/src//types/globalEnums'
|
||||
import {
|
||||
SUBGRAPH_INPUT_ID,
|
||||
SUBGRAPH_OUTPUT_ID
|
||||
} from '@/lib/litegraph/src/constants'
|
||||
import {
|
||||
LinkDirection,
|
||||
NodeSlotType
|
||||
} from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { createUuidv4 } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
import { subgraphTest } from './__fixtures__/subgraphFixtures'
|
||||
import {
|
||||
createTestSubgraphData,
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from './__fixtures__/subgraphHelpers'
|
||||
|
||||
describe('SubgraphIO - Slot Identity Normalization', () => {
|
||||
subgraphTest(
|
||||
'adds duplicate input/output names with stable canonical names while preserving display labels',
|
||||
({ simpleSubgraph }) => {
|
||||
const inputA = simpleSubgraph.addInput('duplicate', 'number')
|
||||
const inputB = simpleSubgraph.addInput('duplicate', 'string')
|
||||
const outputA = simpleSubgraph.addOutput('duplicate', 'number')
|
||||
const outputB = simpleSubgraph.addOutput('duplicate', 'string')
|
||||
|
||||
expect(inputA.name).toBe('duplicate')
|
||||
expect(inputA.displayName).toBe('duplicate')
|
||||
expect(inputB.name).toBe(`duplicate__${inputB.id}`)
|
||||
expect(inputB.label).toBe('duplicate')
|
||||
expect(inputB.displayName).toBe('duplicate')
|
||||
|
||||
expect(outputA.name).toBe('duplicate')
|
||||
expect(outputA.displayName).toBe('duplicate')
|
||||
expect(outputB.name).toBe(`duplicate__${outputB.id}`)
|
||||
expect(outputB.label).toBe('duplicate')
|
||||
expect(outputB.displayName).toBe('duplicate')
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'renaming to an existing slot name preserves display labels while assigning stable canonical identities',
|
||||
({ simpleSubgraph }) => {
|
||||
const inputA = simpleSubgraph.addInput('source', 'number')
|
||||
const inputB = simpleSubgraph.addInput('target', 'number')
|
||||
const outputA = simpleSubgraph.addOutput('source', 'number')
|
||||
const outputB = simpleSubgraph.addOutput('target', 'number')
|
||||
|
||||
simpleSubgraph.renameInput(inputB, 'source')
|
||||
simpleSubgraph.renameOutput(outputB, 'source')
|
||||
|
||||
expect(inputA.name).toBe('source')
|
||||
expect(inputB.name).toBe(`source__${inputB.id}`)
|
||||
expect(inputB.displayName).toBe('source')
|
||||
|
||||
expect(outputA.name).toBe('source')
|
||||
expect(outputB.name).toBe(`source__${outputB.id}`)
|
||||
expect(outputB.displayName).toBe('source')
|
||||
}
|
||||
)
|
||||
|
||||
it('normalizes legacy duplicate slot names into stable canonical identities on configure', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const inputIdA = createUuidv4()
|
||||
const inputIdB = createUuidv4()
|
||||
const outputIdA = createUuidv4()
|
||||
const outputIdB = createUuidv4()
|
||||
|
||||
const subgraph = rootGraph.createSubgraph(
|
||||
createTestSubgraphData({
|
||||
inputs: [
|
||||
{ id: inputIdA, name: 'legacy', type: 'number' },
|
||||
{ id: inputIdB, name: 'legacy', type: 'number' }
|
||||
],
|
||||
outputs: [
|
||||
{ id: outputIdA, name: 'legacy', type: 'number' },
|
||||
{ id: outputIdB, name: 'legacy', type: 'number' }
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
expect(subgraph.inputs.map((slot) => slot.name)).toEqual([
|
||||
'legacy',
|
||||
`legacy__${inputIdB}`
|
||||
])
|
||||
expect(subgraph.outputs.map((slot) => slot.name)).toEqual([
|
||||
'legacy',
|
||||
`legacy__${outputIdB}`
|
||||
])
|
||||
expect(subgraph.inputs.map((slot) => slot.displayName)).toEqual([
|
||||
'legacy',
|
||||
'legacy'
|
||||
])
|
||||
expect(subgraph.outputs.map((slot) => slot.displayName)).toEqual([
|
||||
'legacy',
|
||||
'legacy'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubgraphIO - Input Slot Dual-Nature Behavior', () => {
|
||||
subgraphTest(
|
||||
'connect callback payload keeps current subgraph-input asymmetry',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraph } = subgraphWithNode
|
||||
const internalNode = new LGraphNode('Internal Target')
|
||||
internalNode.addInput('in', '*')
|
||||
subgraph.add(internalNode)
|
||||
|
||||
const inputCallback = vi.fn()
|
||||
internalNode.onConnectionsChange = inputCallback
|
||||
|
||||
const subgraphInput = subgraph.inputNode.slots[0]
|
||||
const nodeInput = internalNode.inputs[0]
|
||||
const link = subgraphInput.connect(nodeInput, internalNode)
|
||||
|
||||
expect(link).toBeDefined()
|
||||
expect(link?.origin_id).toBe(SUBGRAPH_INPUT_ID)
|
||||
expect(link?.target_id).toBe(internalNode.id)
|
||||
expect(inputCallback).toHaveBeenCalledTimes(1)
|
||||
expect(inputCallback).toHaveBeenLastCalledWith(
|
||||
NodeSlotType.INPUT,
|
||||
0,
|
||||
true,
|
||||
link,
|
||||
nodeInput
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'disconnect callback payload keeps current subgraph-input asymmetry',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraph } = subgraphWithNode
|
||||
const internalNode = new LGraphNode('Internal Target')
|
||||
internalNode.addInput('in', '*')
|
||||
subgraph.add(internalNode)
|
||||
|
||||
const inputCallback = vi.fn()
|
||||
internalNode.onConnectionsChange = inputCallback
|
||||
|
||||
const subgraphInput = subgraph.inputNode.slots[0]
|
||||
const link = subgraphInput.connect(internalNode.inputs[0], internalNode)
|
||||
if (!link) throw new Error('Expected link')
|
||||
|
||||
new ToInputFromIoNodeLink(
|
||||
subgraph,
|
||||
subgraph.inputNode,
|
||||
subgraphInput,
|
||||
undefined,
|
||||
LinkDirection.CENTER,
|
||||
link
|
||||
).disconnect()
|
||||
|
||||
expect(inputCallback).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
NodeSlotType.INPUT,
|
||||
0,
|
||||
true,
|
||||
link,
|
||||
internalNode.inputs[0]
|
||||
)
|
||||
expect(inputCallback).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
NodeSlotType.INPUT,
|
||||
0,
|
||||
false,
|
||||
link,
|
||||
internalNode.inputs[0]
|
||||
)
|
||||
expect(internalNode.inputs[0].link).toBeNull()
|
||||
expect(subgraphInput.linkIds).toEqual([])
|
||||
expect(subgraph.links.get(link.id)).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'connect lifecycle keeps input-connected event before node callback',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraph } = subgraphWithNode
|
||||
const internalNode = new LGraphNode('Internal Target')
|
||||
internalNode.addInput('in', '*')
|
||||
internalNode.addWidget('number', 'in-widget', 0, null)
|
||||
internalNode.inputs[0].widget = { name: 'in-widget' }
|
||||
subgraph.add(internalNode)
|
||||
|
||||
const subgraphInput = subgraph.inputNode.slots[0]
|
||||
const callbackOrder: string[] = []
|
||||
subgraphInput.events.addEventListener('input-connected', () => {
|
||||
callbackOrder.push('event:input-connected')
|
||||
})
|
||||
internalNode.onConnectionsChange = () => {
|
||||
callbackOrder.push('callback:node-connected')
|
||||
}
|
||||
|
||||
subgraphInput.connect(internalNode.inputs[0], internalNode)
|
||||
|
||||
expect(callbackOrder).toEqual([
|
||||
'event:input-connected',
|
||||
'callback:node-connected'
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'disconnect lifecycle keeps node callback before input-disconnected event',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraph } = subgraphWithNode
|
||||
const internalNode = new LGraphNode('Internal Target')
|
||||
internalNode.addInput('in', '*')
|
||||
subgraph.add(internalNode)
|
||||
|
||||
const subgraphInput = subgraph.inputNode.slots[0]
|
||||
subgraphInput.connect(internalNode.inputs[0], internalNode)
|
||||
|
||||
const callbackOrder: string[] = []
|
||||
internalNode.onConnectionsChange = (...args) => {
|
||||
if (args[2]) return
|
||||
callbackOrder.push('callback:node-disconnected')
|
||||
}
|
||||
subgraphInput.events.addEventListener('input-disconnected', () => {
|
||||
callbackOrder.push('event:input-disconnected')
|
||||
})
|
||||
|
||||
subgraphInput.disconnect()
|
||||
|
||||
expect(callbackOrder).toEqual([
|
||||
'callback:node-disconnected',
|
||||
'event:input-disconnected'
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'input accepts external connections from parent graph',
|
||||
({ subgraphWithNode }) => {
|
||||
@@ -101,6 +325,35 @@ describe('SubgraphIO - Input Slot Dual-Nature Behavior', () => {
|
||||
expect(internalNode.onConnectionsChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
subgraphTest(
|
||||
'disconnects subgraph-input links even when link origin_slot points to a missing slot',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraph } = subgraphWithNode
|
||||
const internalNode = new LGraphNode('Internal Target')
|
||||
internalNode.addInput('in', '*')
|
||||
subgraph.add(internalNode)
|
||||
|
||||
const subgraphInput = subgraph.inputNode.slots[0]
|
||||
const link = subgraphInput.connect(internalNode.inputs[0], internalNode)
|
||||
if (!link) throw new Error('Expected link')
|
||||
|
||||
// Simulate stale legacy/corrupt topology where the slot index no longer resolves.
|
||||
link.origin_slot = 999
|
||||
|
||||
new ToInputFromIoNodeLink(
|
||||
subgraph,
|
||||
subgraph.inputNode,
|
||||
subgraphInput,
|
||||
undefined,
|
||||
LinkDirection.CENTER,
|
||||
link
|
||||
).disconnect()
|
||||
|
||||
expect(internalNode.inputs[0].link).toBeNull()
|
||||
expect(subgraph.links.get(link.id)).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'handles slot renaming with active connections',
|
||||
({ subgraphWithNode }) => {
|
||||
@@ -128,6 +381,75 @@ describe('SubgraphIO - Input Slot Dual-Nature Behavior', () => {
|
||||
})
|
||||
|
||||
describe('SubgraphIO - Output Slot Dual-Nature Behavior', () => {
|
||||
subgraphTest(
|
||||
'connect callback payload keeps current subgraph-output asymmetry',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraph } = subgraphWithNode
|
||||
|
||||
const internalNode = new LGraphNode('Internal Source')
|
||||
internalNode.addOutput('out', '*')
|
||||
subgraph.add(internalNode)
|
||||
|
||||
const outputCallback = vi.fn()
|
||||
internalNode.onConnectionsChange = outputCallback
|
||||
|
||||
const subgraphOutput = subgraph.outputNode.slots[0]
|
||||
const nodeOutput = internalNode.outputs[0]
|
||||
const link = subgraphOutput.connect(nodeOutput, internalNode)
|
||||
|
||||
expect(link).toBeDefined()
|
||||
expect(link?.origin_id).toBe(internalNode.id)
|
||||
expect(link?.target_id).toBe(SUBGRAPH_OUTPUT_ID)
|
||||
expect(outputCallback).toHaveBeenLastCalledWith(
|
||||
NodeSlotType.OUTPUT,
|
||||
0,
|
||||
true,
|
||||
link,
|
||||
nodeOutput
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'disconnect callback payload keeps current subgraph-output asymmetry',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraph } = subgraphWithNode
|
||||
|
||||
const internalNode = new LGraphNode('Internal Source')
|
||||
internalNode.addOutput('out', '*')
|
||||
subgraph.add(internalNode)
|
||||
|
||||
const outputCallback = vi.fn()
|
||||
internalNode.onConnectionsChange = outputCallback
|
||||
|
||||
const subgraphOutput = subgraph.outputNode.slots[0]
|
||||
const link = subgraphOutput.connect(internalNode.outputs[0], internalNode)
|
||||
if (!link) throw new Error('Expected link')
|
||||
|
||||
subgraphOutput.disconnect()
|
||||
|
||||
expect(outputCallback).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
NodeSlotType.OUTPUT,
|
||||
0,
|
||||
true,
|
||||
link,
|
||||
internalNode.outputs[0]
|
||||
)
|
||||
expect(outputCallback).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
NodeSlotType.OUTPUT,
|
||||
0,
|
||||
false,
|
||||
link,
|
||||
subgraphOutput
|
||||
)
|
||||
expect(subgraph.links.get(link.id)).toBeUndefined()
|
||||
expect(subgraphOutput.linkIds).toEqual([])
|
||||
expect(internalNode.outputs[0].links).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'output provides connections to parent graph',
|
||||
({ subgraphWithNode }) => {
|
||||
@@ -218,6 +540,54 @@ describe('SubgraphIO - Output Slot Dual-Nature Behavior', () => {
|
||||
expect(subgraph.outputs[0].displayName).toBe('new_name')
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'cleans stale subgraph-output linkIds while disconnecting active output links',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraph } = subgraphWithNode
|
||||
|
||||
const internalNode = new LGraphNode('Internal Source')
|
||||
internalNode.addOutput('out', '*')
|
||||
subgraph.add(internalNode)
|
||||
|
||||
const subgraphOutput = subgraph.outputNode.slots[0]
|
||||
const link = subgraphOutput.connect(internalNode.outputs[0], internalNode)
|
||||
if (!link) throw new Error('Expected link')
|
||||
|
||||
// Simulate stale/corrupt bookkeeping where a dead link id remains.
|
||||
const staleLinkId = 999_999
|
||||
subgraphOutput.linkIds.push(staleLinkId)
|
||||
|
||||
subgraphOutput.disconnect()
|
||||
|
||||
expect(subgraphOutput.linkIds).toEqual([])
|
||||
expect(subgraph.links.get(link.id)).toBeUndefined()
|
||||
expect(internalNode.outputs[0].links).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'disconnect is idempotent and keeps output endpoints detached',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraph } = subgraphWithNode
|
||||
|
||||
const internalNode = new LGraphNode('Internal Source')
|
||||
internalNode.addOutput('out', '*')
|
||||
subgraph.add(internalNode)
|
||||
|
||||
const subgraphOutput = subgraph.outputNode.slots[0]
|
||||
const link = subgraphOutput.connect(internalNode.outputs[0], internalNode)
|
||||
if (!link) throw new Error('Expected link')
|
||||
|
||||
subgraphOutput.disconnect()
|
||||
subgraphOutput.disconnect()
|
||||
|
||||
expect(subgraph.getLink(link.id)).toBeUndefined()
|
||||
expect(subgraphOutput.getLinks()).toEqual([])
|
||||
expect(subgraphOutput.linkIds).toEqual([])
|
||||
expect(internalNode.outputs[0].links).toEqual([])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('SubgraphIO - Boundary Connection Management', () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
|
||||
import { CustomEventTarget } from '@/lib/litegraph/src/infrastructure/CustomEventTarget'
|
||||
import { graphLifecycleEventDispatcher } from '@/lib/litegraph/src/infrastructure/GraphLifecycleEventDispatcher'
|
||||
import type { SubgraphInputEventMap } from '@/lib/litegraph/src/infrastructure/SubgraphInputEventMap'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
@@ -97,44 +98,21 @@ export class SubgraphInput extends SubgraphSlot {
|
||||
})
|
||||
}
|
||||
|
||||
const link = new LLink(
|
||||
++subgraph.state.lastLinkId,
|
||||
slot.type,
|
||||
this.parent.id,
|
||||
this.parent.slots.indexOf(this),
|
||||
node.id,
|
||||
const link = subgraph.connectSubgraphInputSlot(
|
||||
this,
|
||||
node,
|
||||
inputIndex,
|
||||
afterRerouteId
|
||||
)
|
||||
|
||||
// Add to graph links list
|
||||
subgraph._links.set(link.id, link)
|
||||
|
||||
// Set link ID in each slot
|
||||
this.linkIds.push(link.id)
|
||||
slot.link = link.id
|
||||
|
||||
// Reroutes
|
||||
const reroutes = LLink.getReroutes(subgraph, link)
|
||||
for (const reroute of reroutes) {
|
||||
reroute.linkIds.add(link.id)
|
||||
if (reroute.floating) delete reroute.floating
|
||||
reroute._dragging = undefined
|
||||
}
|
||||
|
||||
// If this is the terminus of a floating link, remove it
|
||||
const lastReroute = reroutes.at(-1)
|
||||
if (lastReroute) {
|
||||
for (const linkId of lastReroute.floatingLinkIds) {
|
||||
const link = subgraph.floatingLinks.get(linkId)
|
||||
if (link?.parentId === lastReroute.id) {
|
||||
subgraph.removeFloatingLink(link)
|
||||
}
|
||||
}
|
||||
}
|
||||
subgraph._version++
|
||||
|
||||
node.onConnectionsChange?.(NodeSlotType.INPUT, inputIndex, true, link, slot)
|
||||
graphLifecycleEventDispatcher.dispatchNodeConnectionChange({
|
||||
node,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: inputIndex,
|
||||
connected: true,
|
||||
link,
|
||||
slot
|
||||
})
|
||||
|
||||
subgraph.afterChange()
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
|
||||
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
|
||||
import { SUBGRAPH_INPUT_ID } from '@/lib/litegraph/src/constants'
|
||||
import { graphLifecycleEventDispatcher } from '@/lib/litegraph/src/infrastructure/GraphLifecycleEventDispatcher'
|
||||
import type {
|
||||
DefaultConnectionColors,
|
||||
INodeInputSlot,
|
||||
@@ -91,31 +92,6 @@ export class SubgraphInputNode
|
||||
return inputNode.canConnectTo(this, input, fromSlot)
|
||||
}
|
||||
|
||||
connectSlots(
|
||||
fromSlot: SubgraphInput,
|
||||
inputNode: LGraphNode,
|
||||
input: INodeInputSlot,
|
||||
afterRerouteId: RerouteId | undefined
|
||||
): LLink {
|
||||
const { subgraph } = this
|
||||
|
||||
const outputIndex = this.slots.indexOf(fromSlot)
|
||||
const inputIndex = inputNode.inputs.indexOf(input)
|
||||
|
||||
if (outputIndex === -1 || inputIndex === -1)
|
||||
throw new Error('Invalid slot indices.')
|
||||
|
||||
return new LLink(
|
||||
++subgraph.state.lastLinkId,
|
||||
input.type || fromSlot.type,
|
||||
this.id,
|
||||
outputIndex,
|
||||
inputNode.id,
|
||||
inputIndex,
|
||||
afterRerouteId
|
||||
)
|
||||
}
|
||||
|
||||
// #region Legacy LGraphNode compatibility
|
||||
|
||||
connectByType(
|
||||
@@ -170,52 +146,57 @@ export class SubgraphInputNode
|
||||
): void {
|
||||
const { subgraph } = this
|
||||
|
||||
// Break floating links
|
||||
if (input._floatingLinks?.size) {
|
||||
for (const link of input._floatingLinks) {
|
||||
subgraph.removeFloatingLink(link)
|
||||
}
|
||||
const slotIndex = node.inputs.findIndex((inp) => inp === input)
|
||||
if (slotIndex === -1) {
|
||||
console.warn('disconnectNodeInput: target input slot not found', this)
|
||||
return
|
||||
}
|
||||
|
||||
input.link = null
|
||||
subgraph.setDirtyCanvas(false, true)
|
||||
|
||||
if (!link) return
|
||||
|
||||
const subgraphInputIndex = link.origin_slot
|
||||
link.disconnect(subgraph, 'output')
|
||||
subgraph._version++
|
||||
|
||||
const subgraphInput = this.slots.at(subgraphInputIndex)
|
||||
const subgraphInput = link ? this.slots.at(link.origin_slot) : undefined
|
||||
if (!subgraphInput) {
|
||||
console.warn(
|
||||
'disconnectNodeInput: subgraphInput not found',
|
||||
this,
|
||||
subgraphInputIndex
|
||||
link?.origin_slot
|
||||
)
|
||||
|
||||
if (input._floatingLinks?.size) {
|
||||
for (const floatingLink of input._floatingLinks) {
|
||||
subgraph.removeFloatingLink(floatingLink)
|
||||
}
|
||||
}
|
||||
|
||||
input.link = null
|
||||
if (link) {
|
||||
subgraph.disconnectLink(link, 'output')
|
||||
subgraph._version++
|
||||
}
|
||||
subgraph.setDirtyCanvas(false, true)
|
||||
graphLifecycleEventDispatcher.dispatchNodeConnectionChange({
|
||||
node,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex,
|
||||
connected: false,
|
||||
link,
|
||||
slot: input
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// search in the inputs list for this link
|
||||
const index = subgraphInput.linkIds.indexOf(link.id)
|
||||
if (index !== -1) {
|
||||
subgraphInput.linkIds.splice(index, 1)
|
||||
} else {
|
||||
console.warn(
|
||||
'disconnectNodeInput: link ID not found in subgraphInput linkIds',
|
||||
link.id
|
||||
)
|
||||
}
|
||||
const slotIndex = node.inputs.findIndex((inp) => inp === input)
|
||||
if (slotIndex !== -1) {
|
||||
node.onConnectionsChange?.(
|
||||
NodeSlotType.INPUT,
|
||||
slotIndex,
|
||||
false,
|
||||
link,
|
||||
subgraphInput
|
||||
)
|
||||
}
|
||||
subgraph.disconnectSubgraphInputLink(subgraphInput, node, slotIndex, link)
|
||||
subgraph.setDirtyCanvas(false, true)
|
||||
|
||||
// Compat: onConnectionsChange 5th arg is now the INodeInputSlot (previously
|
||||
// passed the SubgraphInput). Extensions relying on the old type should
|
||||
// adapt to accept INodeInputSlot.
|
||||
graphLifecycleEventDispatcher.dispatchNodeConnectionChange({
|
||||
node,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex,
|
||||
connected: false,
|
||||
link,
|
||||
slot: input
|
||||
})
|
||||
}
|
||||
|
||||
override drawProtected(
|
||||
|
||||
@@ -174,7 +174,8 @@ describe.skip('SubgraphNode Synchronization', () => {
|
||||
input: subgraph.inputs[0],
|
||||
index: 0,
|
||||
oldName: 'oldName',
|
||||
newName: 'newName'
|
||||
newName: 'newName',
|
||||
canonicalName: 'newName'
|
||||
})
|
||||
|
||||
expect(subgraphNode.inputs[0].label).toBe('newName')
|
||||
@@ -185,7 +186,8 @@ describe.skip('SubgraphNode Synchronization', () => {
|
||||
output: subgraph.outputs[0],
|
||||
index: 0,
|
||||
oldName: 'oldOutput',
|
||||
newName: 'newOutput'
|
||||
newName: 'newOutput',
|
||||
canonicalName: 'newOutput'
|
||||
})
|
||||
|
||||
expect(subgraphNode.outputs[0].label).toBe('newOutput')
|
||||
|
||||
@@ -380,7 +380,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
const existingInput = this.inputs.find((i) => i.name === name)
|
||||
if (existingInput) {
|
||||
const linkId = subgraphInput.linkIds[0]
|
||||
const { inputNode, input } = subgraph.links[linkId].resolve(subgraph)
|
||||
const link = subgraph.links.get(linkId)
|
||||
if (!link) return
|
||||
|
||||
const { inputNode, input } = link.resolve(subgraph)
|
||||
const widget = inputNode?.widgets?.find?.((w) => w.name === name)
|
||||
if (widget && inputNode)
|
||||
this._setWidget(
|
||||
@@ -430,14 +433,18 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
{ signal }
|
||||
)
|
||||
|
||||
// Compat: `.name` now changes on rename (previously only `.label` changed).
|
||||
// Extensions that key on `input.name` should be aware of this.
|
||||
subgraphEvents.addEventListener(
|
||||
'renaming-input',
|
||||
(e) => {
|
||||
const { index, newName } = e.detail
|
||||
const { index, newName, canonicalName } = e.detail
|
||||
const input = this.inputs.at(index)
|
||||
if (!input) throw new Error('Subgraph input not found')
|
||||
|
||||
input.name = canonicalName
|
||||
input.label = newName
|
||||
if (input.widget) input.widget.name = input.name
|
||||
if (input._widget) {
|
||||
input._widget.label = newName
|
||||
}
|
||||
@@ -448,10 +455,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
subgraphEvents.addEventListener(
|
||||
'renaming-output',
|
||||
(e) => {
|
||||
const { index, newName } = e.detail
|
||||
const { index, newName, canonicalName } = e.detail
|
||||
const output = this.outputs.at(index)
|
||||
if (!output) throw new Error('Subgraph output not found')
|
||||
|
||||
output.name = canonicalName
|
||||
output.label = newName
|
||||
},
|
||||
{ signal }
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { pull } from 'es-toolkit/compat'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
|
||||
import { graphLifecycleEventDispatcher } from '@/lib/litegraph/src/infrastructure/GraphLifecycleEventDispatcher'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
@@ -11,6 +10,7 @@ import type {
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { warnDeprecated } from '@/lib/litegraph/src/utils/feedback'
|
||||
|
||||
import type { SubgraphInput } from './SubgraphInput'
|
||||
import type { SubgraphOutputNode } from './SubgraphOutputNode'
|
||||
@@ -52,66 +52,53 @@ export class SubgraphOutput extends SubgraphSlot {
|
||||
)
|
||||
return
|
||||
|
||||
// Link should not be present, but just in case, disconnect it
|
||||
const existingLink = this.getLinks().at(0)
|
||||
if (existingLink != null) {
|
||||
subgraph.beforeChange()
|
||||
subgraph.beforeChange()
|
||||
try {
|
||||
// Link should not be present, but just in case, disconnect it
|
||||
const existingLink = this.getLinks().at(0)
|
||||
if (existingLink != null) {
|
||||
const { outputNode } = existingLink.resolve(subgraph)
|
||||
if (!outputNode)
|
||||
throw new Error('Expected output node for existing link')
|
||||
|
||||
existingLink.disconnect(subgraph, 'input')
|
||||
const resolved = existingLink.resolve(subgraph)
|
||||
const links = resolved.output?.links
|
||||
if (links) pull(links, existingLink.id)
|
||||
}
|
||||
subgraph.disconnectSubgraphOutputLink(
|
||||
this,
|
||||
outputNode,
|
||||
existingLink.origin_slot,
|
||||
existingLink
|
||||
)
|
||||
|
||||
const link = new LLink(
|
||||
++subgraph.state.lastLinkId,
|
||||
slot.type,
|
||||
node.id,
|
||||
outputIndex,
|
||||
this.parent.id,
|
||||
this.parent.slots.indexOf(this),
|
||||
afterRerouteId
|
||||
)
|
||||
|
||||
// Add to graph links list
|
||||
subgraph._links.set(link.id, link)
|
||||
|
||||
// Set link ID in each slot
|
||||
this.linkIds[0] = link.id
|
||||
slot.links ??= []
|
||||
slot.links.push(link.id)
|
||||
|
||||
// Reroutes
|
||||
const reroutes = LLink.getReroutes(subgraph, link)
|
||||
for (const reroute of reroutes) {
|
||||
reroute.linkIds.add(link.id)
|
||||
if (reroute.floating) delete reroute.floating
|
||||
reroute._dragging = undefined
|
||||
}
|
||||
|
||||
// If this is the terminus of a floating link, remove it
|
||||
const lastReroute = reroutes.at(-1)
|
||||
if (lastReroute) {
|
||||
for (const linkId of lastReroute.floatingLinkIds) {
|
||||
const link = subgraph.floatingLinks.get(linkId)
|
||||
if (link?.parentId === lastReroute.id) {
|
||||
subgraph.removeFloatingLink(link)
|
||||
}
|
||||
graphLifecycleEventDispatcher.dispatchNodeConnectionChange({
|
||||
node: outputNode,
|
||||
slotType: NodeSlotType.OUTPUT,
|
||||
slotIndex: existingLink.origin_slot,
|
||||
connected: false,
|
||||
link: existingLink,
|
||||
slot: this
|
||||
})
|
||||
}
|
||||
|
||||
const link = subgraph.connectSubgraphOutputSlot(
|
||||
node,
|
||||
outputIndex,
|
||||
this,
|
||||
afterRerouteId
|
||||
)
|
||||
if (!link) return
|
||||
|
||||
graphLifecycleEventDispatcher.dispatchNodeConnectionChange({
|
||||
node,
|
||||
slotType: NodeSlotType.OUTPUT,
|
||||
slotIndex: outputIndex,
|
||||
connected: true,
|
||||
link,
|
||||
slot
|
||||
})
|
||||
|
||||
return link
|
||||
} finally {
|
||||
subgraph.afterChange()
|
||||
}
|
||||
subgraph._version++
|
||||
|
||||
node.onConnectionsChange?.(
|
||||
NodeSlotType.OUTPUT,
|
||||
outputIndex,
|
||||
true,
|
||||
link,
|
||||
slot
|
||||
)
|
||||
|
||||
subgraph.afterChange()
|
||||
|
||||
return link
|
||||
}
|
||||
|
||||
get labelPos(): Point {
|
||||
@@ -153,24 +140,42 @@ export class SubgraphOutput extends SubgraphSlot {
|
||||
|
||||
return false
|
||||
}
|
||||
private static _disconnectDeprecationWarned = false
|
||||
|
||||
override disconnect() {
|
||||
const { subgraph } = this.parent
|
||||
//should never have more than one connection
|
||||
for (const linkId of this.linkIds) {
|
||||
const link = subgraph.links[linkId]
|
||||
if (!link) continue
|
||||
subgraph.removeLink(linkId)
|
||||
const { output, outputNode } = link.resolve(subgraph)
|
||||
if (output)
|
||||
output.links = output.links?.filter((id) => id !== linkId) ?? null
|
||||
outputNode?.onConnectionsChange?.(
|
||||
NodeSlotType.OUTPUT,
|
||||
link.origin_slot,
|
||||
false,
|
||||
link,
|
||||
this
|
||||
if (!SubgraphOutput._disconnectDeprecationWarned) {
|
||||
SubgraphOutput._disconnectDeprecationWarned = true
|
||||
warnDeprecated(
|
||||
'[DEPRECATED] SubgraphOutput.disconnect now dispatches onConnectionsChange for output-node disconnect parity. Remedy: update extension handlers to treat OUTPUT/disconnected callbacks as the canonical disconnect signal and no-op safely if already detached.'
|
||||
)
|
||||
}
|
||||
|
||||
//should never have more than one connection
|
||||
for (const linkId of [...this.linkIds]) {
|
||||
const link = subgraph.links.get(linkId)
|
||||
if (!link) continue
|
||||
|
||||
const { outputNode } = link.resolve(subgraph)
|
||||
if (!outputNode) continue
|
||||
|
||||
subgraph.disconnectSubgraphOutputLink(
|
||||
this,
|
||||
outputNode,
|
||||
link.origin_slot,
|
||||
link
|
||||
)
|
||||
|
||||
graphLifecycleEventDispatcher.dispatchNodeConnectionChange({
|
||||
node: outputNode,
|
||||
slotType: NodeSlotType.OUTPUT,
|
||||
slotIndex: link.origin_slot,
|
||||
connected: false,
|
||||
link,
|
||||
slot: this
|
||||
})
|
||||
}
|
||||
|
||||
this.linkIds.length = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type { ResolvedConnection } from '@/lib/litegraph/src/LLink'
|
||||
import type { LinkId, ResolvedConnection } from '@/lib/litegraph/src/LLink'
|
||||
import { Reroute } from '@/lib/litegraph/src/Reroute'
|
||||
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
|
||||
import {
|
||||
@@ -51,6 +52,12 @@ export function splitPositionables(
|
||||
|
||||
for (const item of items) {
|
||||
switch (true) {
|
||||
case item instanceof SubgraphInputNode:
|
||||
subgraphInputNodes.add(item)
|
||||
break
|
||||
case item instanceof SubgraphOutputNode:
|
||||
subgraphOutputNodes.add(item)
|
||||
break
|
||||
case item instanceof LGraphNode:
|
||||
nodes.add(item)
|
||||
break
|
||||
@@ -60,12 +67,6 @@ export function splitPositionables(
|
||||
case item instanceof Reroute:
|
||||
reroutes.add(item)
|
||||
break
|
||||
case item instanceof SubgraphInputNode:
|
||||
subgraphInputNodes.add(item)
|
||||
break
|
||||
case item instanceof SubgraphOutputNode:
|
||||
subgraphOutputNodes.add(item)
|
||||
break
|
||||
default:
|
||||
unknown.add(item)
|
||||
break
|
||||
@@ -90,6 +91,64 @@ interface BoundaryLinks {
|
||||
boundaryOutputLinks: LLink[]
|
||||
}
|
||||
|
||||
interface SubgraphBoundaryNodeView {
|
||||
id: NodeId
|
||||
inputs: Array<{ link?: LinkId | null }>
|
||||
outputs: Array<{ links?: LinkId[] | null }>
|
||||
}
|
||||
|
||||
interface SubgraphBoundaryOutputEndpoint {
|
||||
targetId: NodeId
|
||||
targetSlot: number
|
||||
externalParentId: RerouteId | undefined
|
||||
}
|
||||
|
||||
interface SubgraphBoundaryInputEndpoint {
|
||||
originId: NodeId
|
||||
originSlot: number
|
||||
externalParentId: RerouteId | undefined
|
||||
}
|
||||
|
||||
export const subgraphBoundaryAdapter = {
|
||||
remapInputBoundaryForUnpack(
|
||||
link: LLink,
|
||||
subgraphNode: SubgraphBoundaryNodeView,
|
||||
links: Map<LinkId, LLink>
|
||||
): SubgraphBoundaryInputEndpoint | undefined {
|
||||
const outerLinkId = subgraphNode.inputs[link.origin_slot]?.link
|
||||
if (outerLinkId == null) return
|
||||
|
||||
const outerLink = links.get(outerLinkId)
|
||||
if (!outerLink) return
|
||||
|
||||
return {
|
||||
originId: outerLink.origin_id,
|
||||
originSlot: outerLink.origin_slot,
|
||||
externalParentId: outerLink.parentId
|
||||
}
|
||||
},
|
||||
|
||||
resolveOutputBoundaryForUnpack(
|
||||
link: LLink,
|
||||
subgraphNode: SubgraphBoundaryNodeView,
|
||||
links: Map<LinkId, LLink>
|
||||
): SubgraphBoundaryOutputEndpoint[] {
|
||||
const results: SubgraphBoundaryOutputEndpoint[] = []
|
||||
for (const linkId of subgraphNode.outputs[link.target_slot]?.links ?? []) {
|
||||
const outerLink = links.get(linkId)
|
||||
if (!outerLink) continue
|
||||
|
||||
results.push({
|
||||
targetId: outerLink.target_id,
|
||||
targetSlot: outerLink.target_slot,
|
||||
externalParentId: outerLink.parentId
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
export function getBoundaryLinks(
|
||||
graph: LGraph,
|
||||
items: Set<Positionable>
|
||||
@@ -263,7 +322,7 @@ export function groupResolvedByOutput(
|
||||
function mapReroutes(
|
||||
link: SerialisableLLink,
|
||||
reroutes: Map<RerouteId, Reroute>
|
||||
) {
|
||||
): RerouteId | undefined {
|
||||
let child: SerialisableLLink | Reroute = link
|
||||
let nextReroute =
|
||||
child.parentId === undefined ? undefined : reroutes.get(child.parentId)
|
||||
|
||||
41
src/lib/litegraph/src/utils/slotIdentity.ts
Normal file
41
src/lib/litegraph/src/utils/slotIdentity.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { warnDeprecated } from '@/lib/litegraph/src/utils/feedback'
|
||||
|
||||
const DUPLICATE_IDENTITY_SEPARATOR = '__'
|
||||
|
||||
export function resolveCanonicalSlotName<
|
||||
TSlot extends { id: UUID; name: string }
|
||||
>(slots: readonly TSlot[], requestedName: string, slotId: UUID): string {
|
||||
if (!slots.some((slot) => slot.id !== slotId && slot.name === requestedName))
|
||||
return requestedName
|
||||
|
||||
return `${requestedName}${DUPLICATE_IDENTITY_SEPARATOR}${slotId}`
|
||||
}
|
||||
|
||||
export function normalizeLegacySlotIdentity<
|
||||
TSlot extends { id: UUID; name: string; label?: string }
|
||||
>(slots: TSlot[]): void {
|
||||
const seenCounts = new Map<string, number>()
|
||||
|
||||
for (const slot of slots) {
|
||||
const count = seenCounts.get(slot.name) ?? 0
|
||||
seenCounts.set(slot.name, count + 1)
|
||||
if (count === 0) continue
|
||||
|
||||
warnDeprecated(
|
||||
'[DEPRECATED] Legacy subgraph workflows with duplicate slot names are automatically canonicalized by appending a stable slot ID. Remedy: resave the workflow in the current frontend to persist canonical slot names and avoid compatibility fallback.'
|
||||
)
|
||||
|
||||
const oldName = slot.name
|
||||
slot.label ??= slot.name
|
||||
slot.name = `${slot.name}${DUPLICATE_IDENTITY_SEPARATOR}${slot.id}`
|
||||
console.warn(
|
||||
'Subgraph slot identity deduplicated during legacy normalization',
|
||||
{
|
||||
slotId: slot.id,
|
||||
oldName,
|
||||
canonicalName: slot.name
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -204,10 +204,8 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
const { slotMetadata } = widget
|
||||
|
||||
// Get metadata from store (registered during BaseWidget.setNodeId)
|
||||
const bareWidgetId = stripGraphPrefix(
|
||||
widget.storeNodeId ?? widget.nodeId ?? nodeId
|
||||
)
|
||||
const storeWidgetName = widget.storeName ?? widget.name
|
||||
const bareWidgetId = stripGraphPrefix(widget.nodeId ?? nodeId)
|
||||
const storeWidgetName = widget.name
|
||||
const widgetState = graphId
|
||||
? widgetValueStore.getWidget(graphId, bareWidgetId, storeWidgetName)
|
||||
: undefined
|
||||
|
||||
@@ -22,8 +22,7 @@ vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isImageNode: vi.fn(),
|
||||
isVideoNode: vi.fn(),
|
||||
isAudioNode: vi.fn(),
|
||||
executeWidgetsCallback: vi.fn(),
|
||||
fixLinkInputSlots: vi.fn()
|
||||
executeWidgetsCallback: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/usePaste', () => ({
|
||||
|
||||
@@ -5,8 +5,7 @@ import { reactive, unref } from 'vue'
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { flushScheduledSlotLayoutSync } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
import { addAfterConfigureHandler } from '@/utils/graphConfigureUtil'
|
||||
|
||||
import { st, t } from '@/i18n'
|
||||
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
|
||||
@@ -91,7 +90,6 @@ import {
|
||||
import {
|
||||
executeWidgetsCallback,
|
||||
createNode,
|
||||
fixLinkInputSlots,
|
||||
isImageNode,
|
||||
isVideoNode
|
||||
} from '@/utils/litegraphUtil'
|
||||
@@ -795,37 +793,6 @@ export class ComfyApp {
|
||||
}
|
||||
}
|
||||
|
||||
private addAfterConfigureHandler(graph: LGraph) {
|
||||
const { onConfigure } = graph
|
||||
graph.onConfigure = function (...args) {
|
||||
// Set pending sync flag to suppress link rendering until slots are synced
|
||||
if (LiteGraph.vueNodesMode) {
|
||||
layoutStore.setPendingSlotSync(true)
|
||||
}
|
||||
|
||||
try {
|
||||
fixLinkInputSlots(this)
|
||||
|
||||
// Fire callbacks before the onConfigure, this is used by widget inputs to setup the config
|
||||
triggerCallbackOnAllNodes(this, 'onGraphConfigured')
|
||||
|
||||
const r = onConfigure?.apply(this, args)
|
||||
|
||||
// Fire after onConfigure, used by primitives to generate widget using input nodes config
|
||||
triggerCallbackOnAllNodes(this, 'onAfterGraphConfigured')
|
||||
|
||||
return r
|
||||
} finally {
|
||||
// Flush pending slot layout syncs to fix link alignment after undo/redo
|
||||
// Using finally ensures links aren't permanently suppressed if an error occurs
|
||||
if (LiteGraph.vueNodesMode) {
|
||||
flushScheduledSlotLayoutSync()
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the app on the page
|
||||
*/
|
||||
@@ -864,7 +831,7 @@ export class ComfyApp {
|
||||
}
|
||||
})
|
||||
|
||||
this.addAfterConfigureHandler(graph)
|
||||
addAfterConfigureHandler(graph, () => this.canvas)
|
||||
|
||||
this.rootGraphInternal = graph
|
||||
this.canvas = new LGraphCanvas(canvasEl, graph)
|
||||
|
||||
66
src/scripts/changeTracker.test.ts
Normal file
66
src/scripts/changeTracker.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import { ChangeTracker } from './changeTracker'
|
||||
|
||||
function createTopologyGraph() {
|
||||
const graph = new LGraph()
|
||||
|
||||
const source = new LGraphNode('source')
|
||||
source.addOutput('out', 'number')
|
||||
|
||||
const floatingTarget = new LGraphNode('floating-target')
|
||||
floatingTarget.addInput('in', 'number')
|
||||
|
||||
const linkedTarget = new LGraphNode('linked-target')
|
||||
linkedTarget.addInput('in', 'number')
|
||||
|
||||
graph.add(source)
|
||||
graph.add(floatingTarget)
|
||||
graph.add(linkedTarget)
|
||||
|
||||
source.connect(0, floatingTarget, 0)
|
||||
source.connect(0, linkedTarget, 0)
|
||||
|
||||
const link = graph.getLink(floatingTarget.inputs[0].link)
|
||||
if (!link) throw new Error('Expected link')
|
||||
|
||||
graph.createReroute([100, 100], link)
|
||||
floatingTarget.disconnectInput(0, true)
|
||||
|
||||
return graph
|
||||
}
|
||||
|
||||
describe('ChangeTracker.graphEqual', () => {
|
||||
it('returns false when links differ', () => {
|
||||
const graph = createTopologyGraph()
|
||||
const stateA = graph.asSerialisable() as unknown as ComfyWorkflowJSON
|
||||
const stateB = structuredClone(stateA)
|
||||
|
||||
stateB.links = []
|
||||
|
||||
expect(ChangeTracker.graphEqual(stateA, stateB)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when floatingLinks differ', () => {
|
||||
const graph = createTopologyGraph()
|
||||
const stateA = graph.asSerialisable() as unknown as ComfyWorkflowJSON
|
||||
const stateB = structuredClone(stateA)
|
||||
|
||||
stateB.floatingLinks = []
|
||||
|
||||
expect(ChangeTracker.graphEqual(stateA, stateB)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when reroutes differ', () => {
|
||||
const graph = createTopologyGraph()
|
||||
const stateA = graph.asSerialisable() as unknown as ComfyWorkflowJSON
|
||||
const stateB = structuredClone(stateA)
|
||||
|
||||
stateB.reroutes = []
|
||||
|
||||
expect(ChangeTracker.graphEqual(stateA, stateB)).toBe(false)
|
||||
})
|
||||
})
|
||||
81
src/stores/linkStore.ts
Normal file
81
src/stores/linkStore.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type { LinkId, LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type { Reroute, RerouteId } from '@/lib/litegraph/src/Reroute'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
/**
|
||||
* ReadonlyMap fields are live projections of mutable graph-owned Maps,
|
||||
* not immutable snapshots. Do not cache or rely on referential stability.
|
||||
*/
|
||||
interface LinkStoreTopology {
|
||||
links: ReadonlyMap<LinkId, LLink>
|
||||
floatingLinks: ReadonlyMap<LinkId, LLink>
|
||||
reroutes: ReadonlyMap<RerouteId, Reroute>
|
||||
}
|
||||
|
||||
const EMPTY_TOPOLOGY: Readonly<LinkStoreTopology> = Object.freeze({
|
||||
links: new Map(),
|
||||
floatingLinks: new Map(),
|
||||
reroutes: new Map()
|
||||
})
|
||||
|
||||
/**
|
||||
* Graph-scoped topology store (Pinia).
|
||||
*
|
||||
* This store owns no mutation logic and is rehydrated from graph lifecycle
|
||||
* boundaries (`clear` and `configure`). The `ReadonlyMap` fields are live
|
||||
* projections of mutable graph state, not immutable snapshots.
|
||||
*
|
||||
* Each graph/subgraph registers its own topology keyed by graph UUID.
|
||||
*/
|
||||
export const useLinkStore = defineStore('link', () => {
|
||||
// Intentionally non-reactive: used as an imperative graph lookup boundary,
|
||||
// not as UI-driven reactive state.
|
||||
const topologies = new Map<UUID, LinkStoreTopology>()
|
||||
|
||||
function rehydrate(graphId: UUID, topology: LinkStoreTopology) {
|
||||
topologies.set(graphId, topology)
|
||||
}
|
||||
|
||||
function getTopology(graphId: UUID): LinkStoreTopology {
|
||||
return topologies.get(graphId) ?? EMPTY_TOPOLOGY
|
||||
}
|
||||
|
||||
function getLink(
|
||||
graphId: UUID,
|
||||
id: LinkId | null | undefined
|
||||
): LLink | undefined {
|
||||
if (id == null) return undefined
|
||||
return topologies.get(graphId)?.links.get(id)
|
||||
}
|
||||
|
||||
function getFloatingLink(
|
||||
graphId: UUID,
|
||||
id: LinkId | null | undefined
|
||||
): LLink | undefined {
|
||||
if (id == null) return undefined
|
||||
return topologies.get(graphId)?.floatingLinks.get(id)
|
||||
}
|
||||
|
||||
function getReroute(
|
||||
graphId: UUID,
|
||||
id: RerouteId | null | undefined
|
||||
): Reroute | undefined {
|
||||
if (id == null) return undefined
|
||||
return topologies.get(graphId)?.reroutes.get(id)
|
||||
}
|
||||
|
||||
function clearGraph(graphId: UUID) {
|
||||
topologies.delete(graphId)
|
||||
}
|
||||
|
||||
return {
|
||||
rehydrate,
|
||||
getTopology,
|
||||
getLink,
|
||||
getFloatingLink,
|
||||
getReroute,
|
||||
clearGraph
|
||||
}
|
||||
})
|
||||
72
src/utils/graphConfigureUtil.test.ts
Normal file
72
src/utils/graphConfigureUtil.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { fixLinkInputSlots } from '@/utils/litegraphUtil'
|
||||
import { triggerCallbackOnAllNodes } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import { addAfterConfigureHandler } from './graphConfigureUtil'
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
fixLinkInputSlots: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
triggerCallbackOnAllNodes: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: { setPendingSlotSync: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/composables/useSlotElementTracking',
|
||||
() => ({
|
||||
flushScheduledSlotLayoutSync: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
function createConfigureGraph(): LGraph {
|
||||
return {
|
||||
nodes: [],
|
||||
onConfigure: vi.fn()
|
||||
} satisfies Partial<LGraph> as unknown as LGraph
|
||||
}
|
||||
|
||||
describe('addAfterConfigureHandler', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('runs legacy slot repair on configure', () => {
|
||||
const graph = createConfigureGraph()
|
||||
|
||||
addAfterConfigureHandler(graph, () => undefined)
|
||||
graph.onConfigure!.call(
|
||||
graph,
|
||||
{} as Parameters<NonNullable<LGraph['onConfigure']>>[0]
|
||||
)
|
||||
|
||||
expect(fixLinkInputSlots).toHaveBeenCalledWith(graph)
|
||||
})
|
||||
|
||||
it('runs onAfterGraphConfigured even if onConfigure throws', () => {
|
||||
const graph = createConfigureGraph()
|
||||
graph.onConfigure = vi.fn(() => {
|
||||
throw new Error('onConfigure failed')
|
||||
})
|
||||
|
||||
addAfterConfigureHandler(graph, () => undefined)
|
||||
|
||||
expect(() =>
|
||||
graph.onConfigure!.call(
|
||||
graph,
|
||||
{} as Parameters<NonNullable<LGraph['onConfigure']>>[0]
|
||||
)
|
||||
).toThrow('onConfigure failed')
|
||||
|
||||
expect(triggerCallbackOnAllNodes).toHaveBeenCalledWith(
|
||||
graph,
|
||||
'onAfterGraphConfigured'
|
||||
)
|
||||
})
|
||||
})
|
||||
37
src/utils/graphConfigureUtil.ts
Normal file
37
src/utils/graphConfigureUtil.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { flushScheduledSlotLayoutSync } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
import { triggerCallbackOnAllNodes } from '@/utils/graphTraversalUtil'
|
||||
import { fixLinkInputSlots } from '@/utils/litegraphUtil'
|
||||
|
||||
/**
|
||||
* Wraps graph.onConfigure to add legacy slot repair,
|
||||
* node configure callbacks, and layout sync flushing.
|
||||
*/
|
||||
export function addAfterConfigureHandler(
|
||||
graph: LGraph,
|
||||
getCanvas: () => LGraphCanvas | undefined
|
||||
) {
|
||||
const { onConfigure } = graph
|
||||
graph.onConfigure = function (...args) {
|
||||
if (LiteGraph.vueNodesMode) {
|
||||
layoutStore.setPendingSlotSync(true)
|
||||
}
|
||||
|
||||
try {
|
||||
fixLinkInputSlots(this)
|
||||
|
||||
triggerCallbackOnAllNodes(this, 'onGraphConfigured')
|
||||
|
||||
return onConfigure?.apply(this, args)
|
||||
} finally {
|
||||
triggerCallbackOnAllNodes(this, 'onAfterGraphConfigured')
|
||||
|
||||
if (LiteGraph.vueNodesMode) {
|
||||
flushScheduledSlotLayoutSync()
|
||||
getCanvas()?.setDirty(true, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import {
|
||||
compressWidgetInputSlots,
|
||||
createNode,
|
||||
fixLinkInputSlots,
|
||||
isAnimatedOutput,
|
||||
isVideoOutput,
|
||||
migrateWidgetsValues,
|
||||
@@ -26,6 +27,10 @@ vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/utils/feedback', () => ({
|
||||
warnDeprecated: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { rootGraph: null }
|
||||
}))
|
||||
@@ -391,6 +396,82 @@ describe('compressWidgetInputSlots', () => {
|
||||
})
|
||||
})
|
||||
|
||||
function createGraphWithLinks(options: {
|
||||
targetSlot: number
|
||||
inputLink: number | null
|
||||
nestedTargetSlot?: number
|
||||
nestedInputLink?: number | null
|
||||
}) {
|
||||
const nestedGraph = {
|
||||
nodes: [
|
||||
{
|
||||
inputs: [{ link: options.nestedInputLink ?? null }],
|
||||
isSubgraphNode: vi.fn(() => false)
|
||||
}
|
||||
],
|
||||
links: new Map(
|
||||
options.nestedInputLink
|
||||
? [
|
||||
[
|
||||
options.nestedInputLink,
|
||||
{ target_slot: options.nestedTargetSlot ?? 0 }
|
||||
]
|
||||
]
|
||||
: []
|
||||
)
|
||||
}
|
||||
|
||||
const graph = {
|
||||
nodes: [
|
||||
{
|
||||
inputs: [{ link: options.inputLink }],
|
||||
isSubgraphNode: vi.fn(() => true),
|
||||
subgraph: nestedGraph
|
||||
}
|
||||
],
|
||||
links: new Map(
|
||||
options.inputLink
|
||||
? [[options.inputLink, { target_slot: options.targetSlot }]]
|
||||
: []
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
graph: graph as unknown as LGraph,
|
||||
nestedGraph
|
||||
}
|
||||
}
|
||||
|
||||
describe('fixLinkInputSlots', () => {
|
||||
it('repairs stale target slot indices recursively', () => {
|
||||
const { graph, nestedGraph } = createGraphWithLinks({
|
||||
targetSlot: 4,
|
||||
inputLink: 11,
|
||||
nestedTargetSlot: 3,
|
||||
nestedInputLink: 22
|
||||
})
|
||||
|
||||
const result = fixLinkInputSlots(graph)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(graph.links.get(11)?.target_slot).toBe(0)
|
||||
expect(nestedGraph.links.get(22)?.target_slot).toBe(0)
|
||||
})
|
||||
|
||||
it('returns false when no repair is needed', () => {
|
||||
const { graph } = createGraphWithLinks({
|
||||
targetSlot: 0,
|
||||
inputLink: 11,
|
||||
nestedTargetSlot: 0,
|
||||
nestedInputLink: 22
|
||||
})
|
||||
|
||||
const result = fixLinkInputSlots(graph)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNode', () => {
|
||||
function mockGraph(
|
||||
nodeList: Partial<LGraphNode>[],
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Reroute,
|
||||
isColorable
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { warnDeprecated } from '@/lib/litegraph/src/utils/feedback'
|
||||
import type {
|
||||
ExportedSubgraph,
|
||||
ISerialisableNodeInput,
|
||||
@@ -218,11 +219,10 @@ export function migrateWidgetsValues<TWidgetValue>(
|
||||
*
|
||||
* @param graph - The graph to fix links for.
|
||||
*/
|
||||
export function fixLinkInputSlots(graph: LGraph) {
|
||||
// Note: We can't use forEachNode here because we need access to the graph's
|
||||
// links map at each level. Links are stored in their respective graph/subgraph.
|
||||
export function fixLinkInputSlots(graph: LGraph, isRoot = true): boolean {
|
||||
let hasMismatch = false
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
// Fix links for the current node
|
||||
for (const [inputIndex, input] of node.inputs.entries()) {
|
||||
const linkId = input.link
|
||||
if (!linkId) continue
|
||||
@@ -230,14 +230,24 @@ export function fixLinkInputSlots(graph: LGraph) {
|
||||
const link = graph.links.get(linkId)
|
||||
if (!link) continue
|
||||
|
||||
link.target_slot = inputIndex
|
||||
if (link.target_slot !== inputIndex) {
|
||||
link.target_slot = inputIndex
|
||||
hasMismatch = true
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively fix links in subgraphs
|
||||
if (node.isSubgraphNode?.() && node.subgraph) {
|
||||
fixLinkInputSlots(node.subgraph)
|
||||
if (fixLinkInputSlots(node.subgraph, false)) hasMismatch = true
|
||||
}
|
||||
}
|
||||
|
||||
if (isRoot && hasMismatch) {
|
||||
warnDeprecated(
|
||||
'[DEPRECATED] Legacy slot-index repair (fixLinkInputSlots) now narrows to connected inputs only. Remedy: resave workflows in the current frontend to persist canonical link target slots and remove reliance on migration repair.'
|
||||
)
|
||||
}
|
||||
|
||||
return hasMismatch
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { vi } from 'vitest'
|
||||
import 'vue'
|
||||
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
// Mock @sparkjsdev/spark which uses WASM that doesn't work in Node.js
|
||||
vi.mock('@sparkjsdev/spark', () => ({
|
||||
SplatMesh: class SplatMesh {
|
||||
|
||||
Reference in New Issue
Block a user