Support cross domain/application copy/paste (#6087)

![AnimateDiff_00001](https://github.com/user-attachments/assets/8ae88dc5-bba8-40c0-9cc2-5e81f579761d)


Browsers place very heavy restrictions on what can be copied and pasted.
See:
- https://alexharri.com/blog/clipboard
- https://www.w3.org/TR/clipboard-apis/#mandatory-data-types-x

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6087-Experimental-cross-domain-application-copy-paste-28e6d73d36508154a0a8deeb392f43a4)
by [Unito](https://www.unito.io)
This commit is contained in:
AustinMroz
2025-10-20 10:03:15 -07:00
committed by GitHub
parent 55d2b300a6
commit 8eac19d06e
4 changed files with 71 additions and 30 deletions

View File

@@ -1,6 +1,12 @@
import { useEventListener } from '@vueuse/core'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { shouldIgnoreCopyPaste } from '@/workbench/eventHelpers'
const clipboardHTMLWrapper = [
'<meta charset="utf-8"><div><span data-metadata="',
'"></span></div><span style="white-space:pre-wrap;">Text</span>'
]
/**
* Adds a handler on copy that serializes selected nodes to JSON
@@ -9,28 +15,19 @@ export const useCopy = () => {
const canvasStore = useCanvasStore()
useEventListener(document, 'copy', (e) => {
if (!(e.target instanceof Element)) {
return
}
if (
(e.target instanceof HTMLTextAreaElement &&
e.target.type === 'textarea') ||
(e.target instanceof HTMLInputElement && e.target.type === 'text')
) {
if (shouldIgnoreCopyPaste(e.target)) {
// Default system copy
return
}
const isTargetInGraph =
e.target.classList.contains('litegraph') ||
e.target.classList.contains('graph-canvas-container') ||
e.target.id === 'graph-canvas'
// copy nodes and clear clipboard
const canvas = canvasStore.canvas
if (isTargetInGraph && canvas?.selectedItems) {
canvas.copyToClipboard()
if (canvas?.selectedItems) {
const serializedData = canvas.copyToClipboard()
// clearData doesn't remove images from clipboard
e.clipboardData?.setData('text', ' ')
e.clipboardData?.setData(
'text/html',
clipboardHTMLWrapper.join(btoa(serializedData))
)
e.preventDefault()
e.stopImmediatePropagation()
return false

View File

@@ -7,6 +7,22 @@ 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 {
useCanvasStore()
.getCanvas()
._deserializeItems(JSON.parse(atob(match)), {})
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
@@ -38,15 +54,10 @@ export const usePaste = () => {
}
useEventListener(document, 'paste', async (e) => {
const isTargetInGraph =
e.target instanceof Element &&
(e.target.classList.contains('litegraph') ||
e.target.classList.contains('graph-canvas-container') ||
e.target.id === 'graph-canvas')
// If the target is not in the graph, we don't want to handle the paste event
if (!isTargetInGraph) return
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
@@ -109,6 +120,7 @@ export const usePaste = () => {
return
}
}
if (pasteClipboardItems(data)) return
// No image found. Look for node data
data = data.getData('text/plain')

View File

@@ -3867,11 +3867,10 @@ export class LGraphCanvas
* When called without parameters, it copies {@link selectedItems}.
* @param items The items to copy. If nullish, all selected items are copied.
*/
copyToClipboard(items?: Iterable<Positionable>): void {
localStorage.setItem(
'litegrapheditor_clipboard',
JSON.stringify(this._serializeItems(items))
)
copyToClipboard(items?: Iterable<Positionable>): string {
const serializedData = JSON.stringify(this._serializeItems(items))
localStorage.setItem('litegrapheditor_clipboard', serializedData)
return serializedData
}
emitEvent(detail: LGraphCanvasEventMap['litegraph:canvas']): void {
@@ -3907,6 +3906,7 @@ export class LGraphCanvas
if (!data) return
return this._deserializeItems(JSON.parse(data), options)
}
_deserializeItems(
parsed: ClipboardItems,
options: IPasteFromClipboardOptions
@@ -3923,6 +3923,7 @@ export class LGraphCanvas
const { graph } = this
if (!graph) throw new NullGraphError()
graph.beforeChange()
this.emitBeforeChange()
// Parse & initialise
parsed.nodes ??= []
@@ -4092,6 +4093,7 @@ export class LGraphCanvas
this.selectItems(created)
graph.afterChange()
this.emitAfterChange()
return results
}

View File

@@ -0,0 +1,30 @@
/**
* Utility functions for handling workbench events
*/
/**
* Used by clipboard handlers to determine if copy/paste events should be
* intercepted for graph operations vs. allowing default browser behavior
* for text inputs and other UI elements.
*
* @param target - The event target to check
* @returns true if copy paste events will be handled by target
*/
export function shouldIgnoreCopyPaste(target: EventTarget | null): boolean {
return (
target instanceof HTMLTextAreaElement ||
(target instanceof HTMLInputElement &&
![
'button',
'checkbox',
'file',
'hidden',
'image',
'radio',
'range',
'reset',
'search',
'submit'
].includes(target.type))
)
}