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 <amp@ampcode.com>
This commit is contained in:
bymyself
2026-01-16 12:26:46 -08:00
parent be8916b4ce
commit 0ac7ef2266
3 changed files with 131 additions and 19 deletions

View File

@@ -40,7 +40,6 @@
<!-- Main Image -->
<img
v-if="!imageError"
ref="currentImageEl"
:src="currentImageUrl"
:alt="imageAltText"
class="block size-full object-contain pointer-events-none"
@@ -128,8 +127,8 @@ import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
interface ImagePreviewProps {
@@ -142,7 +141,6 @@ interface ImagePreviewProps {
const props = defineProps<ImagePreviewProps>()
const { t } = useI18n()
const commandStore = useCommandStore()
const nodeOutputStore = useNodeOutputStore()
const actionButtonClass =
@@ -156,7 +154,6 @@ const actualDimensions = ref<string | null>(null)
const imageError = ref(false)
const showLoader = ref(false)
const currentImageEl = ref<HTMLImageElement>()
const imageWrapperEl = ref<HTMLDivElement>()
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 = () => {

View File

@@ -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> = {}): LGraphNode =>
const createMockNode = (overrides: Record<string, unknown> = {}): 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)
})
})

View File

@@ -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,