diff --git a/static/boot.js b/static/boot.js index a54f75a5..d6d47611 100644 --- a/static/boot.js +++ b/static/boot.js @@ -965,8 +965,15 @@ document.addEventListener('keydown',async e=>{ }); $('msg').addEventListener('paste',e=>{ const items=Array.from(e.clipboardData?.items||[]); - const imageItems=items.filter(i=>i.type.startsWith('image/')); - if(!imageItems.length)return; + // When the clipboard carries BOTH text and an image (common from Notes, + // Word, browsers, Slack — the OS attaches a rendered preview alongside + // the plain text), prefer the text and let the browser paste normally. + // Only intercept when the clipboard is image-only (true screenshot paste). + // Tighten the image filter to kind==='file' so string items advertising an + // image MIME (e.g. text/html with an embedded data URI) are not misclassified. + const hasText=items.some(i=>i.kind==='string'&&(i.type==='text/plain'||i.type==='text/html')); + 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 blob=i.getAsFile(); diff --git a/tests/test_1620_paste_text_with_image.py b/tests/test_1620_paste_text_with_image.py new file mode 100644 index 00000000..7928b66f --- /dev/null +++ b/tests/test_1620_paste_text_with_image.py @@ -0,0 +1,105 @@ +"""Tests for #1620 — Cmd+V always attaches an image when clipboard contains both text and image. + +The composer paste handler in `static/boot.js` previously intercepted any paste +event whose clipboard carried an `image/*` item, called `e.preventDefault()`, +and attached the image as a screenshot. When the clipboard came from a rich-text +source (Notes, Word, Slack, browser selection), macOS/Windows/Linux attach a +rendered preview image alongside the plain text — so the handler swallowed the +text payload and only the rogue image was attached. + +The fix: + • Skip image-attach when the clipboard also carries `text/plain` or `text/html` + string items (rich-text source — let the browser paste text normally). + • Tighten the image filter to `kind === 'file'` so string items advertising an + image MIME are not misclassified as a true screenshot paste. + +These tests guard the handler shape against regression by static-analyzing +`static/boot.js`. They follow the same pattern as `test_issue1095_pasted_images.py`. +""" +import os +import re + + +def _read_boot_js() -> str: + with open(os.path.join('static', 'boot.js')) as f: + return f.read() + + +def _paste_handler_body() -> str: + """Extract the body of the #msg paste handler for assertions.""" + src = _read_boot_js() + m = re.search(r"\$\('msg'\)\.addEventListener\('paste',\s*e\s*=>\s*\{", src) + assert m, "#msg paste handler not found in static/boot.js" + # Walk braces from the opening { to find the matching close. + start = m.end() - 1 + depth = 0 + for i in range(start, len(src)): + c = src[i] + if c == '{': + depth += 1 + elif c == '}': + depth -= 1 + if depth == 0: + return src[start:i + 1] + raise AssertionError("Unbalanced braces in #msg paste handler") + + +class TestPasteHandlerTextWithImage: + """Regression suite for #1620.""" + + def test_handler_detects_text_in_clipboard(self): + """Handler must inspect string items for text/plain or text/html so it can + defer to the browser's default text-paste behavior when text is present.""" + body = _paste_handler_body() + assert "kind==='string'" in body or 'kind === "string"' in body or "kind === 'string'" in body, ( + "paste handler must check items[].kind === 'string' to detect text payload" + ) + assert "'text/plain'" in body, "paste handler must check for text/plain" + assert "'text/html'" in body, "paste handler must check for text/html" + + def test_image_filter_requires_kind_file(self): + """Image filter must require kind === 'file' to avoid misclassifying string + items that advertise an image MIME (e.g. text/html with embedded data URIs).""" + body = _paste_handler_body() + # The image filter line must combine kind==='file' with type.startsWith('image/'). + assert re.search( + r"kind\s*===\s*'file'\s*&&\s*[a-zA-Z_$][\w$]*\.type\.startsWith\('image/'\)", + body, + ), "imageItems filter must use kind === 'file' && type.startsWith('image/')" + + def test_handler_skips_attach_when_text_present(self): + """The early-return guard must short-circuit when text is in the clipboard, + so the browser's default text-paste runs and no image is attached.""" + body = _paste_handler_body() + # Guard shape: if(!imageItems.length || hasText) return; + assert re.search( + r"if\s*\(\s*!\s*imageItems\.length\s*\|\|\s*hasText\s*\)\s*return\s*;", + body, + ), "guard must early-return when there are no image files OR text is present" + + def test_handler_still_intercepts_pure_screenshot_paste(self): + """Pure-screenshot paste (image-only clipboard) must still call preventDefault() + and route through addFiles() so the screenshot attaches as a file.""" + body = _paste_handler_body() + assert 'e.preventDefault()' in body, "handler must still preventDefault on image-only paste" + assert 'addFiles(files)' in body, "handler must still call addFiles(files) for screenshots" + assert 'screenshot-' in body, "handler must still synthesize screenshot- filename" + + def test_handler_does_not_use_loose_image_check(self): + """The pre-fix loose check `i.type.startsWith('image/')` (without kind==='file') + must not be the imageItems filter — that was the source of the bug.""" + body = _paste_handler_body() + # Find the imageItems assignment line. + m = re.search(r"const\s+imageItems\s*=\s*items\.filter\([^)]*\)", body) + assert m, "imageItems filter not found" + filter_expr = m.group(0) + assert "kind==='file'" in filter_expr or "kind === 'file'" in filter_expr, ( + "imageItems filter must be tightened with kind === 'file' (regression for #1620)" + ) + + def test_handler_does_not_lose_status_message(self): + """The image_pasted status message must still be emitted on the screenshot path.""" + body = _paste_handler_body() + assert "setStatus(t('image_pasted')" in body, ( + "handler must still emit the image_pasted status on screenshot attach" + )