mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 13:32:11 +00:00
Compare commits
3 Commits
ext-api/i-
...
glary/use-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dea1c51875 | ||
|
|
4eeb900e56 | ||
|
|
db617fa3cc |
@@ -2,8 +2,11 @@ import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
assertValidDownloadUrl,
|
||||
downloadFile,
|
||||
downloadFileAsync,
|
||||
extractFilenameFromContentDisposition,
|
||||
inferDownloadFilename,
|
||||
openFileInNewTab
|
||||
} from '@/base/common/downloadUtil'
|
||||
|
||||
@@ -214,9 +217,9 @@ describe('downloadUtil', () => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve() // let fetchAsBlob throw
|
||||
await Promise.resolve() // let .catch handler run
|
||||
expect(consoleSpy).toHaveBeenCalled()
|
||||
await vi.waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalled()
|
||||
})
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
@@ -312,6 +315,102 @@ describe('downloadUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadFileAsync', () => {
|
||||
it('resolves after blob download completes in cloud mode', async () => {
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
const headersMock = { get: vi.fn().mockReturnValue(null) }
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
})
|
||||
)
|
||||
|
||||
await downloadFileAsync(testUrl)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
expect(blobFn).toHaveBeenCalled()
|
||||
expect(createObjectURLSpy).toHaveBeenCalledWith(blob)
|
||||
expect(mockLink.click).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects when cloud fetch fails', async () => {
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/missing.bin'
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({ ok: false, status: 404, blob: vi.fn() })
|
||||
)
|
||||
|
||||
await expect(downloadFileAsync(testUrl)).rejects.toThrow(
|
||||
'Failed to download file'
|
||||
)
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves immediately for non-cloud downloads', async () => {
|
||||
mockIsCloud.value = false
|
||||
const testUrl = 'https://example.com/image.png'
|
||||
|
||||
await downloadFileAsync(testUrl)
|
||||
|
||||
expect(mockLink.href).toBe(testUrl)
|
||||
expect(mockLink.click).toHaveBeenCalled()
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws synchronously for invalid URLs', async () => {
|
||||
await expect(downloadFileAsync('')).rejects.toThrow(
|
||||
'Invalid URL provided for download'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertValidDownloadUrl', () => {
|
||||
it('throws for empty string', () => {
|
||||
expect(() => assertValidDownloadUrl('')).toThrow(
|
||||
'Invalid URL provided for download'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws for whitespace-only string', () => {
|
||||
expect(() => assertValidDownloadUrl(' ')).toThrow(
|
||||
'Invalid URL provided for download'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not throw for valid URL', () => {
|
||||
expect(() => assertValidDownloadUrl('https://example.com')).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('inferDownloadFilename', () => {
|
||||
it('prefers explicit filename over URL extraction', () => {
|
||||
expect(
|
||||
inferDownloadFilename(
|
||||
'https://example.com/file?filename=url-name.png',
|
||||
'explicit.png'
|
||||
)
|
||||
).toBe('explicit.png')
|
||||
})
|
||||
|
||||
it('falls back to URL filename parameter', () => {
|
||||
expect(
|
||||
inferDownloadFilename('https://example.com/file?filename=url-name.png')
|
||||
).toBe('url-name.png')
|
||||
})
|
||||
|
||||
it('falls back to default when no filename available', () => {
|
||||
expect(inferDownloadFilename('https://example.com/file')).toBe(
|
||||
'download.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('openFileInNewTab', () => {
|
||||
let windowOpenSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
|
||||
@@ -10,6 +10,16 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
// Constants
|
||||
const DEFAULT_DOWNLOAD_FILENAME = 'download.png'
|
||||
|
||||
export function assertValidDownloadUrl(url: string): void {
|
||||
if (!url || typeof url !== 'string' || url.trim().length === 0) {
|
||||
throw new Error('Invalid URL provided for download')
|
||||
}
|
||||
}
|
||||
|
||||
export function inferDownloadFilename(url: string, filename?: string): string {
|
||||
return filename || extractFilenameFromUrl(url) || DEFAULT_DOWNLOAD_FILENAME
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a download by creating a temporary anchor element
|
||||
* @param href - The URL or blob URL to download
|
||||
@@ -27,24 +37,41 @@ function triggerLinkDownload(href: string, filename: string): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from a URL by creating a temporary anchor element
|
||||
* Download a file from a URL by creating a temporary anchor element.
|
||||
* Fire-and-forget: errors on cloud blob fetches are logged but not thrown.
|
||||
* Use {@link downloadFileAsync} when you need to await completion or track loading state.
|
||||
* @param url - The URL of the file to download (must be a valid URL string)
|
||||
* @param filename - Optional filename override (will use URL filename or default if not provided)
|
||||
* @throws {Error} If the URL is invalid or empty
|
||||
*/
|
||||
export function downloadFile(url: string, filename?: string): void {
|
||||
if (!url || typeof url !== 'string' || url.trim().length === 0) {
|
||||
throw new Error('Invalid URL provided for download')
|
||||
}
|
||||
void downloadFileAsync(url, filename).catch((error) => {
|
||||
console.error('Failed to download file', error)
|
||||
})
|
||||
}
|
||||
|
||||
const inferredFilename =
|
||||
filename || extractFilenameFromUrl(url) || DEFAULT_DOWNLOAD_FILENAME
|
||||
/**
|
||||
* Async version of {@link downloadFile} that returns a Promise.
|
||||
* On cloud, the promise resolves after the blob fetch and browser download trigger complete.
|
||||
* On non-cloud, resolves immediately after triggering the anchor download.
|
||||
* Use this when you need to track download completion (e.g. for loading spinners).
|
||||
* @param url - The URL of the file to download (must be a valid URL string)
|
||||
* @param filename - Optional filename override
|
||||
* @throws {Error} If the URL is invalid or empty, or if the cloud blob fetch fails
|
||||
*/
|
||||
export async function downloadFileAsync(
|
||||
url: string,
|
||||
filename?: string
|
||||
): Promise<void> {
|
||||
assertValidDownloadUrl(url)
|
||||
const inferredFilename = inferDownloadFilename(url, filename)
|
||||
|
||||
if (isCloud) {
|
||||
// Assets from cross-origin (e.g., GCS) cannot be downloaded this way
|
||||
void downloadViaBlobFetch(url, inferredFilename).catch((error) => {
|
||||
console.error('Failed to download file', error)
|
||||
})
|
||||
try {
|
||||
await downloadViaBlobFetch(url, inferredFilename)
|
||||
} catch {
|
||||
throw new Error('Failed to download file')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
132
src/base/common/useDownloadFile.test.ts
Normal file
132
src/base/common/useDownloadFile.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useDownloadFile } from '@/base/common/useDownloadFile'
|
||||
|
||||
const { mockDownloadFileAsync } = vi.hoisted(() => ({
|
||||
mockDownloadFileAsync: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/base/common/downloadUtil', () => ({
|
||||
downloadFileAsync: mockDownloadFileAsync
|
||||
}))
|
||||
|
||||
describe('useDownloadFile', () => {
|
||||
beforeEach(() => {
|
||||
mockDownloadFileAsync.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('starts with isLoading false and no error', () => {
|
||||
const { isLoading, error } = useDownloadFile()
|
||||
expect(isLoading.value).toBe(false)
|
||||
expect(error.value).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sets isLoading true while download is in progress', async () => {
|
||||
let resolveDownload!: () => void
|
||||
mockDownloadFileAsync.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
resolveDownload = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const { isLoading, downloadIfIdle } = useDownloadFile()
|
||||
const promise = downloadIfIdle('https://example.com/file.png')
|
||||
|
||||
await nextTick()
|
||||
expect(isLoading.value).toBe(true)
|
||||
|
||||
resolveDownload()
|
||||
await promise
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('sets error ref when download fails', async () => {
|
||||
mockDownloadFileAsync.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { error, downloadIfIdle } = useDownloadFile()
|
||||
await downloadIfIdle('https://example.com/file.png')
|
||||
|
||||
expect(error.value).toBeInstanceOf(Error)
|
||||
expect(error.value?.message).toBe('Network error')
|
||||
})
|
||||
|
||||
it('clears error on next successful download', async () => {
|
||||
mockDownloadFileAsync.mockRejectedValueOnce(new Error('fail'))
|
||||
mockDownloadFileAsync.mockResolvedValueOnce(undefined)
|
||||
|
||||
const { error, isLoading, downloadIfIdle } = useDownloadFile()
|
||||
|
||||
await downloadIfIdle('https://example.com/a.png')
|
||||
expect(error.value).toBeDefined()
|
||||
expect(isLoading.value).toBe(false)
|
||||
|
||||
await downloadIfIdle('https://example.com/b.png')
|
||||
expect(error.value).toBeUndefined()
|
||||
})
|
||||
|
||||
it('passes url and filename to downloadFileAsync', async () => {
|
||||
mockDownloadFileAsync.mockResolvedValue(undefined)
|
||||
|
||||
const { downloadIfIdle } = useDownloadFile()
|
||||
await downloadIfIdle('https://example.com/file.png', 'custom.png')
|
||||
|
||||
expect(mockDownloadFileAsync).toHaveBeenCalledWith(
|
||||
'https://example.com/file.png',
|
||||
'custom.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores concurrent calls while download is in progress', async () => {
|
||||
let resolveDownload!: () => void
|
||||
mockDownloadFileAsync.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
resolveDownload = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const { isLoading, downloadIfIdle } = useDownloadFile()
|
||||
const promise = downloadIfIdle('https://example.com/a.png')
|
||||
await nextTick()
|
||||
expect(isLoading.value).toBe(true)
|
||||
|
||||
await downloadIfIdle('https://example.com/b.png')
|
||||
expect(mockDownloadFileAsync).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveDownload()
|
||||
await promise
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('tracks loading independently per instance', async () => {
|
||||
let resolveFirst!: () => void
|
||||
mockDownloadFileAsync
|
||||
.mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
|
||||
const first = useDownloadFile()
|
||||
const second = useDownloadFile()
|
||||
|
||||
const promise1 = first.downloadIfIdle('https://example.com/a.png')
|
||||
await nextTick()
|
||||
|
||||
expect(first.isLoading.value).toBe(true)
|
||||
expect(second.isLoading.value).toBe(false)
|
||||
|
||||
await second.downloadIfIdle('https://example.com/b.png')
|
||||
expect(second.isLoading.value).toBe(false)
|
||||
expect(first.isLoading.value).toBe(true)
|
||||
|
||||
resolveFirst()
|
||||
await promise1
|
||||
expect(first.isLoading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
29
src/base/common/useDownloadFile.ts
Normal file
29
src/base/common/useDownloadFile.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { downloadFileAsync } from '@/base/common/downloadUtil'
|
||||
|
||||
export function useDownloadFile(): {
|
||||
isLoading: Ref<boolean>
|
||||
error: Ref<Error | undefined>
|
||||
downloadIfIdle: (url: string, filename?: string) => Promise<void>
|
||||
} {
|
||||
const isLoading = ref(false)
|
||||
const error = ref<Error | undefined>()
|
||||
|
||||
async function downloadIfIdle(url: string, filename?: string): Promise<void> {
|
||||
if (isLoading.value) return
|
||||
error.value = undefined
|
||||
isLoading.value = true
|
||||
try {
|
||||
await downloadFileAsync(url, filename)
|
||||
} catch (e) {
|
||||
error.value =
|
||||
e instanceof Error ? e : new Error('Failed to download file')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { isLoading, error, downloadIfIdle }
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { inject } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue'
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { downloadFile, downloadFileAsync } from '@/base/common/downloadUtil'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useWorkflowActionsService } from '@/platform/workflow/core/services/workflowActionsService'
|
||||
@@ -71,6 +71,10 @@ export function useMediaAssetActions() {
|
||||
* 2+ assets or with any asset whose job has `outputCount > 1`.
|
||||
* Falls back to direct downloads in OSS mode and for single single-output
|
||||
* assets. With no argument, uses the asset from `MediaAssetKey` context.
|
||||
*
|
||||
* Single-asset downloads use the awaitable `downloadFileAsync` so toasts
|
||||
* reflect the actual blob fetch result (cloud) instead of just the
|
||||
* navigator dispatch.
|
||||
*/
|
||||
const downloadAssets = (assets?: AssetItem[]) => {
|
||||
const targetAssets =
|
||||
@@ -87,6 +91,31 @@ export function useMediaAssetActions() {
|
||||
return
|
||||
}
|
||||
|
||||
if (targetAssets.length === 1) {
|
||||
const asset = targetAssets[0]
|
||||
const filename = getAssetDisplayName(asset)
|
||||
const downloadUrl = asset.preview_url || getAssetUrl(asset)
|
||||
|
||||
downloadFileAsync(downloadUrl, filename).then(
|
||||
() => {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('mediaAsset.selection.downloadsStarted', 1),
|
||||
life: 2000
|
||||
})
|
||||
},
|
||||
() => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.failedToDownloadFile')
|
||||
})
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
targetAssets.forEach((asset) => {
|
||||
const filename = getAssetDisplayName(asset)
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { downloadFileAsync } from '@/base/common/downloadUtil'
|
||||
import { useDownloadFile } from '@/base/common/useDownloadFile'
|
||||
import Loader from '@/components/loader/Loader.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
|
||||
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
|
||||
@@ -23,7 +26,13 @@ import { app } from '@/scripts/app'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toastStore = useToastStore()
|
||||
const mediaActions = useMediaAssetActions()
|
||||
const {
|
||||
isLoading: downloading,
|
||||
error: downloadError,
|
||||
downloadIfIdle: download
|
||||
} = useDownloadFile()
|
||||
const { isBuilderMode, isArrangeMode } = useAppMode()
|
||||
const { allOutputs, isWorkflowActive, cancelActiveWorkflowJobs } =
|
||||
useOutputHistory()
|
||||
@@ -39,6 +48,16 @@ const canShowPreview = ref(true)
|
||||
const latentPreview = ref<string>()
|
||||
const showSkeleton = ref(false)
|
||||
|
||||
watch(downloadError, (err) => {
|
||||
if (err) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.failedToDownloadImage')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function handleSelection(sel: OutputSelection) {
|
||||
selectedItem.value = sel.asset
|
||||
selectedOutput.value = sel.output
|
||||
@@ -47,9 +66,35 @@ function handleSelection(sel: OutputSelection) {
|
||||
showSkeleton.value = sel.showSkeleton ?? false
|
||||
}
|
||||
|
||||
function downloadAsset(item?: AssetItem) {
|
||||
for (const output of allOutputs(item))
|
||||
downloadFile(output.url, output.filename)
|
||||
const downloadingAll = ref(false)
|
||||
|
||||
async function downloadAsset(item?: AssetItem) {
|
||||
if (downloadingAll.value) return
|
||||
|
||||
const outputs = allOutputs(item)
|
||||
if (outputs.length === 0) return
|
||||
|
||||
downloadingAll.value = true
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
outputs.map((output) => downloadFileAsync(output.url, output.filename))
|
||||
)
|
||||
|
||||
if (results.some((result) => result.status === 'rejected')) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.failedToDownloadFile')
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
downloadingAll.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSingleDownload() {
|
||||
if (!selectedOutput.value?.url) return
|
||||
void download(selectedOutput.value.url)
|
||||
}
|
||||
|
||||
async function loadWorkflow(item: AssetItem | undefined) {
|
||||
@@ -93,13 +138,11 @@ async function rerun(e: Event) {
|
||||
v-tooltip.top="t('g.download')"
|
||||
size="icon"
|
||||
:aria-label="t('g.download')"
|
||||
@click="
|
||||
() => {
|
||||
if (selectedOutput?.url) downloadFile(selectedOutput.url)
|
||||
}
|
||||
"
|
||||
:disabled="downloading"
|
||||
@click="handleSingleDownload"
|
||||
>
|
||||
<i class="icon-[lucide--download]" />
|
||||
<Loader v-if="downloading" size="sm" />
|
||||
<i v-else class="icon-[lucide--download]" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isWorkflowActive && !selectedItem"
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import VideoPreview from '@/renderer/extensions/vueNodes/VideoPreview.vue'
|
||||
|
||||
vi.mock('@/base/common/downloadUtil', () => ({
|
||||
downloadFile: vi.fn()
|
||||
downloadFileAsync: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
|
||||
@@ -64,9 +64,16 @@
|
||||
:class="actionButtonClass"
|
||||
:title="$t('g.downloadVideo')"
|
||||
:aria-label="$t('g.downloadVideo')"
|
||||
:disabled="downloading"
|
||||
@click="handleDownload"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
<i
|
||||
:class="
|
||||
downloading
|
||||
? 'icon-[lucide--loader-circle] size-4 animate-spin'
|
||||
: 'icon-[lucide--download] size-4'
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Close Button -->
|
||||
@@ -125,7 +132,7 @@ import Skeleton from 'primevue/skeleton'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { useDownloadFile } from '@/base/common/useDownloadFile'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
@@ -140,6 +147,23 @@ const props = defineProps<VideoPreviewProps>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const {
|
||||
isLoading: downloading,
|
||||
error: downloadError,
|
||||
downloadIfIdle: download
|
||||
} = useDownloadFile()
|
||||
|
||||
watch(downloadError, (err) => {
|
||||
if (err) {
|
||||
useToast().add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.failedToDownloadVideo'),
|
||||
life: 3000,
|
||||
group: 'video-preview'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const actionButtonClass =
|
||||
'flex h-8 min-h-8 items-center justify-center gap-2.5 rounded-lg border-0 bg-button-surface px-2 py-2 text-button-surface-contrast shadow-sm transition-colors duration-200 hover:bg-button-hover-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-button-surface-contrast focus-visible:ring-offset-2 focus-visible:ring-offset-transparent cursor-pointer'
|
||||
@@ -201,17 +225,7 @@ const handleVideoError = () => {
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
try {
|
||||
downloadFile(currentVideoUrl.value)
|
||||
} catch (error) {
|
||||
useToast().add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: t('g.failedToDownloadVideo'),
|
||||
life: 3000,
|
||||
group: 'video-preview'
|
||||
})
|
||||
}
|
||||
void download(currentVideoUrl.value)
|
||||
}
|
||||
|
||||
const handleRemove = () => {
|
||||
|
||||
@@ -7,12 +7,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { downloadFileAsync } from '@/base/common/downloadUtil'
|
||||
import ImagePreview from '@/renderer/extensions/vueNodes/components/ImagePreview.vue'
|
||||
|
||||
// Mock downloadFile to avoid DOM errors
|
||||
vi.mock('@/base/common/downloadUtil', () => ({
|
||||
downloadFile: vi.fn()
|
||||
downloadFileAsync: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
@@ -145,7 +144,10 @@ describe('ImagePreview', () => {
|
||||
})
|
||||
await user.click(downloadButton)
|
||||
|
||||
expect(downloadFile).toHaveBeenCalledWith(defaultProps.imageUrls[0])
|
||||
expect(downloadFileAsync).toHaveBeenCalledWith(
|
||||
defaultProps.imageUrls[0],
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('switches images when navigation dots are clicked', async () => {
|
||||
|
||||
@@ -94,9 +94,16 @@
|
||||
:class="actionButtonClass"
|
||||
:title="$t('g.downloadImage')"
|
||||
:aria-label="$t('g.downloadImage')"
|
||||
:disabled="downloading"
|
||||
@click="handleDownload"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
<i
|
||||
:class="
|
||||
downloading
|
||||
? 'icon-[lucide--loader-circle] size-4 animate-spin'
|
||||
: 'icon-[lucide--download] size-4'
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Back to Grid Button -->
|
||||
@@ -170,8 +177,8 @@ import { useTimeoutFn } from '@vueuse/core'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
import { useDownloadFile } from '@/base/common/useDownloadFile'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
@@ -191,6 +198,21 @@ const { t } = useI18n()
|
||||
const maskEditor = useMaskEditor()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const toastStore = useToastStore()
|
||||
const {
|
||||
isLoading: downloading,
|
||||
error: downloadError,
|
||||
downloadIfIdle: download
|
||||
} = useDownloadFile()
|
||||
|
||||
watch(downloadError, (err) => {
|
||||
if (err) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.failedToDownloadImage')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const actionButtonClass =
|
||||
'flex h-8 min-h-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground p-2 text-base-background shadow-interface transition-colors duration-200 hover:bg-base-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground focus-visible:ring-offset-2'
|
||||
@@ -288,15 +310,7 @@ function handleEditMask() {
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
try {
|
||||
downloadFile(currentImageUrl.value)
|
||||
} catch {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.failedToDownloadImage')
|
||||
})
|
||||
}
|
||||
void download(currentImageUrl.value)
|
||||
}
|
||||
|
||||
function setCurrentIndex(index: number) {
|
||||
|
||||
@@ -49,9 +49,16 @@
|
||||
<button
|
||||
:class="actionButtonClass"
|
||||
:aria-label="t('g.downloadImage')"
|
||||
:disabled="downloading"
|
||||
@click="handleDownload"
|
||||
>
|
||||
<i class="icon-[lucide--arrow-down-to-line] size-4" />
|
||||
<i
|
||||
:class="
|
||||
downloading
|
||||
? 'icon-[lucide--loader-circle] size-4 animate-spin'
|
||||
: 'icon-[lucide--arrow-down-to-line] size-4'
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
:class="actionButtonClass"
|
||||
@@ -163,7 +170,7 @@
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { useDownloadFile } from '@/base/common/useDownloadFile'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
@@ -197,6 +204,21 @@ const { t } = useI18n()
|
||||
const maskEditor = useMaskEditor()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const toastStore = useToastStore()
|
||||
const {
|
||||
isLoading: downloading,
|
||||
error: downloadError,
|
||||
downloadIfIdle: download
|
||||
} = useDownloadFile()
|
||||
|
||||
watch(downloadError, (err) => {
|
||||
if (err) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.failedToDownloadImage')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const activeIndex = ref(0)
|
||||
const displayMode = ref<DisplayMode>('single')
|
||||
@@ -349,15 +371,7 @@ function handleEditMask() {
|
||||
function handleDownload() {
|
||||
const src = activeItem.value ? getItemSrc(activeItem.value) : ''
|
||||
if (!src) return
|
||||
try {
|
||||
downloadFile(src)
|
||||
} catch {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.failedToDownloadImage')
|
||||
})
|
||||
}
|
||||
void download(src)
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
|
||||
@@ -13,7 +13,7 @@ vi.mock('primevue/usetoast', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/base/common/downloadUtil', () => ({
|
||||
downloadFile: vi.fn()
|
||||
downloadFileAsync: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
@@ -55,21 +55,24 @@ describe('AudioPreviewPlayer', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls downloadFile when download button is clicked', async () => {
|
||||
const { downloadFile } = await import('@/base/common/downloadUtil')
|
||||
it('calls downloadFileAsync when download button is clicked', async () => {
|
||||
const { downloadFileAsync } = await import('@/base/common/downloadUtil')
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderPlayer('http://example.com/audio.mp3')
|
||||
await user.click(screen.getByRole('button', { name: 'g.downloadAudio' }))
|
||||
|
||||
expect(downloadFile).toHaveBeenCalledWith('http://example.com/audio.mp3')
|
||||
expect(downloadFileAsync).toHaveBeenCalledWith(
|
||||
'http://example.com/audio.mp3',
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('shows toast on download failure', async () => {
|
||||
const { downloadFile } = await import('@/base/common/downloadUtil')
|
||||
vi.mocked(downloadFile).mockImplementation(() => {
|
||||
throw new Error('download failed')
|
||||
})
|
||||
const { downloadFileAsync } = await import('@/base/common/downloadUtil')
|
||||
vi.mocked(downloadFileAsync).mockRejectedValueOnce(
|
||||
new Error('download failed')
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderPlayer('http://example.com/audio.mp3')
|
||||
@@ -80,8 +83,6 @@ describe('AudioPreviewPlayer', () => {
|
||||
severity: 'error'
|
||||
})
|
||||
)
|
||||
|
||||
vi.mocked(downloadFile).mockReset()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -82,10 +82,17 @@
|
||||
variant="textonly"
|
||||
:aria-label="$t('g.downloadAudio')"
|
||||
:title="$t('g.downloadAudio')"
|
||||
:disabled="downloading"
|
||||
class="size-6 hover:bg-interface-menu-component-surface-hovered"
|
||||
@click="handleDownload"
|
||||
>
|
||||
<i class="text-secondary icon-[lucide--download] size-4" />
|
||||
<i
|
||||
:class="
|
||||
downloading
|
||||
? 'text-secondary icon-[lucide--loader-circle] size-4 animate-spin'
|
||||
: 'text-secondary icon-[lucide--download] size-4'
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<!-- Options Button -->
|
||||
@@ -146,20 +153,37 @@
|
||||
<script setup lang="ts">
|
||||
import Slider from 'primevue/slider'
|
||||
import TieredMenu from 'primevue/tieredmenu'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { whenever } from '@vueuse/core'
|
||||
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import { useDownloadFile } from '@/base/common/useDownloadFile'
|
||||
|
||||
import { formatTime } from '@/utils/formatUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const {
|
||||
isLoading: downloading,
|
||||
error: downloadError,
|
||||
downloadIfIdle: download
|
||||
} = useDownloadFile()
|
||||
|
||||
watch(downloadError, (err) => {
|
||||
if (err) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.failedToDownloadFile')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const { hideWhenEmpty = true, showOptionsButton } = defineProps<{
|
||||
hideWhenEmpty?: boolean
|
||||
@@ -202,15 +226,7 @@ const togglePlayPause = () => {
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!modelValue.value) return
|
||||
try {
|
||||
downloadFile(modelValue.value)
|
||||
} catch {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.failedToDownloadFile')
|
||||
})
|
||||
}
|
||||
void download(modelValue.value)
|
||||
}
|
||||
|
||||
const toggleMute = () => {
|
||||
|
||||
Reference in New Issue
Block a user