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,