mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-25 17:17:19 +00:00
Compare commits
5 Commits
rizumu/fea
...
DynamicGro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
842e3d7541 | ||
|
|
c406042215 | ||
|
|
395b0a1c89 | ||
|
|
6068571b35 | ||
|
|
e37f168eaa |
1
.github/workflows/cla.yml
vendored
1
.github/workflows/cla.yml
vendored
@@ -5,6 +5,7 @@ on:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, closed]
|
||||
merge_group:
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
|
||||
@@ -56,12 +56,16 @@ class ComfyPropertiesPanel {
|
||||
readonly panelTitle: Locator
|
||||
readonly searchBox: Locator
|
||||
readonly titleEditor: TitleEditor
|
||||
readonly toggleButton: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.root = page.getByTestId(TestIds.propertiesPanel.root)
|
||||
this.panelTitle = this.root.locator('h3')
|
||||
this.searchBox = this.root.getByPlaceholder(/^Search/)
|
||||
this.titleEditor = new TitleEditor(this.root)
|
||||
this.toggleButton = page.getByRole('button', {
|
||||
name: 'Toggle properties panel'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { ConsoleMessage } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
|
||||
const domPreviewSelector = '.image-preview'
|
||||
@@ -95,4 +98,225 @@ test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
|
||||
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Detach Race Repro', { tag: ['@vue-nodes'] }, () => {
|
||||
const SUBGRAPH_NODE_TITLE = 'New Subgraph'
|
||||
|
||||
// Queues legacy onNodeRemoved/onSelectionChange so unpack completes first,
|
||||
// widening the race window so a guard regression deterministically surfaces.
|
||||
async function deferLegacyHandlers(comfyPage: ComfyPage) {
|
||||
return await comfyPage.page.evaluateHandle(() => {
|
||||
const graph = window.app!.graph!
|
||||
const canvas = window.app!.canvas!
|
||||
const queue: Array<() => void> = []
|
||||
const originalNodeRemoved = graph.onNodeRemoved
|
||||
const originalSelectionChange = canvas.onSelectionChange
|
||||
graph.onNodeRemoved = function (node) {
|
||||
queue.push(() => originalNodeRemoved?.call(this, node))
|
||||
}
|
||||
canvas.onSelectionChange = function (selected) {
|
||||
queue.push(() => originalSelectionChange?.call(this, selected))
|
||||
}
|
||||
return {
|
||||
drain: () => {
|
||||
for (const fn of queue.splice(0)) fn()
|
||||
},
|
||||
restore: () => {
|
||||
graph.onNodeRemoved = originalNodeRemoved
|
||||
canvas.onSelectionChange = originalSelectionChange
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type DeferredHandlers = Awaited<ReturnType<typeof deferLegacyHandlers>>
|
||||
|
||||
// Defers only the legacy selection-change callback, so the detached host
|
||||
// node lingers in the reactive selection while onNodeRemoved still runs
|
||||
// normally and clears it from the canvas. This isolates the panel render
|
||||
// path: a panel mounted during this window reads the stale selection.
|
||||
async function deferSelectionChange(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<DeferredHandlers> {
|
||||
return await comfyPage.page.evaluateHandle(() => {
|
||||
const canvas = window.app!.canvas!
|
||||
const queue: Array<() => void> = []
|
||||
const original = canvas.onSelectionChange
|
||||
canvas.onSelectionChange = function (selected) {
|
||||
queue.push(() => original?.call(this, selected))
|
||||
}
|
||||
return {
|
||||
drain: () => {
|
||||
for (const fn of queue.splice(0)) fn()
|
||||
},
|
||||
restore: () => {
|
||||
canvas.onSelectionChange = original
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function isNullGraphErrorText(text: string): boolean {
|
||||
return text.includes('NullGraphError') || text.endsWith('has no graph')
|
||||
}
|
||||
|
||||
// Vue's default errorHandler routes render throws to console.error,
|
||||
// not pageerror - listen to both.
|
||||
function captureNullGraphErrors(comfyPage: ComfyPage) {
|
||||
const captured: string[] = []
|
||||
const onPageError = (err: Error) => {
|
||||
if (
|
||||
err.name === 'NullGraphError' ||
|
||||
isNullGraphErrorText(err.message ?? '')
|
||||
) {
|
||||
captured.push(`pageerror ${err.name}: ${err.message}`)
|
||||
}
|
||||
}
|
||||
const onConsoleMessage = (msg: ConsoleMessage) => {
|
||||
if (msg.type() !== 'error') return
|
||||
const text = msg.text()
|
||||
if (isNullGraphErrorText(text)) {
|
||||
captured.push(`console.error: ${text}`)
|
||||
}
|
||||
}
|
||||
comfyPage.page.on('pageerror', onPageError)
|
||||
comfyPage.page.on('console', onConsoleMessage)
|
||||
return {
|
||||
getErrors: () => [...captured],
|
||||
stop: () => {
|
||||
comfyPage.page.off('pageerror', onPageError)
|
||||
comfyPage.page.off('console', onConsoleMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function unpackViaContextMenu(comfyPage: ComfyPage, title: string) {
|
||||
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
|
||||
await comfyPage.contextMenu.openForVueNode(fixture.header)
|
||||
await comfyPage.contextMenu.clickMenuItemExact('Unpack Subgraph')
|
||||
}
|
||||
|
||||
async function reopenRightSidePanel(comfyPage: ComfyPage) {
|
||||
const { propertiesPanel } = comfyPage.menu
|
||||
await propertiesPanel.toggleButton.click()
|
||||
await expect(propertiesPanel.root).toBeHidden()
|
||||
await propertiesPanel.toggleButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
// Unpacks the subgraph behind deferred teardown, runs an optional
|
||||
// interaction while the node is detached but not yet cleaned up, then
|
||||
// drains the deferred handlers and reports any NullGraphErrors seen.
|
||||
async function unpackAndCaptureNullGraphErrors(
|
||||
comfyPage: ComfyPage,
|
||||
options: {
|
||||
defer: (comfyPage: ComfyPage) => Promise<DeferredHandlers>
|
||||
duringWindow?: (comfyPage: ComfyPage) => Promise<void>
|
||||
}
|
||||
): Promise<string[]> {
|
||||
const subgraphNode =
|
||||
comfyPage.vueNodes.getNodeByTitle(SUBGRAPH_NODE_TITLE)
|
||||
const errors = captureNullGraphErrors(comfyPage)
|
||||
const deferred = await options.defer(comfyPage)
|
||||
try {
|
||||
await unpackViaContextMenu(comfyPage, SUBGRAPH_NODE_TITLE)
|
||||
await expect(subgraphNode).toHaveCount(0)
|
||||
await options.duringWindow?.(comfyPage)
|
||||
await deferred.evaluate((handlers) => handlers.drain())
|
||||
// Let drained-handler reactive flushes settle before stop().
|
||||
await comfyPage.nextFrame()
|
||||
return errors.getErrors()
|
||||
} finally {
|
||||
await deferred.evaluate((handlers) => handlers.restore())
|
||||
await deferred.dispose()
|
||||
errors.stop()
|
||||
}
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', true)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
const subgraphNode =
|
||||
comfyPage.vueNodes.getNodeByTitle(SUBGRAPH_NODE_TITLE)
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
|
||||
const fixture =
|
||||
await comfyPage.vueNodes.getFixtureByTitle(SUBGRAPH_NODE_TITLE)
|
||||
await fixture.header.click()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
|
||||
).toBeVisible()
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('unpack does not surface NullGraphError on the LGraphNode render path', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
|
||||
defer: deferLegacyHandlers
|
||||
})
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'LGraphNode render path: detach race must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('unpack does not surface NullGraphError from the TabSubgraphInputs panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
|
||||
defer: deferLegacyHandlers
|
||||
})
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'TabSubgraphInputs panel: detach race must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('unpack with subgraph editor open does not surface NullGraphError from the SubgraphEditor panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
|
||||
defer: deferLegacyHandlers
|
||||
})
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'SubgraphEditor panel: detach race must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('reopening the right side panel after unpack does not surface NullGraphError', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
|
||||
defer: deferSelectionChange,
|
||||
duringWindow: reopenRightSidePanel
|
||||
})
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'TabSubgraphInputs remount: stale selection must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('reopening the right side panel with the subgraph editor open does not surface NullGraphError', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
|
||||
defer: deferSelectionChange,
|
||||
duringWindow: reopenRightSidePanel
|
||||
})
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'SubgraphEditor remount: stale selection must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -335,6 +335,30 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
})
|
||||
|
||||
test('pointerCancel stops autopan', async ({ comfyPage }) => {
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await ksampler.header.click({ trial: true })
|
||||
await comfyPage.page.mouse.down()
|
||||
|
||||
const getOffset = () => comfyPage.canvasOps.getOffset()
|
||||
const initialOffset = await getOffset()
|
||||
await comfyPage.page.mouse.move(10, 10, { steps: 20 })
|
||||
await expect.poll(getOffset, 'drag with autopan').not.toEqual(initialOffset)
|
||||
|
||||
await test.step('move outside pan range and cancel drag', async () => {
|
||||
await comfyPage.page.mouse.move(400, 400, { steps: 20 })
|
||||
await ksampler.header.evaluate((node) =>
|
||||
node.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true }))
|
||||
)
|
||||
})
|
||||
|
||||
const secondaryOffset = await getOffset()
|
||||
|
||||
await comfyPage.page.mouse.move(10, 10, { steps: 20 })
|
||||
await comfyPage.nextFrame()
|
||||
expect(await getOffset(), 'drag canceled').toEqual(secondaryOffset)
|
||||
})
|
||||
|
||||
test(
|
||||
'@mobile should allow moving nodes by dragging on touch devices',
|
||||
{ tag: '@screenshot' },
|
||||
|
||||
@@ -344,6 +344,15 @@ export const zDynamicComboInputSpec = z.tuple([
|
||||
})
|
||||
])
|
||||
|
||||
export const zDynamicGroupInputSpec = z.tuple([
|
||||
z.literal('COMFY_DYNAMICGROUP_V3'),
|
||||
zBaseInputOptions.extend({
|
||||
template: zComfyInputsSpec,
|
||||
min: z.number().int().nonnegative().optional().default(0),
|
||||
max: z.number().int().positive().max(100).optional().default(50)
|
||||
})
|
||||
])
|
||||
|
||||
export const zMatchTypeOptions = z.object({
|
||||
...zBaseInputOptions.shape,
|
||||
type: z.literal('COMFY_MATCHTYPE_V3'),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
@@ -165,7 +166,9 @@ describe('WidgetRange', () => {
|
||||
outputsHolder.nodeOutputs = {
|
||||
loc1: { histogram_range_w: [1, 2, 3, 4] }
|
||||
}
|
||||
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
|
||||
renderWidget(
|
||||
makeWidget({}, { nodeLocatorId: createNodeLocatorId(null, 'loc1') })
|
||||
)
|
||||
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
|
||||
'true'
|
||||
)
|
||||
@@ -175,7 +178,9 @@ describe('WidgetRange', () => {
|
||||
outputsHolder.nodeOutputs = {
|
||||
loc1: { histogram_range_w: [] }
|
||||
}
|
||||
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
|
||||
renderWidget(
|
||||
makeWidget({}, { nodeLocatorId: createNodeLocatorId(null, 'loc1') })
|
||||
)
|
||||
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
|
||||
'false'
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import type { ErrorCardData } from './types'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
const meta: Meta<typeof ErrorNodeCard> = {
|
||||
title: 'RightSidePanel/Errors/ErrorNodeCard',
|
||||
@@ -23,7 +24,7 @@ type Story = StoryObj<typeof meta>
|
||||
const singleErrorCard: ErrorCardData = {
|
||||
id: 'node-10',
|
||||
title: 'CLIPTextEncode',
|
||||
nodeId: '10',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeTitle: 'CLIP Text Encode (Prompt)',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
@@ -37,7 +38,7 @@ const singleErrorCard: ErrorCardData = {
|
||||
const multipleErrorsCard: ErrorCardData = {
|
||||
id: 'node-24',
|
||||
title: 'VAEDecode',
|
||||
nodeId: '24',
|
||||
nodeId: createNodeExecutionId([24]),
|
||||
nodeTitle: 'VAE Decode',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
@@ -55,7 +56,7 @@ const multipleErrorsCard: ErrorCardData = {
|
||||
const runtimeErrorCard: ErrorCardData = {
|
||||
id: 'exec-45',
|
||||
title: 'KSampler',
|
||||
nodeId: '45',
|
||||
nodeId: createNodeExecutionId([45]),
|
||||
nodeTitle: 'KSampler',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
@@ -75,7 +76,7 @@ const runtimeErrorCard: ErrorCardData = {
|
||||
const subgraphErrorCard: ErrorCardData = {
|
||||
id: 'node-3:15',
|
||||
title: 'KSampler',
|
||||
nodeId: '3:15',
|
||||
nodeId: createNodeExecutionId([3, 15]),
|
||||
nodeTitle: 'Nested KSampler',
|
||||
isSubgraphNode: true,
|
||||
errors: [
|
||||
|
||||
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import type { ErrorCardData } from './types'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
const mockGetLogs = vi.fn(() => Promise.resolve('mock server logs'))
|
||||
const mockSerialize = vi.fn(() => ({ nodes: [] }))
|
||||
@@ -156,7 +157,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
return {
|
||||
id: `exec-${++cardIdCounter}`,
|
||||
title: 'KSampler',
|
||||
nodeId: '10',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeTitle: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
@@ -249,7 +250,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
renderCard({
|
||||
id: `node-${++cardIdCounter}`,
|
||||
title: 'KSampler',
|
||||
nodeId: '10',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeTitle: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
@@ -387,7 +388,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
const card: ErrorCardData = {
|
||||
id: `exec-${++cardIdCounter}`,
|
||||
title: 'KSampler',
|
||||
nodeId: '10',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeTitle: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ResolvedErrorMessage } from '@/platform/errorCatalog/types'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
export interface ErrorItem extends ResolvedErrorMessage {
|
||||
/** Raw source/API-compatible message. */
|
||||
@@ -12,7 +13,7 @@ export interface ErrorItem extends ResolvedErrorMessage {
|
||||
export interface ErrorCardData {
|
||||
id: string
|
||||
title: string
|
||||
nodeId?: string
|
||||
nodeId?: NodeExecutionId
|
||||
nodeTitle?: string
|
||||
graphNodeId?: string
|
||||
isSubgraphNode?: boolean
|
||||
|
||||
@@ -671,6 +671,30 @@ describe('useErrorGroups', () => {
|
||||
expect(nodeIds).toEqual(['1', '2', '10'])
|
||||
})
|
||||
|
||||
it('marks only nested execution paths as subgraph node cards', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'err', message: 'Error', details: '' }]
|
||||
},
|
||||
'1:20': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'err', message: 'Error', details: '' }]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const execGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'execution'
|
||||
)
|
||||
expect(execGroup?.cards).toMatchObject([
|
||||
{ nodeId: '1', isSubgraphNode: false },
|
||||
{ nodeId: '1:20', isSubgraphNode: true }
|
||||
])
|
||||
})
|
||||
it('sorts cards with subpath nodeIds before higher root IDs', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
|
||||
@@ -39,8 +39,8 @@ import {
|
||||
resolveRunErrorMessage
|
||||
} from '@/platform/errorCatalog/errorMessageResolver'
|
||||
import {
|
||||
isNodeExecutionId,
|
||||
compareExecutionId
|
||||
compareExecutionId,
|
||||
tryNormalizeNodeExecutionId
|
||||
} from '@/types/nodeIdentification'
|
||||
|
||||
const PROMPT_CARD_ID = '__prompt__'
|
||||
@@ -82,7 +82,7 @@ interface ErrorSearchItem {
|
||||
type CataloguedErrorItem = ErrorItem & ResolvedCatalogErrorMessage
|
||||
|
||||
/** Resolve display info for a node by its execution ID. */
|
||||
function resolveNodeInfo(nodeId: string) {
|
||||
function resolveNodeInfo(nodeId: NodeExecutionId) {
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
|
||||
return {
|
||||
@@ -119,7 +119,7 @@ function getOrCreateGroup(
|
||||
}
|
||||
|
||||
function createErrorCard(
|
||||
nodeId: string,
|
||||
nodeId: NodeExecutionId,
|
||||
classType: string,
|
||||
idPrefix: string
|
||||
): ErrorCardData {
|
||||
@@ -130,7 +130,7 @@ function createErrorCard(
|
||||
nodeId,
|
||||
nodeTitle: nodeInfo.title,
|
||||
graphNodeId: nodeInfo.graphNodeId,
|
||||
isSubgraphNode: isNodeExecutionId(nodeId),
|
||||
isSubgraphNode: nodeId.includes(':'),
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
@@ -288,7 +288,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
return map
|
||||
})
|
||||
|
||||
function isErrorInSelection(executionNodeId: string): boolean {
|
||||
function isErrorInSelection(executionNodeId: NodeExecutionId): boolean {
|
||||
const nodeIds = selectedNodeInfo.value.nodeIds
|
||||
if (!nodeIds) return true
|
||||
|
||||
@@ -305,7 +305,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
|
||||
function addNodeErrorToGroup(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
nodeId: string,
|
||||
nodeId: NodeExecutionId,
|
||||
classType: string,
|
||||
idPrefix: string,
|
||||
error: CataloguedErrorItem,
|
||||
@@ -371,9 +371,11 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
) {
|
||||
if (!executionErrorStore.lastNodeErrors) return
|
||||
|
||||
for (const [nodeId, nodeError] of Object.entries(
|
||||
for (const [rawNodeId, nodeError] of Object.entries(
|
||||
executionErrorStore.lastNodeErrors
|
||||
)) {
|
||||
const nodeId = tryNormalizeNodeExecutionId(rawNodeId)
|
||||
if (!nodeId) continue
|
||||
const nodeDisplayName =
|
||||
resolveNodeInfo(nodeId).title || nodeError.class_type
|
||||
for (const e of nodeError.errors) {
|
||||
@@ -404,9 +406,12 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
if (!executionErrorStore.lastExecutionError) return
|
||||
|
||||
const e = executionErrorStore.lastExecutionError
|
||||
const nodeId = tryNormalizeNodeExecutionId(e.node_id)
|
||||
if (!nodeId) return
|
||||
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
String(e.node_id),
|
||||
nodeId,
|
||||
e.node_type,
|
||||
'exec',
|
||||
{
|
||||
@@ -417,8 +422,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
...resolveRunErrorMessage({
|
||||
kind: 'execution',
|
||||
error: e,
|
||||
nodeDisplayName:
|
||||
resolveNodeInfo(String(e.node_id)).title || e.node_type
|
||||
nodeDisplayName: resolveNodeInfo(nodeId).title || e.node_type
|
||||
})
|
||||
},
|
||||
filterBySelection
|
||||
@@ -669,7 +673,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
]
|
||||
}
|
||||
|
||||
function isAssetErrorInSelection(executionNodeId: string): boolean {
|
||||
function isAssetErrorInSelection(executionNodeId: NodeExecutionId): boolean {
|
||||
const nodeIds = selectedNodeInfo.value.nodeIds
|
||||
if (!nodeIds) return true
|
||||
|
||||
@@ -691,12 +695,17 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
return false
|
||||
}
|
||||
|
||||
function isAssetCandidateInSelection(nodeId: string | number): boolean {
|
||||
const executionNodeId = tryNormalizeNodeExecutionId(nodeId)
|
||||
return executionNodeId ? isAssetErrorInSelection(executionNodeId) : false
|
||||
}
|
||||
|
||||
const filteredMissingModelGroups = computed(() => {
|
||||
if (!selectedNodeInfo.value.nodeIds) return missingModelGroups.value
|
||||
const candidates = missingModelStore.missingModelCandidates
|
||||
if (!candidates?.length) return []
|
||||
const filtered = candidates.filter(
|
||||
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
|
||||
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
|
||||
)
|
||||
if (!filtered.length) return []
|
||||
return groupMissingModelCandidates(filtered, isCloud)
|
||||
@@ -707,7 +716,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
const candidates = missingMediaStore.missingMediaCandidates
|
||||
if (!candidates?.length) return []
|
||||
const filtered = candidates.filter(
|
||||
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
|
||||
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
|
||||
)
|
||||
if (!filtered.length) return []
|
||||
return groupCandidatesByMediaType(filtered)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { nextTick, ref } from 'vue'
|
||||
import type { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
|
||||
import type { ErrorCardData } from './types'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { useErrorReport } from './useErrorReport'
|
||||
|
||||
async function flushPromises() {
|
||||
@@ -103,7 +104,7 @@ function makeCard(overrides: Partial<ErrorCardData> = {}): ErrorCardData {
|
||||
return {
|
||||
id: 'card-1',
|
||||
title: 'KSampler',
|
||||
nodeId: '42',
|
||||
nodeId: createNodeExecutionId([42]),
|
||||
errors: [],
|
||||
...overrides
|
||||
}
|
||||
@@ -181,7 +182,7 @@ describe('useErrorReport', () => {
|
||||
exceptionType: 'RuntimeError',
|
||||
exceptionMessage: 'CUDA oom',
|
||||
traceback: 'trace-0',
|
||||
nodeId: '42',
|
||||
nodeId: createNodeExecutionId([42]),
|
||||
nodeType: 'KSampler',
|
||||
systemStats: sampleSystemStats,
|
||||
serverLogs: 'server logs',
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { seedRequiredInputMissingNodeError } from '@/utils/__tests__/executionErrorTestUtils'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
@@ -50,7 +51,11 @@ describe('Connection error clearing via onConnectionsChange', () => {
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([node.id]),
|
||||
'clip'
|
||||
)
|
||||
|
||||
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
|
||||
|
||||
@@ -62,7 +67,11 @@ describe('Connection error clearing via onConnectionsChange', () => {
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([node.id]),
|
||||
'clip'
|
||||
)
|
||||
|
||||
node.onConnectionsChange!(
|
||||
NodeSlotType.INPUT,
|
||||
@@ -81,7 +90,11 @@ describe('Connection error clearing via onConnectionsChange', () => {
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([node.id]),
|
||||
'clip'
|
||||
)
|
||||
|
||||
node.onConnectionsChange!(
|
||||
NodeSlotType.OUTPUT,
|
||||
@@ -103,7 +116,11 @@ describe('Connection error clearing via onConnectionsChange', () => {
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
seedRequiredInputMissingNodeError(store, String(node.id), 'model')
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([node.id]),
|
||||
'model'
|
||||
)
|
||||
|
||||
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
|
||||
|
||||
@@ -229,7 +246,11 @@ describe('Widget change error clearing via onWidgetChanged', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
const mediaStore = useMissingMediaStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
seedRequiredInputMissingNodeError(store, String(node.id), 'image')
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([node.id]),
|
||||
'image'
|
||||
)
|
||||
mediaStore.setMissingMedia([
|
||||
{
|
||||
nodeId: String(node.id),
|
||||
@@ -279,7 +300,11 @@ describe('installErrorClearingHooks lifecycle', () => {
|
||||
// Verify the hooks actually work
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
seedRequiredInputMissingNodeError(store, String(lateNode.id), 'value')
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([lateNode.id]),
|
||||
'value'
|
||||
)
|
||||
|
||||
lateNode.onConnectionsChange!(
|
||||
NodeSlotType.INPUT,
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacem
|
||||
import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { appendNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import {
|
||||
collectAllNodes,
|
||||
@@ -83,7 +84,7 @@ function installNodeHooks(node: LGraphNode): void {
|
||||
|
||||
const promotedSource = widgetPromotedSource(node, widget)
|
||||
const executionId = promotedSource
|
||||
? `${hostExecId}:${promotedSource.nodeId}`
|
||||
? appendNodeExecutionId(hostExecId, promotedSource.nodeId)
|
||||
: hostExecId
|
||||
const widgetName = promotedSource?.widgetName ?? widget.name
|
||||
|
||||
|
||||
@@ -703,3 +703,55 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
|
||||
expect(subgraphNode.has_errors).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Pre-remove vueNodeData drain', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('drops vueNodeData entry before node.onRemoved fires', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
expect(vueNodeData.has(String(node.id))).toBe(true)
|
||||
|
||||
let dataPresentInOnRemoved: boolean | undefined
|
||||
node.onRemoved = () => {
|
||||
dataPresentInOnRemoved = vueNodeData.has(String(node.id))
|
||||
}
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
expect(
|
||||
dataPresentInOnRemoved,
|
||||
'vueNodeData entry must be cleared before node.onRemoved fires so reactive consumers cannot observe the detached node'
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('clears vueNodeData when LGraph.clear() dispatches node:before-removed for each node', () => {
|
||||
const graph = new LGraph()
|
||||
const nodeA = new LGraphNode('a')
|
||||
const nodeB = new LGraphNode('b')
|
||||
graph.add(nodeA)
|
||||
graph.add(nodeB)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
expect(vueNodeData.size).toBe(2)
|
||||
|
||||
const beforeRemovedSpy = vi.fn()
|
||||
graph.events.addEventListener('node:before-removed', beforeRemovedSpy)
|
||||
|
||||
graph.clear()
|
||||
|
||||
expect(
|
||||
beforeRemovedSpy,
|
||||
'clear() must dispatch node:before-removed so reactive consumers can drop refs before nodes detach'
|
||||
).toHaveBeenCalledTimes(2)
|
||||
expect(
|
||||
vueNodeData.size,
|
||||
'node:before-removed listener must drain vueNodeData when clear() removes every node'
|
||||
).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,6 +30,8 @@ import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
|
||||
import { normalizeControlOption } from '@/types/simplifiedWidget'
|
||||
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
|
||||
import type { NodeId as WorkflowNodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
|
||||
import type {
|
||||
@@ -82,6 +84,7 @@ export interface SafeWidgetData {
|
||||
advanced?: boolean
|
||||
hidden?: boolean
|
||||
read_only?: boolean
|
||||
removable?: boolean
|
||||
values?: unknown
|
||||
}
|
||||
/** Input specification from node definition */
|
||||
@@ -94,7 +97,7 @@ export interface SafeWidgetData {
|
||||
* host subgraph node. Used for missing-model lookups that key by
|
||||
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
|
||||
*/
|
||||
sourceExecutionId?: string
|
||||
sourceExecutionId?: NodeExecutionId
|
||||
/**
|
||||
* Interior source widget name. Only set for promoted widgets, where `name`
|
||||
* is the host input slot name; missing-model lookups key by the interior
|
||||
@@ -137,7 +140,7 @@ export interface GraphNodeManager {
|
||||
vueNodeData: ReadonlyMap<string, VueNodeData>
|
||||
|
||||
// Access to original LiteGraph nodes (non-reactive)
|
||||
getNode(id: string): LGraphNode | undefined
|
||||
getNode(id: WorkflowNodeId): LGraphNode | undefined
|
||||
|
||||
// Lifecycle methods
|
||||
cleanup(): void
|
||||
@@ -211,7 +214,8 @@ function extractWidgetDisplayOptions(
|
||||
canvasOnly: widget.options.canvasOnly,
|
||||
advanced: widget.options?.advanced ?? widget.advanced,
|
||||
hidden: widget.options.hidden,
|
||||
read_only: widget.options.read_only
|
||||
read_only: widget.options.read_only,
|
||||
removable: widget.options.removable
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +229,7 @@ function isDOMBackedWidget(widget: IBaseWidget): boolean {
|
||||
interface PromotedWidgetMetadata {
|
||||
controlWidget?: SafeControlWidget
|
||||
isDOMWidget: boolean
|
||||
sourceExecutionId?: string
|
||||
sourceExecutionId?: NodeExecutionId
|
||||
sourceWidgetName?: string
|
||||
}
|
||||
|
||||
@@ -516,8 +520,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
}
|
||||
|
||||
// Get access to original LiteGraph node (non-reactive)
|
||||
const getNode = (id: string): LGraphNode | undefined => {
|
||||
return nodeRefs.get(id)
|
||||
const getNode = (id: WorkflowNodeId): LGraphNode | undefined => {
|
||||
return nodeRefs.get(String(id))
|
||||
}
|
||||
|
||||
const syncWithGraph = () => {
|
||||
@@ -608,27 +612,20 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles node removal from the graph - cleans up all references
|
||||
*/
|
||||
const dropNodeReferences = (node: LGraphNode) => {
|
||||
const id = String(node.id)
|
||||
nodeRefs.delete(id)
|
||||
vueNodeData.delete(id)
|
||||
}
|
||||
|
||||
const handleNodeRemoved = (
|
||||
node: LGraphNode,
|
||||
originalCallback?: (node: LGraphNode) => void
|
||||
) => {
|
||||
const id = String(node.id)
|
||||
|
||||
// Remove node from layout store
|
||||
setSource(LayoutSource.Canvas)
|
||||
void deleteNode(id)
|
||||
|
||||
// Clean up all tracking references
|
||||
nodeRefs.delete(id)
|
||||
vueNodeData.delete(id)
|
||||
|
||||
// Call original callback if provided
|
||||
if (originalCallback) {
|
||||
originalCallback(node)
|
||||
}
|
||||
originalCallback?.(node)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -637,7 +634,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
const createCleanupFunction = (
|
||||
originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
|
||||
originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
|
||||
originalOnTrigger: ((event: LGraphTriggerEvent) => void) | undefined
|
||||
originalOnTrigger: ((event: LGraphTriggerEvent) => void) | undefined,
|
||||
beforeNodeRemovedListener: (e: CustomEvent<{ node: LGraphNode }>) => void
|
||||
) => {
|
||||
return () => {
|
||||
// Restore original callbacks
|
||||
@@ -645,15 +643,17 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
graph.onNodeRemoved = originalOnNodeRemoved || undefined
|
||||
graph.onTrigger = originalOnTrigger || undefined
|
||||
|
||||
graph.events.removeEventListener(
|
||||
'node:before-removed',
|
||||
beforeNodeRemovedListener
|
||||
)
|
||||
|
||||
// Clear all state maps
|
||||
nodeRefs.clear()
|
||||
vueNodeData.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up event listeners - now simplified with extracted handlers
|
||||
*/
|
||||
const setupEventListeners = (): (() => void) => {
|
||||
// Store original callbacks
|
||||
const originalOnNodeAdded = graph.onNodeAdded
|
||||
@@ -669,6 +669,16 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
handleNodeRemoved(node, originalOnNodeRemoved)
|
||||
}
|
||||
|
||||
const beforeNodeRemovedListener = (
|
||||
e: CustomEvent<{ node: LGraphNode }>
|
||||
) => {
|
||||
dropNodeReferences(e.detail.node)
|
||||
}
|
||||
graph.events.addEventListener(
|
||||
'node:before-removed',
|
||||
beforeNodeRemovedListener
|
||||
)
|
||||
|
||||
const triggerHandlers: {
|
||||
[K in LGraphTriggerAction]: (event: LGraphTriggerParam<K>) => void
|
||||
} = {
|
||||
@@ -817,11 +827,11 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
// Initialize state
|
||||
syncWithGraph()
|
||||
|
||||
// Return cleanup function
|
||||
return createCleanupFunction(
|
||||
originalOnNodeAdded || undefined,
|
||||
originalOnNodeRemoved || undefined,
|
||||
originalOnTrigger || undefined
|
||||
originalOnTrigger || undefined,
|
||||
beforeNodeRemovedListener
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -137,6 +137,18 @@ describe(usePromotedPreviews, () => {
|
||||
expect(promotedPreviews.value).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array (does not throw) when SubgraphNode is detached', () => {
|
||||
const setup = createSetup()
|
||||
const parentGraph = setup.subgraphNode.graph!
|
||||
parentGraph.add(setup.subgraphNode)
|
||||
parentGraph.remove(setup.subgraphNode)
|
||||
|
||||
expect(setup.subgraphNode.graph).toBeNull()
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(() => promotedPreviews.value).not.toThrow()
|
||||
expect(promotedPreviews.value).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when no $$ promotions exist', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10 })
|
||||
|
||||
@@ -6,7 +6,11 @@ import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import {
|
||||
appendNodeExecutionId,
|
||||
createNodeLocatorId
|
||||
} from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
interface PromotedPreview {
|
||||
sourceNodeId: string
|
||||
@@ -38,7 +42,7 @@ export function usePromotedPreviews(
|
||||
function readReactivePreviewUrls(
|
||||
leafHost: SubgraphNode,
|
||||
leafSourceNodeId: string,
|
||||
leafExecutionId: string,
|
||||
leafExecutionId: NodeExecutionId,
|
||||
interiorNode: LGraphNode
|
||||
): string[] | undefined {
|
||||
const locatorId = createNodeLocatorId(
|
||||
@@ -68,6 +72,7 @@ export function usePromotedPreviews(
|
||||
const promotedPreviews = computed((): PromotedPreview[] => {
|
||||
const node = toValue(lgraphNode)
|
||||
if (!(node instanceof SubgraphNode)) return []
|
||||
if (node.isDetached) return []
|
||||
|
||||
const rootGraphId = node.rootGraph.id
|
||||
const hostLocator = String(node.id)
|
||||
@@ -121,7 +126,7 @@ export function usePromotedPreviews(
|
||||
const urls = readReactivePreviewUrls(
|
||||
leafHost,
|
||||
leaf.sourceNodeId,
|
||||
`${leafHostLocator}:${leaf.sourceNodeId}`,
|
||||
appendNodeExecutionId(leafHostLocator, leaf.sourceNodeId),
|
||||
interiorNode
|
||||
)
|
||||
if (!urls?.length) return []
|
||||
|
||||
@@ -522,6 +522,22 @@ describe('hasUnpromotedWidgets', () => {
|
||||
|
||||
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false (does not throw) when SubgraphNode is detached', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const parentGraph = subgraphNode.graph!
|
||||
parentGraph.add(subgraphNode)
|
||||
const interiorNode = new LGraphNode('InnerNode')
|
||||
subgraph.add(interiorNode)
|
||||
interiorNode.addWidget('text', 'seed', '123', () => {})
|
||||
|
||||
parentGraph.remove(subgraphNode)
|
||||
|
||||
expect(subgraphNode.graph).toBeNull()
|
||||
expect(() => hasUnpromotedWidgets(subgraphNode)).not.toThrow()
|
||||
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLinkedPromotion', () => {
|
||||
|
||||
@@ -633,6 +633,7 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
|
||||
}
|
||||
|
||||
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
|
||||
if (subgraphNode.isDetached) return false
|
||||
const { subgraph } = subgraphNode
|
||||
|
||||
return subgraph.nodes.some((interiorNode) =>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import { zAutogrowOptions, zMatchTypeOptions } from '@/schemas/nodeDefSchema'
|
||||
import {
|
||||
zAutogrowOptions,
|
||||
zDynamicGroupInputSpec,
|
||||
zMatchTypeOptions
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
@@ -8,6 +12,7 @@ const dynamicTypeResolvers: Record<
|
||||
(inputSpec: InputSpecV2) => string[]
|
||||
> = {
|
||||
COMFY_AUTOGROW_V3: resolveAutogrowType,
|
||||
COMFY_DYNAMICGROUP_V3: resolveDynamicGroupType,
|
||||
COMFY_MATCHTYPE_V3: (input) =>
|
||||
zMatchTypeOptions
|
||||
.safeParse(input)
|
||||
@@ -20,6 +25,21 @@ export function resolveInputType(input: InputSpecV2): string[] {
|
||||
: input.type.split(',')
|
||||
}
|
||||
|
||||
function resolveDynamicGroupType(rawSpec: InputSpecV2): string[] {
|
||||
const parsed = zDynamicGroupInputSpec.safeParse([rawSpec.type, rawSpec])
|
||||
const template = parsed.data?.[1]?.template
|
||||
if (!template) return []
|
||||
const inputTypes: (Record<string, InputSpec> | undefined)[] = [
|
||||
template.required,
|
||||
template.optional
|
||||
]
|
||||
return inputTypes.flatMap((inputType) =>
|
||||
Object.entries(inputType ?? {}).flatMap(([name, v]) =>
|
||||
resolveInputType(transformInputSpecV1ToV2(v, { name }))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function resolveAutogrowType(rawSpec: InputSpecV2): string[] {
|
||||
const { input } = zAutogrowOptions.safeParse(rawSpec).data?.template ?? {}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
@@ -47,6 +49,22 @@ function addDynamicCombo(node: LGraphNode, inputs: DynamicInputs) {
|
||||
transformInputSpecV1ToV2(inputSpec, { name: namePrefix, isOptional: false })
|
||||
)
|
||||
}
|
||||
function addDynamicGroup(
|
||||
node: LGraphNode,
|
||||
template: object,
|
||||
{ min, max, name = 'g' }: { min?: number; max?: number; name?: string } = {}
|
||||
) {
|
||||
const options: Record<string, unknown> = { template }
|
||||
if (min !== undefined) options.min = min
|
||||
if (max !== undefined) options.max = max
|
||||
addNodeInput(
|
||||
node,
|
||||
transformInputSpecV1ToV2(['COMFY_DYNAMICGROUP_V3', options] as InputSpec, {
|
||||
name,
|
||||
isOptional: false
|
||||
})
|
||||
)
|
||||
}
|
||||
function addAutogrow(node: LGraphNode, template: unknown) {
|
||||
addNodeInput(
|
||||
node,
|
||||
@@ -287,3 +305,101 @@ describe('Autogrow', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
describe('Dynamic Groups', () => {
|
||||
const stringTemplate = { required: { a: ['STRING', {}] } }
|
||||
const widgetNames = (node: LGraphNode) => node.widgets!.map((w) => w.name)
|
||||
const inputNames = (node: LGraphNode) => node.inputs.map((i) => i.name)
|
||||
const widgetNamed = (node: LGraphNode, name: string) =>
|
||||
node.widgets!.find((w) => w.name === name)!
|
||||
|
||||
test('renders min rows on creation', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 2, max: 5 })
|
||||
expect(widgetNames(node)).toStrictEqual([
|
||||
'g',
|
||||
'g.__row__0',
|
||||
'g.0.a',
|
||||
'g.__row__1',
|
||||
'g.1.a'
|
||||
])
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
})
|
||||
|
||||
test('add row appends a new row up to max', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 0, max: 2 })
|
||||
expect(widgetNames(node)).toStrictEqual(['g'])
|
||||
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a'])
|
||||
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
|
||||
// At max, further adds are ignored.
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
})
|
||||
|
||||
test('remove row renumbers later rows', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
|
||||
const row0Field = widgetNamed(node, 'g.0.a')
|
||||
const row2Field = widgetNamed(node, 'g.2.a')
|
||||
|
||||
widgetNamed(node, 'g.__row__1').callback?.(undefined)
|
||||
|
||||
expect(widgetNames(node)).toStrictEqual([
|
||||
'g',
|
||||
'g.__row__0',
|
||||
'g.0.a',
|
||||
'g.__row__1',
|
||||
'g.1.a'
|
||||
])
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
// Row 0 is untouched; the former row 2 shifts down into row 1.
|
||||
expect(widgetNamed(node, 'g.0.a')).toBe(row0Field)
|
||||
expect(widgetNamed(node, 'g.1.a')).toBe(row2Field)
|
||||
})
|
||||
|
||||
test('rows below min are not removable', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 1, max: 5 })
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
|
||||
expect(widgetNamed(node, 'g.__row__0').options?.removable).toBe(false)
|
||||
expect(widgetNamed(node, 'g.__row__1').options?.removable).toBe(true)
|
||||
|
||||
// Attempting to remove a protected row is a no-op.
|
||||
widgetNamed(node, 'g.__row__0').callback?.(undefined)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
})
|
||||
|
||||
test('canvas click removes a row only on the remove hit target', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
|
||||
const header = widgetNamed(node, 'g.__row__1')
|
||||
const up = { type: 'pointerup' } as CanvasPointerEvent
|
||||
const down = { type: 'pointerdown' } as CanvasPointerEvent
|
||||
const xCenter = node.size[0] - 15 - LiteGraph.NODE_WIDGET_HEIGHT * 0.5
|
||||
|
||||
// Releasing away from the remove target does nothing.
|
||||
header.mouse?.(up, [0, 0] as Point, node)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
|
||||
// A pointerdown on the target does nothing (only release acts).
|
||||
header.mouse?.(down, [xCenter, 0] as Point, node)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
|
||||
// Releasing on the target removes the row.
|
||||
header.mouse?.(up, [xCenter, 0] as Point, node)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,10 +2,12 @@ import { remove } from 'es-toolkit'
|
||||
import { shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { t } from '@/i18n'
|
||||
import type {
|
||||
ISlotType,
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
INodeOutputSlot,
|
||||
Point
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -13,11 +15,14 @@ import type { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import { commonType } from '@/lib/litegraph/src/utils/type'
|
||||
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import {
|
||||
zAutogrowOptions,
|
||||
zDynamicComboInputSpec,
|
||||
zDynamicGroupInputSpec,
|
||||
zMatchTypeOptions
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
@@ -28,6 +33,15 @@ import { widgetId } from '@/types/widgetId'
|
||||
|
||||
const INLINE_INPUTS = false
|
||||
|
||||
type DynamicGroupState = {
|
||||
min: number
|
||||
max: number
|
||||
inputSpecs: InputSpecV2[]
|
||||
}
|
||||
type DynamicGroupNode = LGraphNode & {
|
||||
comfyDynamic: { dynamicGroup: Record<string, DynamicGroupState> }
|
||||
}
|
||||
|
||||
type MatchTypeNode = LGraphNode &
|
||||
Pick<Required<LGraphNode>, 'onConnectionsChange'> & {
|
||||
comfyDynamic: { matchType: Record<string, Record<string, string>> }
|
||||
@@ -210,7 +224,321 @@ function dynamicComboWidget(
|
||||
return { widget, minWidth, minHeight }
|
||||
}
|
||||
|
||||
export const dynamicWidgets = { COMFY_DYNAMICCOMBO_V3: dynamicComboWidget }
|
||||
function withComfyDynamicGroup(
|
||||
node: LGraphNode
|
||||
): asserts node is DynamicGroupNode {
|
||||
if (node.comfyDynamic?.dynamicGroup) return
|
||||
node.comfyDynamic ??= {}
|
||||
node.comfyDynamic.dynamicGroup = {}
|
||||
}
|
||||
|
||||
const ROW_MARKER = '__row__'
|
||||
const rowHeaderName = (group: string, row: number) =>
|
||||
`${group}.${ROW_MARKER}${row}`
|
||||
const fieldName = (group: string, row: number, field: string) =>
|
||||
`${group}.${row}.${field}`
|
||||
|
||||
/** Extract the row index from a header widget name, or `undefined`. */
|
||||
function headerRowIndex(group: string, name: string): number | undefined {
|
||||
const prefix = `${group}.${ROW_MARKER}`
|
||||
if (!name.startsWith(prefix)) return undefined
|
||||
const row = Number(name.slice(prefix.length))
|
||||
return Number.isInteger(row) ? row : undefined
|
||||
}
|
||||
|
||||
/** Rename a field that sits above the removed row, shifting its index down. */
|
||||
function shiftedFieldName(
|
||||
group: string,
|
||||
name: string,
|
||||
removedRow: number
|
||||
): string | undefined {
|
||||
const prefix = `${group}.`
|
||||
if (!name.startsWith(prefix)) return undefined
|
||||
const rest = name.slice(prefix.length)
|
||||
const dot = rest.indexOf('.')
|
||||
if (dot === -1) return undefined
|
||||
const row = Number(rest.slice(0, dot))
|
||||
if (!Number.isInteger(row) || row <= removedRow) return undefined
|
||||
return fieldName(group, row - 1, rest.slice(dot + 1))
|
||||
}
|
||||
|
||||
const belongsToRow = (group: string, name: string, row: number): boolean =>
|
||||
name === rowHeaderName(group, row) || name.startsWith(`${group}.${row}.`)
|
||||
|
||||
const CANVAS_MARGIN = 15
|
||||
|
||||
/** Draw the "Add row" capsule button on the LiteGraph canvas. */
|
||||
function drawGroupButton(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
y: number,
|
||||
label: string,
|
||||
disabled: boolean
|
||||
): void {
|
||||
const height = LiteGraph.NODE_WIDGET_HEIGHT
|
||||
ctx.save()
|
||||
if (disabled) ctx.globalAlpha *= 0.5
|
||||
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR
|
||||
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(CANVAS_MARGIN, y, width - CANVAS_MARGIN * 2, height, [
|
||||
height * 0.5
|
||||
])
|
||||
ctx.fill()
|
||||
if (!disabled) ctx.stroke()
|
||||
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR
|
||||
ctx.font = `${LiteGraph.NODE_TEXT_SIZE}px ${LiteGraph.NODE_FONT}`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(label, width * 0.5, y + height * 0.7)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
/** Horizontal centre of a row header's remove (✕) hit target. */
|
||||
const removeButtonCenterX = (width: number) =>
|
||||
width - CANVAS_MARGIN - LiteGraph.NODE_WIDGET_HEIGHT * 0.5
|
||||
|
||||
/** Draw a row header (label on the left, ✕ on the right) on the canvas. */
|
||||
function drawGroupRowHeader(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
y: number,
|
||||
label: string,
|
||||
removable: boolean
|
||||
): void {
|
||||
const height = LiteGraph.NODE_WIDGET_HEIGHT
|
||||
ctx.save()
|
||||
ctx.font = `${LiteGraph.NODE_TEXT_SIZE}px ${LiteGraph.NODE_FONT}`
|
||||
ctx.fillStyle = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(label, CANVAS_MARGIN, y + height * 0.7)
|
||||
if (removable) {
|
||||
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('\u2715', removeButtonCenterX(width), y + height * 0.7)
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
const countGroupRows = (group: string, node: LGraphNode): number =>
|
||||
(node.widgets ?? []).reduce(
|
||||
(count, w) =>
|
||||
headerRowIndex(group, w.name) !== undefined ? count + 1 : count,
|
||||
0
|
||||
)
|
||||
|
||||
/** Build a row's header + field widgets, returning them detached from the node. */
|
||||
function createRow(
|
||||
group: string,
|
||||
row: number,
|
||||
state: DynamicGroupState,
|
||||
node: DynamicGroupNode
|
||||
): IBaseWidget[] {
|
||||
const { addNodeInput } = useLitegraphService()
|
||||
const startLen = node.widgets!.length
|
||||
|
||||
const header = node.addCustomWidget({
|
||||
name: rowHeaderName(group, row),
|
||||
type: 'dynamic_group_row',
|
||||
value: row,
|
||||
y: 0,
|
||||
serialize: false,
|
||||
callback: undefined as IBaseWidget['callback'],
|
||||
draw(
|
||||
this: IBaseWidget,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
_node: LGraphNode,
|
||||
width: number,
|
||||
y: number
|
||||
) {
|
||||
const idx = headerRowIndex(group, this.name) ?? 0
|
||||
const label = t('dynamicGroup.row', { index: idx + 1 })
|
||||
drawGroupRowHeader(ctx, width, y, label, !!this.options?.removable)
|
||||
},
|
||||
mouse(this: IBaseWidget, event: CanvasPointerEvent, pos: Point) {
|
||||
if (event.type !== 'pointerup' || !this.options?.removable) return false
|
||||
const half = LiteGraph.NODE_WIDGET_HEIGHT * 0.5
|
||||
if (Math.abs(pos[0] - removeButtonCenterX(node.size[0])) > half)
|
||||
return false
|
||||
const idx = headerRowIndex(group, this.name)
|
||||
if (idx !== undefined) removeRow(group, idx, node)
|
||||
return true
|
||||
},
|
||||
options: { serialize: false, socketless: true, removable: row >= state.min }
|
||||
})
|
||||
header.callback = function (this: IBaseWidget) {
|
||||
const idx = headerRowIndex(group, this.name)
|
||||
if (idx !== undefined) removeRow(group, idx, node)
|
||||
}
|
||||
|
||||
for (const spec of state.inputSpecs)
|
||||
addNodeInput(node, {
|
||||
...spec,
|
||||
name: fieldName(group, row, spec.name),
|
||||
display_name: spec.display_name ?? spec.name
|
||||
})
|
||||
|
||||
return node.widgets!.splice(startLen)
|
||||
}
|
||||
|
||||
function insertRowAfterGroup(
|
||||
group: string,
|
||||
node: LGraphNode,
|
||||
rowWidgets: IBaseWidget[]
|
||||
): void {
|
||||
const lastIdx = node.widgets!.findLastIndex(
|
||||
(w) => w.name === group || w.name.startsWith(`${group}.`)
|
||||
)
|
||||
node.widgets!.splice(lastIdx + 1, 0, ...rowWidgets)
|
||||
}
|
||||
|
||||
function syncController(group: string, node: DynamicGroupNode): void {
|
||||
const state = node.comfyDynamic.dynamicGroup[group]
|
||||
const controller = node.widgets?.find((w) => w.name === group)
|
||||
if (!state || !controller) return
|
||||
controller.options ??= {}
|
||||
controller.options.disabled = countGroupRows(group, node) >= state.max
|
||||
node.size[1] = node.computeSize([...node.size])[1]
|
||||
}
|
||||
|
||||
function addRow(group: string, node: DynamicGroupNode): void {
|
||||
const state = node.comfyDynamic.dynamicGroup[group]
|
||||
if (!state) return
|
||||
node.widgets ??= []
|
||||
const row = countGroupRows(group, node)
|
||||
if (row >= state.max) return
|
||||
insertRowAfterGroup(group, node, createRow(group, row, state, node))
|
||||
syncController(group, node)
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function removeRow(group: string, row: number, node: DynamicGroupNode): void {
|
||||
const state = node.comfyDynamic.dynamicGroup[group]
|
||||
if (!state || row < state.min) return
|
||||
|
||||
for (const w of remove(node.widgets!, (w) =>
|
||||
belongsToRow(group, w.name, row)
|
||||
))
|
||||
w.onRemove?.()
|
||||
remove(node.inputs, (inp) => belongsToRow(group, inp.name, row))
|
||||
|
||||
for (const w of node.widgets ?? []) {
|
||||
const headerRow = headerRowIndex(group, w.name)
|
||||
if (headerRow !== undefined && headerRow > row) {
|
||||
w.name = rowHeaderName(group, headerRow - 1)
|
||||
w.options ??= {}
|
||||
w.options.removable = headerRow - 1 >= state.min
|
||||
continue
|
||||
}
|
||||
const shifted = shiftedFieldName(group, w.name, row)
|
||||
if (shifted !== undefined) w.name = shifted
|
||||
}
|
||||
for (const inp of node.inputs) {
|
||||
const shifted = shiftedFieldName(group, inp.name, row)
|
||||
if (shifted === undefined) continue
|
||||
inp.name = shifted
|
||||
if (inp.widget) inp.widget.name = shifted
|
||||
}
|
||||
|
||||
syncController(group, node)
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
/** Rebuild the group from scratch to hold exactly `count` rows. */
|
||||
function rebuildRows(group: string, count: number, node: DynamicGroupNode) {
|
||||
const state = node.comfyDynamic.dynamicGroup[group]
|
||||
if (!state) return
|
||||
node.widgets ??= []
|
||||
|
||||
const isRowMember = (name: string) => name.startsWith(`${group}.`)
|
||||
for (const w of remove(node.widgets, (w) => isRowMember(w.name)))
|
||||
w.onRemove?.()
|
||||
remove(node.inputs, (inp) => isRowMember(inp.name))
|
||||
|
||||
const insertAt = node.widgets.findIndex((w) => w.name === group) + 1
|
||||
const rowWidgets: IBaseWidget[] = []
|
||||
for (let row = 0; row < count; row++)
|
||||
rowWidgets.push(...createRow(group, row, state, node))
|
||||
node.widgets.splice(insertAt, 0, ...rowWidgets)
|
||||
}
|
||||
|
||||
function dynamicGroupWidget(
|
||||
node: LGraphNode,
|
||||
inputName: string,
|
||||
untypedInputData: InputSpec,
|
||||
_appArg: ComfyApp
|
||||
) {
|
||||
const parseResult = zDynamicGroupInputSpec.safeParse(untypedInputData)
|
||||
if (!parseResult.success) throw new Error('invalid DynamicGroup spec')
|
||||
const [, { template, min, max }] = parseResult.data
|
||||
|
||||
const toSpecs = (
|
||||
inputs: Record<string, InputSpec> | undefined,
|
||||
isOptional: boolean
|
||||
) =>
|
||||
Object.entries(inputs ?? {}).map(([name, spec]) =>
|
||||
transformInputSpecV1ToV2(spec, { name, isOptional })
|
||||
)
|
||||
const inputSpecs = [
|
||||
...toSpecs(template.required, false),
|
||||
...toSpecs(template.optional, true)
|
||||
]
|
||||
|
||||
withComfyDynamicGroup(node)
|
||||
const typedNode = node as DynamicGroupNode
|
||||
typedNode.comfyDynamic.dynamicGroup[inputName] = { min, max, inputSpecs }
|
||||
|
||||
node.widgets ??= []
|
||||
const controller = node.addCustomWidget({
|
||||
name: inputName,
|
||||
type: 'dynamic_group_add',
|
||||
value: min,
|
||||
y: 0,
|
||||
serialize: true,
|
||||
callback: () => addRow(inputName, typedNode),
|
||||
draw(
|
||||
this: IBaseWidget,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
_node: LGraphNode,
|
||||
width: number,
|
||||
y: number
|
||||
) {
|
||||
drawGroupButton(
|
||||
ctx,
|
||||
width,
|
||||
y,
|
||||
t('dynamicGroup.addRow'),
|
||||
!!this.options?.disabled
|
||||
)
|
||||
},
|
||||
mouse(this: IBaseWidget, event: CanvasPointerEvent) {
|
||||
if (event.type !== 'pointerup' || this.options?.disabled) return false
|
||||
addRow(inputName, typedNode)
|
||||
return true
|
||||
},
|
||||
options: { serialize: false, socketless: true, disabled: false }
|
||||
})
|
||||
|
||||
Object.defineProperty(controller, 'value', {
|
||||
get() {
|
||||
return countGroupRows(inputName, typedNode)
|
||||
},
|
||||
set(count: unknown) {
|
||||
if (typeof count !== 'number') return
|
||||
rebuildRows(inputName, count, typedNode)
|
||||
syncController(inputName, typedNode)
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
|
||||
controller.value = min
|
||||
|
||||
return { widget: controller }
|
||||
}
|
||||
|
||||
export const dynamicWidgets = {
|
||||
COMFY_DYNAMICCOMBO_V3: dynamicComboWidget,
|
||||
COMFY_DYNAMICGROUP_V3: dynamicGroupWidget
|
||||
}
|
||||
const dynamicInputs: Record<
|
||||
string,
|
||||
(node: LGraphNode, inputSpec: InputSpecV2) => void
|
||||
|
||||
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NodeId, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraph,
|
||||
LGraphGroup,
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
LLink,
|
||||
@@ -323,6 +324,96 @@ describe('Graph Clearing and Callbacks', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('node:before-removed event', () => {
|
||||
it('fires node:before-removed for a successful node removal', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
|
||||
const events: { node: LGraphNode; graphAtDispatch: unknown }[] = []
|
||||
graph.events.addEventListener('node:before-removed', (e) => {
|
||||
events.push({
|
||||
node: e.detail.node,
|
||||
graphAtDispatch: e.detail.node.graph
|
||||
})
|
||||
})
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0].node).toBe(node)
|
||||
expect(events[0].graphAtDispatch).toBe(graph)
|
||||
expect(node.graph).toBeNull()
|
||||
})
|
||||
|
||||
it('does not fire node:before-removed for a node not in the graph', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
|
||||
const fired = vi.fn()
|
||||
graph.events.addEventListener('node:before-removed', fired)
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
expect(fired).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fire node:before-removed when removing an LGraphGroup', () => {
|
||||
const graph = new LGraph()
|
||||
const group = new LGraphGroup('test-group')
|
||||
graph.add(group)
|
||||
|
||||
const fired = vi.fn()
|
||||
graph.events.addEventListener('node:before-removed', fired)
|
||||
|
||||
graph.remove(group)
|
||||
|
||||
expect(fired).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fire node:before-removed when ignore_remove is set', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
node.ignore_remove = true
|
||||
|
||||
const fired = vi.fn()
|
||||
graph.events.addEventListener('node:before-removed', fired)
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
expect(fired).not.toHaveBeenCalled()
|
||||
expect(graph.nodes).toContain(node)
|
||||
})
|
||||
|
||||
it('fires node:before-removed before node.onRemoved and detach', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
|
||||
const order: string[] = []
|
||||
graph.events.addEventListener('node:before-removed', () => {
|
||||
order.push(
|
||||
`before-removed(graph=${node.graph === graph ? 'set' : 'null'})`
|
||||
)
|
||||
})
|
||||
node.onRemoved = () => {
|
||||
order.push(`onRemoved(graph=${node.graph === graph ? 'set' : 'null'})`)
|
||||
}
|
||||
graph.onNodeRemoved = (n) => {
|
||||
order.push(`onNodeRemoved(graph=${n.graph === null ? 'null' : 'set'})`)
|
||||
}
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
expect(order).toEqual([
|
||||
'before-removed(graph=set)',
|
||||
'onRemoved(graph=set)',
|
||||
'onNodeRemoved(graph=null)'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph Definition Garbage Collection', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -375,6 +466,53 @@ describe('Subgraph Definition Garbage Collection', () => {
|
||||
expect(graphRemovedNodeIds.size).toBe(2)
|
||||
})
|
||||
|
||||
it('subgraph-definition GC dispatches node:before-removed on the inner subgraph for each inner node', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const { subgraph, innerNodes } = createSubgraphWithNodes(rootGraph, 2)
|
||||
|
||||
const dispatched: { node: LGraphNode; graphAtDispatch: unknown }[] = []
|
||||
subgraph.events.addEventListener('node:before-removed', (e) => {
|
||||
dispatched.push({
|
||||
node: e.detail.node,
|
||||
graphAtDispatch: e.detail.node.graph
|
||||
})
|
||||
})
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
rootGraph.remove(subgraphNode)
|
||||
|
||||
expect(dispatched.map((e) => e.node)).toEqual(innerNodes)
|
||||
for (const entry of dispatched) {
|
||||
expect(entry.graphAtDispatch).toBe(subgraph)
|
||||
}
|
||||
})
|
||||
|
||||
it('subgraph-definition GC dispatches node:before-removed before each inner node onRemoved', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const { subgraph, innerNodes } = createSubgraphWithNodes(rootGraph, 1)
|
||||
const innerNode = innerNodes[0]
|
||||
|
||||
const order: string[] = []
|
||||
subgraph.events.addEventListener('node:before-removed', () => {
|
||||
order.push('before-removed')
|
||||
})
|
||||
innerNode.onRemoved = () => {
|
||||
order.push('onRemoved')
|
||||
}
|
||||
subgraph.onNodeRemoved = () => {
|
||||
order.push('onNodeRemoved')
|
||||
}
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
rootGraph.remove(subgraphNode)
|
||||
|
||||
expect(order).toEqual(['before-removed', 'onRemoved', 'onNodeRemoved'])
|
||||
})
|
||||
|
||||
it('subgraph definition is removed when SubgraphNode is removed', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const { subgraph } = createSubgraphWithNodes(rootGraph, 1)
|
||||
|
||||
@@ -155,6 +155,13 @@ export interface BaseLGraph {
|
||||
readonly rootGraph: LGraph
|
||||
}
|
||||
|
||||
function fireNodeRemovalLifecycle(node: LGraphNode): void {
|
||||
const graph: LGraph | null = node.graph
|
||||
graph?.events.dispatch('node:before-removed', { node })
|
||||
node.onRemoved?.()
|
||||
graph?.onNodeRemoved?.(node)
|
||||
}
|
||||
|
||||
/**
|
||||
* LGraph is the class that contain a full graph. We instantiate one and add nodes to it, and then we can run the execution loop.
|
||||
* supported callbacks:
|
||||
@@ -386,8 +393,7 @@ export class LGraph
|
||||
// safe clear
|
||||
if (this._nodes) {
|
||||
for (const _node of this._nodes) {
|
||||
_node.onRemoved?.()
|
||||
this.onNodeRemoved?.(_node)
|
||||
fireNodeRemovalLifecycle(_node)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1046,6 +1052,8 @@ export class LGraph
|
||||
// sure? - almost sure is wrong
|
||||
this.beforeChange()
|
||||
|
||||
this.events.dispatch('node:before-removed', { node })
|
||||
|
||||
const { inputs, outputs } = node
|
||||
|
||||
// disconnect inputs
|
||||
@@ -1081,10 +1089,7 @@ export class LGraph
|
||||
)
|
||||
|
||||
if (!hasRemainingReferences) {
|
||||
forEachNode(node.subgraph, (innerNode) => {
|
||||
innerNode.onRemoved?.()
|
||||
innerNode.graph?.onNodeRemoved?.(innerNode)
|
||||
})
|
||||
forEachNode(node.subgraph, fireNodeRemovalLifecycle)
|
||||
this.rootGraph.subgraphs.delete(node.subgraph.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -829,6 +829,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (this._lowQualityZoomThreshold > 0) {
|
||||
this._isLowQuality = scale < this._lowQualityZoomThreshold
|
||||
}
|
||||
this.setDirty(true, true)
|
||||
}
|
||||
|
||||
// Initialize link renderer if graph is available
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LLink, ResolvedConnection } from '@/lib/litegraph/src/LLink'
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
@@ -51,6 +51,13 @@ export interface LGraphEventMap {
|
||||
closingGraph: LGraph | Subgraph
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires on the owning graph before per-node teardown begins
|
||||
*/
|
||||
'node:before-removed': {
|
||||
node: LGraphNode
|
||||
}
|
||||
|
||||
'node:property:changed': {
|
||||
nodeId: NodeId
|
||||
property: string
|
||||
|
||||
@@ -85,6 +85,19 @@ describe('SubgraphNode Construction', () => {
|
||||
expect(subgraphNode.graph).toBeNull()
|
||||
})
|
||||
|
||||
it('should return empty widgets array (not throw) after removal', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const parentGraph = subgraphNode.graph!
|
||||
parentGraph.add(subgraphNode)
|
||||
|
||||
parentGraph.remove(subgraphNode)
|
||||
|
||||
expect(subgraphNode.graph).toBeNull()
|
||||
expect(() => subgraphNode.widgets).not.toThrow()
|
||||
expect(subgraphNode.widgets).toEqual([])
|
||||
})
|
||||
|
||||
subgraphTest(
|
||||
'should synchronize slots with subgraph definition',
|
||||
({ subgraphWithNode }) => {
|
||||
|
||||
@@ -68,6 +68,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
return this.graph.rootGraph
|
||||
}
|
||||
|
||||
get isDetached(): boolean {
|
||||
return !this.graph
|
||||
}
|
||||
|
||||
override get displayType(): string {
|
||||
return 'Subgraph node'
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface IWidgetOptions<TValues = unknown> {
|
||||
|
||||
// Vue widget options
|
||||
disabled?: boolean
|
||||
removable?: boolean
|
||||
useGrouping?: boolean
|
||||
placeholder?: string
|
||||
showThumbnails?: boolean
|
||||
|
||||
@@ -2233,6 +2233,11 @@
|
||||
"slots": "Node Slots Error",
|
||||
"widgets": "Node Widgets Error"
|
||||
},
|
||||
"dynamicGroup": {
|
||||
"addRow": "Add row",
|
||||
"removeRow": "Remove row",
|
||||
"row": "Row {index}"
|
||||
},
|
||||
"oauth": {
|
||||
"consent": {
|
||||
"allow": "Continue",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useMissingMediaStore } from './missingMediaStore'
|
||||
import type { MissingMediaCandidate } from './types'
|
||||
|
||||
@@ -96,14 +97,6 @@ describe('useMissingMediaStore', () => {
|
||||
expect(store.missingMediaNodeIds.has('2')).toBe(true)
|
||||
})
|
||||
|
||||
it('hasMissingMediaOnNode checks node presence', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([makeCandidate('42', 'photo.png')])
|
||||
|
||||
expect(store.hasMissingMediaOnNode('42')).toBe(true)
|
||||
expect(store.hasMissingMediaOnNode('99')).toBe(false)
|
||||
})
|
||||
|
||||
it('removeMissingMediaByWidget removes matching node+widget entry', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
|
||||
@@ -68,10 +68,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
missingMediaCandidates.value = media.length ? media : null
|
||||
}
|
||||
|
||||
function hasMissingMediaOnNode(nodeLocatorId: string): boolean {
|
||||
return missingMediaNodeIds.value.has(nodeLocatorId)
|
||||
}
|
||||
|
||||
function isContainerWithMissingMedia(node: LGraphNode): boolean {
|
||||
return activeMissingMediaGraphIds.value.has(String(node.id))
|
||||
}
|
||||
@@ -157,7 +153,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
clearMissingMedia,
|
||||
createVerificationAbortController,
|
||||
|
||||
hasMissingMediaOnNode,
|
||||
isContainerWithMissingMedia
|
||||
}
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
IBaseWidget,
|
||||
IComboWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import {
|
||||
collectAllNodes,
|
||||
getExecutionIdByNode
|
||||
@@ -169,7 +170,7 @@ export function scanNodeModelCandidates(
|
||||
function scanAssetWidget(
|
||||
node: { type: string },
|
||||
widget: IAssetWidget,
|
||||
executionId: string,
|
||||
executionId: NodeExecutionId,
|
||||
getDirectory: ((nodeType: string) => string | undefined) | undefined
|
||||
): MissingModelCandidate | null {
|
||||
const value = widget.value
|
||||
@@ -190,7 +191,7 @@ function scanAssetWidget(
|
||||
function scanComboWidget(
|
||||
node: { type: string },
|
||||
widget: IComboWidget,
|
||||
executionId: string,
|
||||
executionId: NodeExecutionId,
|
||||
isAssetSupported: (nodeType: string, widgetName: string) => boolean,
|
||||
getDirectory: ((nodeType: string) => string | undefined) | undefined
|
||||
): MissingModelCandidate | null {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
|
||||
const mockNodeLocatorIdToNodeExecutionId = vi.hoisted(() =>
|
||||
vi.fn((nodeLocatorId: string) => nodeLocatorId)
|
||||
)
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: vi.fn((key: string) => `translated:${key}`),
|
||||
st: vi.fn((_key: string, fallback: string) => fallback)
|
||||
@@ -12,6 +18,12 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
nodeLocatorIdToNodeExecutionId: mockNodeLocatorIdToNodeExecutionId
|
||||
})
|
||||
}))
|
||||
|
||||
import { useMissingModelStore } from './missingModelStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -39,6 +51,9 @@ describe('missingModelStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.restoreAllMocks()
|
||||
mockNodeLocatorIdToNodeExecutionId.mockImplementation(
|
||||
(nodeLocatorId: string) => nodeLocatorId
|
||||
)
|
||||
})
|
||||
|
||||
describe('setMissingModels', () => {
|
||||
@@ -146,7 +161,9 @@ describe('missingModelStore', () => {
|
||||
makeModelCandidate('model_a.safetensors', { nodeId: '5' })
|
||||
])
|
||||
|
||||
expect(store.hasMissingModelOnNode('5')).toBe(true)
|
||||
expect(store.hasMissingModelOnNode(createNodeLocatorId(null, 5))).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('returns false when node has no missing model', () => {
|
||||
@@ -155,12 +172,30 @@ describe('missingModelStore', () => {
|
||||
makeModelCandidate('model_a.safetensors', { nodeId: '5' })
|
||||
])
|
||||
|
||||
expect(store.hasMissingModelOnNode('99')).toBe(false)
|
||||
expect(store.hasMissingModelOnNode(createNodeLocatorId(null, 99))).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('returns false when no models are missing', () => {
|
||||
const store = useMissingModelStore()
|
||||
expect(store.hasMissingModelOnNode('1')).toBe(false)
|
||||
expect(store.hasMissingModelOnNode(createNodeLocatorId(null, 1))).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('compares subgraph locators against missing model execution IDs', () => {
|
||||
const store = useMissingModelStore()
|
||||
const locatorId = createNodeLocatorId(
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
63
|
||||
)
|
||||
mockNodeLocatorIdToNodeExecutionId.mockReturnValueOnce('65:70:63')
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', { nodeId: '65:70:63' })
|
||||
])
|
||||
|
||||
expect(store.hasMissingModelOnNode(locatorId)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -6,10 +6,11 @@ import { t } from '@/i18n'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
|
||||
|
||||
/**
|
||||
@@ -19,6 +20,7 @@ import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
|
||||
*/
|
||||
export const useMissingModelStore = defineStore('missingModel', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const missingModelCandidates = ref<MissingModelCandidate[] | null>(null)
|
||||
const isRefreshingMissingModels = ref(false)
|
||||
@@ -193,8 +195,10 @@ export const useMissingModelStore = defineStore('missingModel', () => {
|
||||
missingModelCandidates.value = [...existing, ...newModels]
|
||||
}
|
||||
|
||||
function hasMissingModelOnNode(nodeLocatorId: string): boolean {
|
||||
return missingModelNodeIds.value.has(nodeLocatorId)
|
||||
function hasMissingModelOnNode(nodeLocatorId: NodeLocatorId): boolean {
|
||||
const executionId =
|
||||
workflowStore.nodeLocatorIdToNodeExecutionId(nodeLocatorId)
|
||||
return executionId ? missingModelNodeIds.value.has(executionId) : false
|
||||
}
|
||||
|
||||
function isWidgetMissingModel(nodeId: string, widgetName: string): boolean {
|
||||
|
||||
@@ -53,6 +53,7 @@ import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil'
|
||||
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
|
||||
import { rescanAndSurfaceMissingNodes } from './missingNodeScan'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
function mockNode(
|
||||
id: number,
|
||||
@@ -138,7 +139,9 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
|
||||
|
||||
it('uses executionId when available for nodeId', () => {
|
||||
vi.mocked(collectAllNodes).mockReturnValue([mockNode(1, 'Missing')])
|
||||
vi.mocked(getExecutionIdByNode).mockReturnValue('exec-42')
|
||||
vi.mocked(getExecutionIdByNode).mockReturnValue(
|
||||
createNodeExecutionId(['exec-42'])
|
||||
)
|
||||
|
||||
rescanAndSurfaceMissingNodes(mockGraph())
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useWorkflowDraftStoreV2 } from '@/platform/workflow/persistence/stores/
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { defaultGraph, defaultGraphJSON } from '@/scripts/defaultGraph'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { isSubgraph } from '@/utils/typeGuardUtil'
|
||||
import {
|
||||
createMockCanvas,
|
||||
@@ -887,81 +888,75 @@ describe('useWorkflowStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeExecutionIdToNodeLocatorId', () => {
|
||||
it('should convert execution ID to NodeLocatorId', () => {
|
||||
const result = store.nodeExecutionIdToNodeLocatorId('123:456')
|
||||
expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456')
|
||||
describe('executionIdToCurrentId', () => {
|
||||
it('should convert an execution ID to the active subgraph node ID', () => {
|
||||
const result = store.executionIdToCurrentId('123:456')
|
||||
expect(result).toBe('456')
|
||||
})
|
||||
|
||||
it('should return simple node ID for root level nodes', () => {
|
||||
const result = store.nodeExecutionIdToNodeLocatorId('123')
|
||||
expect(result).toBe('123')
|
||||
it('should return undefined for execution IDs outside the active subgraph', () => {
|
||||
expect(() => store.executionIdToCurrentId('999:456')).not.toThrow()
|
||||
expect(store.executionIdToCurrentId('999:456')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return null for invalid execution IDs', () => {
|
||||
const result = store.nodeExecutionIdToNodeLocatorId('999:456')
|
||||
expect(result).toBeNull()
|
||||
it('should return undefined for malformed execution IDs', () => {
|
||||
expect(() => store.executionIdToCurrentId('123::456')).not.toThrow()
|
||||
expect(store.executionIdToCurrentId('123::456')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeLocatorIdToNodeId', () => {
|
||||
it('should extract node ID from NodeLocatorId', () => {
|
||||
const result = store.nodeLocatorIdToNodeId(
|
||||
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
|
||||
createNodeLocatorId('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 456)
|
||||
)
|
||||
expect(result).toBe(456)
|
||||
})
|
||||
|
||||
it('should handle string node IDs', () => {
|
||||
const result = store.nodeLocatorIdToNodeId(
|
||||
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:node_1'
|
||||
createNodeLocatorId('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'node_1')
|
||||
)
|
||||
expect(result).toBe('node_1')
|
||||
})
|
||||
|
||||
it('should handle simple node IDs (root graph)', () => {
|
||||
const result = store.nodeLocatorIdToNodeId('123')
|
||||
const result = store.nodeLocatorIdToNodeId(
|
||||
createNodeLocatorId(null, 123)
|
||||
)
|
||||
expect(result).toBe(123)
|
||||
|
||||
const stringResult = store.nodeLocatorIdToNodeId('node_1')
|
||||
const stringResult = store.nodeLocatorIdToNodeId(
|
||||
createNodeLocatorId(null, 'node_1')
|
||||
)
|
||||
expect(stringResult).toBe('node_1')
|
||||
})
|
||||
|
||||
it('should return null for invalid NodeLocatorId', () => {
|
||||
const result = store.nodeLocatorIdToNodeId('invalid:format')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeLocatorIdToNodeExecutionId', () => {
|
||||
it('should convert NodeLocatorId to execution ID', () => {
|
||||
// Need to mock isSubgraph to identify our mockSubgraph
|
||||
vi.mocked(isSubgraph).mockImplementation((obj): obj is Subgraph => {
|
||||
return obj === store.activeSubgraph
|
||||
})
|
||||
|
||||
const result = store.nodeLocatorIdToNodeExecutionId(
|
||||
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
|
||||
createNodeLocatorId('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 456)
|
||||
)
|
||||
expect(result).toBe('123:456')
|
||||
})
|
||||
|
||||
it('should handle simple node IDs (root graph)', () => {
|
||||
const result = store.nodeLocatorIdToNodeExecutionId('123')
|
||||
const result = store.nodeLocatorIdToNodeExecutionId(
|
||||
createNodeLocatorId(null, 123)
|
||||
)
|
||||
expect(result).toBe('123')
|
||||
})
|
||||
|
||||
it('should return null for unknown subgraph UUID', () => {
|
||||
const result = store.nodeLocatorIdToNodeExecutionId(
|
||||
'unknown-uuid-1234-5678-90ab-cdef12345678:456'
|
||||
createNodeLocatorId('unknown-uuid-1234-5678-90ab-cdef12345678', 456)
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for invalid NodeLocatorId', () => {
|
||||
const result = store.nodeLocatorIdToNodeExecutionId('invalid:format')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -83,12 +83,9 @@ interface WorkflowStore {
|
||||
executionIdToCurrentId: (id: string) => string | undefined
|
||||
nodeIdToNodeLocatorId: (nodeId: NodeId, subgraph?: Subgraph) => NodeLocatorId
|
||||
nodeToNodeLocatorId: (node: LGraphNode) => NodeLocatorId
|
||||
nodeExecutionIdToNodeLocatorId: (
|
||||
nodeExecutionId: NodeExecutionId | string
|
||||
) => NodeLocatorId | null
|
||||
nodeLocatorIdToNodeId: (locatorId: NodeLocatorId | string) => NodeId | null
|
||||
nodeLocatorIdToNodeId: (locatorId: NodeLocatorId) => NodeId
|
||||
nodeLocatorIdToNodeExecutionId: (
|
||||
locatorId: NodeLocatorId | string,
|
||||
locatorId: NodeLocatorId,
|
||||
targetSubgraph?: Subgraph
|
||||
) => NodeExecutionId | null
|
||||
}
|
||||
@@ -580,17 +577,16 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
|
||||
const getSubgraphsFromInstanceIds = (
|
||||
currentGraph: LGraph | Subgraph,
|
||||
subgraphNodeIds: string[],
|
||||
subgraphs: Subgraph[] = []
|
||||
): Subgraph[] => {
|
||||
const currentPart = subgraphNodeIds.shift()
|
||||
if (currentPart === undefined) return subgraphs
|
||||
subgraphNodeIds: string[]
|
||||
): Subgraph[] | undefined => {
|
||||
const [currentPart, ...remainingParts] = subgraphNodeIds
|
||||
if (currentPart === undefined) return []
|
||||
|
||||
const subgraph = subgraphNodeIdToSubgraph(currentPart, currentGraph)
|
||||
if (subgraph === undefined) throw new Error('Subgraph not found')
|
||||
if (subgraph === undefined) return
|
||||
|
||||
subgraphs.push(subgraph)
|
||||
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
|
||||
const childSubgraphs = getSubgraphsFromInstanceIds(subgraph, remainingParts)
|
||||
return childSubgraphs ? [subgraph, ...childSubgraphs] : undefined
|
||||
}
|
||||
|
||||
//FIXME: use existing util function
|
||||
@@ -604,17 +600,17 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the execution ID (e.g., "123:456:789")
|
||||
const subgraphNodeIds = id.split(':')
|
||||
const executionPath = parseNodeExecutionId(id)?.map(String)
|
||||
if (!executionPath) return
|
||||
|
||||
// Start from the root graph
|
||||
const graph = comfyApp.rootGraph
|
||||
const nodeId = executionPath.at(-1)
|
||||
if (nodeId === undefined) return
|
||||
|
||||
// If the last subgraph is the active subgraph, return the node ID
|
||||
const subgraphs = getSubgraphsFromInstanceIds(graph, subgraphNodeIds)
|
||||
if (subgraphs.at(-1) === subgraph) {
|
||||
return subgraphNodeIds.at(-1)
|
||||
}
|
||||
const subgraphs = getSubgraphsFromInstanceIds(
|
||||
comfyApp.rootGraph,
|
||||
executionPath.slice(0, -1)
|
||||
)
|
||||
if (subgraphs?.at(-1) === subgraph) return nodeId
|
||||
}
|
||||
|
||||
watch(activeWorkflow, updateActiveGraph)
|
||||
@@ -632,7 +628,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
const targetSubgraph = subgraph ?? activeSubgraph.value
|
||||
if (!targetSubgraph) {
|
||||
// Node is in the root graph, return the node ID as-is
|
||||
return String(nodeId)
|
||||
return createNodeLocatorId(null, nodeId)
|
||||
}
|
||||
|
||||
return createNodeLocatorId(targetSubgraph.id, nodeId)
|
||||
@@ -646,55 +642,16 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
const nodeToNodeLocatorId = (node: LGraphNode): NodeLocatorId => {
|
||||
if (isSubgraph(node.graph))
|
||||
return createNodeLocatorId(node.graph.id, node.id)
|
||||
return String(node.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an execution ID to a NodeLocatorId
|
||||
* @param nodeExecutionId The execution node ID (e.g., "123:456:789")
|
||||
* @returns The NodeLocatorId or null if conversion fails
|
||||
*/
|
||||
const nodeExecutionIdToNodeLocatorId = (
|
||||
nodeExecutionId: NodeExecutionId | string
|
||||
): NodeLocatorId | null => {
|
||||
// Handle simple node IDs (root graph - no colons)
|
||||
if (!nodeExecutionId.includes(':')) {
|
||||
return nodeExecutionId
|
||||
}
|
||||
|
||||
const parts = parseNodeExecutionId(nodeExecutionId)
|
||||
if (!parts || parts.length === 0) return null
|
||||
|
||||
const nodeId = parts[parts.length - 1]
|
||||
const subgraphNodeIds = parts.slice(0, -1)
|
||||
|
||||
if (subgraphNodeIds.length === 0) {
|
||||
// Node is in root graph, return the node ID as-is
|
||||
return String(nodeId)
|
||||
}
|
||||
|
||||
try {
|
||||
const subgraphs = getSubgraphsFromInstanceIds(
|
||||
comfyApp.rootGraph,
|
||||
subgraphNodeIds.map((id) => String(id))
|
||||
)
|
||||
const immediateSubgraph = subgraphs[subgraphs.length - 1]
|
||||
return createNodeLocatorId(immediateSubgraph.id, nodeId)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return createNodeLocatorId(null, node.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the node ID from a NodeLocatorId
|
||||
* @param locatorId The NodeLocatorId
|
||||
* @returns The local node ID or null if invalid
|
||||
* @returns The local node ID
|
||||
*/
|
||||
const nodeLocatorIdToNodeId = (
|
||||
locatorId: NodeLocatorId | string
|
||||
): NodeId | null => {
|
||||
const parsed = parseNodeLocatorId(locatorId)
|
||||
return parsed?.localNodeId ?? null
|
||||
const nodeLocatorIdToNodeId = (locatorId: NodeLocatorId): NodeId => {
|
||||
return parseNodeLocatorId(locatorId)!.localNodeId
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -704,7 +661,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
* @returns The execution ID or null if the node is not accessible from the target context
|
||||
*/
|
||||
const nodeLocatorIdToNodeExecutionId = (
|
||||
locatorId: NodeLocatorId | string,
|
||||
locatorId: NodeLocatorId,
|
||||
targetSubgraph?: Subgraph
|
||||
): NodeExecutionId | null => {
|
||||
const parsed = parseNodeLocatorId(locatorId)
|
||||
@@ -714,7 +671,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
|
||||
// If no subgraph UUID, this is a root graph node
|
||||
if (!subgraphUuid) {
|
||||
return String(localNodeId)
|
||||
return createNodeExecutionId([localNodeId])
|
||||
}
|
||||
|
||||
// Find the path from root to the subgraph with this UUID
|
||||
@@ -751,7 +708,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
comfyApp.rootGraph,
|
||||
path.slice(0, idx + 1).map((id) => String(id))
|
||||
)
|
||||
return subgraphs[subgraphs.length - 1] === targetSubgraph
|
||||
return subgraphs?.at(-1) === targetSubgraph
|
||||
})
|
||||
) {
|
||||
return null
|
||||
@@ -795,7 +752,6 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
executionIdToCurrentId,
|
||||
nodeIdToNodeLocatorId,
|
||||
nodeToNodeLocatorId,
|
||||
nodeExecutionIdToNodeLocatorId,
|
||||
nodeLocatorIdToNodeId,
|
||||
nodeLocatorIdToNodeExecutionId
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphCanvas, Positionable } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
@@ -85,6 +88,42 @@ describe('useCanvasStore', () => {
|
||||
expect(originalHandler).toHaveBeenCalledWith(2.0, app.canvas.ds.offset)
|
||||
})
|
||||
})
|
||||
|
||||
describe('node:before-removed selection cleanup', () => {
|
||||
it('removes the node from store.selectedItems before its onRemoved fires', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
|
||||
const selectedItems = new Set<Positionable>([node])
|
||||
const fakeCanvas = {
|
||||
canvas: document.createElement('canvas'),
|
||||
graph,
|
||||
selectedItems,
|
||||
deselect: vi.fn((item: Positionable) => {
|
||||
selectedItems.delete(item)
|
||||
})
|
||||
}
|
||||
store.canvas = fakeCanvas as unknown as LGraphCanvas
|
||||
await nextTick()
|
||||
store.updateSelectedItems()
|
||||
expect(store.selectedItems).toContain(node)
|
||||
|
||||
let stillSelectedInOnRemoved: boolean | undefined
|
||||
node.onRemoved = () => {
|
||||
stillSelectedInOnRemoved = store.selectedItems.includes(node)
|
||||
}
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
expect(
|
||||
stillSelectedInOnRemoved,
|
||||
'selectedItems must not contain the node when onRemoved fires'
|
||||
).toBe(false)
|
||||
expect(store.selectedItems).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it('Does not include groups in selected nodeIds', async () => {
|
||||
store.selectedItems = [new LGraphGroup()]
|
||||
|
||||
|
||||
@@ -131,6 +131,18 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
whenever(
|
||||
() => canvas.value,
|
||||
(newCanvas) => {
|
||||
currentGraph.value = newCanvas.graph
|
||||
// Scoped to the on-screen graph: selection only holds items from it,
|
||||
// so removals in other graphs can't affect the live selection.
|
||||
useEventListener(
|
||||
() => currentGraph.value?.events,
|
||||
'node:before-removed',
|
||||
(e: CustomEvent<{ node: LGraphNode }>) => {
|
||||
newCanvas.deselect(e.detail.node)
|
||||
updateSelectedItems()
|
||||
}
|
||||
)
|
||||
|
||||
useEventListener(
|
||||
newCanvas.canvas,
|
||||
'litegraph:set-graph',
|
||||
|
||||
@@ -21,7 +21,6 @@ vi.mock('@/renderer/core/layout/transform/useTransformState', () => {
|
||||
useTransformState: () => ({
|
||||
camera: computed(() => mockData.mockCamera),
|
||||
transformStyle: computed(() => mockData.mockTransformStyle),
|
||||
canvasToScreen: vi.fn(),
|
||||
screenToCanvas: vi.fn(),
|
||||
isNodeInViewport: vi.fn(),
|
||||
syncWithCanvas
|
||||
@@ -180,7 +179,6 @@ describe('TransformPane', () => {
|
||||
|
||||
const transformState = useTransformState()
|
||||
expect(transformState.syncWithCanvas).toBeDefined()
|
||||
expect(transformState.canvasToScreen).toBeDefined()
|
||||
expect(transformState.screenToCanvas).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { NodeId } from '@/renderer/core/layout/types'
|
||||
|
||||
/**
|
||||
* Slot identifier utilities for consistent slot key generation and parsing
|
||||
*
|
||||
@@ -7,7 +9,7 @@
|
||||
*/
|
||||
|
||||
interface SlotIdentifier {
|
||||
nodeId: string
|
||||
nodeId: NodeId
|
||||
index: number
|
||||
isInput: boolean
|
||||
}
|
||||
@@ -18,23 +20,23 @@ interface SlotIdentifier {
|
||||
*/
|
||||
export function getSlotKey(identifier: SlotIdentifier): string
|
||||
export function getSlotKey(
|
||||
nodeId: string,
|
||||
nodeId: NodeId,
|
||||
index: number,
|
||||
isInput: boolean
|
||||
): string
|
||||
export function getSlotKey(
|
||||
nodeIdOrIdentifier: string | SlotIdentifier,
|
||||
nodeIdOrIdentifier: NodeId | SlotIdentifier,
|
||||
index?: number,
|
||||
isInput?: boolean
|
||||
): string {
|
||||
if (typeof nodeIdOrIdentifier === 'object') {
|
||||
const { nodeId, index, isInput } = nodeIdOrIdentifier
|
||||
return `${nodeId}-${isInput ? 'in' : 'out'}-${index}`
|
||||
return `${String(nodeId)}-${isInput ? 'in' : 'out'}-${index}`
|
||||
}
|
||||
|
||||
if (index === undefined || isInput === undefined) {
|
||||
throw new Error('Missing required parameters for slot key generation')
|
||||
}
|
||||
|
||||
return `${nodeIdOrIdentifier}-${isInput ? 'in' : 'out'}-${index}`
|
||||
return `${String(nodeIdOrIdentifier)}-${isInput ? 'in' : 'out'}-${index}`
|
||||
}
|
||||
|
||||
@@ -127,8 +127,8 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
private isGlobalDispatchQueued = false
|
||||
|
||||
// CustomRef cache and trigger functions
|
||||
private nodeRefs = new Map<NodeId, Ref<NodeLayout | null>>()
|
||||
private nodeTriggers = new Map<NodeId, () => void>()
|
||||
private nodeRefs = new Map<string, Ref<NodeLayout | null>>()
|
||||
private nodeTriggers = new Map<string, () => void>()
|
||||
|
||||
// New data structures for hit testing
|
||||
private linkLayouts = new Map<LinkId, LinkLayout>()
|
||||
@@ -137,10 +137,10 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
private rerouteLayouts = new Map<RerouteId, RerouteLayout>()
|
||||
|
||||
// Spatial index managers
|
||||
private spatialIndex: SpatialIndexManager // For nodes
|
||||
private linkSegmentSpatialIndex: SpatialIndexManager // For link segments (single index for all link geometry)
|
||||
private slotSpatialIndex: SpatialIndexManager // For slots
|
||||
private rerouteSpatialIndex: SpatialIndexManager // For reroutes
|
||||
private spatialIndex: SpatialIndexManager<NodeId> // For nodes
|
||||
private linkSegmentSpatialIndex: SpatialIndexManager<string> // For link segments (single index for all link geometry)
|
||||
private slotSpatialIndex: SpatialIndexManager<string> // For slots
|
||||
private rerouteSpatialIndex: SpatialIndexManager<string> // For reroutes
|
||||
|
||||
// Vue dragging state for selection toolbox (public ref for direct mutation)
|
||||
public isDraggingVueNodes = ref(false)
|
||||
@@ -173,10 +173,10 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
this.yoperations = this.ydoc.getArray('operations')
|
||||
|
||||
// Initialize spatial index managers
|
||||
this.spatialIndex = new SpatialIndexManager()
|
||||
this.linkSegmentSpatialIndex = new SpatialIndexManager() // Single index for all link geometry
|
||||
this.slotSpatialIndex = new SpatialIndexManager()
|
||||
this.rerouteSpatialIndex = new SpatialIndexManager()
|
||||
this.spatialIndex = new SpatialIndexManager<NodeId>()
|
||||
this.linkSegmentSpatialIndex = new SpatialIndexManager<string>() // Single index for all link geometry
|
||||
this.slotSpatialIndex = new SpatialIndexManager<string>()
|
||||
this.rerouteSpatialIndex = new SpatialIndexManager<string>()
|
||||
|
||||
// Listen for Yjs changes and trigger Vue reactivity
|
||||
this.ynodes.observe((event: Y.YMapEvent<NodeLayoutMap>) => {
|
||||
@@ -230,24 +230,24 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
* Get or create a customRef for a node layout
|
||||
*/
|
||||
getNodeLayoutRef(nodeId: NodeId): Ref<NodeLayout | null> {
|
||||
let nodeRef = this.nodeRefs.get(nodeId)
|
||||
const nodeKey = String(nodeId)
|
||||
let nodeRef = this.nodeRefs.get(nodeKey)
|
||||
|
||||
if (!nodeRef) {
|
||||
nodeRef = customRef<NodeLayout | null>((track, trigger) => {
|
||||
// Store the trigger so we can call it when Yjs changes
|
||||
this.nodeTriggers.set(nodeId, trigger)
|
||||
this.nodeTriggers.set(nodeKey, trigger)
|
||||
|
||||
return {
|
||||
get: () => {
|
||||
track()
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
const ynode = this.ynodes.get(nodeKey)
|
||||
const layout = ynode ? yNodeToLayout(ynode) : null
|
||||
return layout
|
||||
},
|
||||
set: (newLayout: NodeLayout | null) => {
|
||||
if (newLayout === null) {
|
||||
// Delete operation
|
||||
const existing = this.ynodes.get(nodeId)
|
||||
const existing = this.ynodes.get(nodeKey)
|
||||
if (existing) {
|
||||
this.applyOperation({
|
||||
type: 'deleteNode',
|
||||
@@ -261,7 +261,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
}
|
||||
} else {
|
||||
// Update operation - detect what changed
|
||||
const existing = this.ynodes.get(nodeId)
|
||||
const existing = this.ynodes.get(nodeKey)
|
||||
if (!existing) {
|
||||
// Create operation
|
||||
this.applyOperation({
|
||||
@@ -326,7 +326,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
}
|
||||
})
|
||||
|
||||
this.nodeRefs.set(nodeId, nodeRef)
|
||||
this.nodeRefs.set(nodeKey, nodeRef)
|
||||
}
|
||||
|
||||
return nodeRef
|
||||
@@ -341,13 +341,10 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
void this.version
|
||||
|
||||
const result: NodeId[] = []
|
||||
for (const [nodeId] of this.ynodes) {
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
if (ynode) {
|
||||
const layout = yNodeToLayout(ynode)
|
||||
if (layout && boundsIntersect(layout.bounds, bounds)) {
|
||||
result.push(nodeId)
|
||||
}
|
||||
for (const [nodeId, ynode] of this.ynodes) {
|
||||
const layout = yNodeToLayout(ynode)
|
||||
if (boundsIntersect(layout.bounds, bounds)) {
|
||||
result.push(nodeId)
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -363,14 +360,9 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
void this.version
|
||||
|
||||
const result = new Map<NodeId, NodeLayout>()
|
||||
for (const [nodeId] of this.ynodes) {
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
if (ynode) {
|
||||
const layout = yNodeToLayout(ynode)
|
||||
if (layout) {
|
||||
result.set(nodeId, layout)
|
||||
}
|
||||
}
|
||||
for (const [nodeId, ynode] of this.ynodes) {
|
||||
const layout = yNodeToLayout(ynode)
|
||||
result.set(nodeId, layout)
|
||||
}
|
||||
return result
|
||||
})
|
||||
@@ -389,14 +381,9 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
queryNodeAtPoint(point: Point): NodeId | null {
|
||||
const nodes: Array<[NodeId, NodeLayout]> = []
|
||||
|
||||
for (const [nodeId] of this.ynodes) {
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
if (ynode) {
|
||||
const layout = yNodeToLayout(ynode)
|
||||
if (layout) {
|
||||
nodes.push([nodeId, layout])
|
||||
}
|
||||
}
|
||||
for (const [nodeId, ynode] of this.ynodes) {
|
||||
const layout = yNodeToLayout(ynode)
|
||||
nodes.push([nodeId, layout])
|
||||
}
|
||||
|
||||
// Sort by zIndex (top to bottom)
|
||||
@@ -446,17 +433,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
deleteLinkLayout(linkId: LinkId): void {
|
||||
const deleted = this.linkLayouts.delete(linkId)
|
||||
if (deleted) {
|
||||
// Clean up any segment layouts for this link
|
||||
const keysToDelete: string[] = []
|
||||
for (const [key] of this.linkSegmentLayouts) {
|
||||
if (key.startsWith(`${linkId}:`)) {
|
||||
keysToDelete.push(key)
|
||||
}
|
||||
}
|
||||
for (const key of keysToDelete) {
|
||||
this.linkSegmentLayouts.delete(key)
|
||||
this.linkSegmentSpatialIndex.remove(key)
|
||||
}
|
||||
this.cleanupLinkSegments(linkId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,6 +513,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
* Update reroute layout data
|
||||
*/
|
||||
updateRerouteLayout(rerouteId: RerouteId, layout: RerouteLayout): void {
|
||||
const rerouteKey = String(rerouteId)
|
||||
const existing = this.rerouteLayouts.get(rerouteId)
|
||||
|
||||
if (!existing) {
|
||||
@@ -548,10 +526,10 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
|
||||
if (existing) {
|
||||
// Update spatial index
|
||||
this.rerouteSpatialIndex.update(String(rerouteId), layout.bounds) // Spatial index uses strings
|
||||
this.rerouteSpatialIndex.update(rerouteKey, layout.bounds)
|
||||
} else {
|
||||
// Insert into spatial index
|
||||
this.rerouteSpatialIndex.insert(String(rerouteId), layout.bounds) // Spatial index uses strings
|
||||
this.rerouteSpatialIndex.insert(rerouteKey, layout.bounds)
|
||||
}
|
||||
|
||||
this.rerouteLayouts.set(rerouteId, layout)
|
||||
@@ -564,7 +542,8 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
const deleted = this.rerouteLayouts.delete(rerouteId)
|
||||
if (deleted) {
|
||||
// Remove from spatial index
|
||||
this.rerouteSpatialIndex.remove(String(rerouteId)) // Spatial index uses strings
|
||||
const rerouteKey = String(rerouteId)
|
||||
this.rerouteSpatialIndex.remove(rerouteKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -917,7 +896,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
// Manually trigger affected node refs after transaction
|
||||
// This is needed because Yjs observers don't fire for property changes
|
||||
change.nodeIds.forEach((nodeId) => {
|
||||
const trigger = this.nodeTriggers.get(nodeId)
|
||||
const trigger = this.nodeTriggers.get(String(nodeId))
|
||||
if (trigger) {
|
||||
trigger()
|
||||
}
|
||||
@@ -989,8 +968,9 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
* This should be called from the component's onUnmounted hook.
|
||||
*/
|
||||
cleanupNodeRef(nodeId: NodeId): void {
|
||||
this.nodeRefs.delete(nodeId)
|
||||
this.nodeTriggers.delete(nodeId)
|
||||
const nodeKey = String(nodeId)
|
||||
this.nodeRefs.delete(nodeKey)
|
||||
this.nodeTriggers.delete(nodeKey)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1018,8 +998,9 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
this.isGlobalDispatchQueued = false
|
||||
|
||||
nodes.forEach((node, index) => {
|
||||
const nodeId = String(node.id)
|
||||
const layout: NodeLayout = {
|
||||
id: node.id.toString(),
|
||||
id: nodeId,
|
||||
position: { x: node.pos[0], y: node.pos[1] },
|
||||
size: { width: node.size[0], height: node.size[1] },
|
||||
zIndex: index,
|
||||
@@ -1032,10 +1013,10 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
}
|
||||
}
|
||||
|
||||
this.ynodes.set(layout.id, layoutToYNode(layout))
|
||||
this.ynodes.set(nodeId, layoutToYNode(layout))
|
||||
|
||||
// Add to spatial index
|
||||
this.spatialIndex.insert(layout.id, layout.bounds)
|
||||
this.spatialIndex.insert(nodeId, layout.bounds)
|
||||
})
|
||||
|
||||
// Trigger all existing refs to notify Vue of the new data
|
||||
@@ -1048,7 +1029,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
operation: MoveNodeOperation,
|
||||
change: LayoutChange
|
||||
): void {
|
||||
const ynode = this.ynodes.get(operation.nodeId)
|
||||
const ynode = this.ynodes.get(String(operation.nodeId))
|
||||
if (!ynode) {
|
||||
return
|
||||
}
|
||||
@@ -1076,7 +1057,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
operation: ResizeNodeOperation,
|
||||
change: LayoutChange
|
||||
): void {
|
||||
const ynode = this.ynodes.get(operation.nodeId)
|
||||
const ynode = this.ynodes.get(String(operation.nodeId))
|
||||
if (!ynode) return
|
||||
|
||||
const position = yNodeToLayout(ynode).position
|
||||
@@ -1102,7 +1083,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
operation: SetNodeZIndexOperation,
|
||||
change: LayoutChange
|
||||
): void {
|
||||
const ynode = this.ynodes.get(operation.nodeId)
|
||||
const ynode = this.ynodes.get(String(operation.nodeId))
|
||||
if (!ynode) return
|
||||
|
||||
ynode.set('zIndex', operation.zIndex)
|
||||
@@ -1114,7 +1095,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
change: LayoutChange
|
||||
): void {
|
||||
const ynode = layoutToYNode(operation.layout)
|
||||
this.ynodes.set(operation.nodeId, ynode)
|
||||
this.ynodes.set(String(operation.nodeId), ynode)
|
||||
|
||||
// Add to spatial index
|
||||
this.spatialIndex.insert(operation.nodeId, operation.layout.bounds)
|
||||
@@ -1127,9 +1108,10 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
operation: DeleteNodeOperation,
|
||||
change: LayoutChange
|
||||
): void {
|
||||
if (!this.ynodes.has(operation.nodeId)) return
|
||||
const nodeKey = String(operation.nodeId)
|
||||
if (!this.ynodes.has(nodeKey)) return
|
||||
|
||||
this.ynodes.delete(operation.nodeId)
|
||||
this.ynodes.delete(nodeKey)
|
||||
// Note: We intentionally do NOT delete nodeRefs and nodeTriggers here.
|
||||
// During undo/redo, Vue components may still hold references to the old ref.
|
||||
// If we delete the trigger, Vue won't be notified when the node is re-created.
|
||||
@@ -1143,7 +1125,8 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
|
||||
// Delete the associated links
|
||||
for (const linkId of linksToDelete) {
|
||||
this.ylinks.delete(String(linkId))
|
||||
const linkKey = String(linkId)
|
||||
this.ylinks.delete(linkKey)
|
||||
this.linkLayouts.delete(linkId)
|
||||
|
||||
// Clean up link segment layouts
|
||||
@@ -1162,7 +1145,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
|
||||
for (const nodeId of operation.nodeIds) {
|
||||
const data = operation.bounds[nodeId]
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
const ynode = this.ynodes.get(String(nodeId))
|
||||
if (!ynode || !data) continue
|
||||
|
||||
ynode.set('position', { x: data.bounds.x, y: data.bounds.y })
|
||||
@@ -1197,7 +1180,8 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
linkData.set('targetNodeId', operation.targetNodeId)
|
||||
linkData.set('targetSlot', operation.targetSlot)
|
||||
|
||||
this.ylinks.set(String(operation.linkId), linkData)
|
||||
const linkKey = String(operation.linkId)
|
||||
this.ylinks.set(linkKey, linkData)
|
||||
|
||||
// Link geometry will be computed separately when nodes move
|
||||
// This just tracks that the link exists
|
||||
@@ -1208,9 +1192,10 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
operation: DeleteLinkOperation,
|
||||
change: LayoutChange
|
||||
): void {
|
||||
if (!this.ylinks.has(String(operation.linkId))) return
|
||||
const linkKey = String(operation.linkId)
|
||||
if (!this.ylinks.has(linkKey)) return
|
||||
|
||||
this.ylinks.delete(String(operation.linkId))
|
||||
this.ylinks.delete(linkKey)
|
||||
this.linkLayouts.delete(operation.linkId)
|
||||
// Clean up any segment layouts for this link
|
||||
this.cleanupLinkSegments(operation.linkId)
|
||||
@@ -1228,7 +1213,8 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
rerouteData.set('parentId', operation.parentId)
|
||||
rerouteData.set('linkIds', operation.linkIds)
|
||||
|
||||
this.yreroutes.set(String(operation.rerouteId), rerouteData) // Yjs Map keys must be strings
|
||||
const rerouteKey = String(operation.rerouteId)
|
||||
this.yreroutes.set(rerouteKey, rerouteData)
|
||||
|
||||
// The observer will automatically update the spatial index
|
||||
change.type = 'create'
|
||||
@@ -1238,11 +1224,12 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
operation: DeleteRerouteOperation,
|
||||
change: LayoutChange
|
||||
): void {
|
||||
if (!this.yreroutes.has(String(operation.rerouteId))) return // Yjs Map keys are strings
|
||||
const rerouteKey = String(operation.rerouteId)
|
||||
if (!this.yreroutes.has(rerouteKey)) return
|
||||
|
||||
this.yreroutes.delete(String(operation.rerouteId)) // Yjs Map keys are strings
|
||||
this.yreroutes.delete(rerouteKey)
|
||||
this.rerouteLayouts.delete(operation.rerouteId) // Layout map uses numeric ID
|
||||
this.rerouteSpatialIndex.remove(String(operation.rerouteId)) // Spatial index uses strings
|
||||
this.rerouteSpatialIndex.remove(rerouteKey)
|
||||
|
||||
change.type = 'delete'
|
||||
}
|
||||
@@ -1251,7 +1238,8 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
operation: MoveRerouteOperation,
|
||||
change: LayoutChange
|
||||
): void {
|
||||
const yreroute = this.yreroutes.get(String(operation.rerouteId)) // Yjs Map keys are strings
|
||||
const rerouteKey = String(operation.rerouteId)
|
||||
const yreroute = this.yreroutes.get(rerouteKey)
|
||||
if (!yreroute) return
|
||||
|
||||
yreroute.set('position', operation.position)
|
||||
@@ -1331,9 +1319,10 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
* Clean up all segment layouts for a link
|
||||
*/
|
||||
private cleanupLinkSegments(linkId: LinkId): void {
|
||||
const linkPrefix = `${linkId}:`
|
||||
const keysToDelete: string[] = []
|
||||
for (const [key] of this.linkSegmentLayouts) {
|
||||
if (key.startsWith(`${linkId}:`)) {
|
||||
if (key.startsWith(linkPrefix)) {
|
||||
keysToDelete.push(key)
|
||||
}
|
||||
}
|
||||
@@ -1364,15 +1353,17 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
* Handle reroute deletion
|
||||
*/
|
||||
private handleRerouteDelete(rerouteId: RerouteId): void {
|
||||
const rerouteKey = String(rerouteId)
|
||||
this.rerouteLayouts.delete(rerouteId)
|
||||
this.rerouteSpatialIndex.remove(String(rerouteId))
|
||||
this.rerouteSpatialIndex.remove(rerouteKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle reroute upsert (update if exists, create if not)
|
||||
*/
|
||||
private handleRerouteUpsert(rerouteId: RerouteId): void {
|
||||
const rerouteData = this.yreroutes.get(String(rerouteId))
|
||||
const rerouteKey = String(rerouteId)
|
||||
const rerouteData = this.yreroutes.get(rerouteKey)
|
||||
if (!rerouteData) return
|
||||
|
||||
const position = this.getRerouteField(rerouteData, 'position')
|
||||
@@ -1509,7 +1500,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
const boundsRecord: BatchUpdateBoundsOperation['bounds'] = {}
|
||||
|
||||
for (const { nodeId, bounds } of updates) {
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
const ynode = this.ynodes.get(String(nodeId))
|
||||
if (!ynode) continue
|
||||
const currentLayout = yNodeToLayout(ynode)
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ export function useLayoutSync() {
|
||||
if (change.nodeIds.length === 0) return
|
||||
|
||||
for (const nodeId of change.nodeIds) {
|
||||
pendingNodeIds.add(nodeId)
|
||||
pendingNodeIds.add(String(nodeId))
|
||||
}
|
||||
scheduleFlush(change.source, canvas)
|
||||
})
|
||||
|
||||
@@ -117,34 +117,6 @@ describe('useTransformState', () => {
|
||||
transformState.syncWithCanvas(mockCanvas as LGraphCanvas)
|
||||
})
|
||||
|
||||
describe('canvasToScreen', () => {
|
||||
it('should convert canvas coordinates to screen coordinates', () => {
|
||||
const { canvasToScreen } = transformState
|
||||
|
||||
const canvasPoint = { x: 10, y: 20 }
|
||||
const screenPoint = canvasToScreen(canvasPoint)
|
||||
|
||||
// screen = (canvas + offset) * scale
|
||||
// x: (10 + 100) * 2 = 220
|
||||
// y: (20 + 50) * 2 = 140
|
||||
expect(screenPoint).toEqual({ x: 220, y: 140 })
|
||||
})
|
||||
|
||||
it('should handle zero coordinates', () => {
|
||||
const { canvasToScreen } = transformState
|
||||
|
||||
const screenPoint = canvasToScreen({ x: 0, y: 0 })
|
||||
expect(screenPoint).toEqual({ x: 200, y: 100 })
|
||||
})
|
||||
|
||||
it('should handle negative coordinates', () => {
|
||||
const { canvasToScreen } = transformState
|
||||
|
||||
const screenPoint = canvasToScreen({ x: -10, y: -20 })
|
||||
expect(screenPoint).toEqual({ x: 180, y: 60 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('screenToCanvas', () => {
|
||||
it('should convert screen coordinates to canvas coordinates', () => {
|
||||
const { screenToCanvas } = transformState
|
||||
@@ -157,186 +129,10 @@ describe('useTransformState', () => {
|
||||
// y: 140 / 2 - 50 = 20
|
||||
expect(canvasPoint).toEqual({ x: 10, y: 20 })
|
||||
})
|
||||
|
||||
it('should be inverse of canvasToScreen', () => {
|
||||
const { canvasToScreen, screenToCanvas } = transformState
|
||||
|
||||
const originalPoint = { x: 25, y: 35 }
|
||||
const screenPoint = canvasToScreen(originalPoint)
|
||||
const backToCanvas = screenToCanvas(screenPoint)
|
||||
|
||||
expect(backToCanvas.x).toBeCloseTo(originalPoint.x)
|
||||
expect(backToCanvas.y).toBeCloseTo(originalPoint.y)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodeScreenBounds', () => {
|
||||
beforeEach(() => {
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
mockCanvas.ds.offset = [100, 50]
|
||||
mockCanvas.ds.scale = 2
|
||||
transformState.syncWithCanvas(mockCanvas as LGraphCanvas)
|
||||
})
|
||||
|
||||
it('should calculate correct screen bounds for a node', () => {
|
||||
const { getNodeScreenBounds } = transformState
|
||||
|
||||
const nodePos: [number, number] = [10, 20]
|
||||
const nodeSize: [number, number] = [200, 100]
|
||||
const bounds = getNodeScreenBounds(nodePos, nodeSize)
|
||||
|
||||
// Top-left: canvasToScreen(10, 20) = (220, 140)
|
||||
// Width: 200 * 2 = 400
|
||||
// Height: 100 * 2 = 200
|
||||
expect(bounds.x).toBe(220)
|
||||
expect(bounds.y).toBe(140)
|
||||
expect(bounds.width).toBe(400)
|
||||
expect(bounds.height).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isNodeInViewport', () => {
|
||||
beforeEach(() => {
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
mockCanvas.ds.offset = [0, 0]
|
||||
mockCanvas.ds.scale = 1
|
||||
transformState.syncWithCanvas(mockCanvas as LGraphCanvas)
|
||||
})
|
||||
|
||||
const viewport = { width: 1000, height: 600 }
|
||||
|
||||
it('should return true for nodes inside viewport', () => {
|
||||
const { isNodeInViewport } = transformState
|
||||
|
||||
const nodePos: [number, number] = [100, 100]
|
||||
const nodeSize: [number, number] = [200, 100]
|
||||
|
||||
expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for nodes completely outside viewport', () => {
|
||||
const { isNodeInViewport } = transformState
|
||||
|
||||
// Node far to the right
|
||||
expect(isNodeInViewport([2000, 100], [200, 100], viewport)).toBe(false)
|
||||
|
||||
// Node far to the left
|
||||
expect(isNodeInViewport([-500, 100], [200, 100], viewport)).toBe(false)
|
||||
|
||||
// Node far below
|
||||
expect(isNodeInViewport([100, 1000], [200, 100], viewport)).toBe(false)
|
||||
|
||||
// Node far above
|
||||
expect(isNodeInViewport([100, -500], [200, 100], viewport)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true for nodes partially in viewport with margin', () => {
|
||||
const { isNodeInViewport } = transformState
|
||||
|
||||
// Node slightly outside but within margin
|
||||
const nodePos: [number, number] = [-50, -50]
|
||||
const nodeSize: [number, number] = [100, 100]
|
||||
|
||||
expect(isNodeInViewport(nodePos, nodeSize, viewport, 0.2)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for tiny nodes (size culling)', () => {
|
||||
const { isNodeInViewport } = transformState
|
||||
|
||||
// Node is in viewport but too small
|
||||
const nodePos: [number, number] = [100, 100]
|
||||
const nodeSize: [number, number] = [3, 3] // Less than 4 pixels
|
||||
|
||||
expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(false)
|
||||
})
|
||||
|
||||
it('should adjust margin based on zoom level', () => {
|
||||
const { isNodeInViewport, syncWithCanvas } = transformState
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
|
||||
// Test with very low zoom
|
||||
mockCanvas.ds.scale = 0.05
|
||||
syncWithCanvas(mockCanvas as LGraphCanvas)
|
||||
|
||||
// Node at edge should still be visible due to increased margin
|
||||
expect(isNodeInViewport([1100, 100], [200, 100], viewport)).toBe(true)
|
||||
|
||||
// Test with high zoom
|
||||
mockCanvas.ds.scale = 4
|
||||
syncWithCanvas(mockCanvas as LGraphCanvas)
|
||||
|
||||
// Margin should be tighter
|
||||
expect(isNodeInViewport([1100, 100], [200, 100], viewport)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getViewportBounds', () => {
|
||||
beforeEach(() => {
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
mockCanvas.ds.offset = [100, 50]
|
||||
mockCanvas.ds.scale = 2
|
||||
transformState.syncWithCanvas(mockCanvas as LGraphCanvas)
|
||||
})
|
||||
|
||||
it('should calculate viewport bounds in canvas coordinates', () => {
|
||||
const { getViewportBounds } = transformState
|
||||
const viewport = { width: 1000, height: 600 }
|
||||
|
||||
const bounds = getViewportBounds(viewport, 0.2)
|
||||
|
||||
// With 20% margin:
|
||||
// marginX = 1000 * 0.2 = 200
|
||||
// marginY = 600 * 0.2 = 120
|
||||
// topLeft in screen: (-200, -120)
|
||||
// bottomRight in screen: (1200, 720)
|
||||
|
||||
// Convert to canvas coordinates (canvas = screen / scale - offset):
|
||||
// topLeft: (-200 / 2 - 100, -120 / 2 - 50) = (-200, -110)
|
||||
// bottomRight: (1200 / 2 - 100, 720 / 2 - 50) = (500, 310)
|
||||
|
||||
expect(bounds.x).toBe(-200)
|
||||
expect(bounds.y).toBe(-110)
|
||||
expect(bounds.width).toBe(700) // 500 - (-200)
|
||||
expect(bounds.height).toBe(420) // 310 - (-110)
|
||||
})
|
||||
|
||||
it('should handle zero margin', () => {
|
||||
const { getViewportBounds } = transformState
|
||||
const viewport = { width: 1000, height: 600 }
|
||||
|
||||
const bounds = getViewportBounds(viewport, 0)
|
||||
|
||||
// No margin, so viewport bounds are exact
|
||||
expect(bounds.x).toBe(-100) // 0 / 2 - 100
|
||||
expect(bounds.y).toBe(-50) // 0 / 2 - 50
|
||||
expect(bounds.width).toBe(500) // 1000 / 2
|
||||
expect(bounds.height).toBe(300) // 600 / 2
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle extreme zoom levels', () => {
|
||||
const { syncWithCanvas, canvasToScreen } = transformState
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
|
||||
// Very small zoom
|
||||
mockCanvas.ds.scale = 0.001
|
||||
syncWithCanvas(mockCanvas as LGraphCanvas)
|
||||
|
||||
const point1 = canvasToScreen({ x: 1000, y: 1000 })
|
||||
expect(point1.x).toBeCloseTo(1)
|
||||
expect(point1.y).toBeCloseTo(1)
|
||||
|
||||
// Very large zoom
|
||||
mockCanvas.ds.scale = 100
|
||||
syncWithCanvas(mockCanvas as LGraphCanvas)
|
||||
|
||||
const point2 = canvasToScreen({ x: 1, y: 1 })
|
||||
expect(point2.x).toBe(100)
|
||||
expect(point2.y).toBe(100)
|
||||
})
|
||||
|
||||
it('should handle zero scale in screenToCanvas', () => {
|
||||
const { syncWithCanvas, screenToCanvas } = transformState
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
|
||||
@@ -104,24 +104,6 @@ function useTransformStateIndividual() {
|
||||
camera.z = canvas.ds.scale || 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts canvas coordinates to screen coordinates
|
||||
*
|
||||
* Applies the same transform that LiteGraph uses for rendering.
|
||||
* Essential for positioning Vue components to align with canvas elements.
|
||||
*
|
||||
* Formula: screen = (canvas + offset) * scale
|
||||
*
|
||||
* @param point - Point in canvas coordinate system
|
||||
* @returns Point in screen coordinate system
|
||||
*/
|
||||
function canvasToScreen(point: Point): Point {
|
||||
return {
|
||||
x: (point.x + camera.x) * camera.z,
|
||||
y: (point.y + camera.y) * camera.z
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts screen coordinates to canvas coordinates
|
||||
*
|
||||
@@ -140,111 +122,11 @@ function useTransformStateIndividual() {
|
||||
}
|
||||
}
|
||||
|
||||
// Get node's screen bounds for culling
|
||||
function getNodeScreenBounds(
|
||||
pos: [number, number],
|
||||
size: [number, number]
|
||||
): DOMRect {
|
||||
const topLeft = canvasToScreen({ x: pos[0], y: pos[1] })
|
||||
const width = size[0] * camera.z
|
||||
const height = size[1] * camera.z
|
||||
|
||||
return new DOMRect(topLeft.x, topLeft.y, width, height)
|
||||
}
|
||||
|
||||
// Helper: Calculate zoom-adjusted margin for viewport culling
|
||||
function calculateAdjustedMargin(baseMargin: number): number {
|
||||
if (camera.z < 0.1) return Math.min(baseMargin * 5, 2.0)
|
||||
if (camera.z > 3.0) return Math.max(baseMargin * 0.5, 0.05)
|
||||
return baseMargin
|
||||
}
|
||||
|
||||
// Helper: Check if node is too small to be visible at current zoom
|
||||
function isNodeTooSmall(nodeSize: [number, number]): boolean {
|
||||
const nodeScreenSize = Math.max(nodeSize[0], nodeSize[1]) * camera.z
|
||||
return nodeScreenSize < 4
|
||||
}
|
||||
|
||||
// Helper: Calculate expanded viewport bounds with margin
|
||||
function getExpandedViewportBounds(
|
||||
viewport: { width: number; height: number },
|
||||
margin: number
|
||||
) {
|
||||
const marginX = viewport.width * margin
|
||||
const marginY = viewport.height * margin
|
||||
return {
|
||||
left: -marginX,
|
||||
right: viewport.width + marginX,
|
||||
top: -marginY,
|
||||
bottom: viewport.height + marginY
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Test if node intersects with viewport bounds
|
||||
function testViewportIntersection(
|
||||
screenPos: { x: number; y: number },
|
||||
nodeSize: [number, number],
|
||||
bounds: { left: number; right: number; top: number; bottom: number }
|
||||
): boolean {
|
||||
const nodeRight = screenPos.x + nodeSize[0] * camera.z
|
||||
const nodeBottom = screenPos.y + nodeSize[1] * camera.z
|
||||
|
||||
return !(
|
||||
nodeRight < bounds.left ||
|
||||
screenPos.x > bounds.right ||
|
||||
nodeBottom < bounds.top ||
|
||||
screenPos.y > bounds.bottom
|
||||
)
|
||||
}
|
||||
|
||||
// Check if node is within viewport with frustum and size-based culling
|
||||
function isNodeInViewport(
|
||||
nodePos: [number, number],
|
||||
nodeSize: [number, number],
|
||||
viewport: { width: number; height: number },
|
||||
margin: number = 0.2
|
||||
): boolean {
|
||||
// Early exit for tiny nodes
|
||||
if (isNodeTooSmall(nodeSize)) return false
|
||||
|
||||
const screenPos = canvasToScreen({ x: nodePos[0], y: nodePos[1] })
|
||||
const adjustedMargin = calculateAdjustedMargin(margin)
|
||||
const bounds = getExpandedViewportBounds(viewport, adjustedMargin)
|
||||
|
||||
return testViewportIntersection(screenPos, nodeSize, bounds)
|
||||
}
|
||||
|
||||
// Get viewport bounds in canvas coordinates (for spatial index queries)
|
||||
function getViewportBounds(
|
||||
viewport: { width: number; height: number },
|
||||
margin: number = 0.2
|
||||
) {
|
||||
const marginX = viewport.width * margin
|
||||
const marginY = viewport.height * margin
|
||||
|
||||
const topLeft = screenToCanvas({ x: -marginX, y: -marginY })
|
||||
const bottomRight = screenToCanvas({
|
||||
x: viewport.width + marginX,
|
||||
y: viewport.height + marginY
|
||||
})
|
||||
|
||||
return {
|
||||
x: topLeft.x,
|
||||
y: topLeft.y,
|
||||
width: bottomRight.x - topLeft.x,
|
||||
height: bottomRight.y - topLeft.y
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
camera: readonly(camera),
|
||||
transformStyle,
|
||||
syncWithCanvas,
|
||||
canvasToScreen,
|
||||
screenToCanvas,
|
||||
getNodeScreenBounds,
|
||||
isNodeInViewport,
|
||||
getViewportBounds
|
||||
screenToCanvas
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
*/
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import type { NodeId as WorkflowNodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
// Enum for layout source types
|
||||
export enum LayoutSource {
|
||||
Canvas = 'canvas',
|
||||
@@ -37,7 +39,7 @@ export interface NodeBoundsUpdate {
|
||||
bounds: Bounds
|
||||
}
|
||||
|
||||
export type NodeId = string
|
||||
export type NodeId = WorkflowNodeId
|
||||
export type LinkId = number
|
||||
export type RerouteId = number
|
||||
|
||||
|
||||
@@ -15,21 +15,21 @@ import { QuadTree } from './QuadTree'
|
||||
/**
|
||||
* Cache entry for spatial queries
|
||||
*/
|
||||
interface CacheEntry {
|
||||
result: NodeId[]
|
||||
interface CacheEntry<TId> {
|
||||
result: TId[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Spatial index manager using QuadTree
|
||||
*/
|
||||
export class SpatialIndexManager {
|
||||
private quadTree: QuadTree<NodeId>
|
||||
private queryCache: Map<string, CacheEntry>
|
||||
export class SpatialIndexManager<TId extends string | number = NodeId> {
|
||||
private quadTree: QuadTree<TId>
|
||||
private queryCache: Map<string, CacheEntry<TId>>
|
||||
private cacheSize = 0
|
||||
|
||||
constructor(bounds?: Bounds) {
|
||||
this.quadTree = new QuadTree<NodeId>(
|
||||
this.quadTree = new QuadTree<TId>(
|
||||
bounds ?? QUADTREE_CONFIG.DEFAULT_BOUNDS,
|
||||
{
|
||||
maxDepth: QUADTREE_CONFIG.MAX_DEPTH,
|
||||
@@ -42,16 +42,16 @@ export class SpatialIndexManager {
|
||||
/**
|
||||
* Insert a node into the spatial index
|
||||
*/
|
||||
insert(nodeId: NodeId, bounds: Bounds): void {
|
||||
this.quadTree.insert(nodeId, bounds, nodeId)
|
||||
insert(nodeId: TId, bounds: Bounds): void {
|
||||
this.quadTree.insert(String(nodeId), bounds, nodeId)
|
||||
this.invalidateCache()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a node's bounds in the spatial index
|
||||
*/
|
||||
update(nodeId: NodeId, bounds: Bounds): void {
|
||||
this.quadTree.update(nodeId, bounds)
|
||||
update(nodeId: TId, bounds: Bounds): void {
|
||||
this.quadTree.update(String(nodeId), bounds)
|
||||
this.invalidateCache()
|
||||
}
|
||||
|
||||
@@ -59,9 +59,9 @@ export class SpatialIndexManager {
|
||||
* Batch update multiple nodes' bounds in the spatial index
|
||||
* More efficient than calling update() multiple times as it only invalidates cache once
|
||||
*/
|
||||
batchUpdate(updates: Array<{ nodeId: NodeId; bounds: Bounds }>): void {
|
||||
batchUpdate(updates: Array<{ nodeId: TId; bounds: Bounds }>): void {
|
||||
for (const { nodeId, bounds } of updates) {
|
||||
this.quadTree.update(nodeId, bounds)
|
||||
this.quadTree.update(String(nodeId), bounds)
|
||||
}
|
||||
this.invalidateCache()
|
||||
}
|
||||
@@ -69,15 +69,15 @@ export class SpatialIndexManager {
|
||||
/**
|
||||
* Remove a node from the spatial index
|
||||
*/
|
||||
remove(nodeId: NodeId): void {
|
||||
this.quadTree.remove(nodeId)
|
||||
remove(nodeId: TId): void {
|
||||
this.quadTree.remove(String(nodeId))
|
||||
this.invalidateCache()
|
||||
}
|
||||
|
||||
/**
|
||||
* Query nodes within the given bounds
|
||||
*/
|
||||
query(bounds: Bounds): NodeId[] {
|
||||
query(bounds: Bounds): TId[] {
|
||||
const cacheKey = this.getCacheKey(bounds)
|
||||
const cached = this.queryCache.get(cacheKey)
|
||||
|
||||
@@ -137,7 +137,7 @@ export class SpatialIndexManager {
|
||||
/**
|
||||
* Add result to cache with LRU eviction
|
||||
*/
|
||||
private addToCache(key: string, result: NodeId[]): void {
|
||||
private addToCache(key: string, result: TId[]): void {
|
||||
// Evict oldest entries if cache is full
|
||||
if (this.cacheSize >= PERFORMANCE_CONFIG.SPATIAL_CACHE_MAX_SIZE) {
|
||||
const oldestKey = this.findOldestCacheEntry()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
import type { MinimapNodeData } from '../types'
|
||||
import { AbstractMinimapDataSource } from './AbstractMinimapDataSource'
|
||||
@@ -25,7 +26,8 @@ export class LayoutStoreDataSource extends AbstractMinimapDataSource {
|
||||
// Find corresponding LiteGraph node for additional properties
|
||||
const graphNode = this.graph?._nodes?.find((n) => String(n.id) === nodeId)
|
||||
|
||||
const executionState = nodeProgressStates[nodeId]?.state ?? null
|
||||
const executionState =
|
||||
nodeProgressStates[createNodeLocatorId(null, nodeId)]?.state ?? null
|
||||
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
nodeData.mode === LGraphEventMode.ALWAYS &&
|
||||
!nodeData.hasErrors
|
||||
"
|
||||
:id="nodeData.id"
|
||||
:id="nodeId"
|
||||
/>
|
||||
<div
|
||||
v-if="isSelected || executing"
|
||||
@@ -340,11 +340,13 @@ const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeRightClick } =
|
||||
useNodeEventHandlers()
|
||||
const { bringNodeToFront } = useNodeZIndex()
|
||||
|
||||
useVueElementTracking(String(nodeData.id), 'node')
|
||||
const nodeId = computed(() => String(nodeData.id))
|
||||
|
||||
useVueElementTracking(nodeId.value, 'node')
|
||||
|
||||
const { selectedNodeIds, isGhostPlacing } = storeToRefs(useCanvasStore())
|
||||
const isSelected = computed(() => {
|
||||
return selectedNodeIds.value.has(nodeData.id)
|
||||
return selectedNodeIds.value.has(nodeId.value)
|
||||
})
|
||||
|
||||
const nodeLocatorId = computed(() => getLocatorIdFromNodeData(nodeData))
|
||||
@@ -353,7 +355,7 @@ const executionErrorStore = useExecutionErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const missingNodesErrorStore = useMissingNodesErrorStore()
|
||||
const hasExecutionError = computed(
|
||||
() => executionErrorStore.lastExecutionErrorNodeId === nodeData.id
|
||||
() => executionErrorStore.lastExecutionErrorNodeId === nodeId.value
|
||||
)
|
||||
|
||||
const hasAnyError = computed((): boolean => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import type { NodeId as VueNodeId } from '@/renderer/core/layout/types'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { seedRequiredInputMissingNodeError } from '@/utils/__tests__/executionErrorTestUtils'
|
||||
import {
|
||||
createMockNodeInputSlot,
|
||||
@@ -270,7 +271,7 @@ describe('NodeSlots.vue', () => {
|
||||
const { container } = renderSlots(nodeData)
|
||||
seedRequiredInputMissingNodeError(
|
||||
useExecutionErrorStore(),
|
||||
nodeData.id,
|
||||
createNodeExecutionId([nodeData.id]),
|
||||
'model'
|
||||
)
|
||||
await nextTick()
|
||||
@@ -300,7 +301,7 @@ describe('NodeSlots.vue', () => {
|
||||
const { container } = renderSlots(nodeData)
|
||||
seedRequiredInputMissingNodeError(
|
||||
useExecutionErrorStore(),
|
||||
'65:70',
|
||||
createNodeExecutionId([65, 70]),
|
||||
'model'
|
||||
)
|
||||
await nextTick()
|
||||
@@ -337,7 +338,7 @@ describe('NodeSlots.vue', () => {
|
||||
const { container } = renderSlots(nodeData)
|
||||
seedRequiredInputMissingNodeError(
|
||||
useExecutionErrorStore(),
|
||||
'65:70:63',
|
||||
createNodeExecutionId([65, 70, 63]),
|
||||
'mask'
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
const GRAPH_ID = 'graph-test'
|
||||
@@ -64,7 +66,7 @@ describe('NodeWidgets', () => {
|
||||
const createMockNodeData = (
|
||||
nodeType: string = 'TestNode',
|
||||
widgets: SafeWidgetData[] = [],
|
||||
id: string = '1'
|
||||
id: NodeId = 1
|
||||
): VueNodeData => ({
|
||||
id,
|
||||
type: nodeType,
|
||||
@@ -231,13 +233,13 @@ describe('NodeWidgets', () => {
|
||||
nodeId: undefined,
|
||||
name: 'string_a',
|
||||
type: 'text',
|
||||
sourceExecutionId: '65:18'
|
||||
sourceExecutionId: createNodeExecutionId([65, 18])
|
||||
})
|
||||
const secondTransientEntry = createMockWidget({
|
||||
nodeId: undefined,
|
||||
name: 'string_a',
|
||||
type: 'text',
|
||||
sourceExecutionId: '65:19'
|
||||
sourceExecutionId: createNodeExecutionId([65, 19])
|
||||
})
|
||||
const nodeData = createMockNodeData('SubgraphNode', [
|
||||
firstTransientEntry,
|
||||
|
||||
@@ -23,6 +23,10 @@ function useNodeEventHandlersIndividual() {
|
||||
const { bringNodeToFront } = useNodeZIndex()
|
||||
const { shouldHandleNodePointerEvents } = useCanvasInteractions()
|
||||
|
||||
function getNode(nodeId: NodeId) {
|
||||
return nodeManager.value?.getNode(String(nodeId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle node selection events
|
||||
* Supports single selection and multi-select with Ctrl/Cmd
|
||||
@@ -30,9 +34,9 @@ function useNodeEventHandlersIndividual() {
|
||||
function handleNodeSelect(event: PointerEvent, nodeId: NodeId) {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
if (!canvasStore.canvas) return
|
||||
|
||||
const node = nodeManager.value.getNode(nodeId)
|
||||
const node = getNode(nodeId)
|
||||
if (!node) return
|
||||
|
||||
const multiSelect = isMultiSelectKey(event)
|
||||
@@ -67,9 +71,7 @@ function useNodeEventHandlersIndividual() {
|
||||
function handleNodeCollapse(nodeId: NodeId, collapsed: boolean) {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!nodeManager.value) return
|
||||
|
||||
const node = nodeManager.value.getNode(nodeId)
|
||||
const node = getNode(nodeId)
|
||||
if (!node) return
|
||||
|
||||
// Use LiteGraph's collapse method if the state needs to change
|
||||
@@ -86,9 +88,7 @@ function useNodeEventHandlersIndividual() {
|
||||
function handleNodeTitleUpdate(nodeId: NodeId, newTitle: string) {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!nodeManager.value) return
|
||||
|
||||
const node = nodeManager.value.getNode(nodeId)
|
||||
const node = getNode(nodeId)
|
||||
if (!node) return
|
||||
|
||||
// Update the node title in LiteGraph for persistence
|
||||
@@ -107,9 +107,9 @@ function useNodeEventHandlersIndividual() {
|
||||
function handleNodeRightClick(event: PointerEvent, nodeId: NodeId) {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
if (!canvasStore.canvas) return
|
||||
|
||||
const node = nodeManager.value.getNode(nodeId)
|
||||
const node = getNode(nodeId)
|
||||
if (!node) return
|
||||
|
||||
// Prevent default context menu
|
||||
@@ -130,9 +130,9 @@ function useNodeEventHandlersIndividual() {
|
||||
) {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
if (!canvasStore.canvas) return
|
||||
|
||||
const node = nodeManager.value.getNode(nodeId)
|
||||
const node = getNode(nodeId)
|
||||
if (!node) return
|
||||
|
||||
if (!multiSelect) {
|
||||
|
||||
@@ -10,12 +10,13 @@ import { useClickDragGuard } from '@/composables/useClickDragGuard'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { NodeId } from '@/renderer/core/layout/types'
|
||||
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
|
||||
import { isMultiSelectKey } from '@/renderer/extensions/vueNodes/utils/selectionUtils'
|
||||
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
|
||||
|
||||
export function useNodePointerInteractions(
|
||||
nodeIdRef: MaybeRefOrGetter<string>
|
||||
nodeIdRef: MaybeRefOrGetter<NodeId>
|
||||
) {
|
||||
const { startDrag, endDrag, handleDrag } = useNodeDrag()
|
||||
// Use canvas interactions for proper wheel event handling and pointer event capture control
|
||||
@@ -25,6 +26,10 @@ export function useNodePointerInteractions(
|
||||
useNodeEventHandlers()
|
||||
const { nodeManager } = useVueNodeLifecycle()
|
||||
|
||||
function isPinnedNode(nodeId: NodeId): boolean {
|
||||
return nodeManager.value?.getNode(String(nodeId))?.flags?.pinned ?? false
|
||||
}
|
||||
|
||||
const forwardMiddlePointerIfNeeded = (
|
||||
event: PointerEvent,
|
||||
isMiddleInput: (event: PointerEvent) => boolean
|
||||
@@ -59,7 +64,7 @@ export function useNodePointerInteractions(
|
||||
}
|
||||
|
||||
// IMPORTANT: Read from actual LGraphNode to get correct state
|
||||
if (nodeManager.value?.getNode(nodeId)?.flags?.pinned) {
|
||||
if (isPinnedNode(nodeId)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -76,7 +81,7 @@ export function useNodePointerInteractions(
|
||||
|
||||
const nodeId = toValue(nodeIdRef)
|
||||
|
||||
if (nodeManager.value?.getNode(nodeId)?.flags?.pinned) {
|
||||
if (isPinnedNode(nodeId)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -111,7 +116,7 @@ export function useNodePointerInteractions(
|
||||
layoutStore.isDraggingVueNodes.value = false
|
||||
}
|
||||
|
||||
function safeDragStart(event: PointerEvent, nodeId: string) {
|
||||
function safeDragStart(event: PointerEvent, nodeId: NodeId) {
|
||||
try {
|
||||
startDrag(event, nodeId)
|
||||
} finally {
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import {
|
||||
createNodeExecutionId,
|
||||
createNodeLocatorId
|
||||
} from '@/types/nodeIdentification'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
const GRAPH_ID = 'graph-test'
|
||||
@@ -83,7 +87,7 @@ describe('getWidgetIdentity', () => {
|
||||
it('uses sourceExecutionId for identity when no nodeId', () => {
|
||||
const widget = createMockWidget({
|
||||
nodeId: undefined,
|
||||
sourceExecutionId: '65:18'
|
||||
sourceExecutionId: createNodeExecutionId([65, 18])
|
||||
})
|
||||
const { dedupeIdentity } = getWidgetIdentity(widget, '1', 0)
|
||||
expect(dedupeIdentity).toBe('exec:65:18:test_widget:combo')
|
||||
@@ -131,7 +135,7 @@ describe('hasWidgetError', () => {
|
||||
expect(
|
||||
hasWidgetError(
|
||||
widget,
|
||||
'1',
|
||||
createNodeExecutionId([1]),
|
||||
undefined,
|
||||
executionErrorStore,
|
||||
missingModelStore
|
||||
@@ -147,7 +151,7 @@ describe('hasWidgetError', () => {
|
||||
expect(
|
||||
hasWidgetError(
|
||||
widget,
|
||||
'1',
|
||||
createNodeExecutionId([1]),
|
||||
nodeErrors,
|
||||
executionErrorStore,
|
||||
missingModelStore
|
||||
@@ -158,7 +162,7 @@ describe('hasWidgetError', () => {
|
||||
it('returns true via sourceExecutionId when execution store has matching error', () => {
|
||||
const widget = createMockWidget({
|
||||
name: 'seed',
|
||||
sourceExecutionId: '65:18'
|
||||
sourceExecutionId: createNodeExecutionId([65, 18])
|
||||
})
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'65:18': {
|
||||
@@ -177,7 +181,7 @@ describe('hasWidgetError', () => {
|
||||
expect(
|
||||
hasWidgetError(
|
||||
widget,
|
||||
'1',
|
||||
createNodeExecutionId([1]),
|
||||
undefined,
|
||||
executionErrorStore,
|
||||
missingModelStore
|
||||
@@ -191,7 +195,7 @@ describe('hasWidgetError', () => {
|
||||
expect(
|
||||
hasWidgetError(
|
||||
widget,
|
||||
'1',
|
||||
createNodeExecutionId([1]),
|
||||
undefined,
|
||||
executionErrorStore,
|
||||
missingModelStore
|
||||
@@ -210,7 +214,7 @@ describe('hasWidgetError', () => {
|
||||
expect(
|
||||
hasWidgetError(
|
||||
widget,
|
||||
'1',
|
||||
createNodeExecutionId([1]),
|
||||
nodeErrors,
|
||||
executionErrorStore,
|
||||
missingModelStore
|
||||
@@ -221,7 +225,7 @@ describe('hasWidgetError', () => {
|
||||
it('matches missing models by the interior source widget name', () => {
|
||||
const widget = createMockWidget({
|
||||
name: 'display_slot',
|
||||
sourceExecutionId: '65:18',
|
||||
sourceExecutionId: createNodeExecutionId([65, 18]),
|
||||
sourceWidgetName: 'ckpt_name'
|
||||
})
|
||||
const spy = vi
|
||||
@@ -230,7 +234,7 @@ describe('hasWidgetError', () => {
|
||||
expect(
|
||||
hasWidgetError(
|
||||
widget,
|
||||
'1',
|
||||
createNodeExecutionId([1]),
|
||||
undefined,
|
||||
executionErrorStore,
|
||||
missingModelStore
|
||||
@@ -399,6 +403,37 @@ describe('computeProcessedWidgets borderStyle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses widget nodeId for simplified widget locator when present', () => {
|
||||
const widget = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: 'inner-node'
|
||||
})
|
||||
|
||||
const result = computeProcessedWidgets({
|
||||
nodeData: {
|
||||
id: 'host-node',
|
||||
type: 'SubgraphNode',
|
||||
widgets: [widget],
|
||||
title: 'Test',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
subgraphId: 'subgraph-node'
|
||||
},
|
||||
graphId: GRAPH_ID,
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: noopUi
|
||||
})
|
||||
|
||||
expect(result[0].simplified.nodeLocatorId).toBe(
|
||||
createNodeLocatorId('subgraph-node', 'inner-node')
|
||||
)
|
||||
})
|
||||
it('deduplication keeps visible widget over hidden duplicate', () => {
|
||||
const sharedWidgetId = widgetId(GRAPH_ID, '1', 'text')
|
||||
const hiddenWidget = createMockWidget({
|
||||
@@ -480,7 +515,7 @@ describe('computeProcessedWidgets borderStyle', () => {
|
||||
|
||||
describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
|
||||
const GRAPH_ID = 'graph-test'
|
||||
const NODE_ID = '1'
|
||||
const NODE_ID = 1
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -591,7 +626,7 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
|
||||
expect(
|
||||
hasWidgetError(
|
||||
widget,
|
||||
NODE_ID,
|
||||
createNodeExecutionId([NODE_ID]),
|
||||
executionErrorStore.lastNodeErrors[NODE_ID],
|
||||
executionErrorStore,
|
||||
missingModelStore
|
||||
@@ -603,7 +638,7 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
|
||||
expect(
|
||||
hasWidgetError(
|
||||
widget,
|
||||
NODE_ID,
|
||||
createNodeExecutionId([NODE_ID]),
|
||||
executionErrorStore.lastNodeErrors?.[NODE_ID],
|
||||
executionErrorStore,
|
||||
missingModelStore
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import type { WidgetState } from '@/types/widgetState'
|
||||
@@ -81,7 +83,7 @@ interface ComputeProcessedWidgetsOptions {
|
||||
function createWidgetUpdateHandler(
|
||||
widgetState: WidgetState | undefined,
|
||||
widget: SafeWidgetData,
|
||||
nodeExecId: string,
|
||||
nodeExecId: NodeExecutionId,
|
||||
widgetOptions: IWidgetOptions | Record<string, never>,
|
||||
executionErrorStore: ReturnType<typeof useExecutionErrorStore>
|
||||
): (newValue: WidgetValue) => void {
|
||||
@@ -101,7 +103,7 @@ function createWidgetUpdateHandler(
|
||||
|
||||
export function hasWidgetError(
|
||||
widget: SafeWidgetData,
|
||||
nodeExecId: string,
|
||||
nodeExecId: NodeExecutionId,
|
||||
nodeErrors:
|
||||
| { errors: { extra_info?: { input_name?: string } }[] }
|
||||
| undefined,
|
||||
@@ -178,7 +180,7 @@ export function computeProcessedWidgets({
|
||||
const nodeExecId =
|
||||
isGraphReady && rootGraph
|
||||
? getExecutionIdFromNodeData(rootGraph, nodeData)
|
||||
: String(nodeData.id ?? '')
|
||||
: createNodeExecutionId([nodeData.id])
|
||||
|
||||
const nodeErrors = executionErrorStore.lastNodeErrors?.[nodeExecId]
|
||||
|
||||
@@ -288,11 +290,12 @@ export function computeProcessedWidgets({
|
||||
}
|
||||
: undefined
|
||||
|
||||
const nodeLocatorId = widget.nodeId
|
||||
? widget.nodeId
|
||||
: nodeData
|
||||
? getLocatorIdFromNodeData(nodeData)
|
||||
: undefined
|
||||
const nodeLocatorId = nodeData
|
||||
? getLocatorIdFromNodeData({
|
||||
...nodeData,
|
||||
id: widget.nodeId ?? nodeData.id
|
||||
})
|
||||
: undefined
|
||||
|
||||
const simplified: SimplifiedWidget = {
|
||||
name: widgetState?.name ?? widget.name,
|
||||
@@ -320,7 +323,7 @@ export function computeProcessedWidgets({
|
||||
const handleContextMenu = (e: PointerEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (nodeId !== undefined) ui.handleNodeRightClick(e, nodeId)
|
||||
if (nodeId !== undefined) ui.handleNodeRightClick(e, String(nodeId))
|
||||
showNodeOptions(
|
||||
e,
|
||||
widget.name,
|
||||
|
||||
@@ -141,7 +141,7 @@ export function useSlotLinkInteraction({
|
||||
const nodeId = link.node.id
|
||||
if (nodeId != null) {
|
||||
const isInputFrom = link.toType === 'output'
|
||||
const key = getSlotKey(String(nodeId), link.fromSlotIndex, isInputFrom)
|
||||
const key = getSlotKey(nodeId, link.fromSlotIndex, isInputFrom)
|
||||
const layout = layoutStore.getSlotLayout(key)
|
||||
if (layout) return layout.position
|
||||
}
|
||||
@@ -218,7 +218,7 @@ export function useSlotLinkInteraction({
|
||||
): { position: Point; direction: LinkDirection } | null => {
|
||||
if (!link) return null
|
||||
|
||||
const slotKey = getSlotKey(String(link.origin_id), link.origin_slot, false)
|
||||
const slotKey = getSlotKey(link.origin_id, link.origin_slot, false)
|
||||
const layout = layoutStore.getSlotLayout(slotKey)
|
||||
if (!layout) return null
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ function createResizeEntry(options?: {
|
||||
} = options ?? {}
|
||||
|
||||
const element = document.createElement('div')
|
||||
element.dataset.nodeId = nodeId
|
||||
element.dataset.nodeId = String(nodeId)
|
||||
if (collapsed) {
|
||||
element.dataset.collapsed = ''
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ const resizeObserver = new ResizeObserver((entries) => {
|
||||
// slot-layout pipeline and skip bounds processing entirely.
|
||||
const widgetsGridParentNodeId = element.dataset.widgetsGridNodeId
|
||||
if (widgetsGridParentNodeId) {
|
||||
scheduleSlotLayoutSync(widgetsGridParentNodeId as NodeId)
|
||||
scheduleSlotLayoutSync(widgetsGridParentNodeId)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -259,7 +259,7 @@ const resizeObserver = new ResizeObserver((entries) => {
|
||||
// After node bounds are updated, refresh slot cached offsets and layouts
|
||||
if (nodesNeedingSlotResync.size > 0) {
|
||||
for (const nodeId of nodesNeedingSlotResync) {
|
||||
syncNodeSlotLayoutsFromDOM(nodeId)
|
||||
syncNodeSlotLayoutsFromDOM(String(nodeId))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
/**
|
||||
* Composable for managing execution state of Vue-based nodes
|
||||
@@ -14,7 +15,7 @@ import { useExecutionStore } from '@/stores/executionStore'
|
||||
* @returns Object containing reactive execution state and progress
|
||||
*/
|
||||
export const useNodeExecutionState = (
|
||||
nodeLocatorIdMaybe: MaybeRefOrGetter<string | undefined>
|
||||
nodeLocatorIdMaybe: MaybeRefOrGetter<NodeLocatorId | undefined>
|
||||
) => {
|
||||
const locatorId = computed(() => toValue(nodeLocatorIdMaybe) ?? '')
|
||||
const { nodeLocationProgressStates, isIdle } =
|
||||
|
||||
@@ -113,7 +113,8 @@ vi.mock('@/utils/litegraphUtil', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
createSharedComposable: (fn: () => unknown) => fn
|
||||
createSharedComposable: (fn: () => unknown) => fn,
|
||||
whenever: vi.fn()
|
||||
}))
|
||||
|
||||
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createSharedComposable, whenever } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { toValue } from 'vue'
|
||||
|
||||
@@ -16,7 +17,6 @@ import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeS
|
||||
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
import { isLGraphGroup } from '@/utils/litegraphUtil'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
export const useNodeDrag = createSharedComposable(useNodeDragIndividual)
|
||||
|
||||
@@ -65,17 +65,18 @@ function useNodeDragIndividual() {
|
||||
lastPointerY = event.clientY
|
||||
|
||||
const selectedNodes = toValue(selectedNodeIds)
|
||||
const nodeIdKey = String(nodeId)
|
||||
|
||||
// capture the starting positions of all other selected nodes
|
||||
// Only move other selected items if the dragged node is part of the selection
|
||||
const isDraggedNodeInSelection = selectedNodes?.has(nodeId)
|
||||
const isDraggedNodeInSelection = selectedNodes?.has(nodeIdKey)
|
||||
|
||||
if (isDraggedNodeInSelection && selectedNodes.size > 1) {
|
||||
otherSelectedNodesStartPositions = new Map()
|
||||
|
||||
for (const id of selectedNodes) {
|
||||
// Skip the current node being dragged
|
||||
if (id === nodeId) continue
|
||||
if (id === nodeIdKey) continue
|
||||
|
||||
const nodeLayout = layoutStore.getNodeLayoutRef(id).value
|
||||
if (nodeLayout) {
|
||||
@@ -281,27 +282,29 @@ function useNodeDragIndividual() {
|
||||
}
|
||||
}
|
||||
|
||||
resetDragState()
|
||||
}
|
||||
|
||||
function resetDragState() {
|
||||
dragStartPos = null
|
||||
dragStartMouse = null
|
||||
otherSelectedNodesStartPositions = null
|
||||
selectedGroups = null
|
||||
lastCanvasDelta = null
|
||||
|
||||
// Stop auto-pan
|
||||
autoPan?.stop()
|
||||
autoPan = null
|
||||
|
||||
// Stop tracking shift key state
|
||||
stopShiftSync?.()
|
||||
stopShiftSync = null
|
||||
|
||||
// Cancel any pending animation frame
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
}
|
||||
|
||||
whenever(() => !layoutStore.isDraggingVueNodes.value, resetDragState)
|
||||
return {
|
||||
startDrag,
|
||||
handleDrag,
|
||||
|
||||
@@ -4,13 +4,13 @@ import type { MaybeRefOrGetter } from 'vue'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { Point } from '@/renderer/core/layout/types'
|
||||
import type { NodeId, Point } from '@/renderer/core/layout/types'
|
||||
|
||||
/**
|
||||
* Composable for individual Vue node components
|
||||
* Uses customRef for shared write access with Canvas renderer
|
||||
*/
|
||||
export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<NodeId>) {
|
||||
const nodeId = toValue(nodeIdMaybe)
|
||||
const mutations = useLayoutMutations()
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@ import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue'
|
||||
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { NodeId } from '@/renderer/core/layout/types'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
|
||||
export const useNodePreviewState = (
|
||||
nodeIdMaybe: MaybeRefOrGetter<string>,
|
||||
nodeIdMaybe: MaybeRefOrGetter<NodeId>,
|
||||
options?: {
|
||||
isCollapsed?: Ref<boolean>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="col-span-2 flex justify-start">
|
||||
<Button
|
||||
class="border-0 bg-component-node-widget-background px-2 py-1 text-base-foreground"
|
||||
:disabled="widget.options?.disabled"
|
||||
size="sm"
|
||||
variant="textonly"
|
||||
@click="handleClick"
|
||||
>
|
||||
<span
|
||||
class="mr-1 icon-[material-symbols--add] size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('dynamicGroup.addRow') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const { widget } = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function handleClick() {
|
||||
widget.callback?.(widget.value)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div
|
||||
class="border-node-slot-background col-span-2 flex items-center justify-between border-t pt-1"
|
||||
>
|
||||
<span class="text-xs font-medium text-base-foreground/70">
|
||||
{{ rowLabel }}
|
||||
</span>
|
||||
<button
|
||||
v-if="widget.options?.removable"
|
||||
class="hover:text-danger rounded-sm p-0.5 text-base-foreground/50 transition-colors"
|
||||
:aria-label="t('dynamicGroup.removeRow')"
|
||||
@click="handleRemove"
|
||||
>
|
||||
<span
|
||||
class="icon-[material-symbols--close] size-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const { widget } = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const rowLabel = computed(() => {
|
||||
const match = /__row__(\d+)$/.exec(widget.name)
|
||||
const index = match ? Number(match[1]) : 0
|
||||
return t('dynamicGroup.row', { index: index + 1 })
|
||||
})
|
||||
|
||||
function handleRemove() {
|
||||
widget.callback?.(widget.value)
|
||||
}
|
||||
</script>
|
||||
@@ -75,6 +75,14 @@ const WidgetBoundingBoxes = defineAsyncComponent(
|
||||
const WidgetColors = defineAsyncComponent(
|
||||
() => import('@/components/palette/WidgetColors.vue')
|
||||
)
|
||||
const WidgetDynamicGroupAdd = defineAsyncComponent(
|
||||
() =>
|
||||
import('@/renderer/extensions/vueNodes/widgets/components/WidgetDynamicGroupAdd.vue')
|
||||
)
|
||||
const WidgetDynamicGroupRow = defineAsyncComponent(
|
||||
() =>
|
||||
import('@/renderer/extensions/vueNodes/widgets/components/WidgetDynamicGroupRow.vue')
|
||||
)
|
||||
|
||||
export const FOR_TESTING = {
|
||||
WidgetButton,
|
||||
@@ -241,6 +249,22 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
|
||||
aliases: ['COLORS'],
|
||||
essential: false
|
||||
}
|
||||
],
|
||||
[
|
||||
'dynamic_group_add',
|
||||
{
|
||||
component: WidgetDynamicGroupAdd,
|
||||
aliases: ['COMFY_DYNAMICGROUP_V3'],
|
||||
essential: false
|
||||
}
|
||||
],
|
||||
[
|
||||
'dynamic_group_row',
|
||||
{
|
||||
component: WidgetDynamicGroupRow,
|
||||
aliases: [],
|
||||
essential: false
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
@@ -77,6 +77,10 @@ import { useExtensionStore } from '@/stores/extensionStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
||||
import {
|
||||
getAncestorExecutionIds,
|
||||
tryNormalizeNodeExecutionId
|
||||
} from '@/types/nodeIdentification'
|
||||
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
@@ -772,7 +776,10 @@ export class ComfyApp {
|
||||
|
||||
api.addEventListener('executed', ({ detail }) => {
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const executionId = String(detail.display_node || detail.node)
|
||||
const executionId = tryNormalizeNodeExecutionId(
|
||||
detail.display_node || detail.node
|
||||
)
|
||||
if (!executionId) return
|
||||
|
||||
nodeOutputStore.setNodeOutputsByExecutionId(executionId, detail.output, {
|
||||
merge: detail.merge
|
||||
@@ -810,16 +817,17 @@ export class ComfyApp {
|
||||
const { blob, displayNodeId, jobId } = detail
|
||||
const { setNodePreviewsByExecutionId, revokePreviewsByExecutionId } =
|
||||
useNodeOutputStore()
|
||||
const displayNodeExecutionId = tryNormalizeNodeExecutionId(displayNodeId)
|
||||
if (!displayNodeExecutionId) return
|
||||
const blobUrl = createSharedObjectUrl(blob)
|
||||
useJobPreviewStore().setPreviewUrl(jobId, blobUrl, displayNodeId)
|
||||
// Ensure clean up if `executing` event is missed.
|
||||
revokePreviewsByExecutionId(displayNodeId)
|
||||
revokePreviewsByExecutionId(displayNodeExecutionId)
|
||||
// Preview cleanup is handled in progress_state event to support multiple concurrent previews
|
||||
const nodeParents = displayNodeId.split(':')
|
||||
for (let i = 1; i <= nodeParents.length; i++) {
|
||||
setNodePreviewsByExecutionId(nodeParents.slice(0, i).join(':'), [
|
||||
blobUrl
|
||||
])
|
||||
for (const executionId of getAncestorExecutionIds(
|
||||
displayNodeExecutionId
|
||||
)) {
|
||||
setNodePreviewsByExecutionId(executionId, [blobUrl])
|
||||
}
|
||||
releaseSharedObjectUrl(blobUrl)
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/i18n', () => ({
|
||||
@@ -47,7 +48,7 @@ describe('executionErrorStore — node error operations', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastNodeErrors = null
|
||||
// Should not error
|
||||
store.clearSimpleNodeErrors('123', 'widgetName')
|
||||
store.clearSimpleNodeErrors(createNodeExecutionId([123]), 'widgetName')
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
})
|
||||
|
||||
@@ -68,7 +69,7 @@ describe('executionErrorStore — node error operations', () => {
|
||||
}
|
||||
}
|
||||
|
||||
store.clearSimpleNodeErrors('123', 'testSlot')
|
||||
store.clearSimpleNodeErrors(createNodeExecutionId([123]), 'testSlot')
|
||||
|
||||
// Should be entirely removed (empty object becomes null)
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
@@ -97,7 +98,7 @@ describe('executionErrorStore — node error operations', () => {
|
||||
}
|
||||
}
|
||||
|
||||
store.clearSimpleNodeErrors('123', 'testSlot')
|
||||
store.clearSimpleNodeErrors(createNodeExecutionId([123]), 'testSlot')
|
||||
|
||||
// otherSlot error should still exist
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
@@ -124,7 +125,7 @@ describe('executionErrorStore — node error operations', () => {
|
||||
}
|
||||
}
|
||||
|
||||
store.clearSimpleNodeErrors('999', 'testSlot')
|
||||
store.clearSimpleNodeErrors(createNodeExecutionId([999]), 'testSlot')
|
||||
|
||||
// Original error should remain untouched
|
||||
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
|
||||
@@ -153,7 +154,7 @@ describe('executionErrorStore — node error operations', () => {
|
||||
}
|
||||
}
|
||||
|
||||
store.clearSimpleNodeErrors('123', 'testSlot')
|
||||
store.clearSimpleNodeErrors(createNodeExecutionId([123]), 'testSlot')
|
||||
|
||||
// Mixed simple+complex: not all are simple, so none are cleared
|
||||
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(2)
|
||||
@@ -188,7 +189,7 @@ describe('executionErrorStore — node error operations', () => {
|
||||
}
|
||||
}
|
||||
|
||||
store.clearSimpleNodeErrors('123', 'steps')
|
||||
store.clearSimpleNodeErrors(createNodeExecutionId([123]), 'steps')
|
||||
|
||||
// Node 123 cleared, node 456 remains
|
||||
expect(store.lastNodeErrors?.['123']).toBeUndefined()
|
||||
@@ -218,7 +219,7 @@ describe('executionErrorStore — node error operations', () => {
|
||||
}
|
||||
}
|
||||
|
||||
store.clearSimpleNodeErrors('123')
|
||||
store.clearSimpleNodeErrors(createNodeExecutionId([123]))
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
})
|
||||
@@ -246,7 +247,7 @@ describe('executionErrorStore — node error operations', () => {
|
||||
}
|
||||
}
|
||||
|
||||
store.clearSimpleNodeErrors('123')
|
||||
store.clearSimpleNodeErrors(createNodeExecutionId([123]))
|
||||
|
||||
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(2)
|
||||
})
|
||||
@@ -268,7 +269,7 @@ describe('executionErrorStore — node error operations', () => {
|
||||
}
|
||||
}
|
||||
|
||||
store.clearSimpleNodeErrors('123', 'testSlot')
|
||||
store.clearSimpleNodeErrors(createNodeExecutionId([123]), 'testSlot')
|
||||
|
||||
// Error should remain
|
||||
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
|
||||
@@ -294,9 +295,15 @@ describe('executionErrorStore — node error operations', () => {
|
||||
}
|
||||
|
||||
// Valid value (5 < 10)
|
||||
store.clearWidgetRelatedErrors('123', 'testWidget', 'testWidget', 5, {
|
||||
max: 10
|
||||
})
|
||||
store.clearWidgetRelatedErrors(
|
||||
createNodeExecutionId([123]),
|
||||
'testWidget',
|
||||
'testWidget',
|
||||
5,
|
||||
{
|
||||
max: 10
|
||||
}
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
})
|
||||
@@ -318,7 +325,12 @@ describe('executionErrorStore — node error operations', () => {
|
||||
}
|
||||
}
|
||||
|
||||
store.clearWidgetRelatedErrors('123', 'sampler', 'sampler', 'euler_a')
|
||||
store.clearWidgetRelatedErrors(
|
||||
createNodeExecutionId([123]),
|
||||
'sampler',
|
||||
'sampler',
|
||||
'euler_a'
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
})
|
||||
@@ -341,9 +353,15 @@ describe('executionErrorStore — node error operations', () => {
|
||||
}
|
||||
|
||||
// Invalid value (15 > 10)
|
||||
store.clearWidgetRelatedErrors('123', 'testWidget', 'testWidget', 15, {
|
||||
max: 10
|
||||
})
|
||||
store.clearWidgetRelatedErrors(
|
||||
createNodeExecutionId([123]),
|
||||
'testWidget',
|
||||
'testWidget',
|
||||
15,
|
||||
{
|
||||
max: 10
|
||||
}
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
|
||||
|
||||
@@ -84,7 +84,10 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
* Removes a node's errors if they consist entirely of simple, auto-resolvable
|
||||
* types. When `slotName` is provided, only errors for that slot are checked.
|
||||
*/
|
||||
function clearSimpleNodeErrors(executionId: string, slotName?: string): void {
|
||||
function clearSimpleNodeErrors(
|
||||
executionId: NodeExecutionId,
|
||||
slotName?: string
|
||||
): void {
|
||||
if (!lastNodeErrors.value) return
|
||||
const nodeError = lastNodeErrors.value[executionId]
|
||||
if (!nodeError) return
|
||||
@@ -131,7 +134,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
* (asset system vs objectInfo) making runtime validation non-trivial.
|
||||
*/
|
||||
function clearSlotErrorsWithRangeCheck(
|
||||
executionId: string,
|
||||
executionId: NodeExecutionId,
|
||||
widgetName: string,
|
||||
newValue: unknown,
|
||||
options?: { min?: number; max?: number }
|
||||
@@ -157,7 +160,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
* At the legacy canvas call site both names are identical (`widget.name`).
|
||||
*/
|
||||
function clearWidgetRelatedErrors(
|
||||
executionId: string,
|
||||
executionId: NodeExecutionId,
|
||||
errorInputName: string,
|
||||
widgetName: string,
|
||||
newValue: unknown,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { app } from '@/scripts/app'
|
||||
import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
|
||||
import type * as DistributionTypes from '@/platform/distribution/types'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
@@ -13,9 +14,9 @@ import type * as WorkflowStoreModule from '@/platform/workflow/management/stores
|
||||
import type { NodeProgressState } from '@/schemas/apiSchema'
|
||||
|
||||
const {
|
||||
mockNodeExecutionIdToNodeLocatorId,
|
||||
mockNodeIdToNodeLocatorId,
|
||||
mockNodeLocatorIdToNodeExecutionId,
|
||||
mockExecutionIdToCurrentId,
|
||||
mockActiveWorkflow,
|
||||
mockOpenWorkflows,
|
||||
mockShowTextPreview,
|
||||
@@ -25,9 +26,9 @@ const {
|
||||
} = await vi.hoisted(async () => {
|
||||
const { shallowRef } = await import('vue')
|
||||
return {
|
||||
mockNodeExecutionIdToNodeLocatorId: vi.fn(),
|
||||
mockNodeIdToNodeLocatorId: vi.fn(),
|
||||
mockNodeLocatorIdToNodeExecutionId: vi.fn(),
|
||||
mockExecutionIdToCurrentId: vi.fn(),
|
||||
mockActiveWorkflow: shallowRef<{ path?: string } | null>(null),
|
||||
mockOpenWorkflows: shallowRef<{ path: string }[]>([]),
|
||||
mockShowTextPreview: vi.fn(),
|
||||
@@ -65,9 +66,9 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
|
||||
return {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore: vi.fn(() => ({
|
||||
nodeExecutionIdToNodeLocatorId: mockNodeExecutionIdToNodeLocatorId,
|
||||
nodeIdToNodeLocatorId: mockNodeIdToNodeLocatorId,
|
||||
nodeLocatorIdToNodeExecutionId: mockNodeLocatorIdToNodeExecutionId,
|
||||
executionIdToCurrentId: mockExecutionIdToCurrentId,
|
||||
get activeWorkflow() {
|
||||
return mockActiveWorkflow.value
|
||||
},
|
||||
@@ -180,9 +181,9 @@ describe('useExecutionStore - NodeLocatorId conversions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset mock implementations
|
||||
mockNodeExecutionIdToNodeLocatorId.mockReset()
|
||||
mockNodeIdToNodeLocatorId.mockReset()
|
||||
mockNodeLocatorIdToNodeExecutionId.mockReset()
|
||||
mockExecutionIdToCurrentId.mockReset()
|
||||
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useExecutionStore()
|
||||
@@ -235,23 +236,24 @@ describe('useExecutionStore - NodeLocatorId conversions', () => {
|
||||
|
||||
describe('nodeLocatorIdToExecutionId', () => {
|
||||
it('should convert NodeLocatorId to execution ID', () => {
|
||||
const locatorId = createNodeLocatorId(
|
||||
'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
456
|
||||
)
|
||||
const mockExecutionId = '123:456'
|
||||
mockNodeLocatorIdToNodeExecutionId.mockReturnValue(mockExecutionId)
|
||||
|
||||
const result = store.nodeLocatorIdToExecutionId(
|
||||
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
|
||||
)
|
||||
const result = store.nodeLocatorIdToExecutionId(locatorId)
|
||||
|
||||
expect(mockNodeLocatorIdToNodeExecutionId).toHaveBeenCalledWith(
|
||||
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
|
||||
)
|
||||
expect(mockNodeLocatorIdToNodeExecutionId).toHaveBeenCalledWith(locatorId)
|
||||
expect(result).toBe(mockExecutionId)
|
||||
})
|
||||
|
||||
it('should return null when conversion fails', () => {
|
||||
const locatorId = createNodeLocatorId('unknown-subgraph-id', 456)
|
||||
mockNodeLocatorIdToNodeExecutionId.mockReturnValue(null)
|
||||
|
||||
const result = store.nodeLocatorIdToExecutionId('invalid:format')
|
||||
const result = store.nodeLocatorIdToExecutionId(locatorId)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
@@ -263,9 +265,9 @@ describe('useExecutionStore - nodeLocationProgressStates caching', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockNodeExecutionIdToNodeLocatorId.mockReset()
|
||||
mockNodeIdToNodeLocatorId.mockReset()
|
||||
mockNodeLocatorIdToNodeExecutionId.mockReset()
|
||||
mockExecutionIdToCurrentId.mockReset()
|
||||
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useExecutionStore()
|
||||
@@ -296,8 +298,10 @@ describe('useExecutionStore - nodeLocationProgressStates caching', () => {
|
||||
|
||||
const result = store.nodeLocationProgressStates
|
||||
|
||||
expect(result['123']).toBeDefined()
|
||||
expect(result['a1b2c3d4-e5f6-7890-abcd-ef1234567890:456']).toBeDefined()
|
||||
expect(result[createNodeLocatorId(null, 123)]).toBeDefined()
|
||||
expect(
|
||||
result[createNodeLocatorId('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 456)]
|
||||
).toBeDefined()
|
||||
})
|
||||
|
||||
it('should not re-traverse graph for same execution IDs across progress updates', () => {
|
||||
@@ -324,7 +328,9 @@ describe('useExecutionStore - nodeLocationProgressStates caching', () => {
|
||||
}
|
||||
|
||||
// First evaluation triggers graph traversal
|
||||
expect(store.nodeLocationProgressStates['123']).toBeDefined()
|
||||
expect(
|
||||
store.nodeLocationProgressStates[createNodeLocatorId(null, 123)]
|
||||
).toBeDefined()
|
||||
const callCountAfterFirst = vi.mocked(app.rootGraph.getNodeById).mock.calls
|
||||
.length
|
||||
|
||||
@@ -340,7 +346,9 @@ describe('useExecutionStore - nodeLocationProgressStates caching', () => {
|
||||
}
|
||||
}
|
||||
|
||||
expect(store.nodeLocationProgressStates['123']).toBeDefined()
|
||||
expect(
|
||||
store.nodeLocationProgressStates[createNodeLocatorId(null, 123)]
|
||||
).toBeDefined()
|
||||
|
||||
// getNodeById should NOT be called again for the same execution ID
|
||||
expect(vi.mocked(app.rootGraph.getNodeById).mock.calls.length).toBe(
|
||||
@@ -383,12 +391,16 @@ describe('useExecutionStore - nodeLocationProgressStates caching', () => {
|
||||
const result = store.nodeLocationProgressStates
|
||||
|
||||
// Both sibling nodes should be resolved with the correct subgraph UUID
|
||||
expect(result['a1b2c3d4-e5f6-7890-abcd-ef1234567890:456']).toBeDefined()
|
||||
expect(result['a1b2c3d4-e5f6-7890-abcd-ef1234567890:789']).toBeDefined()
|
||||
expect(
|
||||
result[createNodeLocatorId('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 456)]
|
||||
).toBeDefined()
|
||||
expect(
|
||||
result[createNodeLocatorId('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 789)]
|
||||
).toBeDefined()
|
||||
|
||||
// The shared parent "123" should also have a merged state
|
||||
expect(result['123']).toBeDefined()
|
||||
expect(result['123'].state).toBe('running')
|
||||
expect(result[createNodeLocatorId(null, 123)]).toBeDefined()
|
||||
expect(result[createNodeLocatorId(null, 123)].state).toBe('running')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -867,6 +879,21 @@ describe('useExecutionStore - progress_text startup guard', () => {
|
||||
|
||||
expect(mockShowTextPreview).toHaveBeenCalledWith(mockNode, 'warming up')
|
||||
})
|
||||
it('should ignore nested progress_text when the execution ID cannot be mapped', async () => {
|
||||
const { useCanvasStore } =
|
||||
await import('@/renderer/core/canvas/canvasStore')
|
||||
useCanvasStore().canvas = {
|
||||
graph: { getNodeById: vi.fn() }
|
||||
} as unknown as LGraphCanvas
|
||||
mockExecutionIdToCurrentId.mockReturnValue(undefined)
|
||||
|
||||
expect(() =>
|
||||
fireProgressText({ nodeId: '1:2', text: 'warming up' })
|
||||
).not.toThrow()
|
||||
|
||||
expect(mockExecutionIdToCurrentId).toHaveBeenCalledWith('1:2')
|
||||
expect(mockShowTextPreview).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionErrorStore - Node Error Lookups', () => {
|
||||
@@ -880,7 +907,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
|
||||
|
||||
describe('getNodeErrors', () => {
|
||||
it('should return undefined when no errors exist', () => {
|
||||
const result = store.getNodeErrors('123')
|
||||
const result = store.getNodeErrors(createNodeLocatorId(null, 123))
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -900,7 +927,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const result = store.getNodeErrors('123')
|
||||
const result = store.getNodeErrors(createNodeLocatorId(null, 123))
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.errors).toHaveLength(1)
|
||||
expect(result?.errors[0].message).toBe('Invalid input')
|
||||
@@ -937,7 +964,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const locatorId = `${subgraphUuid}:456`
|
||||
const locatorId = createNodeLocatorId(subgraphUuid, 456)
|
||||
const result = store.getNodeErrors(locatorId)
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.errors[0].message).toBe('Invalid subgraph input')
|
||||
@@ -946,7 +973,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
|
||||
|
||||
describe('slotHasError', () => {
|
||||
it('should return false when node has no errors', () => {
|
||||
const result = store.slotHasError('123', 'width')
|
||||
const result = store.slotHasError(createNodeLocatorId(null, 123), 'width')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
@@ -966,7 +993,10 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const result = store.slotHasError('123', 'height')
|
||||
const result = store.slotHasError(
|
||||
createNodeLocatorId(null, 123),
|
||||
'height'
|
||||
)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
@@ -986,7 +1016,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const result = store.slotHasError('123', 'width')
|
||||
const result = store.slotHasError(createNodeLocatorId(null, 123), 'width')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
@@ -1012,7 +1042,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const result = store.slotHasError('123', 'width')
|
||||
const result = store.slotHasError(createNodeLocatorId(null, 123), 'width')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
@@ -1031,7 +1061,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const result = store.slotHasError('123', 'width')
|
||||
const result = store.slotHasError(createNodeLocatorId(null, 123), 'width')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,6 +33,7 @@ import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { tryNormalizeNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { classifyCloudValidationError } from '@/utils/executionErrorUtil'
|
||||
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
|
||||
@@ -487,7 +488,8 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
// here intentionally. That way, we don't clear the preview every time a new node
|
||||
// within an expanded graph starts executing.
|
||||
const { revokePreviewsByExecutionId } = useNodeOutputStore()
|
||||
revokePreviewsByExecutionId(nodeId)
|
||||
const executionId = tryNormalizeNodeExecutionId(nodeId)
|
||||
if (executionId) revokePreviewsByExecutionId(executionId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -788,7 +790,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
* @returns The execution ID or null if conversion fails
|
||||
*/
|
||||
const nodeLocatorIdToExecutionId = (
|
||||
locatorId: NodeLocatorId | string
|
||||
locatorId: NodeLocatorId
|
||||
): string | null => {
|
||||
const executionId = workflowStore.nodeLocatorIdToNodeExecutionId(locatorId)
|
||||
return executionId
|
||||
|
||||
@@ -8,6 +8,7 @@ import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import * as litegraphUtil from '@/utils/litegraphUtil'
|
||||
|
||||
const mockResolveNode = vi.fn()
|
||||
@@ -66,11 +67,21 @@ describe('nodeOutputStore setNodeOutputsByExecutionId with merge', () => {
|
||||
const firstOutput = createMockOutputs([{ filename: 'first.png' }])
|
||||
const secondOutput = createMockOutputs([{ filename: 'second.png' }])
|
||||
|
||||
store.setNodeOutputsByExecutionId('11:20:10', firstOutput)
|
||||
store.setNodeOutputsByExecutionId('12:20:10', secondOutput)
|
||||
store.setNodeOutputsByExecutionId(
|
||||
createNodeExecutionId([11, 20, 10]),
|
||||
firstOutput
|
||||
)
|
||||
store.setNodeOutputsByExecutionId(
|
||||
createNodeExecutionId([12, 20, 10]),
|
||||
secondOutput
|
||||
)
|
||||
|
||||
expect(store.getNodeOutputByExecutionId('11:20:10')).toEqual(firstOutput)
|
||||
expect(store.getNodeOutputByExecutionId('12:20:10')).toEqual(secondOutput)
|
||||
expect(
|
||||
store.getNodeOutputByExecutionId(createNodeExecutionId([11, 20, 10]))
|
||||
).toEqual(firstOutput)
|
||||
expect(
|
||||
store.getNodeOutputByExecutionId(createNodeExecutionId([12, 20, 10]))
|
||||
).toEqual(secondOutput)
|
||||
})
|
||||
|
||||
it('merges execution-keyed outputs when merge is true', () => {
|
||||
@@ -78,32 +89,49 @@ describe('nodeOutputStore setNodeOutputsByExecutionId with merge', () => {
|
||||
const initialOutput = createMockOutputs([{ filename: 'first.png' }])
|
||||
const nextOutput = createMockOutputs([{ filename: 'second.png' }])
|
||||
|
||||
store.setNodeOutputsByExecutionId('11:20:10', initialOutput)
|
||||
store.setNodeOutputsByExecutionId('11:20:10', nextOutput, { merge: true })
|
||||
store.setNodeOutputsByExecutionId(
|
||||
createNodeExecutionId([11, 20, 10]),
|
||||
initialOutput
|
||||
)
|
||||
store.setNodeOutputsByExecutionId(
|
||||
createNodeExecutionId([11, 20, 10]),
|
||||
nextOutput,
|
||||
{
|
||||
merge: true
|
||||
}
|
||||
)
|
||||
|
||||
expect(store.getNodeOutputByExecutionId('11:20:10')?.images).toEqual([
|
||||
{ filename: 'first.png' },
|
||||
{ filename: 'second.png' }
|
||||
])
|
||||
expect(
|
||||
store.getNodeOutputByExecutionId(createNodeExecutionId([11, 20, 10]))
|
||||
?.images
|
||||
).toEqual([{ filename: 'first.png' }, { filename: 'second.png' }])
|
||||
})
|
||||
|
||||
it('keeps execution-keyed previews distinct from locator-keyed previews', () => {
|
||||
const store = useNodeOutputStore()
|
||||
|
||||
store.setNodePreviewsByExecutionId('11:20:10', ['blob:first'])
|
||||
store.setNodePreviewsByExecutionId('12:20:10', ['blob:second'])
|
||||
|
||||
expect(store.getNodePreviewImagesByExecutionId('11:20:10')).toEqual([
|
||||
store.setNodePreviewsByExecutionId(createNodeExecutionId([11, 20, 10]), [
|
||||
'blob:first'
|
||||
])
|
||||
expect(store.getNodePreviewImagesByExecutionId('12:20:10')).toEqual([
|
||||
store.setNodePreviewsByExecutionId(createNodeExecutionId([12, 20, 10]), [
|
||||
'blob:second'
|
||||
])
|
||||
|
||||
expect(
|
||||
store.getNodePreviewImagesByExecutionId(
|
||||
createNodeExecutionId([11, 20, 10])
|
||||
)
|
||||
).toEqual(['blob:first'])
|
||||
expect(
|
||||
store.getNodePreviewImagesByExecutionId(
|
||||
createNodeExecutionId([12, 20, 10])
|
||||
)
|
||||
).toEqual(['blob:second'])
|
||||
})
|
||||
|
||||
it('should update reactive nodeOutputs.value when merging outputs', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const executionId = '1'
|
||||
const executionId = createNodeExecutionId([1])
|
||||
|
||||
const initialOutput = createMockOutputs([{ filename: 'a.png' }])
|
||||
store.setNodeOutputsByExecutionId(executionId, initialOutput)
|
||||
@@ -120,7 +148,7 @@ describe('nodeOutputStore setNodeOutputsByExecutionId with merge', () => {
|
||||
|
||||
it('should assign to reactive ref after merge for Vue reactivity', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const executionId = '1'
|
||||
const executionId = createNodeExecutionId([1])
|
||||
|
||||
const initialOutput = createMockOutputs([{ filename: 'a.png' }])
|
||||
store.setNodeOutputsByExecutionId(executionId, initialOutput)
|
||||
@@ -137,7 +165,7 @@ describe('nodeOutputStore setNodeOutputsByExecutionId with merge', () => {
|
||||
|
||||
it('should create a new object reference on merge so Vue detects the change', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const executionId = '1'
|
||||
const executionId = createNodeExecutionId([1])
|
||||
|
||||
const initialOutput = createMockOutputs([{ filename: 'a.png' }])
|
||||
store.setNodeOutputsByExecutionId(executionId, initialOutput)
|
||||
@@ -184,7 +212,7 @@ describe('nodeOutputStore restoreOutputs', () => {
|
||||
const widgetOutput = createMockOutputs([
|
||||
{ filename: 'example.png', subfolder: '', type: 'input' }
|
||||
])
|
||||
store.setNodeOutputsByExecutionId('3', widgetOutput)
|
||||
store.setNodeOutputsByExecutionId(createNodeExecutionId([3]), widgetOutput)
|
||||
|
||||
// The reactive store must reflect the new output.
|
||||
// Before the fix, the raw write to app.nodeOutputs would mutate the
|
||||
@@ -205,7 +233,7 @@ describe('nodeOutputStore input preview preservation', () => {
|
||||
|
||||
it('should preserve input preview when execution sends empty output', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const executionId = '3'
|
||||
const executionId = createNodeExecutionId([3])
|
||||
|
||||
const inputPreview = createMockOutputs([
|
||||
{ filename: 'example.png', subfolder: '', type: 'input' }
|
||||
@@ -225,7 +253,7 @@ describe('nodeOutputStore input preview preservation', () => {
|
||||
|
||||
it('should preserve input preview when execution sends output with empty images array', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const executionId = '3'
|
||||
const executionId = createNodeExecutionId([3])
|
||||
|
||||
const inputPreview = createMockOutputs([
|
||||
{ filename: 'example.png', subfolder: '', type: 'input' }
|
||||
@@ -241,7 +269,7 @@ describe('nodeOutputStore input preview preservation', () => {
|
||||
|
||||
it('should allow execution output with images to overwrite input preview', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const executionId = '3'
|
||||
const executionId = createNodeExecutionId([3])
|
||||
|
||||
const inputPreview = createMockOutputs([
|
||||
{ filename: 'example.png', subfolder: '', type: 'input' }
|
||||
@@ -261,7 +289,7 @@ describe('nodeOutputStore input preview preservation', () => {
|
||||
|
||||
it('should not preserve non-input outputs from being overwritten', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const executionId = '4'
|
||||
const executionId = createNodeExecutionId([4])
|
||||
|
||||
const tempOutput = createMockOutputs([
|
||||
{ filename: 'temp.png', subfolder: '', type: 'temp' }
|
||||
@@ -276,7 +304,7 @@ describe('nodeOutputStore input preview preservation', () => {
|
||||
|
||||
it('should pass through non-image fields while preserving input preview images', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const executionId = '5'
|
||||
const executionId = createNodeExecutionId([5])
|
||||
|
||||
const inputPreview = createMockOutputs([
|
||||
{ filename: 'example.png', subfolder: '', type: 'input' }
|
||||
@@ -392,12 +420,12 @@ describe('nodeOutputStore snapshotOutputs / restoreOutputs', () => {
|
||||
const inputOutput = createMockOutputs([
|
||||
{ filename: 'example.png', subfolder: '', type: 'input' }
|
||||
])
|
||||
store.setNodeOutputsByExecutionId('3', inputOutput)
|
||||
store.setNodeOutputsByExecutionId(createNodeExecutionId([3]), inputOutput)
|
||||
|
||||
const execOutput = createMockOutputs([
|
||||
{ filename: 'ComfyUI_00001.png', subfolder: '', type: 'temp' }
|
||||
])
|
||||
store.setNodeOutputsByExecutionId('4', execOutput)
|
||||
store.setNodeOutputsByExecutionId(createNodeExecutionId([4]), execOutput)
|
||||
|
||||
// Snapshot
|
||||
const snapshot = store.snapshotOutputs()
|
||||
@@ -426,8 +454,8 @@ describe('nodeOutputStore snapshotOutputs / restoreOutputs', () => {
|
||||
const outputA2 = createMockOutputs([
|
||||
{ filename: 'example.png', subfolder: '', type: 'input' }
|
||||
])
|
||||
store.setNodeOutputsByExecutionId('1', outputA1)
|
||||
store.setNodeOutputsByExecutionId('3', outputA2)
|
||||
store.setNodeOutputsByExecutionId(createNodeExecutionId([1]), outputA1)
|
||||
store.setNodeOutputsByExecutionId(createNodeExecutionId([3]), outputA2)
|
||||
|
||||
// --- Switch away: store() then clean ---
|
||||
const tabASnapshot = store.snapshotOutputs()
|
||||
@@ -452,7 +480,7 @@ describe('nodeOutputStore snapshotOutputs / restoreOutputs', () => {
|
||||
|
||||
// New execution should still work after restore
|
||||
const newOutput = createMockOutputs([{ filename: 'new.png' }])
|
||||
store.setNodeOutputsByExecutionId('5', newOutput)
|
||||
store.setNodeOutputsByExecutionId(createNodeExecutionId([5]), newOutput)
|
||||
expect(store.nodeOutputs['5']).toStrictEqual(newOutput)
|
||||
})
|
||||
|
||||
@@ -461,13 +489,13 @@ describe('nodeOutputStore snapshotOutputs / restoreOutputs', () => {
|
||||
|
||||
// Tab A: execute
|
||||
const outputA = createMockOutputs([{ filename: 'tab_a.png' }])
|
||||
store.setNodeOutputsByExecutionId('1', outputA)
|
||||
store.setNodeOutputsByExecutionId(createNodeExecutionId([1]), outputA)
|
||||
const snapshotA = store.snapshotOutputs()
|
||||
|
||||
// Switch to Tab B
|
||||
store.resetAllOutputsAndPreviews()
|
||||
const outputB = createMockOutputs([{ filename: 'tab_b.png' }])
|
||||
store.setNodeOutputsByExecutionId('1', outputB)
|
||||
store.setNodeOutputsByExecutionId(createNodeExecutionId([1]), outputB)
|
||||
const snapshotB = store.snapshotOutputs()
|
||||
|
||||
// Switch back to Tab A
|
||||
@@ -494,7 +522,7 @@ describe('nodeOutputStore snapshotOutputs / restoreOutputs', () => {
|
||||
const store = useNodeOutputStore()
|
||||
|
||||
const output = createMockOutputs([{ filename: 'a.png' }])
|
||||
store.setNodeOutputsByExecutionId('1', output)
|
||||
store.setNodeOutputsByExecutionId(createNodeExecutionId([1]), output)
|
||||
|
||||
const snapshot = store.snapshotOutputs()
|
||||
|
||||
@@ -521,15 +549,15 @@ describe('nodeOutputStore resetAllOutputsAndPreviews', () => {
|
||||
const store = useNodeOutputStore()
|
||||
|
||||
store.setNodeOutputsByExecutionId(
|
||||
'1',
|
||||
createNodeExecutionId([1]),
|
||||
createMockOutputs([{ filename: 'a.png' }])
|
||||
)
|
||||
store.setNodeOutputsByExecutionId(
|
||||
'2',
|
||||
createNodeExecutionId([2]),
|
||||
createMockOutputs([{ filename: 'b.png' }])
|
||||
)
|
||||
store.setNodeOutputsByExecutionId(
|
||||
'3',
|
||||
createNodeExecutionId([3]),
|
||||
createMockOutputs([{ filename: 'c.png', type: 'input' }])
|
||||
)
|
||||
|
||||
@@ -570,7 +598,7 @@ describe('nodeOutputStore restoreOutputs + execution interaction', () => {
|
||||
const execOutput = createMockOutputs([
|
||||
{ filename: 'ComfyUI_00001.png', subfolder: '', type: 'temp' }
|
||||
])
|
||||
store.setNodeOutputsByExecutionId('4', execOutput)
|
||||
store.setNodeOutputsByExecutionId(createNodeExecutionId([4]), execOutput)
|
||||
|
||||
// Both should be present
|
||||
expect(store.nodeOutputs['3']).toStrictEqual(inputOutput)
|
||||
@@ -592,7 +620,7 @@ describe('nodeOutputStore restoreOutputs + execution interaction', () => {
|
||||
const execOutput = createMockOutputs([
|
||||
{ filename: 'result.png', subfolder: '', type: 'temp' }
|
||||
])
|
||||
store.setNodeOutputsByExecutionId('3', execOutput)
|
||||
store.setNodeOutputsByExecutionId(createNodeExecutionId([3]), execOutput)
|
||||
|
||||
// On current main (without PR #9123 guard), execution overwrites
|
||||
expect(store.nodeOutputs['3']).toStrictEqual(execOutput)
|
||||
@@ -615,13 +643,15 @@ describe('nodeOutputStore merge mode interactions', () => {
|
||||
const inputOutput = createMockOutputs([
|
||||
{ filename: 'uploaded.png', subfolder: '', type: 'input' }
|
||||
])
|
||||
store.setNodeOutputsByExecutionId('3', inputOutput)
|
||||
store.setNodeOutputsByExecutionId(createNodeExecutionId([3]), inputOutput)
|
||||
|
||||
// Merge new execution images
|
||||
const execOutput = createMockOutputs([
|
||||
{ filename: 'result.png', subfolder: '', type: 'temp' }
|
||||
])
|
||||
store.setNodeOutputsByExecutionId('3', execOutput, { merge: true })
|
||||
store.setNodeOutputsByExecutionId(createNodeExecutionId([3]), execOutput, {
|
||||
merge: true
|
||||
})
|
||||
|
||||
// Should have both images concatenated
|
||||
expect(store.nodeOutputs['3']?.images).toHaveLength(2)
|
||||
@@ -637,13 +667,15 @@ describe('nodeOutputStore merge mode interactions', () => {
|
||||
const inputOutput = createMockOutputs([
|
||||
{ filename: 'uploaded.png', subfolder: '', type: 'input' }
|
||||
])
|
||||
store.setNodeOutputsByExecutionId('3', inputOutput)
|
||||
store.setNodeOutputsByExecutionId(createNodeExecutionId([3]), inputOutput)
|
||||
|
||||
// Merge with empty images — the input-preview guard (lines 166-177)
|
||||
// copies existing input images into the incoming outputs before the
|
||||
// merge concat runs, resulting in duplication.
|
||||
const emptyOutput = createMockOutputs([])
|
||||
store.setNodeOutputsByExecutionId('3', emptyOutput, { merge: true })
|
||||
store.setNodeOutputsByExecutionId(createNodeExecutionId([3]), emptyOutput, {
|
||||
merge: true
|
||||
})
|
||||
|
||||
expect(store.nodeOutputs['3']?.images).toHaveLength(2)
|
||||
expect(store.nodeOutputs['3']?.images?.[0]?.filename).toBe('uploaded.png')
|
||||
|
||||
@@ -14,7 +14,8 @@ import type {
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { clone } from '@/scripts/utils'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { parseFilePath } from '@/utils/formatUtil'
|
||||
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
|
||||
import {
|
||||
@@ -127,7 +128,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
}
|
||||
|
||||
function getNodeOutputByExecutionId(
|
||||
executionId: string
|
||||
executionId: NodeExecutionId
|
||||
): ExecutedWsMessage['output'] | undefined {
|
||||
const locatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
|
||||
if (!locatorId) return undefined
|
||||
@@ -135,7 +136,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
}
|
||||
|
||||
function getNodePreviewImagesByExecutionId(
|
||||
executionId: string
|
||||
executionId: NodeExecutionId
|
||||
): string[] | undefined {
|
||||
const locatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
|
||||
if (!locatorId) return undefined
|
||||
@@ -143,7 +144,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
}
|
||||
|
||||
function getNodeImageUrlsByExecutionId(
|
||||
executionId: string,
|
||||
executionId: NodeExecutionId,
|
||||
node: LGraphNode
|
||||
): string[] | undefined {
|
||||
const previews = getNodePreviewImagesByExecutionId(executionId)
|
||||
@@ -188,7 +189,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
if (existingOutput && outputs) {
|
||||
for (const k in outputs) {
|
||||
const existingValue = existingOutput[k]
|
||||
const newValue = (outputs as Record<NodeLocatorId, unknown>)[k]
|
||||
const newValue = (outputs as Record<string, unknown>)[k]
|
||||
|
||||
if (Array.isArray(existingValue) && Array.isArray(newValue)) {
|
||||
existingOutput[k] = existingValue.concat(newValue)
|
||||
@@ -232,7 +233,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
}
|
||||
|
||||
function setNodeOutputsByExecutionId(
|
||||
executionId: string,
|
||||
executionId: NodeExecutionId,
|
||||
outputs: ExecutedWsMessage['output'] | ResultItem,
|
||||
options: SetOutputOptions = {}
|
||||
) {
|
||||
@@ -242,7 +243,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
}
|
||||
|
||||
function setNodePreviewsByExecutionId(
|
||||
executionId: string,
|
||||
executionId: NodeExecutionId,
|
||||
previewImages: string[]
|
||||
) {
|
||||
const nodeLocatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
|
||||
@@ -279,7 +280,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
setNodePreviewsByLocatorId(nodeIdToNodeLocatorId(nodeId), previewImages)
|
||||
}
|
||||
|
||||
function revokePreviewsByExecutionId(executionId: string) {
|
||||
function revokePreviewsByExecutionId(executionId: NodeExecutionId) {
|
||||
const nodeLocatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
|
||||
if (!nodeLocatorId) return
|
||||
scheduleRevoke(nodeLocatorId, () =>
|
||||
@@ -316,10 +317,13 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
const { graph } = subgraphNode
|
||||
if (!graph) return
|
||||
|
||||
const graphId = graph.isRootGraph ? '' : graph.id + ':'
|
||||
revokePreviewsByLocatorId(graphId + subgraphNode.id)
|
||||
revokePreviewsByLocatorId(
|
||||
createNodeLocatorId(graph.isRootGraph ? null : graph.id, subgraphNode.id)
|
||||
)
|
||||
for (const node of subgraphNode.subgraph.nodes) {
|
||||
revokePreviewsByLocatorId(subgraphNode.subgraph.id + node.id)
|
||||
revokePreviewsByLocatorId(
|
||||
createNodeLocatorId(subgraphNode.subgraph.id, node.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useExtensionService } from '@/services/extensionService'
|
||||
import { getJobDetail } from '@/services/jobOutputCache'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { tryNormalizeNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
|
||||
@@ -440,10 +441,12 @@ export class TaskItemImpl {
|
||||
|
||||
const nodeOutputsStore = useNodeOutputStore()
|
||||
const rawOutputs = toRaw(outputsToLoad)
|
||||
for (const nodeExecutionId in rawOutputs) {
|
||||
for (const rawNodeExecutionId in rawOutputs) {
|
||||
const nodeExecutionId = tryNormalizeNodeExecutionId(rawNodeExecutionId)
|
||||
if (!nodeExecutionId) continue
|
||||
nodeOutputsStore.setNodeOutputsByExecutionId(
|
||||
nodeExecutionId,
|
||||
rawOutputs[nodeExecutionId]
|
||||
rawOutputs[rawNodeExecutionId]
|
||||
)
|
||||
}
|
||||
useExtensionService().invokeExtensions(
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { isNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
@@ -84,14 +85,14 @@ export const useFavoritedWidgetsStore = defineStore('favoritedWidgets', () => {
|
||||
function parseFavoriteKey(key: string): FavoritedWidgetId | null {
|
||||
try {
|
||||
const [nodeLocatorId, widgetName] = JSON.parse(key) as [string, string]
|
||||
if (!nodeLocatorId || !widgetName) return null
|
||||
if (!isNodeLocatorId(nodeLocatorId) || !widgetName) return null
|
||||
return { nodeLocatorId, widgetName }
|
||||
} catch {
|
||||
const separatorIndex = key.indexOf(':')
|
||||
if (separatorIndex === -1) return null
|
||||
const nodeLocatorId = key.slice(0, separatorIndex)
|
||||
const widgetName = key.slice(separatorIndex + 1)
|
||||
if (!nodeLocatorId || !widgetName) return null
|
||||
if (!isNodeLocatorId(nodeLocatorId) || !widgetName) return null
|
||||
return { nodeLocatorId, widgetName }
|
||||
}
|
||||
}
|
||||
@@ -102,8 +103,9 @@ export const useFavoritedWidgetsStore = defineStore('favoritedWidgets', () => {
|
||||
if (!id || !id.widgetName) return null
|
||||
|
||||
if ('nodeLocatorId' in id && id.nodeLocatorId) {
|
||||
if (!isNodeLocatorId(id.nodeLocatorId)) return null
|
||||
return {
|
||||
nodeLocatorId: String(id.nodeLocatorId),
|
||||
nodeLocatorId: id.nodeLocatorId,
|
||||
widgetName: String(id.widgetName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,15 +10,15 @@ import {
|
||||
isNodeExecutionId,
|
||||
isNodeLocatorId,
|
||||
parseNodeExecutionId,
|
||||
parseNodeLocatorId
|
||||
parseNodeLocatorId,
|
||||
tryNormalizeNodeExecutionId
|
||||
} from '@/types/nodeIdentification'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
describe('nodeIdentification', () => {
|
||||
describe('NodeLocatorId', () => {
|
||||
const validUuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
const validNodeId = '123'
|
||||
const validNodeLocatorId = `${validUuid}:${validNodeId}` as NodeLocatorId
|
||||
const validNodeLocatorId = createNodeLocatorId(validUuid, validNodeId)
|
||||
|
||||
describe('isNodeLocatorId', () => {
|
||||
it('should return true for valid NodeLocatorId', () => {
|
||||
@@ -127,18 +127,35 @@ describe('nodeIdentification', () => {
|
||||
expect(isNodeExecutionId('123:456')).toBe(true)
|
||||
expect(isNodeExecutionId('123:456:789')).toBe(true)
|
||||
expect(isNodeExecutionId('node_1:node_2')).toBe(true)
|
||||
expect(isNodeExecutionId('123')).toBe(true)
|
||||
expect(isNodeExecutionId('node_1')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for non-execution IDs', () => {
|
||||
expect(isNodeExecutionId('123')).toBe(false)
|
||||
expect(isNodeExecutionId('node_1')).toBe(false)
|
||||
it('should return false for malformed execution IDs', () => {
|
||||
expect(isNodeExecutionId('')).toBe(false)
|
||||
expect(isNodeExecutionId(':123')).toBe(false)
|
||||
expect(isNodeExecutionId('123:')).toBe(false)
|
||||
expect(isNodeExecutionId('123::456')).toBe(false)
|
||||
expect(isNodeExecutionId(123)).toBe(false)
|
||||
expect(isNodeExecutionId(null)).toBe(false)
|
||||
expect(isNodeExecutionId(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tryNormalizeNodeExecutionId', () => {
|
||||
it('should return a branded ID for valid execution IDs', () => {
|
||||
expect(tryNormalizeNodeExecutionId(123)).toBe('123')
|
||||
expect(tryNormalizeNodeExecutionId('123:456')).toBe('123:456')
|
||||
})
|
||||
|
||||
it('should return null for malformed execution IDs', () => {
|
||||
expect(tryNormalizeNodeExecutionId('')).toBeNull()
|
||||
expect(tryNormalizeNodeExecutionId(':123')).toBeNull()
|
||||
expect(tryNormalizeNodeExecutionId('123:')).toBeNull()
|
||||
expect(tryNormalizeNodeExecutionId('123::456')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseNodeExecutionId', () => {
|
||||
it('should parse execution IDs correctly', () => {
|
||||
expect(parseNodeExecutionId('123:456')).toEqual([123, 456])
|
||||
@@ -152,11 +169,15 @@ describe('nodeIdentification', () => {
|
||||
'node_2',
|
||||
456
|
||||
])
|
||||
expect(parseNodeExecutionId('123')).toEqual([123])
|
||||
expect(parseNodeExecutionId('node_1')).toEqual(['node_1'])
|
||||
})
|
||||
|
||||
it('should return null for non-execution IDs', () => {
|
||||
expect(parseNodeExecutionId('123')).toBeNull()
|
||||
it('should return null for malformed execution IDs', () => {
|
||||
expect(parseNodeExecutionId('')).toBeNull()
|
||||
expect(parseNodeExecutionId(':123')).toBeNull()
|
||||
expect(parseNodeExecutionId('123:')).toBeNull()
|
||||
expect(parseNodeExecutionId('123::456')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -175,12 +196,19 @@ describe('nodeIdentification', () => {
|
||||
it('should handle single node ID', () => {
|
||||
const result = createNodeExecutionId([123])
|
||||
expect(result).toBe('123')
|
||||
// Single node IDs are not execution IDs
|
||||
expect(isNodeExecutionId(result)).toBe(false)
|
||||
expect(isNodeExecutionId(result)).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle empty array', () => {
|
||||
expect(createNodeExecutionId([])).toBe('')
|
||||
it('should reject an empty array', () => {
|
||||
const emptyNodeIds = [] as NodeId[]
|
||||
expect(() => createNodeExecutionId(emptyNodeIds)).toThrow(
|
||||
'NodeExecutionId requires at least one node ID'
|
||||
)
|
||||
})
|
||||
|
||||
it('should preserve empty path segments', () => {
|
||||
expect(createNodeExecutionId([''])).toBe('')
|
||||
expect(createNodeExecutionId([123, ''])).toBe('123:')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -213,6 +241,10 @@ describe('nodeIdentification', () => {
|
||||
expect(getAncestorExecutionIds('65')).toEqual(['65'])
|
||||
})
|
||||
|
||||
it('returns an empty list for malformed execution IDs', () => {
|
||||
expect(getAncestorExecutionIds('65::70')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns all ancestors including self for nested IDs', () => {
|
||||
expect(getAncestorExecutionIds('65:70')).toEqual(['65', '65:70'])
|
||||
expect(getAncestorExecutionIds('65:70:63')).toEqual([
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
const NODE_EXECUTION_ID_PATTERN = /^[^:]+(?::[^:]+)*$/
|
||||
|
||||
/**
|
||||
* A globally unique identifier for nodes that maintains consistency across
|
||||
* multiple instances of the same subgraph.
|
||||
@@ -15,7 +17,7 @@ import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSche
|
||||
* Unlike execution IDs which change based on the instance path,
|
||||
* NodeLocatorId remains the same for all instances of a particular node.
|
||||
*/
|
||||
export type NodeLocatorId = string
|
||||
export type NodeLocatorId = string & { readonly __brand: 'NodeLocatorId' }
|
||||
|
||||
/**
|
||||
* An execution identifier representing a node's position in nested subgraphs.
|
||||
@@ -24,7 +26,17 @@ export type NodeLocatorId = string
|
||||
* Format: Colon-separated path of node IDs
|
||||
* Example: "123:456:789" (node 789 in subgraph 456 in subgraph 123)
|
||||
*/
|
||||
export type NodeExecutionId = string
|
||||
export type NodeExecutionId = string & { readonly __brand: 'NodeExecutionId' }
|
||||
|
||||
function parseNodeIdSegment(part: string): NodeId {
|
||||
return isNaN(Number(part)) ? part : Number(part)
|
||||
}
|
||||
|
||||
function nodeExecutionIdFromString(value: string): NodeExecutionId | null {
|
||||
return NODE_EXECUTION_ID_PATTERN.test(value)
|
||||
? (value as NodeExecutionId)
|
||||
: null
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a value is a NodeLocatorId
|
||||
@@ -55,9 +67,7 @@ export function isNodeLocatorId(value: unknown): value is NodeLocatorId {
|
||||
* Type guard to check if a value is a NodeExecutionId
|
||||
*/
|
||||
export function isNodeExecutionId(value: unknown): value is NodeExecutionId {
|
||||
if (typeof value !== 'string') return false
|
||||
// Must contain at least one colon to be an execution ID
|
||||
return value.includes(':')
|
||||
return typeof value === 'string' && nodeExecutionIdFromString(value) !== null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,14 +86,14 @@ export function parseNodeLocatorId(
|
||||
// Simple node ID (root graph)
|
||||
return {
|
||||
subgraphUuid: null,
|
||||
localNodeId: isNaN(Number(id)) ? id : Number(id)
|
||||
localNodeId: parseNodeIdSegment(id)
|
||||
}
|
||||
}
|
||||
|
||||
const [subgraphUuid, localNodeId] = parts
|
||||
return {
|
||||
subgraphUuid,
|
||||
localNodeId: isNaN(Number(localNodeId)) ? localNodeId : Number(localNodeId)
|
||||
localNodeId: parseNodeIdSegment(localNodeId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,10 +104,12 @@ export function parseNodeLocatorId(
|
||||
* @returns A properly formatted NodeLocatorId
|
||||
*/
|
||||
export function createNodeLocatorId(
|
||||
subgraphUuid: string,
|
||||
subgraphUuid: string | null,
|
||||
localNodeId: NodeId
|
||||
): NodeLocatorId {
|
||||
return `${subgraphUuid}:${localNodeId}`
|
||||
return (
|
||||
subgraphUuid ? `${subgraphUuid}:${localNodeId}` : String(localNodeId)
|
||||
) as NodeLocatorId
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,11 +118,10 @@ export function createNodeLocatorId(
|
||||
* @returns Array of node IDs from root to target, or null if not an execution ID
|
||||
*/
|
||||
export function parseNodeExecutionId(id: string): NodeId[] | null {
|
||||
if (!isNodeExecutionId(id)) return null
|
||||
const executionId = tryNormalizeNodeExecutionId(id)
|
||||
if (!executionId) return null
|
||||
|
||||
return id
|
||||
.split(':')
|
||||
.map((part) => (isNaN(Number(part)) ? part : Number(part)))
|
||||
return executionId.split(':').map(parseNodeIdSegment)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,8 +129,26 @@ export function parseNodeExecutionId(id: string): NodeId[] | null {
|
||||
* @param nodeIds Array of node IDs from root to target
|
||||
* @returns A properly formatted NodeExecutionId
|
||||
*/
|
||||
export function createNodeExecutionId(nodeIds: NodeId[]): NodeExecutionId {
|
||||
return nodeIds.join(':')
|
||||
export function createNodeExecutionId<const T extends readonly NodeId[]>(
|
||||
nodeIds: T & (T extends readonly [] ? never : unknown)
|
||||
): NodeExecutionId {
|
||||
if (nodeIds.length === 0) {
|
||||
throw new Error('NodeExecutionId requires at least one node ID')
|
||||
}
|
||||
return nodeIds.map(String).join(':') as NodeExecutionId
|
||||
}
|
||||
|
||||
export function tryNormalizeNodeExecutionId(
|
||||
value: string | number
|
||||
): NodeExecutionId | null {
|
||||
return nodeExecutionIdFromString(String(value))
|
||||
}
|
||||
|
||||
export function appendNodeExecutionId(
|
||||
parentExecutionId: string,
|
||||
childNodeId: NodeId
|
||||
): NodeExecutionId {
|
||||
return createNodeExecutionId([...parentExecutionId.split(':'), childNodeId])
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,11 +157,14 @@ export function createNodeExecutionId(nodeIds: NodeId[]): NodeExecutionId {
|
||||
* Example: "65:70:63" → ["65", "65:70", "65:70:63"]
|
||||
*/
|
||||
export function getAncestorExecutionIds(
|
||||
executionId: string | NodeExecutionId
|
||||
executionId: string
|
||||
): NodeExecutionId[] {
|
||||
const parts = executionId.split(':')
|
||||
const normalized = tryNormalizeNodeExecutionId(executionId)
|
||||
if (!normalized) return []
|
||||
|
||||
const parts = normalized.split(':')
|
||||
return Array.from({ length: parts.length }, (_, i) =>
|
||||
parts.slice(0, i + 1).join(':')
|
||||
createNodeExecutionId(parts.slice(0, i + 1))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -141,9 +173,7 @@ export function getAncestorExecutionIds(
|
||||
*
|
||||
* Example: "65:70:63" → ["65", "65:70"]
|
||||
*/
|
||||
export function getParentExecutionIds(
|
||||
executionId: string | NodeExecutionId
|
||||
): NodeExecutionId[] {
|
||||
export function getParentExecutionIds(executionId: string): NodeExecutionId[] {
|
||||
return getAncestorExecutionIds(executionId).slice(0, -1)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
/** Valid types for widget values */
|
||||
export type WidgetValue =
|
||||
@@ -77,7 +78,7 @@ export interface SimplifiedWidget<
|
||||
serializeValue?: () => unknown
|
||||
|
||||
/** NodeLocatorId for the node that owns this widget's execution outputs */
|
||||
nodeLocatorId?: string
|
||||
nodeLocatorId?: NodeLocatorId
|
||||
|
||||
/** Optional input specification backing this widget */
|
||||
spec?: InputSpecV2
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
type NodeId = number | string
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
type UUID = string
|
||||
|
||||
export type WidgetId = string & { readonly __brand: 'WidgetId' }
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import {
|
||||
createNodeExecutionId,
|
||||
createNodeLocatorId,
|
||||
getParentExecutionIds,
|
||||
parseNodeLocatorId
|
||||
@@ -22,10 +23,8 @@ import { isSubgraphIoNode } from './typeGuardUtil'
|
||||
export function getLocatorIdFromNodeData(nodeData: {
|
||||
id: string | number
|
||||
subgraphId?: string | null
|
||||
}): string {
|
||||
return nodeData.subgraphId
|
||||
? `${nodeData.subgraphId}:${String(nodeData.id)}`
|
||||
: String(nodeData.id)
|
||||
}): NodeLocatorId {
|
||||
return createNodeLocatorId(nodeData.subgraphId ?? null, nodeData.id)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -352,7 +351,7 @@ export function getExecutionIdByNode(
|
||||
if (!node.graph) return null
|
||||
|
||||
if (node.graph === rootGraph || node.graph.isRootGraph) {
|
||||
return String(node.id)
|
||||
return createNodeExecutionId([node.id])
|
||||
}
|
||||
|
||||
const parentPath = findPartialExecutionPathToGraph(
|
||||
@@ -361,7 +360,7 @@ export function getExecutionIdByNode(
|
||||
)
|
||||
if (parentPath === undefined) return null
|
||||
|
||||
return `${parentPath}:${node.id}`
|
||||
return createNodeExecutionId([...parentPath.split(':'), node.id])
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -431,10 +430,14 @@ export function getExecutionIdForNodeInGraph(
|
||||
rootGraph: LGraph,
|
||||
graph: LGraph | Subgraph,
|
||||
nodeId: string | number
|
||||
): string {
|
||||
if (graph === rootGraph || graph.isRootGraph) return String(nodeId)
|
||||
): NodeExecutionId {
|
||||
if (graph === rootGraph || graph.isRootGraph) {
|
||||
return createNodeExecutionId([nodeId])
|
||||
}
|
||||
const parentPath = findPartialExecutionPathToGraph(graph as LGraph, rootGraph)
|
||||
return parentPath !== undefined ? `${parentPath}:${nodeId}` : String(nodeId)
|
||||
return parentPath !== undefined
|
||||
? createNodeExecutionId([...parentPath.split(':'), nodeId])
|
||||
: createNodeExecutionId([nodeId])
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -449,12 +452,13 @@ export function getExecutionIdForNodeInGraph(
|
||||
export function getExecutionIdFromNodeData(
|
||||
rootGraph: LGraph,
|
||||
nodeData: { id: string | number; subgraphId?: string | null }
|
||||
): string {
|
||||
): NodeExecutionId {
|
||||
const locatorId = getLocatorIdFromNodeData(nodeData)
|
||||
const node = getNodeByLocatorId(rootGraph, locatorId)
|
||||
return node
|
||||
? (getExecutionIdByNode(rootGraph, node) ?? String(nodeData.id))
|
||||
: String(nodeData.id)
|
||||
? (getExecutionIdByNode(rootGraph, node) ??
|
||||
createNodeExecutionId([nodeData.id]))
|
||||
: createNodeExecutionId([nodeData.id])
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -467,7 +471,7 @@ export function getExecutionIdFromNodeData(
|
||||
*/
|
||||
export function getNodeByLocatorId(
|
||||
rootGraph: LGraph,
|
||||
locatorId: NodeLocatorId | string
|
||||
locatorId: string
|
||||
): LGraphNode | null {
|
||||
if (!rootGraph) return null
|
||||
|
||||
@@ -504,7 +508,7 @@ export function executionIdToNodeLocatorId(
|
||||
|
||||
if (!nodeIdStr.includes(':')) {
|
||||
// It's a top-level node ID
|
||||
return nodeIdStr
|
||||
return createNodeLocatorId(null, nodeIdStr)
|
||||
}
|
||||
|
||||
// It's an execution node ID — resolve subgraph path
|
||||
@@ -734,7 +738,9 @@ export function getExecutionIdsForSelectedNodes(
|
||||
|
||||
const buildExecId = (node: LGraphNode, parentExecutionId: string) => {
|
||||
const nodeId = String(node.id)
|
||||
return parentExecutionId ? `${parentExecutionId}:${nodeId}` : nodeId
|
||||
return parentExecutionId
|
||||
? createNodeExecutionId([...parentExecutionId.split(':'), nodeId])
|
||||
: createNodeExecutionId([nodeId])
|
||||
}
|
||||
return collectFromNodes<NodeExecutionId, string>(selectedNodes, {
|
||||
collector: buildExecId,
|
||||
|
||||
Reference in New Issue
Block a user