diff --git a/browser_tests/tests/vueNodes/interactions/zoom.spec.ts b/browser_tests/tests/vueNodes/interactions/canvas/zoom.spec.ts similarity index 96% rename from browser_tests/tests/vueNodes/interactions/zoom.spec.ts rename to browser_tests/tests/vueNodes/interactions/canvas/zoom.spec.ts index b28caec40..b87309f10 100644 --- a/browser_tests/tests/vueNodes/interactions/zoom.spec.ts +++ b/browser_tests/tests/vueNodes/interactions/canvas/zoom.spec.ts @@ -1,7 +1,7 @@ import { comfyExpect as expect, comfyPageFixture as test -} from '../../../fixtures/ComfyPage' +} from '../../../../fixtures/ComfyPage' test.describe('Vue Nodes Zoom', () => { test.beforeEach(async ({ comfyPage }) => { diff --git a/browser_tests/tests/vueNodes/interactions/zoom.spec.ts-snapshots/zoomed-in-ctrl-shift-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/canvas/zoom.spec.ts-snapshots/zoomed-in-ctrl-shift-chromium-linux.png similarity index 100% rename from browser_tests/tests/vueNodes/interactions/zoom.spec.ts-snapshots/zoomed-in-ctrl-shift-chromium-linux.png rename to browser_tests/tests/vueNodes/interactions/canvas/zoom.spec.ts-snapshots/zoomed-in-ctrl-shift-chromium-linux.png diff --git a/browser_tests/tests/vueNodes/linkInteraction.spec.ts b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts similarity index 97% rename from browser_tests/tests/vueNodes/linkInteraction.spec.ts rename to browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts index 4f24ec9c5..ebc09cf2e 100644 --- a/browser_tests/tests/vueNodes/linkInteraction.spec.ts +++ b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts @@ -1,13 +1,13 @@ import type { Locator, Page } from '@playwright/test' -import type { NodeId } from '../../../src/platform/workflow/validation/schemas/workflowSchema' -import { getSlotKey } from '../../../src/renderer/core/layout/slots/slotIdentifier' +import type { NodeId } from '../../../../../src/platform/workflow/validation/schemas/workflowSchema' +import { getSlotKey } from '../../../../../src/renderer/core/layout/slots/slotIdentifier' import { comfyExpect as expect, comfyPageFixture as test -} from '../../fixtures/ComfyPage' -import { getMiddlePoint } from '../../fixtures/utils/litegraphUtils' -import { fitToViewInstant } from '../../helpers/fitToView' +} from '../../../../fixtures/ComfyPage' +import { getMiddlePoint } from '../../../../fixtures/utils/litegraphUtils' +import { fitToViewInstant } from '../../../../helpers/fitToView' async function getCenter(locator: Locator): Promise<{ x: number; y: number }> { const box = await locator.boundingBox() @@ -189,6 +189,13 @@ test.describe('Vue Node Link Interaction', () => { expect(await samplerOutput.getLinkCount()).toBe(0) expect(await clipInput.getLinkCount()).toBe(0) + + const graphLinkDetails = await getInputLinkDetails( + comfyPage.page, + clipNode.id, + 0 + ) + expect(graphLinkDetails).toBeNull() }) test('should not create a link when dropping onto a slot on the same node', async ({ @@ -218,7 +225,6 @@ test.describe('Vue Node Link Interaction', () => { const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0] const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0] expect(samplerNode && vaeNode).toBeTruthy() - const samplerOutputCenter = await getSlotCenter( comfyPage.page, samplerNode.id, diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png new file mode 100644 index 000000000..a30e4b2c0 Binary files /dev/null and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/deleteKeyInteraction.spec.ts b/browser_tests/tests/vueNodes/interactions/node/remove.spec.ts similarity index 98% rename from browser_tests/tests/vueNodes/deleteKeyInteraction.spec.ts rename to browser_tests/tests/vueNodes/interactions/node/remove.spec.ts index 51b52e7ce..e7a610643 100644 --- a/browser_tests/tests/vueNodes/deleteKeyInteraction.spec.ts +++ b/browser_tests/tests/vueNodes/interactions/node/remove.spec.ts @@ -1,6 +1,6 @@ import { expect } from '@playwright/test' -import { comfyPageFixture as test } from '../../fixtures/ComfyPage' +import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage' test.beforeEach(async ({ comfyPage }) => { await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') diff --git a/browser_tests/tests/vueNodes/interactions/node/rename.spec.ts b/browser_tests/tests/vueNodes/interactions/node/rename.spec.ts new file mode 100644 index 000000000..3984989e1 --- /dev/null +++ b/browser_tests/tests/vueNodes/interactions/node/rename.spec.ts @@ -0,0 +1,76 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../../../fixtures/ComfyPage' +import { VueNodeFixture } from '../../../../fixtures/utils/vueNodeFixtures' + +test.describe('Vue Nodes Renaming', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false) + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.setup() + }) + + test('should display node title', async ({ comfyPage }) => { + // Get the KSampler node from the default workflow + const nodes = await comfyPage.getNodeRefsByType('KSampler') + expect(nodes.length).toBeGreaterThanOrEqual(1) + + const node = nodes[0] + const vueNode = new VueNodeFixture(node, comfyPage.page) + + const title = await vueNode.getTitle() + expect(title).toBe('KSampler') + + // Verify title is visible in the header + const header = await vueNode.getHeader() + await expect(header).toContainText('KSampler') + }) + + test('should allow title renaming by double clicking on the node header', async ({ + comfyPage + }) => { + const nodes = await comfyPage.getNodeRefsByType('KSampler') + const node = nodes[0] + const vueNode = new VueNodeFixture(node, comfyPage.page) + + // Test renaming with Enter + await vueNode.setTitle('My Custom Sampler') + const newTitle = await vueNode.getTitle() + expect(newTitle).toBe('My Custom Sampler') + + // Verify the title is displayed + const header = await vueNode.getHeader() + await expect(header).toContainText('My Custom Sampler') + + // Test cancel with Escape + const titleElement = await vueNode.getTitleElement() + await titleElement.dblclick() + await comfyPage.nextFrame() + + // Type a different value but cancel + const input = (await vueNode.getHeader()).locator( + '[data-testid="node-title-input"]' + ) + await input.fill('This Should Be Cancelled') + await input.press('Escape') + await comfyPage.nextFrame() + + // Title should remain as the previously saved value + const titleAfterCancel = await vueNode.getTitle() + expect(titleAfterCancel).toBe('My Custom Sampler') + }) + + test('Double click node body does not trigger edit', async ({ + comfyPage + }) => { + const loadCheckpointNode = + comfyPage.vueNodes.getNodeByTitle('Load Checkpoint') + const nodeBbox = await loadCheckpointNode.boundingBox() + if (!nodeBbox) throw new Error('Node not found') + await loadCheckpointNode.dblclick() + + const editingTitleInput = comfyPage.page.getByTestId('node-title-input') + await expect(editingTitleInput).not.toBeVisible() + }) +}) diff --git a/browser_tests/tests/vueNodes/nodeInteractions/selectionState.spec.ts b/browser_tests/tests/vueNodes/interactions/node/select.spec.ts similarity index 93% rename from browser_tests/tests/vueNodes/nodeInteractions/selectionState.spec.ts rename to browser_tests/tests/vueNodes/interactions/node/select.spec.ts index 591c1d307..2af676589 100644 --- a/browser_tests/tests/vueNodes/nodeInteractions/selectionState.spec.ts +++ b/browser_tests/tests/vueNodes/interactions/node/select.spec.ts @@ -1,7 +1,7 @@ import { comfyExpect as expect, comfyPageFixture as test -} from '../../../fixtures/ComfyPage' +} from '../../../../fixtures/ComfyPage' test.beforeEach(async ({ comfyPage }) => { await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') @@ -15,7 +15,8 @@ test.describe('Vue Node Selection', () => { const modifiers = [ { key: 'Control', name: 'ctrl' }, - { key: 'Shift', name: 'shift' } + { key: 'Shift', name: 'shift' }, + { key: 'Meta', name: 'meta' } ] as const for (const { key: modifier, name } of modifiers) { diff --git a/browser_tests/tests/vueNodes/lod.spec.ts-snapshots/vue-nodes-default-chromium-linux.png b/browser_tests/tests/vueNodes/lod.spec.ts-snapshots/vue-nodes-default-chromium-linux.png deleted file mode 100644 index c9ab0ed1a..000000000 Binary files a/browser_tests/tests/vueNodes/lod.spec.ts-snapshots/vue-nodes-default-chromium-linux.png and /dev/null differ diff --git a/browser_tests/tests/vueNodes/lod.spec.ts-snapshots/vue-nodes-lod-active-chromium-linux.png b/browser_tests/tests/vueNodes/lod.spec.ts-snapshots/vue-nodes-lod-active-chromium-linux.png deleted file mode 100644 index 5dfa61c19..000000000 Binary files a/browser_tests/tests/vueNodes/lod.spec.ts-snapshots/vue-nodes-lod-active-chromium-linux.png and /dev/null differ diff --git a/browser_tests/tests/vueNodes/lod.spec.ts-snapshots/vue-nodes-lod-inactive-chromium-linux.png b/browser_tests/tests/vueNodes/lod.spec.ts-snapshots/vue-nodes-lod-inactive-chromium-linux.png deleted file mode 100644 index 55b9bce6f..000000000 Binary files a/browser_tests/tests/vueNodes/lod.spec.ts-snapshots/vue-nodes-lod-inactive-chromium-linux.png and /dev/null differ diff --git a/browser_tests/tests/vueNodes/NodeHeader.spec.ts b/browser_tests/tests/vueNodes/nodeStates/collapse.spec.ts similarity index 50% rename from browser_tests/tests/vueNodes/NodeHeader.spec.ts rename to browser_tests/tests/vueNodes/nodeStates/collapse.spec.ts index 83e6b8aae..a339a0a25 100644 --- a/browser_tests/tests/vueNodes/NodeHeader.spec.ts +++ b/browser_tests/tests/vueNodes/nodeStates/collapse.spec.ts @@ -1,80 +1,20 @@ import { comfyExpect as expect, comfyPageFixture as test -} from '../../fixtures/ComfyPage' -import { VueNodeFixture } from '../../fixtures/utils/vueNodeFixtures' +} from '../../../fixtures/ComfyPage' +import { VueNodeFixture } from '../../../fixtures/utils/vueNodeFixtures' -test.describe('NodeHeader', () => { +test.describe('Vue Node Collapse', () => { test.beforeEach(async ({ comfyPage }) => { - await comfyPage.setSetting('Comfy.UseNewMenu', 'Top') await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false) await comfyPage.setSetting('Comfy.EnableTooltips', true) await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) await comfyPage.setup() }) - test('displays node title', async ({ comfyPage }) => { - // Get the KSampler node from the default workflow - const nodes = await comfyPage.getNodeRefsByType('KSampler') - expect(nodes.length).toBeGreaterThanOrEqual(1) - - const node = nodes[0] - const vueNode = new VueNodeFixture(node, comfyPage.page) - - const title = await vueNode.getTitle() - expect(title).toBe('KSampler') - - // Verify title is visible in the header - const header = await vueNode.getHeader() - await expect(header).toContainText('KSampler') - }) - - test('allows title renaming', async ({ comfyPage }) => { - const nodes = await comfyPage.getNodeRefsByType('KSampler') - const node = nodes[0] - const vueNode = new VueNodeFixture(node, comfyPage.page) - - // Test renaming with Enter - await vueNode.setTitle('My Custom Sampler') - const newTitle = await vueNode.getTitle() - expect(newTitle).toBe('My Custom Sampler') - - // Verify the title is displayed - const header = await vueNode.getHeader() - await expect(header).toContainText('My Custom Sampler') - - // Test cancel with Escape - const titleElement = await vueNode.getTitleElement() - await titleElement.dblclick() - await comfyPage.nextFrame() - - // Type a different value but cancel - const input = (await vueNode.getHeader()).locator( - '[data-testid="node-title-input"]' - ) - await input.fill('This Should Be Cancelled') - await input.press('Escape') - await comfyPage.nextFrame() - - // Title should remain as the previously saved value - const titleAfterCancel = await vueNode.getTitle() - expect(titleAfterCancel).toBe('My Custom Sampler') - }) - - test('Double click node body does not trigger edit', async ({ + test('should allow collapsing node with collapse icon', async ({ comfyPage }) => { - const loadCheckpointNode = - comfyPage.vueNodes.getNodeByTitle('Load Checkpoint') - const nodeBbox = await loadCheckpointNode.boundingBox() - if (!nodeBbox) throw new Error('Node not found') - await loadCheckpointNode.dblclick() - - const editingTitleInput = comfyPage.page.getByTestId('node-title-input') - await expect(editingTitleInput).not.toBeVisible() - }) - - test('handles node collapsing', async ({ comfyPage }) => { // Get the KSampler node from the default workflow const nodes = await comfyPage.getNodeRefsByType('KSampler') const node = nodes[0] @@ -103,7 +43,7 @@ test.describe('NodeHeader', () => { expect(expandedSize.height).toBeGreaterThanOrEqual(collapsedSize.height) }) - test('shows collapse/expand icon state', async ({ comfyPage }) => { + test('should show collapse/expand icon state', async ({ comfyPage }) => { const nodes = await comfyPage.getNodeRefsByType('KSampler') const node = nodes[0] const vueNode = new VueNodeFixture(node, comfyPage.page) @@ -123,7 +63,9 @@ test.describe('NodeHeader', () => { expect(iconClass).toContain('pi-chevron-down') }) - test('preserves title when collapsing/expanding', async ({ comfyPage }) => { + test('should preserve title when collapsing/expanding', async ({ + comfyPage + }) => { const nodes = await comfyPage.getNodeRefsByType('KSampler') const node = nodes[0] const vueNode = new VueNodeFixture(node, comfyPage.page) diff --git a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts new file mode 100644 index 000000000..e61e3ca01 --- /dev/null +++ b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts @@ -0,0 +1,49 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../../fixtures/ComfyPage' + +test.describe('Vue Node Custom Colors', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Top') + await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true) + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.vueNodes.waitForNodes() + }) + + test('displays color picker button and allows color selection', async ({ + comfyPage + }) => { + const loadCheckpointNode = comfyPage.page.locator('[data-node-id]').filter({ + hasText: 'Load Checkpoint' + }) + await loadCheckpointNode.getByText('Load Checkpoint').click() + + await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click() + await comfyPage.page + .locator('.color-picker-container') + .locator('i[data-testid="blue"]') + .click() + + await expect(comfyPage.canvas).toHaveScreenshot( + 'vue-node-custom-color-blue.png' + ) + }) + + test('should load node colors from workflow', async ({ comfyPage }) => { + await comfyPage.loadWorkflow('nodes/every_node_color') + await expect(comfyPage.canvas).toHaveScreenshot( + 'vue-node-custom-colors-dark-all-colors.png' + ) + }) + + test('should show brightened node colors on light theme', async ({ + comfyPage + }) => { + await comfyPage.setSetting('Comfy.ColorPalette', 'light') + await comfyPage.loadWorkflow('nodes/every_node_color') + await expect(comfyPage.canvas).toHaveScreenshot( + 'vue-node-custom-colors-light-all-colors.png' + ) + }) +}) diff --git a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-color-blue-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-color-blue-chromium-linux.png new file mode 100644 index 000000000..64898a216 Binary files /dev/null and b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-color-blue-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-dark-all-colors-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-dark-all-colors-chromium-linux.png new file mode 100644 index 000000000..ac6a841ae Binary files /dev/null and b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-dark-all-colors-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-light-all-colors-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-light-all-colors-chromium-linux.png new file mode 100644 index 000000000..c89b8b240 Binary files /dev/null and b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-light-all-colors-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/lod.spec.ts b/browser_tests/tests/vueNodes/nodeStates/lod.spec.ts similarity index 95% rename from browser_tests/tests/vueNodes/lod.spec.ts rename to browser_tests/tests/vueNodes/nodeStates/lod.spec.ts index 2ed598ef8..f8c94aba5 100644 --- a/browser_tests/tests/vueNodes/lod.spec.ts +++ b/browser_tests/tests/vueNodes/nodeStates/lod.spec.ts @@ -1,6 +1,6 @@ import { expect } from '@playwright/test' -import { comfyPageFixture as test } from '../../fixtures/ComfyPage' +import { comfyPageFixture as test } from '../../../fixtures/ComfyPage' test.beforeEach(async ({ comfyPage }) => { await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') diff --git a/browser_tests/tests/vueNodes/nodeStates/lod.spec.ts-snapshots/vue-nodes-default-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/lod.spec.ts-snapshots/vue-nodes-default-chromium-linux.png new file mode 100644 index 000000000..cf8384a03 Binary files /dev/null and b/browser_tests/tests/vueNodes/nodeStates/lod.spec.ts-snapshots/vue-nodes-default-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeStates/lod.spec.ts-snapshots/vue-nodes-lod-active-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/lod.spec.ts-snapshots/vue-nodes-lod-active-chromium-linux.png new file mode 100644 index 000000000..ee051f5df Binary files /dev/null and b/browser_tests/tests/vueNodes/nodeStates/lod.spec.ts-snapshots/vue-nodes-lod-active-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeStates/lod.spec.ts-snapshots/vue-nodes-lod-inactive-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/lod.spec.ts-snapshots/vue-nodes-lod-inactive-chromium-linux.png new file mode 100644 index 000000000..cf8384a03 Binary files /dev/null and b/browser_tests/tests/vueNodes/nodeStates/lod.spec.ts-snapshots/vue-nodes-lod-inactive-chromium-linux.png differ diff --git a/package.json b/package.json index 75a7349ab..da9b83ebe 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@comfyorg/comfyui-frontend", "private": true, - "version": "1.28.2", + "version": "1.28.3", "type": "module", "repository": "https://github.com/Comfy-Org/ComfyUI_frontend", "homepage": "https://comfy.org", @@ -158,4 +158,4 @@ "zod": "^3.23.8", "zod-validation-error": "^3.3.0" } -} \ No newline at end of file +} diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index 260f4b9b6..6d86a0fec 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -93,6 +93,7 @@ import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vu import SideToolbar from '@/components/sidebar/SideToolbar.vue' import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue' import { useChainCallback } from '@/composables/functional/useChainCallback' +import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import { useViewportCulling } from '@/composables/graph/useViewportCulling' import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle' import { useNodeBadge } from '@/composables/node/useNodeBadge' @@ -103,7 +104,6 @@ import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph' import { usePaste } from '@/composables/usePaste' import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags' import { i18n, t } from '@/i18n' -import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings' import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings' import { useSettingStore } from '@/platform/settings/settingStore' @@ -189,8 +189,8 @@ watch( } ) -const allNodes = computed(() => - Array.from(vueNodeLifecycle.vueNodeData.value.values()) +const allNodes = computed((): VueNodeData[] => + Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? []) ) watchEffect(() => { @@ -225,7 +225,6 @@ watch( for (const n of comfyApp.graph.nodes) { if (!n.widgets) continue for (const w of n.widgets) { - // @ts-expect-error fixme ts strict error if (w[IS_CONTROL_WIDGET]) { updateControlWidgetLabel(w) if (w.linkedWidgets) { @@ -293,45 +292,36 @@ watch( { deep: true } ) -// Update node slot errors +// Update node slot errors for LiteGraph nodes +// (Vue nodes read from store directly) watch( () => executionStore.lastNodeErrors, (lastNodeErrors) => { - const removeSlotError = (node: LGraphNode) => { + if (!comfyApp.graph) return + + for (const node of comfyApp.graph.nodes) { + // Clear existing errors for (const slot of node.inputs) { delete slot.hasErrors } for (const slot of node.outputs) { delete slot.hasErrors } - } - for (const node of comfyApp.graph.nodes) { - removeSlotError(node) const nodeErrors = lastNodeErrors?.[node.id] if (!nodeErrors) continue const validErrors = nodeErrors.errors.filter( (error) => error.extra_info?.input_name !== undefined ) - const slotErrorsChanged = - validErrors.length > 0 && - validErrors.some((error) => { - const inputName = error.extra_info!.input_name! - const inputIndex = node.findInputSlot(inputName) - if (inputIndex !== -1) { - node.inputs[inputIndex].hasErrors = true - return true - } - return false - }) - // Trigger Vue node data update if slot errors changed - if (slotErrorsChanged && comfyApp.graph.onTrigger) { - comfyApp.graph.onTrigger('node:slot-errors:changed', { - nodeId: node.id - }) - } + validErrors.forEach((error) => { + const inputName = error.extra_info!.input_name! + const inputIndex = node.findInputSlot(inputName) + if (inputIndex !== -1) { + node.inputs[inputIndex].hasErrors = true + } + }) } comfyApp.canvas.draw(true, true) @@ -364,7 +354,6 @@ const loadCustomNodesI18n = async () => { const comfyAppReady = ref(false) const workflowPersistence = useWorkflowPersistence() -// @ts-expect-error fixme ts strict error useCanvasDrop(canvasRef) useLitegraphSettings() useNodeBadge() diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts index beaca8d4c..55b06cabd 100644 --- a/src/composables/graph/useGraphNodeManager.ts +++ b/src/composables/graph/useGraphNodeManager.ts @@ -41,6 +41,8 @@ export interface VueNodeData { collapsed?: boolean pinned?: boolean } + color?: string + bgcolor?: string } export interface GraphNodeManager { @@ -126,7 +128,9 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { widgets: safeWidgets, inputs: node.inputs ? [...node.inputs] : undefined, outputs: node.outputs ? [...node.outputs] : undefined, - flags: node.flags ? { ...node.flags } : undefined + flags: node.flags ? { ...node.flags } : undefined, + color: node.color || undefined, + bgcolor: node.bgcolor || undefined } } @@ -449,6 +453,24 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { ...currentData, mode: typeof event.newValue === 'number' ? event.newValue : 0 }) + break + case 'color': + vueNodeData.set(nodeId, { + ...currentData, + color: + typeof event.newValue === 'string' + ? event.newValue + : undefined + }) + break + case 'bgcolor': + vueNodeData.set(nodeId, { + ...currentData, + bgcolor: + typeof event.newValue === 'string' + ? event.newValue + : undefined + }) } } } else if ( diff --git a/src/composables/graph/useVueNodeLifecycle.ts b/src/composables/graph/useVueNodeLifecycle.ts index 84e095b5f..ff89dfc85 100644 --- a/src/composables/graph/useVueNodeLifecycle.ts +++ b/src/composables/graph/useVueNodeLifecycle.ts @@ -1,11 +1,8 @@ import { createSharedComposable } from '@vueuse/core' -import { readonly, ref, shallowRef, watch } from 'vue' +import { shallowRef, watch } from 'vue' import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager' -import type { - GraphNodeManager, - VueNodeData -} from '@/composables/graph/useGraphNodeManager' +import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager' import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags' import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' @@ -22,31 +19,19 @@ function useVueNodeLifecycleIndividual() { const { shouldRenderVueNodes } = useVueFeatureFlags() const nodeManager = shallowRef(null) - const cleanupNodeManager = shallowRef<(() => void) | null>(null) - // Sync management - const slotSync = shallowRef | null>(null) - const slotSyncStarted = ref(false) - const linkSync = shallowRef | null>(null) - - // Vue node data state - const vueNodeData = ref>(new Map()) - - // Trigger for forcing computed re-evaluation - const nodeDataTrigger = ref(0) + const { startSync } = useLayoutSync() + const linkSyncManager = useLinkLayoutSync() + const slotSyncManager = useSlotLayoutSync() const initializeNodeManager = () => { // Use canvas graph if available (handles subgraph contexts), fallback to app graph - const activeGraph = comfyApp.canvas?.graph || comfyApp.graph + const activeGraph = comfyApp.canvas?.graph if (!activeGraph || nodeManager.value) return // Initialize the core node manager const manager = useGraphNodeManager(activeGraph) nodeManager.value = manager - cleanupNodeManager.value = manager.cleanup - - // Use the manager's data maps - vueNodeData.value = manager.vueNodeData // Initialize layout system with existing nodes from active graph const nodes = activeGraph._nodes.map((node: LGraphNode) => ({ @@ -76,46 +61,29 @@ function useVueNodeLifecycleIndividual() { } // Initialize layout sync (one-way: Layout Store → LiteGraph) - const { startSync } = useLayoutSync() startSync(canvasStore.canvas) - // Initialize link layout sync for event-driven updates - const linkSyncManager = useLinkLayoutSync() - linkSync.value = linkSyncManager if (comfyApp.canvas) { linkSyncManager.start(comfyApp.canvas) } - - // Force computed properties to re-evaluate - nodeDataTrigger.value++ } const disposeNodeManagerAndSyncs = () => { if (!nodeManager.value) return try { - cleanupNodeManager.value?.() + nodeManager.value.cleanup() } catch { /* empty */ } nodeManager.value = null - cleanupNodeManager.value = null - // Clean up link layout sync - if (linkSync.value) { - linkSync.value.stop() - linkSync.value = null - } - - // Reset reactive maps to clean state - vueNodeData.value = new Map() + linkSyncManager.stop() } // Watch for Vue nodes enabled state changes watch( - () => - shouldRenderVueNodes.value && - Boolean(comfyApp.canvas?.graph || comfyApp.graph), + () => shouldRenderVueNodes.value && Boolean(comfyApp.canvas?.graph), (enabled) => { if (enabled) { initializeNodeManager() @@ -138,20 +106,14 @@ function useVueNodeLifecycleIndividual() { } // Switching to Vue - if (vueMode && slotSyncStarted.value) { - slotSync.value?.stop() - slotSyncStarted.value = false + if (vueMode) { + slotSyncManager.stop() } // Switching to LG const shouldRun = Boolean(canvas?.graph) && !vueMode - if (shouldRun && !slotSyncStarted.value && canvas) { - // Initialize slot sync if not already created - if (!slotSync.value) { - slotSync.value = useSlotLayoutSync() - } - const started = slotSync.value.attemptStart(canvas as LGraphCanvas) - slotSyncStarted.value = started + if (shouldRun && canvas) { + slotSyncManager.attemptStart(canvas as LGraphCanvas) } }, { immediate: true } @@ -159,26 +121,27 @@ function useVueNodeLifecycleIndividual() { // Handle case where Vue nodes are enabled but graph starts empty const setupEmptyGraphListener = () => { + const activeGraph = comfyApp.canvas?.graph if ( - shouldRenderVueNodes.value && - comfyApp.graph && - !nodeManager.value && - comfyApp.graph._nodes.length === 0 + !shouldRenderVueNodes.value || + nodeManager.value || + activeGraph?._nodes.length !== 0 ) { - const originalOnNodeAdded = comfyApp.graph.onNodeAdded - comfyApp.graph.onNodeAdded = function (node: LGraphNode) { - // Restore original handler - comfyApp.graph.onNodeAdded = originalOnNodeAdded + return + } + const originalOnNodeAdded = activeGraph.onNodeAdded + activeGraph.onNodeAdded = function (node: LGraphNode) { + // Restore original handler + activeGraph.onNodeAdded = originalOnNodeAdded - // Initialize node manager if needed - if (shouldRenderVueNodes.value && !nodeManager.value) { - initializeNodeManager() - } + // Initialize node manager if needed + if (shouldRenderVueNodes.value && !nodeManager.value) { + initializeNodeManager() + } - // Call original handler - if (originalOnNodeAdded) { - originalOnNodeAdded.call(this, node) - } + // Call original handler + if (originalOnNodeAdded) { + originalOnNodeAdded.call(this, node) } } } @@ -189,20 +152,12 @@ function useVueNodeLifecycleIndividual() { nodeManager.value.cleanup() nodeManager.value = null } - if (slotSyncStarted.value) { - slotSync.value?.stop() - slotSyncStarted.value = false - } - slotSync.value = null - if (linkSync.value) { - linkSync.value.stop() - linkSync.value = null - } + slotSyncManager.stop() + linkSyncManager.stop() } return { - vueNodeData, - nodeManager: readonly(nodeManager), + nodeManager, // Lifecycle methods initializeNodeManager, diff --git a/src/composables/useCanvasDrop.ts b/src/composables/useCanvasDrop.ts index 76db7e513..d3870b04b 100644 --- a/src/composables/useCanvasDrop.ts +++ b/src/composables/useCanvasDrop.ts @@ -14,7 +14,7 @@ import { useModelToNodeStore } from '@/stores/modelToNodeStore' import { ComfyNodeDefImpl } from '@/stores/nodeDefStore' import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes' -export const useCanvasDrop = (canvasRef: Ref) => { +export const useCanvasDrop = (canvasRef: Ref) => { const modelToNodeStore = useModelToNodeStore() const litegraphService = useLitegraphService() const workflowService = useWorkflowService() diff --git a/src/composables/usePragmaticDragAndDrop.ts b/src/composables/usePragmaticDragAndDrop.ts index 642b9e310..ed000b72e 100644 --- a/src/composables/usePragmaticDragAndDrop.ts +++ b/src/composables/usePragmaticDragAndDrop.ts @@ -2,19 +2,17 @@ import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter' -import { onBeforeUnmount, onMounted } from 'vue' +import { toValue } from 'vue' +import { type MaybeRefOrGetter, onBeforeUnmount, onMounted } from 'vue' export function usePragmaticDroppable( - dropTargetElement: HTMLElement | (() => HTMLElement), + dropTargetElement: MaybeRefOrGetter, options: Omit[0], 'element'> ) { let cleanup = () => {} onMounted(() => { - const element = - typeof dropTargetElement === 'function' - ? dropTargetElement() - : dropTargetElement + const element = toValue(dropTargetElement) if (!element) { return @@ -32,16 +30,13 @@ export function usePragmaticDroppable( } export function usePragmaticDraggable( - draggableElement: HTMLElement | (() => HTMLElement), + draggableElement: MaybeRefOrGetter, options: Omit[0], 'element'> ) { let cleanup = () => {} onMounted(() => { - const element = - typeof draggableElement === 'function' - ? draggableElement() - : draggableElement + const element = toValue(draggableElement) if (!element) { return @@ -51,6 +46,7 @@ export function usePragmaticDraggable( element, ...options }) + // TODO: Change to onScopeDispose }) onBeforeUnmount(() => { diff --git a/src/extensions/core/groupNode.ts b/src/extensions/core/groupNode.ts index 4b3ce78bc..df063ed0b 100644 --- a/src/extensions/core/groupNode.ts +++ b/src/extensions/core/groupNode.ts @@ -170,7 +170,7 @@ class GroupNodeBuilder { // Use the built in copyToClipboard function to generate the node data we need try { // @ts-expect-error fixme ts strict error - const serialised = serialise(this.nodes, app.canvas.graph) + const serialised = serialise(this.nodes, app.canvas?.graph) const config = JSON.parse(serialised) storeLinkTypes(config) diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index 2c952787d..22b71c66a 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -757,9 +757,7 @@ export class LGraphCanvas // Initialize link renderer if graph is available if (graph) { - this.linkRenderer = new LitegraphLinkAdapter(graph) - // Disable layout writes during render - this.linkRenderer.enableLayoutStoreWrites = false + this.linkRenderer = new LitegraphLinkAdapter(false) } this.linkConnector.events.addEventListener('link-created', () => @@ -1858,9 +1856,7 @@ export class LGraphCanvas newGraph.attachCanvas(this) // Re-initialize link renderer with new graph - this.linkRenderer = new LitegraphLinkAdapter(newGraph) - // Disable layout writes during render - this.linkRenderer.enableLayoutStoreWrites = false + this.linkRenderer = new LitegraphLinkAdapter(false) this.dispatch('litegraph:set-graph', { newGraph, oldGraph: graph }) this.#dirty() diff --git a/src/lib/litegraph/src/LGraphNodeProperties.ts b/src/lib/litegraph/src/LGraphNodeProperties.ts index 271d98e1e..f4a5d0c13 100644 --- a/src/lib/litegraph/src/LGraphNodeProperties.ts +++ b/src/lib/litegraph/src/LGraphNodeProperties.ts @@ -7,7 +7,9 @@ const DEFAULT_TRACKED_PROPERTIES: string[] = [ 'title', 'flags.collapsed', 'flags.pinned', - 'mode' + 'mode', + 'color', + 'bgcolor' ] /** diff --git a/src/lib/litegraph/src/types/widgets.ts b/src/lib/litegraph/src/types/widgets.ts index 850fe9bcb..1b01b66b0 100644 --- a/src/lib/litegraph/src/types/widgets.ts +++ b/src/lib/litegraph/src/types/widgets.ts @@ -251,6 +251,8 @@ export interface IBaseWidget< TType extends string = string, TOptions extends IWidgetOptions = IWidgetOptions > { + [symbol: symbol]: boolean + linkedWidgets?: IBaseWidget[] name: string diff --git a/src/renderer/core/canvas/canvasStore.ts b/src/renderer/core/canvas/canvasStore.ts index ec38940fe..4c5539204 100644 --- a/src/renderer/core/canvas/canvasStore.ts +++ b/src/renderer/core/canvas/canvasStore.ts @@ -116,7 +116,7 @@ export const useCanvasStore = defineStore('canvas', () => { newCanvas.canvas, 'litegraph:set-graph', (event: CustomEvent<{ newGraph: LGraph; oldGraph: LGraph }>) => { - const newGraph = event.detail?.newGraph || app.canvas?.graph + const newGraph = event.detail?.newGraph ?? app.canvas?.graph // TODO: Ambiguous Graph currentGraph.value = newGraph isInSubgraph.value = Boolean(app.canvas?.subgraph) } diff --git a/src/renderer/core/canvas/litegraph/litegraphLinkAdapter.ts b/src/renderer/core/canvas/litegraph/litegraphLinkAdapter.ts index 1bb3f7dae..da2e57b88 100644 --- a/src/renderer/core/canvas/litegraph/litegraphLinkAdapter.ts +++ b/src/renderer/core/canvas/litegraph/litegraphLinkAdapter.ts @@ -6,7 +6,6 @@ * rendering data that can be consumed by the PathRenderer. * Maintains backward compatibility with existing litegraph integration. */ -import type { LGraph } from '@/lib/litegraph/src/LGraph' import type { LLink } from '@/lib/litegraph/src/LLink' import type { Reroute } from '@/lib/litegraph/src/Reroute' import type { @@ -19,7 +18,6 @@ import { LinkMarkerShape, LinkRenderType } from '@/lib/litegraph/src/types/globalEnums' -import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations' import { type ArrowShape, CanvasPathRenderer, @@ -54,142 +52,10 @@ export interface LinkRenderContext { disabledPattern?: CanvasPattern | null } -interface LinkRenderOptions { - color?: CanvasColour - flow?: boolean - skipBorder?: boolean - disabled?: boolean -} - export class LitegraphLinkAdapter { - private graph: LGraph - private pathRenderer: CanvasPathRenderer - public enableLayoutStoreWrites = true + private readonly pathRenderer = new CanvasPathRenderer() - constructor(graph: LGraph) { - this.graph = graph - this.pathRenderer = new CanvasPathRenderer() - } - - /** - * Render a single link with all necessary data properly fetched - * Populates link.path for hit detection - */ - renderLink( - ctx: CanvasRenderingContext2D, - link: LLink, - context: LinkRenderContext, - options: LinkRenderOptions = {} - ): void { - // Get nodes from graph - const sourceNode = this.graph.getNodeById(link.origin_id) - const targetNode = this.graph.getNodeById(link.target_id) - - if (!sourceNode || !targetNode) { - console.warn(`Cannot render link ${link.id}: missing nodes`) - return - } - - // Get slots from nodes - const sourceSlot = sourceNode.outputs?.[link.origin_slot] - const targetSlot = targetNode.inputs?.[link.target_slot] - - if (!sourceSlot || !targetSlot) { - console.warn(`Cannot render link ${link.id}: missing slots`) - return - } - - // Get positions using layout tree data if available - const startPos = getSlotPosition( - sourceNode, - link.origin_slot, - false // output - ) - const endPos = getSlotPosition( - targetNode, - link.target_slot, - true // input - ) - - // Get directions from slots - const startDir = sourceSlot.dir || LinkDirection.RIGHT - const endDir = targetSlot.dir || LinkDirection.LEFT - - // Convert to pure render data - const linkData = this.convertToLinkRenderData( - link, - { x: startPos[0], y: startPos[1] }, - { x: endPos[0], y: endPos[1] }, - startDir, - endDir, - options - ) - - // Convert context - const pathContext = this.convertToPathRenderContext(context) - - // Render using pure renderer - const path = this.pathRenderer.drawLink(ctx, linkData, pathContext) - - // Store path for hit detection - link.path = path - - // Update layout store when writes are enabled (event-driven path) - if (this.enableLayoutStoreWrites && link.id !== -1) { - // Calculate bounds and center only when writing - const bounds = this.calculateLinkBounds(startPos, endPos, linkData) - const centerPos = linkData.centerPos || { - x: (startPos[0] + endPos[0]) / 2, - y: (startPos[1] + endPos[1]) / 2 - } - - layoutStore.updateLinkLayout(link.id, { - id: link.id, - path: path, - bounds: bounds, - centerPos: centerPos, - sourceNodeId: String(link.origin_id), - targetNodeId: String(link.target_id), - sourceSlot: link.origin_slot, - targetSlot: link.target_slot - }) - - // Also update segment layout for the whole link (null rerouteId means final segment) - layoutStore.updateLinkSegmentLayout(link.id, null, { - path: path, - bounds: bounds, - centerPos: centerPos - }) - } - } - - /** - * Convert litegraph link data to pure render format - */ - private convertToLinkRenderData( - link: LLink, - startPoint: Point, - endPoint: Point, - startDir: LinkDirection, - endDir: LinkDirection, - options: LinkRenderOptions - ): LinkRenderData { - return { - id: String(link.id), - startPoint, - endPoint, - startDirection: this.convertDirection(startDir), - endDirection: this.convertDirection(endDir), - color: options.color - ? String(options.color) - : link.color - ? String(link.color) - : undefined, - type: link.type !== undefined ? String(link.type) : undefined, - flow: options.flow || false, - disabled: options.disabled || false - } - } + constructor(public readonly enableLayoutStoreWrites = true) {} /** * Convert LinkDirection enum to Direction string diff --git a/src/renderer/core/layout/sync/useLayoutSync.ts b/src/renderer/core/layout/sync/useLayoutSync.ts index 2aee7974c..cdecabd49 100644 --- a/src/renderer/core/layout/sync/useLayoutSync.ts +++ b/src/renderer/core/layout/sync/useLayoutSync.ts @@ -5,7 +5,9 @@ * The layout store is the single source of truth. */ import { onUnmounted } from 'vue' +import { ref } from 'vue' +import type { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { layoutStore } from '@/renderer/core/layout/store/layoutStore' /** @@ -13,27 +15,27 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore' * This replaces the bidirectional sync with a one-way sync */ export function useLayoutSync() { - let unsubscribe: (() => void) | null = null + const unsubscribe = ref<() => void>() /** - * Start syncing from Layout system to LiteGraph - * This is one-way: Layout → LiteGraph only + * Start syncing from Layout → LiteGraph */ - function startSync(canvas: any) { + function startSync(canvas: ReturnType['canvas']) { if (!canvas?.graph) return + // Cancel last subscription + stopSync() // Subscribe to layout changes - unsubscribe = layoutStore.onChange((change) => { + unsubscribe.value = layoutStore.onChange((change) => { // Apply changes to LiteGraph regardless of source // The layout store is the single source of truth for (const nodeId of change.nodeIds) { const layout = layoutStore.getNodeLayoutRef(nodeId).value if (!layout) continue - const liteNode = canvas.graph.getNodeById(parseInt(nodeId)) + const liteNode = canvas.graph?.getNodeById(parseInt(nodeId)) if (!liteNode) continue - // Update position if changed if ( liteNode.pos[0] !== layout.position.x || liteNode.pos[1] !== layout.position.y @@ -42,7 +44,6 @@ export function useLayoutSync() { liteNode.pos[1] = layout.position.y } - // Update size if changed if ( liteNode.size[0] !== layout.size.width || liteNode.size[1] !== layout.size.height @@ -57,20 +58,12 @@ export function useLayoutSync() { }) } - /** - * Stop syncing - */ function stopSync() { - if (unsubscribe) { - unsubscribe() - unsubscribe = null - } + unsubscribe.value?.() + unsubscribe.value = undefined } - // Auto-cleanup on unmount - onUnmounted(() => { - stopSync() - }) + onUnmounted(stopSync) return { startSync, diff --git a/src/renderer/core/layout/sync/useLinkLayoutSync.ts b/src/renderer/core/layout/sync/useLinkLayoutSync.ts index fd6b3b19c..b39f97b45 100644 --- a/src/renderer/core/layout/sync/useLinkLayoutSync.ts +++ b/src/renderer/core/layout/sync/useLinkLayoutSync.ts @@ -1,14 +1,6 @@ -/** - * Composable for event-driven link layout synchronization - * - * Implements event-driven link layout updates decoupled from the render cycle. - * Updates link geometry only when it actually changes (node move/resize, link create/delete, - * reroute create/delete/move, collapse toggles). - */ -import log from 'loglevel' -import { onUnmounted } from 'vue' +import { tryOnScopeDispose } from '@vueuse/core' +import { computed, ref, toValue } from 'vue' -import type { LGraph } from '@/lib/litegraph/src/LGraph' import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas' import { LLink } from '@/lib/litegraph/src/LLink' import { Reroute } from '@/lib/litegraph/src/Reroute' @@ -20,23 +12,17 @@ import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculatio import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import type { LayoutChange } from '@/renderer/core/layout/types' -const logger = log.getLogger('useLinkLayoutSync') - -/** - * Composable for managing link layout synchronization - */ export function useLinkLayoutSync() { - let canvas: LGraphCanvas | null = null - let graph: LGraph | null = null - let offscreenCtx: CanvasRenderingContext2D | null = null - let adapter: LitegraphLinkAdapter | null = null - let unsubscribeLayoutChange: (() => void) | null = null - let restoreHandlers: (() => void) | null = null + const canvasRef = ref() + const graphRef = computed(() => canvasRef.value?.graph) + const unsubscribeLayoutChange = ref<() => void>() + const adapter = new LitegraphLinkAdapter() /** * Build link render context from canvas properties */ function buildLinkRenderContext(): LinkRenderContext { + const canvas = toValue(canvasRef) if (!canvas) { throw new Error('Canvas not initialized') } @@ -73,7 +59,9 @@ export function useLinkLayoutSync() { * - No dragging state handling (pure geometry computation) */ function recomputeLinkById(linkId: number): void { - if (!graph || !adapter || !offscreenCtx || !canvas) return + const canvas = toValue(canvasRef) + const graph = toValue(graphRef) + if (!graph || !canvas) return const link = graph.links.get(linkId) if (!link || link.id === -1) return // Skip floating/temp links @@ -131,7 +119,7 @@ export function useLinkLayoutSync() { // Render segment to this reroute adapter.renderLinkDirect( - offscreenCtx, + canvas.ctx, segmentStartPos, reroute.pos, link, @@ -167,7 +155,7 @@ export function useLinkLayoutSync() { ] adapter.renderLinkDirect( - offscreenCtx, + canvas.ctx, lastReroute.pos, endPos, link, @@ -185,7 +173,7 @@ export function useLinkLayoutSync() { } else { // No reroutes - render direct link adapter.renderLinkDirect( - offscreenCtx, + canvas.ctx, startPos, endPos, link, @@ -206,6 +194,7 @@ export function useLinkLayoutSync() { * Recompute all links connected to a node */ function recomputeLinksForNode(nodeId: number): void { + const graph = toValue(graphRef) if (!graph) return const node = graph.getNodeById(nodeId) @@ -243,6 +232,7 @@ export function useLinkLayoutSync() { * Recompute all links associated with a reroute */ function recomputeLinksForReroute(rerouteId: number): void { + const graph = toValue(graphRef) if (!graph) return const reroute = graph.reroutes.get(rerouteId) @@ -258,105 +248,55 @@ export function useLinkLayoutSync() { * Start link layout sync with event-driven functionality */ function start(canvasInstance: LGraphCanvas): void { - canvas = canvasInstance - graph = canvas.graph - if (!graph) return - - // Create offscreen canvas context - const offscreenCanvas = document.createElement('canvas') - offscreenCtx = offscreenCanvas.getContext('2d') - if (!offscreenCtx) { - logger.error('Failed to create offscreen canvas context') - return - } - - // Create dedicated adapter with layout writes enabled - adapter = new LitegraphLinkAdapter(graph) - adapter.enableLayoutStoreWrites = true + canvasRef.value = canvasInstance + if (!canvasInstance.graph) return // Initial computation for all existing links - for (const link of graph._links.values()) { + for (const link of canvasInstance.graph._links.values()) { if (link.id !== -1) { recomputeLinkById(link.id) } } // Subscribe to layout store changes - unsubscribeLayoutChange = layoutStore.onChange((change: LayoutChange) => { - switch (change.operation.type) { - case 'moveNode': - case 'resizeNode': - recomputeLinksForNode(parseInt(change.operation.nodeId)) - break - case 'createLink': - recomputeLinkById(change.operation.linkId) - break - case 'deleteLink': - // No-op - store already cleaned by existing code - break - case 'createReroute': - case 'deleteReroute': - // Recompute all affected links - if ('linkIds' in change.operation) { - for (const linkId of change.operation.linkIds) { - recomputeLinkById(linkId) + unsubscribeLayoutChange.value?.() + unsubscribeLayoutChange.value = layoutStore.onChange( + (change: LayoutChange) => { + switch (change.operation.type) { + case 'moveNode': + case 'resizeNode': + recomputeLinksForNode(parseInt(change.operation.nodeId)) + break + case 'createLink': + recomputeLinkById(change.operation.linkId) + break + case 'deleteLink': + // No-op - store already cleaned by existing code + break + case 'createReroute': + case 'deleteReroute': + // Recompute all affected links + if ('linkIds' in change.operation) { + for (const linkId of change.operation.linkIds) { + recomputeLinkById(linkId) + } } - } - break - case 'moveReroute': - recomputeLinksForReroute(change.operation.rerouteId) - break - } - }) - - // Hook collapse events - const origTrigger = graph.onTrigger - - graph.onTrigger = (action: string, param: any) => { - if ( - action === 'node:property:changed' && - param?.property === 'flags.collapsed' - ) { - const nodeId = parseInt(String(param.nodeId)) - if (!isNaN(nodeId)) { - recomputeLinksForNode(nodeId) + break + case 'moveReroute': + recomputeLinksForReroute(change.operation.rerouteId) + break } } - if (origTrigger) { - origTrigger.call(graph, action, param) - } - } - - // Store cleanup function - restoreHandlers = () => { - if (graph) { - graph.onTrigger = origTrigger || undefined - } - } + ) } - /** - * Stop link layout sync and cleanup all resources - */ function stop(): void { - if (unsubscribeLayoutChange) { - unsubscribeLayoutChange() - unsubscribeLayoutChange = null - } - if (restoreHandlers) { - restoreHandlers() - restoreHandlers = null - } - canvas = null - graph = null - offscreenCtx = null - adapter = null + unsubscribeLayoutChange.value?.() + unsubscribeLayoutChange.value = undefined + canvasRef.value = undefined } - // Auto-cleanup on unmount - onUnmounted(() => { - stop() - }) + tryOnScopeDispose(stop) return { start, diff --git a/src/renderer/core/layout/sync/useSlotLayoutSync.ts b/src/renderer/core/layout/sync/useSlotLayoutSync.ts index 281199e8b..099f2497d 100644 --- a/src/renderer/core/layout/sync/useSlotLayoutSync.ts +++ b/src/renderer/core/layout/sync/useSlotLayoutSync.ts @@ -1,10 +1,5 @@ -/** - * Composable for managing slot layout registration - * - * Implements event-driven slot registration decoupled from the draw cycle. - * Registers slots once on initial load and keeps them updated when necessary. - */ -import { onUnmounted } from 'vue' +import { tryOnScopeDispose } from '@vueuse/core' +import { ref } from 'vue' import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' @@ -13,10 +8,6 @@ import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotC import { registerNodeSlots } from '@/renderer/core/layout/slots/register' import { layoutStore } from '@/renderer/core/layout/store/layoutStore' -/** - * Compute and register slot layouts for a node - * @param node LiteGraph node to process - */ function computeAndRegisterSlots(node: LGraphNode): void { const nodeId = String(node.id) const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value @@ -50,12 +41,9 @@ function computeAndRegisterSlots(node: LGraphNode): void { registerNodeSlots(nodeId, context) } -/** - * Composable for managing slot layout registration - */ export function useSlotLayoutSync() { - let unsubscribeLayoutChange: (() => void) | null = null - let restoreHandlers: (() => void) | null = null + const unsubscribeLayoutChange = ref<() => void>() + const restoreHandlers = ref<() => void>() /** * Attempt to start slot layout sync with full event-driven functionality @@ -77,7 +65,8 @@ export function useSlotLayoutSync() { } // Layout changes → recompute slots for changed nodes - unsubscribeLayoutChange = layoutStore.onChange((change) => { + unsubscribeLayoutChange.value?.() + unsubscribeLayoutChange.value = layoutStore.onChange((change) => { for (const nodeId of change.nodeIds) { const node = graph.getNodeById(parseInt(nodeId)) if (node) { @@ -131,7 +120,7 @@ export function useSlotLayoutSync() { } // Store cleanup function - restoreHandlers = () => { + restoreHandlers.value = () => { graph.onNodeAdded = origNodeAdded || undefined graph.onNodeRemoved = origNodeRemoved || undefined // Only restore onTrigger if Vue nodes are not active @@ -145,24 +134,14 @@ export function useSlotLayoutSync() { return true } - /** - * Stop slot layout sync and cleanup all subscriptions - */ function stop(): void { - if (unsubscribeLayoutChange) { - unsubscribeLayoutChange() - unsubscribeLayoutChange = null - } - if (restoreHandlers) { - restoreHandlers() - restoreHandlers = null - } + unsubscribeLayoutChange.value?.() + unsubscribeLayoutChange.value = undefined + restoreHandlers.value?.() + restoreHandlers.value = undefined } - // Auto-cleanup on unmount - onUnmounted(() => { - stop() - }) + tryOnScopeDispose(stop) return { attemptStart, diff --git a/src/renderer/extensions/vueNodes/components/InputSlot.vue b/src/renderer/extensions/vueNodes/components/InputSlot.vue index 1e8387335..10de3a402 100644 --- a/src/renderer/extensions/vueNodes/components/InputSlot.vue +++ b/src/renderer/extensions/vueNodes/components/InputSlot.vue @@ -5,7 +5,7 @@ @@ -13,7 +13,9 @@
{{ slotData.localized_name || slotData.name || `Input ${index}` }} @@ -39,6 +41,7 @@ import type { INodeSlot } from '@/lib/litegraph/src/litegraph' import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips' import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking' import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction' +import { useExecutionStore } from '@/stores/executionStore' import { cn } from '@/utils/tailwindUtil' import LODFallback from './LODFallback.vue' @@ -57,7 +60,30 @@ interface InputSlotProps { const props = defineProps() -// Error boundary implementation +const executionStore = useExecutionStore() + +const hasSlotError = computed(() => { + const nodeErrors = executionStore.lastNodeErrors?.[props.nodeId ?? ''] + if (!nodeErrors) return false + + const slotName = props.slotData.name + return nodeErrors.errors.some( + (error) => error.extra_info?.input_name === slotName + ) +}) + +const errorClassesDot = computed(() => { + return hasSlotError.value + ? 'ring-2 ring-error dark-theme:ring-error ring-offset-0 rounded-full' + : '' +}) + +const labelClasses = computed(() => + hasSlotError.value + ? 'text-error dark-theme:text-error font-medium' + : 'dark-theme:text-slate-200 text-stone-200' +) + const renderError = ref(null) const { toastErrorHandler } = useErrorHandling() @@ -81,8 +107,12 @@ onErrorCaptured((error) => { return false }) -// Get slot color based on type -const slotColor = computed(() => getSlotColor(props.slotData.type)) +const slotColor = computed(() => { + if (hasSlotError.value) { + return 'var(--color-error)' + } + return getSlotColor(props.slotData.type) +}) const slotWrapperClass = computed(() => cn( @@ -103,8 +133,6 @@ const connectionDotRef = ref | null>(null) const slotElRef = ref(null) -// Watch for when the child component's ref becomes available -// Vue automatically unwraps the Ref when exposing it watchEffect(() => { const el = connectionDotRef.value?.slotElRef slotElRef.value = el || null diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue index a86d8de3a..a61111bcf 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNode.vue +++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue @@ -10,7 +10,7 @@ cn( 'bg-white dark-theme:bg-charcoal-800', 'lg-node absolute rounded-2xl', - 'border border-solid border-sand-100 dark-theme:border-charcoal-600', + 'border-2 border-solid border-sand-100 dark-theme:border-charcoal-600', // hover (only when node should handle events) shouldHandleNodePointerEvents && 'hover:ring-7 ring-gray-500/50 dark-theme:ring-gray-500/20', @@ -34,7 +34,9 @@ :style="[ { transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`, - zIndex: zIndex + zIndex: zIndex, + backgroundColor: nodeBodyBackgroundColor, + opacity: nodeOpacity }, dragStyle ]" @@ -47,9 +49,14 @@ - @@ -139,6 +150,7 @@ import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import { toggleNodeOptions } from '@/composables/graph/useMoreOptionsMenu' import { useErrorHandling } from '@/composables/useErrorHandling' import { LiteGraph } from '@/lib/litegraph/src/litegraph' +import { useSettingStore } from '@/platform/settings/settingStore' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' import { TransformStateKey } from '@/renderer/core/layout/injectionKeys' @@ -148,9 +160,11 @@ import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composable import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState' import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout' import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState' +import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils' import { app } from '@/scripts/app' import { useExecutionStore } from '@/stores/executionStore' import { useNodeOutputStore } from '@/stores/imagePreviewStore' +import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore' import { getLocatorIdFromNodeData, getNodeByLocatorId @@ -205,22 +219,35 @@ const hasExecutionError = computed( () => executionStore.lastExecutionErrorNodeId === nodeData.id ) -// Computed error states for styling const hasAnyError = computed((): boolean => { return !!( hasExecutionError.value || nodeData.hasErrors || error || - // Type assertions needed because VueNodeData.inputs/outputs are typed as unknown[] - // but at runtime they contain INodeInputSlot/INodeOutputSlot objects - nodeData.inputs?.some((slot) => slot?.hasErrors) || - nodeData.outputs?.some((slot) => slot?.hasErrors) + (executionStore.lastNodeErrors?.[nodeData.id]?.errors.length ?? 0) > 0 ) }) const bypassed = computed((): boolean => nodeData.mode === 4) const muted = computed((): boolean => nodeData.mode === 2) // NEVER mode +const nodeBodyBackgroundColor = computed(() => { + const colorPaletteStore = useColorPaletteStore() + + if (!nodeData.bgcolor) { + return '' + } + + return applyLightThemeColor( + nodeData.bgcolor, + Boolean(colorPaletteStore.completedActivePalette.light_theme) + ) +}) + +const nodeOpacity = computed( + () => useSettingStore().get('Comfy.Node.Opacity') ?? 1 +) + // Use canvas interactions for proper wheel event handling and pointer event capture control const { handleWheel, shouldHandleNodePointerEvents } = useCanvasInteractions() @@ -289,26 +316,19 @@ const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState( ) const borderClass = computed(() => { - if (hasAnyError.value) { - return 'border-error dark-theme:border-error' - } - if (executing.value) { - return 'border-blue-500' - } - return undefined + return ( + (hasAnyError.value && 'border-error dark-theme:border-error') || + (executing.value && 'border-blue-500') + ) }) const outlineClass = computed(() => { - if (!isSelected.value) { - return undefined - } - if (hasAnyError.value) { - return 'outline-error dark-theme:outline-error' - } - if (executing.value) { - return 'outline-blue-500 dark-theme:outline-blue-500' - } - return 'outline-black dark-theme:outline-white' + return ( + isSelected.value && + ((hasAnyError.value && 'outline-error dark-theme:outline-error') || + (executing.value && 'outline-blue-500 dark-theme:outline-blue-500') || + 'outline-black dark-theme:outline-white') + ) }) // Event handlers diff --git a/src/renderer/extensions/vueNodes/components/NodeHeader.vue b/src/renderer/extensions/vueNodes/components/NodeHeader.vue index d625a79e5..4f84ff531 100644 --- a/src/renderer/extensions/vueNodes/components/NodeHeader.vue +++ b/src/renderer/extensions/vueNodes/components/NodeHeader.vue @@ -4,7 +4,8 @@
@@ -71,8 +72,11 @@ import EditableText from '@/components/common/EditableText.vue' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import { useErrorHandling } from '@/composables/useErrorHandling' import { st } from '@/i18n' +import { useSettingStore } from '@/platform/settings/settingStore' import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips' +import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils' import { app } from '@/scripts/app' +import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore' import { normalizeI18nKey } from '@/utils/formatUtil' import { getLocatorIdFromNodeData, @@ -123,6 +127,23 @@ const tooltipConfig = computed(() => { return createTooltipConfig(description) }) +const headerStyle = computed(() => { + const colorPaletteStore = useColorPaletteStore() + + const opacity = useSettingStore().get('Comfy.Node.Opacity') ?? 1 + + if (!nodeData?.color) { + return { backgroundColor: '', opacity } + } + + const headerColor = applyLightThemeColor( + nodeData.color, + Boolean(colorPaletteStore.completedActivePalette.light_theme) + ) + + return { backgroundColor: headerColor, opacity } +}) + const resolveTitle = (info: VueNodeData | undefined) => { const title = (info?.title ?? '').trim() if (title.length > 0) return title diff --git a/src/renderer/extensions/vueNodes/utils/nodeStyleUtils.ts b/src/renderer/extensions/vueNodes/utils/nodeStyleUtils.ts new file mode 100644 index 000000000..143684d13 --- /dev/null +++ b/src/renderer/extensions/vueNodes/utils/nodeStyleUtils.ts @@ -0,0 +1,14 @@ +import { adjustColor } from '@/utils/colorUtil' + +/** + * Applies light theme color adjustments to a color + */ +export function applyLightThemeColor( + color: string, + isLightTheme: boolean +): string { + if (!color || !isLightTheme) { + return color + } + return adjustColor(color, { lightness: 0.5 }) +} diff --git a/src/scripts/app.ts b/src/scripts/app.ts index d7583724a..49423436d 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -157,11 +157,22 @@ export class ComfyApp { // @ts-expect-error fixme ts strict error _nodeOutputs: Record nodePreviewImages: Record - // @ts-expect-error fixme ts strict error - #graph: LGraph + + private rootGraphInternal: LGraph | undefined + + // TODO: Migrate internal usage to the + /** @deprecated Use {@link rootGraph} instead */ get graph() { - return this.#graph + return this.rootGraphInternal! } + + get rootGraph(): LGraph | undefined { + if (!this.rootGraphInternal) { + console.error('ComfyApp graph accessed before initialization') + } + return this.rootGraphInternal + } + // @ts-expect-error fixme ts strict error canvas: LGraphCanvas dragOverNode: LGraphNode | null = null @@ -765,8 +776,7 @@ export class ComfyApp { } } - #addAfterConfigureHandler() { - const { graph } = this + private addAfterConfigureHandler(graph: LGraph) { const { onConfigure } = graph graph.onConfigure = function (...args) { fixLinkInputSlots(this) @@ -809,10 +819,10 @@ export class ComfyApp { this.#addConfigureHandler() this.#addApiUpdateHandlers() - this.#graph = new LGraph() + const graph = new LGraph() // Register the subgraph - adds type wrapper for Litegraph's `createNode` factory - this.graph.events.addEventListener('subgraph-created', (e) => { + graph.events.addEventListener('subgraph-created', (e) => { try { const { subgraph, data } = e.detail useSubgraphService().registerNewSubgraph(subgraph, data) @@ -826,9 +836,10 @@ export class ComfyApp { } }) - this.#addAfterConfigureHandler() + this.addAfterConfigureHandler(graph) - this.canvas = new LGraphCanvas(canvasEl, this.graph) + this.rootGraphInternal = graph + this.canvas = new LGraphCanvas(canvasEl, graph) // Make canvas states reactive so we can observe changes on them. this.canvas.state = reactive(this.canvas.state) diff --git a/src/scripts/widgets.ts b/src/scripts/widgets.ts index 33b20988b..d87f650dd 100644 --- a/src/scripts/widgets.ts +++ b/src/scripts/widgets.ts @@ -140,7 +140,6 @@ export function addValueControlWidgets( valueControl.tooltip = 'Allows the linked widget to be changed automatically, for example randomizing the noise seed.' - // @ts-ignore index with symbol valueControl[IS_CONTROL_WIDGET] = true updateControlWidgetLabel(valueControl) const widgets: [IComboWidget, ...IStringWidget[]] = [valueControl] @@ -273,12 +272,10 @@ export function addValueControlWidgets( valueControl.beforeQueued = () => { if (controlValueRunBefore()) { // Don't run on first execution - // @ts-ignore index with symbol if (valueControl[HAS_EXECUTED]) { applyWidgetControl() } } - // @ts-ignore index with symbol valueControl[HAS_EXECUTED] = true }