Files
ComfyUI_frontend/src/composables/graph/useImageMenuOptions.ts
Christian Byrne a94574d379 fix: open image in new tab on cloud fetches as blob to avoid GCS auto-download (#9122)
## Summary

Fix "Open Image" on cloud opening a new tab that auto-downloads the
asset instead of displaying it inline.

## Changes

- **What**: Add `openFileInNewTab()` to `downloadUtil.ts` that fetches
cross-origin URLs as blobs before opening in a new tab, avoiding GCS
`Content-Disposition: attachment` redirects. Opens the blank tab
synchronously to preserve user-gesture activation (avoiding popup
blockers), then navigates to a blob URL once the fetch completes. Blob
URLs are revoked after 60s or immediately if the tab was closed. Update
both call sites (`useImageMenuOptions` and `litegraphService`) to use
the new function.

## Review Focus

- The synchronous `window.open('', '_blank')` before the async fetch is
intentional to preserve user-gesture context and avoid popup blockers.
- Blob URL revocation strategy: 60s timeout for successful opens,
immediate revoke if tab was closed, tab closed on fetch failure.
- Shared `fetchAsBlob()` helper is also used by the existing
`downloadViaBlobFetch`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9122-fix-open-image-in-new-tab-on-cloud-fetches-as-blob-to-avoid-GCS-auto-download-3106d73d365081a3bfa6eb7d77fde99f)
by [Unito](https://www.unito.io)
2026-02-23 21:28:16 -08:00

110 lines
2.8 KiB
TypeScript

import { useI18n } from 'vue-i18n'
import { downloadFile, openFileInNewTab } from '@/base/common/downloadUtil'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useCommandStore } from '@/stores/commandStore'
import type { MenuOption } from './useMoreOptionsMenu'
/**
* Composable for image-related menu operations
*/
export function useImageMenuOptions() {
const { t } = useI18n()
const openMaskEditor = () => {
const commandStore = useCommandStore()
void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor')
}
const openImage = (node: LGraphNode) => {
if (!node?.imgs?.length) return
const img = node.imgs[node.imageIndex ?? 0]
if (!img) return
const url = new URL(img.src)
url.searchParams.delete('preview')
void openFileInNewTab(url.toString())
}
const copyImage = async (node: LGraphNode) => {
if (!node?.imgs?.length) return
const img = node.imgs[node.imageIndex ?? 0]
if (!img) return
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) return
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
ctx.drawImage(img, 0, 0)
try {
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, 'image/png')
})
if (!blob) {
console.warn('Failed to create image blob')
return
}
// Check if clipboard API is available
if (!navigator.clipboard?.write) {
console.warn('Clipboard API not available')
return
}
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob })
])
} catch (error) {
console.error('Failed to copy image to clipboard:', error)
}
}
const saveImage = (node: LGraphNode) => {
if (!node?.imgs?.length) return
const img = node.imgs[node.imageIndex ?? 0]
if (!img) return
try {
const url = new URL(img.src)
url.searchParams.delete('preview')
downloadFile(url.toString())
} catch (error) {
console.error('Failed to save image:', error)
}
}
const getImageMenuOptions = (node: LGraphNode): MenuOption[] => {
if (!node?.imgs?.length) return []
return [
{
label: t('contextMenu.Open in Mask Editor'),
action: () => openMaskEditor()
},
{
label: t('contextMenu.Open Image'),
icon: 'icon-[lucide--external-link]',
action: () => openImage(node)
},
{
label: t('contextMenu.Copy Image'),
icon: 'icon-[lucide--copy]',
action: () => copyImage(node)
},
{
label: t('contextMenu.Save Image'),
icon: 'icon-[lucide--download]',
action: () => saveImage(node)
}
]
}
return {
getImageMenuOptions
}
}