fix(web): copy button actually copies on iOS

The execCommand fallback returned true but copied nothing because the textarea
was readOnly=false. iOS only copies from a readOnly + contentEditable field with
a real Range selection + setSelectionRange — fixed that. Also skip the async
Clipboard API unless window.isSecureContext (on the plain-HTTP LAN PWA it could
resolve without copying, showing a false checkmark). If programmatic copy still
fails, fall back to a prompt() with the text so it can be copied by hand, and only
show the ✓ on real success.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 03:46:33 +00:00
parent 654a7531e8
commit 5e9f3efeec
+18 -7
View File
@@ -499,7 +499,10 @@
// iOS over plain-HTTP LAN (where navigator.clipboard is undefined).
function copyToClipboard(text) {
text = text == null ? "" : String(text);
if (navigator.clipboard && navigator.clipboard.writeText) {
// Only trust the async Clipboard API in a secure context; on the LAN PWA
// (plain HTTP) it's either absent or resolves without actually copying, so
// we go straight to the iOS-tuned execCommand path there.
if (window.isSecureContext && navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text).catch(() => legacyCopy(text));
}
return legacyCopy(text);
@@ -508,19 +511,24 @@
return new Promise((resolve, reject) => {
const ta = document.createElement("textarea");
ta.value = text;
// iOS will only copy from a readOnly + contentEditable field with a real
// Range selection; readOnly also stops the keyboard from popping.
ta.readOnly = true;
ta.contentEditable = "true";
ta.readOnly = false;
ta.style.position = "fixed";
ta.style.top = "0";
ta.style.left = "0";
ta.style.opacity = "0";
ta.style.width = "1px";
ta.style.height = "1px";
ta.style.fontSize = "16px"; // avoid iOS zoom side-effects
document.body.appendChild(ta);
ta.focus();
const range = document.createRange();
range.selectNodeContents(ta);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
ta.setSelectionRange(0, text.length); // iOS needs an explicit range
ta.setSelectionRange(0, text.length); // the bit iOS actually needs
let ok = false;
try { ok = document.execCommand("copy"); } catch (e) { ok = false; }
sel.removeAllRanges();
@@ -537,14 +545,17 @@
b.title = "Copy message";
b.addEventListener("click", (e) => {
e.stopPropagation();
copyToClipboard(typeof getText === "function" ? getText() : getText)
const text = typeof getText === "function" ? getText() : getText;
copyToClipboard(text)
.then(() => {
b.textContent = "✓"; b.classList.add("copied");
setTimeout(() => { b.textContent = "⧉"; b.classList.remove("copied"); }, 1200);
})
.catch(() => {
b.textContent = "✗";
setTimeout(() => { b.textContent = "⧉"; }, 1200);
// Last resort (some iOS configs block programmatic copy): surface the
// text in a prompt so it can be selected + copied by hand.
window.prompt("Copy this message:", text);
b.textContent = "⧉";
});
});
return b;