Files
ComfyUI_frontend/src/composables/usePaste.ts
Terry Jia 379af28678 fix: handle Unicode characters in clipboard copy/paste and add Paste menu option (#7103)
## Summary
Use TextEncoder/TextDecoder for UTF-8 safe base64 encoding/decoding

When copying nodes containing non-Latin1 characters (e.g., Chinese
characters in localized_name field), btoa() throws an error:

InvalidCharacterError: Failed to execute 'btoa' on 'Window': The string
to be encoded contains characters outside of the Latin1 range.

The copy operation saved data to localStorage successfully, but failed
to write to the browser clipboard. On paste, the browser clipboard still
contained old data, causing the wrong node to be pasted.

<!-- Fixes #ISSUE_NUMBER -->
https://github.com/Comfy-Org/ComfyUI_frontend/issues/6993
https://github.com/Comfy-Org/ComfyUI_frontend/issues/5449
https://github.com/comfyanonymous/ComfyUI/issues/8481

## Screenshots (if applicable)
before

https://github.com/user-attachments/assets/8abd9049-91bb-4200-8853-e26753376007

after

https://github.com/user-attachments/assets/7d969f32-bb0f-4c7a-baa2-65d576a4eba2

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7103-fix-handle-Unicode-characters-in-clipboard-copy-paste-and-add-Paste-menu-option-2bd6d73d365081f39c40e7e7f832b97c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-12-02 15:31:27 -08:00

159 lines
5.0 KiB
TypeScript

import { useEventListener } from '@vueuse/core'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isAudioNode, isImageNode, isVideoNode } from '@/utils/litegraphUtil'
import { shouldIgnoreCopyPaste } from '@/workbench/eventHelpers'
function pasteClipboardItems(data: DataTransfer): boolean {
const rawData = data.getData('text/html')
const match = rawData.match(/data-metadata="([A-Za-z0-9+/=]+)"/)?.[1]
if (!match) return false
try {
// Decode UTF-8 safe base64
const binaryString = atob(match)
const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0))
const decodedData = new TextDecoder().decode(bytes)
useCanvasStore().getCanvas()._deserializeItems(JSON.parse(decodedData), {})
return true
} catch (err) {
console.error(err)
}
return false
}
/**
* Adds a handler on paste that extracts and loads images or workflows from pasted JSON data
*/
export const usePaste = () => {
const workspaceStore = useWorkspaceStore()
const canvasStore = useCanvasStore()
const pasteItemsOnNode = (
items: DataTransferItemList,
node: LGraphNode | null,
contentType: string
) => {
if (!node) return
const filteredItems = Array.from(items).filter((item) =>
item.type.startsWith(contentType)
)
const blob = filteredItems[0]?.getAsFile()
if (!blob) return
node.pasteFile?.(blob)
node.pasteFiles?.(
Array.from(filteredItems)
.map((i) => i.getAsFile())
.filter((f) => f !== null)
)
}
useEventListener(document, 'paste', async (e) => {
if (shouldIgnoreCopyPaste(e.target)) {
// Default system copy
return
}
// ctrl+shift+v is used to paste nodes with connections
// this is handled by litegraph
if (workspaceStore.shiftDown) return
const { canvas } = canvasStore
if (!canvas) return
const { graph } = canvas
let data: DataTransfer | string | null = e.clipboardData
if (!data) throw new Error('No clipboard data on clipboard event')
const { items } = data
const currentNode = canvas.current_node as LGraphNode
const isNodeSelected = currentNode?.is_selected
const isImageNodeSelected = isNodeSelected && isImageNode(currentNode)
const isVideoNodeSelected = isNodeSelected && isVideoNode(currentNode)
const isAudioNodeSelected = isNodeSelected && isAudioNode(currentNode)
let imageNode: LGraphNode | null = isImageNodeSelected ? currentNode : null
let audioNode: LGraphNode | null = isAudioNodeSelected ? currentNode : null
const videoNode: LGraphNode | null = isVideoNodeSelected
? currentNode
: null
// Look for image paste data
for (const item of items) {
if (item.type.startsWith('image/')) {
if (!imageNode) {
// No image node selected: add a new one
const newNode = LiteGraph.createNode('LoadImage')
if (newNode) {
newNode.pos = [canvas.graph_mouse[0], canvas.graph_mouse[1]]
imageNode = graph?.add(newNode) ?? null
}
graph?.change()
}
pasteItemsOnNode(items, imageNode, 'image')
return
} else if (item.type.startsWith('video/')) {
if (!videoNode) {
// No video node selected: add a new one
// TODO: when video node exists
} else {
pasteItemsOnNode(items, videoNode, 'video')
return
}
} else if (item.type.startsWith('audio/')) {
if (!audioNode) {
// No audio node selected: add a new one
const newNode = LiteGraph.createNode('LoadAudio')
if (newNode) {
newNode.pos = [canvas.graph_mouse[0], canvas.graph_mouse[1]]
audioNode = graph?.add(newNode) ?? null
}
graph?.change()
}
pasteItemsOnNode(items, audioNode, 'audio')
return
}
}
if (pasteClipboardItems(data)) return
// No image found. Look for node data
data = data.getData('text/plain')
let workflow: ComfyWorkflowJSON | null = null
try {
data = data.slice(data.indexOf('{'))
workflow = JSON.parse(data)
} catch (err) {
try {
data = data.slice(data.indexOf('workflow\n'))
data = data.slice(data.indexOf('{'))
workflow = JSON.parse(data)
} catch (error) {
workflow = null
}
}
if (workflow && workflow.version && workflow.nodes && workflow.extra) {
await app.loadGraphData(workflow)
} else {
if (
(e.target instanceof HTMLTextAreaElement &&
e.target.type === 'textarea') ||
(e.target instanceof HTMLInputElement && e.target.type === 'text')
) {
return
}
// Litegraph default paste
canvas.pasteFromClipboard()
}
})
}