mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-07 16:40:05 +00:00
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:
@@ -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 = () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user