From 39ce4a23ccf1d7bd18fe09ebc9b94d114d2ad522 Mon Sep 17 00:00:00 2001 From: Dante Date: Fri, 13 Mar 2026 00:44:11 +0900 Subject: [PATCH] fix: skip node metadata paste when media node is selected (#9773) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - When a media node (LoadImage/LoadAudio/LoadVideo) is selected and the clipboard contains stale node metadata from a prior Ctrl+C, pasting skips the node-metadata deserialization so that the paste falls through to litegraph's default handler instead of incorrectly pasting the old copied node. - Fixes Comfy-Org/ComfyUI#12896 ## Root Cause The paste handler in `usePaste.ts` checks clipboard `text/html` for `data-metadata` (serialized node data) **before** falling through to litegraph's default paste. When a user copies a node, then copies a web image, the browser clipboard may retain the old `data-metadata` in `text/html` while the image data is not available as a `DataTransferItem` file. This causes the stale node to be pasted instead of the image. ## Fix Skip `pasteClipboardItems()` when a media node is selected, allowing the paste to fall through to litegraph's default handler which can handle the clipboard content appropriately. ## Test plan - [x] Added unit test verifying node metadata paste is skipped when media node is selected - [x] Manual: Copy a node β†’ copy a web image β†’ select LoadImage node β†’ Ctrl+V β†’ verify image is pasted, not the node ## AS IS https://github.com/user-attachments/assets/210d77d3-5c49-4e38-91b7-b9d9ea0e7ca0 ## TO BE https://github.com/user-attachments/assets/b68e4582-0c57-48b8-9ed9-0b3243bb1554 πŸ€– Generated with [Claude Code](https://claude.com/claude-code) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9773-fix-skip-node-metadata-paste-when-media-node-is-selected-3216d73d3650814d92dadcd0c0ec79c7) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: GitHub Action --- .../nodes/load_image_with_ksampler.json | 47 +++++++++++++++ .../tests/imagePastePriority.spec.ts | 58 +++++++++++++++++++ src/composables/usePaste.test.ts | 28 +++++++++ src/composables/usePaste.ts | 5 +- 4 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 browser_tests/assets/nodes/load_image_with_ksampler.json create mode 100644 browser_tests/tests/imagePastePriority.spec.ts diff --git a/browser_tests/assets/nodes/load_image_with_ksampler.json b/browser_tests/assets/nodes/load_image_with_ksampler.json new file mode 100644 index 0000000000..fcd5dab906 --- /dev/null +++ b/browser_tests/assets/nodes/load_image_with_ksampler.json @@ -0,0 +1,47 @@ +{ + "last_node_id": 2, + "last_link_id": 0, + "nodes": [ + { + "id": 1, + "type": "LoadImage", + "pos": [50, 50], + "size": [315, 314], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { "name": "IMAGE", "type": "IMAGE", "links": null }, + { "name": "MASK", "type": "MASK", "links": null } + ], + "properties": { "Node name for S&R": "LoadImage" }, + "widgets_values": ["example.png", "image"] + }, + { + "id": 2, + "type": "KSampler", + "pos": [500, 50], + "size": [315, 262], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { "name": "model", "type": "MODEL", "link": null }, + { "name": "positive", "type": "CONDITIONING", "link": null }, + { "name": "negative", "type": "CONDITIONING", "link": null }, + { "name": "latent_image", "type": "LATENT", "link": null } + ], + "outputs": [{ "name": "LATENT", "type": "LATENT", "links": null }], + "properties": { "Node name for S&R": "KSampler" }, + "widgets_values": [0, "randomize", 20, 8, "euler", "normal", 1] + } + ], + "links": [], + "groups": [], + "config": {}, + "extra": { + "ds": { "offset": [0, 0], "scale": 1 } + }, + "version": 0.4 +} diff --git a/browser_tests/tests/imagePastePriority.spec.ts b/browser_tests/tests/imagePastePriority.spec.ts new file mode 100644 index 0000000000..9bbab8634c --- /dev/null +++ b/browser_tests/tests/imagePastePriority.spec.ts @@ -0,0 +1,58 @@ +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '../fixtures/ComfyPage' + +test.describe( + 'Image paste priority over stale node metadata', + { tag: ['@node'] }, + () => { + test('Should not paste copied node when a LoadImage node is selected and clipboard has stale node metadata', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler') + + const initialCount = await comfyPage.nodeOps.getGraphNodesCount() + expect(initialCount).toBe(2) + + // Copy the KSampler node (puts data-metadata in clipboard) + const ksamplerNodes = + await comfyPage.nodeOps.getNodeRefsByType('KSampler') + await ksamplerNodes[0].copy() + + // Select the LoadImage node + const loadImageNodes = + await comfyPage.nodeOps.getNodeRefsByType('LoadImage') + await loadImageNodes[0].click('title') + + // Simulate pasting when clipboard has stale node metadata (text/html + // with data-metadata) but no image file items. This replicates the bug + // scenario: user copied a node, then copied a web image (which replaces + // clipboard files but may leave stale text/html with node metadata). + await comfyPage.page.evaluate(() => { + const nodeData = { nodes: [{ type: 'KSampler', id: 99 }] } + const base64 = btoa(JSON.stringify(nodeData)) + const html = + '
Text' + + const dataTransfer = new DataTransfer() + dataTransfer.setData('text/html', html) + + const event = new ClipboardEvent('paste', { + clipboardData: dataTransfer, + bubbles: true, + cancelable: true + }) + document.dispatchEvent(event) + }) + + await comfyPage.nextFrame() + + // Node count should remain the same β€” stale node metadata should NOT + // be deserialized when a media node is selected. + const finalCount = await comfyPage.nodeOps.getGraphNodesCount() + expect(finalCount).toBe(initialCount) + }) + } +) diff --git a/src/composables/usePaste.test.ts b/src/composables/usePaste.test.ts index dcd2acd44e..604a1034b0 100644 --- a/src/composables/usePaste.test.ts +++ b/src/composables/usePaste.test.ts @@ -595,6 +595,34 @@ describe('usePaste', () => { ) }) }) + + it('should skip node metadata paste when a media node is selected', async () => { + const mockNode = createMockLGraphNode({ + is_selected: true, + pasteFile: vi.fn(), + pasteFiles: vi.fn() + }) + mockCanvas.current_node = mockNode + vi.mocked(isImageNode).mockReturnValue(true) + + usePaste() + + const nodeData = { nodes: [{ type: 'KSampler' }] } + const encoded = btoa(JSON.stringify(nodeData)) + const html = `
` + + const dataTransfer = new DataTransfer() + dataTransfer.setData('text/html', html) + dataTransfer.setData('text/plain', 'some text') + + const event = new ClipboardEvent('paste', { clipboardData: dataTransfer }) + document.dispatchEvent(event) + + await vi.waitFor(() => { + expect(mockCanvas._deserializeItems).not.toHaveBeenCalled() + expect(mockCanvas.pasteFromClipboard).toHaveBeenCalled() + }) + }) }) describe('cloneDataTransfer', () => { diff --git a/src/composables/usePaste.ts b/src/composables/usePaste.ts index b60afc1501..93306e0af6 100644 --- a/src/composables/usePaste.ts +++ b/src/composables/usePaste.ts @@ -229,7 +229,10 @@ export const usePaste = () => { return } } - if (pasteClipboardItems(data)) return + + const isMediaNodeSelected = + isImageNodeSelected || isVideoNodeSelected || isAudioNodeSelected + if (!isMediaNodeSelected && pasteClipboardItems(data)) return // No image found. Look for node data data = data.getData('text/plain')