mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 01:48:06 +00:00
Compare commits
13 Commits
fix/perf-c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74a48ab2aa | ||
|
|
0875e2f50f | ||
|
|
63442d2fb0 | ||
|
|
bb6f00dc68 | ||
|
|
11fc09220c | ||
|
|
16f4f3f3ed | ||
|
|
4c5a49860c | ||
|
|
db5e8961e0 | ||
|
|
9f9fa60137 | ||
|
|
7131c274f3 | ||
|
|
82556f02a9 | ||
|
|
e34548724d | ||
|
|
fcdc08fb27 |
77
browser_tests/tests/subgraphProgressClear.spec.ts
Normal file
77
browser_tests/tests/subgraphProgressClear.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe(
|
||||
'Subgraph progress clear on navigation',
|
||||
{ tag: ['@subgraph'] },
|
||||
() => {
|
||||
test('Stale progress is cleared on subgraph node after navigating back', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Find the subgraph node
|
||||
const subgraphNodeId = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const subgraphNode = graph.nodes.find(
|
||||
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
return subgraphNode ? String(subgraphNode.id) : null
|
||||
})
|
||||
expect(subgraphNodeId).not.toBeNull()
|
||||
|
||||
// Simulate a stale progress value on the subgraph node.
|
||||
// This happens when:
|
||||
// 1. User views root graph during execution
|
||||
// 2. Progress watcher sets node.progress = 0.5
|
||||
// 3. User enters subgraph
|
||||
// 4. Execution completes (nodeProgressStates becomes {})
|
||||
// 5. Watcher fires, clears subgraph-internal nodes, but root-level
|
||||
// SubgraphNode isn't visible so it keeps stale progress
|
||||
// 6. User navigates back — watcher should fire and clear it
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
|
||||
node.progress = 0.5
|
||||
}, subgraphNodeId!)
|
||||
|
||||
// Verify progress is set
|
||||
const progressBefore = await comfyPage.page.evaluate((nodeId) => {
|
||||
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
|
||||
}, subgraphNodeId!)
|
||||
expect(progressBefore).toBe(0.5)
|
||||
|
||||
// Navigate into the subgraph
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById(
|
||||
subgraphNodeId!
|
||||
)
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Verify we're inside the subgraph
|
||||
const inSubgraph = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
return !!graph && 'inputNode' in graph
|
||||
})
|
||||
expect(inSubgraph).toBe(true)
|
||||
|
||||
// Navigate back to the root graph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The progress watcher should fire when graph changes (because
|
||||
// nodeLocationProgressStates is empty {} and the watcher should
|
||||
// iterate canvas.graph.nodes to clear stale node.progress values).
|
||||
//
|
||||
// BUG: Without watching canvasStore.currentGraph, the watcher doesn't
|
||||
// fire on subgraph->root navigation when progress is already empty,
|
||||
// leaving stale node.progress = 0.5 on the SubgraphNode.
|
||||
await expect(async () => {
|
||||
const progressAfter = await comfyPage.page.evaluate((nodeId) => {
|
||||
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
|
||||
}, subgraphNodeId!)
|
||||
expect(progressAfter).toBeUndefined()
|
||||
}).toPass({ timeout: 2_000 })
|
||||
})
|
||||
}
|
||||
)
|
||||
531
pnpm-lock.yaml
generated
531
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -108,7 +108,7 @@ catalog:
|
||||
unplugin-icons: ^22.5.0
|
||||
unplugin-typegpu: 0.8.0
|
||||
unplugin-vue-components: ^30.0.0
|
||||
vite: 8.0.0-beta.13
|
||||
vite: ^8.0.0
|
||||
vite-plugin-dts: ^4.5.4
|
||||
vite-plugin-html: ^3.2.2
|
||||
vite-plugin-vue-devtools: ^8.0.0
|
||||
|
||||
72
src/App.vue
72
src/App.vue
@@ -9,15 +9,19 @@ import { captureException } from '@sentry/vue'
|
||||
import BlockUI from 'primevue/blockui'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import config from '@/config'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { parsePreloadError } from '@/utils/preloadErrorUtil'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
const { t } = useI18n()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
app.extensionManager = useWorkspaceStore()
|
||||
|
||||
@@ -45,6 +49,19 @@ const showContextMenu = (event: MouseEvent) => {
|
||||
}
|
||||
}
|
||||
|
||||
function handleResourceError(url: string, tagName: string) {
|
||||
console.error('[resource:loadError]', { url, tagName })
|
||||
|
||||
if (__DISTRIBUTION__ === 'cloud') {
|
||||
captureException(new Error(`Resource load failed: ${url}`), {
|
||||
tags: {
|
||||
error_type: 'resource_load_error',
|
||||
tag_name: tagName
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window['__COMFYUI_FRONTEND_VERSION__'] = config.app_version
|
||||
|
||||
@@ -56,15 +73,56 @@ onMounted(() => {
|
||||
// See: https://vite.dev/guide/build#load-error-handling
|
||||
window.addEventListener('vite:preloadError', (event) => {
|
||||
event.preventDefault()
|
||||
const info = parsePreloadError(event.payload)
|
||||
console.error('[vite:preloadError]', {
|
||||
url: info.url,
|
||||
fileType: info.fileType,
|
||||
chunkName: info.chunkName,
|
||||
message: info.message
|
||||
})
|
||||
if (__DISTRIBUTION__ === 'cloud') {
|
||||
captureException(event.payload, {
|
||||
tags: { error_type: 'vite_preload_error' }
|
||||
tags: {
|
||||
error_type: 'vite_preload_error',
|
||||
file_type: info.fileType,
|
||||
chunk_name: info.chunkName ?? undefined
|
||||
},
|
||||
contexts: {
|
||||
preload: {
|
||||
url: info.url,
|
||||
fileType: info.fileType,
|
||||
chunkName: info.chunkName
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.error('[vite:preloadError]', event.payload)
|
||||
}
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('g.preloadErrorTitle'),
|
||||
detail: t('g.preloadError'),
|
||||
life: 10000
|
||||
})
|
||||
})
|
||||
|
||||
// Capture resource load failures (CSS, scripts) in non-localhost distributions
|
||||
if (__DISTRIBUTION__ !== 'localhost') {
|
||||
window.addEventListener(
|
||||
'error',
|
||||
(event) => {
|
||||
const target = event.target
|
||||
if (target instanceof HTMLScriptElement) {
|
||||
handleResourceError(target.src, 'script')
|
||||
} else if (
|
||||
target instanceof HTMLLinkElement &&
|
||||
target.rel === 'stylesheet'
|
||||
) {
|
||||
handleResourceError(target.href, 'link')
|
||||
}
|
||||
},
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
// Initialize conflict detection in background
|
||||
// This runs async and doesn't block UI setup
|
||||
void conflictDetection.initializeConflictDetection()
|
||||
|
||||
@@ -3,19 +3,19 @@
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.x') }}
|
||||
</label>
|
||||
<ScrubableNumberInput v-model="x" :min="0" :step="1" />
|
||||
<ScrubableNumberInput v-model="x" :min="0" :step="1" :disabled />
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.y') }}
|
||||
</label>
|
||||
<ScrubableNumberInput v-model="y" :min="0" :step="1" />
|
||||
<ScrubableNumberInput v-model="y" :min="0" :step="1" :disabled />
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.width') }}
|
||||
</label>
|
||||
<ScrubableNumberInput v-model="width" :min="1" :step="1" />
|
||||
<ScrubableNumberInput v-model="width" :min="1" :step="1" :disabled />
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.height') }}
|
||||
</label>
|
||||
<ScrubableNumberInput v-model="height" :min="1" :step="1" />
|
||||
<ScrubableNumberInput v-model="height" :min="1" :step="1" :disabled />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -25,6 +25,10 @@ import { computed } from 'vue'
|
||||
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
|
||||
const { disabled = false } = defineProps<{
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<Bounds>({
|
||||
default: () => ({ x: 0, y: 0, width: 512, height: 512 })
|
||||
})
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
ref="svgRef"
|
||||
viewBox="-0.04 -0.04 1.08 1.08"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
class="aspect-square w-full cursor-crosshair rounded-[5px] bg-node-component-surface"
|
||||
@pointerdown.stop="handleSvgPointerDown"
|
||||
:class="
|
||||
cn(
|
||||
'aspect-square w-full rounded-[5px] bg-node-component-surface',
|
||||
disabled ? 'cursor-default' : 'cursor-crosshair'
|
||||
)
|
||||
"
|
||||
@pointerdown.stop="onSvgPointerDown"
|
||||
@contextmenu.prevent.stop
|
||||
>
|
||||
<line
|
||||
@@ -56,20 +61,23 @@
|
||||
:stroke="curveColor"
|
||||
stroke-width="0.008"
|
||||
stroke-linecap="round"
|
||||
:opacity="disabled ? 0.5 : 1"
|
||||
/>
|
||||
|
||||
<circle
|
||||
v-for="(point, i) in modelValue"
|
||||
:key="i"
|
||||
:cx="point[0]"
|
||||
:cy="1 - point[1]"
|
||||
r="0.02"
|
||||
:fill="curveColor"
|
||||
stroke="white"
|
||||
stroke-width="0.004"
|
||||
class="cursor-grab"
|
||||
@pointerdown.stop="startDrag(i, $event)"
|
||||
/>
|
||||
<template v-if="!disabled">
|
||||
<circle
|
||||
v-for="(point, i) in modelValue"
|
||||
:key="i"
|
||||
:cx="point[0]"
|
||||
:cy="1 - point[1]"
|
||||
r="0.02"
|
||||
:fill="curveColor"
|
||||
stroke="white"
|
||||
stroke-width="0.004"
|
||||
class="cursor-grab"
|
||||
@pointerdown.stop="startDrag(i, $event)"
|
||||
/>
|
||||
</template>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
@@ -77,14 +85,20 @@
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
|
||||
import { useCurveEditor } from '@/composables/useCurveEditor'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { CurvePoint } from './types'
|
||||
|
||||
import { histogramToPath } from './curveUtils'
|
||||
|
||||
const { curveColor = 'white', histogram } = defineProps<{
|
||||
const {
|
||||
curveColor = 'white',
|
||||
histogram,
|
||||
disabled = false
|
||||
} = defineProps<{
|
||||
curveColor?: string
|
||||
histogram?: Uint32Array | null
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<CurvePoint[]>({
|
||||
@@ -98,6 +112,10 @@ const { curvePath, handleSvgPointerDown, startDrag } = useCurveEditor({
|
||||
modelValue
|
||||
})
|
||||
|
||||
function onSvgPointerDown(e: PointerEvent) {
|
||||
if (!disabled) handleSvgPointerDown(e)
|
||||
}
|
||||
|
||||
const histogramPath = computed(() =>
|
||||
histogram ? histogramToPath(histogram) : ''
|
||||
)
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
<template>
|
||||
<CurveEditor v-model="modelValue" />
|
||||
<CurveEditor
|
||||
:model-value="effectivePoints"
|
||||
:disabled="isDisabled"
|
||||
@update:model-value="modelValue = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CurvePoint } from './types'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
singleValueExtractor,
|
||||
useUpstreamValue
|
||||
} from '@/composables/useUpstreamValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import CurveEditor from './CurveEditor.vue'
|
||||
import { isCurvePointArray } from './curveUtils'
|
||||
import type { CurvePoint } from './types'
|
||||
|
||||
const { widget } = defineProps<{
|
||||
widget: SimplifiedWidget
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<CurvePoint[]>({
|
||||
default: () => [
|
||||
@@ -13,4 +29,17 @@ const modelValue = defineModel<CurvePoint[]>({
|
||||
[1, 1]
|
||||
]
|
||||
})
|
||||
|
||||
const isDisabled = computed(() => !!widget.options?.disabled)
|
||||
|
||||
const upstreamValue = useUpstreamValue(
|
||||
() => widget.linkedUpstream,
|
||||
singleValueExtractor(isCurvePointArray)
|
||||
)
|
||||
|
||||
const effectivePoints = computed(() =>
|
||||
isDisabled.value && upstreamValue.value
|
||||
? upstreamValue.value
|
||||
: modelValue.value
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import type { CurvePoint } from './types'
|
||||
|
||||
export function isCurvePointArray(value: unknown): value is CurvePoint[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length >= 2 &&
|
||||
value.every(
|
||||
(p) =>
|
||||
Array.isArray(p) &&
|
||||
p.length === 2 &&
|
||||
typeof p[0] === 'number' &&
|
||||
typeof p[1] === 'number'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Monotone cubic Hermite interpolation.
|
||||
* Produces a smooth curve that passes through all control points
|
||||
|
||||
@@ -381,10 +381,17 @@ watch(
|
||||
* No `deep: true` needed — `nodeLocationProgressStates` is a computed that
|
||||
* returns a new `Record` object on every progress event (the underlying
|
||||
* `nodeProgressStates` ref is replaced wholesale by the WebSocket handler).
|
||||
*
|
||||
* `currentGraph` triggers this watcher on subgraph navigation so stale
|
||||
* progress bars are cleared when returning to the root graph.
|
||||
*/
|
||||
watch(
|
||||
() =>
|
||||
[executionStore.nodeLocationProgressStates, canvasStore.canvas] as const,
|
||||
[
|
||||
executionStore.nodeLocationProgressStates,
|
||||
canvasStore.canvas,
|
||||
canvasStore.currentGraph
|
||||
] as const,
|
||||
([nodeLocationProgressStates, canvas]) => {
|
||||
if (!canvas?.graph) return
|
||||
for (const node of canvas.graph.nodes) {
|
||||
@@ -563,10 +570,8 @@ onUnmounted(() => {
|
||||
vueNodeLifecycle.cleanup()
|
||||
})
|
||||
function forwardPanEvent(e: PointerEvent) {
|
||||
if (
|
||||
(shouldIgnoreCopyPaste(e.target) && document.activeElement === e.target) ||
|
||||
!isMiddlePointerInput(e)
|
||||
)
|
||||
if (!isMiddlePointerInput(e)) return
|
||||
if (shouldIgnoreCopyPaste(e.target) && document.activeElement === e.target)
|
||||
return
|
||||
|
||||
canvasInteractions.forwardEventToCanvas(e)
|
||||
|
||||
@@ -36,26 +36,37 @@
|
||||
|
||||
<div
|
||||
v-if="imageUrl && !isLoading"
|
||||
class="absolute box-content cursor-move border-2 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]"
|
||||
:class="
|
||||
cn(
|
||||
'absolute box-content cursor-move border-2 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]',
|
||||
isDisabled && 'pointer-events-none opacity-60'
|
||||
)
|
||||
"
|
||||
:style="cropBoxStyle"
|
||||
@pointerdown="handleDragStart"
|
||||
@pointermove="handleDragMove"
|
||||
@pointerup="handleDragEnd"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-for="handle in resizeHandles"
|
||||
v-show="imageUrl && !isLoading"
|
||||
:key="handle.direction"
|
||||
:class="['absolute', handle.class]"
|
||||
:style="handle.style"
|
||||
@pointerdown="(e) => handleResizeStart(e, handle.direction)"
|
||||
@pointermove="handleResizeMove"
|
||||
@pointerup="handleResizeEnd"
|
||||
/>
|
||||
<template v-for="handle in resizeHandles" :key="handle.direction">
|
||||
<div
|
||||
v-show="imageUrl && !isLoading"
|
||||
:class="
|
||||
cn(
|
||||
'absolute',
|
||||
handle.class,
|
||||
isDisabled && 'pointer-events-none opacity-60'
|
||||
)
|
||||
"
|
||||
:style="handle.style"
|
||||
@pointerdown="(e) => handleResizeStart(e, handle.direction)"
|
||||
@pointermove="handleResizeMove"
|
||||
@pointerup="handleResizeEnd"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<div v-if="!isDisabled" class="flex shrink-0 items-center gap-2">
|
||||
<label class="text-xs text-muted-foreground">
|
||||
{{ $t('imageCrop.ratio') }}
|
||||
</label>
|
||||
@@ -90,12 +101,16 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<WidgetBoundingBox v-model="modelValue" class="shrink-0" />
|
||||
<WidgetBoundingBox
|
||||
v-model="effectiveBounds"
|
||||
:disabled="isDisabled"
|
||||
class="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
|
||||
import WidgetBoundingBox from '@/components/boundingbox/WidgetBoundingBox.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -105,10 +120,17 @@ import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import { ASPECT_RATIOS, useImageCrop } from '@/composables/useImageCrop'
|
||||
import {
|
||||
boundsExtractor,
|
||||
useUpstreamValue
|
||||
} from '@/composables/useUpstreamValue'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
const { widget, nodeId } = defineProps<{
|
||||
widget: SimplifiedWidget
|
||||
nodeId: NodeId
|
||||
}>()
|
||||
|
||||
@@ -116,6 +138,23 @@ const modelValue = defineModel<Bounds>({
|
||||
default: () => ({ x: 0, y: 0, width: 512, height: 512 })
|
||||
})
|
||||
|
||||
const isDisabled = computed(() => !!widget.options?.disabled)
|
||||
|
||||
const upstreamValue = useUpstreamValue(
|
||||
() => widget.linkedUpstream,
|
||||
boundsExtractor()
|
||||
)
|
||||
|
||||
const effectiveBounds = computed({
|
||||
get: () =>
|
||||
isDisabled.value && upstreamValue.value
|
||||
? upstreamValue.value
|
||||
: modelValue.value,
|
||||
set: (v) => {
|
||||
if (!isDisabled.value) modelValue.value = v
|
||||
}
|
||||
})
|
||||
|
||||
const imageEl = useTemplateRef<HTMLImageElement>('imageEl')
|
||||
const containerEl = useTemplateRef<HTMLDivElement>('containerEl')
|
||||
|
||||
@@ -139,5 +178,5 @@ const {
|
||||
handleResizeStart,
|
||||
handleResizeMove,
|
||||
handleResizeEnd
|
||||
} = useImageCrop(props.nodeId, { imageEl, containerEl, modelValue })
|
||||
} = useImageCrop(nodeId, { imageEl, containerEl, modelValue: effectiveBounds })
|
||||
</script>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
|
||||
import { GetNodeParentGroupKey } from '../shared'
|
||||
import WidgetItem from './WidgetItem.vue'
|
||||
import { getStableWidgetRenderKey } from '@/core/graph/subgraph/widgetRenderKey'
|
||||
|
||||
const {
|
||||
label,
|
||||
@@ -272,7 +273,7 @@ defineExpose({
|
||||
<TransitionGroup name="list-scale">
|
||||
<WidgetItem
|
||||
v-for="{ widget, node } in widgets"
|
||||
:key="`${node.id}-${widget.name}-${widget.type}`"
|
||||
:key="getStableWidgetRenderKey(widget)"
|
||||
:widget="widget"
|
||||
:node="node"
|
||||
:is-draggable="isDraggable"
|
||||
|
||||
@@ -241,6 +241,88 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers exact _widget input matches before same-name fallbacks for promoted widgets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'seed', type: '*' },
|
||||
{ name: 'seed', type: '*' }
|
||||
]
|
||||
})
|
||||
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const firstInput = firstNode.addInput('seed', '*')
|
||||
firstNode.addWidget('number', 'seed', 1, () => undefined, {})
|
||||
firstInput.widget = { name: 'seed' }
|
||||
subgraph.add(firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
const secondInput = secondNode.addInput('seed', '*')
|
||||
secondNode.addWidget('number', 'seed', 2, () => undefined, {})
|
||||
secondInput.widget = { name: 'seed' }
|
||||
subgraph.add(secondNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 124 })
|
||||
const graph = subgraphNode.graph
|
||||
if (!graph) throw new Error('Expected subgraph node graph')
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const promotedViews = subgraphNode.widgets
|
||||
const secondPromotedView = promotedViews[1]
|
||||
if (!secondPromotedView) throw new Error('Expected second promoted view')
|
||||
|
||||
;(
|
||||
secondPromotedView as unknown as {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
).sourceNodeId = '9999'
|
||||
;(
|
||||
secondPromotedView as unknown as {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
).sourceWidgetName = 'stale_widget'
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
const secondMappedWidget = nodeData?.widgets?.find(
|
||||
(widget) => widget.slotMetadata?.index === 1
|
||||
)
|
||||
if (!secondMappedWidget)
|
||||
throw new Error('Expected mapped widget for slot 1')
|
||||
|
||||
expect(secondMappedWidget.name).not.toBe('stale_widget')
|
||||
})
|
||||
|
||||
it('clears stale slotMetadata when input no longer matches widget', async () => {
|
||||
const { graph, node } = createWidgetInputGraph()
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
const nodeData = vueNodeData.get(String(node.id))!
|
||||
const widgetData = nodeData.widgets!.find((w) => w.name === 'prompt')!
|
||||
|
||||
expect(widgetData.slotMetadata?.linked).toBe(true)
|
||||
|
||||
node.inputs[0].name = 'other'
|
||||
node.inputs[0].widget = { name: 'other' }
|
||||
node.inputs[0].link = null
|
||||
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: 0,
|
||||
connected: false,
|
||||
linkId: 42
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(widgetData.slotMetadata).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph output slot label reactivity', () => {
|
||||
@@ -390,6 +472,56 @@ describe('Nested promoted widget mapping', () => {
|
||||
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps linked and independent same-name promotions as distinct sources', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
|
||||
const linkedNode = new LGraphNode('LinkedNode')
|
||||
const linkedInput = linkedNode.addInput('string_a', '*')
|
||||
linkedNode.addWidget('text', 'string_a', 'linked', () => undefined, {})
|
||||
linkedInput.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNode)
|
||||
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
|
||||
|
||||
const independentNode = new LGraphNode('IndependentNode')
|
||||
independentNode.addWidget(
|
||||
'text',
|
||||
'string_a',
|
||||
'independent',
|
||||
() => undefined,
|
||||
{}
|
||||
)
|
||||
subgraph.add(independentNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 109 })
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(independentNode.id),
|
||||
'string_a'
|
||||
)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
const promotedWidgets = nodeData?.widgets?.filter(
|
||||
(widget) => widget.name === 'string_a'
|
||||
)
|
||||
|
||||
expect(promotedWidgets).toHaveLength(2)
|
||||
expect(
|
||||
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
|
||||
).toEqual(
|
||||
new Set([
|
||||
`${subgraph.id}:${linkedNode.id}`,
|
||||
`${subgraph.id}:${independentNode.id}`
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Promoted widget sourceExecutionId', () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { reactive, shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
@@ -41,6 +42,8 @@ import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
|
||||
export interface WidgetSlotMetadata {
|
||||
index: number
|
||||
linked: boolean
|
||||
originNodeId?: string
|
||||
originOutputName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,16 +245,17 @@ function safeWidgetMapper(
|
||||
}
|
||||
}
|
||||
|
||||
const promotedInputName = node.inputs?.find((input) => {
|
||||
if (input.name === widget.name) return true
|
||||
if (input._widget === widget) return true
|
||||
return false
|
||||
})?.name
|
||||
const matchedInput = matchPromotedInput(node.inputs, widget)
|
||||
const promotedInputName = matchedInput?.name
|
||||
const displayName = promotedInputName ?? widget.name
|
||||
const promotedSource = resolvePromotedSourceByInputName(displayName) ?? {
|
||||
const directSource = {
|
||||
sourceNodeId: widget.sourceNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
}
|
||||
const promotedSource =
|
||||
matchedInput?._widget === widget
|
||||
? (resolvePromotedSourceByInputName(displayName) ?? directSource)
|
||||
: directSource
|
||||
|
||||
return {
|
||||
displayName,
|
||||
@@ -355,6 +359,36 @@ function safeWidgetMapper(
|
||||
}
|
||||
}
|
||||
|
||||
function buildSlotMetadata(
|
||||
inputs: INodeInputSlot[] | undefined,
|
||||
graphRef: LGraph | null | undefined
|
||||
): Map<string, WidgetSlotMetadata> {
|
||||
const metadata = new Map<string, WidgetSlotMetadata>()
|
||||
inputs?.forEach((input, index) => {
|
||||
let originNodeId: string | undefined
|
||||
let originOutputName: string | undefined
|
||||
|
||||
if (input.link != null && graphRef) {
|
||||
const link = graphRef.getLink(input.link)
|
||||
if (link) {
|
||||
originNodeId = String(link.origin_id)
|
||||
const originNode = graphRef.getNodeById(link.origin_id)
|
||||
originOutputName = originNode?.outputs?.[link.origin_slot]?.name
|
||||
}
|
||||
}
|
||||
|
||||
const slotInfo: WidgetSlotMetadata = {
|
||||
index,
|
||||
linked: input.link != null,
|
||||
originNodeId,
|
||||
originOutputName
|
||||
}
|
||||
if (input.name) metadata.set(input.name, slotInfo)
|
||||
if (input.widget?.name) metadata.set(input.widget.name, slotInfo)
|
||||
})
|
||||
return metadata
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -427,15 +461,11 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
|
||||
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
|
||||
const widgetsSnapshot = node.widgets ?? []
|
||||
|
||||
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
|
||||
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)
|
||||
})
|
||||
for (const [key, value] of freshMetadata) {
|
||||
slotMetadata.set(key, value)
|
||||
}
|
||||
return widgetsSnapshot.map(safeWidgetMapper(node, slotMetadata))
|
||||
})
|
||||
|
||||
@@ -488,22 +518,11 @@ 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>()
|
||||
|
||||
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)
|
||||
})
|
||||
const slotMetadata = buildSlotMetadata(nodeRef.inputs, graph)
|
||||
|
||||
// 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
|
||||
widget.slotMetadata = slotMetadata.get(widget.slotName ?? widget.name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
118
src/composables/useUpstreamValue.test.ts
Normal file
118
src/composables/useUpstreamValue.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import { boundsExtractor, singleValueExtractor } from './useUpstreamValue'
|
||||
|
||||
function widget(name: string, value: unknown): WidgetState {
|
||||
return { name, type: 'INPUT', value, nodeId: '1' as NodeId, options: {} }
|
||||
}
|
||||
|
||||
const isNumber = (v: unknown): v is number => typeof v === 'number'
|
||||
|
||||
describe('singleValueExtractor', () => {
|
||||
const extract = singleValueExtractor(isNumber)
|
||||
|
||||
it('matches widget by outputName', () => {
|
||||
const widgets = [widget('a', 'text'), widget('b', 42)]
|
||||
expect(extract(widgets, 'b')).toBe(42)
|
||||
})
|
||||
|
||||
it('returns undefined when outputName widget has invalid value', () => {
|
||||
const widgets = [widget('a', 'text'), widget('b', 'not a number')]
|
||||
expect(extract(widgets, 'b')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to unique valid widget when outputName has no match', () => {
|
||||
const widgets = [widget('a', 'text'), widget('b', 42)]
|
||||
expect(extract(widgets, 'missing')).toBe(42)
|
||||
})
|
||||
|
||||
it('falls back to unique valid widget when no outputName provided', () => {
|
||||
const widgets = [widget('a', 'text'), widget('b', 42)]
|
||||
expect(extract(widgets, undefined)).toBe(42)
|
||||
})
|
||||
|
||||
it('returns undefined when multiple widgets have valid values', () => {
|
||||
const widgets = [widget('a', 1), widget('b', 2)]
|
||||
expect(extract(widgets, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when no widgets have valid values', () => {
|
||||
const widgets = [widget('a', 'text')]
|
||||
expect(extract(widgets, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for empty widgets', () => {
|
||||
expect(extract([], undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('boundsExtractor', () => {
|
||||
const extract = boundsExtractor()
|
||||
|
||||
it('extracts a single bounds object widget', () => {
|
||||
const bounds = { x: 10, y: 20, width: 100, height: 200 }
|
||||
const widgets = [widget('crop', bounds)]
|
||||
expect(extract(widgets, undefined)).toEqual(bounds)
|
||||
})
|
||||
|
||||
it('matches bounds widget by outputName', () => {
|
||||
const bounds = { x: 1, y: 2, width: 3, height: 4 }
|
||||
const widgets = [widget('other', 'text'), widget('crop', bounds)]
|
||||
expect(extract(widgets, 'crop')).toEqual(bounds)
|
||||
})
|
||||
|
||||
it('assembles bounds from individual x/y/width/height widgets', () => {
|
||||
const widgets = [
|
||||
widget('x', 10),
|
||||
widget('y', 20),
|
||||
widget('width', 100),
|
||||
widget('height', 200)
|
||||
]
|
||||
expect(extract(widgets, undefined)).toEqual({
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 200
|
||||
})
|
||||
})
|
||||
|
||||
it('returns undefined when some bound components are missing', () => {
|
||||
const widgets = [widget('x', 10), widget('y', 20), widget('width', 100)]
|
||||
expect(extract(widgets, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when bound components have wrong types', () => {
|
||||
const widgets = [
|
||||
widget('x', '10'),
|
||||
widget('y', 20),
|
||||
widget('width', 100),
|
||||
widget('height', 200)
|
||||
]
|
||||
expect(extract(widgets, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for empty widgets', () => {
|
||||
expect(extract([], undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects partial bounds objects', () => {
|
||||
const partial = { x: 10, y: 20 }
|
||||
const widgets = [widget('crop', partial)]
|
||||
expect(extract(widgets, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('prefers single bounds object over individual widgets', () => {
|
||||
const bounds = { x: 1, y: 2, width: 3, height: 4 }
|
||||
const widgets = [
|
||||
widget('crop', bounds),
|
||||
widget('x', 99),
|
||||
widget('y', 99),
|
||||
widget('width', 99),
|
||||
widget('height', 99)
|
||||
]
|
||||
expect(extract(widgets, undefined)).toEqual(bounds)
|
||||
})
|
||||
})
|
||||
80
src/composables/useUpstreamValue.ts
Normal file
80
src/composables/useUpstreamValue.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import type { LinkedUpstreamInfo } from '@/types/simplifiedWidget'
|
||||
|
||||
type ValueExtractor<T = unknown> = (
|
||||
widgets: WidgetState[],
|
||||
outputName: string | undefined
|
||||
) => T | undefined
|
||||
|
||||
export function useUpstreamValue<T>(
|
||||
getLinkedUpstream: () => LinkedUpstreamInfo | undefined,
|
||||
extractValue: ValueExtractor<T>
|
||||
) {
|
||||
const canvasStore = useCanvasStore()
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
|
||||
return computed(() => {
|
||||
const upstream = getLinkedUpstream()
|
||||
if (!upstream) return undefined
|
||||
const graphId = canvasStore.canvas?.graph?.rootGraph.id
|
||||
if (!graphId) return undefined
|
||||
const widgets = widgetValueStore.getNodeWidgets(graphId, upstream.nodeId)
|
||||
return extractValue(widgets, upstream.outputName)
|
||||
})
|
||||
}
|
||||
|
||||
export function singleValueExtractor<T>(
|
||||
isValid: (value: unknown) => value is T
|
||||
): ValueExtractor<T> {
|
||||
return (widgets, outputName) => {
|
||||
if (outputName) {
|
||||
const matched = widgets.find((w) => w.name === outputName)
|
||||
if (matched && isValid(matched.value)) return matched.value
|
||||
}
|
||||
const validValues = widgets.map((w) => w.value).filter(isValid)
|
||||
return validValues.length === 1 ? validValues[0] : undefined
|
||||
}
|
||||
}
|
||||
|
||||
function isBoundsObject(value: unknown): value is Bounds {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
const v = value as Record<string, unknown>
|
||||
return (
|
||||
typeof v.x === 'number' &&
|
||||
typeof v.y === 'number' &&
|
||||
typeof v.width === 'number' &&
|
||||
typeof v.height === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
export function boundsExtractor(): ValueExtractor<Bounds> {
|
||||
const single = singleValueExtractor(isBoundsObject)
|
||||
return (widgets, outputName) => {
|
||||
const singleResult = single(widgets, outputName)
|
||||
if (singleResult) return singleResult
|
||||
|
||||
// Fallback: assemble from individual widgets matching BoundingBoxInputSpec field names
|
||||
const getNum = (name: string): number | undefined => {
|
||||
const w = widgets.find((w) => w.name === name)
|
||||
return typeof w?.value === 'number' ? w.value : undefined
|
||||
}
|
||||
const x = getNum('x')
|
||||
const y = getNum('y')
|
||||
const width = getNum('width')
|
||||
const height = getNum('height')
|
||||
if (
|
||||
x !== undefined &&
|
||||
y !== undefined &&
|
||||
width !== undefined &&
|
||||
height !== undefined
|
||||
) {
|
||||
return { x, y, width, height }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
77
src/core/graph/subgraph/matchPromotedInput.test.ts
Normal file
77
src/core/graph/subgraph/matchPromotedInput.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { matchPromotedInput } from './matchPromotedInput'
|
||||
|
||||
type MockInput = {
|
||||
name: string
|
||||
_widget?: IBaseWidget
|
||||
}
|
||||
|
||||
function createWidget(name: string): IBaseWidget {
|
||||
return {
|
||||
name,
|
||||
type: 'text'
|
||||
} as IBaseWidget
|
||||
}
|
||||
|
||||
describe(matchPromotedInput, () => {
|
||||
it('prefers exact _widget matches before same-name inputs', () => {
|
||||
const targetWidget = createWidget('seed')
|
||||
const aliasWidget = createWidget('seed')
|
||||
|
||||
const aliasInput: MockInput = {
|
||||
name: 'seed',
|
||||
_widget: aliasWidget
|
||||
}
|
||||
const exactInput: MockInput = {
|
||||
name: 'seed',
|
||||
_widget: targetWidget
|
||||
}
|
||||
|
||||
const matched = matchPromotedInput(
|
||||
[aliasInput, exactInput] as unknown as Array<{
|
||||
name: string
|
||||
_widget?: IBaseWidget
|
||||
}>,
|
||||
targetWidget
|
||||
)
|
||||
|
||||
expect(matched).toBe(exactInput)
|
||||
})
|
||||
|
||||
it('falls back to same-name matching when no exact widget match exists', () => {
|
||||
const targetWidget = createWidget('seed')
|
||||
const aliasInput: MockInput = {
|
||||
name: 'seed'
|
||||
}
|
||||
|
||||
const matched = matchPromotedInput(
|
||||
[aliasInput] as unknown as Array<{ name: string; _widget?: IBaseWidget }>,
|
||||
targetWidget
|
||||
)
|
||||
|
||||
expect(matched).toBe(aliasInput)
|
||||
})
|
||||
|
||||
it('does not guess when multiple same-name inputs exist without an exact match', () => {
|
||||
const targetWidget = createWidget('seed')
|
||||
const firstAliasInput: MockInput = {
|
||||
name: 'seed'
|
||||
}
|
||||
const secondAliasInput: MockInput = {
|
||||
name: 'seed'
|
||||
}
|
||||
|
||||
const matched = matchPromotedInput(
|
||||
[firstAliasInput, secondAliasInput] as unknown as Array<{
|
||||
name: string
|
||||
_widget?: IBaseWidget
|
||||
}>,
|
||||
targetWidget
|
||||
)
|
||||
|
||||
expect(matched).toBeUndefined()
|
||||
})
|
||||
})
|
||||
19
src/core/graph/subgraph/matchPromotedInput.ts
Normal file
19
src/core/graph/subgraph/matchPromotedInput.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
type PromotedInputLike = {
|
||||
name: string
|
||||
_widget?: IBaseWidget
|
||||
}
|
||||
|
||||
export function matchPromotedInput(
|
||||
inputs: PromotedInputLike[] | undefined,
|
||||
widget: IBaseWidget
|
||||
): PromotedInputLike | undefined {
|
||||
if (!inputs) return undefined
|
||||
|
||||
const exactMatch = inputs.find((input) => input._widget === widget)
|
||||
if (exactMatch) return exactMatch
|
||||
|
||||
const sameNameMatches = inputs.filter((input) => input.name === widget.name)
|
||||
return sameNameMatches.length === 1 ? sameNameMatches[0] : undefined
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
// Barrel import must come first to avoid circular dependency
|
||||
// (promotedWidgetView → widgetMap → BaseWidget → LegacyWidget → barrel)
|
||||
@@ -11,19 +11,26 @@ import {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
CanvasPointerEvent,
|
||||
LGraphCanvas,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
|
||||
import {
|
||||
cleanupComplexPromotionFixtureNodeType,
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
createTestSubgraphNode,
|
||||
setupComplexPromotionFixture
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
@@ -78,6 +85,22 @@ function firstInnerNode(innerNodes: LGraphNode[]): LGraphNode {
|
||||
return innerNode
|
||||
}
|
||||
|
||||
function promotedWidgets(node: SubgraphNode): PromotedWidgetView[] {
|
||||
return node.widgets as PromotedWidgetView[]
|
||||
}
|
||||
|
||||
function callSyncPromotions(node: SubgraphNode) {
|
||||
;(
|
||||
node as unknown as {
|
||||
_syncPromotions: () => void
|
||||
}
|
||||
)._syncPromotions()
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanupComplexPromotionFixtureNodeType()
|
||||
})
|
||||
|
||||
describe(createPromotedWidgetView, () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -263,6 +286,31 @@ describe(createPromotedWidgetView, () => {
|
||||
expect(fallbackWidget.value).toBe('updated')
|
||||
})
|
||||
|
||||
test('value setter falls back to host widget when linked states are unavailable', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 124 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNode = new LGraphNode('LinkedNode')
|
||||
const linkedInput = linkedNode.addInput('string_a', '*')
|
||||
linkedNode.addWidget('text', 'string_a', 'initial', () => {})
|
||||
linkedInput.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNode)
|
||||
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
|
||||
|
||||
const linkedView = promotedWidgets(subgraphNode)[0]
|
||||
if (!linkedView) throw new Error('Expected a linked promoted widget')
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
vi.spyOn(widgetValueStore, 'getWidget').mockReturnValue(undefined)
|
||||
|
||||
linkedView.value = 'updated'
|
||||
|
||||
expect(linkedNode.widgets?.[0].value).toBe('updated')
|
||||
})
|
||||
|
||||
test('label falls back to displayName then widgetName', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
@@ -495,6 +543,185 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('renders all promoted widgets when duplicate input names are connected to different nodes', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'seed', type: '*' },
|
||||
{ name: 'seed', type: '*' }
|
||||
]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 94 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const firstNode = new LGraphNode('FirstSeedNode')
|
||||
const firstInput = firstNode.addInput('seed', '*')
|
||||
firstNode.addWidget('number', 'seed', 1, () => {})
|
||||
firstInput.widget = { name: 'seed' }
|
||||
subgraph.add(firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('SecondSeedNode')
|
||||
const secondInput = secondNode.addInput('seed', '*')
|
||||
secondNode.addWidget('number', 'seed', 2, () => {})
|
||||
secondInput.widget = { name: 'seed' }
|
||||
subgraph.add(secondNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
|
||||
|
||||
const widgets = promotedWidgets(subgraphNode)
|
||||
expect(widgets).toHaveLength(2)
|
||||
expect(widgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
|
||||
String(firstNode.id),
|
||||
String(secondNode.id)
|
||||
])
|
||||
})
|
||||
|
||||
test('input-linked same-name widgets share value state while store-promoted peer stays independent', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 95 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNodeA = new LGraphNode('LinkedNodeA')
|
||||
const linkedInputA = linkedNodeA.addInput('string_a', '*')
|
||||
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
|
||||
linkedInputA.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeA)
|
||||
|
||||
const linkedNodeB = new LGraphNode('LinkedNodeB')
|
||||
const linkedInputB = linkedNodeB.addInput('string_a', '*')
|
||||
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
|
||||
linkedInputB.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeB)
|
||||
|
||||
const promotedNode = new LGraphNode('PromotedNode')
|
||||
promotedNode.addWidget('text', 'string_a', 'independent', () => {})
|
||||
subgraph.add(promotedNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
|
||||
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
|
||||
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(promotedNode.id),
|
||||
'string_a'
|
||||
)
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(linkedNodeA.id),
|
||||
'string_a'
|
||||
)
|
||||
|
||||
const widgets = promotedWidgets(subgraphNode)
|
||||
expect(widgets).toHaveLength(2)
|
||||
|
||||
const linkedView = widgets.find(
|
||||
(widget) => widget.sourceNodeId === String(linkedNodeA.id)
|
||||
)
|
||||
const promotedView = widgets.find(
|
||||
(widget) => widget.sourceNodeId === String(promotedNode.id)
|
||||
)
|
||||
if (!linkedView || !promotedView)
|
||||
throw new Error(
|
||||
'Expected linked and store-promoted widgets to be present'
|
||||
)
|
||||
|
||||
linkedView.value = 'shared-value'
|
||||
|
||||
const widgetStore = useWidgetValueStore()
|
||||
const graphId = subgraphNode.rootGraph.id
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(linkedNodeA.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('shared-value')
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(linkedNodeB.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('shared-value')
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(promotedNode.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('independent')
|
||||
|
||||
promotedView.value = 'independent-updated'
|
||||
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(linkedNodeA.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('shared-value')
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(linkedNodeB.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('shared-value')
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(promotedNode.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('independent-updated')
|
||||
})
|
||||
|
||||
test('duplicate-name promoted views map slot linkage by view identity', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 109 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNode = new LGraphNode('LinkedNode')
|
||||
const linkedInput = linkedNode.addInput('string_a', '*')
|
||||
linkedNode.addWidget('text', 'string_a', 'linked', () => {})
|
||||
linkedInput.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNode)
|
||||
|
||||
const independentNode = new LGraphNode('IndependentNode')
|
||||
independentNode.addWidget('text', 'string_a', 'independent', () => {})
|
||||
subgraph.add(independentNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(independentNode.id),
|
||||
'string_a'
|
||||
)
|
||||
|
||||
const widgets = promotedWidgets(subgraphNode)
|
||||
const linkedView = widgets.find(
|
||||
(widget) => widget.sourceNodeId === String(linkedNode.id)
|
||||
)
|
||||
const independentView = widgets.find(
|
||||
(widget) => widget.sourceNodeId === String(independentNode.id)
|
||||
)
|
||||
if (!linkedView || !independentView)
|
||||
throw new Error('Expected linked and independent promoted views')
|
||||
|
||||
const linkedSlot = subgraphNode.getSlotFromWidget(linkedView)
|
||||
const independentSlot = subgraphNode.getSlotFromWidget(independentView)
|
||||
|
||||
expect(linkedSlot).toBeDefined()
|
||||
expect(independentSlot).toBeUndefined()
|
||||
})
|
||||
|
||||
test('returns empty array when no proxyWidgets', () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
expect(subgraphNode.widgets).toEqual([])
|
||||
@@ -558,6 +785,273 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('full linked coverage does not prune unresolved independent fallback promotions', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'widgetA', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 125 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const liveNode = new LGraphNode('LiveNode')
|
||||
const liveInput = liveNode.addInput('widgetA', '*')
|
||||
liveNode.addWidget('text', 'widgetA', 'a', () => {})
|
||||
liveInput.widget = { name: 'widgetA' }
|
||||
subgraph.add(liveNode)
|
||||
subgraph.inputNode.slots[0].connect(liveInput, liveNode)
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
[String(liveNode.id), 'widgetA'],
|
||||
['9999', 'widgetA']
|
||||
])
|
||||
|
||||
callSyncPromotions(subgraphNode)
|
||||
|
||||
const promotions = usePromotionStore().getPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
expect(promotions).toStrictEqual([
|
||||
{ interiorNodeId: String(liveNode.id), widgetName: 'widgetA' },
|
||||
{ interiorNodeId: '9999', widgetName: 'widgetA' }
|
||||
])
|
||||
})
|
||||
|
||||
test('input-added existing-input path tolerates missing link metadata', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'widgetA', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 126 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const existingSlot = subgraph.inputNode.slots[0]
|
||||
if (!existingSlot) throw new Error('Expected subgraph input slot')
|
||||
|
||||
expect(() => {
|
||||
subgraph.events.dispatch('input-added', { input: existingSlot })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
test('syncPromotions prunes stale connected entries but keeps independent promotions', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 96 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNodeA = new LGraphNode('LinkedNodeA')
|
||||
const linkedInputA = linkedNodeA.addInput('string_a', '*')
|
||||
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
|
||||
linkedInputA.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeA)
|
||||
|
||||
const linkedNodeB = new LGraphNode('LinkedNodeB')
|
||||
const linkedInputB = linkedNodeB.addInput('string_a', '*')
|
||||
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
|
||||
linkedInputB.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeB)
|
||||
|
||||
const independentNode = new LGraphNode('IndependentNode')
|
||||
independentNode.addWidget('text', 'string_a', 'independent', () => {})
|
||||
subgraph.add(independentNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
|
||||
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
[String(independentNode.id), 'string_a'],
|
||||
[String(linkedNodeA.id), 'string_a'],
|
||||
[String(linkedNodeB.id), 'string_a']
|
||||
])
|
||||
|
||||
callSyncPromotions(subgraphNode)
|
||||
|
||||
const promotions = usePromotionStore().getPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
expect(promotions).toStrictEqual([
|
||||
{ interiorNodeId: String(linkedNodeA.id), widgetName: 'string_a' },
|
||||
{ interiorNodeId: String(independentNode.id), widgetName: 'string_a' }
|
||||
])
|
||||
})
|
||||
|
||||
test('syncPromotions prunes stale deep-alias entries for nested linked promotions', () => {
|
||||
const { subgraphNodeB } = createTwoLevelNestedSubgraph()
|
||||
const linkedView = promotedWidgets(subgraphNodeB)[0]
|
||||
if (!linkedView)
|
||||
throw new Error(
|
||||
'Expected nested subgraph to expose a linked promoted view'
|
||||
)
|
||||
|
||||
const concrete = resolveConcretePromotedWidget(
|
||||
subgraphNodeB,
|
||||
linkedView.sourceNodeId,
|
||||
linkedView.sourceWidgetName
|
||||
)
|
||||
if (concrete.status !== 'resolved')
|
||||
throw new Error(
|
||||
'Expected nested promoted view to resolve to concrete widget'
|
||||
)
|
||||
|
||||
const linkedEntry = [
|
||||
linkedView.sourceNodeId,
|
||||
linkedView.sourceWidgetName
|
||||
] as [string, string]
|
||||
const deepAliasEntry = [
|
||||
String(concrete.resolved.node.id),
|
||||
concrete.resolved.widget.name
|
||||
] as [string, string]
|
||||
|
||||
// Guardrail: this test specifically validates host/deep alias cleanup.
|
||||
expect(deepAliasEntry).not.toStrictEqual(linkedEntry)
|
||||
|
||||
setPromotions(subgraphNodeB, [linkedEntry, deepAliasEntry])
|
||||
|
||||
callSyncPromotions(subgraphNodeB)
|
||||
|
||||
const promotions = usePromotionStore().getPromotions(
|
||||
subgraphNodeB.rootGraph.id,
|
||||
subgraphNodeB.id
|
||||
)
|
||||
expect(promotions).toStrictEqual([
|
||||
{
|
||||
interiorNodeId: linkedEntry[0],
|
||||
widgetName: linkedEntry[1]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test('configure prunes stale disconnected host aliases that resolve to the active linked concrete widget', () => {
|
||||
const nestedSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
|
||||
const concreteNode = new LGraphNode('ConcreteNode')
|
||||
const concreteInput = concreteNode.addInput('string_a', '*')
|
||||
concreteNode.addWidget('text', 'string_a', 'value', () => {})
|
||||
concreteInput.widget = { name: 'string_a' }
|
||||
nestedSubgraph.add(concreteNode)
|
||||
nestedSubgraph.inputNode.slots[0].connect(concreteInput, concreteNode)
|
||||
|
||||
const hostSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
|
||||
const activeAliasNode = createTestSubgraphNode(nestedSubgraph, { id: 118 })
|
||||
const staleAliasNode = createTestSubgraphNode(nestedSubgraph, { id: 119 })
|
||||
hostSubgraph.add(activeAliasNode)
|
||||
hostSubgraph.add(staleAliasNode)
|
||||
|
||||
activeAliasNode._internalConfigureAfterSlots()
|
||||
staleAliasNode._internalConfigureAfterSlots()
|
||||
hostSubgraph.inputNode.slots[0].connect(
|
||||
activeAliasNode.inputs[0],
|
||||
activeAliasNode
|
||||
)
|
||||
|
||||
const hostSubgraphNode = createTestSubgraphNode(hostSubgraph, { id: 120 })
|
||||
hostSubgraphNode.graph?.add(hostSubgraphNode)
|
||||
|
||||
setPromotions(hostSubgraphNode, [
|
||||
[String(activeAliasNode.id), 'string_a'],
|
||||
[String(staleAliasNode.id), 'string_a']
|
||||
])
|
||||
|
||||
const serialized = hostSubgraphNode.serialize()
|
||||
const restoredNode = createTestSubgraphNode(hostSubgraph, { id: 121 })
|
||||
restoredNode.configure({
|
||||
...serialized,
|
||||
id: restoredNode.id,
|
||||
type: hostSubgraph.id,
|
||||
inputs: []
|
||||
})
|
||||
|
||||
const restoredPromotions = usePromotionStore().getPromotions(
|
||||
restoredNode.rootGraph.id,
|
||||
restoredNode.id
|
||||
)
|
||||
expect(restoredPromotions).toStrictEqual([
|
||||
{
|
||||
interiorNodeId: String(activeAliasNode.id),
|
||||
widgetName: 'string_a'
|
||||
}
|
||||
])
|
||||
|
||||
const restoredWidgets = promotedWidgets(restoredNode)
|
||||
expect(restoredWidgets).toHaveLength(1)
|
||||
expect(restoredWidgets[0].sourceNodeId).toBe(String(activeAliasNode.id))
|
||||
})
|
||||
|
||||
test('serialize syncs duplicate-name linked inputs by subgraph slot identity', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'seed', type: '*' },
|
||||
{ name: 'seed', type: '*' }
|
||||
]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 127 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const firstInput = firstNode.addInput('seed', '*')
|
||||
firstNode.addWidget('text', 'seed', 'first-initial', () => {})
|
||||
firstInput.widget = { name: 'seed' }
|
||||
subgraph.add(firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
const secondInput = secondNode.addInput('seed', '*')
|
||||
secondNode.addWidget('text', 'seed', 'second-initial', () => {})
|
||||
secondInput.widget = { name: 'seed' }
|
||||
subgraph.add(secondNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
|
||||
|
||||
const widgets = promotedWidgets(subgraphNode)
|
||||
const firstView = widgets[0]
|
||||
const secondView = widgets[1]
|
||||
if (!firstView || !secondView)
|
||||
throw new Error('Expected two linked promoted views')
|
||||
|
||||
firstView.value = 'first-updated'
|
||||
secondView.value = 'second-updated'
|
||||
|
||||
expect(firstNode.widgets?.[0].value).toBe('first-updated')
|
||||
expect(secondNode.widgets?.[0].value).toBe('second-updated')
|
||||
|
||||
subgraphNode.serialize()
|
||||
|
||||
expect(firstNode.widgets?.[0].value).toBe('first-updated')
|
||||
expect(secondNode.widgets?.[0].value).toBe('second-updated')
|
||||
})
|
||||
|
||||
test('renaming an input updates linked promoted view display names', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 128 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNode = new LGraphNode('LinkedNode')
|
||||
const linkedInput = linkedNode.addInput('seed', '*')
|
||||
linkedNode.addWidget('text', 'seed', 'value', () => {})
|
||||
linkedInput.widget = { name: 'seed' }
|
||||
subgraph.add(linkedNode)
|
||||
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
|
||||
|
||||
const beforeRename = promotedWidgets(subgraphNode)[0]
|
||||
if (!beforeRename) throw new Error('Expected linked promoted view')
|
||||
expect(beforeRename.name).toBe('seed')
|
||||
|
||||
const inputToRename = subgraph.inputs[0]
|
||||
if (!inputToRename) throw new Error('Expected input to rename')
|
||||
subgraph.renameInput(inputToRename, 'seed_renamed')
|
||||
|
||||
const afterRename = promotedWidgets(subgraphNode)[0]
|
||||
if (!afterRename) throw new Error('Expected linked promoted view')
|
||||
expect(afterRename.name).toBe('seed_renamed')
|
||||
})
|
||||
|
||||
test('caches view objects across getter calls (stable references)', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
@@ -701,6 +1195,236 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('configure with empty serialized inputs keeps linked filtering active', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 97 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNodeA = new LGraphNode('LinkedNodeA')
|
||||
const linkedInputA = linkedNodeA.addInput('string_a', '*')
|
||||
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
|
||||
linkedInputA.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeA)
|
||||
|
||||
const linkedNodeB = new LGraphNode('LinkedNodeB')
|
||||
const linkedInputB = linkedNodeB.addInput('string_a', '*')
|
||||
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
|
||||
linkedInputB.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeB)
|
||||
|
||||
const storeOnlyNode = new LGraphNode('StoreOnlyNode')
|
||||
storeOnlyNode.addWidget('text', 'string_a', 'independent', () => {})
|
||||
subgraph.add(storeOnlyNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
|
||||
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
[String(linkedNodeA.id), 'string_a'],
|
||||
[String(linkedNodeB.id), 'string_a'],
|
||||
[String(storeOnlyNode.id), 'string_a']
|
||||
])
|
||||
|
||||
const serialized = subgraphNode.serialize()
|
||||
const restoredNode = createTestSubgraphNode(subgraph, { id: 98 })
|
||||
restoredNode.configure({
|
||||
...serialized,
|
||||
id: restoredNode.id,
|
||||
type: subgraph.id,
|
||||
inputs: []
|
||||
})
|
||||
|
||||
const restoredWidgets = promotedWidgets(restoredNode)
|
||||
expect(restoredWidgets).toHaveLength(2)
|
||||
|
||||
const linkedViewCount = restoredWidgets.filter((widget) =>
|
||||
[String(linkedNodeA.id), String(linkedNodeB.id)].includes(
|
||||
widget.sourceNodeId
|
||||
)
|
||||
).length
|
||||
expect(linkedViewCount).toBe(1)
|
||||
expect(
|
||||
restoredWidgets.some(
|
||||
(widget) => widget.sourceNodeId === String(storeOnlyNode.id)
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('configure with serialized inputs rebinds subgraph slots for linked filtering', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 107 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNodeA = new LGraphNode('LinkedNodeA')
|
||||
const linkedInputA = linkedNodeA.addInput('string_a', '*')
|
||||
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
|
||||
linkedInputA.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeA)
|
||||
|
||||
const linkedNodeB = new LGraphNode('LinkedNodeB')
|
||||
const linkedInputB = linkedNodeB.addInput('string_a', '*')
|
||||
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
|
||||
linkedInputB.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeB)
|
||||
|
||||
const storeOnlyNode = new LGraphNode('StoreOnlyNode')
|
||||
storeOnlyNode.addWidget('text', 'string_a', 'independent', () => {})
|
||||
subgraph.add(storeOnlyNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
|
||||
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
[String(linkedNodeA.id), 'string_a'],
|
||||
[String(linkedNodeB.id), 'string_a'],
|
||||
[String(storeOnlyNode.id), 'string_a']
|
||||
])
|
||||
|
||||
const serialized = subgraphNode.serialize()
|
||||
const restoredNode = createTestSubgraphNode(subgraph, { id: 108 })
|
||||
restoredNode.configure({
|
||||
...serialized,
|
||||
id: restoredNode.id,
|
||||
type: subgraph.id,
|
||||
inputs: [
|
||||
{
|
||||
name: 'string_a',
|
||||
type: '*',
|
||||
link: null
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const restoredWidgets = promotedWidgets(restoredNode)
|
||||
expect(restoredWidgets).toHaveLength(2)
|
||||
|
||||
const linkedViewCount = restoredWidgets.filter((widget) =>
|
||||
[String(linkedNodeA.id), String(linkedNodeB.id)].includes(
|
||||
widget.sourceNodeId
|
||||
)
|
||||
).length
|
||||
expect(linkedViewCount).toBe(1)
|
||||
expect(
|
||||
restoredWidgets.some(
|
||||
(widget) => widget.sourceNodeId === String(storeOnlyNode.id)
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('fixture keeps earliest linked representative and independent promotion only', () => {
|
||||
const { graph, hostNode } = setupComplexPromotionFixture()
|
||||
|
||||
const hostWidgets = promotedWidgets(hostNode)
|
||||
expect(hostWidgets).toHaveLength(2)
|
||||
expect(hostWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
|
||||
'20',
|
||||
'19'
|
||||
])
|
||||
|
||||
const promotions = usePromotionStore().getPromotions(graph.id, hostNode.id)
|
||||
expect(promotions).toStrictEqual([
|
||||
{ interiorNodeId: '20', widgetName: 'string_a' },
|
||||
{ interiorNodeId: '19', widgetName: 'string_a' }
|
||||
])
|
||||
|
||||
const linkedView = hostWidgets[0]
|
||||
const independentView = hostWidgets[1]
|
||||
if (!linkedView || !independentView)
|
||||
throw new Error('Expected linked and independent promoted widgets')
|
||||
|
||||
independentView.value = 'independent-value'
|
||||
linkedView.value = 'shared-linked'
|
||||
|
||||
const widgetStore = useWidgetValueStore()
|
||||
const getValue = (nodeId: string) =>
|
||||
widgetStore.getWidget(graph.id, stripGraphPrefix(nodeId), 'string_a')
|
||||
?.value
|
||||
|
||||
expect(getValue('20')).toBe('shared-linked')
|
||||
expect(getValue('18')).toBe('shared-linked')
|
||||
expect(getValue('19')).toBe('independent-value')
|
||||
})
|
||||
|
||||
test('fixture refreshes duplicate fallback after linked representative recovers', () => {
|
||||
const { subgraph, hostNode } = setupComplexPromotionFixture()
|
||||
|
||||
const earliestLinkedNode = subgraph.getNodeById(20)
|
||||
if (!earliestLinkedNode?.widgets)
|
||||
throw new Error('Expected fixture to contain node 20 with widgets')
|
||||
|
||||
const originalWidgets = earliestLinkedNode.widgets
|
||||
earliestLinkedNode.widgets = originalWidgets.filter(
|
||||
(widget) => widget.name !== 'string_a'
|
||||
)
|
||||
|
||||
const unresolvedWidgets = promotedWidgets(hostNode)
|
||||
expect(
|
||||
unresolvedWidgets.map((widget) => widget.sourceNodeId)
|
||||
).toStrictEqual(['18', '20', '19'])
|
||||
|
||||
earliestLinkedNode.widgets = originalWidgets
|
||||
|
||||
const restoredWidgets = promotedWidgets(hostNode)
|
||||
expect(restoredWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
|
||||
'20',
|
||||
'19'
|
||||
])
|
||||
})
|
||||
|
||||
test('fixture converges external widgets and keeps rendered value isolation after transient linked fallback churn', () => {
|
||||
const { subgraph, hostNode } = setupComplexPromotionFixture()
|
||||
|
||||
const initialWidgets = promotedWidgets(hostNode)
|
||||
expect(initialWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
|
||||
'20',
|
||||
'19'
|
||||
])
|
||||
|
||||
const earliestLinkedNode = subgraph.getNodeById(20)
|
||||
if (!earliestLinkedNode?.widgets)
|
||||
throw new Error('Expected fixture to contain node 20 with widgets')
|
||||
|
||||
const originalWidgets = earliestLinkedNode.widgets
|
||||
earliestLinkedNode.widgets = originalWidgets.filter(
|
||||
(widget) => widget.name !== 'string_a'
|
||||
)
|
||||
|
||||
const transientWidgets = promotedWidgets(hostNode)
|
||||
expect(transientWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual(
|
||||
['18', '20', '19']
|
||||
)
|
||||
|
||||
earliestLinkedNode.widgets = originalWidgets
|
||||
|
||||
const finalWidgets = promotedWidgets(hostNode)
|
||||
expect(finalWidgets).toHaveLength(2)
|
||||
expect(finalWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
|
||||
'20',
|
||||
'19'
|
||||
])
|
||||
|
||||
const finalLinkedView = finalWidgets.find(
|
||||
(widget) => widget.sourceNodeId === '20'
|
||||
)
|
||||
const finalIndependentView = finalWidgets.find(
|
||||
(widget) => widget.sourceNodeId === '19'
|
||||
)
|
||||
if (!finalLinkedView || !finalIndependentView)
|
||||
throw new Error('Expected final rendered linked and independent views')
|
||||
|
||||
finalIndependentView.value = 'independent-final'
|
||||
expect(finalIndependentView.value).toBe('independent-final')
|
||||
expect(finalLinkedView.value).not.toBe('independent-final')
|
||||
|
||||
finalLinkedView.value = 'linked-final'
|
||||
expect(finalLinkedView.value).toBe('linked-final')
|
||||
expect(finalIndependentView.value).toBe('independent-final')
|
||||
})
|
||||
|
||||
test('clone output preserves proxyWidgets for promotion hydration', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
@@ -751,6 +1475,103 @@ describe('widgets getter caching', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
test('reconciles at most once per canvas frame across repeated widgets reads', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
|
||||
const fakeCanvas = { frame: 12 } as Pick<LGraphCanvas, 'frame'>
|
||||
subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas
|
||||
|
||||
const reconcileSpy = vi.spyOn(
|
||||
subgraphNode as unknown as {
|
||||
_buildPromotionReconcileState: (
|
||||
entries: Array<{ interiorNodeId: string; widgetName: string }>,
|
||||
linkedEntries: Array<{
|
||||
inputName: string
|
||||
inputKey: string
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}>
|
||||
) => unknown
|
||||
},
|
||||
'_buildPromotionReconcileState'
|
||||
)
|
||||
|
||||
void subgraphNode.widgets
|
||||
void subgraphNode.widgets
|
||||
void subgraphNode.widgets
|
||||
|
||||
expect(reconcileSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('does not re-run reconciliation when only canvas frame advances', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
|
||||
const fakeCanvas = { frame: 24 } as Pick<LGraphCanvas, 'frame'>
|
||||
subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas
|
||||
|
||||
const reconcileSpy = vi.spyOn(
|
||||
subgraphNode as unknown as {
|
||||
_buildPromotionReconcileState: (
|
||||
entries: Array<{ interiorNodeId: string; widgetName: string }>,
|
||||
linkedEntries: Array<{
|
||||
inputName: string
|
||||
inputKey: string
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}>
|
||||
) => unknown
|
||||
},
|
||||
'_buildPromotionReconcileState'
|
||||
)
|
||||
|
||||
void subgraphNode.widgets
|
||||
fakeCanvas.frame += 1
|
||||
void subgraphNode.widgets
|
||||
|
||||
expect(reconcileSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('does not re-resolve linked entries when linked input state is unchanged', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 97 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNodeA = new LGraphNode('LinkedNodeA')
|
||||
const linkedInputA = linkedNodeA.addInput('string_a', '*')
|
||||
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
|
||||
linkedInputA.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeA)
|
||||
|
||||
const linkedNodeB = new LGraphNode('LinkedNodeB')
|
||||
const linkedInputB = linkedNodeB.addInput('string_a', '*')
|
||||
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
|
||||
linkedInputB.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeB)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
|
||||
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
|
||||
|
||||
const resolveSpy = vi.spyOn(
|
||||
subgraphNode as unknown as {
|
||||
_resolveLinkedPromotionBySubgraphInput: (...args: unknown[]) => unknown
|
||||
},
|
||||
'_resolveLinkedPromotionBySubgraphInput'
|
||||
)
|
||||
|
||||
void subgraphNode.widgets
|
||||
const initialResolveCount = resolveSpy.mock.calls.length
|
||||
expect(initialResolveCount).toBeLessThanOrEqual(1)
|
||||
|
||||
void subgraphNode.widgets
|
||||
expect(resolveSpy).toHaveBeenCalledTimes(initialResolveCount)
|
||||
})
|
||||
|
||||
test('preserves view identities when promotion order changes', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
@@ -13,11 +13,15 @@ import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import {
|
||||
resolveConcretePromotedWidget,
|
||||
resolvePromotedWidgetAtHost
|
||||
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
|
||||
|
||||
import { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
export type { PromotedWidgetView } from './promotedWidgetTypes'
|
||||
@@ -131,6 +135,38 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
}
|
||||
|
||||
set value(value: IBaseWidget['value']) {
|
||||
const linkedWidgets = this.getLinkedInputWidgets()
|
||||
if (linkedWidgets.length > 0) {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
let didUpdateState = false
|
||||
for (const linkedWidget of linkedWidgets) {
|
||||
const state = widgetStore.getWidget(
|
||||
this.graphId,
|
||||
linkedWidget.nodeId,
|
||||
linkedWidget.widgetName
|
||||
)
|
||||
if (state) {
|
||||
state.value = value
|
||||
didUpdateState = true
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = this.resolveDeepest()
|
||||
if (resolved) {
|
||||
const resolvedState = widgetStore.getWidget(
|
||||
this.graphId,
|
||||
stripGraphPrefix(String(resolved.node.id)),
|
||||
resolved.widget.name
|
||||
)
|
||||
if (resolvedState) {
|
||||
resolvedState.value = value
|
||||
didUpdateState = true
|
||||
}
|
||||
}
|
||||
|
||||
if (didUpdateState) return
|
||||
}
|
||||
|
||||
const state = this.getWidgetState()
|
||||
if (state) {
|
||||
state.value = value
|
||||
@@ -278,6 +314,9 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
}
|
||||
|
||||
private getWidgetState() {
|
||||
const linkedState = this.getLinkedInputWidgetStates()[0]
|
||||
if (linkedState) return linkedState
|
||||
|
||||
const resolved = this.resolveDeepest()
|
||||
if (!resolved) return undefined
|
||||
return useWidgetValueStore().getWidget(
|
||||
@@ -287,6 +326,57 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
)
|
||||
}
|
||||
|
||||
private getLinkedInputWidgets(): Array<{
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
widget: IBaseWidget
|
||||
}> {
|
||||
const linkedInputSlot = this.subgraphNode.inputs.find((input) => {
|
||||
if (!input._subgraphSlot) return false
|
||||
if (matchPromotedInput([input], this) !== input) return false
|
||||
|
||||
const boundWidget = input._widget
|
||||
if (boundWidget === this) return true
|
||||
|
||||
if (boundWidget && isPromotedWidgetView(boundWidget)) {
|
||||
return (
|
||||
boundWidget.sourceNodeId === this.sourceNodeId &&
|
||||
boundWidget.sourceWidgetName === this.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
return input._subgraphSlot
|
||||
.getConnectedWidgets()
|
||||
.filter(hasWidgetNode)
|
||||
.some(
|
||||
(widget) =>
|
||||
String(widget.node.id) === this.sourceNodeId &&
|
||||
widget.name === this.sourceWidgetName
|
||||
)
|
||||
})
|
||||
const linkedInput = linkedInputSlot?._subgraphSlot
|
||||
if (!linkedInput) return []
|
||||
|
||||
return linkedInput
|
||||
.getConnectedWidgets()
|
||||
.filter(hasWidgetNode)
|
||||
.map((widget) => ({
|
||||
nodeId: stripGraphPrefix(String(widget.node.id)),
|
||||
widgetName: widget.name,
|
||||
widget
|
||||
}))
|
||||
}
|
||||
|
||||
private getLinkedInputWidgetStates(): WidgetState[] {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
|
||||
return this.getLinkedInputWidgets()
|
||||
.map(({ nodeId, widgetName }) =>
|
||||
widgetStore.getWidget(this.graphId, nodeId, widgetName)
|
||||
)
|
||||
.filter((state): state is WidgetState => state !== undefined)
|
||||
}
|
||||
|
||||
private getProjectedWidget(resolved: {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
|
||||
8
src/core/graph/subgraph/widgetNodeTypeGuard.ts
Normal file
8
src/core/graph/subgraph/widgetNodeTypeGuard.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export function hasWidgetNode(
|
||||
widget: IBaseWidget
|
||||
): widget is IBaseWidget & { node: LGraphNode } {
|
||||
return 'node' in widget && !!widget.node
|
||||
}
|
||||
34
src/core/graph/subgraph/widgetRenderKey.test.ts
Normal file
34
src/core/graph/subgraph/widgetRenderKey.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { getStableWidgetRenderKey } from './widgetRenderKey'
|
||||
|
||||
function createWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
|
||||
return {
|
||||
name: 'seed',
|
||||
type: 'number',
|
||||
...overrides
|
||||
} as IBaseWidget
|
||||
}
|
||||
|
||||
describe(getStableWidgetRenderKey, () => {
|
||||
it('returns a stable key for the same widget instance', () => {
|
||||
const widget = createWidget()
|
||||
|
||||
const first = getStableWidgetRenderKey(widget)
|
||||
const second = getStableWidgetRenderKey(widget)
|
||||
|
||||
expect(second).toBe(first)
|
||||
})
|
||||
|
||||
it('returns distinct keys for distinct widget instances', () => {
|
||||
const firstWidget = createWidget()
|
||||
const secondWidget = createWidget()
|
||||
|
||||
const firstKey = getStableWidgetRenderKey(firstWidget)
|
||||
const secondKey = getStableWidgetRenderKey(secondWidget)
|
||||
|
||||
expect(secondKey).not.toBe(firstKey)
|
||||
})
|
||||
})
|
||||
17
src/core/graph/subgraph/widgetRenderKey.ts
Normal file
17
src/core/graph/subgraph/widgetRenderKey.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
|
||||
const widgetRenderKeys = new WeakMap<IBaseWidget, string>()
|
||||
let nextWidgetRenderKeyId = 0
|
||||
|
||||
export function getStableWidgetRenderKey(widget: IBaseWidget): string {
|
||||
const cachedKey = widgetRenderKeys.get(widget)
|
||||
if (cachedKey) return cachedKey
|
||||
|
||||
const prefix = isPromotedWidgetView(widget) ? 'promoted' : 'widget'
|
||||
const key = `${prefix}:${nextWidgetRenderKeyId++}`
|
||||
|
||||
widgetRenderKeys.set(widget, key)
|
||||
return key
|
||||
}
|
||||
@@ -45,7 +45,8 @@ import { LiteGraph, SubgraphNode } from './litegraph'
|
||||
import {
|
||||
alignOutsideContainer,
|
||||
alignToContainer,
|
||||
createBounds
|
||||
createBounds,
|
||||
snapPoint
|
||||
} from './measure'
|
||||
import { SubgraphInput } from './subgraph/SubgraphInput'
|
||||
import { SubgraphInputNode } from './subgraph/SubgraphInputNode'
|
||||
@@ -2594,7 +2595,18 @@ export class LGraph
|
||||
|
||||
// configure nodes afterwards so they can reach each other
|
||||
for (const [id, nodeData] of nodeDataMap) {
|
||||
this.getNodeById(id)?.configure(nodeData)
|
||||
const node = this.getNodeById(id)
|
||||
node?.configure(nodeData)
|
||||
|
||||
if (LiteGraph.alwaysSnapToGrid && node) {
|
||||
const snapTo = this.getSnapToGridSize()
|
||||
if (node.snapToGrid(snapTo)) {
|
||||
// snapToGrid mutates the internal _pos array in-place, bypassing the setter
|
||||
// This reassignment triggers the pos setter to sync to the Vue layout store
|
||||
node.pos = [node.pos[0], node.pos[1]]
|
||||
}
|
||||
snapPoint(node.size, snapTo, 'ceil')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2959,6 +2959,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
// Enforce minimum size
|
||||
const min = node.computeSize()
|
||||
if (this._snapToGrid) {
|
||||
// Previously newBounds.size is snapped with 'round'
|
||||
// Now the minimum size is snapped with 'ceil' to avoid clipping
|
||||
snapPoint(min, this._snapToGrid, 'ceil')
|
||||
}
|
||||
if (newBounds.width < min[0]) {
|
||||
// If resizing from left, adjust position to maintain right edge
|
||||
if (resizeDirection.includes('W')) {
|
||||
|
||||
@@ -130,6 +130,34 @@ test('snapPoint correctly snaps points to grid', ({ expect }) => {
|
||||
expect(point3).toEqual([20, 20])
|
||||
})
|
||||
|
||||
test('snapPoint correctly snaps points to grid using ceil', ({ expect }) => {
|
||||
const point: Point = [12.3, 18.7]
|
||||
expect(snapPoint(point, 5, 'ceil')).toBe(true)
|
||||
expect(point).toEqual([15, 20])
|
||||
|
||||
const point2: Point = [15, 20]
|
||||
expect(snapPoint(point2, 5, 'ceil')).toBe(true)
|
||||
expect(point2).toEqual([15, 20])
|
||||
|
||||
const point3: Point = [15.1, -18.7]
|
||||
expect(snapPoint(point3, 10, 'ceil')).toBe(true)
|
||||
expect(point3).toEqual([20, -10])
|
||||
})
|
||||
|
||||
test('snapPoint correctly snaps points to grid using floor', ({ expect }) => {
|
||||
const point: Point = [12.3, 18.7]
|
||||
expect(snapPoint(point, 5, 'floor')).toBe(true)
|
||||
expect(point).toEqual([10, 15])
|
||||
|
||||
const point2: Point = [15, 20]
|
||||
expect(snapPoint(point2, 5, 'floor')).toBe(true)
|
||||
expect(point2).toEqual([15, 20])
|
||||
|
||||
const point3: Point = [15.1, -18.7]
|
||||
expect(snapPoint(point3, 10, 'floor')).toBe(true)
|
||||
expect(point3).toEqual([10, -20])
|
||||
})
|
||||
|
||||
test('createBounds correctly creates bounding box', ({ expect }) => {
|
||||
const objects = [
|
||||
{ boundingRect: [0, 0, 10, 10] as Rect },
|
||||
|
||||
@@ -351,11 +351,15 @@ export function createBounds(
|
||||
* @returns `true` if snapTo is truthy, otherwise `false`
|
||||
* @remarks `NaN` propagates through this function and does not affect return value.
|
||||
*/
|
||||
export function snapPoint(pos: Point | Rect, snapTo: number): boolean {
|
||||
export function snapPoint(
|
||||
pos: Point | Rect,
|
||||
snapTo: number,
|
||||
method: 'round' | 'ceil' | 'floor' = 'round'
|
||||
): boolean {
|
||||
if (!snapTo) return false
|
||||
|
||||
pos[0] = snapTo * Math.round(pos[0] / snapTo)
|
||||
pos[1] = snapTo * Math.round(pos[1] / snapTo)
|
||||
pos[0] = snapTo * Math[method](pos[0] / snapTo)
|
||||
pos[1] = snapTo * Math[method](pos[1] / snapTo)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ export function parseSlotTypes(type: ISlotType): string[] {
|
||||
* @param name The name to make unique
|
||||
* @param existingNames The names that already exist. Default: an empty array
|
||||
* @returns The name, or a unique name if it already exists.
|
||||
* @remark Used by SubgraphInputNode to deduplicate input names when promoting
|
||||
* the same widget name from multiple node instances (e.g. `seed` → `seed_1`).
|
||||
* Extensions matching by slot name should account for the `_N` suffix.
|
||||
*/
|
||||
export function nextUniqueName(
|
||||
name: string,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { ToInputFromIoNodeLink } from '@/lib/litegraph/src/canvas/ToInputFromIoNodeLink'
|
||||
import { LinkDirection } from '@/lib/litegraph/src//types/globalEnums'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
import { subgraphTest } from './__fixtures__/subgraphFixtures'
|
||||
import {
|
||||
@@ -456,4 +457,50 @@ describe('SubgraphIO - Empty Slot Connection', () => {
|
||||
expect(link.origin_slot).toBe(1) // Should be the second slot
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'creates distinct named inputs when promoting same widget name from multiple node instances',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraph, subgraphNode } = subgraphWithNode
|
||||
|
||||
const firstNode = new LGraphNode('First Seed Node')
|
||||
const firstInput = firstNode.addInput('seed', 'number')
|
||||
firstNode.addWidget('number', 'seed', 1, () => undefined)
|
||||
firstInput.widget = { name: 'seed' }
|
||||
subgraph.add(firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('Second Seed Node')
|
||||
const secondInput = secondNode.addInput('seed', 'number')
|
||||
secondNode.addWidget('number', 'seed', 2, () => undefined)
|
||||
secondInput.widget = { name: 'seed' }
|
||||
subgraph.add(secondNode)
|
||||
|
||||
subgraph.inputNode.connectByType(-1, firstNode, 'number')
|
||||
subgraph.inputNode.connectByType(-1, secondNode, 'number')
|
||||
|
||||
expect(subgraph.inputs.map((input) => input.name)).toStrictEqual([
|
||||
'input',
|
||||
'seed',
|
||||
'seed_1'
|
||||
])
|
||||
expect(subgraphNode.inputs.map((input) => input.name)).toStrictEqual([
|
||||
'input',
|
||||
'seed',
|
||||
'seed_1'
|
||||
])
|
||||
expect(subgraphNode.widgets.map((widget) => widget.name)).toStrictEqual([
|
||||
'seed',
|
||||
'seed_1'
|
||||
])
|
||||
expect(
|
||||
usePromotionStore().getPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
).toStrictEqual([
|
||||
{ interiorNodeId: String(firstNode.id), widgetName: 'seed' },
|
||||
{ interiorNodeId: String(secondNode.id), widgetName: 'seed' }
|
||||
])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { NodeLike } from '@/lib/litegraph/src/types/NodeLike'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { findFreeSlotOfType } from '@/lib/litegraph/src/utils/collections'
|
||||
import { nextUniqueName } from '@/lib/litegraph/src/strings'
|
||||
|
||||
import { EmptySubgraphInput } from './EmptySubgraphInput'
|
||||
import { SubgraphIONodeBase } from './SubgraphIONodeBase'
|
||||
@@ -130,8 +131,10 @@ export class SubgraphInputNode
|
||||
if (slot === -1) {
|
||||
// This indicates a connection is being made from the "Empty" slot.
|
||||
// We need to create a new, concrete input on the subgraph that matches the target.
|
||||
const existingNames = this.subgraph.inputs.map((input) => input.name)
|
||||
const uniqueName = nextUniqueName(inputSlot.slot.name, existingNames)
|
||||
const newSubgraphInput = this.subgraph.addInput(
|
||||
inputSlot.slot.name,
|
||||
uniqueName,
|
||||
String(inputSlot.slot.type ?? '')
|
||||
)
|
||||
const newSlotIndex = this.slots.indexOf(newSubgraphInput)
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
* IO synchronization, and edge cases.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -615,3 +617,35 @@ describe.skip('SubgraphNode Cleanup', () => {
|
||||
expect(abortSpy2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubgraphNode promotion view keys', () => {
|
||||
it('distinguishes tuples that differ only by colon placement', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const nodeWithKeyBuilder = subgraphNode as unknown as {
|
||||
_makePromotionViewKey: (
|
||||
inputKey: string,
|
||||
interiorNodeId: string,
|
||||
widgetName: string,
|
||||
inputName?: string
|
||||
) => string
|
||||
}
|
||||
|
||||
const firstKey = nodeWithKeyBuilder._makePromotionViewKey(
|
||||
'65',
|
||||
'18',
|
||||
'a:b',
|
||||
'c'
|
||||
)
|
||||
const secondKey = nodeWithKeyBuilder._makePromotionViewKey(
|
||||
'65',
|
||||
'18',
|
||||
'a',
|
||||
'b:c'
|
||||
)
|
||||
|
||||
expect(firstKey).not.toBe(secondKey)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,7 +35,9 @@ import {
|
||||
isPromotedWidgetView
|
||||
} from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
|
||||
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
@@ -52,6 +54,12 @@ workflowSvg.src =
|
||||
|
||||
type LinkedPromotionEntry = {
|
||||
inputName: string
|
||||
inputKey: string
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}
|
||||
|
||||
type PromotionEntry = {
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}
|
||||
@@ -91,46 +99,113 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
* `onAdded()`, so construction-time promotions require normal add-to-graph
|
||||
* lifecycle to persist.
|
||||
*/
|
||||
private _pendingPromotions: Array<{
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}> = []
|
||||
private _pendingPromotions: PromotionEntry[] = []
|
||||
private _cacheVersion = 0
|
||||
private _linkedEntriesCache?: {
|
||||
version: number
|
||||
hasMissingBoundSourceWidget: boolean
|
||||
entries: LinkedPromotionEntry[]
|
||||
}
|
||||
private _promotedViewsCache?: {
|
||||
version: number
|
||||
entriesRef: PromotionEntry[]
|
||||
hasMissingBoundSourceWidget: boolean
|
||||
views: PromotedWidgetView[]
|
||||
}
|
||||
|
||||
// Declared as accessor via Object.defineProperty in constructor.
|
||||
// TypeScript doesn't allow overriding a property with get/set syntax,
|
||||
// so we use declare + defineProperty instead.
|
||||
declare widgets: IBaseWidget[]
|
||||
|
||||
private _resolveLinkedPromotionByInputName(
|
||||
inputName: string
|
||||
private _resolveLinkedPromotionBySubgraphInput(
|
||||
subgraphInput: SubgraphInput
|
||||
): { interiorNodeId: string; widgetName: string } | undefined {
|
||||
const resolvedTarget = resolveSubgraphInputTarget(this, inputName)
|
||||
if (!resolvedTarget) return undefined
|
||||
// Preserve deterministic representative selection for multi-linked inputs:
|
||||
// the first connected source remains the promoted linked view.
|
||||
for (const linkId of subgraphInput.linkIds) {
|
||||
const link = this.subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
|
||||
return {
|
||||
interiorNodeId: resolvedTarget.nodeId,
|
||||
widgetName: resolvedTarget.widgetName
|
||||
const { inputNode } = link.resolve(this.subgraph)
|
||||
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
|
||||
|
||||
const targetInput = inputNode.inputs.find(
|
||||
(entry) => entry.link === linkId
|
||||
)
|
||||
if (!targetInput) continue
|
||||
|
||||
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
|
||||
if (!targetWidget) continue
|
||||
|
||||
if (inputNode.isSubgraphNode())
|
||||
return {
|
||||
interiorNodeId: String(inputNode.id),
|
||||
widgetName: targetInput.name
|
||||
}
|
||||
|
||||
return {
|
||||
interiorNodeId: String(inputNode.id),
|
||||
widgetName: targetWidget.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _getLinkedPromotionEntries(): LinkedPromotionEntry[] {
|
||||
private _getLinkedPromotionEntries(cache = true): LinkedPromotionEntry[] {
|
||||
const hasMissingBoundSourceWidget = this._hasMissingBoundSourceWidget()
|
||||
const cached = this._linkedEntriesCache
|
||||
if (
|
||||
cache &&
|
||||
cached?.version === this._cacheVersion &&
|
||||
cached.hasMissingBoundSourceWidget === hasMissingBoundSourceWidget
|
||||
)
|
||||
return cached.entries
|
||||
|
||||
const linkedEntries: LinkedPromotionEntry[] = []
|
||||
|
||||
// TODO(pr9282): Optimization target. This path runs on widgets getter reads
|
||||
// and resolves each input link chain eagerly.
|
||||
for (const input of this.inputs) {
|
||||
const resolved = this._resolveLinkedPromotionByInputName(input.name)
|
||||
const subgraphInput = input._subgraphSlot
|
||||
if (!subgraphInput) continue
|
||||
|
||||
const boundWidget =
|
||||
input._widget && isPromotedWidgetView(input._widget)
|
||||
? input._widget
|
||||
: undefined
|
||||
if (boundWidget) {
|
||||
const boundNode = this.subgraph.getNodeById(boundWidget.sourceNodeId)
|
||||
const hasBoundSourceWidget =
|
||||
boundNode?.widgets?.some(
|
||||
(widget) => widget.name === boundWidget.sourceWidgetName
|
||||
) === true
|
||||
if (hasBoundSourceWidget) {
|
||||
linkedEntries.push({
|
||||
inputName: input.label ?? input.name,
|
||||
inputKey: String(subgraphInput.id),
|
||||
interiorNodeId: boundWidget.sourceNodeId,
|
||||
widgetName: boundWidget.sourceWidgetName
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const resolved =
|
||||
this._resolveLinkedPromotionBySubgraphInput(subgraphInput)
|
||||
if (!resolved) continue
|
||||
|
||||
linkedEntries.push({ inputName: input.name, ...resolved })
|
||||
linkedEntries.push({
|
||||
inputName: input.label ?? input.name,
|
||||
inputKey: String(subgraphInput.id),
|
||||
...resolved
|
||||
})
|
||||
}
|
||||
|
||||
const seenEntryKeys = new Set<string>()
|
||||
const deduplicatedEntries = linkedEntries.filter((entry) => {
|
||||
const entryKey = this._makePromotionViewKey(
|
||||
entry.inputName,
|
||||
entry.inputKey,
|
||||
entry.interiorNodeId,
|
||||
entry.widgetName
|
||||
entry.widgetName,
|
||||
entry.inputName
|
||||
)
|
||||
if (seenEntryKeys.has(entryKey)) return false
|
||||
|
||||
@@ -138,24 +213,73 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
return true
|
||||
})
|
||||
|
||||
if (cache)
|
||||
this._linkedEntriesCache = {
|
||||
version: this._cacheVersion,
|
||||
hasMissingBoundSourceWidget,
|
||||
entries: deduplicatedEntries
|
||||
}
|
||||
|
||||
return deduplicatedEntries
|
||||
}
|
||||
|
||||
private _hasMissingBoundSourceWidget(): boolean {
|
||||
return this.inputs.some((input) => {
|
||||
const boundWidget =
|
||||
input._widget && isPromotedWidgetView(input._widget)
|
||||
? input._widget
|
||||
: undefined
|
||||
if (!boundWidget) return false
|
||||
|
||||
const boundNode = this.subgraph.getNodeById(boundWidget.sourceNodeId)
|
||||
return (
|
||||
boundNode?.widgets?.some(
|
||||
(widget) => widget.name === boundWidget.sourceWidgetName
|
||||
) !== true
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private _getPromotedViews(): PromotedWidgetView[] {
|
||||
const store = usePromotionStore()
|
||||
const entries = store.getPromotionsRef(this.rootGraph.id, this.id)
|
||||
const hasMissingBoundSourceWidget = this._hasMissingBoundSourceWidget()
|
||||
const cachedViews = this._promotedViewsCache
|
||||
if (
|
||||
cachedViews?.version === this._cacheVersion &&
|
||||
cachedViews.entriesRef === entries &&
|
||||
cachedViews.hasMissingBoundSourceWidget === hasMissingBoundSourceWidget
|
||||
)
|
||||
return cachedViews.views
|
||||
|
||||
const linkedEntries = this._getLinkedPromotionEntries()
|
||||
|
||||
const { displayNameByViewKey, reconcileEntries } =
|
||||
this._buildPromotionReconcileState(entries, linkedEntries)
|
||||
|
||||
return this._promotedViewManager.reconcile(reconcileEntries, (entry) =>
|
||||
createPromotedWidgetView(
|
||||
this,
|
||||
entry.interiorNodeId,
|
||||
entry.widgetName,
|
||||
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined
|
||||
)
|
||||
const views = this._promotedViewManager.reconcile(
|
||||
reconcileEntries,
|
||||
(entry) =>
|
||||
createPromotedWidgetView(
|
||||
this,
|
||||
entry.interiorNodeId,
|
||||
entry.widgetName,
|
||||
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined
|
||||
)
|
||||
)
|
||||
|
||||
this._promotedViewsCache = {
|
||||
version: this._cacheVersion,
|
||||
entriesRef: entries,
|
||||
hasMissingBoundSourceWidget,
|
||||
views
|
||||
}
|
||||
|
||||
return views
|
||||
}
|
||||
|
||||
private _invalidatePromotedViewsCache(): void {
|
||||
this._cacheVersion++
|
||||
}
|
||||
|
||||
private _syncPromotions(): void {
|
||||
@@ -163,10 +287,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
const store = usePromotionStore()
|
||||
const entries = store.getPromotionsRef(this.rootGraph.id, this.id)
|
||||
const linkedEntries = this._getLinkedPromotionEntries()
|
||||
const { mergedEntries, shouldPersistLinkedOnly } =
|
||||
this._buildPromotionPersistenceState(entries, linkedEntries)
|
||||
if (!shouldPersistLinkedOnly) return
|
||||
const linkedEntries = this._getLinkedPromotionEntries(false)
|
||||
// Intentionally preserve independent store promotions when linked coverage is partial;
|
||||
// tests assert that mixed linked/independent states must not collapse to linked-only.
|
||||
const { mergedEntries } = this._buildPromotionPersistenceState(
|
||||
entries,
|
||||
linkedEntries
|
||||
)
|
||||
|
||||
const hasChanged =
|
||||
mergedEntries.length !== entries.length ||
|
||||
@@ -181,7 +308,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
private _buildPromotionReconcileState(
|
||||
entries: Array<{ interiorNodeId: string; widgetName: string }>,
|
||||
entries: PromotionEntry[],
|
||||
linkedEntries: LinkedPromotionEntry[]
|
||||
): {
|
||||
displayNameByViewKey: Map<string, string>
|
||||
@@ -197,48 +324,64 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
)
|
||||
const linkedReconcileEntries =
|
||||
this._buildLinkedReconcileEntries(linkedEntries)
|
||||
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(linkedEntries)
|
||||
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(
|
||||
linkedEntries,
|
||||
fallbackStoredEntries
|
||||
)
|
||||
const reconcileEntries = shouldPersistLinkedOnly
|
||||
? linkedReconcileEntries
|
||||
: [...linkedReconcileEntries, ...fallbackStoredEntries]
|
||||
|
||||
return {
|
||||
displayNameByViewKey: this._buildDisplayNameByViewKey(linkedEntries),
|
||||
reconcileEntries: shouldPersistLinkedOnly
|
||||
? linkedReconcileEntries
|
||||
: [...linkedReconcileEntries, ...fallbackStoredEntries]
|
||||
reconcileEntries
|
||||
}
|
||||
}
|
||||
|
||||
private _buildPromotionPersistenceState(
|
||||
entries: Array<{ interiorNodeId: string; widgetName: string }>,
|
||||
entries: PromotionEntry[],
|
||||
linkedEntries: LinkedPromotionEntry[]
|
||||
): {
|
||||
mergedEntries: Array<{ interiorNodeId: string; widgetName: string }>
|
||||
shouldPersistLinkedOnly: boolean
|
||||
mergedEntries: PromotionEntry[]
|
||||
} {
|
||||
const { linkedPromotionEntries, fallbackStoredEntries } =
|
||||
this._collectLinkedAndFallbackEntries(entries, linkedEntries)
|
||||
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(linkedEntries)
|
||||
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(
|
||||
linkedEntries,
|
||||
fallbackStoredEntries
|
||||
)
|
||||
|
||||
return {
|
||||
mergedEntries: shouldPersistLinkedOnly
|
||||
? linkedPromotionEntries
|
||||
: [...linkedPromotionEntries, ...fallbackStoredEntries],
|
||||
shouldPersistLinkedOnly
|
||||
: [...linkedPromotionEntries, ...fallbackStoredEntries]
|
||||
}
|
||||
}
|
||||
|
||||
private _collectLinkedAndFallbackEntries(
|
||||
entries: Array<{ interiorNodeId: string; widgetName: string }>,
|
||||
entries: PromotionEntry[],
|
||||
linkedEntries: LinkedPromotionEntry[]
|
||||
): {
|
||||
linkedPromotionEntries: Array<{
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}>
|
||||
fallbackStoredEntries: Array<{ interiorNodeId: string; widgetName: string }>
|
||||
linkedPromotionEntries: PromotionEntry[]
|
||||
fallbackStoredEntries: PromotionEntry[]
|
||||
} {
|
||||
const linkedPromotionEntries = this._toPromotionEntries(linkedEntries)
|
||||
const fallbackStoredEntries = this._getFallbackStoredEntries(
|
||||
const excludedEntryKeys = new Set(
|
||||
linkedPromotionEntries.map((entry) =>
|
||||
this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName)
|
||||
)
|
||||
)
|
||||
const connectedEntryKeys = this._getConnectedPromotionEntryKeys()
|
||||
for (const key of connectedEntryKeys) {
|
||||
excludedEntryKeys.add(key)
|
||||
}
|
||||
|
||||
const prePruneFallbackStoredEntries = this._getFallbackStoredEntries(
|
||||
entries,
|
||||
excludedEntryKeys
|
||||
)
|
||||
const fallbackStoredEntries = this._pruneStaleAliasFallbackEntries(
|
||||
prePruneFallbackStoredEntries,
|
||||
linkedPromotionEntries
|
||||
)
|
||||
|
||||
@@ -249,14 +392,37 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
private _shouldPersistLinkedOnly(
|
||||
linkedEntries: LinkedPromotionEntry[]
|
||||
linkedEntries: LinkedPromotionEntry[],
|
||||
fallbackStoredEntries: PromotionEntry[]
|
||||
): boolean {
|
||||
return this.inputs.length > 0 && linkedEntries.length === this.inputs.length
|
||||
if (
|
||||
!(this.inputs.length > 0 && linkedEntries.length === this.inputs.length)
|
||||
)
|
||||
return false
|
||||
|
||||
const linkedWidgetNames = new Set(
|
||||
linkedEntries.map((entry) => entry.widgetName)
|
||||
)
|
||||
|
||||
const hasFallbackToKeep = fallbackStoredEntries.some((entry) => {
|
||||
const sourceNode = this.subgraph.getNodeById(entry.interiorNodeId)
|
||||
const hasSourceWidget =
|
||||
sourceNode?.widgets?.some(
|
||||
(widget) => widget.name === entry.widgetName
|
||||
) === true
|
||||
if (hasSourceWidget) return true
|
||||
|
||||
// If the fallback widget name overlaps a linked widget name, keep it
|
||||
// until aliasing can be positively proven.
|
||||
return linkedWidgetNames.has(entry.widgetName)
|
||||
})
|
||||
|
||||
return !hasFallbackToKeep
|
||||
}
|
||||
|
||||
private _toPromotionEntries(
|
||||
linkedEntries: LinkedPromotionEntry[]
|
||||
): Array<{ interiorNodeId: string; widgetName: string }> {
|
||||
): PromotionEntry[] {
|
||||
return linkedEntries.map(({ interiorNodeId, widgetName }) => ({
|
||||
interiorNodeId,
|
||||
widgetName
|
||||
@@ -264,33 +430,98 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
private _getFallbackStoredEntries(
|
||||
entries: Array<{ interiorNodeId: string; widgetName: string }>,
|
||||
linkedPromotionEntries: Array<{
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}>
|
||||
): Array<{ interiorNodeId: string; widgetName: string }> {
|
||||
const linkedKeys = new Set(
|
||||
linkedPromotionEntries.map((entry) =>
|
||||
this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName)
|
||||
)
|
||||
)
|
||||
entries: PromotionEntry[],
|
||||
excludedEntryKeys: Set<string>
|
||||
): PromotionEntry[] {
|
||||
return entries.filter(
|
||||
(entry) =>
|
||||
!linkedKeys.has(
|
||||
!excludedEntryKeys.has(
|
||||
this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private _pruneStaleAliasFallbackEntries(
|
||||
fallbackStoredEntries: PromotionEntry[],
|
||||
linkedPromotionEntries: PromotionEntry[]
|
||||
): PromotionEntry[] {
|
||||
if (
|
||||
fallbackStoredEntries.length === 0 ||
|
||||
linkedPromotionEntries.length === 0
|
||||
)
|
||||
return fallbackStoredEntries
|
||||
|
||||
const linkedConcreteKeys = new Set(
|
||||
linkedPromotionEntries
|
||||
.map((entry) => this._resolveConcretePromotionEntryKey(entry))
|
||||
.filter((key): key is string => key !== undefined)
|
||||
)
|
||||
if (linkedConcreteKeys.size === 0) return fallbackStoredEntries
|
||||
|
||||
const prunedEntries: PromotionEntry[] = []
|
||||
|
||||
for (const entry of fallbackStoredEntries) {
|
||||
const concreteKey = this._resolveConcretePromotionEntryKey(entry)
|
||||
if (concreteKey && linkedConcreteKeys.has(concreteKey)) continue
|
||||
|
||||
prunedEntries.push(entry)
|
||||
}
|
||||
|
||||
return prunedEntries
|
||||
}
|
||||
|
||||
private _resolveConcretePromotionEntryKey(
|
||||
entry: PromotionEntry
|
||||
): string | undefined {
|
||||
const result = resolveConcretePromotedWidget(
|
||||
this,
|
||||
entry.interiorNodeId,
|
||||
entry.widgetName
|
||||
)
|
||||
if (result.status !== 'resolved') return undefined
|
||||
|
||||
return this._makePromotionEntryKey(
|
||||
String(result.resolved.node.id),
|
||||
result.resolved.widget.name
|
||||
)
|
||||
}
|
||||
|
||||
private _getConnectedPromotionEntryKeys(): Set<string> {
|
||||
const connectedEntryKeys = new Set<string>()
|
||||
|
||||
for (const input of this.inputs) {
|
||||
const subgraphInput = input._subgraphSlot
|
||||
if (!subgraphInput) continue
|
||||
|
||||
const connectedWidgets = subgraphInput.getConnectedWidgets()
|
||||
|
||||
for (const widget of connectedWidgets) {
|
||||
if (!hasWidgetNode(widget)) continue
|
||||
|
||||
connectedEntryKeys.add(
|
||||
this._makePromotionEntryKey(String(widget.node.id), widget.name)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return connectedEntryKeys
|
||||
}
|
||||
|
||||
private _buildLinkedReconcileEntries(
|
||||
linkedEntries: LinkedPromotionEntry[]
|
||||
): Array<{ interiorNodeId: string; widgetName: string; viewKey: string }> {
|
||||
return linkedEntries.map(({ inputName, interiorNodeId, widgetName }) => ({
|
||||
interiorNodeId,
|
||||
widgetName,
|
||||
viewKey: this._makePromotionViewKey(inputName, interiorNodeId, widgetName)
|
||||
}))
|
||||
return linkedEntries.map(
|
||||
({ inputKey, inputName, interiorNodeId, widgetName }) => ({
|
||||
interiorNodeId,
|
||||
widgetName,
|
||||
viewKey: this._makePromotionViewKey(
|
||||
inputKey,
|
||||
interiorNodeId,
|
||||
widgetName,
|
||||
inputName
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private _buildDisplayNameByViewKey(
|
||||
@@ -299,9 +530,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
return new Map(
|
||||
linkedEntries.map((entry) => [
|
||||
this._makePromotionViewKey(
|
||||
entry.inputName,
|
||||
entry.inputKey,
|
||||
entry.interiorNodeId,
|
||||
entry.widgetName
|
||||
entry.widgetName,
|
||||
entry.inputName
|
||||
),
|
||||
entry.inputName
|
||||
])
|
||||
@@ -316,11 +548,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
private _makePromotionViewKey(
|
||||
inputName: string,
|
||||
inputKey: string,
|
||||
interiorNodeId: string,
|
||||
widgetName: string
|
||||
widgetName: string,
|
||||
inputName = ''
|
||||
): string {
|
||||
return `${inputName}:${interiorNodeId}:${widgetName}`
|
||||
return JSON.stringify([inputKey, interiorNodeId, widgetName, inputName])
|
||||
}
|
||||
|
||||
private _resolveLegacyEntry(
|
||||
@@ -378,22 +611,34 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
(e) => {
|
||||
const subgraphInput = e.detail.input
|
||||
const { name, type } = subgraphInput
|
||||
const existingInput = this.inputs.find((i) => i.name === name)
|
||||
const existingInput = this.inputs.find(
|
||||
(input) => input._subgraphSlot === subgraphInput
|
||||
)
|
||||
if (existingInput) {
|
||||
const linkId = subgraphInput.linkIds[0]
|
||||
const { inputNode, input } = subgraph.links[linkId].resolve(subgraph)
|
||||
const widget = inputNode?.widgets?.find?.((w) => w.name === name)
|
||||
if (widget && inputNode)
|
||||
if (linkId === undefined) return
|
||||
|
||||
const link = this.subgraph.getLink(linkId)
|
||||
if (!link) return
|
||||
|
||||
const { inputNode, input } = link.resolve(subgraph)
|
||||
if (!inputNode || !input) return
|
||||
|
||||
const widget = inputNode.getWidgetFromSlot(input)
|
||||
if (widget)
|
||||
this._setWidget(
|
||||
subgraphInput,
|
||||
existingInput,
|
||||
widget,
|
||||
input?.widget,
|
||||
input.widget,
|
||||
inputNode
|
||||
)
|
||||
return
|
||||
}
|
||||
const input = this.addInput(name, type)
|
||||
const input = this.addInput(name, type, {
|
||||
_subgraphSlot: subgraphInput
|
||||
})
|
||||
this._invalidatePromotedViewsCache()
|
||||
|
||||
this._addSubgraphInputListeners(subgraphInput, input)
|
||||
},
|
||||
@@ -407,6 +652,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
if (widget) this.ensureWidgetRemoved(widget)
|
||||
|
||||
this.removeInput(e.detail.index)
|
||||
this._invalidatePromotedViewsCache()
|
||||
this._syncPromotions()
|
||||
this.setDirtyCanvas(true, true)
|
||||
},
|
||||
@@ -442,6 +688,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
if (input._widget) {
|
||||
input._widget.label = newName
|
||||
}
|
||||
this._invalidatePromotedViewsCache()
|
||||
this.graph?.trigger('node:slot-label:changed', {
|
||||
nodeId: this.id,
|
||||
slotType: NodeSlotType.INPUT
|
||||
@@ -493,6 +740,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
subgraphInput: SubgraphInput,
|
||||
input: INodeInputSlot & Partial<ISubgraphInput>
|
||||
) {
|
||||
input._subgraphSlot = subgraphInput
|
||||
|
||||
if (
|
||||
input._listenerController &&
|
||||
typeof input._listenerController.abort === 'function'
|
||||
@@ -505,36 +754,39 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
subgraphInput.events.addEventListener(
|
||||
'input-connected',
|
||||
(e) => {
|
||||
const widget = subgraphInput._widget
|
||||
if (!widget) return
|
||||
this._invalidatePromotedViewsCache()
|
||||
|
||||
// If this widget is already promoted, demote it first
|
||||
// so it transitions cleanly to being linked via SubgraphInput.
|
||||
// `SubgraphInput.connect()` dispatches before appending to `linkIds`,
|
||||
// so resolve by current links would miss this new connection.
|
||||
// Keep the earliest bound view once present, and only bind from event
|
||||
// payload when this input has no representative yet.
|
||||
const nodeId = String(e.detail.node.id)
|
||||
if (
|
||||
usePromotionStore().isPromoted(
|
||||
this.rootGraph.id,
|
||||
this.id,
|
||||
nodeId,
|
||||
widget.name
|
||||
e.detail.widget.name
|
||||
)
|
||||
) {
|
||||
usePromotionStore().demote(
|
||||
this.rootGraph.id,
|
||||
this.id,
|
||||
nodeId,
|
||||
widget.name
|
||||
e.detail.widget.name
|
||||
)
|
||||
}
|
||||
|
||||
const widgetLocator = e.detail.input.widget
|
||||
this._setWidget(
|
||||
subgraphInput,
|
||||
input,
|
||||
widget,
|
||||
widgetLocator,
|
||||
e.detail.node
|
||||
)
|
||||
const didSetWidgetFromEvent = !input._widget
|
||||
if (didSetWidgetFromEvent)
|
||||
this._setWidget(
|
||||
subgraphInput,
|
||||
input,
|
||||
e.detail.widget,
|
||||
e.detail.input.widget,
|
||||
e.detail.node
|
||||
)
|
||||
|
||||
this._syncPromotions()
|
||||
},
|
||||
{ signal }
|
||||
@@ -543,9 +795,15 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
subgraphInput.events.addEventListener(
|
||||
'input-disconnected',
|
||||
() => {
|
||||
// If the input is connected to more than one widget, don't remove the widget
|
||||
this._invalidatePromotedViewsCache()
|
||||
|
||||
// If links remain, rebind to the current representative.
|
||||
const connectedWidgets = subgraphInput.getConnectedWidgets()
|
||||
if (connectedWidgets.length > 0) return
|
||||
if (connectedWidgets.length > 0) {
|
||||
this._resolveInputWidget(subgraphInput, input)
|
||||
this._syncPromotions()
|
||||
return
|
||||
}
|
||||
|
||||
if (input._widget) this.ensureWidgetRemoved(input._widget)
|
||||
|
||||
@@ -558,6 +816,62 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
)
|
||||
}
|
||||
|
||||
private _rebindInputSubgraphSlots(): void {
|
||||
this._invalidatePromotedViewsCache()
|
||||
|
||||
const subgraphSlots = [...this.subgraph.inputNode.slots]
|
||||
const slotsBySignature = new Map<string, SubgraphInput[]>()
|
||||
const slotsByName = new Map<string, SubgraphInput[]>()
|
||||
|
||||
for (const slot of subgraphSlots) {
|
||||
const signature = `${slot.name}:${String(slot.type)}`
|
||||
const signatureSlots = slotsBySignature.get(signature)
|
||||
if (signatureSlots) {
|
||||
signatureSlots.push(slot)
|
||||
} else {
|
||||
slotsBySignature.set(signature, [slot])
|
||||
}
|
||||
|
||||
const nameSlots = slotsByName.get(slot.name)
|
||||
if (nameSlots) {
|
||||
nameSlots.push(slot)
|
||||
} else {
|
||||
slotsByName.set(slot.name, [slot])
|
||||
}
|
||||
}
|
||||
|
||||
const assignedSlotIds = new Set<string>()
|
||||
const takeUnassignedSlot = (
|
||||
slots: SubgraphInput[] | undefined
|
||||
): SubgraphInput | undefined => {
|
||||
if (!slots) return undefined
|
||||
return slots.find((slot) => !assignedSlotIds.has(String(slot.id)))
|
||||
}
|
||||
|
||||
for (const input of this.inputs) {
|
||||
const existingSlot = input._subgraphSlot
|
||||
if (
|
||||
existingSlot &&
|
||||
this.subgraph.inputNode.slots.some((slot) => slot === existingSlot)
|
||||
) {
|
||||
assignedSlotIds.add(String(existingSlot.id))
|
||||
continue
|
||||
}
|
||||
|
||||
const signature = `${input.name}:${String(input.type)}`
|
||||
const matchedSlot =
|
||||
takeUnassignedSlot(slotsBySignature.get(signature)) ??
|
||||
takeUnassignedSlot(slotsByName.get(input.name))
|
||||
|
||||
if (matchedSlot) {
|
||||
input._subgraphSlot = matchedSlot
|
||||
assignedSlotIds.add(String(matchedSlot.id))
|
||||
} else {
|
||||
delete input._subgraphSlot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override configure(info: ExportedSubgraphInstance): void {
|
||||
for (const input of this.inputs) {
|
||||
if (
|
||||
@@ -570,8 +884,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
this.inputs.length = 0
|
||||
this.inputs.push(
|
||||
...this.subgraph.inputNode.slots.map(
|
||||
(slot) =>
|
||||
...this.subgraph.inputNode.slots.map((slot) =>
|
||||
Object.assign(
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: slot.name,
|
||||
@@ -581,7 +895,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
link: null
|
||||
},
|
||||
this
|
||||
)
|
||||
),
|
||||
{
|
||||
_subgraphSlot: slot
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -606,6 +924,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
override _internalConfigureAfterSlots() {
|
||||
this._rebindInputSubgraphSlots()
|
||||
|
||||
// Ensure proxyWidgets is initialized so it serializes
|
||||
this.properties.proxyWidgets ??= []
|
||||
|
||||
@@ -613,10 +933,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
// Do NOT clear properties.proxyWidgets — it was already populated
|
||||
// from serialized data by super.configure(info) before this runs.
|
||||
this._promotedViewManager.clear()
|
||||
this._invalidatePromotedViewsCache()
|
||||
|
||||
// Hydrate the store from serialized properties.proxyWidgets
|
||||
const raw = parseProxyWidgets(this.properties.proxyWidgets)
|
||||
const store = usePromotionStore()
|
||||
|
||||
const entries = raw
|
||||
.map(([nodeId, widgetName]) => {
|
||||
if (nodeId === '-1') {
|
||||
@@ -633,6 +955,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
return { interiorNodeId: nodeId, widgetName }
|
||||
})
|
||||
.filter((e): e is NonNullable<typeof e> => e !== null)
|
||||
|
||||
store.setPromotions(this.rootGraph.id, this.id, entries)
|
||||
|
||||
// Write back resolved entries so legacy -1 format doesn't persist
|
||||
@@ -645,9 +968,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
// Check all inputs for connected widgets
|
||||
for (const input of this.inputs) {
|
||||
const subgraphInput = this.subgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === input.name
|
||||
)
|
||||
const subgraphInput = input._subgraphSlot
|
||||
if (!subgraphInput) {
|
||||
// Skip inputs that don't exist in the subgraph definition
|
||||
// This can happen when loading workflows with dynamically added inputs
|
||||
@@ -711,6 +1032,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
inputWidget: IWidgetLocator | undefined,
|
||||
interiorNode: LGraphNode
|
||||
) {
|
||||
this._invalidatePromotedViewsCache()
|
||||
this._flushPendingPromotions()
|
||||
|
||||
const nodeId = String(interiorNode.id)
|
||||
@@ -760,8 +1082,18 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
nodeId,
|
||||
widgetName,
|
||||
() =>
|
||||
createPromotedWidgetView(this, nodeId, widgetName, subgraphInput.name),
|
||||
this._makePromotionViewKey(subgraphInput.name, nodeId, widgetName)
|
||||
createPromotedWidgetView(
|
||||
this,
|
||||
nodeId,
|
||||
widgetName,
|
||||
input.label ?? subgraphInput.name
|
||||
),
|
||||
this._makePromotionViewKey(
|
||||
String(subgraphInput.id),
|
||||
nodeId,
|
||||
widgetName,
|
||||
input.label ?? input.name
|
||||
)
|
||||
)
|
||||
|
||||
// NOTE: This code creates linked chains of prototypes for passing across
|
||||
@@ -817,6 +1149,20 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
return super.addInput(name, type, inputProperties)
|
||||
}
|
||||
|
||||
override getSlotFromWidget(
|
||||
widget: IBaseWidget | undefined
|
||||
): INodeInputSlot | undefined {
|
||||
if (!widget || !isPromotedWidgetView(widget))
|
||||
return super.getSlotFromWidget(widget)
|
||||
|
||||
return this.inputs.find((input) => input._widget === widget)
|
||||
}
|
||||
|
||||
override getWidgetFromSlot(slot: INodeInputSlot): IBaseWidget | undefined {
|
||||
if (slot._widget) return slot._widget
|
||||
return super.getWidgetFromSlot(slot)
|
||||
}
|
||||
|
||||
override getInputLink(slot: number): LLink | null {
|
||||
// Output side: the link from inside the subgraph
|
||||
const innerLink = this.subgraph.outputNode.slots[slot].getLinks().at(0)
|
||||
@@ -946,18 +1292,24 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
private _removePromotedView(view: PromotedWidgetView): void {
|
||||
this._invalidatePromotedViewsCache()
|
||||
|
||||
this._promotedViewManager.remove(view.sourceNodeId, view.sourceWidgetName)
|
||||
// Reconciled views can also be keyed by inputName-scoped view keys.
|
||||
// Remove both key shapes to avoid stale cache entries across promote/rebind flows.
|
||||
this._promotedViewManager.removeByViewKey(
|
||||
view.sourceNodeId,
|
||||
view.sourceWidgetName,
|
||||
this._makePromotionViewKey(
|
||||
view.name,
|
||||
for (const input of this.inputs) {
|
||||
if (input._widget !== view || !input._subgraphSlot) continue
|
||||
const inputName = input.label ?? input.name
|
||||
|
||||
this._promotedViewManager.removeByViewKey(
|
||||
view.sourceNodeId,
|
||||
view.sourceWidgetName
|
||||
view.sourceWidgetName,
|
||||
this._makePromotionViewKey(
|
||||
String(input._subgraphSlot.id),
|
||||
view.sourceNodeId,
|
||||
view.sourceWidgetName,
|
||||
inputName
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override removeWidget(widget: IBaseWidget): void {
|
||||
@@ -996,6 +1348,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
override onRemoved(): void {
|
||||
this._eventAbortController.abort()
|
||||
this._invalidatePromotedViewsCache()
|
||||
|
||||
for (const widget of this.widgets) {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
@@ -1062,9 +1415,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
for (const input of this.inputs) {
|
||||
if (!input._widget) continue
|
||||
|
||||
const subgraphInput = this.subgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === input.name
|
||||
)
|
||||
const subgraphInput =
|
||||
input._subgraphSlot ??
|
||||
this.subgraph.inputNode.slots.find((slot) => slot.name === input.name)
|
||||
if (!subgraphInput) continue
|
||||
|
||||
const connectedWidgets = subgraphInput.getConnectedWidgets()
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import type { ISerialisedGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
|
||||
export const subgraphComplexPromotion1 = {
|
||||
id: 'e49902fa-ee3e-40e6-a59e-c8931888ad0e',
|
||||
revision: 0,
|
||||
last_node_id: 21,
|
||||
last_link_id: 23,
|
||||
nodes: [
|
||||
{
|
||||
id: 12,
|
||||
type: 'PreviewAny',
|
||||
pos: [1367.8236034435063, 305.51100163315823],
|
||||
size: [225, 166],
|
||||
flags: {},
|
||||
order: 3,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
name: 'source',
|
||||
type: '*',
|
||||
link: 21
|
||||
}
|
||||
],
|
||||
outputs: [],
|
||||
properties: {
|
||||
'Node name for S&R': 'PreviewAny'
|
||||
},
|
||||
widgets_values: [null, null, null]
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
type: 'PreviewAny',
|
||||
pos: [1271.9742739655217, 551.9124470179938],
|
||||
size: [225, 166],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
name: 'source',
|
||||
type: '*',
|
||||
link: 19
|
||||
}
|
||||
],
|
||||
outputs: [],
|
||||
properties: {
|
||||
'Node name for S&R': 'PreviewAny'
|
||||
},
|
||||
widgets_values: [null, null, null]
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
type: 'PreviewAny',
|
||||
pos: [1414.8695925586444, 847.9456885036253],
|
||||
size: [225, 166],
|
||||
flags: {},
|
||||
order: 2,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
name: 'source',
|
||||
type: '*',
|
||||
link: 20
|
||||
}
|
||||
],
|
||||
outputs: [],
|
||||
properties: {
|
||||
'Node name for S&R': 'PreviewAny'
|
||||
},
|
||||
widgets_values: [null, null, null]
|
||||
},
|
||||
{
|
||||
id: 21,
|
||||
type: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f',
|
||||
pos: [741.0375276545419, 560.8496560588814],
|
||||
size: [225, 305.3333435058594],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{
|
||||
name: 'STRING',
|
||||
type: 'STRING',
|
||||
links: [19]
|
||||
},
|
||||
{
|
||||
name: 'STRING_1',
|
||||
type: 'STRING',
|
||||
links: [20]
|
||||
},
|
||||
{
|
||||
name: 'STRING_2',
|
||||
type: 'STRING',
|
||||
links: [21]
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
proxyWidgets: [
|
||||
['20', 'string_a'],
|
||||
['19', 'string_a'],
|
||||
['18', 'string_a']
|
||||
]
|
||||
},
|
||||
widgets_values: []
|
||||
}
|
||||
],
|
||||
links: [
|
||||
[19, 21, 0, 13, 0, 'STRING'],
|
||||
[20, 21, 1, 14, 0, 'STRING'],
|
||||
[21, 21, 2, 12, 0, 'STRING']
|
||||
],
|
||||
groups: [],
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
{
|
||||
id: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f',
|
||||
version: 1,
|
||||
state: {
|
||||
lastGroupId: 0,
|
||||
lastNodeId: 21,
|
||||
lastLinkId: 23,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
revision: 0,
|
||||
config: {},
|
||||
name: 'New Subgraph',
|
||||
inputNode: {
|
||||
id: -10,
|
||||
bounding: [596.9206067268835, 805.5404332481304, 120, 60]
|
||||
},
|
||||
outputNode: {
|
||||
id: -20,
|
||||
bounding: [1376.7286067268833, 769.5404332481304, 120, 100]
|
||||
},
|
||||
inputs: [
|
||||
{
|
||||
id: '78479bf4-8145-41d5-9d11-c38e3149fc59',
|
||||
name: 'string_a',
|
||||
type: 'STRING',
|
||||
linkIds: [22, 23],
|
||||
pos: [696.9206067268835, 825.5404332481304]
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
id: 'aa263e4e-b558-4dbf-bcb9-ff0c1c72cbef',
|
||||
name: 'STRING',
|
||||
type: 'STRING',
|
||||
linkIds: [16],
|
||||
localized_name: 'STRING',
|
||||
pos: [1396.7286067268833, 789.5404332481304]
|
||||
},
|
||||
{
|
||||
id: '8eee6fe3-dc2f-491a-9e01-04ef83309dad',
|
||||
name: 'STRING_1',
|
||||
type: 'STRING',
|
||||
linkIds: [17],
|
||||
localized_name: 'STRING_1',
|
||||
pos: [1396.7286067268833, 809.5404332481304]
|
||||
},
|
||||
{
|
||||
id: 'a446d5b9-6042-434d-848a-5d3af5e8e0d4',
|
||||
name: 'STRING_2',
|
||||
type: 'STRING',
|
||||
linkIds: [18],
|
||||
localized_name: 'STRING_2',
|
||||
pos: [1396.7286067268833, 829.5404332481304]
|
||||
}
|
||||
],
|
||||
widgets: [],
|
||||
nodes: [
|
||||
{
|
||||
id: 18,
|
||||
type: 'StringConcatenate',
|
||||
pos: [818.5102631756379, 706.4562049408103],
|
||||
size: [480, 268],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
localized_name: 'string_a',
|
||||
name: 'string_a',
|
||||
type: 'STRING',
|
||||
widget: {
|
||||
name: 'string_a'
|
||||
},
|
||||
link: 23
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
localized_name: 'STRING',
|
||||
name: 'STRING',
|
||||
type: 'STRING',
|
||||
links: [16]
|
||||
}
|
||||
],
|
||||
title: 'InnerCatB',
|
||||
properties: {
|
||||
'Node name for S&R': 'StringConcatenate'
|
||||
},
|
||||
widgets_values: ['Poop', '_B', '']
|
||||
},
|
||||
{
|
||||
id: 19,
|
||||
type: 'StringConcatenate',
|
||||
pos: [812.9370280206649, 1040.648423402667],
|
||||
size: [480, 268],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{
|
||||
localized_name: 'STRING',
|
||||
name: 'STRING',
|
||||
type: 'STRING',
|
||||
links: [17]
|
||||
}
|
||||
],
|
||||
title: 'InnerCatC',
|
||||
properties: {
|
||||
'Node name for S&R': 'StringConcatenate'
|
||||
},
|
||||
widgets_values: ['', '_C', '']
|
||||
},
|
||||
{
|
||||
id: 20,
|
||||
type: 'StringConcatenate',
|
||||
pos: [824.7110975088726, 386.4230523609899],
|
||||
size: [480, 268],
|
||||
flags: {},
|
||||
order: 2,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
localized_name: 'string_a',
|
||||
name: 'string_a',
|
||||
type: 'STRING',
|
||||
widget: {
|
||||
name: 'string_a'
|
||||
},
|
||||
link: 22
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
localized_name: 'STRING',
|
||||
name: 'STRING',
|
||||
type: 'STRING',
|
||||
links: [18]
|
||||
}
|
||||
],
|
||||
title: 'InnerCatA',
|
||||
properties: {
|
||||
'Node name for S&R': 'StringConcatenate'
|
||||
},
|
||||
widgets_values: ['Poop', '_A', '']
|
||||
}
|
||||
],
|
||||
groups: [],
|
||||
links: [
|
||||
{
|
||||
id: 16,
|
||||
origin_id: 18,
|
||||
origin_slot: 0,
|
||||
target_id: -20,
|
||||
target_slot: 0,
|
||||
type: 'STRING'
|
||||
},
|
||||
{
|
||||
id: 17,
|
||||
origin_id: 19,
|
||||
origin_slot: 0,
|
||||
target_id: -20,
|
||||
target_slot: 1,
|
||||
type: 'STRING'
|
||||
},
|
||||
{
|
||||
id: 18,
|
||||
origin_id: 20,
|
||||
origin_slot: 0,
|
||||
target_id: -20,
|
||||
target_slot: 2,
|
||||
type: 'STRING'
|
||||
},
|
||||
{
|
||||
id: 22,
|
||||
origin_id: -10,
|
||||
origin_slot: 0,
|
||||
target_id: 20,
|
||||
target_slot: 0,
|
||||
type: 'STRING'
|
||||
},
|
||||
{
|
||||
id: 23,
|
||||
origin_id: -10,
|
||||
origin_slot: 0,
|
||||
target_id: 18,
|
||||
target_slot: 0,
|
||||
type: 'STRING'
|
||||
}
|
||||
],
|
||||
extra: {
|
||||
workflowRendererVersion: 'Vue'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
config: {},
|
||||
extra: {
|
||||
ds: {
|
||||
scale: 0.6638894832438259,
|
||||
offset: [-408.2009703049473, -183.8039508449224]
|
||||
},
|
||||
workflowRendererVersion: 'Vue',
|
||||
frontendVersion: '1.42.3'
|
||||
},
|
||||
version: 0.4
|
||||
} as const as unknown as ISerialisedGraph
|
||||
@@ -0,0 +1,32 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
cleanupComplexPromotionFixtureNodeType,
|
||||
setupComplexPromotionFixture
|
||||
} from './subgraphHelpers'
|
||||
|
||||
const FIXTURE_STRING_CONCAT_TYPE = 'Fixture/StringConcatenate'
|
||||
|
||||
describe('setupComplexPromotionFixture', () => {
|
||||
afterEach(() => {
|
||||
cleanupComplexPromotionFixtureNodeType()
|
||||
})
|
||||
|
||||
it('can clean up the globally registered fixture node type', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
setupComplexPromotionFixture()
|
||||
expect(
|
||||
LiteGraph.registered_node_types[FIXTURE_STRING_CONCAT_TYPE]
|
||||
).toBeDefined()
|
||||
|
||||
cleanupComplexPromotionFixtureNodeType()
|
||||
expect(
|
||||
LiteGraph.registered_node_types[FIXTURE_STRING_CONCAT_TYPE]
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,12 @@
|
||||
import { expect } from 'vitest'
|
||||
|
||||
import type { ISlotType, NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraph, LGraphNode, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type {
|
||||
ExportedSubgraph,
|
||||
@@ -17,6 +22,27 @@ import type {
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { createUuidv4 } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
import { subgraphComplexPromotion1 } from './subgraphComplexPromotion1'
|
||||
|
||||
const FIXTURE_STRING_CONCAT_TYPE = 'Fixture/StringConcatenate'
|
||||
|
||||
class FixtureStringConcatenateNode extends LGraphNode {
|
||||
constructor() {
|
||||
super('StringConcatenate')
|
||||
const input = this.addInput('string_a', 'STRING')
|
||||
input.widget = { name: 'string_a' }
|
||||
this.addOutput('STRING', 'STRING')
|
||||
this.addWidget('text', 'string_a', '', () => {})
|
||||
this.addWidget('text', 'string_b', '', () => {})
|
||||
this.addWidget('text', 'delimiter', '', () => {})
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanupComplexPromotionFixtureNodeType(): void {
|
||||
if (!LiteGraph.registered_node_types[FIXTURE_STRING_CONCAT_TYPE]) return
|
||||
LiteGraph.unregisterNodeType(FIXTURE_STRING_CONCAT_TYPE)
|
||||
}
|
||||
|
||||
interface TestSubgraphOptions {
|
||||
id?: UUID
|
||||
name?: string
|
||||
@@ -209,6 +235,48 @@ export function createTestSubgraphNode(
|
||||
return new SubgraphNode(parentGraph, subgraph, instanceData)
|
||||
}
|
||||
|
||||
export function setupComplexPromotionFixture(): {
|
||||
graph: LGraph
|
||||
subgraph: Subgraph
|
||||
hostNode: SubgraphNode
|
||||
} {
|
||||
const fixture = structuredClone(subgraphComplexPromotion1)
|
||||
const subgraphData = fixture.definitions?.subgraphs?.[0]
|
||||
if (!subgraphData)
|
||||
throw new Error('Expected fixture to contain one subgraph definition')
|
||||
|
||||
cleanupComplexPromotionFixtureNodeType()
|
||||
LiteGraph.registerNodeType(
|
||||
FIXTURE_STRING_CONCAT_TYPE,
|
||||
FixtureStringConcatenateNode
|
||||
)
|
||||
|
||||
for (const node of subgraphData.nodes as Array<{ type: string }>) {
|
||||
if (node.type === 'StringConcatenate')
|
||||
node.type = FIXTURE_STRING_CONCAT_TYPE
|
||||
}
|
||||
|
||||
const hostNodeData = fixture.nodes.find((node) => node.id === 21)
|
||||
if (!hostNodeData)
|
||||
throw new Error('Expected fixture to contain subgraph instance node id 21')
|
||||
|
||||
const graph = new LGraph()
|
||||
const subgraph = graph.createSubgraph(subgraphData as ExportedSubgraph)
|
||||
subgraph.configure(subgraphData as ExportedSubgraph)
|
||||
const hostNode = new SubgraphNode(
|
||||
graph,
|
||||
subgraph,
|
||||
hostNodeData as ExportedSubgraphInstance
|
||||
)
|
||||
graph.add(hostNode)
|
||||
|
||||
return {
|
||||
graph,
|
||||
subgraph,
|
||||
hostNode
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a nested hierarchy of subgraphs for testing deep nesting scenarios.
|
||||
* @param options Configuration for the nested structure
|
||||
|
||||
@@ -326,6 +326,8 @@
|
||||
"nightly": "NIGHTLY",
|
||||
"profile": "Profile",
|
||||
"noItems": "No items",
|
||||
"preloadError": "A required resource failed to load. Please reload the page.",
|
||||
"preloadErrorTitle": "Loading Error",
|
||||
"recents": "Recents",
|
||||
"partner": "Partner",
|
||||
"collapseAll": "Collapse all",
|
||||
|
||||
@@ -217,22 +217,6 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
commandId: 'Comfy.Canvas.SelectAll',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
ctrl: true,
|
||||
key: 'c'
|
||||
},
|
||||
commandId: 'Comfy.Canvas.CopySelected',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
ctrl: true,
|
||||
key: 'v'
|
||||
},
|
||||
commandId: 'Comfy.Canvas.PasteFromClipboard',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
ctrl: true,
|
||||
|
||||
@@ -141,7 +141,7 @@ describe('keybindingService - Canvas Keybindings', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute CopySelected for Ctrl+C on canvas', async () => {
|
||||
it('should not intercept Ctrl+C to allow native copy event', async () => {
|
||||
const event = createTestKeyboardEvent('c', {
|
||||
ctrlKey: true,
|
||||
target: canvasChild
|
||||
@@ -149,12 +149,10 @@ describe('keybindingService - Canvas Keybindings', () => {
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.CopySelected'
|
||||
)
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should execute PasteFromClipboard for Ctrl+V on canvas', async () => {
|
||||
it('should not intercept Ctrl+V to allow native paste event', async () => {
|
||||
const event = createTestKeyboardEvent('v', {
|
||||
ctrlKey: true,
|
||||
target: canvasChild
|
||||
@@ -162,9 +160,7 @@ describe('keybindingService - Canvas Keybindings', () => {
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.PasteFromClipboard'
|
||||
)
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should execute PasteFromClipboardWithConnect for Ctrl+Shift+V on canvas', async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { OverridedMixpanel } from 'mixpanel-browser'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import {
|
||||
checkForCompletedTopup as checkTopupUtil,
|
||||
@@ -278,6 +279,7 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}): void {
|
||||
const executionContext = getExecutionContext()
|
||||
const { mode, isAppMode } = useAppMode()
|
||||
|
||||
const runButtonProperties: RunButtonProperties = {
|
||||
subscribe_to_run: options?.subscribe_to_run || false,
|
||||
@@ -290,7 +292,9 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
api_node_names: executionContext.api_node_names,
|
||||
has_toolkit_nodes: executionContext.has_toolkit_nodes,
|
||||
toolkit_node_names: executionContext.toolkit_node_names,
|
||||
trigger_source: options?.trigger_source
|
||||
trigger_source: options?.trigger_source,
|
||||
view_mode: mode.value,
|
||||
is_app_mode: isAppMode.value
|
||||
}
|
||||
|
||||
this.lastTriggerSource = options?.trigger_source
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PostHog } from 'posthog-js'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
@@ -277,6 +278,7 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}): void {
|
||||
const executionContext = getExecutionContext()
|
||||
const { mode, isAppMode } = useAppMode()
|
||||
|
||||
const runButtonProperties: RunButtonProperties = {
|
||||
subscribe_to_run: options?.subscribe_to_run || false,
|
||||
@@ -289,7 +291,9 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
api_node_names: executionContext.api_node_names,
|
||||
has_toolkit_nodes: executionContext.has_toolkit_nodes,
|
||||
toolkit_node_names: executionContext.toolkit_node_names,
|
||||
trigger_source: options?.trigger_source
|
||||
trigger_source: options?.trigger_source,
|
||||
view_mode: mode.value,
|
||||
is_app_mode: isAppMode.value
|
||||
}
|
||||
|
||||
this.lastTriggerSource = options?.trigger_source
|
||||
|
||||
@@ -63,6 +63,8 @@ export interface RunButtonProperties {
|
||||
has_toolkit_nodes: boolean
|
||||
toolkit_node_names: string[]
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
view_mode?: string
|
||||
is_app_mode?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -78,6 +78,7 @@ vi.mock('vue-i18n', () => ({
|
||||
|
||||
const mockShowLayoutDialog = vi.hoisted(() => vi.fn())
|
||||
const mockCloseDialog = vi.hoisted(() => vi.fn())
|
||||
const mockHideTemplateSelector = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
@@ -91,6 +92,12 @@ vi.mock('@/stores/dialogStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useWorkflowTemplateSelectorDialog', () => ({
|
||||
useWorkflowTemplateSelectorDialog: () => ({
|
||||
hide: mockHideTemplateSelector
|
||||
})
|
||||
}))
|
||||
|
||||
function makePayload(
|
||||
overrides: Partial<SharedWorkflowPayload> = {}
|
||||
): SharedWorkflowPayload {
|
||||
@@ -173,6 +180,18 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('hides template selector when user confirms opening shared workflow', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
mockShowLayoutDialog.mockImplementation(() => {
|
||||
resolveDialogWithConfirm(makePayload())
|
||||
})
|
||||
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(mockHideTemplateSelector).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not load graph when user cancels dialog', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
mockShowLayoutDialog.mockImplementation(() => {
|
||||
@@ -190,6 +209,18 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not hide template selector when user cancels shared workflow dialog', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
mockShowLayoutDialog.mockImplementation(() => {
|
||||
resolveDialogWithCancel()
|
||||
})
|
||||
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(mockHideTemplateSelector).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls import when non-owned assets exist and user confirms', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
const payload = makePayload({
|
||||
@@ -241,6 +272,18 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
expect(mockImportPublishedAssets).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides template selector when user chooses open-only', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
mockShowLayoutDialog.mockImplementation(() => {
|
||||
resolveDialogWithOpenOnly(makePayload())
|
||||
})
|
||||
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(mockHideTemplateSelector).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows toast on import failure and returns loaded-without-assets', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
const payload = makePayload({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useToast } from 'primevue/usetoast'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
|
||||
import OpenSharedWorkflowDialogContent from '@/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue'
|
||||
import type { SharedWorkflowPayload } from '@/platform/workflow/sharing/types/shareTypes'
|
||||
import {
|
||||
@@ -35,6 +36,7 @@ export function useSharedWorkflowUrlLoader() {
|
||||
const workflowShareService = useWorkflowShareService()
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
|
||||
const SHARE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.SHARE
|
||||
|
||||
function isValidParameter(param: string): boolean {
|
||||
@@ -133,6 +135,8 @@ export function useSharedWorkflowUrlLoader() {
|
||||
return 'cancelled'
|
||||
}
|
||||
|
||||
templateSelectorDialog.hide()
|
||||
|
||||
const { payload } = result
|
||||
const workflowName = payload.name || t('openSharedWorkflow.dialogTitle')
|
||||
const nonOwnedAssets = payload.assets.filter((a) => !a.in_library)
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
SafeWidgetData,
|
||||
VueNodeData
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: {
|
||||
graph: {
|
||||
rootGraph: {
|
||||
id: 'graph-test'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
describe('NodeWidgets', () => {
|
||||
const createMockWidget = (
|
||||
overrides: Partial<SafeWidgetData> = {}
|
||||
@@ -40,12 +55,15 @@ describe('NodeWidgets', () => {
|
||||
})
|
||||
|
||||
const mountComponent = (nodeData?: VueNodeData) => {
|
||||
const pinia = createTestingPinia({ stubActions: false })
|
||||
setActivePinia(pinia)
|
||||
|
||||
return mount(NodeWidgets, {
|
||||
props: {
|
||||
nodeData
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestingPinia()],
|
||||
plugins: [pinia],
|
||||
stubs: {
|
||||
// Stub InputSlot to avoid complex slot registration dependencies
|
||||
InputSlot: true
|
||||
@@ -117,4 +135,165 @@ describe('NodeWidgets', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('deduplicates widgets with identical render identity while keeping distinct promoted sources', () => {
|
||||
const duplicateA = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'text',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeName: 'string_a',
|
||||
slotName: 'string_a'
|
||||
})
|
||||
const duplicateB = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'text',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeName: 'string_a',
|
||||
slotName: 'string_a'
|
||||
})
|
||||
const distinct = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'text',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20',
|
||||
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20',
|
||||
storeName: 'string_a',
|
||||
slotName: 'string_a'
|
||||
})
|
||||
const nodeData = createMockNodeData('SubgraphNode', [
|
||||
duplicateA,
|
||||
duplicateB,
|
||||
distinct
|
||||
])
|
||||
|
||||
const wrapper = mountComponent(nodeData)
|
||||
|
||||
expect(wrapper.findAll('.lg-node-widget')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('prefers a visible duplicate over a hidden duplicate when identities collide', () => {
|
||||
const hiddenDuplicate = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'text',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeName: 'string_a',
|
||||
slotName: 'string_a',
|
||||
options: { hidden: true }
|
||||
})
|
||||
const visibleDuplicate = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'text',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeName: 'string_a',
|
||||
slotName: 'string_a',
|
||||
options: { hidden: false }
|
||||
})
|
||||
const nodeData = createMockNodeData('SubgraphNode', [
|
||||
hiddenDuplicate,
|
||||
visibleDuplicate
|
||||
])
|
||||
|
||||
const wrapper = mountComponent(nodeData)
|
||||
|
||||
expect(wrapper.findAll('.lg-node-widget')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not deduplicate entries that share names but have different widget types', () => {
|
||||
const textWidget = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'text',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeName: 'string_a',
|
||||
slotName: 'string_a'
|
||||
})
|
||||
const comboWidget = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'combo',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeName: 'string_a',
|
||||
slotName: 'string_a'
|
||||
})
|
||||
const nodeData = createMockNodeData('SubgraphNode', [
|
||||
textWidget,
|
||||
comboWidget
|
||||
])
|
||||
|
||||
const wrapper = mountComponent(nodeData)
|
||||
|
||||
expect(wrapper.findAll('.lg-node-widget')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps unresolved same-name promoted entries distinct by source execution identity', () => {
|
||||
const firstTransientEntry = createMockWidget({
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
name: 'string_a',
|
||||
storeName: 'string_a',
|
||||
slotName: 'string_a',
|
||||
type: 'text',
|
||||
sourceExecutionId: '65:18'
|
||||
})
|
||||
const secondTransientEntry = createMockWidget({
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
name: 'string_a',
|
||||
storeName: 'string_a',
|
||||
slotName: 'string_a',
|
||||
type: 'text',
|
||||
sourceExecutionId: '65:19'
|
||||
})
|
||||
const nodeData = createMockNodeData('SubgraphNode', [
|
||||
firstTransientEntry,
|
||||
secondTransientEntry
|
||||
])
|
||||
|
||||
const wrapper = mountComponent(nodeData)
|
||||
|
||||
expect(wrapper.findAll('.lg-node-widget')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('hides widgets when merged store options mark them hidden', async () => {
|
||||
const nodeData = createMockNodeData('TestNode', [
|
||||
createMockWidget({
|
||||
nodeId: 'test_node',
|
||||
name: 'test_widget',
|
||||
options: { hidden: false }
|
||||
})
|
||||
])
|
||||
|
||||
const wrapper = mountComponent(nodeData)
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
widgetValueStore.registerWidget('graph-test', {
|
||||
nodeId: 'test_node',
|
||||
name: 'test_widget',
|
||||
type: 'combo',
|
||||
value: 'value',
|
||||
options: { hidden: true },
|
||||
label: undefined,
|
||||
serialize: true,
|
||||
disabled: false
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.findAll('.lg-node-widget')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps AppInput ids mapped to node identity for selection', () => {
|
||||
const nodeData = createMockNodeData('TestNode', [
|
||||
createMockWidget({ nodeId: 'test_node', name: 'seed_a', type: 'text' }),
|
||||
createMockWidget({ nodeId: 'test_node', name: 'seed_b', type: 'text' })
|
||||
])
|
||||
|
||||
const wrapper = mountComponent(nodeData)
|
||||
const appInputWrappers = wrapper.findAllComponents({ name: 'AppInput' })
|
||||
const ids = appInputWrappers.map((component) => component.props('id'))
|
||||
|
||||
expect(ids).toStrictEqual(['test_node', 'test_node'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,10 +21,7 @@
|
||||
@pointermove="handleWidgetPointerEvent"
|
||||
@pointerup="handleWidgetPointerEvent"
|
||||
>
|
||||
<template
|
||||
v-for="(widget, index) in processedWidgets"
|
||||
:key="`widget-${index}-${widget.name}`"
|
||||
>
|
||||
<template v-for="widget in processedWidgets" :key="widget.renderKey">
|
||||
<div
|
||||
v-if="!widget.hidden && (!widget.advanced || showAdvanced)"
|
||||
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
|
||||
@@ -120,7 +117,11 @@ import {
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||
import type {
|
||||
LinkedUpstreamInfo,
|
||||
SimplifiedWidget,
|
||||
WidgetValue
|
||||
} from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { getExecutionIdFromNodeData } from '@/utils/graphTraversalUtil'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -214,6 +215,7 @@ interface ProcessedWidget {
|
||||
hidden: boolean
|
||||
id: string
|
||||
name: string
|
||||
renderKey: string
|
||||
simplified: SimplifiedWidget
|
||||
tooltipConfig: TooltipOptions
|
||||
type: string
|
||||
@@ -241,12 +243,48 @@ function hasWidgetError(
|
||||
)
|
||||
}
|
||||
|
||||
function getWidgetIdentity(
|
||||
widget: SafeWidgetData,
|
||||
nodeId: string | number | undefined,
|
||||
index: number
|
||||
): {
|
||||
dedupeIdentity?: string
|
||||
renderKey: string
|
||||
} {
|
||||
const rawWidgetId = widget.storeNodeId ?? widget.nodeId
|
||||
const storeWidgetName = widget.storeName ?? widget.name
|
||||
const slotNameForIdentity = widget.slotName ?? widget.name
|
||||
const stableIdentityRoot = rawWidgetId
|
||||
? `node:${String(stripGraphPrefix(rawWidgetId))}`
|
||||
: widget.sourceExecutionId
|
||||
? `exec:${widget.sourceExecutionId}`
|
||||
: undefined
|
||||
|
||||
const dedupeIdentity = stableIdentityRoot
|
||||
? `${stableIdentityRoot}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}`
|
||||
: undefined
|
||||
const renderKey =
|
||||
dedupeIdentity ??
|
||||
`transient:${String(nodeId ?? '')}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}:${index}`
|
||||
|
||||
return {
|
||||
dedupeIdentity,
|
||||
renderKey
|
||||
}
|
||||
}
|
||||
|
||||
function isWidgetVisible(options: IWidgetOptions): boolean {
|
||||
const hidden = options.hidden ?? false
|
||||
const advanced = options.advanced ?? false
|
||||
return !hidden && (!advanced || showAdvanced.value)
|
||||
}
|
||||
|
||||
const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
if (!nodeData?.widgets) return []
|
||||
|
||||
// nodeData.id is the local node ID; subgraph nodes need the full execution
|
||||
// path (e.g. "65:63") to match keys in lastNodeErrors.
|
||||
const nodeExecId = app.rootGraph
|
||||
const nodeExecId = app.isGraphReady
|
||||
? getExecutionIdFromNodeData(app.rootGraph, nodeData)
|
||||
: String(nodeData.id ?? '')
|
||||
|
||||
@@ -256,10 +294,76 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
const nodeId = nodeData.id
|
||||
const { widgets } = nodeData
|
||||
const result: ProcessedWidget[] = []
|
||||
const uniqueWidgets: Array<{
|
||||
widget: SafeWidgetData
|
||||
identity: ReturnType<typeof getWidgetIdentity>
|
||||
mergedOptions: IWidgetOptions
|
||||
widgetState: WidgetState | undefined
|
||||
isVisible: boolean
|
||||
}> = []
|
||||
const dedupeIndexByIdentity = new Map<string, number>()
|
||||
|
||||
for (const widget of widgets) {
|
||||
for (const [index, widget] of widgets.entries()) {
|
||||
if (!shouldRenderAsVue(widget)) continue
|
||||
|
||||
const identity = getWidgetIdentity(widget, nodeId, index)
|
||||
const storeWidgetName = widget.storeName ?? widget.name
|
||||
const bareWidgetId = String(
|
||||
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
|
||||
)
|
||||
const widgetState = graphId
|
||||
? widgetValueStore.getWidget(graphId, bareWidgetId, storeWidgetName)
|
||||
: undefined
|
||||
const mergedOptions: IWidgetOptions = {
|
||||
...(widget.options ?? {}),
|
||||
...(widgetState?.options ?? {})
|
||||
}
|
||||
const visible = isWidgetVisible(mergedOptions)
|
||||
if (!identity.dedupeIdentity) {
|
||||
uniqueWidgets.push({
|
||||
widget,
|
||||
identity,
|
||||
mergedOptions,
|
||||
widgetState,
|
||||
isVisible: visible
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const existingIndex = dedupeIndexByIdentity.get(identity.dedupeIdentity)
|
||||
if (existingIndex === undefined) {
|
||||
dedupeIndexByIdentity.set(identity.dedupeIdentity, uniqueWidgets.length)
|
||||
uniqueWidgets.push({
|
||||
widget,
|
||||
identity,
|
||||
mergedOptions,
|
||||
widgetState,
|
||||
isVisible: visible
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const existingWidget = uniqueWidgets[existingIndex]
|
||||
if (existingWidget && !existingWidget.isVisible && visible) {
|
||||
uniqueWidgets[existingIndex] = {
|
||||
widget,
|
||||
identity,
|
||||
mergedOptions,
|
||||
widgetState,
|
||||
isVisible: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const {
|
||||
widget,
|
||||
mergedOptions,
|
||||
widgetState,
|
||||
identity: { renderKey }
|
||||
} of uniqueWidgets) {
|
||||
const bareWidgetId = String(
|
||||
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
|
||||
)
|
||||
const isPromotedView = !!widget.nodeId
|
||||
|
||||
const vueComponent =
|
||||
@@ -268,35 +372,33 @@ 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 widgetState = graphId
|
||||
? widgetValueStore.getWidget(graphId, bareWidgetId, storeWidgetName)
|
||||
: undefined
|
||||
|
||||
// Get value from store (falls back to undefined if not registered)
|
||||
const value = widgetState?.value as WidgetValue
|
||||
|
||||
// Build options from store state, with disabled override for
|
||||
// slot-linked widgets or widgets with disabled state (e.g. display-only)
|
||||
const storeOptions = widgetState?.options ?? {}
|
||||
const isDisabled = slotMetadata?.linked || widgetState?.disabled
|
||||
const widgetOptions = isDisabled
|
||||
? { ...storeOptions, disabled: true }
|
||||
: storeOptions
|
||||
? { ...mergedOptions, disabled: true }
|
||||
: mergedOptions
|
||||
|
||||
const borderStyle =
|
||||
graphId &&
|
||||
!isPromotedView &&
|
||||
promotionStore.isPromotedByAny(graphId, String(bareWidgetId), widget.name)
|
||||
? 'ring ring-component-node-widget-promoted'
|
||||
: widget.options?.advanced
|
||||
: mergedOptions.advanced
|
||||
? 'ring ring-component-node-widget-advanced'
|
||||
: undefined
|
||||
|
||||
const linkedUpstream: LinkedUpstreamInfo | undefined =
|
||||
slotMetadata?.linked && slotMetadata.originNodeId
|
||||
? {
|
||||
nodeId: slotMetadata.originNodeId,
|
||||
outputName: slotMetadata.originOutputName
|
||||
}
|
||||
: undefined
|
||||
|
||||
const simplified: SimplifiedWidget = {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
@@ -305,6 +407,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
callback: widget.callback,
|
||||
controlWidget: widget.controlWidget,
|
||||
label: widgetState?.label,
|
||||
linkedUpstream,
|
||||
options: widgetOptions,
|
||||
spec: widget.spec
|
||||
}
|
||||
@@ -332,13 +435,14 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
}
|
||||
|
||||
result.push({
|
||||
advanced: widget.options?.advanced ?? false,
|
||||
advanced: mergedOptions.advanced ?? false,
|
||||
handleContextMenu,
|
||||
hasLayoutSize: widget.hasLayoutSize ?? false,
|
||||
hasError: hasWidgetError(widget, nodeExecId, nodeErrors),
|
||||
hidden: widget.options?.hidden ?? false,
|
||||
hidden: mergedOptions.hidden ?? false,
|
||||
id: String(bareWidgetId),
|
||||
name: widget.name,
|
||||
renderKey,
|
||||
type: widget.type,
|
||||
vueComponent,
|
||||
simplified,
|
||||
|
||||
@@ -57,7 +57,7 @@ export function useNodeSnap() {
|
||||
if (!gridSizeValue) return { ...size }
|
||||
|
||||
const sizeArray: [number, number] = [size.width, size.height]
|
||||
if (snapPoint(sizeArray, gridSizeValue)) {
|
||||
if (snapPoint(sizeArray, gridSizeValue, 'ceil')) {
|
||||
return { width: sizeArray[0], height: sizeArray[1] }
|
||||
}
|
||||
return { ...size }
|
||||
|
||||
@@ -748,6 +748,7 @@ export function useSlotLinkInteraction({
|
||||
})
|
||||
|
||||
function onDoubleClick(e: PointerEvent) {
|
||||
if (!app.canvas) return
|
||||
const { graph } = app.canvas
|
||||
if (!graph) return
|
||||
const node = graph.getNodeById(nodeId)
|
||||
@@ -756,6 +757,7 @@ export function useSlotLinkInteraction({
|
||||
node.onInputDblClick?.(index, e)
|
||||
}
|
||||
function onClick(e: PointerEvent) {
|
||||
if (!app.canvas) return
|
||||
const { graph } = app.canvas
|
||||
if (!graph) return
|
||||
const node = graph.getNodeById(nodeId)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { LGraph, RendererType } from '@/lib/litegraph/src/LGraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { snapPoint } from '@/lib/litegraph/src/measure'
|
||||
import type { Point as LGPoint } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Point } from '@/renderer/core/layout/types'
|
||||
import {
|
||||
@@ -15,7 +17,7 @@ interface Positioned {
|
||||
size: LGPoint
|
||||
}
|
||||
|
||||
function unprojectPosSize(item: Positioned, anchor: Point) {
|
||||
function unprojectPosSize(item: Positioned, anchor: Point, graph: LGraph) {
|
||||
const c = unprojectBounds(
|
||||
{
|
||||
x: item.pos[0],
|
||||
@@ -30,6 +32,14 @@ function unprojectPosSize(item: Positioned, anchor: Point) {
|
||||
item.pos[1] = c.y
|
||||
item.size[0] = c.width
|
||||
item.size[1] = c.height
|
||||
|
||||
if (LiteGraph.alwaysSnapToGrid) {
|
||||
const snapTo = graph.getSnapToGridSize?.()
|
||||
if (snapTo) {
|
||||
snapPoint(item.pos, snapTo, 'round')
|
||||
snapPoint(item.size, snapTo, 'ceil')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,6 +70,18 @@ export function ensureCorrectLayoutScale(
|
||||
|
||||
const anchor = getGraphRenderAnchor(graph)
|
||||
|
||||
const applySnap = (
|
||||
pos: [number, number],
|
||||
method: 'round' | 'ceil' | 'floor' = 'round'
|
||||
) => {
|
||||
if (LiteGraph.alwaysSnapToGrid) {
|
||||
const snapTo = graph.getSnapToGridSize?.()
|
||||
if (snapTo) {
|
||||
snapPoint(pos, snapTo, method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
const c = unprojectBounds(
|
||||
{
|
||||
@@ -75,6 +97,9 @@ export function ensureCorrectLayoutScale(
|
||||
node.pos[1] = c.y
|
||||
node.size[0] = c.width
|
||||
node.size[1] = c.height
|
||||
|
||||
applySnap(node.pos)
|
||||
applySnap(node.size, 'ceil')
|
||||
}
|
||||
|
||||
for (const reroute of graph.reroutes.values()) {
|
||||
@@ -84,10 +109,11 @@ export function ensureCorrectLayoutScale(
|
||||
RENDER_SCALE_FACTOR
|
||||
)
|
||||
reroute.pos = [p.x, p.y]
|
||||
applySnap(reroute.pos)
|
||||
}
|
||||
|
||||
for (const group of graph.groups) {
|
||||
unprojectPosSize(group, anchor)
|
||||
unprojectPosSize(group, anchor, graph)
|
||||
}
|
||||
|
||||
if ('inputNode' in graph && 'outputNode' in graph) {
|
||||
@@ -96,7 +122,7 @@ export function ensureCorrectLayoutScale(
|
||||
graph.outputNode as SubgraphOutputNode
|
||||
]) {
|
||||
if (ioNode) {
|
||||
unprojectPosSize(ioNode, anchor)
|
||||
unprojectPosSize(ioNode, anchor, graph)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { snapPoint } from '@/lib/litegraph/src/measure'
|
||||
import type { Vector2 } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
@@ -1309,10 +1310,14 @@ export class ComfyApp {
|
||||
console.error(error)
|
||||
return
|
||||
}
|
||||
const snapTo = LiteGraph.alwaysSnapToGrid
|
||||
? this.rootGraph.getSnapToGridSize()
|
||||
: 0
|
||||
forEachNode(this.rootGraph, (node) => {
|
||||
const size = node.computeSize()
|
||||
size[0] = Math.max(node.size[0], size[0])
|
||||
size[1] = Math.max(node.size[1], size[1])
|
||||
snapPoint(size, snapTo, 'ceil')
|
||||
node.setSize(size)
|
||||
if (node.widgets) {
|
||||
// If you break something in the backend and want to patch workflows in the frontend
|
||||
|
||||
@@ -384,6 +384,15 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
|
||||
quickRegister('BiRefNet/pth', 'LS_LoadBiRefNetModel', 'model')
|
||||
quickRegister('onnx/human-parts', 'LS_HumanPartsUltra', '')
|
||||
quickRegister('lama', 'LaMa', 'lama_model')
|
||||
|
||||
// CogVideoX video generation models (comfyui-cogvideoxwrapper)
|
||||
quickRegister('CogVideo', 'DownloadAndLoadCogVideoModel', 'model')
|
||||
|
||||
// Inpaint models (comfyui-inpaint-nodes)
|
||||
quickRegister('inpaint', 'INPAINT_LoadInpaintModel', 'model_name')
|
||||
|
||||
// LayerDiffuse transparent image generation (comfyui-layerdiffuse)
|
||||
quickRegister('layer_model', 'LayeredDiffusionApply', 'config')
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -23,6 +23,13 @@ describe(usePromotionStore, () => {
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns a stable empty ref for unknown node', () => {
|
||||
const first = store.getPromotionsRef(graphA, nodeId)
|
||||
const second = store.getPromotionsRef(graphA, nodeId)
|
||||
|
||||
expect(second).toBe(first)
|
||||
})
|
||||
|
||||
it('returns entries after setPromotions', () => {
|
||||
const entries = [
|
||||
{ interiorNodeId: '10', widgetName: 'seed' },
|
||||
|
||||
@@ -9,6 +9,8 @@ interface PromotionEntry {
|
||||
widgetName: string
|
||||
}
|
||||
|
||||
const EMPTY_PROMOTIONS: PromotionEntry[] = []
|
||||
|
||||
export const usePromotionStore = defineStore('promotion', () => {
|
||||
const graphPromotions = ref(new Map<UUID, Map<NodeId, PromotionEntry[]>>())
|
||||
const graphRefCounts = ref(new Map<UUID, Map<string, number>>())
|
||||
@@ -62,7 +64,9 @@ export const usePromotionStore = defineStore('promotion', () => {
|
||||
graphId: UUID,
|
||||
subgraphNodeId: NodeId
|
||||
): PromotionEntry[] {
|
||||
return _getPromotionsForGraph(graphId).get(subgraphNodeId) ?? []
|
||||
return (
|
||||
_getPromotionsForGraph(graphId).get(subgraphNodeId) ?? EMPTY_PROMOTIONS
|
||||
)
|
||||
}
|
||||
|
||||
function getPromotions(
|
||||
@@ -99,6 +103,7 @@ export const usePromotionStore = defineStore('promotion', () => {
|
||||
): void {
|
||||
const promotions = _getPromotionsForGraph(graphId)
|
||||
const oldEntries = promotions.get(subgraphNodeId) ?? []
|
||||
|
||||
_decrementKeys(graphId, oldEntries)
|
||||
_incrementKeys(graphId, entries)
|
||||
|
||||
@@ -116,6 +121,7 @@ export const usePromotionStore = defineStore('promotion', () => {
|
||||
widgetName: string
|
||||
): void {
|
||||
if (isPromoted(graphId, subgraphNodeId, interiorNodeId, widgetName)) return
|
||||
|
||||
const entries = getPromotionsRef(graphId, subgraphNodeId)
|
||||
setPromotions(graphId, subgraphNodeId, [
|
||||
...entries,
|
||||
|
||||
@@ -38,6 +38,11 @@ export type SafeControlWidget = {
|
||||
update: (value: WidgetValue) => void
|
||||
}
|
||||
|
||||
export interface LinkedUpstreamInfo {
|
||||
nodeId: string
|
||||
outputName?: string
|
||||
}
|
||||
|
||||
export interface SimplifiedWidget<
|
||||
T extends WidgetValue = WidgetValue,
|
||||
O extends IWidgetOptions = IWidgetOptions
|
||||
@@ -77,6 +82,8 @@ export interface SimplifiedWidget<
|
||||
tooltip?: string
|
||||
|
||||
controlWidget?: SafeControlWidget
|
||||
|
||||
linkedUpstream?: LinkedUpstreamInfo
|
||||
}
|
||||
|
||||
export interface SimplifiedControlWidget<
|
||||
|
||||
92
src/utils/preloadErrorUtil.test.ts
Normal file
92
src/utils/preloadErrorUtil.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parsePreloadError } from './preloadErrorUtil'
|
||||
|
||||
describe('parsePreloadError', () => {
|
||||
it('parses CSS preload error', () => {
|
||||
const error = new Error(
|
||||
'Unable to preload CSS for /assets/vendor-vue-core-abc123.css'
|
||||
)
|
||||
const result = parsePreloadError(error)
|
||||
|
||||
expect(result.url).toBe('/assets/vendor-vue-core-abc123.css')
|
||||
expect(result.fileType).toBe('css')
|
||||
expect(result.chunkName).toBe('vendor-vue-core')
|
||||
expect(result.message).toBe(error.message)
|
||||
})
|
||||
|
||||
it('parses dynamically imported module error', () => {
|
||||
const error = new Error(
|
||||
'Failed to fetch dynamically imported module: https://example.com/assets/vendor-three-def456.js'
|
||||
)
|
||||
const result = parsePreloadError(error)
|
||||
|
||||
expect(result.url).toBe('https://example.com/assets/vendor-three-def456.js')
|
||||
expect(result.fileType).toBe('js')
|
||||
expect(result.chunkName).toBe('vendor-three')
|
||||
})
|
||||
|
||||
it('extracts URL from generic error message', () => {
|
||||
const error = new Error(
|
||||
'Something went wrong loading https://cdn.example.com/assets/app-9f8e7d.js'
|
||||
)
|
||||
const result = parsePreloadError(error)
|
||||
|
||||
expect(result.url).toBe('https://cdn.example.com/assets/app-9f8e7d.js')
|
||||
expect(result.fileType).toBe('js')
|
||||
expect(result.chunkName).toBe('app')
|
||||
})
|
||||
|
||||
it('returns null url when no URL found', () => {
|
||||
const error = new Error('Something failed')
|
||||
const result = parsePreloadError(error)
|
||||
|
||||
expect(result.url).toBeNull()
|
||||
expect(result.fileType).toBe('unknown')
|
||||
expect(result.chunkName).toBeNull()
|
||||
})
|
||||
|
||||
it('detects font file types', () => {
|
||||
const error = new Error(
|
||||
'Unable to preload CSS for /assets/inter-abc123.woff2'
|
||||
)
|
||||
const result = parsePreloadError(error)
|
||||
|
||||
expect(result.fileType).toBe('font')
|
||||
})
|
||||
|
||||
it('detects image file types', () => {
|
||||
const error = new Error('Unable to preload CSS for /assets/logo-abc123.png')
|
||||
const result = parsePreloadError(error)
|
||||
|
||||
expect(result.fileType).toBe('image')
|
||||
})
|
||||
|
||||
it('handles mjs extension', () => {
|
||||
const error = new Error(
|
||||
'Failed to fetch dynamically imported module: /assets/chunk-abc123.mjs'
|
||||
)
|
||||
const result = parsePreloadError(error)
|
||||
|
||||
expect(result.fileType).toBe('js')
|
||||
})
|
||||
|
||||
it('handles URLs with query parameters', () => {
|
||||
const error = new Error(
|
||||
'Unable to preload CSS for /assets/style-abc123.css?v=2'
|
||||
)
|
||||
const result = parsePreloadError(error)
|
||||
|
||||
expect(result.url).toBe('/assets/style-abc123.css?v=2')
|
||||
expect(result.fileType).toBe('css')
|
||||
})
|
||||
|
||||
it('extracts chunk name from filename without hash', () => {
|
||||
const error = new Error(
|
||||
'Failed to fetch dynamically imported module: /assets/index.js'
|
||||
)
|
||||
const result = parsePreloadError(error)
|
||||
|
||||
expect(result.chunkName).toBe('index')
|
||||
})
|
||||
})
|
||||
77
src/utils/preloadErrorUtil.ts
Normal file
77
src/utils/preloadErrorUtil.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
type PreloadFileType = 'js' | 'css' | 'font' | 'image' | 'unknown'
|
||||
|
||||
interface PreloadErrorInfo {
|
||||
url: string | null
|
||||
fileType: PreloadFileType
|
||||
chunkName: string | null
|
||||
message: string
|
||||
}
|
||||
|
||||
const CSS_PRELOAD_RE = /Unable to preload CSS for (.+)/
|
||||
const JS_DYNAMIC_IMPORT_RE =
|
||||
/Failed to fetch dynamically imported module:\s*(.+)/
|
||||
const URL_FALLBACK_RE = /https?:\/\/[^\s"')]+/
|
||||
|
||||
const FONT_EXTENSIONS = new Set(['woff', 'woff2', 'ttf', 'otf', 'eot'])
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'svg',
|
||||
'webp',
|
||||
'avif',
|
||||
'ico'
|
||||
])
|
||||
|
||||
function extractUrl(message: string): string | null {
|
||||
const cssMatch = message.match(CSS_PRELOAD_RE)
|
||||
if (cssMatch) return cssMatch[1].trim()
|
||||
|
||||
const jsMatch = message.match(JS_DYNAMIC_IMPORT_RE)
|
||||
if (jsMatch) return jsMatch[1].trim()
|
||||
|
||||
const fallbackMatch = message.match(URL_FALLBACK_RE)
|
||||
if (fallbackMatch) return fallbackMatch[0]
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function detectFileType(url: string): PreloadFileType {
|
||||
const pathname = new URL(url, 'https://cloud.comfy.org').pathname
|
||||
const ext = pathname.split('.').pop()?.toLowerCase()
|
||||
if (!ext) return 'unknown'
|
||||
|
||||
// Strip query params from extension
|
||||
const cleanExt = ext.split('?')[0]
|
||||
|
||||
if (cleanExt === 'js' || cleanExt === 'mjs') return 'js'
|
||||
if (cleanExt === 'css') return 'css'
|
||||
if (FONT_EXTENSIONS.has(cleanExt)) return 'font'
|
||||
if (IMAGE_EXTENSIONS.has(cleanExt)) return 'image'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function extractChunkName(url: string): string | null {
|
||||
const pathname = new URL(url, 'https://cloud.comfy.org').pathname
|
||||
const filename = pathname.split('/').pop()
|
||||
if (!filename) return null
|
||||
|
||||
// Strip extension
|
||||
const nameWithoutExt = filename.replace(/\.[^.]+$/, '')
|
||||
// Strip hash suffix (e.g. "vendor-vue-core-abc123" -> "vendor-vue-core")
|
||||
const withoutHash = nameWithoutExt.replace(/-[a-f0-9]{6,}$/, '')
|
||||
return withoutHash || null
|
||||
}
|
||||
|
||||
export function parsePreloadError(error: Error): PreloadErrorInfo {
|
||||
const message = error.message || String(error)
|
||||
const url = extractUrl(message)
|
||||
|
||||
return {
|
||||
url,
|
||||
fileType: url ? detectFileType(url) : 'unknown',
|
||||
chunkName: url ? extractChunkName(url) : null,
|
||||
message
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user