From 0ac7ef2266d82b6df67aa6c2de61e642edad7764 Mon Sep 17 00:00:00 2001 From: bymyself Date: Fri, 16 Jan 2026 12:26:46 -0800 Subject: [PATCH] fix: sync node.imgs for legacy context menu in Vue Nodes mode Add syncLegacyNodeImgs store method to sync loaded image elements to node.imgs for backwards compatibility with legacy systems (Copy Image, Open Image, Save Image, Open in Mask Editor). - Only runs when vueNodesMode is enabled - Reuses already-loaded img element from Vue component (no duplicate loading) - Store owns the sync logic, component just hands off the element - Simplify mask editor handling to call composable directly Fixes missing context menu options on SaveImage vue node. Amp-Thread-ID: https://ampcode.com/threads/T-019bba3e-0ad8-754a-bd50-5cf17165d5a6 Co-authored-by: Amp --- .../vueNodes/components/ImagePreview.vue | 25 ++--- src/stores/imagePreviewStore.test.ts | 97 ++++++++++++++++++- src/stores/imagePreviewStore.ts | 28 ++++++ 3 files changed, 131 insertions(+), 19 deletions(-) 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,