diff --git a/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-menu-chromium-linux.png b/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-menu-chromium-linux.png index ccfcbd7ba6..306ee8ebb7 100644 Binary files a/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-menu-chromium-linux.png and b/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-menu-chromium-linux.png differ diff --git a/src/composables/useCopy.ts b/src/composables/useCopy.ts index b2455fef86..abf60d71d2 100644 --- a/src/composables/useCopy.ts +++ b/src/composables/useCopy.ts @@ -23,10 +23,16 @@ export const useCopy = () => { const canvas = canvasStore.canvas if (canvas?.selectedItems) { const serializedData = canvas.copyToClipboard() + // Use TextEncoder to handle Unicode characters properly + const base64Data = btoa( + String.fromCharCode( + ...Array.from(new TextEncoder().encode(serializedData)) + ) + ) // clearData doesn't remove images from clipboard e.clipboardData?.setData( 'text/html', - clipboardHTMLWrapper.join(btoa(serializedData)) + clipboardHTMLWrapper.join(base64Data) ) e.preventDefault() e.stopImmediatePropagation() diff --git a/src/composables/usePaste.ts b/src/composables/usePaste.ts index 551065f0d5..9a40fab9a3 100644 --- a/src/composables/usePaste.ts +++ b/src/composables/usePaste.ts @@ -14,9 +14,11 @@ function pasteClipboardItems(data: DataTransfer): boolean { const match = rawData.match(/data-metadata="([A-Za-z0-9+/=]+)"/)?.[1] if (!match) return false try { - useCanvasStore() - .getCanvas() - ._deserializeItems(JSON.parse(atob(match)), {}) + // 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) diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index d1d2062c94..0f2fae1da4 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -8031,7 +8031,13 @@ export class LGraphCanvas has_submenu: true, callback: LGraphCanvas.onMenuAdd }, - { content: 'Add Group', callback: LGraphCanvas.onGroupAdd } + { content: 'Add Group', callback: LGraphCanvas.onGroupAdd }, + { + content: 'Paste', + callback: () => { + this.pasteFromClipboard() + } + } // { content: "Arrange", callback: that.graph.arrange }, // {content:"Collapse All", callback: LGraphCanvas.onMenuCollapseAll } ] diff --git a/tests-ui/tests/composables/clipboard.test.ts b/tests-ui/tests/composables/clipboard.test.ts new file mode 100644 index 0000000000..bed017f332 --- /dev/null +++ b/tests-ui/tests/composables/clipboard.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' + +/** + * Encodes a UTF-8 string to base64 (same logic as useCopy.ts) + */ +function encodeClipboardData(data: string): string { + return btoa( + String.fromCharCode(...Array.from(new TextEncoder().encode(data))) + ) +} + +/** + * Decodes base64 to UTF-8 string (same logic as usePaste.ts) + */ +function decodeClipboardData(base64: string): string { + const binaryString = atob(base64) + const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0)) + return new TextDecoder().decode(bytes) +} + +describe('Clipboard UTF-8 base64 encoding/decoding', () => { + it('should handle ASCII-only strings', () => { + const original = '{"nodes":[{"id":1,"type":"LoadImage"}]}' + const encoded = encodeClipboardData(original) + const decoded = decodeClipboardData(encoded) + expect(decoded).toBe(original) + }) + + it('should handle Chinese characters in localized_name', () => { + const original = + '{"nodes":[{"id":1,"type":"LoadImage","localized_name":"图像"}]}' + const encoded = encodeClipboardData(original) + const decoded = decodeClipboardData(encoded) + expect(decoded).toBe(original) + }) + + it('should handle Japanese characters', () => { + const original = '{"localized_name":"画像を読み込む"}' + const encoded = encodeClipboardData(original) + const decoded = decodeClipboardData(encoded) + expect(decoded).toBe(original) + }) + + it('should handle Korean characters', () => { + const original = '{"localized_name":"이미지 불러오기"}' + const encoded = encodeClipboardData(original) + const decoded = decodeClipboardData(encoded) + expect(decoded).toBe(original) + }) + + it('should handle mixed ASCII and Unicode characters', () => { + const original = + '{"nodes":[{"id":1,"type":"LoadImage","localized_name":"加载图像","label":"Load Image 图片"}]}' + const encoded = encodeClipboardData(original) + const decoded = decodeClipboardData(encoded) + expect(decoded).toBe(original) + }) + + it('should handle emoji characters', () => { + const original = '{"title":"Test Node 🎨🖼️"}' + const encoded = encodeClipboardData(original) + const decoded = decodeClipboardData(encoded) + expect(decoded).toBe(original) + }) + + it('should handle empty string', () => { + const original = '' + const encoded = encodeClipboardData(original) + const decoded = decodeClipboardData(encoded) + expect(decoded).toBe(original) + }) + + it('should handle complex node data with multiple Unicode fields', () => { + const original = JSON.stringify({ + nodes: [ + { + id: 1, + type: 'LoadImage', + localized_name: '图像', + inputs: [{ localized_name: '图片', name: 'image' }], + outputs: [{ localized_name: '输出', name: 'output' }] + } + ], + groups: [{ title: '预处理组 🔧' }], + links: [] + }) + const encoded = encodeClipboardData(original) + const decoded = decodeClipboardData(encoded) + expect(decoded).toBe(original) + expect(JSON.parse(decoded)).toEqual(JSON.parse(original)) + }) + + it('should produce valid base64 output', () => { + const original = '{"localized_name":"中文测试"}' + const encoded = encodeClipboardData(original) + // Base64 should only contain valid characters + expect(encoded).toMatch(/^[A-Za-z0-9+/=]+$/) + }) + + it('should fail with plain btoa for non-Latin1 characters', () => { + const original = '{"localized_name":"图像"}' + // This demonstrates why we need TextEncoder - plain btoa fails + expect(() => btoa(original)).toThrow() + }) +})