diff --git a/src/renderer/extensions/vueNodes/components/ImagePreview.vue b/src/renderer/extensions/vueNodes/components/ImagePreview.vue index a8b0908c1..c97c3579e 100644 --- a/src/renderer/extensions/vueNodes/components/ImagePreview.vue +++ b/src/renderer/extensions/vueNodes/components/ImagePreview.vue @@ -40,7 +40,6 @@ () const { t } = useI18n() -const commandStore = useCommandStore() const nodeOutputStore = useNodeOutputStore() const actionButtonClass = @@ -156,7 +154,6 @@ const actualDimensions = ref(null) const imageError = ref(false) const showLoader = ref(false) -const currentImageEl = ref() const imageWrapperEl = ref() const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn( @@ -201,6 +198,10 @@ const handleImageLoad = (event: Event) => { if (img.naturalWidth && img.naturalHeight) { actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}` } + + if (props.nodeId) { + nodeOutputStore.syncLegacyNodeImgs(props.nodeId, img, currentIndex.value) + } } const handleImageError = () => { @@ -210,19 +211,11 @@ const handleImageError = () => { actualDimensions.value = null } -// In vueNodes mode, we need to set them manually before opening the mask editor. -const setupNodeForMaskEditor = () => { - if (!props.nodeId || !currentImageEl.value) return - const node = app.rootGraph?.getNodeById(props.nodeId) - if (!node) return - node.imageIndex = currentIndex.value - node.imgs = [currentImageEl.value] - app.canvas?.select(node) -} - const handleEditMask = () => { - setupNodeForMaskEditor() - void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor') + if (!props.nodeId) return + const node = app.rootGraph?.getNodeById(Number(props.nodeId)) + if (!node) return + useMaskEditor().openMaskEditor(node) } const handleDownload = () => { diff --git a/src/stores/imagePreviewStore.test.ts b/src/stores/imagePreviewStore.test.ts index d2bb8d639..d77c90723 100644 --- a/src/stores/imagePreviewStore.test.ts +++ b/src/stores/imagePreviewStore.test.ts @@ -2,6 +2,7 @@ import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import { LiteGraph } from '@/lib/litegraph/src/litegraph' import type { ExecutedWsMessage } from '@/schemas/apiSchema' import { app } from '@/scripts/app' import { useNodeOutputStore } from '@/stores/imagePreviewStore' @@ -11,13 +12,18 @@ vi.mock('@/utils/litegraphUtil', () => ({ isVideoNode: vi.fn() })) +const mockGetNodeById = vi.fn() + vi.mock('@/scripts/app', () => ({ app: { - getPreviewFormatParam: vi.fn(() => '&format=test_webp') + getPreviewFormatParam: vi.fn(() => '&format=test_webp'), + rootGraph: { + getNodeById: (...args: unknown[]) => mockGetNodeById(...args) + } } })) -const createMockNode = (overrides: Partial = {}): LGraphNode => +const createMockNode = (overrides: Record = {}): LGraphNode => ({ id: 1, type: 'TestNode', @@ -37,7 +43,6 @@ describe('imagePreviewStore getPreviewParam', () => { it('should return empty string if node.animatedImages is true', () => { const store = useNodeOutputStore() - // @ts-expect-error `animatedImages` property is not typed const node = createMockNode({ animatedImages: true }) const outputs = createMockOutputs([{ filename: 'img.png' }]) expect(store.getPreviewParam(node, outputs)).toBe('') @@ -96,3 +101,89 @@ describe('imagePreviewStore getPreviewParam', () => { expect(vi.mocked(app).getPreviewFormatParam).toHaveBeenCalledTimes(1) }) }) + +describe('imagePreviewStore syncLegacyNodeImgs', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + LiteGraph.vueNodesMode = false + }) + + it('should not sync when vueNodesMode is disabled', () => { + const store = useNodeOutputStore() + const mockNode = createMockNode({ id: 1 }) + const mockImg = document.createElement('img') + + mockGetNodeById.mockReturnValue(mockNode) + + store.syncLegacyNodeImgs(1, mockImg, 0) + + expect(mockNode.imgs).toBeUndefined() + expect(mockNode.imageIndex).toBeUndefined() + }) + + it('should sync node.imgs when vueNodesMode is enabled', () => { + LiteGraph.vueNodesMode = true + const store = useNodeOutputStore() + const mockNode = createMockNode({ id: 1 }) + const mockImg = document.createElement('img') + + mockGetNodeById.mockReturnValue(mockNode) + + store.syncLegacyNodeImgs(1, mockImg, 0) + + expect(mockNode.imgs).toEqual([mockImg]) + expect(mockNode.imageIndex).toBe(0) + }) + + it('should sync with correct activeIndex', () => { + LiteGraph.vueNodesMode = true + const store = useNodeOutputStore() + const mockNode = createMockNode({ id: 42 }) + const mockImg = document.createElement('img') + + mockGetNodeById.mockReturnValue(mockNode) + + store.syncLegacyNodeImgs(42, mockImg, 3) + + expect(mockNode.imgs).toEqual([mockImg]) + expect(mockNode.imageIndex).toBe(3) + }) + + it('should handle string nodeId', () => { + LiteGraph.vueNodesMode = true + const store = useNodeOutputStore() + const mockNode = createMockNode({ id: 123 }) + const mockImg = document.createElement('img') + + mockGetNodeById.mockReturnValue(mockNode) + + store.syncLegacyNodeImgs('123', mockImg, 0) + + expect(mockGetNodeById).toHaveBeenCalledWith(123) + expect(mockNode.imgs).toEqual([mockImg]) + }) + + it('should not throw when node is not found', () => { + LiteGraph.vueNodesMode = true + const store = useNodeOutputStore() + const mockImg = document.createElement('img') + + mockGetNodeById.mockReturnValue(undefined) + + expect(() => store.syncLegacyNodeImgs(999, mockImg, 0)).not.toThrow() + }) + + it('should default activeIndex to 0', () => { + LiteGraph.vueNodesMode = true + const store = useNodeOutputStore() + const mockNode = createMockNode({ id: 1 }) + const mockImg = document.createElement('img') + + mockGetNodeById.mockReturnValue(mockNode) + + store.syncLegacyNodeImgs(1, mockImg) + + expect(mockNode.imageIndex).toBe(0) + }) +}) diff --git a/src/stores/imagePreviewStore.ts b/src/stores/imagePreviewStore.ts index db2c9abaa..6702422be 100644 --- a/src/stores/imagePreviewStore.ts +++ b/src/stores/imagePreviewStore.ts @@ -3,6 +3,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import type { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph' +import { LiteGraph } from '@/lib/litegraph/src/litegraph' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import type { ExecutedWsMessage, @@ -364,6 +365,32 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { revokeAllPreviews() } + /** + * Sync legacy node.imgs property for backwards compatibility. + * + * In Vue Nodes mode, legacy systems (Copy Image, Open Image, Save Image, + * Open in Mask Editor) rely on `node.imgs` containing HTMLImageElement + * references. Since Vue handles image rendering, we need to sync the + * already-loaded element from the Vue component to the node. + * + * @param nodeId - The node ID + * @param element - The loaded HTMLImageElement from the Vue component + * @param activeIndex - The current image index (for multi-image outputs) + */ + function syncLegacyNodeImgs( + nodeId: string | number, + element: HTMLImageElement, + activeIndex: number = 0 + ) { + if (!LiteGraph.vueNodesMode) return + + const node = app.rootGraph?.getNodeById(Number(nodeId)) + if (!node) return + + node.imgs = [element] + node.imageIndex = activeIndex + } + return { // Getters getNodeOutputs, @@ -377,6 +404,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { setNodePreviewsByExecutionId, setNodePreviewsByNodeId, updateNodeImages, + syncLegacyNodeImgs, // Cleanup revokePreviewsByExecutionId,