Fix #1620: don't attach image on paste when clipboard also has text
When the clipboard carries both text and an image (rich-text sources like
Notes, Word, Slack, browser selection attach a rendered preview alongside
the plain text), the paste handler in static/boot.js unconditionally
called e.preventDefault() and routed the image into addFiles(), silently
discarding the text payload.
Fix:
- Detect text in the clipboard via items[].kind === 'string' &&
(type === 'text/plain' || type === 'text/html'). When present, return
early so the browser's default text-paste runs.
- Tighten the image filter to kind === 'file' && type.startsWith('image/')
so string items advertising an image MIME (e.g. text/html with an
embedded data URI) are not misclassified as a true screenshot paste.
Pure-screenshot paste (image-only clipboard, e.g. Cmd+Shift+Ctrl+4 on macOS)
is unchanged.
Adds tests/test_1620_paste_text_with_image.py with 6 static-analysis checks
on the handler shape, matching the pattern of test_issue1095_pasted_images.py.
This commit is contained in:
1 parent
9986d2fd30
commit
1ad0ab42e5
2 files changed
+114
-2
No files matched your search
+9
-2
@@ -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();
|
||||
|
||||
@@ -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-<ts> 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"
|
||||
)
|
||||
Reference in new issue
Block a user