mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
[backport core/1.42] fix: handle clipboard errors in Copy Image and useCopyToClipboard (#10524)
Backport of #9299 to `core/1.42` Automatically created by backport workflow. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10524-backport-core-1-42-fix-handle-clipboard-errors-in-Copy-Image-and-useCopyToClipboard-32e6d73d3650813eb88ac841d236c192) by [Unito](https://www.unito.io) Co-authored-by: Christian Byrne <cbyrne@comfy.org>
This commit is contained in:
94
src/composables/useCopyToClipboard.test.ts
Normal file
94
src/composables/useCopyToClipboard.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockCopy = vi.fn()
|
||||
const mockToastAdd = vi.fn()
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useClipboard: vi.fn(() => ({
|
||||
copy: mockCopy,
|
||||
copied: ref(false),
|
||||
isSupported: computed(() => true)
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: vi.fn(() => ({
|
||||
add: mockToastAdd
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
|
||||
describe('useCopyToClipboard', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.mocked(useClipboard).mockReturnValue({
|
||||
copy: mockCopy,
|
||||
copied: ref(false),
|
||||
isSupported: computed(() => true),
|
||||
text: ref('')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows success toast when modern clipboard succeeds', async () => {
|
||||
mockCopy.mockResolvedValue(undefined)
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
await copyToClipboard('hello')
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('hello')
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to legacy when modern clipboard fails', async () => {
|
||||
mockCopy.mockRejectedValue(new Error('Not allowed'))
|
||||
document.execCommand = vi.fn(() => true)
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
await copyToClipboard('hello')
|
||||
|
||||
expect(document.execCommand).toHaveBeenCalledWith('copy')
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
|
||||
it('shows error toast when both modern and legacy fail', async () => {
|
||||
mockCopy.mockRejectedValue(new Error('Not allowed'))
|
||||
document.execCommand = vi.fn(() => false)
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
await copyToClipboard('hello')
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
)
|
||||
})
|
||||
|
||||
it('falls through to legacy when isSupported is false', async () => {
|
||||
vi.mocked(useClipboard).mockReturnValue({
|
||||
copy: mockCopy,
|
||||
copied: ref(false),
|
||||
isSupported: computed(() => false),
|
||||
text: ref('')
|
||||
})
|
||||
document.execCommand = vi.fn(() => true)
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
await copyToClipboard('hello')
|
||||
|
||||
expect(mockCopy).not.toHaveBeenCalled()
|
||||
expect(document.execCommand).toHaveBeenCalledWith('copy')
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -3,34 +3,60 @@ import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
|
||||
function legacyCopy(text: string): boolean {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.value = text
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.left = '-9999px'
|
||||
textarea.style.top = '-9999px'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
try {
|
||||
return document.execCommand('copy')
|
||||
} finally {
|
||||
textarea.remove()
|
||||
}
|
||||
}
|
||||
|
||||
export function useCopyToClipboard() {
|
||||
const { copy, copied } = useClipboard({ legacy: true })
|
||||
const { copy, isSupported } = useClipboard()
|
||||
const toast = useToast()
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
let success = false
|
||||
|
||||
try {
|
||||
await copy(text)
|
||||
if (copied.value) {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('clipboard.successMessage'),
|
||||
life: 3000
|
||||
})
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('clipboard.errorMessage')
|
||||
})
|
||||
if (isSupported.value) {
|
||||
await copy(text)
|
||||
success = true
|
||||
}
|
||||
} catch {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('clipboard.errorMessage')
|
||||
})
|
||||
// Modern clipboard API failed, fall through to legacy
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
try {
|
||||
success = legacyCopy(text)
|
||||
} catch {
|
||||
// Legacy also failed
|
||||
}
|
||||
}
|
||||
|
||||
toast.add(
|
||||
success
|
||||
? {
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('clipboard.successMessage'),
|
||||
life: 3000
|
||||
}
|
||||
: {
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('clipboard.errorMessage')
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -75,6 +75,49 @@ import { getOrderedInputSpecs } from '@/workbench/utils/nodeDefOrderingUtil'
|
||||
import { useExtensionService } from './extensionService'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
|
||||
async function reencodeAsPngBlob(
|
||||
blob: Blob,
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<Blob> {
|
||||
const canvas = $el('canvas', { width, height }) as HTMLCanvasElement
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('Could not get canvas context')
|
||||
|
||||
let image: ImageBitmap | HTMLImageElement
|
||||
if (typeof window.createImageBitmap === 'undefined') {
|
||||
const img = new Image()
|
||||
const loaded = new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve()
|
||||
img.onerror = () => reject(new Error('Image load failed'))
|
||||
})
|
||||
img.src = URL.createObjectURL(blob)
|
||||
try {
|
||||
await loaded
|
||||
} finally {
|
||||
URL.revokeObjectURL(img.src)
|
||||
}
|
||||
image = img
|
||||
} else {
|
||||
image = await createImageBitmap(blob)
|
||||
}
|
||||
|
||||
try {
|
||||
ctx.drawImage(image, 0, 0)
|
||||
} finally {
|
||||
if ('close' in image && typeof image.close === 'function') {
|
||||
image.close()
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((result) => {
|
||||
if (result) resolve(result)
|
||||
else reject(new Error('PNG conversion failed'))
|
||||
}, 'image/png')
|
||||
})
|
||||
}
|
||||
|
||||
export interface HasInitialMinSize {
|
||||
_initialMinSize: { width: number; height: number }
|
||||
}
|
||||
@@ -605,58 +648,24 @@ export const useLitegraphService = () => {
|
||||
const url = new URL(img.src)
|
||||
url.searchParams.delete('preview')
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const writeImage = async (blob) => {
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[blob.type]: blob
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetch(url)
|
||||
const blob = await data.blob()
|
||||
try {
|
||||
await writeImage(blob)
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ [blob.type]: blob })
|
||||
])
|
||||
} catch (error) {
|
||||
// Chrome seems to only support PNG on write, convert and try again
|
||||
if (blob.type !== 'image/png') {
|
||||
const canvas = $el('canvas', {
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight
|
||||
}) as HTMLCanvasElement
|
||||
const ctx = canvas.getContext('2d')
|
||||
// @ts-expect-error fixme ts strict error
|
||||
let image
|
||||
if (typeof window.createImageBitmap === 'undefined') {
|
||||
image = new Image()
|
||||
const p = new Promise((resolve, reject) => {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
image.onload = resolve
|
||||
// @ts-expect-error fixme ts strict error
|
||||
image.onerror = reject
|
||||
}).finally(() => {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
URL.revokeObjectURL(image.src)
|
||||
})
|
||||
image.src = URL.createObjectURL(blob)
|
||||
await p
|
||||
} else {
|
||||
image = await createImageBitmap(blob)
|
||||
}
|
||||
try {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
ctx.drawImage(image, 0, 0)
|
||||
canvas.toBlob(writeImage, 'image/png')
|
||||
} finally {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
if (typeof image.close === 'function') {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
image.close()
|
||||
}
|
||||
}
|
||||
|
||||
const pngBlob = await reencodeAsPngBlob(
|
||||
blob,
|
||||
img.naturalWidth,
|
||||
img.naturalHeight
|
||||
)
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ 'image/png': pngBlob })
|
||||
])
|
||||
return
|
||||
}
|
||||
throw error
|
||||
|
||||
Reference in New Issue
Block a user