diff --git a/browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts b/browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts
new file mode 100644
index 00000000000..d604602cea3
--- /dev/null
+++ b/browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts
@@ -0,0 +1,55 @@
+import { expect } from '@playwright/test'
+
+import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
+
+test.describe('Vue Nodes Image Preview', () => {
+ test.beforeEach(async ({ comfyPage }) => {
+ await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
+ await comfyPage.loadWorkflow('widgets/load_image_widget')
+ await comfyPage.vueNodes.waitForNodes()
+ })
+
+ async function loadImageOnNode(
+ comfyPage: Awaited<
+ ReturnType<(typeof test)['info']>
+ >['fixtures']['comfyPage']
+ ) {
+ const loadImageNode = (await comfyPage.getNodeRefsByType('LoadImage'))[0]
+ const { x, y } = await loadImageNode.getPosition()
+
+ await comfyPage.dragAndDropFile('image64x64.webp', {
+ dropPosition: { x, y }
+ })
+
+ const imagePreview = comfyPage.page.locator('.image-preview')
+ await expect(imagePreview).toBeVisible()
+ await expect(imagePreview.locator('img')).toBeVisible()
+ await expect(imagePreview).toContainText('x')
+
+ return imagePreview
+ }
+
+ test('opens mask editor from image preview button', async ({ comfyPage }) => {
+ const imagePreview = await loadImageOnNode(comfyPage)
+
+ await imagePreview.locator('[role="img"]').hover()
+ await comfyPage.page.getByLabel('Edit or mask image').click()
+
+ await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
+ })
+
+ test('shows image context menu options', async ({ comfyPage }) => {
+ await loadImageOnNode(comfyPage)
+
+ const nodeHeader = comfyPage.vueNodes.getNodeByTitle('Load Image')
+ await nodeHeader.click()
+ await nodeHeader.click({ button: 'right' })
+
+ const contextMenu = comfyPage.page.locator('.p-contextmenu')
+ await expect(contextMenu).toBeVisible()
+ await expect(contextMenu.getByText('Open Image')).toBeVisible()
+ await expect(contextMenu.getByText('Copy Image')).toBeVisible()
+ await expect(contextMenu.getByText('Save Image')).toBeVisible()
+ await expect(contextMenu.getByText('Open in Mask Editor')).toBeVisible()
+ })
+})
diff --git a/src/renderer/extensions/vueNodes/components/ImagePreview.vue b/src/renderer/extensions/vueNodes/components/ImagePreview.vue
index 5d1e5ad8eb7..73b63491a84 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(
@@ -209,6 +206,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 = () => {
@@ -218,19 +219,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 dfabad853a7..d7f1e6767e1 100644
--- a/src/stores/imagePreviewStore.test.ts
+++ b/src/stores/imagePreviewStore.test.ts
@@ -3,6 +3,7 @@ import { 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'
@@ -13,15 +14,20 @@ vi.mock('@/utils/litegraphUtil', () => ({
isVideoNode: vi.fn()
}))
+const mockGetNodeById = vi.fn()
+
vi.mock('@/scripts/app', () => ({
app: {
getPreviewFormatParam: vi.fn(() => '&format=test_webp'),
+ rootGraph: {
+ getNodeById: (...args: unknown[]) => mockGetNodeById(...args)
+ },
nodeOutputs: {} as Record,
nodePreviewImages: {} as Record
}
}))
-const createMockNode = (overrides: Partial = {}): LGraphNode =>
+const createMockNode = (overrides: Record = {}): LGraphNode =>
({
id: 1,
type: 'TestNode',
@@ -157,8 +163,7 @@ describe('imagePreviewStore getPreviewParam', () => {
it('should return empty string if output is animated', () => {
const store = useNodeOutputStore()
- vi.mocked(litegraphUtil.isAnimatedOutput).mockReturnValue(true)
- const node = createMockNode()
+ const node = createMockNode({ animatedImages: true })
const outputs = createMockOutputs([{ filename: 'img.png' }])
expect(store.getPreviewParam(node, outputs)).toBe('')
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
@@ -216,3 +221,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 4b49eb3bfe7..08740e826f8 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,
@@ -394,6 +395,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,
@@ -407,6 +434,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
setNodePreviewsByExecutionId,
setNodePreviewsByNodeId,
updateNodeImages,
+ syncLegacyNodeImgs,
// Cleanup
revokePreviewsByExecutionId,