[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:
Comfy Org PR Bot
2026-03-26 04:28:11 +09:00
committed by GitHub
parent 2a21f3b25b
commit 0dbdbe32e4
3 changed files with 194 additions and 65 deletions

View 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' })
)
})
})

View File

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

View File

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