From 8c8e2d35730362735146191fa7289dd1d52144e0 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Tue, 5 May 2026 08:45:14 -0700 Subject: [PATCH] fix: keep multi-image paste attachments --- static/boot.js | 6 +- tests/test_issue1697_multi_image_paste.py | 142 ++++++++++++++++++++++ 2 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 tests/test_issue1697_multi_image_paste.py diff --git a/static/boot.js b/static/boot.js index d4e8bdd0..62f3c8ab 100644 --- a/static/boot.js +++ b/static/boot.js @@ -975,10 +975,12 @@ $('msg').addEventListener('paste',e=>{ const imageItems=items.filter(i=>i.kind==='file'&&i.type.startsWith('image/')); if(!imageItems.length||hasText)return; e.preventDefault(); - const files=imageItems.map(i=>{ + const pasteTs=Date.now(); + const files=imageItems.map((i,idx)=>{ const blob=i.getAsFile(); const ext=i.type.split('/')[1]||'png'; - return new File([blob],`screenshot-${Date.now()}.${ext}`,{type:i.type}); + const suffix=imageItems.length>1?`-${idx+1}`:''; + return new File([blob],`screenshot-${pasteTs}${suffix}.${ext}`,{type:i.type}); }); addFiles(files); setStatus(t('image_pasted')+files.map(f=>f.name).join(', ')); diff --git a/tests/test_issue1697_multi_image_paste.py b/tests/test_issue1697_multi_image_paste.py new file mode 100644 index 00000000..250cb155 --- /dev/null +++ b/tests/test_issue1697_multi_image_paste.py @@ -0,0 +1,142 @@ +"""Regression coverage for #1697: multi-image clipboard paste attachments.""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).parent.parent.resolve() +BOOT_JS_PATH = REPO_ROOT / "static" / "boot.js" +PANELS_JS_PATH = REPO_ROOT / "static" / "panels.js" +NODE = shutil.which("node") + +pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") + + +def _read_js(path: Path) -> str: + with open(path, encoding="utf-8") as f: + return f.read() + + +def _extract_msg_paste_registration() -> str: + boot = _read_js(BOOT_JS_PATH) + marker = "$('msg').addEventListener('paste',e=>{" + start = boot.find(marker) + assert start >= 0, "boot.js must register the composer paste handler" + end_marker = "\n});" + end = boot.find(end_marker, start) + assert end >= 0, "composer paste handler should end with a listener close" + return boot[start : end + len(end_marker)] + + +def _run_node(source: str) -> str: + result = subprocess.run( + [NODE], + input=source, + text=True, + capture_output=True, + cwd=REPO_ROOT, + timeout=20, + check=False, + ) + if result.returncode != 0: + raise RuntimeError(f"node driver failed:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}") + return result.stdout.strip() + + +def _paste_harness(items_js: str) -> dict: + paste_registration = json.dumps(_extract_msg_paste_registration()) + source = f""" +const vm = require('vm'); +const pasteRegistration = {paste_registration}; +const listeners = {{}}; +const S = {{pendingFiles: []}}; +let renderCount = 0; +let lastStatus = ''; +let preventDefaultCount = 0; +class File extends Blob {{ + constructor(parts, name, options={{}}) {{ + super(parts, options); + this.name = name; + this.lastModified = options.lastModified || 0; + }} +}} +const context = {{ + S, + File, + Blob, + Date: {{now: () => 1700000000000}}, + Array, + console, + $: (id) => {{ + if (id !== 'msg') throw new Error('unexpected element id '+id); + return {{addEventListener: (type, cb) => {{listeners[type] = cb;}}}}; + }}, + addFiles: (files) => {{ + for (const f of files) {{ + if (!S.pendingFiles.find(p => p.name === f.name)) S.pendingFiles.push(f); + }} + renderCount += 1; + }}, + setStatus: (text) => {{ lastStatus = text; }}, + t: (key) => key === 'image_pasted' ? 'Image pasted: ' : key, +}}; +vm.createContext(context); +vm.runInContext(pasteRegistration, context); +listeners.paste({{ + clipboardData: {{items: {items_js}}}, + preventDefault: () => {{ preventDefaultCount += 1; }}, +}}); +console.log(JSON.stringify({{ + pendingNames: S.pendingFiles.map(f => f.name), + pendingCount: S.pendingFiles.length, + renderCount, + lastStatus, + preventDefaultCount, +}})); +""" + return json.loads(_run_node(source)) + + +def test_one_clipboard_paste_with_two_image_items_adds_two_attachment_chips(): + """Two image clipboard items from one paste must survive addFiles() filename de-dupe.""" + result = _paste_harness( + "[" + "{kind:'file', type:'image/png', getAsFile:()=>new Blob(['one'], {type:'image/png'})}," + "{kind:'file', type:'image/png', getAsFile:()=>new Blob(['two'], {type:'image/png'})}" + "]" + ) + + assert result["preventDefaultCount"] == 1 + assert result["renderCount"] == 1 + assert result["pendingCount"] == 2 + assert result["pendingNames"] == [ + "screenshot-1700000000000-1.png", + "screenshot-1700000000000-2.png", + ] + assert result["lastStatus"] == ( + "Image pasted: screenshot-1700000000000-1.png, " + "screenshot-1700000000000-2.png" + ) + + +def test_single_image_paste_keeps_existing_screenshot_filename_shape(): + """The one-image path should keep screenshot-. for compatibility.""" + result = _paste_harness( + "[{kind:'file', type:'image/png', getAsFile:()=>new Blob(['one'], {type:'image/png'})}]" + ) + + assert result["pendingNames"] == ["screenshot-1700000000000.png"] + + +def test_file_picker_and_drop_paths_still_pass_real_file_names_to_addfiles(): + """Non-clipboard multi-file paths should preserve browser-provided filenames.""" + boot = _read_js(BOOT_JS_PATH) + panels = _read_js(PANELS_JS_PATH) + + assert "$('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';};" in boot + assert "const files=Array.from(e.dataTransfer.files);" in panels + assert "if(files.length){addFiles(files);$('msg').focus();}" in panels + assert "screenshot-" not in panels[panels.find("document.addEventListener('drop'") : panels.find("document.addEventListener('drop'") + 900]