feat(#4372): attach large composer text pastes as .md files
Pastes over 4000 chars or 100 lines attach as a timestamped pasted-text-*.md in the upload tray instead of flooding the composer. Image-paste path unchanged; an oversize paste that exceeds MAX_UPLOAD_BYTES falls through to a normal inline paste so nothing is lost. Co-authored-by: santastabber <204424800+santastabber@users.noreply.github.com>
This commit is contained in:
1 parent
9f14dd6686
commit
299fab5631
5 files changed
+163
-20
No files matched your search
@@ -9,6 +9,12 @@
|
||||
|
||||
- **The conversation-outline toggle button no longer overlaps the scroll-to-bottom control (#2124).** The outline FAB was `position:fixed` at a high z-index and could stack on top of the scroll-to-bottom button in the bottom-right corner. It's now anchored inside the messages shell (`position:absolute`, lower z-index) so the two controls sit cleanly without colliding. Thanks @Habib1001-m.
|
||||
|
||||
## [v0.51.490] — 2026-06-18 — Release QZ (large plain-text pastes become .md attachments)
|
||||
|
||||
### Added
|
||||
|
||||
- **Large plain-text pastes in the composer now become `.md` attachments instead of flooding the input (#4372).** Pasting text over 4000 characters or 100 lines now attaches it as a timestamped `pasted-text-*.md` file in the upload tray rather than dumping it inline. Image-paste (with or without accompanying text) is unchanged, and a paste that would exceed the upload size limit falls through to a normal inline paste so nothing is lost. Thanks @santastabber.
|
||||
|
||||
## [v0.51.488] — 2026-06-18 — Release QW (rescue state.db user prompts that fall before a newer sidecar tail)
|
||||
|
||||
### Fixed
|
||||
|
||||
+55
-15
@@ -1569,26 +1569,66 @@ document.addEventListener('keydown',async e=>{
|
||||
}
|
||||
}
|
||||
});
|
||||
const LARGE_TEXT_PASTE_CHAR_THRESHOLD=4000;
|
||||
const LARGE_TEXT_PASTE_LINE_THRESHOLD=100;
|
||||
function _largeTextPasteLineCount(text){
|
||||
const value=String(text||'');
|
||||
const lines=value.split('\n');
|
||||
return value.endsWith('\n')?lines.length-1:lines.length;
|
||||
}
|
||||
function _shouldAttachLargePastedText(text){
|
||||
const value=String(text||'');
|
||||
if(!value.trim())return false;
|
||||
return value.length>=LARGE_TEXT_PASTE_CHAR_THRESHOLD || _largeTextPasteLineCount(value)>=LARGE_TEXT_PASTE_LINE_THRESHOLD;
|
||||
}
|
||||
function _largeTextPasteFileName(now){
|
||||
const d=new Date(now||Date.now());
|
||||
const stamp=d.toISOString().replace(/[:.]/g,'-').replace('T','_').replace('Z','');
|
||||
const existing=new Set((S.pendingFiles||[]).map(f=>f&&f.name).filter(Boolean));
|
||||
let name=`pasted-text-${stamp}.md`;
|
||||
for(let i=2;existing.has(name);i++)name=`pasted-text-${stamp}-${i}.md`;
|
||||
return name;
|
||||
}
|
||||
function _largeTextPasteFile(text,now){
|
||||
const name=_largeTextPasteFileName(now||Date.now());
|
||||
return new File([String(text||'')],name,{type:'text/markdown;charset=utf-8'});
|
||||
}
|
||||
function _largeTextPasteFitsUploadLimit(file){
|
||||
return !(file&&typeof MAX_UPLOAD_BYTES==='number'&&file.size>MAX_UPLOAD_BYTES);
|
||||
}
|
||||
function _attachLargePastedText(file){
|
||||
addFiles([file]);
|
||||
if(typeof setStatus==='function')setStatus(t('text_pasted')+file.name);
|
||||
return file;
|
||||
}
|
||||
$('msg').addEventListener('paste',e=>{
|
||||
const items=Array.from(e.clipboardData?.items||[]);
|
||||
// Extract image items (kind==='file' filter avoids misclassifying text/html
|
||||
// with embedded data URIs as images).
|
||||
const imageItems=items.filter(i=>i.kind==='file'&&i.type.startsWith('image/'));
|
||||
if(!imageItems.length)return;
|
||||
// If text is also present (common when copying images from browsers, Notes,
|
||||
// Slack, etc.), let the browser paste the text normally AND attach the image.
|
||||
// Only preventDefault when the clipboard is image-only (true screenshot paste).
|
||||
const hasText=items.some(i=>i.kind==='string'&&(i.type==='text/plain'||i.type==='text/html'));
|
||||
if(!hasText)e.preventDefault();
|
||||
const pasteTs=Date.now();
|
||||
const files=imageItems.map((i,idx)=>{
|
||||
const blob=i.getAsFile();
|
||||
const ext=i.type.split('/')[1]||'png';
|
||||
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(', '));
|
||||
if(imageItems.length){
|
||||
// If text is also present (common when copying images from browsers, Notes,
|
||||
// Slack, etc.), let the browser paste the text normally AND attach the image.
|
||||
// Only preventDefault when the clipboard is image-only (true screenshot paste).
|
||||
const hasText=items.some(i=>i.kind==='string'&&(i.type==='text/plain'||i.type==='text/html'));
|
||||
if(!hasText)e.preventDefault();
|
||||
const pasteTs=Date.now();
|
||||
const files=imageItems.map((i,idx)=>{
|
||||
const blob=i.getAsFile();
|
||||
const ext=i.type.split('/')[1]||'png';
|
||||
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(', '));
|
||||
return;
|
||||
}
|
||||
const plainText=e.clipboardData?.getData('text/plain')||'';
|
||||
if(!_shouldAttachLargePastedText(plainText))return;
|
||||
const pastedTextFile=_largeTextPasteFile(plainText);
|
||||
if(!_largeTextPasteFitsUploadLimit(pastedTextFile))return;
|
||||
e.preventDefault();
|
||||
_attachLargePastedText(pastedTextFile);
|
||||
});
|
||||
document.querySelectorAll('.suggestion').forEach(btn=>{
|
||||
btn.onclick=()=>{$('msg').value=btn.dataset.msg;send();};
|
||||
|
||||
@@ -39,6 +39,7 @@ const LOCALES = {
|
||||
import_failed: 'Import failed: ',
|
||||
import_invalid_json: 'Invalid JSON',
|
||||
image_pasted: 'Image pasted: ',
|
||||
text_pasted: 'Pasted text attached as ',
|
||||
// messages.js
|
||||
edit_message: 'Edit message',
|
||||
regenerate: 'Regenerate response',
|
||||
@@ -1550,6 +1551,7 @@ const LOCALES = {
|
||||
import_failed: 'Importazione fallita: ',
|
||||
import_invalid_json: 'JSON non valido',
|
||||
image_pasted: 'Immagine incollata: ',
|
||||
text_pasted: 'Testo incollato allegato come ',
|
||||
// messages.js
|
||||
edit_message: 'Modifica messaggio',
|
||||
regenerate: 'Rigenera risposta',
|
||||
@@ -3052,6 +3054,7 @@ const LOCALES = {
|
||||
import_failed: 'インポート失敗: ',
|
||||
import_invalid_json: '無効な JSON',
|
||||
image_pasted: '画像を貼り付けました: ',
|
||||
text_pasted: '貼り付けたテキストを添付しました: ',
|
||||
// messages.js
|
||||
edit_message: 'メッセージを編集',
|
||||
regenerate: '応答を再生成',
|
||||
@@ -4557,6 +4560,7 @@ const LOCALES = {
|
||||
import_failed: 'Не удалось импортировать: ',
|
||||
import_invalid_json: 'Неверный JSON',
|
||||
image_pasted: 'Изображение вставлено: ',
|
||||
text_pasted: 'Вставленный текст прикреплён как ',
|
||||
edit_message: 'Редактировать сообщение',
|
||||
regenerate: 'Сгенерировать ответ заново',
|
||||
copy: 'Копировать',
|
||||
@@ -5990,6 +5994,7 @@ const LOCALES = {
|
||||
import_failed: 'Error al importar: ',
|
||||
import_invalid_json: 'JSON inválido',
|
||||
image_pasted: 'Imagen pegada: ',
|
||||
text_pasted: 'Texto pegado adjuntado como ',
|
||||
// messages.js
|
||||
edit_message: 'Editar mensaje',
|
||||
regenerate: 'Regenerar respuesta',
|
||||
@@ -7428,6 +7433,7 @@ const LOCALES = {
|
||||
import_failed: 'Import fehlgeschlagen: ',
|
||||
import_invalid_json: 'Ungültiges JSON',
|
||||
image_pasted: 'Bild eingefügt: ',
|
||||
text_pasted: 'Eingefügter Text angehängt als ',
|
||||
// messages.js
|
||||
edit_message: 'Nachricht bearbeiten',
|
||||
regenerate: 'Antwort regenerieren',
|
||||
@@ -8870,6 +8876,7 @@ const LOCALES = {
|
||||
import_failed: '导入失败:',
|
||||
import_invalid_json: 'JSON 无效',
|
||||
image_pasted: '已粘贴图片:',
|
||||
text_pasted: '已将粘贴文本附加为:',
|
||||
// messages.js
|
||||
edit_message: '编辑消息',
|
||||
regenerate: '重新生成回复',
|
||||
@@ -10320,6 +10327,7 @@ const LOCALES = {
|
||||
import_failed: '匯入失敗:',
|
||||
import_invalid_json: '無效的 JSON',
|
||||
image_pasted: '已貼上圖片:',
|
||||
text_pasted: '已將貼上文字附加為:',
|
||||
// messages.js
|
||||
edit_message: '編輯訊息',
|
||||
regenerate: '重新產生回覆',
|
||||
@@ -11811,6 +11819,7 @@ const LOCALES = {
|
||||
import_failed: 'Falha na importação: ',
|
||||
import_invalid_json: 'JSON inválido',
|
||||
image_pasted: 'Imagem colada: ',
|
||||
text_pasted: 'Texto colado anexado como ',
|
||||
// messages.js
|
||||
edit_message: 'Editar mensagem',
|
||||
regenerate: 'Regenerar resposta',
|
||||
@@ -13133,6 +13142,7 @@ const LOCALES = {
|
||||
import_failed: '가져오기 실패: ',
|
||||
import_invalid_json: '잘못된 JSON입니다',
|
||||
image_pasted: '붙여넣은 이미지: ',
|
||||
text_pasted: '붙여넣은 텍스트를 첨부했습니다: ',
|
||||
// messages.js
|
||||
edit_message: '메시지 편집',
|
||||
regenerate: '응답 다시 생성',
|
||||
@@ -14635,6 +14645,7 @@ const LOCALES = {
|
||||
import_failed: 'Échec de l\'importation :',
|
||||
import_invalid_json: 'JSON invalide',
|
||||
image_pasted: 'Image collée :',
|
||||
text_pasted: 'Texte collé joint sous ',
|
||||
edit_message: 'Modifier le message',
|
||||
regenerate: 'Régénérer la réponse',
|
||||
copy: 'Copie',
|
||||
@@ -16077,6 +16088,7 @@ const LOCALES = {
|
||||
import_failed: 'İçe aktarma başarısız oldu:',
|
||||
import_invalid_json: 'Geçersiz JSON',
|
||||
image_pasted: 'Yapıştırılan resim:',
|
||||
text_pasted: 'Yapıştırılan metin şu adla eklendi: ',
|
||||
// messages.js
|
||||
edit_message: 'Mesajı düzenle',
|
||||
regenerate: 'Yanıtı yeniden oluştur',
|
||||
@@ -17572,6 +17584,7 @@ const LOCALES = {
|
||||
import_failed: 'Import nie powiódł się: ',
|
||||
import_invalid_json: 'Nieprawidłowy JSON',
|
||||
image_pasted: 'Wklejono obraz: ',
|
||||
text_pasted: 'Wklejony tekst załączono jako ',
|
||||
// messages.js
|
||||
edit_message: 'Edytuj wiadomość',
|
||||
regenerate: 'Wygeneruj ponownie odpowiedź',
|
||||
|
||||
@@ -75,11 +75,16 @@ class TestPasteHandlerTextWithImage:
|
||||
Additionally, text-only clipboards (no image items) must early-return before
|
||||
reaching the attach path."""
|
||||
body = _paste_handler_body()
|
||||
# Text-only early-out: if no image items, return before any attach logic.
|
||||
assert re.search(
|
||||
r"if\s*\(\s*!\s*imageItems\.length\s*\)\s*return\s*;",
|
||||
body,
|
||||
), "handler must early-return when there are no image files (text-only paste)"
|
||||
# Text-only small pastes should return before preventDefault/attach;
|
||||
# large text-only pastes may continue into the markdown-attachment path.
|
||||
assert "if(!_shouldAttachLargePastedText(plainText))return;" in body, (
|
||||
"handler must leave normal text-only paste to the browser before any preventDefault"
|
||||
)
|
||||
# The new text-only branch must run after the image path has returned,
|
||||
# so image+text paste still uses the #1620 path below.
|
||||
assert body.index("return;\n }\n const plainText") > body.index("addFiles(files)"), (
|
||||
"text-only large-paste handling must not intercept the image+text attach path"
|
||||
)
|
||||
# preventDefault must be gated on !hasText so text+image pastes let the
|
||||
# browser insert text natively.
|
||||
assert re.search(
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Regression tests for large composer text paste attachment behavior."""
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
CHANGELOG = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_large_text_paste_threshold_helpers_are_defined():
|
||||
assert "const LARGE_TEXT_PASTE_CHAR_THRESHOLD=4000" in BOOT_JS
|
||||
assert "const LARGE_TEXT_PASTE_LINE_THRESHOLD=100" in BOOT_JS
|
||||
assert "function _shouldAttachLargePastedText(text)" in BOOT_JS
|
||||
assert "value.length>=LARGE_TEXT_PASTE_CHAR_THRESHOLD" in BOOT_JS
|
||||
assert "_largeTextPasteLineCount(value)>=LARGE_TEXT_PASTE_LINE_THRESHOLD" in BOOT_JS
|
||||
|
||||
|
||||
def test_large_text_line_count_does_not_overcount_trailing_newline():
|
||||
assert "const lines=value.split('\\n');" in BOOT_JS
|
||||
assert "return value.endsWith('\\n')?lines.length-1:lines.length;" in BOOT_JS
|
||||
|
||||
|
||||
def test_line_threshold_is_one_hundred_lines():
|
||||
assert "const LARGE_TEXT_PASTE_LINE_THRESHOLD=100" in BOOT_JS
|
||||
assert "_largeTextPasteLineCount(value)>=LARGE_TEXT_PASTE_LINE_THRESHOLD" in BOOT_JS
|
||||
|
||||
|
||||
def test_large_text_paste_creates_markdown_file_and_uses_existing_tray():
|
||||
assert "function _largeTextPasteFile(text,now)" in BOOT_JS
|
||||
assert "function _attachLargePastedText(file)" in BOOT_JS
|
||||
assert "pasted-text-${stamp}.md" in BOOT_JS
|
||||
assert "const existing=new Set((S.pendingFiles||[]).map(f=>f&&f.name).filter(Boolean))" in BOOT_JS
|
||||
assert "for(let i=2;existing.has(name);i++)name=`pasted-text-${stamp}-${i}.md`;" in BOOT_JS
|
||||
assert "new File([String(text||'')],name,{type:'text/markdown;charset=utf-8'})" in BOOT_JS
|
||||
assert "addFiles([file])" in BOOT_JS
|
||||
assert "setStatus(t('text_pasted')+file.name)" in BOOT_JS
|
||||
|
||||
|
||||
def test_large_text_status_uses_i18n_key_available_to_all_locales():
|
||||
assert "text_pasted: 'Pasted text attached as '," in I18N_JS
|
||||
assert I18N_JS.count("text_pasted:") == I18N_JS.count("image_pasted:")
|
||||
|
||||
|
||||
def test_paste_handler_keeps_image_paste_path_before_large_text_path():
|
||||
paste_idx = BOOT_JS.index("$('msg').addEventListener('paste',e=>{")
|
||||
image_idx = BOOT_JS.index("if(imageItems.length){", paste_idx)
|
||||
return_idx = BOOT_JS.index("return;", image_idx)
|
||||
text_idx = BOOT_JS.index("const plainText=e.clipboardData?.getData('text/plain')||'';", paste_idx)
|
||||
attach_idx = BOOT_JS.index("_attachLargePastedText(pastedTextFile);", text_idx)
|
||||
|
||||
assert image_idx < return_idx < text_idx < attach_idx
|
||||
assert "if(!hasText)e.preventDefault();" in BOOT_JS[image_idx:return_idx]
|
||||
|
||||
|
||||
def test_large_text_paste_prevents_default_textarea_insert_only_for_large_plain_text():
|
||||
text_idx = BOOT_JS.index("const plainText=e.clipboardData?.getData('text/plain')||'';")
|
||||
block = BOOT_JS[text_idx : BOOT_JS.index("});", text_idx)]
|
||||
assert "if(!_shouldAttachLargePastedText(plainText))return;" in block
|
||||
assert "const pastedTextFile=_largeTextPasteFile(plainText);" in block
|
||||
assert "if(!_largeTextPasteFitsUploadLimit(pastedTextFile))return;" in block
|
||||
assert "e.preventDefault();" in block
|
||||
assert "_attachLargePastedText(pastedTextFile);" in block
|
||||
assert block.index("if(!_shouldAttachLargePastedText(plainText))return;") < block.index("const pastedTextFile=_largeTextPasteFile(plainText);")
|
||||
assert block.index("if(!_largeTextPasteFitsUploadLimit(pastedTextFile))return;") < block.index("e.preventDefault();")
|
||||
|
||||
|
||||
def test_oversize_large_text_paste_falls_back_to_native_paste_instead_of_being_dropped():
|
||||
assert "function _largeTextPasteFitsUploadLimit(file)" in BOOT_JS
|
||||
assert "typeof MAX_UPLOAD_BYTES==='number'&&file.size>MAX_UPLOAD_BYTES" in BOOT_JS
|
||||
text_idx = BOOT_JS.index("const plainText=e.clipboardData?.getData('text/plain')||'';")
|
||||
block = BOOT_JS[text_idx : BOOT_JS.index("});", text_idx)]
|
||||
fit_idx = block.index("if(!_largeTextPasteFitsUploadLimit(pastedTextFile))return;")
|
||||
prevent_idx = block.index("e.preventDefault();")
|
||||
attach_idx = block.index("_attachLargePastedText(pastedTextFile);")
|
||||
assert fit_idx < prevent_idx < attach_idx
|
||||
|
||||
|
||||
def test_changelog_mentions_large_text_paste_attachment():
|
||||
assert "Large plain-text pastes in the composer now become `.md` attachments" in CHANGELOG
|
||||
Reference in new issue
Block a user